From 743ecc6d9f01c1b019936d6565c4de3fff197557 Mon Sep 17 00:00:00 2001 From: Oh-Jisong Date: Fri, 3 Jul 2026 20:04:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EC=A0=95=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=BD=94=EB=93=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/EmailVerificationPurpose.java | 2 +- .../dto/EmailVerificationVerifyResponse.java | 1 + .../service/EmailVerificationService.java | 15 ++++++++++ .../EmailVerificationVerifyService.java | 28 +++++++++++++++++++ .../ceos/ipx/global/exception/ErrorCode.java | 2 ++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java index a767e63..6683e61 100644 --- a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java +++ b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationPurpose.java @@ -30,7 +30,7 @@ public static EmailVerificationPurpose from(String value) { || purpose.name().equalsIgnoreCase(value) ) .findFirst() - .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT_VALUE)); + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_EMAIL_VERIFICATION_PURPOSE)); } @JsonValue diff --git a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationVerifyResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationVerifyResponse.java index c59635c..bece512 100644 --- a/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationVerifyResponse.java +++ b/src/main/java/ceos/ipx/domain/auth/dto/EmailVerificationVerifyResponse.java @@ -11,4 +11,5 @@ public class EmailVerificationVerifyResponse { private EmailVerificationPurpose purpose; private boolean verified; private String verificationToken; + private int expiresIn; } \ No newline at end of file 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 04b3fc8..f1e3ac5 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationService.java @@ -25,6 +25,7 @@ public class EmailVerificationService { private static final int VERIFIED_EXPIRES_IN_SECONDS = 600; private static final String VERIFIED_KEY_PREFIX = "emailVerification:verified:"; + private static final String TOKEN_KEY_PREFIX = "emailVerification:token:"; private final StringRedisTemplate stringRedisTemplate; @@ -75,6 +76,12 @@ public String verifyVerificationCode(EmailVerificationPurpose purpose, String em Duration.ofSeconds(VERIFIED_EXPIRES_IN_SECONDS) ); + stringRedisTemplate.opsForValue().set( + createTokenKey(purpose, verificationToken), + email, + Duration.ofSeconds(VERIFIED_EXPIRES_IN_SECONDS) + ); + return verificationToken; } @@ -86,6 +93,10 @@ public int getResendAvailableInSeconds() { return RESEND_AVAILABLE_IN_SECONDS; } + public int getVerifiedExpiresInSeconds() { + return VERIFIED_EXPIRES_IN_SECONDS; + } + private String generateCode() { SecureRandom random = new SecureRandom(); int number = random.nextInt(1_000_000); @@ -104,6 +115,10 @@ private String createVerifiedKey(EmailVerificationPurpose purpose, String email) return VERIFIED_KEY_PREFIX + purpose.getValue() + ":" + email; } + private String createTokenKey(EmailVerificationPurpose purpose, String verificationToken) { + return TOKEN_KEY_PREFIX + purpose.getValue() + ":" + verificationToken; + } + private String createVerificationToken() { return UUID.randomUUID().toString(); } diff --git a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationVerifyService.java b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationVerifyService.java index b9cb393..04f53ae 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationVerifyService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/EmailVerificationVerifyService.java @@ -3,6 +3,11 @@ import ceos.ipx.domain.auth.dto.EmailVerificationPurpose; import ceos.ipx.domain.auth.dto.EmailVerificationVerifyRequest; import ceos.ipx.domain.auth.dto.EmailVerificationVerifyResponse; +import ceos.ipx.domain.user.entity.User; +import ceos.ipx.domain.user.entity.UserProvider; +import ceos.ipx.domain.user.repository.UserRepository; +import ceos.ipx.global.exception.BusinessException; +import ceos.ipx.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -11,12 +16,17 @@ public class EmailVerificationVerifyService { private final EmailVerificationService emailVerificationService; + private final UserRepository userRepository; public EmailVerificationVerifyResponse verifyEmailVerificationCode(EmailVerificationVerifyRequest request) { String email = request.getEmail(); String code = request.getCode(); EmailVerificationPurpose purpose = request.getPurpose(); + if (purpose == EmailVerificationPurpose.PASSWORD_RESET) { + validatePasswordResetTargetUser(email); + } + String verificationToken = emailVerificationService.verifyVerificationCode(purpose, email, code); return EmailVerificationVerifyResponse.builder() @@ -24,6 +34,24 @@ public EmailVerificationVerifyResponse verifyEmailVerificationCode(EmailVerifica .purpose(purpose) .verified(true) .verificationToken(verificationToken) + .expiresIn(emailVerificationService.getVerifiedExpiresInSeconds()) .build(); } + + private void validatePasswordResetTargetUser(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + 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); + } + } } \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index b53ff78..d3293f6 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -23,6 +23,8 @@ public enum ErrorCode { EMAIL_VERIFICATION_CODE_EXPIRED(HttpStatus.GONE, "AU010", "인증 코드가 만료되었거나 존재하지 않습니다."), EMAIL_VERIFICATION_CODE_MISMATCH(HttpStatus.BAD_REQUEST, "AU011", "인증 코드가 일치하지 않습니다."), SOCIAL_ACCOUNT_PASSWORD_RESET_NOT_ALLOWED(HttpStatus.FORBIDDEN, "AU012", "소셜 로그인 계정은 비밀번호를 재설정할 수 없습니다."), + INVALID_EMAIL_VERIFICATION_PURPOSE(HttpStatus.BAD_REQUEST, "AU013", "잘못된 이메일 인증 목적입니다."), + PASSWORD_RESET_TOKEN_EXPIRED(HttpStatus.GONE, "AU014", "비밀번호 재설정 토큰이 만료되었거나 존재하지 않습니다."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다.");