이메일을 아이디처럼 사용하는 서비스에서 이메일 인증 없이 계정 생성이 가능하다면, 무분별한 계정 생성이 가능할 수 있습니다.
따라서 회원가입 기능에 이메일 인증을 붙여보고자 합니다.
사용한 기술 스택은 Redis Session, spring-boot-starter-mail, Google SMTP Server 입니다.
application-email.yml
spring:
mail:
host: smtp.gmail.com
port: 587
protocol: smtp
default-encoding: UTF-8
username:
password:
properties:
mail:
smtp:
auth: true
starttls:
enable: true
EmailConfig
@Configuration
@PropertySource("classpath:application-email.yml")
public class EmailConfig {
@Value("${spring.mail.host}")
private String host;
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.protocol}")
private String protocol;
@Value("${spring.mail.default-encoding}")
private String defaultEncoding;
@Value("${spring.mail.username}")
private String username;
@Value("${spring.mail.password}")
private String password;
@Value("${spring.mail.properties.mail.smtp.starttls.enable}")
private boolean starttls;
@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;
@Bean
public JavaMailSender javaMailSender() {
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
Properties properties = new Properties();
properties.put("mail.smtp.starttls.enable", starttls);
properties.put("mail.smtp.auth", auth);
javaMailSender.setProtocol(protocol);
javaMailSender.setHost(host);
javaMailSender.setPort(port);
javaMailSender.setDefaultEncoding(defaultEncoding);
javaMailSender.setUsername(username);
javaMailSender.setPassword(password);
javaMailSender.setJavaMailProperties(properties);
return javaMailSender;
}
@Bean
public EmailSender emailSender(JavaMailSender javaMailSender) {
return new EmailSender(javaMailSender);
}
}
지메일을 사용하기 위해 Config을 작성합니다.
구글 SMTP 서버의 587(TLS) 포트로 설정해주고 username에 이메일을, password에 구글 계정 2차 인증 설정 후 앱 비밀번호를 생성하여 입력합니다.
구글 계정의 앱 비밀번호 생성은 Google 계정 관리 -> 보안 탭에서 가능하며 생성 후 탈취당하지 않는 곳에 저장하여 사용합니다.
Yaml을 다 작성했다면 Config 클래스에서 @PropertySource
로 값들을 가져온 후, JavaMailSender
를 사용해 이메일을 작성하고 전송하므로 필요한 필드들을 세팅하고 Bean으로 등록해줍니다.
이메일 전송 서비스를 구현할 클래스도 의존성을 주입하여 Bean으로 등록하겠습니다!
이메일 인증 요청과 이메일 인증 & 계정 생성은 모두 인증/인가 제외 대상이므로 Spring Security 설정도 수정해줍니다.
이메일 인증 요청은 다음과 같은 순서로 진행됩니다.
1. 이메일 중복 체크
2. 6자리 인증코드 생성
3. 이메일 전송
4. 세션 생성
UserSerivce
public void sendAuthEmail(HttpSession session, String email) {
if (userRepository.existsByEmail(email)) {
throw new CustomException(DUPLICATED_EMAIL);
}
String authCode = RandomGenerator.generateAuthCode();
emailSender.sendVerificationEmail(email, authCode);
session.setAttribute(EMAIL_AUTH, Map.of(EMAIL, email, AUTH_CODE, authCode));
session.setMaxInactiveInterval(AUTH_MAIL_LIMIT_SECONDS);
}
RandomGenerator
public static String generateAuthCode() {
return generateAuthCodeInternal(new Random().ints(AUTH_CODE_LEN, 0, 3).boxed());
}
private static String generateAuthCodeInternal(Stream<Integer> typeStream) {
Random random = new Random();
return typeStream.map(type -> {
char c;
switch (type) {
case DIGIT:
c = Character.forDigit(random.nextInt(10), 10);
break;
case LOWER_CASE:
c = (char) (LOWER_CASE_START + random.nextInt(ALPHABET_LEN));
break;
case UPPER_CASE:
c = (char) (UPPER_CASE_START + random.nextInt(ALPHABET_LEN));
break;
default:
c = '0';
}
return c;
}).map(String::valueOf).collect(Collectors.joining());
}
Spring Data JPA의 exists 메소드를 사용하여 해당 이메일을 사용하는 계정이 있는지 확인합니다.
생성된 계정이 없다면 인증코드를 생성 후 요청한 이메일로 전송합니다.
인증코드는 0 : 숫자, 1 : 소문자 알파벳, 2 : 대문자 알파벳으로 랜덤한 값을 가지는 Stream을 생성한 후, 내부적으로 각 타입에 맞게 랜덤 값을 넣어 "0a1BcC"와 같이 랜덤하게 생성했습니다.
EmailSender
public void sendVerificationEmail(String email, String authCode) {
try {
String message = createVerificationEmailMessage(authCode);
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage);
helper.setTo(email);
helper.setSubject(EMAIL_SUBJECT);
helper.setText(message, true);
javaMailSender.send(mimeMessage);
} catch (Exception e) {
log.error("인증 이메일 전송 실패 : {}", e.getMessage());
throw new CustomException(EMAIL_SEND_FAILURE);
}
}
이메일 전송은 JavaMailSender
를 사용합니다.
HTML 포맷으로 인증코드를 포함한 이메일을 작성 후 전송하며, 메세징 예외나 이메일 서버 인증 예외 등이 발생할 수 있기 때문에 예외를 잡아주었습니다.
UserSerivce
public void sendAuthEmail(HttpSession session, String email) {
if (userRepository.existsByEmail(email)) {
throw new CustomException(DUPLICATED_EMAIL);
}
String authCode = RandomGenerator.generateAuthCode();
emailSender.sendVerificationEmail(email, authCode);
session.setAttribute(EMAIL_AUTH, Map.of(EMAIL, email, AUTH_CODE, authCode));
session.setMaxInactiveInterval(AUTH_MAIL_LIMIT_SECONDS);
}
다시 Service로 넘어와서 해당 이메일 인증을 위한 세션을 email
, authCode
를 가지는 Hash로 만들고 브루트 포스 공격을 약간이나마 대비하고자 3분의 인증 기한을 주고 세션이 삭제되도록 합니다.
이메일 인증 요청을 하게되면, 3분의 제한 시간동안 이메일 인증을 할 수 있습니다.
사용자는 이메일로 전송된 인증코드를 입력하여 계정 생성을 시도합니다.
UserService
public String register(HttpSession session, RegisterDto registerDto) {
String email = registerDto.getEmail();
String authCode = registerDto.getAuthCode();
if (userRepository.existsByEmail(email)) {
throw new CustomException(DUPLICATED_EMAIL);
}
if (!isEmailAuthenticated(session, email, authCode)) {
throw new CustomException(VERIFY_EMAIL_FAILURE);
}
session.removeAttribute(EMAIL_AUTH);
User newUser = User.createNewUser(passwordEncoder, registerDto);
return userRepository.save(newUser.toEntity()).getNickname();
}
private boolean isEmailAuthenticated(HttpSession session, String email, String authCode) {
Map<String, String> authMap = (Map<String, String>) session.getAttribute(EMAIL_AUTH);
return !isNull(authMap) && verifyEmailAndAuthCode(authMap, email, authCode);
}
private boolean verifyEmailAndAuthCode(Map<String, String> authMap, String email,
String authCode) {
return authMap.get(EMAIL).equals(email) && authMap.get(AUTH_CODE).equals(authCode);
}
세션 검사 후 존재하는 인증 값을 지워버리기 때문에, 계정 생성 과정에 Lock을 걸진 않았습니다.
Hash로 저장한 이메일, 인증코드 값과 요청으로 온 데이터가 일치하면 계정을 생성하는 로직으로 구현했습니다.
이메일 인증 요청을 보내면 위와 같이 이메일을 수신할 수 있습니다.
갈길이 산더미이니, 예쁘게 꾸미는건 TODO로 남겨놓겠습니다...ㅎㅎ
인증코드와 함께 회원가입 정보로 회원가입 요청을 보내면...
데이터베이스에 생성된 계정이 잘 저장된 것을 확인할 수 있습니다. 🥳