Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,6 +87,24 @@ public ResponseEntity<ApiResponse<ReissueResponse>> 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<ApiResponse<Void>> resetPassword(
@Valid @RequestBody PasswordResetRequest request
) {
authService.resetPassword(request);

return ResponseEntity.ok(ApiResponse.<Void>ok(null));
}

@Operation(summary = "로그아웃", description = "현재 사용자를 로그아웃 처리하고 RefreshToken Cookie를 삭제하며 AccessToken을 블랙리스트 처리합니다.")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그아웃 성공"),
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/dto/PasswordResetRequest.java
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions src/main/java/ceos/ipx/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/ceos/ipx/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
5 changes: 3 additions & 2 deletions src/main/java/ceos/ipx/global/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading