TIL

[231227] 이메일 인증 구현 (링크 방식)

진진리 2023. 12. 27. 23:27
728x90
이번 팀 과제에서는 회원가입, 프로필, 댓글 기능을 담당하게 되었다.
지금까지 과제에서 회원가입 시 이메일 인증 기능을 구현해본 적이 없었기 때문에
이번 기회에 공부해보고자 하였다.
처음에는 네이버 SMTP를 이용하였다가 application.yml에 계정 비밀번호가 노출되는 문제가 있어 앱 비밀번호를 사용하는 Gmail SMTP로 변경하였다.

 

이메일 인증 과정

이메일 인증에는 대표적으로

사용자가 입력한 이메일 주소로 인증 코드를 전송하여 해당 코드를 입력받는 방식과

이메일의 링크를 클릭하면 이메일 인증이 완료되는 방식이 있다.

 

이번에는 링크 방식으로 구현해보았다.

인증 과정는 다음과 같다.

  1. sendEmail API에 email을 담아서 요청을 보낸다.
  2. 사용자가 입력한 주소로 이메일을 전송한다.
    • 이때 이메일에는 '인증코드를 확인하는 요청을 보낼 URL + 사용자의 이메일 + 인증코드'가 담긴 링크가 포함되어 있다.
  3. 사용자가 해당 링크를 제한 시간 안에 누르면 해당 이메일의 인증이 완료된다.
  4. 인증이 완료된 이메일로 회원가입을 수행한다.

사전 준비

  • 네이버

'메일 > 환경설정'에서 다음과 같이 설정해준다.

 

  • Gmail

'Gmail > 설정 > 전달 및 POP>IMAP'에서 다음과 같이 설정해준다.

  • Redis 사용을 위해 Docker를 설치한다.

인텔리제이 터미널에서 아래와 같이 입력하여 생성 및 구동시켜준다.

docker run -d -p 6379:6379 redis

build.gradle 의존성 추가

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// email
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

application.yml

인증 메일을 전송하는 과정에서 redis를 사용하기 때문에 redis도 추가해준다.

이때 EMAIL_ID는 아이디@naver.com 또는 gmail.com과 같이 넣어준다.

  • 네이버
 data:
    redis:
      host: localhost
      port: 6379

  mail:
    host: smtp.naver.com
    port: 465 
    username: ${EMAIL_ID}
    password: ${EMAIL_PASSWORD}
    properties:
      smtp:
        auth: true
        starttls:
          enable: true
        ssl:
          enable: true
  • Gmail
spring:
 data:
    redis:
      host: localhost
      port: 6379

  mail:
    host: smtp.gmail.com
    port: 587
    username: ${EMAIL_ID}
    password: ${EMAIL_PASSWORD}
    properties:
      smtp:
        auth: true
        starttls:
          enable: true
        socketFactory:
          class: javax.net.ssl.SSLSocketFactory

 

gmail의 경우 비밀번호는 계정의 비밀번호가 아닌 앱 비밀번호를 사용한다.

'Google 계정 관리 > 보안 > 2단계 인증'에 들어가서 앱 비밀번호를 생성해준다.

 


RedisConfig

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }
}

 


MailConfig

@Configuration
public class MailConfig {

    @Value("${spring.mail.host}")
    private String host;

    @Value("${spring.mail.port}")
    private int port;

    @Value("${spring.mail.username}")
    private String username;

    @Value("${spring.mail.password}")
    private String password;

    @Value("${spring.mail.properties.smtp.auth}")
    private boolean auth;

    @Value("${spring.mail.properties.smtp.starttls.enable}")
    private boolean starttlsEnable;
    
    // naver
    @Value("${spring.mail.properties.smtp.ssl.enable}")
    private boolean ssl;
	
    // gmail
    @Value("${spring.mail.properties.smtp.socketFactory.class}")
    private String socketFactory;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();

        javaMailSender.setHost(host);
        javaMailSender.setPort(port);
        javaMailSender.setUsername(username);
        javaMailSender.setPassword(password);
        javaMailSender.setDefaultEncoding("UTF-8");
        javaMailSender.setJavaMailProperties(getMailProperties());

        return javaMailSender;
    }

    private Properties getMailProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        // naver
        properties.put("mail.smtp.ssl.enable", ssl);
        // gmail
        properties.put("mail.smtp.socketFactory.class", socketFactory);

        return properties;
    }
}

MailUtil

@Component
@RequiredArgsConstructor
public class MailUtil {

    private final JavaMailSender mailSender;
    private final EmailAuthService emailService;
    private static final String EMAIL_LINK = "http://localhost:8080/api/v1/users/confirm-email?";
    private static final String PATH_AND = "&";
    private static final String PATH_KEY_EMAIL = "email=";
    private static final String PATH_KEY_CODE = "authCode=";

    @Value("${spring.mail.username}")
    private String email;
	
    // 임의로 인증 코드를 생성한다.
    public String createAuthCode() {
        return UUID.randomUUID().toString().substring(0, 8);
    }
	
    // 메일을 전송한다.
    public void sendMessage(String to, String subject) {
        try {
            String code = createAuthCode();
            MimeMessage message = createMessage(to, subject, code);

            if (emailService.hasMail(to)) {
                emailService.delete(to);
            }
            EmailAuth emailAuth = EmailAuth.builder().email(to).code(code).build();

            emailService.save(emailAuth);
            mailSender.send(message);
        } catch (MessagingException e) {
            throw new GlobalException(EMAIL_SEND_FAILED);
        }
    }
	
    // redis에 저장된 인증 코드와 일치하는지 확인한다.
    public void checkCode(String email, String code) {
        EmailAuth emailAuth = getEmailAuth(email);

        MailValidator.checkCode(emailAuth.getCode(), code); // 일치하지 않으면 예외 처리

        emailService.delete(email);
        EmailAuth newEmailAuth = EmailAuth.builder().email(email).code(code).isChecked(true).build();

        emailService.save(newEmailAuth);
    }
	
    // redis에 저장된 이메일 인증 기록을 email 값을 통해 가져온다.
    public EmailAuth getEmailAuth(String email) {
        EmailAuth emailAuth = emailService.findById(email);
        MailValidator.validate(emailAuth); // 가져온 값이 null 값인지 체크
        return emailAuth;
    }
	
    // 이메일에 담아 보낼 내용을 작성한다.
    private MimeMessage createMessage(String to, String subject, String code)
            throws MessagingException {
        MimeMessage message = mailSender.createMimeMessage();

        message.setFrom(email);
        message.addRecipients(RecipientType.TO, to);
        message.setSubject(subject, StandardCharsets.UTF_8.name());
        message.setContent(
                EMAIL_LINK + PATH_KEY_EMAIL + to + PATH_AND + PATH_KEY_CODE + code,
                ContentType.TEXT_HTML.getMimeType());

        return message;
    }
}

 

 

EmailAuth

Redis에 이메일과 인증코드, 인증여부를 5분 동안 저장한다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@RedisHash(value = "auth", timeToLive = 300)
public class EmailAuth {

    @Id private String email;

    private String code;

    private boolean isChecked = false;

    @Builder
    private EmailAuth(String email, String code, boolean isChecked) {
        this.email = email;
        this.code = code;
        this.isChecked = isChecked;
    }
}

 

EmailAuthRepository

@RepositoryDefinition(domainClass = EmailAuth.class, idClass = String.class)
public interface EmailAuthRepository {
    EmailAuth findById(String email);

    EmailAuth deleteById(String email);

    boolean existsById(String email);

    EmailAuth save(EmailAuth emailAuth);
}

 

EmailAuthService

@Service
@RequiredArgsConstructor
public class EmailAuthServiceImpl implements EmailAuthService {

    private final EmailAuthRepository emailAuthRepository;

    @Override
    public EmailAuth findById(String email) {
        return emailAuthRepository.findById(email);
    }

    @Override
    public Boolean hasMail(String email) {
        return emailAuthRepository.existsById(email);
    }

    @Override
    public EmailAuth save(EmailAuth emailAuth) {
        return emailAuthRepository.save(emailAuth);
    }

    @Override
    public EmailAuth delete(String email) {
        return emailAuthRepository.deleteById(email);
    }
}

이메일 인증 관련 API와 Service 코드

  • UserController
public class UserController {

    private final UserService userService;

    @PostMapping("/email") // 이메일을 전송
    public RestResponse<UserVerifyEmailRes> sendEmail(@RequestBody UserVerifyEmailReq req) {
        return RestResponse.success(userService.sendEmail(req));
    }

    @GetMapping("/confirm-email") // 링크를 통해 인증코드를 확인
    public RestResponse<UserConfirmEmailRes> confirmEmail(
            @RequestParam(name = "email") String email, @RequestParam(name = "authCode") String code) {
        return RestResponse.success(userService.confirmEmail(email, code));
    }

    @PostMapping("/signup") // 회원가입
    public RestResponse<UserSignupRes> signup(@RequestBody UserSignupReq req) {
        return RestResponse.success(userService.signup(req));
    }
}

 

  • UserService
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    private final PasswordEncoder passwordEncoder;

    private final MailUtil mailUtil;

    private static final String EMAIL_AUTHENTICATION = "이메일 인증";
	
    // 이메일 전송
    @Override
    public UserVerifyEmailRes sendEmail(UserVerifyEmailReq req) {
        UserValidator.validate(req); // 이메일 형식 검증

        mailUtil.sendMessage(req.getEmail(), EMAIL_AUTHENTICATION);

        return new UserVerifyEmailRes();
    }
	
    // 인증코드 확인
    @Override
    public UserConfirmEmailRes confirmEmail(String email, String code) {
        mailUtil.checkCode(email, code);

        return UserConfirmEmailRes.builder().email(email).build();
    }
	
    // 회원가입
    @Override
    public UserSignupRes signup(UserSignupReq req) {
        UserValidator.validate(req);

        User user = userRepository.findByUsername(req.getUsername());
        UserValidator.checkDuplicatedUsername(user);

        checkAuthorizedEmail(req.getEmail()); //해당 email이 5분 이내에 검증된 메일인지 확인

        userRepository.save(
                User.builder()
                        .username(req.getUsername())
                        .password(passwordEncoder.encode(req.getPassword()))
                        .email(req.getEmail())
                        .role(Role.USER)
                        .build());

        return new UserSignupRes();
    }
    
    private void checkAuthorizedEmail(String email) {
        EmailAuth authEmail = mailUtil.getEmailAuth(email);
        UserValidator.checkAuthorizedEmail(authEmail.isChecked());
    }
}

혼자 과제를 하면서 인증 코드를 전송하고 해당 인증 코드를 다시 입력하여 인증하는 방식도 구현해보았는데
로직은 거의 똑같기 때문에 이메일 인증하는 입장에서는 링크 방식이 더 편할 것 같다는 생각이 들었다.
naver와 gamil SMTP를 사용할 때 설정에서 다른 점이 조금 있었기 때문에 이번 기회에 알게 되어서 좋았다.
또한 처음에는 어렵게 느껴지던 이메일 인증 과정과 Redis에 대해서도 구현 과정에서 친숙해지고 더 공부할 수 있었다.