diff --git a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java index 803696a..5fce78c 100644 --- a/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java +++ b/src/main/java/com/nhnacademy/insightonauth/controller/UserController.java @@ -6,10 +6,14 @@ import com.nhnacademy.insightonauth.service.UserService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.Duration; + @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor @@ -61,7 +65,29 @@ public ResponseEntity doLogin( @RequestBody @Valid UserLoginRequest userLoginRequest) { UserLoginResponse userLoginResponse = userService.login(userLoginRequest.email(), userLoginRequest.password()); - return ResponseEntity.ok(userLoginResponse); + // 로컬용 + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", userLoginResponse.refreshToken()) + .httpOnly(true) + .secure(false) // http라 false + .path("/") + .domain("localhost") // 포트 무관 공유 + .sameSite("Lax") + .maxAge(Duration.ofDays(7)) + .build(); + + // 도커용 +// ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", userLoginResponse.refreshToken()) +// .httpOnly(true) +// .secure(true) // https라 true +// .path("/") +// .sameSite("Lax") +// .maxAge(Duration.ofDays(7)) +// .build(); // domain 안 박음 + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) // refresh 쿠키 헤더에 설정 + .body(userLoginResponse); + } @PostMapping("/logout") diff --git a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java index 23247c3..e37d36b 100644 --- a/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java +++ b/src/main/java/com/nhnacademy/insightonauth/email/EmailService.java @@ -1,9 +1,6 @@ package com.nhnacademy.insightonauth.email; -import com.nhnacademy.insightonauth.exception.EmailSendException; -import com.nhnacademy.insightonauth.exception.InvalidVerificationCodeException; -import com.nhnacademy.insightonauth.exception.InvalidVerificationTokenException; -import com.nhnacademy.insightonauth.exception.VerificationTemporarilyLockedException; +import com.nhnacademy.insightonauth.exception.*; import com.nhnacademy.insightonauth.redis.RedisKey; import com.nhnacademy.insightonauth.redis.RedisService; import jakarta.mail.MessagingException; @@ -51,14 +48,23 @@ public void sendVerificationCode(String email) { // 비밀번호 재설정 경로 발성 public void sendPasswordResetPath(String email) { + // 1. 역방향 키로 이 이메일의 기존 토큰을 찾아 예전 링크를 무효화 + String oldUuid = redisService.get(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + email); + if (oldUuid != null && !oldUuid.isBlank()) { + redisService.delete(RedisKey.PASSWORD_RESET.getPrefix() + oldUuid); + } + + // 2. 새 토큰 발급 String uuid = UUID.randomUUID().toString(); - // 재설정 경로 String path = "https://insighton.store/password/reset?token=" + uuid; - // Redis에 저장 (10분 TTL) + // 3. 정방향 키 저장 (uuid → email, 10분 TTL) redisService.set(RedisKey.PASSWORD_RESET.getPrefix() + uuid, email, Duration.ofMinutes(10)); - // 메일 발송 + // 4. 역방향 키 갱신 (email → uuid, 같은 TTL) + redisService.set(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + email, uuid, Duration.ofMinutes(10)); + + // 5. 메일 발송 send(email, "[InsightOn] 비밀번호 재설정", "비밀번호 재설정 경로: " + path + "\n10분 이내에 수정해 주세요."); } @@ -82,7 +88,8 @@ private void send(String to, String subject, String text) { } // 이메일 코드 확인 - public String emailVerify(String email, String inputCode) { + public String emailCodeVerify(String email, String inputCode) { + // 입력 실패 잠금 체크 (검증 전용) if (redisService.hasKey(RedisKey.VERIFY_FAIL_LOCK.getPrefix() + email)) { throw new VerificationTemporarilyLockedException("인증 시도가 5회 초과되어 5분간 잠겼습니다."); } @@ -90,16 +97,16 @@ public String emailVerify(String email, String inputCode) { String savedCode = redisService.get(RedisKey.VERIFY.getPrefix() + email); if (savedCode == null || !savedCode.equals(inputCode)) { - // 만료되었거나 애초에 요청한 적 없음 - increaseVerifyFailCount(email); + increaseVerifyFailCount(email); // 입력 실패 카운트 (여기서 5회 넘으면 VERIFY_FAIL_LOCK 걸림) throw new InvalidVerificationCodeException("인증 코드가 올바르지 않거나 만료되었습니다."); } + + // 성공 처리 redisService.delete(RedisKey.VERIFY_FAIL.getPrefix() + email); redisService.delete(RedisKey.VERIFY.getPrefix() + email); String verificationToken = UUID.randomUUID().toString(); redisService.set(RedisKey.VERIFIED.getPrefix() + email, verificationToken, Duration.ofMinutes(15)); - redisService.delete(RedisKey.VERIFY.getPrefix() + email); return verificationToken; } @@ -123,7 +130,10 @@ public String emailTokenVerify(String token) { throw new InvalidVerificationTokenException("인증 토큰이 올바르지 않거나 만료되었습니다."); } + // 정방향 + 역방향 키 모두 삭제 (토큰 1회용, 재설정 완료 후 정리) redisService.delete(RedisKey.PASSWORD_RESET.getPrefix() + token); + redisService.delete(RedisKey.PASSWORD_RESET_BY_EMAIL.getPrefix() + savedEmail); + return savedEmail; } diff --git a/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java b/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java index c9b158e..cb842fc 100644 --- a/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java +++ b/src/main/java/com/nhnacademy/insightonauth/entity/Oauth.java @@ -40,4 +40,8 @@ public Oauth(User user, String provider, String providerUserId) { this.providerUserId = providerUserId; this.createdAt = OffsetDateTime.now(ZoneOffset.UTC); } + + public void reassignUser(User newUser) { + this.user = newUser; + } } diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java index 723bf5f..cb5a603 100644 --- a/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java +++ b/src/main/java/com/nhnacademy/insightonauth/exception/ErrorCode.java @@ -27,7 +27,18 @@ public enum ErrorCode { USER_NOT_FOUND(HttpStatus.NOT_FOUND), USER_ROLE_NOT_FOUND(HttpStatus.NOT_FOUND), VERIFICATION_TEMPORARILY_LOCKED(HttpStatus.LOCKED), - EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT); + EMAIL_ALREADY_REGISTERED(HttpStatus.CONFLICT), + OAUTH_LINKED_TO_OTHER_ACCOUNT(HttpStatus.CONFLICT), + INVALID_MERGE_REQUEST(HttpStatus.BAD_REQUEST), + // 이메일 인증 코드 재전송 — 연타 방지(쿨다운) + VERIFICATION_RESEND_TOO_SOON(HttpStatus.TOO_MANY_REQUESTS), + // 이메일 인증 코드 재전송 — 횟수 초과 잠금 + VERIFICATION_RESEND_LOCKED(HttpStatus.LOCKED), + + // 비밀번호 재설정 메일 재전송 — 연타 방지(쿨다운) + PASSWORD_RESET_RESEND_TOO_SOON(HttpStatus.TOO_MANY_REQUESTS), + // 비밀번호 재설정 메일 재전송 — 횟수 초과 잠금 + PASSWORD_RESET_RESEND_LOCKED(HttpStatus.LOCKED); private final HttpStatus status; diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/InvalidMergeRequestException.java b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidMergeRequestException.java new file mode 100644 index 0000000..34b38a8 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/InvalidMergeRequestException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class InvalidMergeRequestException extends BusinessException { + public InvalidMergeRequestException(String message) { + super(ErrorCode.INVALID_MERGE_REQUEST, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/OauthLinkedToOtherAccountException.java b/src/main/java/com/nhnacademy/insightonauth/exception/OauthLinkedToOtherAccountException.java new file mode 100644 index 0000000..4c2b9e4 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/OauthLinkedToOtherAccountException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class OauthLinkedToOtherAccountException extends BusinessException { + public OauthLinkedToOtherAccountException(String message, Long conflictingUserId) { + super(ErrorCode.OAUTH_LINKED_TO_OTHER_ACCOUNT, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendLockedException.java new file mode 100644 index 0000000..f458bfd --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendLockedException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class PasswordResetResendLockedException extends BusinessException { + public PasswordResetResendLockedException(String message) { + super(ErrorCode.PASSWORD_RESET_RESEND_LOCKED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendTooSoonException.java b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendTooSoonException.java new file mode 100644 index 0000000..964267b --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/PasswordResetResendTooSoonException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class PasswordResetResendTooSoonException extends BusinessException { + public PasswordResetResendTooSoonException(String message) { + super(ErrorCode.PASSWORD_RESET_RESEND_TOO_SOON, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendLockedException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendLockedException.java new file mode 100644 index 0000000..8a63ff6 --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendLockedException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class VerificationResendLockedException extends BusinessException { + public VerificationResendLockedException(String message) { + super(ErrorCode.VERIFICATION_RESEND_LOCKED, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendTooSoonException.java b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendTooSoonException.java new file mode 100644 index 0000000..bf588da --- /dev/null +++ b/src/main/java/com/nhnacademy/insightonauth/exception/VerificationResendTooSoonException.java @@ -0,0 +1,7 @@ +package com.nhnacademy.insightonauth.exception; + +public class VerificationResendTooSoonException extends BusinessException { + public VerificationResendTooSoonException(String message) { + super(ErrorCode.VERIFICATION_RESEND_TOO_SOON, message); + } +} diff --git a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java index 05721b8..8c02189 100644 --- a/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java +++ b/src/main/java/com/nhnacademy/insightonauth/filter/HeaderAuthenticationFilter.java @@ -31,6 +31,7 @@ public class HeaderAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String userIdHeader = request.getHeader("X-User-Id"); + // admin만 role 줄거임 그외는 다 null로 들어올거라 null체크 String rolesHeader = request.getHeader("X-User-Role"); if (userIdHeader != null) { diff --git a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java index e086c8d..619eb88 100644 --- a/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java +++ b/src/main/java/com/nhnacademy/insightonauth/redis/RedisKey.java @@ -1,18 +1,62 @@ package com.nhnacademy.insightonauth.redis; public enum RedisKey { + // 리프레시 토큰 (userId → jti). 재발급/로그아웃 시 검증에 사용 REFRESH("refresh:"), + + // 이메일 인증 최종 완료 토큰 (email → token, 15분). 회원가입 시 인증 여부 확인 VERIFIED("verified:"), + + // 이메일 인증 코드 (email → 6자리 코드, 5분) VERIFY("verify:"), + + // 이메일 인증 코드 입력 실패 횟수 (email → count, 5분). 5회 초과 시 잠금 VERIFY_FAIL("verify-fail:"), + + // 이메일 인증 실패 잠금 (email → "locked", 5분). 존재 시 인증 시도 차단 VERIFY_FAIL_LOCK("verify-fail-lock:"), + + // 인증 코드 재전송 연타 방지 쿨다운 (email, 예: 60초). 존재 시 재전송 거부 + VERIFY_RESEND_COOLDOWN("verify-resend-cooldown:"), + + // 인증 코드 재전송 누적 횟수 (email → count). 임계치 초과 시 잠금 + VERIFY_RESEND_COUNT("verify-resend-count:"), + + // 인증 코드 재전송 잠금 (email → "locked"). 존재 시 재전송 차단 + VERIFY_RESEND_LOCK("verify-resend-lock:"), + + // 로그인 실패 횟수 (email → count). 임계치 초과 시 잠금 LOGIN_FAIL("login-fail:"), + + // 로그인 실패 잠금 (email → "locked"). 존재 시 로그인 차단 LOGIN_LOCK("login-lock:"), + + // 비밀번호 재설정 토큰 (uuid → email, 10분). 재설정 경로 접근 시 검증 PASSWORD_RESET("password-reset:"), + + // 비밀번호 재설정 역방향 키 (email → uuid, 10분). 재전송 시 예전 토큰 무효화용 + PASSWORD_RESET_BY_EMAIL("password-reset-by-email:"), + + // 비밀번호 재설정 메일 재전송 연타 방지 (email, 예: 60초) + PASSWORD_RESET_RESEND_COOLDOWN("password-reset-resend-cooldown:"), + + // 비밀번호 재설정 메일 재전송 누적 횟수 (email → count) + PASSWORD_RESET_RESEND_COUNT("password-reset-resend-count:"), + + // 비밀번호 재설정 메일 재전송 잠금 (email → "locked") + PASSWORD_RESET_RESEND_LOCK("password-reset-resend-lock:"), + + // 휴면 계정 복구 관련 (reactive) REACTIVE("reactive:"), + + // 무효화된 액세스 토큰 블랙리스트 (로그아웃/차단 토큰) BLACKLIST("blacklist:"), + + // 탈퇴 계정 하드 삭제 스케줄러 분산 락 (Redisson RLock) HARD_DELETE_SCHEDULER_LOCK("scheduler-lock:hard-delete-users"), - SLEEP_CONVERSION_SCHEDULER_LOCK("scheduler-lock:sleep-conversion");; + + // 휴면 전환 스케줄러 분산 락 (Redisson RLock) + SLEEP_CONVERSION_SCHEDULER_LOCK("scheduler-lock:sleep-conversion"); private final String prefix; diff --git a/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java b/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java index 9c1bc1b..723af48 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/MyPageService.java @@ -17,4 +17,6 @@ public interface MyPageService { List findMyOauths(Long userId); void linkOauth(Long userId, String provider, String code); + + void mergeAccount(Long primaryUserId, Long secondaryUserId, String provider, String providerUserId); } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java index ac5ea1a..5c5cd10 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/MyPageServiceImpl.java @@ -6,10 +6,10 @@ import com.nhnacademy.insightonauth.dto.mypage.RoleResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthResponse; import com.nhnacademy.insightonauth.dto.oauth.OauthUserInfo; +import com.nhnacademy.insightonauth.entity.Oauth; import com.nhnacademy.insightonauth.entity.User; import com.nhnacademy.insightonauth.entity.UserCredential; -import com.nhnacademy.insightonauth.exception.InvalidCredentialsException; -import com.nhnacademy.insightonauth.exception.OauthAlreadyLinkedException; +import com.nhnacademy.insightonauth.exception.*; import com.nhnacademy.insightonauth.service.*; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @@ -19,6 +19,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Optional; @Service @Transactional @@ -75,17 +76,45 @@ public List findMyOauths(Long userId) { @Override public void linkOauth(Long userId, String provider, String code) { - User user = userService.findById(userId); // 이미 로그인된 그 사람 - + User primaryUser = userService.findById(userId); OauthClient oauthClient = oauthClientResolver.resolve(provider); - OauthUserInfo userInfo = oauthClient.getUserInfo(code); // Google 검증 + OauthUserInfo userInfo = oauthClient.getUserInfo(code); + + Optional conflictingOauth = oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()); + + if (conflictingOauth.isPresent()) { + User conflictingUser = conflictingOauth.get().getUser(); + + if (conflictingUser.getUserId().equals(primaryUser.getUserId())) { + throw new OauthAlreadyLinkedException("이미 연동된 소셜 계정입니다."); + } + + // 다른 사람 계정에 연동되어 있음 → "병합할지" 물어봐야 하는 상황 + throw new OauthLinkedToOtherAccountException( + "이 계정은 이미 다른 계정에 연동되어 있습니다. 병합하시려면 확인 후 다시 요청해주세요.", + conflictingUser.getUserId()); + } + + oauthService.create(primaryUser, provider, userInfo.providerId()); + } + + // 다른 계정 삭제하고 하나로 합치기 + @Override + public void mergeAccount(Long primaryUserId, Long secondaryUserId, String provider, String providerUserId) { + User primaryUser = userService.findById(primaryUserId); + + // 2차 확인 - secondaryUser가 정말 이 provider/providerUserId를 갖고 있는지 검증 + Oauth secondaryOauth = oauthService.findByProviderAndProviderUserId(provider, providerUserId) + .orElseThrow(() -> new OauthNotFoundException("연동 정보를 찾을 수 없습니다.")); + + if (!secondaryOauth.getUser().getUserId().equals(secondaryUserId)) { + throw new InvalidMergeRequestException("병합 요청이 유효하지 않습니다."); + } - // 이 소셜 계정이 이미 다른 사람 것인지 확인 (중요!) - oauthService.findByProviderAndProviderUserId(provider, userInfo.providerId()) - .ifPresent(existing -> { - throw new OauthAlreadyLinkedException("이미 연동된 소셜 계정입니다."); - }); + // Oauth를 primaryUser로 재연결 + secondaryOauth.reassignUser(primaryUser); - oauthService.create(user, provider, userInfo.providerId()); + // secondaryUser(연동 전에 사용하던 계정 삭제) 삭제 + userService.deleteUser(secondaryUserId); } } diff --git a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java index 3c0aa71..b3d7899 100644 --- a/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java +++ b/src/main/java/com/nhnacademy/insightonauth/service/impl/UserServiceImpl.java @@ -27,7 +27,6 @@ import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; -import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -78,12 +77,26 @@ public boolean checkEmailAvailable(String email) { @Override public void emailVerifyRequest(String email) { + // 1. 재전송 잠금 체크 (더 강한 제한 먼저) + if (redisService.hasKey(RedisKey.VERIFY_RESEND_LOCK.getPrefix() + email)) { + throw new VerificationResendLockedException("재전송 시도가 초과되어 잠겼습니다."); + } + // 2. 재전송 연타 방지 체크 + if (redisService.hasKey(RedisKey.VERIFY_RESEND_COOLDOWN.getPrefix() + email)) { + throw new VerificationResendTooSoonException("잠시 후 다시 시도해 주세요."); + } + + // 3. 발송 emailService.sendVerificationCode(email); + + // 4. 발송 후: 쿨다운 걸기 + 카운터 증가 + redisService.set(RedisKey.VERIFY_RESEND_COOLDOWN.getPrefix() + email, "1", Duration.ofSeconds(60)); + increaseResendCount(email); } @Override public String emailVerifyConfirm(String email, String code) { - return emailService.emailVerify(email, code); + return emailService.emailCodeVerify(email, code); } @Override @@ -154,7 +167,7 @@ public void reactivateRequest(String email) { @Override public UserLoginResponse reactivateConfirm(String email, String code) { - emailService.emailVerify(email, code); + emailService.emailCodeVerify(email, code); User user = userRepository.findByEmail(email) .orElseThrow(() -> new UserNotFoundException("유저를 찾을 수 없습니다.")); @@ -180,10 +193,22 @@ public UserLoginResponse reactive(String reactiveToken) { @Override public void passwordResetRequest(String email) { - // 탈퇴 계정은 메일이 나가지 않게 조정, 예외를 던지면 공격자가 계정 존재를 알 수 있음 + // 1. 연타 방지/잠금 체크 — 계정 여부와 무관하게 항상 + if (redisService.hasKey(RedisKey.PASSWORD_RESET_RESEND_LOCK.getPrefix() + email)) { + throw new PasswordResetResendLockedException("재전송 시도가 초과되어 잠겼습니다."); + } + if (redisService.hasKey(RedisKey.PASSWORD_RESET_RESEND_COOLDOWN.getPrefix() + email)) { + throw new PasswordResetResendTooSoonException("잠시 후 다시 시도해 주세요."); + } + + // 2. 연타 방지/카운터 기록 — 계정 여부와 무관하게 항상 (열거 방지 핵심) + redisService.set(RedisKey.PASSWORD_RESET_RESEND_COOLDOWN.getPrefix() + email, "1", Duration.ofSeconds(60)); + increasePasswordResetResendCount(email); + + // 3. 실제 메일 발송만 계정 있고 정상일 때 (없어도 예외 안 던짐) userRepository.findByEmail(email).ifPresent(user -> { if (user.getStatus() == Status.WITHDRAW) { - return; + return; // 탈퇴 계정: 메일만 안 보냄 (rate limit은 이미 걸림) } emailService.sendPasswordResetPath(email); }); @@ -454,4 +479,31 @@ private UserLoginResponse handleWithdrawnLogin(User user) { return UserLoginResponse.pendingRestore(restoreToken); } + + private void increaseResendCount(String email) { + String saved = redisService.get(RedisKey.VERIFY_RESEND_COUNT.getPrefix() + email); + int count = (saved == null || saved.isBlank()) ? 0 : Integer.parseInt(saved); + count++; + + if (count >= 5) { + redisService.delete(RedisKey.VERIFY_RESEND_COUNT.getPrefix() + email); + redisService.set(RedisKey.VERIFY_RESEND_LOCK.getPrefix() + email, "locked", Duration.ofMinutes(30)); + } else { + redisService.set(RedisKey.VERIFY_RESEND_COUNT.getPrefix() + email, + String.valueOf(count), Duration.ofMinutes(30)); + } + } + + private void increasePasswordResetResendCount(String email) { + String saved = redisService.get(RedisKey.PASSWORD_RESET_RESEND_COUNT.getPrefix() + email); + int count = (saved == null || saved.isBlank()) ? 0 : Integer.parseInt(saved); + count++; + if (count >= 5) { + redisService.delete(RedisKey.PASSWORD_RESET_RESEND_COUNT.getPrefix() + email); + redisService.set(RedisKey.PASSWORD_RESET_RESEND_LOCK.getPrefix() + email, "locked", Duration.ofMinutes(30)); + } else { + redisService.set(RedisKey.PASSWORD_RESET_RESEND_COUNT.getPrefix() + email, + String.valueOf(count), Duration.ofMinutes(30)); + } + } } diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java index 7b69b7f..0b4a6e7 100644 --- a/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java +++ b/src/test/java/com/nhnacademy/insightonauth/repository/OauthRepositoryTest.java @@ -13,7 +13,6 @@ import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; - @DataJpaTest class OauthRepositoryTest { @@ -23,19 +22,24 @@ class OauthRepositoryTest { @Autowired private UserRepository userRepository; - private User user; + private User user1; + private User user2; @BeforeEach void setUp() { - user = new User("test@test.com", "test", "01012345678"); - userRepository.save(user); - oauthRepository.save(new Oauth(user, "google", "google-provider-id-123")); + user1 = new User("test@test.com", "test", "01012345678"); + userRepository.save(user1); + oauthRepository.save(new Oauth(user1, "google", "google-provider-id-123")); + + user2 = new User("other@test.com", "other", "01099998888"); + userRepository.save(user2); + oauthRepository.save(new Oauth(user2, "google", "other-provider-id")); } @Test @DisplayName("user, provider로 연동 정보 조회") void findByUserAndProvider_returnsOauth() { - Optional found = oauthRepository.findByUserAndProvider(user, "google"); + Optional found = oauthRepository.findByUserAndProvider(user1, "google"); assertThat(found) .isPresent() @@ -45,9 +49,9 @@ void findByUserAndProvider_returnsOauth() { } @Test - @DisplayName("연동 없는 provider 조회 시 빈 값 반환") + @DisplayName("연동 없는 provider 조회") void findByUserAndProvider_whenNotExists_returnsEmpty() { - Optional found = oauthRepository.findByUserAndProvider(user, "github"); + Optional found = oauthRepository.findByUserAndProvider(user1, "github"); assertThat(found).isEmpty(); } @@ -61,11 +65,11 @@ void findByProviderAndProviderUserId_returnsOauth() { .isPresent() .get() .extracting(oauth -> oauth.getUser().getUserId()) - .isEqualTo(user.getUserId()); + .isEqualTo(user1.getUserId()); } @Test - @DisplayName("없는 providerUserId 조회 시 빈 값 반환") + @DisplayName("없는 providerUserId 조회") void findByProviderAndProviderUserId_whenNotExists_returnsEmpty() { Optional found = oauthRepository.findByProviderAndProviderUserId("google", "not-exist-id"); @@ -73,32 +77,33 @@ void findByProviderAndProviderUserId_whenNotExists_returnsEmpty() { } @Test - @DisplayName("user로 연동 목록 조회") - void findByUser_returnsOauthList() { - List result = oauthRepository.findByUser(user); + @DisplayName("user로 연동 목록 조회, 다른 사용자 제외 확인") + void findByUser_returnsOnlyOwnOauthList() { + List result = oauthRepository.findByUser(user1); assertThat(result).hasSize(1); assertThat(result.get(0).getProvider()).isEqualTo("google"); + assertThat(result.get(0).getUser().getUserId()).isEqualTo(user1.getUserId()); } @Test - @DisplayName("user로 연동 개수 조회") - void countByUser_returnsCount() { - oauthRepository.save(new Oauth(user, "github", "github-provider-id-456")); + @DisplayName("user로 연동 개수 조회, 다른 사용자 제외 확인") + void countByUser_excludesOtherUserOauths() { + oauthRepository.save(new Oauth(user1, "github", "github-provider-id-456")); - Long count = oauthRepository.countByUser(user); + Long count = oauthRepository.countByUser(user1); assertThat(count).isEqualTo(2L); } @Test - @DisplayName("user로 연동 정보 전체 삭제") - void deleteByUser_removesAllOauths() { - oauthRepository.save(new Oauth(user, "github", "github-provider-id-456")); + @DisplayName("user로 연동 정보 전체 삭제, 다른 사용자 유지 확인") + void deleteByUser_removesOnlyOwnOauths() { + oauthRepository.save(new Oauth(user1, "github", "github-provider-id-456")); - oauthRepository.deleteByUser(user); + oauthRepository.deleteByUser(user1); - List result = oauthRepository.findByUser(user); - assertThat(result).isEmpty(); + assertThat(oauthRepository.findByUser(user1)).isEmpty(); + assertThat(oauthRepository.findByUser(user2)).hasSize(1); } } diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java index 11e3820..f8566fd 100644 --- a/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserCredentialRepositoryTest.java @@ -20,19 +20,24 @@ class UserCredentialRepositoryTest { @Autowired private UserRepository userRepository; - private User user; + private User user1; + private User user2; @BeforeEach void setUp() { - user = new User("test@test.com", "test", "01012345678"); - userRepository.save(user); - userCredentialRepository.save(new UserCredential(user, "hashed-password")); + user1 = new User("test@test.com", "test", "01012345678"); + userRepository.save(user1); + userCredentialRepository.save(new UserCredential(user1, "hashed-password")); + + user2 = new User("other@test.com", "other", "01099998888"); + userRepository.save(user2); + userCredentialRepository.save(new UserCredential(user2, "other-hashed-password")); } @Test @DisplayName("user로 존재 여부 확인 - true") void existsByUser_returnsTrueWhenExists() { - boolean exists = userCredentialRepository.existsByUser(user); + boolean exists = userCredentialRepository.existsByUser(user1); assertThat(exists).isTrue(); } @@ -40,18 +45,18 @@ void existsByUser_returnsTrueWhenExists() { @Test @DisplayName("user로 존재 여부 확인 - false") void existsByUser_returnsFalseWhenNotExists() { - User otherUser = new User("other@test.com", "other", "01099998888"); - userRepository.save(otherUser); + User noCredentialUser = new User("nocred@test.com", "nocred", "01077776666"); + userRepository.save(noCredentialUser); - boolean exists = userCredentialRepository.existsByUser(otherUser); + boolean exists = userCredentialRepository.existsByUser(noCredentialUser); assertThat(exists).isFalse(); } @Test @DisplayName("user로 인증 정보 조회") - void findByUser_returnsUserCredential() { - Optional found = userCredentialRepository.findByUser(user); + void findByUser_returnsOwnUserCredential() { + Optional found = userCredentialRepository.findByUser(user1); assertThat(found) .isPresent() @@ -61,12 +66,24 @@ void findByUser_returnsUserCredential() { } @Test - @DisplayName("인증 정보 없는 user 조회 시 빈 값 반환") + @DisplayName("다른 사용자 인증 정보 조회") + void findByUser_returnsOtherUsersOwnCredential() { + Optional found = userCredentialRepository.findByUser(user2); + + assertThat(found) + .isPresent() + .get() + .extracting(UserCredential::getPasswordHash) + .isEqualTo("other-hashed-password"); + } + + @Test + @DisplayName("인증 정보 없는 user 조회") void findByUser_whenNotExists_returnsEmpty() { - User otherUser = new User("other@test.com", "other", "01099998888"); - userRepository.save(otherUser); + User noCredentialUser = new User("nocred@test.com", "nocred", "01077776666"); + userRepository.save(noCredentialUser); - Optional found = userCredentialRepository.findByUser(otherUser); + Optional found = userCredentialRepository.findByUser(noCredentialUser); assertThat(found).isEmpty(); } diff --git a/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java index c5ed64b..d8fb7c6 100644 --- a/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java +++ b/src/test/java/com/nhnacademy/insightonauth/repository/UserRoleRepositoryTest.java @@ -23,28 +23,43 @@ class UserRoleRepositoryTest { @Autowired private UserRepository userRepository; - private User user; + private User user1; + private User user2; @BeforeEach void setUp() { - user = new User("test@test.com", "test", "01012345678"); - userRepository.save(user); - userRoleRepository.save(new UserRole(user, Role.MEMBER)); + user1 = new User("test@test.com", "test", "01012345678"); + userRepository.save(user1); + userRoleRepository.save(new UserRole(user1, Role.MEMBER)); + + user2 = new User("other@test.com", "other", "01099998888"); + userRepository.save(user2); + userRoleRepository.save(new UserRole(user2, Role.MEMBER)); + userRoleRepository.save(new UserRole(user2, Role.ADMIN)); } @Test - @DisplayName("user로 권한 목록 조회") - void findByUser_returnsRoles() { - List result = userRoleRepository.findByUser(user); + @DisplayName("user로 권한 목록 조회, 다른 사용자 제외 확인") + void findByUser_returnsOnlyOwnRoles() { + List result = userRoleRepository.findByUser(user1); assertThat(result).hasSize(1); assertThat(result.get(0).getRole()).isEqualTo(Role.MEMBER); + assertThat(result.get(0).getUser().getUserId()).isEqualTo(user1.getUserId()); + } + + @Test + @DisplayName("다중 권한 사용자 목록 조회") + void findByUser_otherUserHasMultipleRoles() { + List result = userRoleRepository.findByUser(user2); + + assertThat(result).hasSize(2); } @Test @DisplayName("user, role로 존재 여부 확인 - true") void existsByUserAndRole_returnsTrueWhenExists() { - boolean exists = userRoleRepository.existsByUserAndRole(user, Role.MEMBER); + boolean exists = userRoleRepository.existsByUserAndRole(user1, Role.MEMBER); assertThat(exists).isTrue(); } @@ -52,7 +67,15 @@ void existsByUserAndRole_returnsTrueWhenExists() { @Test @DisplayName("user, role로 존재 여부 확인 - false") void existsByUserAndRole_returnsFalseWhenNotExists() { - boolean exists = userRoleRepository.existsByUserAndRole(user, Role.ADMIN); + boolean exists = userRoleRepository.existsByUserAndRole(user1, Role.ADMIN); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("다른 사용자 역할 미포함 확인") + void existsByUserAndRole_doesNotMixOtherUsersRole() { + boolean exists = userRoleRepository.existsByUserAndRole(user1, Role.ADMIN); assertThat(exists).isFalse(); } @@ -60,16 +83,19 @@ void existsByUserAndRole_returnsFalseWhenNotExists() { @Test @DisplayName("user, role로 권한 조회") void findByUserAndRole_returnsUserRole() { - Optional found = userRoleRepository.findByUserAndRole(user, Role.MEMBER); + Optional found = userRoleRepository.findByUserAndRole(user1, Role.MEMBER); - assertThat(found).isPresent(); - assertThat(found.get().getUser()).isEqualTo(user); + assertThat(found) + .isPresent() + .get() + .extracting(userRole -> userRole.getUser().getUserId()) + .isEqualTo(user1.getUserId()); } @Test - @DisplayName("없는 role로 조회 시 빈 값 반환") + @DisplayName("없는 role로 조회") void findByUserAndRole_whenNotExists_returnsEmpty() { - Optional found = userRoleRepository.findByUserAndRole(user, Role.ADMIN); + Optional found = userRoleRepository.findByUserAndRole(user1, Role.ADMIN); assertThat(found).isEmpty(); }