이룸 프로젝트

[이룸] 240306 인증 메일 전송하기 구현

쿠마냥 2024. 3. 7. 15:48

인증 메일 전송 기능 구현의 이유

  • 이메일을 잘못 입력했는데, 덜컥 가입이 되어 버리면 어쩌지? 라는 질문에서 시작
  • 처음에는 유저가 잘못 입력한 이메일을 수정할 수 있도록, 마이페이지에 이메일 수정 기능이 있었음
  • 그러나…
  • 이메일을 수정한다 → 에러 발생! → 왜? → 발급해 준 토큰 속 이메일과 변경한 이메일의 불일치 → 그러면 이메일을 바꿀 때마다 새로 토큰 두 개를 만들어줘야 하나? → 토큰을 발급하는 건 로그인하는 과정에서 일어난다. 즉, 사용자를 확인하는 과정에서 일어난다 → 그런데 이렇게 되면 ‘이메일 수정 = 로그인’이 되어 버림
  • 그러나222…
  • 만약 비밀번호를 탈취당한다면? → 해커는 탈취한 비밀번호로 로그인한 다음, 아이디까지 바꿔 버림 → 유저는 아이디와 비밀번호를 모두 잃어 버림. 내 아이디가 탈취되었는지 어떤지조차 알 수 없을 것
  • 따라서…
  • 아이디는 서버에게도 유저에게도 해당 유저를 식별하는 식별자값. 그러니 변경되지 않는 것이 좋겠다! → 그렇다면 처음부터 이메일을 잘못 입력하지 않도록 인증 이메일을 전송하자!

레퍼런스 :

https://green-bin.tistory.com/83

 

Spring - 이메일 인증 구현해보기 (랜덤 인증번호 보내기)

배경 새로 시작하게 된 프로젝트에서 회원가입 중 이메일 인증을 하도록 했다. Spring에서 제공하는 API를 사용하면 생각보다 쉽게 구현할 수 있다. 나는 Google SMTP 서버를 이용해서 이메일 인증을

green-bin.tistory.com

 

클래스 생성 :

  • EmailConfig : JavaMailSender 관련 설정 클래스
  • EmailVerification: 인증 메일 객체 설정
  • EmailVerificationRepository : 인증 메일 저장소
  • MemberController: 인증 메일 관련 요청 처리 (수정)
  • MemberService: 인증 메일 관련 비즈니스 로직 (수정)
  • EmailService : 인증 메일 전송 관련 설정 클래스

구현 과정

[yml]

# Gmail Service
  mail:
    host: smtp.gmail.com
    port: 587
    username: eroom.challenge@gmail.com
    password: {16자리 앱 비밀번호}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000
      auth-code-expiration-millis: 1800000  # 30 * 60 * 1000 == 30분
  • host: Gmail의 SMTP 서버 호스트
  • port: SMTP 서버의 포트 번호. Gmail SMTP 서버는 587번 포트를 사용
  • username: 이메일을 보내는 용으로 사용되는 계정의 이메일 주소 입력
  • password: 앱 비밀번호 입력
  • properties: 이메일 구성에 대한 추가 속성
  • auth: SMTP 서버에 인증 필요한 경우 true로 지정한다. Gmail SMTP 서버는 인증을 요구하기 때문에 true로 설정해야 한다.
  • starttls: SMTP 서버가 TLS를 사용하여 안전한 연결을 요구하는 경우 true로 설정한다. TLS는 데이터를 암호화하여 안전한 전송을 보장하는 프로토콜이다.
  • connectiontimeout: 클라이언트가 SMTP 서버와의 연결을 설정하는 데 대기해야 하는 시간(Millisecond). 연결이 불안정한 경우 대기 시간이 길어질 수 있기 때문에 너무 크게 설정하면 전송 속도가 느려질 수 있다.
  • timeout: 클라이언트가 SMTP 서버로부터 응답을 대기해야 하는 시간(Millisecond). 서버에서 응답이 오지 않는 경우 대기 시간을 제한하기 위해 사용된다.
  • writetimeout: 클라이언트가 작업을 완료하는데 대기해야 하는 시간(Millisecond). 이메일을 SMTP 서버로 전송하는 데 걸리는 시간을 제한하는데 사용된다.
  • auth-code-expiration-millis: 이메일 인증 코드의 만료 시간(Millisecond)

[EmailConfig]

@Configuration
public class EmailConfig {
    @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;

    @Bean
    public JavaMailSender javaMailSender() {
        Properties mailProperties = new Properties();
        mailProperties.put("mail.transport.protocol", "smtp"); // 구글의 smtp 프로토콜로 전송
        mailProperties.put("mail.smtp.auth", "true"); // smtp 서버에 이메일을 보낼 때 인증이 필요함. 무단 액세스로부터 계정 보호
        mailProperties.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory"); // 메일 전송에 ssl 기반 소켓 사용하는 설정
        mailProperties.put("mail.smtp.starttls.enable", "true"); // tls 관련 설정. 메일 클라이언트와 서버 사이 연결 암호화. 데이터 안전 전송
        mailProperties.put("mail.smtp.debug", "true"); // 디버깅 가능
        mailProperties.put("mail.smtp.ssl.trust", "smtp.gmail.com"); // ssl 인증서 유효함을 인증함
        mailProperties.put("mail.smtp.ssl.protocols", "TLSv1.2"); // tls 1.2 버전 사용하여 ssl 보안 프로토콜 지정

        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setJavaMailProperties(mailProperties);
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding("UTF-8");

        return mailSender;
    }
}

 

[EmailVerification]

@Entity
@Getter
@NoArgsConstructor
public class EmailVerification {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String authCode;

    @Column(nullable = false)
    private LocalDateTime expirationTime;

    public EmailVerification(String toEmail, String authCode, LocalDateTime expirationTime) {
        this.email = toEmail;
        this.authCode = authCode;
        this.expirationTime = expirationTime;
    }

    public void update(String authCode, LocalDateTime expirationTime) {
        this.authCode = authCode;
        this.expirationTime = expirationTime;
    }
}

 

[EmailVerificationRepository]

public interface EmailVerificationRepository extends JpaRepository<EmailVerification, Long> {
    Optional<EmailVerification> findByEmailAndAuthCode(String email, String authCode);
    Optional<EmailVerification> findByEmail(String email);

    void deleteByEmail(String email);
}

 

[MemberController 추가 메서드]

@PostMapping("/emails/verification-requests")
public ResponseEntity<BaseDto<String>> sendMessage(@RequestParam("email") @Valid String email) {
    String message = memberService.sendCodeToEmail(email);
    return ResponseEntity.ok(new BaseDto<>(null, message, HttpStatus.OK));
}

@GetMapping("/emails/verifications")
public ResponseEntity<BaseDto<String>> verificationEmail(@RequestParam("email") @Valid String email,
                                                          @RequestParam("code") String authCode) {
    String message = memberService.verifiedCode(email, authCode);
        return ResponseEntity.ok(new BaseDto<>(null, message, HttpStatus.OK));

 

[MemberService 추가 메서드]

@Transactional
public String sendCodeToEmail(String toEmail) {
		// 이미 가입된 회원인지 확인
    boolean memberIsPresent = memberRepository.existsByEmail(toEmail);
    if (memberIsPresent) {
        return "이미 가입된 아이디입니다.";
    }
		// 회원이 아니라면 인증 코드 생성
    String authCode = this.createCode();

    // 이메일 내용 정의
    String title = "eroom 이메일 인증 번호";
    String content =
            "<div style='font-family: Arial, Helvetica, sans-serif; color: #333; background-color: #ffffff; padding: 40px; border-radius: 15px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); text-align: center;'>"
                    + "<h2 style='color: #4a7c59; font-size: 22px;'>🎉 안녕하세요, 이룸에 오신 것을 환영합니다! 🎉</h2>"
                    + "<p style='font-size: 16px;'>5분 내에 아래 <strong>인증번호</strong>를 복사하여 인증번호 확인란에 입력해주세요.</p>"
                    + "<div style='margin: 30px auto; padding: 20px; background-color: #e6f9d4; display: inline-block;'>"
                    + "<h3 style='color: #333; font-size: 18px;'>회원가입 인증번호입니다.</h3>"
                    + "<p style='background-color: #d4f7c5; color: #4a7c59; font-size: 24px; padding: 10px 20px; border-radius: 10px; display: inline-block; margin: 0;'>" + authCode + "</p>"
                    + "</div>"
                    + "<p style='font-size: 16px; margin-top: 40px;'>이 코드를 요청하지 않은 경우, 이 이메일을 무시해도 됩니다.<br>다른 사용자가 실수로 이메일 주소를 입력했을 수 있습니다.</p>"
                    + "</div>";


		// 이메일 보내기
    String sendMail = "eroom.challenge@gmail.com";
    emailService.sendEmail(sendMail, toEmail, title, content);

		// 서버시간 기준 5분 후로 이메일 만료 시간 지정
    LocalDateTime expirationTime = LocalDateTime.now().plusMinutes(5);

		// 이미 인증 메일을 보낸 사용자라면 인증 코드와 만료 시간 업데이트, 아니라면 새로 생성 후 저장
    EmailVerification verification = emailVerificationRepository.findByEmail(toEmail)
            .orElse(new EmailVerification(toEmail, authCode, expirationTime));

    verification.update(authCode, expirationTime);
    emailVerificationRepository.save(verification);
    return "인증 메일을 전송하였습니다.";
}


private String createCode() {
    int length = 6;
    try {
        Random random = SecureRandom.getInstanceStrong();
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < length; i++) {
            builder.append(random.nextInt(10));
        }
        return builder.toString();
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException("인증번호를 만들던 중 오류가 발생했습니다.");
    }
}

public String verifiedCode(String email, String authCode) {
    Optional<EmailVerification> verification = emailVerificationRepository.findByEmailAndAuthCode(email, authCode);

		// 인증메일이 제대로 가지 않아서 저장이 되지 않은 경우
    if (!verification.isPresent()) {
        return "인증 메일이 정상적으로 전송되지 않았습니다.";
    }
    LocalDateTime now = LocalDateTime.now();

		// 현재시간보다 만료시간이 뒤인 경우(만료시간 아직 안 지남), 인증 메일 지우고 완료 메시지 전송
    if (verification.get().getExpirationTime().isAfter(now)) {
        emailVerificationRepository.deleteByEmail(email);
        return "인증이 완료되었습니다.";
    } else {
        return "인증 시간이 초과되었습니다.";
    }
}

 

[EmailService]

@Service
public class EmailService {

    private final JavaMailSender emailSender;

    public EmailService(JavaMailSender emailSender) {
        this.emailSender = emailSender;
    }

    public void sendEmail(String sendEmail, String toEmail, String title, String content) {

        // HTML 형식의 이메일 전송하기 위한 설정
        MimeMessage message = emailSender.createMimeMessage();

        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "utf-8");
            helper.setFrom(sendEmail);
            helper.setTo(toEmail);
            helper.setSubject(title);
            // 이메일 형식을 html로 해석되야 함을 의미. false로 설정할 경우 단순 text로 전송됨
            helper.setText(content, true);
            emailSender.send(message);
        } catch (MessagingException | jakarta.mail.MessagingException e) {
            e.printStackTrace();
        }
    }
}

 

MimeMessageHelper란?

 

MIME(Multipurpose Internet Mail Extensions) 메시지를 쉽게 생성하고 조작할 수 있도록 해주는 util class이자 자바 메일 관련 라이브러리. 텍스트 외에 이미지, 오디오, 비디오 등 다양한 유형의 데이터를 이메일로 보낼 수 있게 해준다.

 

new MimeMessageHelper(message, true, "utf-8")에서 true 지정을 하면, 텍스트 + 파일 첨부가 가능한 멀티 파트 3번 메시지로 첨부하게 된다.