공공부하자개발 · 영어 학습 노트
자바
인증·로그인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정리‹ 이전다음 ›

4. 응용 변형 예제

변형 1: 토큰에 넣을 것과 넣지 말 것

java
// ❌ 넣지 말 것
Map.of("password", …, "ssn", "900101-1234567", "cardNo", …)      // 페이로드는 평문. 로그·브라우저·프록시에 남는다
Map.of("permissions", 200개 문자열 목록)                            // 토큰 5KB → 모든 요청 헤더가 5KB. 8KB 제한에 걸림

// ✅ 넣을 것
Jwt.claims("auth-lesson", "kim", 900, Map.of(
    "roles", List.of("ROLE_USER"),           // 권한 그룹 수준. 세부 권한은 서버가 DB/캐시에서 조회
    "tenant", "company-A",                   // 멀티테넌트 격리 키
    "jti", UUID.randomUUID().toString()      // 토큰 고유 ID. 2단계에서 폐기 목록(블랙리스트)에 쓴다
));

토큰은 "누구인가"와 "무엇을 할 수 있는 큰 범주"만 담습니다. 상세 권한, 사용자 프로필, 설정은 토큰의 sub 로 서버가 조회합니다. 그래야 권한 변경이 토큰 재발급 없이 반영되고 토큰이 작게 유지됩니다.

변형 2: 키 회전 — 두 개의 키로 검증하기

java
// 검증자가 kid 별 키 맵을 갖는다. 새 키 추가 → 발급을 새 키로 전환 → 구 키 토큰이 모두 만료되면 구 키 제거
Map<String, byte[]> keys = Map.of("k1", oldSecret, "k2", newSecret);

public static Map<String, Object> verifyHs256Rotating(String token, Map<String, byte[]> keys) {
    Parsed t = parse(token);
    requireAlg(t, "HS256");
    String kid = String.valueOf(t.header().get("kid"));
    byte[] key = keys.get(kid);
    if (key == null) throw new JwtException("알 수 없는 kid: " + kid);   // 폐기된 키의 토큰은 여기서 거부된다
    if (!MessageDigest.isEqual(hmac(t.signingInput(), key), t.signature())) throw new JwtException("서명 불일치");
    checkTime(t.payload());
    return t.payload();
}

비밀키가 유출됐거나 정기 교체 시점이면 키를 바꿔야 하는데, 즉시 바꾸면 발급된 모든 토큰이 무효가 되어 전 사용자가 로그아웃됩니다. kid 를 두면 두 키가 공존하는 전환 기간을 둘 수 있습니다. 전환 기간은 Access 토큰 TTL 만큼이면 충분하고, 유출 대응이면 구 키를 즉시 제거해 강제 로그아웃시킵니다.

변형 3: 토큰을 어디에 두는가 — 헤더 vs 쿠키

저장 위치 XSS CSRF 특징
localStorage + Authorization 헤더 취약. 스크립트가 읽어 탈취 안전. 브라우저가 자동으로 안 붙임 SPA 에서 흔함. XSS 한 번이면 끝
HttpOnly; Secure; SameSite=Strict 쿠키 안전. 스크립트가 못 읽음 SameSite 로 대부분 방어. Lax 면 CSRF 토큰 병행 브라우저가 자동 전송. 서버는 헤더 대신 쿠키에서 꺼냄
java
// 쿠키 방식: 로그인 응답
ex.getResponseHeaders().add("Set-Cookie", "access=" + token + "; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=900");
// 보호 자원: Authorization 헤더 대신 쿠키에서
String token = cookies(ex).get("access");

이 레슨은 헤더 방식(API 서버·모바일 앱·서버 간 호출에 표준)을 썼습니다. 브라우저 SPA 라면 쿠키 방식이 XSS 에 더 강하고, 그때는 Spring Security 의 csrf().disable() 을 다시 생각해야 합니다. 2단계에서 Refresh 토큰을 쿠키에, Access 토큰을 메모리에 두는 조합을 다룹니다.

변형 4: 로그인 감사 로그와 이벤트

java
record LoginEvent(Instant at, String username, String ip, String result) {}     // SUCCESS, BAD_PASSWORD, UNKNOWN_USER, LOCKED
List<LoginEvent> audit = new CopyOnWriteArrayList<>();

// login 의 각 분기에서
audit.add(new LoginEvent(Instant.now(), username, ex.getRemoteAddress().getAddress().getHostAddress(), "BAD_PASSWORD"));

// 이상 징후: 같은 IP 에서 1분에 서로 다른 아이디 20개 이상 → 크리덴셜 스터핑
Map<String, Long> perIp = audit.stream().filter(e -> e.at().isAfter(Instant.now().minusSeconds(60)) && !e.result().equals("SUCCESS"))
        .collect(Collectors.groupingBy(LoginEvent::ip, Collectors.mapping(LoginEvent::username, Collectors.collectingAndThen(Collectors.toSet(), s -> (long) s.size()))));
perIp.forEach((ip, n) -> { if (n >= 20) System.out.println("차단 후보 IP " + ip + ": 1분에 " + n + "개 계정 시도"); });

계정별 잠금은 한 계정을 집중 공격하는 경우만 막습니다. 유출된 아이디·비밀번호 목록으로 수천 계정을 각 1회씩 시도하는 크리덴셜 스터핑은 IP·시간 단위 집계로만 보입니다. Spring Security 는 AuthenticationSuccessEvent / AbstractAuthenticationFailureEvent 를 발행하므로 @EventListener 하나로 이 감사 로그를 모을 수 있습니다.

응용 변형 예제
  • 변형 1: 토큰에 넣을 것과 넣지 말 것
  • 변형 2: 키 회전 — 두 개의 키로 검증하기
  • 변형 3: 토큰을 어디에 두는가 — 헤더 vs 쿠키
  • 변형 4: 로그인 감사 로그와 이벤트
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)