diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java index 6f12038..fe2e544 100644 --- a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import ceos.ipx.domain.auth.dto.PasswordResetRequest; @Tag(name = "Auth API", description = "인증/인가 관련 API") @RestController @@ -86,6 +87,24 @@ public ResponseEntity> reissue( return ResponseEntity.ok(ApiResponse.ok(response)); } + @Operation(summary = "새 비밀번호 설정", description = "비밀번호 재설정 인증 코드 검증 후 발급된 verificationToken을 검증하여 새 비밀번호로 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "비밀번호 재설정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패 또는 비밀번호 형식 오류 또는 비밀번호 확인 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "비활성화 계정 또는 소셜 로그인 계정"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "410", description = "비밀번호 재설정 토큰 만료 또는 존재하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/password/reset") + public ResponseEntity> resetPassword( + @Valid @RequestBody PasswordResetRequest request + ) { + authService.resetPassword(request); + + return ResponseEntity.ok(ApiResponse.ok(null)); + } + @Operation(summary = "로그아웃", description = "현재 사용자를 로그아웃 처리하고 RefreshToken Cookie를 삭제하며 AccessToken을 블랙리스트 처리합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그아웃 성공"), diff --git a/src/main/java/ceos/ipx/domain/auth/dto/PasswordResetRequest.java b/src/main/java/ceos/ipx/domain/auth/dto/PasswordResetRequest.java new file mode 100644 index 0000000..16bcf11 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/PasswordResetRequest.java @@ -0,0 +1,19 @@ +package ceos.ipx.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PasswordResetRequest { + + @NotBlank(message = "비밀번호 재설정 토큰은 필수입니다.") + private String verificationToken; + + @NotBlank(message = "새 비밀번호는 필수입니다.") + private String newPassword; + + @NotBlank(message = "새 비밀번호 확인은 필수입니다.") + private String newPasswordConfirm; +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java index 39e1f8c..7211588 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -17,6 +17,10 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import ceos.ipx.domain.auth.dto.PasswordResetRequest; +import ceos.ipx.domain.user.entity.UserProvider; +import java.util.regex.Pattern; + @Service @RequiredArgsConstructor @@ -25,12 +29,16 @@ public class AuthService { private static final String BEARER_PREFIX = "Bearer "; + private static final Pattern PASSWORD_PATTERN = + Pattern.compile("^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,}$"); + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final AccessTokenBlacklistService accessTokenBlacklistService; private final CookieUtils cookieUtils; + private final EmailVerificationService emailVerificationService; @Transactional public SignUpResponse signUp(SignUpRequest request) { @@ -152,6 +160,50 @@ public ReissueResponse reissue(String refreshToken, HttpServletResponse httpServ .build(); } + @Transactional + public void resetPassword(PasswordResetRequest request) { + String email = emailVerificationService.getEmailByPasswordResetToken(request.getVerificationToken()); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + validatePasswordResetUser(user); + validateNewPassword(request.getNewPassword(), request.getNewPasswordConfirm(), user); + + String encodedPassword = passwordEncoder.encode(request.getNewPassword()); + user.updatePassword(encodedPassword); + + emailVerificationService.deletePasswordResetVerification(request.getVerificationToken(), email); + } + + private void validatePasswordResetUser(User user) { + if (!user.isActive()) { + throw new BusinessException(ErrorCode.INACTIVE_USER); + } + + if (user.getProvider() != UserProvider.LOCAL) { + throw new BusinessException(ErrorCode.SOCIAL_ACCOUNT_PASSWORD_RESET_NOT_ALLOWED); + } + + if (user.getPasswordHash() == null || user.getPasswordHash().isBlank()) { + throw new BusinessException(ErrorCode.SOCIAL_ACCOUNT_PASSWORD_RESET_NOT_ALLOWED); + } + } + + private void validateNewPassword(String newPassword, String newPasswordConfirm, User user) { + if (!newPassword.equals(newPasswordConfirm)) { + throw new BusinessException(ErrorCode.PASSWORD_CONFIRM_MISMATCH); + } + + if (!PASSWORD_PATTERN.matcher(newPassword).matches()) { + throw new BusinessException(ErrorCode.INVALID_PASSWORD_FORMAT); + } + + if (passwordEncoder.matches(newPassword, user.getPasswordHash())) { + throw new BusinessException(ErrorCode.SAME_AS_OLD_PASSWORD); + } + } + @Transactional public void logout( String authorizationHeader, diff --git a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java index f1e3ac5..4d3fa37 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java @@ -103,6 +103,23 @@ private String generateCode() { return String.format("%06d", number); } + public String getEmailByPasswordResetToken(String verificationToken) { + String tokenKey = createTokenKey(EmailVerificationPurpose.PASSWORD_RESET, verificationToken); + String email = stringRedisTemplate.opsForValue().get(tokenKey); + + if (email == null) { + throw new BusinessException(ErrorCode.PASSWORD_RESET_TOKEN_EXPIRED); + } + + return email; + } + + public void deletePasswordResetVerification(String verificationToken, String email) { + stringRedisTemplate.delete(createTokenKey(EmailVerificationPurpose.PASSWORD_RESET, verificationToken)); + stringRedisTemplate.delete(createVerifiedKey(EmailVerificationPurpose.PASSWORD_RESET, email)); + stringRedisTemplate.delete(createCodeKey(EmailVerificationPurpose.PASSWORD_RESET, email)); + } + private String createCodeKey(EmailVerificationPurpose purpose, String email) { return CODE_KEY_PREFIX + purpose.getValue() + ":" + email; } diff --git a/src/main/java/ceos/ipx/domain/user/entity/User.java b/src/main/java/ceos/ipx/domain/user/entity/User.java index 70ff58e..5527753 100644 --- a/src/main/java/ceos/ipx/domain/user/entity/User.java +++ b/src/main/java/ceos/ipx/domain/user/entity/User.java @@ -73,4 +73,8 @@ public void updateProfile(String name, String company) { public void deactivate() { this.isActive = false; } + + public void updatePassword(String encodedPassword) { + this.passwordHash = encodedPassword; + } } diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index d3293f6..481ab93 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -25,9 +25,10 @@ public enum ErrorCode { SOCIAL_ACCOUNT_PASSWORD_RESET_NOT_ALLOWED(HttpStatus.FORBIDDEN, "AU012", "소셜 로그인 계정은 비밀번호를 재설정할 수 없습니다."), INVALID_EMAIL_VERIFICATION_PURPOSE(HttpStatus.BAD_REQUEST, "AU013", "잘못된 이메일 인증 목적입니다."), PASSWORD_RESET_TOKEN_EXPIRED(HttpStatus.GONE, "AU014", "비밀번호 재설정 토큰이 만료되었거나 존재하지 않습니다."), + INVALID_PASSWORD_FORMAT(HttpStatus.BAD_REQUEST, "AU015", "비밀번호 형식이 올바르지 않습니다."), + SAME_AS_OLD_PASSWORD(HttpStatus.BAD_REQUEST, "AU016", "기존 비밀번호와 동일한 비밀번호는 사용할 수 없습니다."), - UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); + UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); private final HttpStatus status; private final String code;