From a9ff2387c86d721ebc11a0f150d4565650692ad1 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 6 Jun 2026 15:03:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AC=B4=EC=B0=A8=EB=B3=84=20=EB=8C=80?= =?UTF-8?q?=EC=9E=85=20=EB=B0=A9=EC=A7=80(=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=C2=B7=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8)=20+=20=EB=AF=B8=EC=B2=98=EB=A6=AC=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EC=A0=81=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인/비밀번호 재인증에 실패 기반 레이트리밋(Redis 슬라이딩 윈도우) · 로그인 IP당 10회/10분, 비밀번호 게이트 회원당 5회/10분, 초과 시 429 · 성공 시 실패 카운터 초기화, Redis 장애 시 통과(가용성 우선) · 회원가입 레이트리밋도 공통 enforceRateLimit 으로 일원화 - GlobalExceptionHandler: 미처리 500 로그에 메서드+경로 기록(추적성), 잘못된 JSON은 400 - 테스트: 레이트리밋 429 케이스 2개 추가 (백엔드 총 34) Co-Authored-By: Claude Opus 4.8 --- .../web/auth/controller/AuthController.java | 4 +- .../com/sb/web/auth/service/AuthService.java | 64 +++++++++++++------ .../exception/GlobalExceptionHandler.java | 13 +++- .../sb/web/auth/service/AuthServiceTest.java | 38 ++++++++++- 4 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/sb/web/auth/controller/AuthController.java b/src/main/java/com/sb/web/auth/controller/AuthController.java index 206448a..048369b 100644 --- a/src/main/java/com/sb/web/auth/controller/AuthController.java +++ b/src/main/java/com/sb/web/auth/controller/AuthController.java @@ -52,8 +52,8 @@ public class AuthController { } @PostMapping("/login") - public LoginResponse login(@Valid @RequestBody LoginRequest req) { - return authService.login(req); + public LoginResponse login(@Valid @RequestBody LoginRequest req, HttpServletRequest request) { + return authService.login(req, clientIp(request)); } @PostMapping("/logout") diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 406c00c..7b9eefd 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -46,6 +46,12 @@ public class AuthService { private static final int SIGNUP_LIMIT = 5; private static final Duration SIGNUP_WINDOW = Duration.ofHours(1); + // 무차별 대입 방지 — 실패한 시도만 카운트 (정상 로그인은 영향 없음) + private static final int LOGIN_LIMIT = 10; + private static final Duration LOGIN_WINDOW = Duration.ofMinutes(10); + private static final int VERIFY_PW_LIMIT = 5; + private static final Duration VERIFY_PW_WINDOW = Duration.ofMinutes(10); + @Transactional public MemberResponse signup(SignupRequest req, String clientIp) { if (!appSettingService.isSignupEnabled()) { @@ -57,7 +63,10 @@ public class AuthService { throw new ApiException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); } // 2) IP 레이트리밋 - rateLimitSignup(clientIp); + if (clientIp != null && !clientIp.isBlank()) { + enforceRateLimit("signup:rl:" + clientIp, SIGNUP_LIMIT, SIGNUP_WINDOW, + "회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요."); + } if (memberMapper.countByLoginId(req.getLoginId()) > 0) { throw new ApiException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다."); } @@ -75,33 +84,47 @@ public class AuthService { return MemberResponse.from(member); } - /** IP당 가입 시도 횟수 제한 (Redis 슬라이딩 윈도우). 초과 시 429 */ - private void rateLimitSignup(String ip) { - if (ip == null || ip.isBlank()) { - return; - } - String key = "signup:rl:" + ip; - Long count = redisTemplate.opsForValue().increment(key); - if (count != null && count == 1L) { - redisTemplate.expire(key, SIGNUP_WINDOW); - } - if (count != null && count > SIGNUP_LIMIT) { - log.warn("[signup] 레이트리밋 차단 ip={} count={}", ip, count); - throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, - "회원가입 시도가 너무 많습니다. 잠시 후 다시 시도해주세요."); + /** + * Redis 슬라이딩 윈도우 레이트리밋. 윈도우 내 호출이 limit 초과면 429. + * Redis 장애 시에는 통과(가용성 우선) — 차단보다 서비스 가용성을 우선한다. + */ + private void enforceRateLimit(String key, int limit, Duration window, String message) { + try { + Long count = redisTemplate.opsForValue().increment(key); + if (count != null && count == 1L) { + redisTemplate.expire(key, window); + } + if (count != null && count > limit) { + log.warn("[ratelimit] 차단 key={} count={}", key, count); + throw new ApiException(HttpStatus.TOO_MANY_REQUESTS, message); + } + } catch (ApiException e) { + throw e; + } catch (Exception e) { + log.warn("[ratelimit] Redis 오류로 레이트리밋 생략: {}", e.toString()); } } - public LoginResponse login(LoginRequest req) { + public LoginResponse login(LoginRequest req, String clientIp) { Member member = memberMapper.findByLoginId(req.getLoginId()); - if (member == null - || member.getPassword() == null - || !passwordEncoder.matches(req.getPassword(), member.getPassword())) { + boolean passwordOk = member != null + && member.getPassword() != null + && passwordEncoder.matches(req.getPassword(), member.getPassword()); + if (!passwordOk) { + // 실패한 시도만 IP 단위로 카운트 — 무차별 대입 방지(정상 로그인엔 영향 없음) + if (clientIp != null && !clientIp.isBlank()) { + enforceRateLimit("login:rl:" + clientIp, LOGIN_LIMIT, LOGIN_WINDOW, + "로그인 시도가 너무 많습니다. 잠시 후 다시 시도해주세요."); + } throw new ApiException(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다."); } if (!"ACTIVE".equals(member.getStatus())) { throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다."); } + // 로그인 성공 — 실패 카운터 초기화 + if (clientIp != null && !clientIp.isBlank()) { + try { redisTemplate.delete("login:rl:" + clientIp); } catch (Exception ignore) { /* 무시 */ } + } Duration ttl = req.isRememberMe() ? REMEMBER_TTL : SESSION_TTL; SessionUser session = SessionUser.from(member); @@ -198,6 +221,9 @@ public class AuthService { throw new ApiException(HttpStatus.BAD_REQUEST, "소셜 로그인 계정은 비밀번호 인증을 사용할 수 없습니다."); } if (password == null || !passwordEncoder.matches(password, member.getPassword())) { + // 실패한 시도만 회원 단위로 카운트 — 비밀번호 게이트 무차별 대입 방지 + enforceRateLimit("verifypw:rl:" + memberId, VERIFY_PW_LIMIT, VERIFY_PW_WINDOW, + "비밀번호 시도가 너무 많습니다. 잠시 후 다시 시도해주세요."); throw new ApiException(HttpStatus.UNAUTHORIZED, "비밀번호가 올바르지 않습니다."); } } diff --git a/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java b/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java index b6b9024..b067abf 100644 --- a/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/sb/web/common/exception/GlobalExceptionHandler.java @@ -1,8 +1,10 @@ package com.sb.web.common.exception; +import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -32,9 +34,16 @@ public class GlobalExceptionHandler { return ResponseEntity.badRequest().body(body(HttpStatus.BAD_REQUEST, message)); } + /** 잘못된/누락된 JSON 본문 — 500 이 아니라 400 으로 응답 */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { + return ResponseEntity.badRequest().body(body(HttpStatus.BAD_REQUEST, "요청 본문이 올바르지 않습니다.")); + } + @ExceptionHandler(Exception.class) - public ResponseEntity> handleUnexpected(Exception e) { - log.error("[unhandled] {}", e.getMessage(), e); + public ResponseEntity> handleUnexpected(Exception e, HttpServletRequest req) { + // 어떤 엔드포인트에서 터졌는지 추적 가능하도록 메서드+경로를 함께 기록 (조용한 500 방지) + log.error("[unhandled] {} {} -> {}", req.getMethod(), req.getRequestURI(), e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(body(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다.")); } diff --git a/src/test/java/com/sb/web/auth/service/AuthServiceTest.java b/src/test/java/com/sb/web/auth/service/AuthServiceTest.java index c68d570..dcbfac8 100644 --- a/src/test/java/com/sb/web/auth/service/AuthServiceTest.java +++ b/src/test/java/com/sb/web/auth/service/AuthServiceTest.java @@ -78,6 +78,8 @@ class AuthServiceTest { void verifyPassword_wrong() { when(memberMapper.findById(1L)).thenReturn(localMember()); when(passwordEncoder.matches("bad", "hash")).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOps); // 실패 카운트(레이트리밋) + when(valueOps.increment(anyString())).thenReturn(1L); assertThatThrownBy(() -> service.verifyPassword(1L, "bad")) .isInstanceOf(ApiException.class) @@ -204,12 +206,14 @@ class AuthServiceTest { void login_wrongPassword() { when(memberMapper.findByLoginId("u1")).thenReturn(localMember()); when(passwordEncoder.matches("bad", "hash")).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOps); // 실패 카운트(레이트리밋) + when(valueOps.increment(anyString())).thenReturn(1L); LoginRequest req = new LoginRequest(); req.setLoginId("u1"); req.setPassword("bad"); - assertThatThrownBy(() -> service.login(req)) + assertThatThrownBy(() -> service.login(req, "1.2.3.4")) .isInstanceOf(ApiException.class) .extracting("status").isEqualTo(HttpStatus.UNAUTHORIZED); } @@ -226,13 +230,43 @@ class AuthServiceTest { req.setPassword("pw"); req.setRememberMe(true); - LoginResponse res = service.login(req); + LoginResponse res = service.login(req, "1.2.3.4"); assertThat(res.getToken()).isNotBlank(); verify(valueOps).set(anyString(), any(), any()); // Redis 저장 verify(authSessionMapper).insert(any(AuthSession.class)); // DB 백업 } + @Test + @DisplayName("login: 실패 누적이 한도 초과면 429") + void login_rateLimited() { + when(memberMapper.findByLoginId("u1")).thenReturn(localMember()); + when(passwordEncoder.matches("bad", "hash")).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.increment(anyString())).thenReturn(11L); // LOGIN_LIMIT(10) 초과 + + LoginRequest req = new LoginRequest(); + req.setLoginId("u1"); + req.setPassword("bad"); + + assertThatThrownBy(() -> service.login(req, "1.2.3.4")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + } + + @Test + @DisplayName("verifyPassword: 실패 누적이 한도 초과면 429") + void verifyPassword_rateLimited() { + when(memberMapper.findById(1L)).thenReturn(localMember()); + when(passwordEncoder.matches("bad", "hash")).thenReturn(false); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + when(valueOps.increment(anyString())).thenReturn(6L); // VERIFY_PW_LIMIT(5) 초과 + + assertThatThrownBy(() -> service.verifyPassword(1L, "bad")) + .isInstanceOf(ApiException.class) + .extracting("status").isEqualTo(HttpStatus.TOO_MANY_REQUESTS); + } + // ===== getSession (DB 백업 복원) ===== @Test