동아리 홈페이지에서 유효하지 않은 이메일 (a@abc.com)으로 가입된 사용자가 있는데 이는 나중에 비밀번호 찾기 등에서 문제가 생길 수 있기 때문에 회원가입시 이메일 인증 기능을 추가하였습니다.
@PostMapping("/auth/send")
@ApiOperation(value = "인증 코드 발송", notes = "작성한 이메일로 인증코드 발송")
public ApiResult<Boolean> sendVerificationCode(@RequestParam String userEmail) {
return ApiResult.OK(userEmailVerifyService.sendVerificationCode(userEmail));
}
@PostMapping("/auth/verify")
@ApiOperation(value = "이메일 검증", notes = "인증 코드 확인을 통한 이메일 검증")
public ApiResult<Boolean> verifyEmail(@Valid @RequestBody VerificationCodeRequest verificationCodeRequest) {
return ApiResult.OK(userEmailVerifyService.verifyEmail(verificationCodeRequest.getVerificationCode()));
}
단순히 /auth/send 에 response에 인증코드를 전달하는 방법도 있지만 이는 클라이언트측에서 response값을 확인할 수 있기 때문에 유효하지 않은 이메일에 대한 검증 목적에 부합하지 않는다고 판단해 redis에 인증코드 값을 캐싱하기로 결정하게 되었습니다.
public class CacheKey {
...
public static final int VERIFICATION_CODE_EXPIRE_SEC = 60 * 5; // 5 minutes
public static final int TEMP_EMAIL_EXPIRE_SEC = 60 * 5; // 5 minutes
...
}
인증번호, 이메일에 대한 TTL값은 300sec로 설정하였습니다.
public Boolean sendVerificationCode(String userEmail) {
if( isNotProperEmail(userEmail)){
throw new CustomException(ErrorCode.VALID_CHECK_FAIL, userEmail);
}
expirePreviousVerificationCode(userEmail);
String verificationCode = getVerificationCode();
saveVerificationCode(verificationCode, userEmail);
sendMail(userEmail, verificationCode);
return true;
}
@Transactional(readOnly = true)
public boolean isNotProperEmail(String userEmail){
if(userRepository.existsByEmail(userEmail)){
throw new CustomException(ErrorCode.ALREADY_EXIST_EMAIL, userEmail);
}
return userEmail == null ||
!(userEmail.contains("@gmail.com") || userEmail.contains("@naver.com"));
}
간단히 userRepository.existByEmail()로 서비스 내에 해당 이메일로 가입된 유저가 있는지 체크하였고, 가입 가능한 이메일 형식은 gmail과 naver로 제한했습니다.
private void expirePreviousVerificationCode(String userEmail) {
String verificationCode = redisUtil.getData(userEmail);
if(verificationCode == null || verificationCode.isEmpty()){
return;
}
//이전에 발급된 인증번호 삭제
redisUtil.deleteData(userEmail);
redisUtil.deleteData(verificationCode);
}
가장 마지막으로 전송된 인증번호로만 인증이 가능하도록 하기 위해서 이전에 해당 이메일로 발급된 이전에 발급된 인증번호들은 만료되도록 하였습니다.
userEmail이 redis내에 key값으로 존재하면 userEmail과 verificationCode를 key값으로 가지는 값들을 삭제하였습니다.
private String getVerificationCode(){
Random random = new Random();
StringBuilder code = new StringBuilder();
for (int j = 0; j < 6; j++) {
int randomInt = random.nextInt(36);
char c = (randomInt < 10) ? (char)('0' + randomInt) : (char)('A' + randomInt - 10);
code.append(c);
}
return code.toString();
}
private void saveVerificationCode(String verificationCode, String userEmail) {
redisUtil.setDataExpire(verificationCode, userEmail, CacheKey.VERIFICATION_CODE_EXPIRE_SEC);
redisUtil.setDataExpire(userEmail,verificationCode, CacheKey.VERIFICATION_CODE_EXPIRE_SEC);
}
이전에 발급된 인증코드를 만료시키기 위해서 verificationCode, userEmail key, value 값을 양방향으로 저장했습니다.
private void sendMail(String userEmail, String verificationCode) {
MimeMessage message = javaMailSender.createMimeMessage();
try {
message.addRecipients(Message.RecipientType.TO, userEmail);
message.setSubject("SAMMaru 인증 코드");
String htmlStr =
"<div>" +
" <h3>SAMMaru</h3>\n" +
" <div><p>다음 인증코드를 입력해주세요.</p><p>인증코드: <span style=\"color:blue\">"
+ verificationCode +
"</span></p></div>" +
"</div>";
message.setText(htmlStr,"UTF-8", "html");
javaMailSender.send(message);
} catch (Exception e ){
e.printStackTrace();
}
}
public Boolean verifyEmail(String verificationCode) {
if (!validateVerificationCode(verificationCode)){
throw new CustomException(ErrorCode.INVALID_VERIFICATION_CODE);
}
saveTempVerifiedEmail(verificationCode);
return true;
}
private boolean validateVerificationCode(String verificationCode) {
return redisUtil.hasKey(verificationCode);
}
redis에 verficationCode가 key값이 존재하는지 체크하였습니다.
private void saveTempVerifiedEmail(String verificationCode){
String userEmail = redisUtil.getData(verificationCode);
redisUtil.deleteData(userEmail);
redisUtil.setDataExpire(userEmail+":auth", verificationCode, CacheKey.TEMP_EMAIL_EXPIRE_SEC);
}
인증된 이메일로만 회원가입을 진행할 수 있도록 userEmail에 ":auth"라는 키워드를 붙여 redis에 다시 저장하였습니다.
package com.sammaru5.sammaru.service.user;
@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class UserRegisterService {
public UserDTO signUpUser(SignUpRequest signUpRequest) {
...
if (!isValidEmail(signUpRequest.getEmail())){
throw new CustomException(ErrorCode.INVALID_EMAIL, signUpRequest.getEmail());
}
}
...
private boolean isValidEmail(String userEmail){
if (!redisUtil.hasKey(userEmail+":auth")){
return false;
}
redisUtil.deleteData(redisUtil.getData(userEmail+":auth"));
redisUtil.deleteData(userEmail+":auth");
return true;
}
}
redis에 signUpRequest에서의 userEmail + ":auth" 키의 존재여부를 판별하는 로직을 추가함으로써 위에서 인증된 이메일로만 회원가입을 할 수 있도록 하였습니다.
단순해 보였지만 내부에서 꽤나 많은 예외처리가 필요하다는 것을 느끼게 되었다.
특히 코드리뷰를 받으면서 생각하지 못했던 부분을 보완하게 되고 생각을 넓힐 수 있었던 것 같다.
smtp 라이브러리 특성상 이메일 전송에 적지 않은 시간이 소요되는데
이는 사용자 입장에서 응답 시간이 길어져 꽤나 불편할 것 같다는 생각이 들었다.
추후에 이메일 전송 부분은 비동기로 처리하여 응답속도를 개선해야 할 것 같다.
나 한승헌이올시다.
잘 봤소이다.
나처럼 살지 마시오.