feat: 무차별 대입 방지(로그인·비밀번호 게이트) + 미처리 예외 추적 강화

- 로그인/비밀번호 재인증에 실패 기반 레이트리밋(Redis 슬라이딩 윈도우)
  · 로그인 IP당 10회/10분, 비밀번호 게이트 회원당 5회/10분, 초과 시 429
  · 성공 시 실패 카운터 초기화, Redis 장애 시 통과(가용성 우선)
  · 회원가입 레이트리밋도 공통 enforceRateLimit 으로 일원화
- GlobalExceptionHandler: 미처리 500 로그에 메서드+경로 기록(추적성), 잘못된 JSON은 400
- 테스트: 레이트리밋 429 케이스 2개 추가 (백엔드 총 34)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-06 15:03:53 +09:00
co-authored by Claude Opus 4.8
parent 4a85902183
commit a9ff2387c8
4 changed files with 94 additions and 25 deletions
@@ -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