비밀번호 재설정 오류 수정

뚜우웅이·2025년 5월 25일

캡스톤 디자인

목록 보기
29/35

콜백 주소 변경

reset-password-url: https://freemarket.duckdns.org/reset-password

CORS

        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);
  • allowedOrigins: 허용할 프론트 도메인들
  • allowedMethods: 허용할 HTTP 메서드
  • allowedHeaders: 어떤 요청 헤더를 허용할지
  • allowCredentials(true): 쿠키 등 인증정보 포함 허용
  • maxAge(3600): preflight 결과를 3600초 동안 캐시

개선할 점

  • 보안 측면에서, 등록되지 않은 이메일로 요청이 들어왔을 때 명시적으로 에러를 반환하는 것은 사용자 정보 노출의 위험이 있습니다. 대신 등록 여부와 상관없이 항상 "이메일이 발송되었습니다"라는 동일한 응답을 주는 것이 좋다.

  • 소셜 로그인 사용자는 실제 비밀번호가 없을 수 있습니다. 이런 사용자들에 대한 처리가 필요하다.

  • 비밀번호 재설정 이메일 전송 시 실패에 대한 예외 처리가 충분하지 않을 수 있다.

PasswordResetService

    @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분). 이는 악의적인 사용자가 비밀번호 재설정 링크를 반복해서 사용하는 것을 방지한다.

  • 사용자 식별: 토큰은 특정 사용자의 이메일과 연결되어 있어, 서버가 어떤 사용자의 비밀번호를 재설정하는지 알 수 있게 한다.

PasswordResetController

    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, "비밀번호 재설정 이메일이 발송되었습니다. 메일함을 확인해주세요"));
    }

이메일 존재 여부와 상관 없이 동일한 성공 응답을 반환하도록 수정해준다.

흐름

  • 사용자가 비밀번호 찾기 페이지에서 자신의 이메일을 입력한다.
  • 서버는 해당 이메일과 연결된 고유한 토큰을 생성하고 데이터베이스에 저장한다.
  • 서버는 이 토큰이 포함된 비밀번호 재설정 링크를 사용자의 이메일로 보낸다.
  • 사용자가 이메일의 링크를 클릭하면, 프론트엔드의 비밀번호 재설정 페이지로 이동하며 URL에 토큰이 파라미터로 포함된다.
    • ex) http://localhost:8081/reset-password?token=65f5d3a0-06f9-430e-ad4b-9b8ae1870f8a
  • 사용자가 새 비밀번호를 입력하고 제출하면, 프론트엔드는 이 토큰과 새 비밀번호를 서버에 보낸다.
  • 서버는 토큰의 유효성을 검사하고, 유효하면 해당 사용자의 비밀번호를 업데이트한다.
profile
공부하는 초보 개발자

0개의 댓글