사용자가 비밀번호나 아이디를 잊어버렸을 때, 안전하고 편리하게 계정을 복구하도록 돕는다.
비밀번호 찾기(재설정) 기능:
개요: "코드 발송 → 코드 검증 및 임시 토큰 발급 → 최종 비밀번호 변경"의 안전한 3단계 프로세스를 구현.
목표: 사용자가 비밀번호를 잊었을 때, 이메일 인증을 통해 안전하게 계정을 복구할 수 있는 필수 기능을 제공.
구현방법:
① JwtUtil을 확장해서, 10분 유효기간을 가진 비밀번호 재설정 전용 임시 토큰(password-reset-jwt)을 발급하고 검증하는 로직을 추가해 보안을 강화.
② 관련 DTO 3종(SendPasswordResetCodeRequest, PasswordResetRequest, PasswordResetTokenResponse)과 PasswordResetController를 새로 만들어 기능을 완성.
아이디 찾기 기능:
개요: "코드 발송 → 코드 검증 및 마스킹된 아이디 반환"의 2단계 프로세스를 구현.
목표: 사용자가 아이디를 잊었을 때, 스스로 계정 정보를 찾을 수 있는 편의 기능을 제공.
구현방법:
① 인증 성공 시, 사용자의 아이디 전체를 노출하지 않고 myid***처럼 일부를 가려서 반환함으로써 개인정보를 보호했음.
② 관련 DTO 1종(FindIdResponse)과 AccountIdFinderController를 새로 만들어서 기능을 완성했음.
작업절차:
△ SendPasswordResetCodeRequest.java(신규 생성)코드 발송 요청 시 loginId와 email을 받기 위한 DTO를 만들었음.
@Getter
@Setter
@NoArgsConstructor
public class SendPasswordResetCodeRequest {
@NotBlank(message = "아이디를 입력해주세요.")
private String loginId;
@NotBlank(message = "이메일을 입력해주세요.")
@Email(message = "유효한 이메일 주소를 입력해주세요.")
private String email;
}
△ PasswordResetRequest.java (신규 생성) 최종 비밀번호 변경 시 resetToken과 newPassword를 받기 위한 DTO를 만들었음.
@Getter
@Setter
@NoArgsConstructor
public class PasswordResetRequest {
@NotBlank(message = "비밀번호 재설정 토큰이 필요합니다.")
private String resetToken;
@NotBlank(message = "새 비밀번호를 입력해주세요.")
@Pattern(
regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&,.])[A-Za-z\\d@$!%*?&,.]{8,16}$",
message = "비밀번호는 8~16자, 영문, 숫자, 특수문자를 모두 포함해야 합니다."
)
private String newPassword;
}
△ PasswordResetTokenResponse.java (신규 생성) 코드 검증 성공 시, 클라이언트에게 임시 resetToken을 보내주기 위한 DTO를 만들었음.
@Getter
@AllArgsConstructor
public class PasswordResetTokenResponse {
private final String resetToken;
}
△ JwtUtil.java (수정) 10분 유효기간을 가진 비밀번호 재설정 전용 임시 토큰을 생성하고(createPasswordResetToken), 검증하는(verifyPasswordResetToken) 두 개의 메서드를 새로 추가했음.
@Component
public class JwtUtil {
// 비밀번호 재설정용 임시 토큰 유효 시간 (5분)
private static final long RESET_TOKEN_EXPIRATION_TIME = 1000L * 60 * 5;
// 비밀번호 재설정용 임시 토큰 생성
public static String createPasswordResetToken(Member member) {
Date expiresAt = new Date(System.currentTimeMillis() + RESET_TOKEN_EXPIRATION_TIME);
return JWT.create()
.withSubject("password-reset-jwt") // 용도를 명확히 구분
.withExpiresAt(expiresAt)
.withClaim("id", member.getId())
.withClaim("role", member.getRole().name()) // 역할 정보도 포함
.sign(Algorithm.HMAC512(SECRET_KEY));
}
// 비밀번호 재설정용 임시 토큰 검증 및 세션 정보 반환
public static SessionUser verifyPasswordResetToken(String jwt) throws JWTVerificationException {
DecodedJWT decodedJWT = JWT.require(Algorithm.HMAC512(SECRET_KEY))
.withSubject("password-reset-jwt") // 반드시 용도가 일치하는지 확인
.build()
.verify(jwt);
Long id = decodedJWT.getClaim("id").asLong();
String roleStr = decodedJWT.getClaim("role").asString();
return SessionUser.builder().id(id).role(Role.valueOf(roleStr)).build();
}
△ MemberAuthService.java (수정) 비밀번호 찾기의 3단계(코드 발송, 코드 검증 및 임시 토큰 발급, 최종 재설정) 비즈니스 로직을 담당하는 3개의 새로운 메서드를 추가했음.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberAuthService {
//비밀번호 재설정을 위한 인증 코드를 이메일로 발송합니다.
public void sendPasswordResetCode(SendPasswordResetCodeRequest request) {
log.info("비밀번호 재설정 코드 발송 요청. 로그인 ID: {}", request.getLoginId());
Member member = memberRepository.findByLoginId(request.getLoginId())
.orElseThrow(() -> new Exception404("해당 아이디를 가진 회원을 찾을 수 없습니다."));
if (!Objects.equals(member.getMemberAuth()
.getEmail(), request.getEmail())) {
throw new Exception400("아이디와 이메일 정보가 일치하지 않습니다.");
}
emailVerificationService.sendCode(request.getEmail(), VerificationPurpose.RESET_PASSWORD);
}
//이메일로 받은 인증 코드를 검증하고, 성공 시 비밀번호 재설정용 임시 토큰을 발급합니다.
public PasswordResetTokenResponse confirmPasswordResetCode(ConfirmVerificationRequest request) {
log.info("비밀번호 재설정 코드 검증 요청. 이메일: {}", request.getEmail());
boolean isVerified = emailVerificationService.verifyCode(request.getEmail(), VerificationPurpose.RESET_PASSWORD, request.getCode());
if (!isVerified) {
throw new Exception400("인증 코드가 유효하지 않거나 만료되었습니다.");
}
Member member = memberAuthRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new Exception404("해당 이메일을 가진 회원을 찾을 수 없습니다."))
.getMember();
String resetToken = JwtUtil.createPasswordResetToken(member);
log.info("비밀번호 재설정 임시 토큰 발급 완료. 사용자 ID: {}", member.getId());
return new PasswordResetTokenResponse(resetToken);
}
//발급받은 임시 토큰을 사용하여 최종적으로 비밀번호를 재설정합니다.
@Transactional
public void resetPassword(PasswordResetRequest request) {
log.info("최종 비밀번호 재설정 요청.");
JwtUtil.SessionUser sessionUser = JwtUtil.verifyPasswordResetToken(request.getResetToken());
Member member = memberService.findMember(sessionUser.getId());
String newEncodedPassword = passwordEncoder.encode(request.getNewPassword());
member.updatePassword(newEncodedPassword);
log.info("최종 비밀번호 재설정 완료. 사용자 ID: {}", member.getId());
}
△ PasswordResetController.java (신규 생성) 비밀번호 찾기 관련 API 3개를 외부에 노출하는 새로운 컨트롤러를 만들었음.
@Tag(name = "Password Reset API", description = "비밀번호 찾기(재설정) 관련 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth/password-reset")
public class PasswordResetController {
private final MemberAuthService memberAuthService;
@Operation(summary = "비밀번호 재설정 코드 발송", description = "아이디와 이메일 정보가 일치하는 회원에게 인증 코드를 발송합니다.")
@PostMapping("/send-code")
public ResponseEntity<ApiUtil.ApiResult<String>> sendPasswordResetCode(
@Valid @RequestBody SendPasswordResetCodeRequest request) {
memberAuthService.sendPasswordResetCode(request);
return ResponseEntity.ok(ApiUtil.success("인증 코드가 성공적으로 발송되었습니다."));
}
@Operation(summary = "비밀번호 재설정 코드 확인", description = "발송된 인증 코드를 검증하고, 성공 시 비밀번호를 재설정할 수 있는 임시 토큰을 발급합니다.")
@PostMapping("/confirm-code")
public ResponseEntity<ApiUtil.ApiResult<PasswordResetTokenResponse>> confirmPasswordResetCode(
@Valid @RequestBody ConfirmVerificationRequest request) {
PasswordResetTokenResponse response = memberAuthService.confirmPasswordResetCode(request);
return ResponseEntity.ok(ApiUtil.success(response));
}
@Operation(summary = "최종 비밀번호 재설정", description = "발급받은 임시 토큰을 사용하여 최종적으로 비밀번호를 변경합니다.")
@PostMapping
public ResponseEntity<ApiUtil.ApiResult<String>> resetPassword(
@Valid @RequestBody PasswordResetRequest request) {
memberAuthService.resetPassword(request);
return ResponseEntity.ok(ApiUtil.success("비밀번호가 성공적으로 재설정되었습니다."));
}
}
△ FindIdResponse.java (신규 생성) 인증 성공 시, 마스킹 처리된 아이디(maskedLoginId)를 클라이언트에게 보내주기 위한 DTO를 만들었음.
@Getter
@AllArgsConstructor
public class FindIdResponse {
private final String maskedLoginId;
}
△ MemberAuthService.java (수정) 아이디 찾기의 2단계(코드 발송, 코드 검증 및 마스킹된 아이디 반환) 비즈니스 로직을 담당하는 2개의 새로운 메서드를 추가했음.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberAuthService {
//아이디 찾기를 위한 인증 코드를 이메일로 발송
public void sendFindIdCode(String email) {
log.info("아이디 찾기 코드 발송 요청. 이메일: {}", email);
if (memberAuthRepository.findByEmail(email)
.isEmpty()) {
throw new Exception404("해당 이메일로 가입된 회원을 찾을 수 없습니다.");
}
emailVerificationService.sendCode(email, VerificationPurpose.FIND_ID);
}
//이메일로 받은 인증 코드를 검증하고, 성공 시 마스킹 처리된 아이디를 반환
public FindIdResponse findLoginIdByEmail(ConfirmVerificationRequest request) {
log.info("아이디 찾기 코드 검증 요청. 이메일: {}", request.getEmail());
boolean isVerified = emailVerificationService.verifyCode(request.getEmail(), VerificationPurpose.FIND_ID, request.getCode());
if (!isVerified) {
throw new Exception400("인증 코드가 유효하지 않거나 만료되었습니다.");
}
Member member = memberAuthRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new Exception404("해당 이메일을 가진 회원을 찾을 수 없습니다."))
.getMember();
String maskedLoginId = maskLoginId(member.getLoginId());
log.info("아이디 찾기 성공. 사용자 ID: {}", member.getId());
return new FindIdResponse(maskedLoginId);
}
private String maskLoginId(String loginId) {
if (loginId == null || loginId.length() <= 3) {
return loginId;
}
return loginId.substring(0, 3) + "***";
}
△ AccountIdFinderController.java (신규 생성) 아이디 찾기 관련 API 2개를 외부에 노출하는 새로운 컨트롤러를 만들었음.
@Tag(name = "Account Finder API", description = "아이디/비밀번호 찾기 관련 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth/login-id")
public class AccountIdFinderController {
private final MemberAuthService memberAuthService;
@Operation(summary = "아이디 찾기용 인증 코드 발송", description = "가입된 이메일로 아이디를 찾기 위한 인증 코드를 발송합니다.")
@PostMapping("/send-code")
public ResponseEntity<ApiUtil.ApiResult<String>> sendFindIdCode(
@Valid @RequestBody SendVerificationRequest request) {
memberAuthService.sendFindIdCode(request.getEmail());
return ResponseEntity.ok(ApiUtil.success("인증 코드가 성공적으로 발송되었습니다."));
}
@Operation(summary = "아이디 찾기용 코드 확인", description = "발송된 인증 코드를 검증하고, 성공 시 마스킹 처리된 아이디를 반환합니다.")
@PostMapping("/confirm")
public ResponseEntity<ApiUtil.ApiResult<FindIdResponse>> confirmFindIdCode(
@Valid @RequestBody ConfirmVerificationRequest request) {
FindIdResponse response = memberAuthService.findLoginIdByEmail(request);
return ResponseEntity.ok(ApiUtil.success(response));
}
}
파일트리:
email
├── VerificationPurpose.java
├── controllers
│ ├── AccountIdFinderController.java
│ ├── EmailVerificationController.java
│ └── PasswordResetController.java
├── dtos
│ ├── ConfirmVerificationRequest.java
│ ├── FindIdResponse.java
│ ├── PasswordResetRequest.java
│ ├── PasswordResetTokenResponse.java
│ ├── SendPasswordResetCodeRequest.java
│ └── SendVerificationRequest.java
└── services
├── EmailService.java
├── InMemoryVerificationCodeStore.java
├── VerificationCodeStore.java
└── EmailVerificationService.java
members
└── services
└── MemberAuthService.java
_core
└── _utils
└── JwtUtil.java
'My Project' 카테고리의 다른 글
Flutter로컬커머스) 이메일 도메인 리팩토링 작업 (0) | 2025.09.02 |
---|---|
Flutter로컬커머스) 인증메일 발송기능 구현 (1) | 2025.09.01 |
CRUD 이후 필요한 고급 기능 체크리스트 (1) | 2025.08.06 |