📘 Backend/Spring
비밀번호 찾기 & 재설정 구현(Google SMTP & Redis)
신건우
2024. 6. 3. 22:44
비밀번호 찾기 & 재설정 기능 구현(Google SMTP)
사내 비밀번호 찾기/초기화 기능을 위해 Email 인증을 자체 SMTP 서버를 만들어서 하려다가, 귀찮아서 Google SMTP를 이용하기로 했습니다.
우선 Google 계정 설정에 들어가서 아래 2개의 작업을 해줍니다.
- Multi Factor Authentication(2FA) 활성화
- App Password 생성

그리고 Gmail 탭으로 들어가 Mail 설정의 Forwarding and POP/IMAP 탭에 들어가서 아래와 같이 설정합니다.

Spring Boot Server 설정
build.gradle 파일에 Mail 추가
implementation 'org.springframework.boot:spring-boot-starter-mail'
application.yml 파일에 메일 설정
spring:
mail:
host: smtp.gmail.com
port: 587
username: dainsdevteam
password: abcd
properties:
mail.smtp.debug: true
mail.smtp.connection timeout: 1000 #1초
mail.starttls.enable: true
mail.smtp.auth: true
Mail Config 작성
@Configuration
@RequiredArgsConstructor
public class MailConfig {
private static final String MAIL_SMTP_AUTH = "mail.smtp.auth";
private static final String MAIL_DEBUG = "mail.stmp.debug";
private static final String MAIL_CONNECTION_TIMEOUT = "mail.smtp.connectiontimeout";
private static final String MAIL_SMTP_STARTTLS_ENABLE = "mail.smtp.starttls.enable";
// SMTP 서버
@Value("${spring.mail.host}")
private String host;
// 계정
@Value("${spring.mail.username}")
private String username;
// 비밀번호
@Value("${spring.mail.password}")
private String password;
// 포트번호
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.properties.mail.smtp.auth}")
private boolean auth;
@Value("${spring.mail.properties.mail.smtp.debug}")
private boolean debug;
@Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
private int connectionTimeout;
@Value("${spring.mail.properties.mail.starttls.enable}")
private boolean startTlsEnable;
@Bean
public JavaMailSender mailSender() {
// Sender 정보 추가
JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl();
javaMailSender.setHost(host);
javaMailSender.setUsername(username);
javaMailSender.setPassword(password);
javaMailSender.setPort(port);
// Mail Properties 추가
Properties properties = javaMailSender.getJavaMailProperties();
properties.put(MAIL_SMTP_AUTH, auth);
properties.put(MAIL_DEBUG, debug);
properties.put(MAIL_CONNECTION_TIMEOUT, connectionTimeout);
properties.put(MAIL_SMTP_STARTTLS_ENABLE, startTlsEnable);
javaMailSender.setJavaMailProperties(properties);
javaMailSender.setDefaultEncoding("UTF-8");
return javaMailSender;
}
}
Mail Service로 테스트 이메일 보내보기
@Slf4j
@Service
@Scheduled
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MailService {
private final JavaMailSender mailSender;
public void sendEmail(String receiver, String title, String text) {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(receiver);
message.setSubject(DateUtil.getDate() + " " + title);
message.setText(text);
try {
mailSender.send(message);
} catch (RuntimeException e) {
log.warn("Failed to Send Email - {}", receiver);
throw new CommonException(CommonExceptionCode.SERVER_ERROR);
}
}
@Scheduled(fixedRate = 5000)
public void emailScheduler() {
sendEmail("tensorflow555@gmail.com", "테스트 이메일", "ㅇㅇㅇ");
}
}

랜덤 인증번호 생성 & 인증번호 검증 함수 구현
- 인증번호를 생성할 때 Redis Hash를 생성해 code, expiration Key/Value를 넣고 Expire를 120초로 설정해줍니다.
- 인증번호 검증 성공 시, 즉시 Redis Hash를 제거합니다.
- Redis Hash 제거 후, 비밀번호 초기화 및 재설정 API에서 사용할 Redis Key를 1개 만들어 "ok"를 저장해줍니다.
@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MailService {
private final AdmUserRepository admUserRepository;
private static final String TITLE = "비밀번호 재설정 인증번호";
private final JavaMailSender mailSender;
private final JwtProvider jwtProvider;
private final RedisTemplate<String, Object> redisTemplate;
// Send Mail
public void sendEmail(String receiver) {
SimpleMailMessage message = new SimpleMailMessage();
String to = receiver;
String code = this.generateAuthCode();
message.setTo(to);
message.setSubject(TITLE);
message.setText(code);
// Redis에 인증코드 + 수신자 저장, 120초로 만료시간 설정
redisTemplate.opsForHash().put(receiver, "code", code);
redisTemplate.opsForHash().put(receiver, "expiration", DateUtil.getTime());
redisTemplate.expire(receiver, 120, TimeUnit.SECONDS);
try {
mailSender.send(message);
} catch (RuntimeException e) {
log.warn("Failed to Send Email - {}", receiver);
throw new CommonException(CommonExceptionCode.SERVER_ERROR);
}
}
// 4자리 인증번호 생성
public String generateAuthCode() {
try {
Random random = SecureRandom.getInstanceStrong();
StringBuilder codeBuilder = new StringBuilder();
for (int i = 0; i < 4; i++) {
codeBuilder.append(random.nextInt(10));
}
return codeBuilder.toString();
} catch (Exception e) {
log.warn("Failed to generate auth code - {}", e.getMessage());
throw new CommonException(CommonExceptionCode.SERVER_ERROR);
}
}
// 인증코드 검증
public boolean verifyAuthCode(String email, String authCode) {
try {
Optional<AdmUser> optUser = admUserRepository.searchUserByEmail(email);
AdmUser user = null;
if (optUser.isPresent()) user = optUser.get();
else throw new CommonException(CommonExceptionCode.NOT_EXIST_USER);
String key = user.getEmail();
String now = DateUtil.getTime();
String redisCode = (String) redisTemplate.opsForHash().get(user.getEmail(), "code");
String expiration = (String) redisTemplate.opsForHash().get(user.getEmail(), "expiration");
// 현재 시간과 만료 시간의 차이를 milli second로 반환
long checkExpirationTime = DateUtil.getSecondsDifference(now, expiration);
// 만료시간 120초로 설정
if (checkExpirationTime < 120 && redisCode.equals(authCode)) {
// 인증 성공 시, Redis Hash 삭제
redisTemplate.opsForHash().delete(key, "code");
redisTemplate.opsForHash().delete(key, "time");
// 비밀번호 재설정 API 에서 사용할 Redis Key 추가(OK 사인)
redisTemplate.opsForValue().set(user.getEmail(), "ok");
return true;
} else {
redisTemplate.opsForHash().delete(key, "code");
redisTemplate.opsForHash().delete(key, "time");
return false;
}
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
}
컨트롤러 작성
@PreAuth(componentId = 4, authorization = AuthorizationType.Create)
@PostMapping("/generate/code")
public ResponseEntity requestAuthCode(CustomHttpServletRequest request, @RequestParam("email") String email) {
mailService.sendEmail(email);
return new ResponseEntity(ApiResponseDto.makeSuccessResponse(), HttpStatus.OK);
}
@PreAuth(componentId = 4, authorization = AuthorizationType.Read)
@GetMapping("/verify/code")
public ResponseEntity verifyAuthCode(CustomHttpServletRequest request, @RequestParam("email") String email, @RequestParam("code") String code) {
return new ResponseEntity(ApiResponseDto.makeResponse(mailService.verifyAuthCode(email, code)), HttpStatus.OK);
}
인증번호 생성 & 검증하기
PostMan으로 요청을 보내 인증번호를 받습니다.

이메일 확인

Redis Hash를 보면 코드와 만료시간이 들어가 있습니다.

인증번호 검증 API를 호출해보면 120초 이내에 요청하고, 검증이 성공해 true 반환

이후 비밀번호 재설정 및 초기화 로직은 인증코드 검증 시 마지막에 추가한 Redis Key를 이용해,
비밀번호를 새로 넣어주고 사용한 Redis Key는 제거 해주었고, 다시 로그인을 하니 비밀번호가 잘 바뀐걸 확인할 수 있습니다.
@Transactional
public void generateNewPassword(final Integer userId, final String password) {
AdmUser user = admUserRepositoryInf.findAdmUserByUserId(userId);
if (user != null) {
String sign = (String) redisTemplate.opsForValue().get(user.getEmail());
if ("ok".equals(sign)) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String newPassword = encoder.encode(password);
user.changeAdminPassword(newPassword);
admUserRepositoryInf.save(user);
redisTemplate.delete(user.getEmail());
} else {
throw new CommonException(CommonExceptionCode.ACCESS_DENIED);
}
} else {
throw new CommonException(CommonExceptionCode.NOT_EXIST_USER);
}
}
