PasswordHasher 에 알고리즘 전환을 추가하세요. 저장 형식의 첫 토큰이 pbkdf2 면 지금처럼, sha256 이면 레거시(단순 SHA-256(salt+password))로 검증하되, 레거시로 로그인 성공하면 즉시 PBKDF2 로 재해시하도록 AuthServer.login 을 수정하세요. 레거시 사용자 1명을 미리 넣고 로그인 전후의 저장 문자열을 출력하세요.
public static boolean verify(char[] password, String stored) {
String[] p = stored.split("\\$");
return switch (p[0]) {
case "pbkdf2" -> {
byte[] actual = pbkdf2(password, Base64.getDecoder().decode(p[2]), Integer.parseInt(p[1]));
yield MessageDigest.isEqual(Base64.getDecoder().decode(p[3]), actual);
}
case "sha256" -> { // 레거시: sha256$salt$hash
byte[] salt = Base64.getDecoder().decode(p[1]);
byte[] actual = sha256(concat(salt, new String(password).getBytes(StandardCharsets.UTF_8)));
yield MessageDigest.isEqual(Base64.getDecoder().decode(p[2]), actual);
}
default -> false;
};
}
public boolean needsRehash(String stored) {
String[] p = stored.split("\\$");
return !p[0].equals("pbkdf2") || Integer.parseInt(p[1]) < iterations; // 알고리즘이 다르거나 반복이 낮으면
}
// AuthServer.login 은 이미 needsRehash → 재해시 로직이 있으므로 변경 없음. 레거시 사용자 삽입:
String legacy = "sha256$" + b64(salt) + "$" + b64(sha256(concat(salt, "Old-Pass-2020".getBytes())));
users.add("legacy", legacy, List.of("ROLE_USER"));
System.out.println("로그인 전: " + users.find("legacy").get().passwordHash());
// POST /login {"username":"legacy","password":"Old-Pass-2020"} → 200
System.out.println("로그인 후: " + users.find("legacy").get().passwordHash());
// 출력:
// 로그인 전: sha256$q3Xk…$8fA2…
// 로그인 후: pbkdf2$210000$Lm9p…$Rt4v…비밀번호 해시는 사용자가 로그인할 때만 평문을 볼 수 있으므로, 알고리즘 교체는 "로그인 성공 직후 재해시"로만 가능합니다. 오래 접속하지 않은 사용자는 영원히 레거시로 남으므로 일정 기간 후 레거시 계정에 비밀번호 재설정을 강제하는 정책을 함께 둡니다. Spring 의 DelegatingPasswordEncoder.upgradeEncoding() 이 정확히 needsRehash 입니다.
aud(audience) 클레임을 추가하세요. 인증 서버가 aud: ["order-api", "member-api"] 로 토큰을 발급하고, 각 리소스 서버는 자기 이름이 aud 에 없으면 401 로 거부해야 합니다. AuthServer 에 생성자 인자로 audience 를 받아 검증에 추가하고, order-api 용 토큰으로 member-api 서버를 호출해 거부되는 것을 확인하세요.
// 발급: Jwt.claims 의 extra 에
Map.of("roles", u.roles(), "aud", List.of("order-api", "member-api"))
// 검증: AuthServer 필드 private final String audience; authenticated() 안에서
claims = Jwt.verifyHs256(auth.substring(7), jwtSecret);
if (!ISSUER.equals(claims.get("iss"))) throw new Jwt.JwtException("iss 불일치");
if (!(claims.get("aud") instanceof List<?> aud && aud.contains(audience)))
throw new Jwt.JwtException("aud 에 " + audience + " 없음");
// 시나리오: 같은 비밀키를 쓰는 서버 두 대
AuthServer orderApi = new AuthServer(0, hasher, secret, 300, "order-api");
AuthServer memberApi = new AuthServer(0, hasher, secret, 300, "member-api");
// order-api 에서 로그인 → aud=[order-api] 만 담은 토큰 (발급 시 자기 이름만 넣도록 바꾼 경우)
String t = login(orderApi, "kim", "Kim-Pass-2026");
call(memberApi, "/me", t);
// 출력:
// GET /me [Bearer] → 401 {"error":"유효하지 않은 토큰: aud 에 member-api 없음"}aud 는 "이 토큰은 어느 서비스용인가"입니다. 같은 인증 서버가 여러 API 에 토큰을 발급할 때, A 서비스용 토큰을 B 에 재사용하는 공격(토큰 재전송)을 막습니다. 마이크로서비스에서 특히 중요하고, OAuth2 의 Access 토큰은 aud 가 리소스 서버 식별자인 것이 표준입니다. 2단계 레슨에서 다시 만납니다.
계정 잠금을 시간 기반으로 바꾸세요. 5회 실패 시 영구 잠금 대신 15분 잠금(lockedUntil)으로 하고, 잠금 중 로그인 시도는 423 과 함께 남은 초를 응답하며, 잠금이 풀리면 실패 카운터가 리셋되어야 합니다. 또 잠금 정책이 서비스 거부 공격(남의 계정을 일부러 5번 틀리기)에 악용되는 것을 완화하는 방법을 두 가지 제시하세요.
public record User(String username, String passwordHash, List<String> roles, int failedLogins, Instant lockedUntil) {
static final int MAX_FAILS = 5;
static final Duration LOCK = Duration.ofMinutes(15);
boolean isLocked(Instant now) { return lockedUntil != null && now.isBefore(lockedUntil); }
long lockRemainingSeconds(Instant now) { return isLocked(now) ? Duration.between(now, lockedUntil).toSeconds() : 0; }
User failed(Instant now) {
int f = (lockedUntil != null && now.isAfter(lockedUntil)) ? 1 : failedLogins + 1; // 잠금이 풀린 뒤 첫 실패면 1부터
return new User(username, passwordHash, roles, f, f >= MAX_FAILS ? now.plus(LOCK) : null);
}
User succeeded() { return new User(username, passwordHash, roles, 0, null); }
}
// login 에서
Instant now = Instant.now();
if (u.isLocked(now)) { json(ex, 423, Map.of("error", "계정 잠금", "retryAfterSeconds", u.lockRemainingSeconds(now))); return; }
// 출력:
// POST /login (6번째 시도) → 423 {"error":"계정 잠금","retryAfterSeconds":899}
// … 15분 후 → 정답이면 200, 오답이면 401 (카운터 1부터 다시)서비스 거부 완화 두 가지입니다.
실무는 둘을 섞고, 여기에 CAPTCHA 와 "새 기기에서 로그인" 알림 메일을 얹습니다. 영구 잠금 + 관리자 해제는 고객센터 비용 때문에 은행권 외에는 잘 쓰지 않습니다.