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:
parent
a52999d81d
commit
a2eb5e587a
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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('로그인 중 문제가 생겼어요. 잠시 후 다시 시도해 주세요.');
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user