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

- 로그인/비밀번호 재인증에 실패 기반 레이트리밋(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
parent 4a85902183
commit a9ff2387c8
4 changed files with 94 additions and 25 deletions
@@ -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")
@@ -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, "비밀번호가 올바르지 않습니다.");
}
}
@@ -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<Map<String, Object>> handleUnreadable(HttpMessageNotReadableException e) {
return ResponseEntity.badRequest().body(body(HttpStatus.BAD_REQUEST, "요청 본문이 올바르지 않습니다."));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleUnexpected(Exception e) {
log.error("[unhandled] {}", e.getMessage(), e);
public ResponseEntity<Map<String, Object>> 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, "서버 오류가 발생했습니다."));
}
@@ -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