공공부하자개발 · 영어 학습 노트
자바
인증·로그인JWT · OAuth2 · 2FA 로 로그인 구현0/3 완료
  • 011단계: 비밀번호 해시와 JWT 로그인
  • 022단계: Access/Refresh 토큰과 OAuth2 인가 코드
  • 033단계: 2FA(TOTP) 와 로그인 흐름 통합
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 인증·로그인 › 01 / 3

1단계: 비밀번호 해시와 JWT 로그인

섹션 7진행 0 / 3
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

6. 연습 문제

문제 1

PasswordHasher 에 알고리즘 전환을 추가하세요. 저장 형식의 첫 토큰이 pbkdf2 면 지금처럼, sha256 이면 레거시(단순 SHA-256(salt+password))로 검증하되, 레거시로 로그인 성공하면 즉시 PBKDF2 로 재해시하도록 AuthServer.login 을 수정하세요. 레거시 사용자 1명을 미리 넣고 로그인 전후의 저장 문자열을 출력하세요.

정답 보기
java
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 입니다.

문제 2

aud(audience) 클레임을 추가하세요. 인증 서버가 aud: ["order-api", "member-api"] 로 토큰을 발급하고, 각 리소스 서버는 자기 이름이 aud 에 없으면 401 로 거부해야 합니다. AuthServer 에 생성자 인자로 audience 를 받아 검증에 추가하고, order-api 용 토큰으로 member-api 서버를 호출해 거부되는 것을 확인하세요.

정답 보기
java
// 발급: 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단계 레슨에서 다시 만납니다.

문제 3

계정 잠금을 시간 기반으로 바꾸세요. 5회 실패 시 영구 잠금 대신 15분 잠금(lockedUntil)으로 하고, 잠금 중 로그인 시도는 423 과 함께 남은 초를 응답하며, 잠금이 풀리면 실패 카운터가 리셋되어야 합니다. 또 잠금 정책이 서비스 거부 공격(남의 계정을 일부러 5번 틀리기)에 악용되는 것을 완화하는 방법을 두 가지 제시하세요.

정답 보기
java
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부터 다시)

서비스 거부 완화 두 가지입니다.

  • 잠금을 계정이 아니라 (계정, IP) 조합에 겁니다. 공격자 IP 에서의 시도만 막히고 정상 사용자는 자기 IP 에서 로그인할 수 있습니다.
  • 잠금 대신 점진적 지연을 씁니다. 3회 실패 후 1초, 4회 2초, 5회 4초처럼 응답을 늦추면 무차별 대입은 사실상 불가능해지고 정상 사용자는 몇 초만 기다리면 됩니다.

실무는 둘을 섞고, 여기에 CAPTCHA 와 "새 기기에서 로그인" 알림 메일을 얹습니다. 영구 잠금 + 관리자 해제는 고객센터 비용 때문에 은행권 외에는 잘 쓰지 않습니다.

연습 문제
  • 문제 1
  • 문제 2
  • 문제 3
이전 섹션5 자주 하는 실수 (Tip)6 / 7다음 섹션7 정리