[Spring] 메일 인증 & Redis를 활용한 사용자 계정 정보 조회 및 수정

개요

 

GitHub - sjiwon/Advanced-Another-Art: AI 기반 작품 경매 플랫폼 (Refactoring)

AI 기반 작품 경매 플랫폼 (Refactoring). Contribute to sjiwon/Advanced-Another-Art development by creating an account on GitHub.

github.com

현재 리팩토링중인 AI 기반 작품 경매 플랫폼 프로젝트에서는 사용자가 본인이 계정을 까먹었을 경우를 대비해서 아이디 찾기/비밀번호 재설정 API를 제공하고 있다
 
그런데 기존에 구현된 이 API는 심각한 문제가 존재한다

[아이디 찾기]
- 자신의 이름 + 이메일 정보를 통해서 계정 아이디 조회

[비밀번호 재설정]
1. 이름 + 이메일 + 로그인 아이디를 통해서 비밀번호 재설정을 위한 인증 진행
2. 인증이 되면 비밀번호 재설정

이 두 API는 이름 + 이메일 + 로그인 아이디 정보만 알아도 모두 통과되는 API로써 어떠한 방어막도 존재하지 않는다
구현할 당시에는 이러한 보안적인 문제에 대해서 별 신경을 쓰지 않았고 그에 따라서 도출된 문제들이다
 
따라서 대부분의 사이트에서 많이 활용하는 메일인증을 통해서 이러한 보안적 문제를 보완하려고 한다

  • 인증번호 TTL with Redis

 
 
개선된 프로세스는 다음과 같다

[아이디 찾기]
1. 이름 & 이메일로 인증번호 받기 API 요청
2. 이름 & 이메일에 일치하는 Record 확인 후 인증번호 전송 (메일 전송 & 유효 시간 10분 Redis)
3. 인증번호 일치하면 아이디 Get

[비밀번호 재설정]
1. 이름 & 이메일 & 로그인 아이디로 인증번호 받기 API 요청
2. 이름 & 이메일 & 로그인 아이디에 일치하는 Record 확인 후 인증번호 전송 (메일 전송 & 유효 시간 10분 Redis)
3. 인증번호 일치하면 비밀번호 재설정 페이지로 넘어가서 재설정 API 호출

 
 

메일 전송 & Redis 인프라 구축

메일 전송 컴포넌트

먼저 인증번호 메일을 전송하기 위한 컴포넌트를 구현해야 한다
Production 환경에서는 AWS의 SES를 활용할 예정이지만 현재는 로컬에서 테스트할 것이기 때문에 기본 메일 전송 컴포넌트인 JavaMailSender를 통해서 구현할 예정이다

// build.gradle
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
implementation "org.springframework.boot:spring-boot-starter-mail"

---

// application.yml
spring:
  thymeleaf:
    cache: false
  mail:
    default-encoding: UTF-8
    host: smtp.gmail.com
    port: 587
    username: ${GOOGLE_EMAIL}
    password: ${GOOGLE_APP_PASSWORD}
    properties:
      mail:
        mime:
          charset: UTF-8
        transport:
          protocol: smtp
        debug: true
        smtp:
          auth: true
          starttls:
            enable: true
  • 메일 전송을 위한 의존성 + yml 설정을 진행한다
public interface EmailSender {
    void sendAuthCodeForLoginId(final String targetEmail, final String authCode);

    void sendAuthCodeForPassword(final String targetEmail, final String authCode);
}

---

public interface EmailMetadata {
    // 템플릿 이름
    String AUTH_TEMPLATE = "EmailAuthCodeTemplate";

    // 이메일 제목
    String LOGIN_ID_AUTH_CODE_TITLE = "아이디 찾기 인증번호 메일입니다.";
    String PASSWORD_AUTH_CODE_TITLE = "비밀번호 재설정 인증번호 메일입니다.";
}
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta content="IE=edge" http-equiv="X-UA-Compatible">
    <meta content="width=device-width, initial-scale=1.0" name="viewport">
    <title>Another Art</title>
</head>
<body>
<p style="font-size:10pt; font-family:sans-serif; padding:0 0 0 10pt"><br><br></p>
<div style="width:440px; margin:30px auto; padding:40px 0 60px; background-color:#fff; border:1px solid #ddd; text-align:center; font-size:16px; font-family:malgun gothic,serif;">
    <h3 style="font-weight:bold; font-size:20px; margin:28px auto;">Another Art 이메일 본인인증</h3>
    <div style="width:200px; margin:28px auto; padding:8px 0 9px; background-color:#f4f4f4; border-radius:3px;">
        <span style="display:inline-block; vertical-align:middle; font-size:13px; color:#666;">인증번호</span>
        <span style="display:inline-block; margin-left:16px; vertical-align:middle; font-size:21px; font-weight:bold; color:#4d5642;" th:text="${authCode}"></span>
    </div>
    <p style="text-align:center; font-size:13px; color:#000; line-height:1.6; margin-top:40px; margin-bottom:0;">
        해당 인증번호를 인증 번호 확인란에 기입하여 주세요.<br>
        Another Art를 이용해 주셔서 감사합니다.<br>
    </p>
</div>
</body>
</html>
@Slf4j
@Profile("default")
@Component
public class DefaultEmailSender implements EmailSender {
    private final JavaMailSender mailSender;
    private final SpringTemplateEngine templateEngine;
    private final String serviceEmail;

    public DefaultEmailSender(
            final JavaMailSender mailSender,
            final SpringTemplateEngine templateEngine,
            @Value("${spring.mail.username}") final String serviceEmail
    ) {
        this.mailSender = mailSender;
        this.templateEngine = templateEngine;
        this.serviceEmail = serviceEmail;
    }

    @Override
    public void sendAuthCodeForLoginId(final String targetEmail, final String authCode) {
        final Context context = new Context();
        context.setVariable("authCode", authCode);

        final String mailBody = templateEngine.process(EmailMetadata.AUTH_TEMPLATE, context);
        sendMail(
                EmailMetadata.LOGIN_ID_AUTH_CODE_TITLE,
                targetEmail,
                mailBody
        );
    }

    @Override
    public void sendAuthCodeForPassword(final String targetEmail, final String authCode) {
        final Context context = new Context();
        context.setVariable("authCode", authCode);

        final String mailBody = templateEngine.process(EmailMetadata.AUTH_TEMPLATE, context);
        sendMail(
                EmailMetadata.PASSWORD_AUTH_CODE_TITLE,
                targetEmail,
                mailBody
        );
    }

    private void sendMail(final String subject, final String email, final String mailBody) {
        try {
            final MimeMessage message = mailSender.createMimeMessage();
            final MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

            helper.setSubject(subject);
            helper.setTo(email);
            helper.setFrom(new InternetAddress(serviceEmail, "Another Art"));
            helper.setText(mailBody, true);

            mailSender.send(message);
        } catch (final Exception e) {
            log.warn("메일 전송 간 오류 발생...", e);
            throw AnotherArtException.type(GlobalErrorCode.INTERNAL_SERVER_ERROR);
        }
    }
}

 
제대로 메일이 전송되는지 테스트해보자

@Bean
public ApplicationRunner mail(final EmailSender emailSender) {
    return args -> {
        emailSender.sendAuthCodeForLoginId("${수신할 이메일}", UUID.randomUUID().toString().substring(0, 8));
        emailSender.sendAuthCodeForPassword("${수신할 이메일}", UUID.randomUUID().toString().substring(0, 8));
    };
}

 
 

Redis 인증 환경 구축

@Getter
@RequiredArgsConstructor
public enum AuthKey {
    LOGIN_AUTH_KEY("LOGIN:%s"),
    PASSWORD_AUTH_KEY("PASSWORD:%s"),
    ;

    private final String value;

    public String generateAuthKey(final String suffix) {
        return String.format(this.value, suffix);
    }
}
  • 사용자별 인증 코드를 저장하기 위한 Key
    • LOGIN:sjiwon4491@gmail.com
    • PASSWORD:sjiwon4491@naver.com
    • ...
public interface MailAuthenticationProcessor {
    String storeAuthCode(final String key);

    void verifyAuthCode(final String key, final String value);

    void deleteAuthCode(final String key);
}

---

@Component
public class RedisMailAuthenticationProcessor implements MailAuthenticationProcessor {
    private final AuthCodeGenerator authCodeGenerator;
    private final StringRedisTemplate stringRedisTemplate;
    private final long authTtl;

    public RedisMailAuthenticationProcessor(
            final AuthCodeGenerator authCodeGenerator,
            final StringRedisTemplate stringRedisTemplate,
            @Value("${mail.auth.ttl}") final long authTtl
    ) {
        this.authCodeGenerator = authCodeGenerator;
        this.stringRedisTemplate = stringRedisTemplate;
        this.authTtl = authTtl;
    }

    @Override
    public String storeAuthCode(final String key) {
        final String authCode = authCodeGenerator.get();
        stringRedisTemplate.opsForValue().set(key, authCode, authTtl, TimeUnit.MILLISECONDS);
        return authCode;
    }

    @Override
    public void verifyAuthCode(final String key, final String value) {
        final String realValue = stringRedisTemplate.opsForValue().get(key);

        if (!value.equals(realValue)) {
            throw AnotherArtException.type(AuthErrorCode.INVALID_AUTH_CODE);
        }
    }

    @Override
    public void deleteAuthCode(final String key) {
        stringRedisTemplate.delete(key);
    }
}

 
 

아이디 찾기 프로세스

private final RetrieveLoginIdUseCase retrieveLoginIdUseCase;

@PostMapping("/retrieve-login-id")
public ResponseEntity<Void> provideAuthCodeForLoginId(
        @RequestBody @Valid final ProvideAuthCodeForLoginIdRequest request
) {
    retrieveLoginIdUseCase.provideAuthCode(new AuthForRetrieveLoginIdCommand(request.name(), request.email()));
    return ResponseEntity.noContent().build();
}

@PostMapping("/retrieve-login-id/confirm")
public ResponseEntity<ResponseWrapper<String>> confirmAuthCodeForLoginId(
        @RequestBody @Valid final ConfirmAuthCodeForLoginIdRequest request
) {
    final String loginId = retrieveLoginIdUseCase.getLoginId(new ConfirmAuthCodeForLoginIdCommand(
            request.name(),
            request.email(),
            request.authCode()
    ));
    return ResponseEntity.ok(ResponseWrapper.from(loginId));
}
@Service
@RequiredArgsConstructor
public class RetrieveLoginIdUseCase {
    private final MemberRepository memberRepository;
    private final MailAuthenticationProcessor mailAuthenticationProcessor;
    private final EmailSender emailSender;

    public void provideAuthCode(final AuthForRetrieveLoginIdCommand command) {
        final Member member = memberRepository.getByNameAndEmail(command.name(), command.email());

        final String key = generateAuthKey(member.getEmail());
        final String authCode = mailAuthenticationProcessor.storeAuthCode(key);
        emailSender.sendAuthCodeForLoginId(member.getEmail().getValue(), authCode);
    }

    public String getLoginId(final ConfirmAuthCodeForLoginIdCommand command) {
        final Member member = memberRepository.getByNameAndEmail(command.name(), command.email());
        verifyAuthCode(member, command.authCode());
        return member.getLoginId();
    }

    private void verifyAuthCode(final Member member, final String authCode) {
        final String key = generateAuthKey(member.getEmail());
        mailAuthenticationProcessor.verifyAuthCode(key, authCode);
        mailAuthenticationProcessor.deleteAuthCode(key); // 인증 성공 후 바로 제거 (재활용 X)
    }

    private String generateAuthKey(final Email email) {
        return AuthKey.LOGIN_AUTH_KEY.generateAuthKey(email.getValue());
    }
}

 

(1) 인증번호 발송

API 요청
이메일 발송
Redis 인증번호 저장

 

(2-1) 인증번호 Wrong

인증번호 불일치

 

(2-2) 인증번호 Correct

인증번호 일치 및 아이디 조회
인증번호 확인 후 제거

 
 
 

비밀번호 재설정 프로세스

private final ResetPasswordUseCase resetPasswordUseCase;

@PostMapping("/reset-password/auth")
public ResponseEntity<Void> provideAuthCodeForResetPassword(
        @RequestBody @Valid final ProvideAuthCodeForResetPasswordRequest request
) {
    resetPasswordUseCase.provideAuthCode(new AuthForResetPasswordCommand(request.name(), request.email(), request.loginId()));
    return ResponseEntity.noContent().build();
}

@PostMapping("/reset-password/auth/confirm")
public ResponseEntity<ResponseWrapper<String>> confirmAuthCodeForResetPassword(
        @RequestBody @Valid final ConfirmAuthCodeForResetPasswordRequest request
) {
    resetPasswordUseCase.confirmAuthCode(new ConfirmAuthCodeForResetPasswordCommand(
            request.name(),
            request.email(),
            request.loginId(),
            request.authCode()
    ));
    return ResponseEntity.noContent().build();
}

@PostMapping("/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody @Valid final ResetPasswordRequest request) {
    resetPasswordUseCase.resetPassword(new ResetPasswordCommand(
            request.name(),
            request.email(),
            request.loginId(),
            request.password()
    ));
    return ResponseEntity.noContent().build();
}
@Service
@RequiredArgsConstructor
public class ResetPasswordUseCase {
    private final MemberRepository memberRepository;
    private final MailAuthenticationProcessor mailAuthenticationProcessor;
    private final EmailSender emailSender;
    private final PasswordEncryptor passwordEncryptor;

    public void provideAuthCode(final AuthForResetPasswordCommand command) {
        final Member member = memberRepository.getByNameAndEmailAndLoginId(command.name(), command.email(), command.loginId());

        final String key = generateAuthKey(member.getEmail());
        final String authCode = mailAuthenticationProcessor.storeAuthCode(key);
        emailSender.sendAuthCodeForPassword(member.getEmail().getValue(), authCode);
    }

    public void confirmAuthCode(final ConfirmAuthCodeForResetPasswordCommand command) {
        final Member member = memberRepository.getByNameAndEmailAndLoginId(command.name(), command.email(), command.loginId());
        verifyAuthCode(member, command.authCode());
    }

    private void verifyAuthCode(final Member member, final String authCode) {
        final String key = generateAuthKey(member.getEmail());
        mailAuthenticationProcessor.verifyAuthCode(key, authCode);
        mailAuthenticationProcessor.deleteAuthCode(key); // 인증 성공 후 바로 제거 (재활용 X)
    }

    private String generateAuthKey(final Email email) {
        return AuthKey.PASSWORD_AUTH_KEY.generateAuthKey(email.getValue());
    }

    @AnotherArtWritableTransactional
    public void resetPassword(final ResetPasswordCommand command) {
        final Member member = memberRepository.getByNameAndEmailAndLoginId(command.name(), command.email(), command.loginId());
        member.updatePassword(command.password(), passwordEncryptor);
    }
}

 

(1) 인증번호 발송

API 요청
이메일 발송
Redis 인증번호 저장

 

(2-1) 인증번호 Wrong

인증번호 불일치

 

(2-2) 인증번호 Correct

인증번호 일치
인증번호 확인 후 제거