feat(security): 로그인 무차별 대입 방어 — 5회 실패 시 5분 잠금

- LoginAttemptService: (아이디+IP) 단위 인메모리 실패 카운트
  · 아이디 단독 키 → 잠금 악용 DoS, IP 단독 키 → 공용 IP 연대 잠금. 둘을 묶어 해결
  · Clock 주입으로 '5분 후 해제'를 가짜 시계로 테스트 (ClockConfig 추가)
  · 5회 도달 시 WARN 로그(보안 사건 추적), 맵 청소로 메모리 누수 방지
- AuthController: 검증 '전에' 잠금 확인(잠금 중엔 정답 확인 기회도 안 줌) → 429
  · X-Forwarded-For에서 클라이언트 IP 추출 (Caddy 뒤 전용 — 신뢰 조건 주석화)
- LoginPage: 429 시 '5분 뒤 다시 시도' 안내
- 테스트 5개 추가 → 전체 22개 통과

운영 검증: 5회 실패 → 401, 6회째 → 429 확인. 다른 IP·계정은 영향 없음.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 10:17:37 +09:00
parent a52999d81d
commit a2eb5e587a
5 changed files with 288 additions and 1 deletions

View File

@ -0,0 +1,24 @@
package dev.awesomedev.mirim.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
/**
* 파일이 하는 :
* "지금 몇 시인가" 알려주는 Clock을 스프링 빈으로 등록한다.
*
* 학습 포인트: 시간을 쓰는 코드(LoginAttemptService의 잠금 만료 ) Instant.now()
* 직접 부르면 테스트에서 시간을 조작할 없다. Clock을 주입받게 두면
* 운영에선 진짜 시계가, 테스트에선 가짜 시계(Clock.fixed) 들어간다.
* 같은 이유로 랜덤(Random)이나 외부 API도 직접 부르지 않고 주입받는 정석이다.
*/
@Configuration
public class ClockConfig {
@Bean
public Clock clock() {
return Clock.systemUTC();
}
}

View File

@ -0,0 +1,113 @@
package dev.awesomedev.mirim.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 파일이 하는 :
* 로그인 실패를 세다가, 너무 많이 틀리면 잠시 잠근다 무차별 대입(brute force) 방어.
*
* 규칙: 같은 (아이디+IP)에서 5회 연속 실패하면 5분간 로그인 시도를 막는다.
*
* 학습 포인트 "아이디+IP" 묶어서 셀까?
* 아이디로만 세면: 공격자가 일부러 mentor1을 5번 틀려서 멘토를 잠가버릴 있다(잠금 악용 DoS).
* IP로만 세면: 학교/회사처럼 여럿이 IP를 쓰는 곳에서 명의 실수로 모두가 잠긴다.
* 둘을 묶으면: 공격자는 막히고, 진짜 사용자는 자기 자리에서 멀쩡히 로그인할 있다.
*
* 학습 포인트 DB가 아니라 메모리(Map) 저장할까?
* 기록은 (1) 5분이면 쓸모없어지고 (2) 유실돼도 피해가 없다(다시 세면 그만).
* 백엔드가 1대뿐인 지금 규모에선 메모리가 가장 단순하고 빠르다.
* 서버가 여러 대가 되면 그때 Redis 같은 공유 저장소로 옮긴다 규모가 설계를 정한다.
*
* 학습 포인트 시간을 Clock으로 주입받는 이유.
* Instant.now() 직접 쓰면 "5분 지나면 풀린다" 테스트하려고 5분을 기다려야 한다.
* Clock을 주입받으면 테스트에서 가짜 시계를 넣어 시간을 마음대로 돌릴 있다.
* "테스트하기 어려운 것(시간, 랜덤, 외부 API)은 밖에서 주입받는다" 테스트 설계의 기본기.
*/
@Service
public class LoginAttemptService {
private static final Logger log = LoggerFactory.getLogger(LoginAttemptService.class);
/** 몇 번 틀리면 잠글까 */
static final int MAX_FAILURES = 5;
/** 얼마나 잠글까 */
static final Duration LOCK_DURATION = Duration.ofMinutes(5);
/** 마지막 실패 후 이 시간이 지나면 실패 횟수를 처음부터 다시 센다 */
static final Duration FAILURE_WINDOW = Duration.ofMinutes(5);
private final Clock clock;
// ConcurrentHashMap: 여러 요청(스레드) 동시에 만져도 안전한 Map.
private final Map<String, Attempt> attempts = new ConcurrentHashMap<>();
/** 실패 기록 한 건: 몇 번 틀렸고, 마지막으로 언제 틀렸나 */
private record Attempt(int failures, Instant lastFailureAt) {
}
public LoginAttemptService(Clock clock) {
this.clock = clock;
}
/** 이 (아이디+IP)가 지금 잠겨 있는가? 로그인 검증 "전에" 먼저 물어봐야 한다. */
public boolean isLocked(String username, String ip) {
Attempt attempt = attempts.get(key(username, ip));
if (attempt == null || attempt.failures() < MAX_FAILURES) {
return false;
}
// 잠금 시간이 지났으면 기록을 지우고 통과시킨다.
if (expired(attempt)) {
attempts.remove(key(username, ip));
return false;
}
return true;
}
/** 로그인 실패를 기록한다. 한도에 도달하면 이후 isLocked()가 true가 된다. */
public void recordFailure(String username, String ip) {
Instant now = clock.instant();
attempts.compute(key(username, ip), (k, prev) -> {
// 오래된 실패 기록은 이어서 세지 않고 1부터 다시 센다.
int count = (prev == null || expired(prev)) ? 1 : prev.failures() + 1;
if (count == MAX_FAILURES) {
// 학습 포인트: 잠금은 보안 "사건"이다 로그를 남겨야 나중에 공격 여부를 추적할 있다.
// , 비밀번호는 절대 로그에 남기지 않는다.
log.warn("로그인 {}회 연속 실패로 잠금: username={}, ip={}", MAX_FAILURES, username, ip);
}
return new Attempt(count, now);
});
purgeIfLarge();
}
/** 로그인에 성공하면 실패 기록을 지운다 — 어제 두 번 틀린 것까지 기억할 필요는 없다. */
public void reset(String username, String ip) {
attempts.remove(key(username, ip));
}
private String key(String username, String ip) {
// 대소문자만 바꿔 다시 시도하는 우회를 막기 위해 아이디는 소문자로 통일한다.
return username.toLowerCase() + "|" + ip;
}
private boolean expired(Attempt attempt) {
Duration limit = attempt.failures() >= MAX_FAILURES ? LOCK_DURATION : FAILURE_WINDOW;
return clock.instant().isAfter(attempt.lastFailureAt().plus(limit));
}
/**
* 학습 포인트: 메모리에 쌓기만 하는 자료구조는 언젠가 메모리를 먹는다(누수).
* 공격자가 아이디를 바꿔가며 수만 시도하면 Map이 그만큼 커지므로,
* 일정 크기를 넘으면 만료된 기록을 청소한다. (우리 규모에선 사실상 돌지만, 습관이다.)
*/
private void purgeIfLarge() {
if (attempts.size() > 10_000) {
attempts.entrySet().removeIf(e -> expired(e.getValue()));
}
}
}

View File

@ -2,6 +2,7 @@ package dev.awesomedev.mirim.web;
import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.web.dto.FindIdRequest;
import dev.awesomedev.mirim.web.dto.FindIdResponse;
import dev.awesomedev.mirim.web.dto.LoginRequest;
@ -41,11 +42,14 @@ import java.util.List;
public class AuthController {
private final AuthService authService;
private final LoginAttemptService loginAttemptService;
private final SecurityContextRepository securityContextRepository;
public AuthController(AuthService authService,
LoginAttemptService loginAttemptService,
SecurityContextRepository securityContextRepository) {
this.authService = authService;
this.loginAttemptService = loginAttemptService;
this.securityContextRepository = securityContextRepository;
}
@ -54,8 +58,26 @@ public class AuthController {
public UserResponse login(@RequestBody LoginRequest loginRequest,
HttpServletRequest request,
HttpServletResponse response) {
// 0) 무차별 대입 방어 비밀번호를 검증하기 "전에" 잠금부터 확인한다.
// 잠긴 상태에서는 맞는 비밀번호를 넣어도 검증 자체를 하지 않는다.
// ( 그러면 공격자가 잠금 중에도 계속 정답 후보를 확인해 있다.)
String clientIp = clientIp(request);
if (loginAttemptService.isLocked(loginRequest.username(), clientIp)) {
throw new org.springframework.web.server.ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS, "로그인 시도가 너무 많습니다. 5분 뒤에 다시 시도해 주세요.");
}
// 1) 아이디·비밀번호 검증 (틀리면 AuthService가 401을 던진다)
User user = authService.authenticate(loginRequest.username(), loginRequest.password());
User user;
try {
user = authService.authenticate(loginRequest.username(), loginRequest.password());
} catch (org.springframework.web.server.ResponseStatusException e) {
// 실패를 기록하고 원래 예외(401) 그대로 다시 던진다.
loginAttemptService.recordFailure(loginRequest.username(), clientIp);
throw e;
}
// 성공하면 실패 기록을 지운다 실수 했다고 다음 실수까지 이어서 세지 않는다.
loginAttemptService.reset(loginRequest.username(), clientIp);
// 1-1) 세션 ID 재발급 세션 고정(session fixation) 공격 방어.
// 학습 포인트: 공격자가 자기 세션 ID를 피해자 브라우저에 미리 심어 ,
@ -150,4 +172,24 @@ public class AuthController {
changePasswordRequest.newPassword());
// 반환할 내용이 없으므로 본문 없이 200 OK만 내려간다.
}
/**
* 요청을 보낸 클라이언트의 IP를 알아낸다 (로그인 잠금의 키로 쓴다).
*
* 학습 포인트: 우리 백엔드 앞에는 Caddy(리버스 프록시) 있다. 그래서
* request.getRemoteAddr() 항상 "Caddy의 IP" 이걸 키로 쓰면 세계 사용자가
* 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
*
* 헤더를 믿어도 되는 조건: 백엔드가 오직 Caddy를 통해서만 접근 가능할 때다
* (우리 구성이 그렇다 backend 컨테이너는 외부 포트를 열지 않는다).
* 백엔드가 인터넷에 직접 노출돼 있다면 누구나 헤더를 위조할 있으므로 믿으면 된다.
*/
private String clientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
// 프록시를 여러 거치면 "원래IP, 프록시1, 프록시2"처럼 쌓인다 앞이 클라이언트.
return forwarded.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}

View File

@ -0,0 +1,105 @@
package dev.awesomedev.mirim.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import static org.junit.jupiter.api.Assertions.*;
/**
* 파일이 하는 :
* 로그인 잠금(무차별 대입 방어) 규칙을 검증한다.
*
* 학습 포인트 가짜 시계(MutableClock) 시간을 돌린다.
* "5분 지나면 풀린다" 검증하려고 진짜 5분을 기다릴 수는 없다.
* LoginAttemptService가 Clock을 주입받게 설계했기 때문에,
* 테스트에서는 우리가 바늘을 마음대로 움직이는 시계를 꽂아 넣을 있다.
*/
class LoginAttemptServiceTest {
/** 테스트용 시계: advance()를 부르면 그만큼 시간이 "지나간다". */
static class MutableClock extends Clock {
private Instant now = Instant.parse("2026-09-01T09:00:00Z");
void advance(Duration duration) {
now = now.plus(duration);
}
@Override
public Instant instant() {
return now;
}
@Override
public java.time.ZoneId getZone() {
return ZoneOffset.UTC;
}
@Override
public Clock withZone(java.time.ZoneId zone) {
return this;
}
}
private final MutableClock clock = new MutableClock();
private final LoginAttemptService service = new LoginAttemptService(clock);
@Test
@DisplayName("5회 실패하면 잠기고, 4회까지는 잠기지 않는다")
void locks_after_five_failures() {
for (int i = 0; i < 4; i++) {
service.recordFailure("mentor1", "1.2.3.4");
}
assertFalse(service.isLocked("mentor1", "1.2.3.4")); // 4회까지는 통과
service.recordFailure("mentor1", "1.2.3.4"); // 5회째
assertTrue(service.isLocked("mentor1", "1.2.3.4")); // 잠김
}
@Test
@DisplayName("잠겨도 다른 IP의 같은 아이디는 로그인할 수 있다 (잠금 악용 DoS 방지)")
void lock_is_per_ip() {
for (int i = 0; i < 5; i++) {
service.recordFailure("mentor1", "6.6.6.6"); // 공격자 IP
}
assertTrue(service.isLocked("mentor1", "6.6.6.6"));
assertFalse(service.isLocked("mentor1", "1.2.3.4")); // 진짜 멘토의 IP는 멀쩡
}
@Test
@DisplayName("잠금은 5분이 지나면 풀린다")
void lock_expires_after_five_minutes() {
for (int i = 0; i < 5; i++) {
service.recordFailure("mentor1", "1.2.3.4");
}
assertTrue(service.isLocked("mentor1", "1.2.3.4"));
clock.advance(Duration.ofMinutes(5).plusSeconds(1)); // 시간을 돌린다!
assertFalse(service.isLocked("mentor1", "1.2.3.4"));
}
@Test
@DisplayName("로그인에 성공하면 실패 횟수가 초기화된다")
void success_resets_failures() {
for (int i = 0; i < 4; i++) {
service.recordFailure("mentor1", "1.2.3.4");
}
service.reset("mentor1", "1.2.3.4"); // 로그인 성공
service.recordFailure("mentor1", "1.2.3.4"); // 다시 1회부터
assertFalse(service.isLocked("mentor1", "1.2.3.4"));
}
@Test
@DisplayName("아이디 대소문자를 바꿔 시도해도 같은 기록으로 센다 (우회 방지)")
void username_is_case_insensitive() {
for (int i = 0; i < 5; i++) {
service.recordFailure("Mentor1", "1.2.3.4");
}
assertTrue(service.isLocked("MENTOR1", "1.2.3.4"));
}
}

View File

@ -28,6 +28,9 @@ export default function LoginPage() {
} catch (err) {
if (err.response?.status === 401) {
setError('아이디 또는 비밀번호가 맞지 않아요. 다시 확인해 주세요.');
} else if (err.response?.status === 429) {
// ( ) LoginAttemptService
setError('로그인 시도가 너무 많아요. 5분 뒤에 다시 시도해 주세요.');
} else {
setError('로그인 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
}