My Project

Flutter로컬커머스) 비밀번호 찾기 기능 구현

조충희 2025. 9. 2. 18:39

사용자가 비밀번호나 아이디를 잊어버렸을 때, 안전하고 편리하게 계정을 복구하도록 돕는다.

비밀번호 찾기(재설정) 기능:

개요: "코드 발송 → 코드 검증 및 임시 토큰 발급 → 최종 비밀번호 변경"의 안전한 3단계 프로세스를 구현.

목표: 사용자가 비밀번호를 잊었을 때, 이메일 인증을 통해 안전하게 계정을 복구할 수 있는 필수 기능을 제공.

구현방법:

JwtUtil을 확장해서, 10분 유효기간을 가진 비밀번호 재설정 전용 임시 토큰(password-reset-jwt)을 발급하고 검증하는 로직을 추가해 보안을 강화.

② 관련 DTO 3종(SendPasswordResetCodeRequest, PasswordResetRequest, PasswordResetTokenResponse)과 PasswordResetController를 새로 만들어 기능을 완성.

아이디 찾기 기능:

개요: "코드 발송 → 코드 검증 및 마스킹된 아이디 반환"의 2단계 프로세스를 구현.

목표: 사용자가 아이디를 잊었을 때, 스스로 계정 정보를 찾을 수 있는 편의 기능을 제공.

구현방법:

① 인증 성공 시, 사용자의 아이디 전체를 노출하지 않고 myid***처럼 일부를 가려서 반환함으로써 개인정보를 보호했음.

② 관련 DTO 1종(FindIdResponse)과 AccountIdFinderController를 새로 만들어서 기능을 완성했음.

작업절차:

SendPasswordResetCodeRequest.java(신규 생성)코드 발송 요청 시 loginIdemail을 받기 위한 DTO를 만들었음.

@Getter
@Setter
@NoArgsConstructor
public class SendPasswordResetCodeRequest {
    @NotBlank(message = "아이디를 입력해주세요.")
    private String loginId;
    @NotBlank(message = "이메일을 입력해주세요.")
    @Email(message = "유효한 이메일 주소를 입력해주세요.")
    private String email;
}

 

PasswordResetRequest.java (신규 생성) 최종 비밀번호 변경 시 resetTokennewPassword를 받기 위한 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