reset-password-url: https://freemarket.duckdns.org/reset-password
registry.addMapping("/login/oauth2/code/**")
.allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081", "https://freemarket.duckdns.org")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
registry.addMapping("/reset-password/**")
.allowedOrigins("http://localhost:8081", "http://127.0.0.1:8081", "https://freemarket.duckdns.org")
.allowedMethods("GET", "POST", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
보안 측면에서, 등록되지 않은 이메일로 요청이 들어왔을 때 명시적으로 에러를 반환하는 것은 사용자 정보 노출의 위험이 있습니다. 대신 등록 여부와 상관없이 항상 "이메일이 발송되었습니다"라는 동일한 응답을 주는 것이 좋다.
소셜 로그인 사용자는 실제 비밀번호가 없을 수 있습니다. 이런 사용자들에 대한 처리가 필요하다.
비밀번호 재설정 이메일 전송 시 실패에 대한 예외 처리가 충분하지 않을 수 있다.
@Transactional
public void requestPasswordReset(PasswordDto.PasswordResetRequest request) {
String email = request.email();
log.info("비밀번호 재설정 요청 처리: {}", email);
// 사용자 존재 여부 확인
Optional<User> userOptional = userRepository.findByEmail(email);
// 사용자가 존재하지 않거나 소셜 로그인 전용 사용자인 경우 처리
if (userOptional.isEmpty()) {
log.info("존재하지 않는 이메일로 비밀번호 재설정 요청: {}", email);
// 보안을 위해 예외를 던지지 않고 조용히 처리 (클라이언트에는 동일한 성공 응답 제공)
return;
}
User user = userOptional.get();
// 소셜 로그인 전용 사용자인지 확 (provider가 있고 직접 설정한 비밀번호가 없는 경우)
if (user.getProvider() != null && !user.getProvider().isEmpty() &&
(user.getPassword() == null || user.getPassword().isEmpty() ||
user.getPassword().startsWith("SOCIAL_"))) {
log.info("소셜 로그인 전용 계정으로 비밀번호 재설정 요청: {}", email);
// 보안을 위해 예외를 던지지 않고 조용히 처리 (클라이언트에는 동일한 성공 응답 제공)
return;
}
try {
// 기존 토큰이 있으면 삭제
passwordResetTokenRepository.findByEmail(email)
.ifPresent(passwordResetTokenRepository::delete);
// 새 토큰 생성
PasswordResetToken resetToken = PasswordResetToken.builder()
.email(email)
.build();
passwordResetTokenRepository.save(resetToken);
// 이메일 발송
emailService.sendPasswordResetEmail(email, resetToken.getToken());
log.info("비밀번호 재설정 요청 처리 완료: {}", email);
} catch (Exception e) {
log.error("비밀번호 재설정 처리 중 오류 발생: {}", email, e);
// 이메일 발송 실패 등의 오류가 발생해도 클라이언트에는 오류를 노출하지 않음
}
}
요청된 이메일을 확인한 뒤 사용자가 존재하는 경우에만 비밀번호 재설정 토큰을 가지고 사용자에게 이메일을 전송하게 된다.
@Transactional
public void resetPassword(PasswordDto.PasswordResetVerifyRequest request) {
String token = request.token();
String newPassword = request.newPassword();
log.info("비밀번호 재설정 검증 및 변경 처리 시작. 토큰: {}", token);
// 토큰 검증
PasswordResetToken resetToken = passwordResetTokenRepository.findByToken(token)
.orElseThrow(() -> {
log.warn("유효하지 않은 비밀번호 재설정 토큰: {}", token);
return new AuthException.PasswordResetTokenNotFoundException();
});
// 만료 여부 확인
if (resetToken.isExpired()) {
log.warn("만료된 비밀번호 재설정 토큰: {}, 만료시간: {}", token, resetToken.getExpiryDate());
throw new AuthException.PasswordResetTokenExpiredException();
}
// 사용자 정보 조회
User user = userRepository.findByEmail(resetToken.getEmail())
.orElseThrow(() -> {
log.warn("토큰에 해당하는 사용자를 찾을 수 없음: {}, 이메일: {}",
token, resetToken.getEmail());
return new UserException.UserNotFoundException(resetToken.getEmail());
});
// 소셜 로그인 사용자인 경우 추가 처리
if (user.getProvider() != null && !user.getProvider().isEmpty()) {
log.info("소셜 로그인 사용자의 비밀번호 변경: {}", resetToken.getEmail());
}
// 비밀번호 변경
user.changePassword(passwordEncoder.encode(newPassword));
userRepository.save(user);
// 사용한 토큰 삭제
passwordResetTokenRepository.delete(resetToken);
log.info("비밀번호 재설정 완료: {}", resetToken.getEmail());
}
사용자가 비밀번호 재설정 이메일에서 토큰을 클릭해서 새 비밀번호를 제출했을 때, 그 요청을 받아서 비밀번호를 실제로 변경하는 서비스 로직이다.
사용자가 제출한 비밀번호 재설정 토큰과 새 비밀번호를 검증하고, 비밀번호를 업데이트한 뒤, 토큰을 폐기한다.
토큰은 로그인 인증 토큰(JWT)과는 다른 종류의 토큰이다.
보안 인증: 비밀번호 재설정 링크를 받은 사람이 실제로 해당 이메일의 소유자인지 확인하기 위해서다. 이메일에 포함된 고유 토큰을 통해 사용자가 이메일에 접근할 수 있는 권한이 있음을 증명한다.
일회성 접근 보장: 토큰은 일반적으로 한 번만 사용할 수 있으며, 시간 제한이 있다(현재 코드에서는 30분). 이는 악의적인 사용자가 비밀번호 재설정 링크를 반복해서 사용하는 것을 방지한다.
사용자 식별: 토큰은 특정 사용자의 이메일과 연결되어 있어, 서버가 어떤 사용자의 비밀번호를 재설정하는지 알 수 있게 한다.
public ResponseEntity<ResponseDTO<PasswordDto.PasswordResetResponse>> requestPasswordReset(
@Parameter(description = "비밀번호 재설정 요청 정보", required = true)
@Valid @RequestBody PasswordDto.PasswordResetRequest request) {
log.info("비밀번호 재설정 요청: {}", request.email());
try {
passwordResetService.requestPasswordReset(request);
} catch (Exception e) {
// 예외가 발생해도 동일한 응답을 제공 (보안상 이유)
log.error("비밀번호 재설정 요청 중 오류: {}", e.getMessage(), e);
}
// 이메일 존재 여부와 상관 없이 동일한 성공 응답 반환
PasswordDto.PasswordResetResponse response = PasswordDto.PasswordResetResponse.builder()
.success(true)
.message("비밀번호 재설정 이메일이 발송되었습니다. 메일함을 확인해주세요")
.build();
return ResponseEntity.ok(ResponseDTO.success(response, "비밀번호 재설정 이메일이 발송되었습니다. 메일함을 확인해주세요"));
}
이메일 존재 여부와 상관 없이 동일한 성공 응답을 반환하도록 수정해준다.
http://localhost:8081/reset-password?token=65f5d3a0-06f9-430e-ad4b-9b8ae1870f8a