- 로그인/비밀번호 재인증에 실패 기반 레이트리밋(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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user