bookiki 서비스를 개발하면서 회원 관련해서 인증을 받아야하는 경우가 생겼다.
OAuth 2.0을 통해서 로그인을 하는 경우도 있었지만 서비스 자체에서 회원가입 해서 로그인을 하는 경우도 생각했었고, 좀 더 많은 사용자가 편하게 사용하게 하기 위해 사용자 인증을 이메일 인증으로 하기로 했다.
또한 비밀번호를 잊어버렸을 경우 재설정 할 수 있는 링크를 이메일로 보내서 비밀번호를 재설정 할 수 있게 했다.
Chrome 브라우저에서 로그인을 Google 계정 관리에 들어간다.

앱 비밀번호를 설정하기 위해서는 2단계 인증을 설정해야 한다.

앱 비밀번호를 검색하고 앱 비밀번호를 생성한다.

앱 이름을 입력하고 16자리의 앱 비밀번호를 얻는다. 이 비밀번호는 구글 계정 비밀번호와 동일한 권한을 가지고 있기 때문에 외부로 유출 되어서는 안된다.
dependencies {
```
// Mail
implementation 'org.springframework.boot:spring-boot-starter-mail'
```
}
Spring에서는 Java Mail Sender 인터페이스를 활용해서 이메일을 전송할 수 있다. 해당 의존성을 주입하면 Java Mail Sender 인테페이스를 구현한 구현체를 직접적으로 활용할 수 있게 된다.
spring:
mail:
host: smtp.gmail.com
port: 587
username: ${mail.username}
password: ${mail.password}
properties:
mail:
smtp:
auth: true
timeout: 1000 // 1초
starttls:
enable: true
host : SMTP 서버의 호스트
port : SMTP 서버에서 사용하는 포트 (Google의 경우 587, SSL을 사용하려면 starttls를 false로 하고 465 사용)
username : SMTP 서버 로그인 ID (Gmail 계정의 ID이므로 @gmail.com 까지 작성해야한다.)
password : 앱 비밀번호
auth : 사용자 인증을 시도할 것인지 체크하는 것이고 default가 false이기 때문에 true로 설정하여 인증 후 메일이 발송될 수 있다.
timeout : 이메일을 보낼 떄 SMTP 서버가 응답할 때 까지 기다리는 시간.
startttls : SSL / TLS 기반으로 SMTP를 활용 여부를 선택하는 변수로, default가 false이기 때문에 true로 설정하여 보안성을 높인다.
username과 password는 .env파일을 만들어서 관리할 수 있다.
package com.corp.bookiki.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Schema(description = "이메일 인증 요청")
public class SendEmailRequest {
@Email(message = "올바른 이메일 형식이 아닙니다.")
@NotBlank(message = "이메일은 필수입니다.")
private String email;
}
SendEmailRequest라는 dto를 만들어서 사용했다.
package com.corp.bookiki.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Schema(description = "이메일 인증 코드 확인 요청")
public class VerifyCodeRequest {
@Schema(
description = "인증 코드",
example = "123456",
required = true,
pattern = "^[0-9]{6}$"
)
@NotBlank(message = "인증코드는 필수입니다.")
@Pattern(regexp = "^[0-9]{6}$", message = "인증코드는 6자리 숫자여야 합니다.")
private String code;
}
6자리의 인증코드를 받는 VerifyCodeRequest dto를 만들었다.
package com.corp.bookiki.user.service;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import com.corp.bookiki.global.error.code.ErrorCode;
import com.corp.bookiki.global.error.exception.EmailAuthenticationException;
import com.corp.bookiki.user.repository.UserRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class EmailVerificationService {
private static final String EMAIL_CODE_PREFIX = "EMAIL_CODE:";
private static final String VERIFIED_EMAIL_PREFIX = "VERIFIED_EMAIL:";
private static final Duration CODE_EXPIRATION = Duration.ofMinutes(5);
private final StringRedisTemplate redisTemplate;
private final JavaMailSender mailSender;
private final UserRepository userRepository;
@Value("${spring.mail.username}")
private String fromEmail;
public void sendVerificationCode(String email) {
// 이미 메일이 db에 있는 경우 이메일이 중복되므로 예외처리
if (userRepository.existsByEmail(email)) {
throw new EmailAuthenticationException(ErrorCode.DUPLICATE_EMAIL);
}
String code = generateVerificationCode(); // 6자리 인증 코드생성
saveVerificationCode(email, code); // redis에 email:code를 key:value로 설정
try {
sendEmail(email, code); // 이메일 전송
} catch (MessagingException e) {
// 이메일 전송 실패 시 예외처리
throw new EmailAuthenticationException(ErrorCode.FAIL_EMAIL_SEND);
}
}
private String generateVerificationCode() {
// 6자리 인증코드 랜덤 생성
return String.format("%06d", (int)(Math.random() * 1000000));
}
private void saveVerificationCode(String email, String code) {
// redis에 이메일 및 인증코드 저장, 일정 시간이 지나면 expire
redisTemplate.opsForValue().set(
EMAIL_CODE_PREFIX + email,
code,
CODE_EXPIRATION
);
}
private void sendEmail(String to, String code) throws MessagingException {
// 인증코드를 담아서 이메일 전송
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(fromEmail);
helper.setTo(to); // 메일 수신자
helper.setSubject("[Bookiki] 이메일 인증번호"); // 메일 제목
helper.setText("인증번호: " + code + "\n\n해당 인증번호는 5분간 유효합니다.", false); // 메일 본문 내용
mailSender.send(message); // 메일전송
}
public void verifyCode(String email, String code) {
// redis에 저장된 인증 코드
String savedCode = redisTemplate.opsForValue().get(EMAIL_CODE_PREFIX + email);
// 저장된 코드가 없거나, 입력받은 코드와 다른 경우 예외처리
if (savedCode == null || !savedCode.equals(code)) {
throw new EmailAuthenticationException(ErrorCode.INVALID_EMAIL_VERIFICATION);
}
// redis에 저장된 정보 delete
redisTemplate.delete(EMAIL_CODE_PREFIX + email);
}
}
redis를 사용해 이메일 인증코드 관리를 진행했다.
Service에서 만든 로직을 Controller나 Schedular등을 이용해 실행하면 된다.

다음과 같이 이메일이 전송된 것을 확인할 수 있다.
사용자가 이름과 이메일을 입력하면 비밀번호 재설정 할 수 있는 링크를 담은 메일을 전송하도록 했다.
기존 비밀번호를 잊어버렸을 때 실행되는 로직이기 때문에 이메일을 UUID를 활용해 사용자별로 이메일에 알맞는 링크를 보낼 수 있게 했다.
package com.corp.bookiki.user.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class PasswordResetEmailRequest {
@NotBlank(message = "이메일은 필수입니다")
@Email(message = "올바른 이메일 형식이 아닙니다")
private String email;
@NotBlank(message = "사용자 이름은 필수입니다")
private String userName;
}
PasswordResetEmailRequest 라는 dto를 만들어 사용했다.
package com.corp.bookiki.user.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
@Schema(description = "비밀번호 재설정 요청")
public class PasswordResetRequest {
@NotBlank(message = "새 비밀번호는 필수입니다.")
private String newPassword;
@NotBlank(message = "새 비밀번호 확인은 필수입니다.")
private String newPasswordConfirm;
}
비밀번호 재설정에는 새 비밀번호와 새 비밀번호 확인만 사용하기 때문에 PasswordResetRequest dto를 만들었다.
import java.time.Duration;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.corp.bookiki.global.error.code.ErrorCode;
import com.corp.bookiki.global.error.exception.PasswordUpdateException;
import com.corp.bookiki.user.entity.Provider;
import com.corp.bookiki.user.entity.UserEntity;
import com.corp.bookiki.user.repository.UserRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PasswordService {
private static final String PASSWORD_RESET_PREFIX = "PASSWORD_RESET:";
private static final Duration RESET_TOKEN_TTL = Duration.ofMinutes(10);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender;
private final StringRedisTemplate redisTemplate;
@Value("${MAIL_USERNAME}")
private String fromEmail;
@Value("${FRONTEND_URL}")
private String frontendUrl;
@Transactional
public void sendPasswordResetEmail(String email, String userName) {
// 비밀번호를 재설정할 사용자의 이메일이 없는 경우 예외처리
UserEntity user = userRepository.findByEmail(email)
.orElseThrow(() -> new PasswordUpdateException(ErrorCode.USER_NOT_FOUND));
// OAuth 2.0 로그인인 경우 예외처리
if (!user.getProvider().equals(Provider.BOOKIKI)) {
throw new PasswordUpdateException(ErrorCode.UNVALID_PROVIDER);
}
// 비밀번호를 재설정할 사용자의 이름이 없는 경우 예외처리
if (!userName.equals(user.getUserName())) {
throw new PasswordUpdateException(ErrorCode.USERNAME_EMAIL_MISMATCH);
}
// 사용자 Email을 UUID를 활용해 resetToken으로 변환 후 redis에 저장
String resetToken = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(PASSWORD_RESET_PREFIX + resetToken, email, RESET_TOKEN_TTL);
// resetToken을 포함한 url 생성
String resetUrl = frontendUrl + "/reset-password/" + resetToken;
try {
sendPasswordResetMail(email, resetUrl); // 링크 포함한 이메일 전송
} catch (MessagingException e) {
// 이메일 전송 실패 시 예외처리
throw new PasswordUpdateException(ErrorCode.FAIL_EMAIL_SEND);
}
}
private void sendPasswordResetMail(String to, String resetUrl) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setFrom(fromEmail);
helper.setTo(to);
helper.setSubject("[Bookiki] 비밀번호 재설정");
helper.setText("비밀번호 재설정을 위해 아래 링크를 클릭해주세요:\n\n" + resetUrl + "\n\n" + "본 링크는 10분간 유효하며, 1회만 사용 가능합니다.", false);
mailSender.send(message);
}
@Transactional
public void resetPassword(String resetToken, String newPassword, String newPasswordConfirm) {
String email = redisTemplate.opsForValue().get(PASSWORD_RESET_PREFIX + resetToken);
if (email == null) {
throw new PasswordUpdateException(ErrorCode.INVALID_TOKEN);
}
if (!newPassword.equals(newPasswordConfirm)) {
throw new PasswordUpdateException(ErrorCode.PASSWORD_MISMATCH);
}
UserEntity user = userRepository.findByEmail(email)
.orElseThrow(() -> new PasswordUpdateException(ErrorCode.USER_NOT_FOUND));
user.changePassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
redisTemplate.delete(PASSWORD_RESET_PREFIX + resetToken);
}
}
이메일 인증과 마찬가지로 redis를 사용해 resetToken을 관리했다.

다음과 같이 이메일이 전송된 것을 확인할 수 있다.
@Async로 이메일 전송 비동기 처리
이메일에 들어갈 html 파싱(thymeleaf)
참고