실행 방법입니다. 외부 jar 는 필요 없습니다. 예제 5 에 만료 시연을 위한 35초 대기가 있습니다.
cd java-src\auth\01_jwt_login
C:\project\jdk-21.0.8\bin\javac -encoding UTF-8 *.java
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main # 5개 섹션 자동 실행
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main --serve # 8080 에 서버만. curl 로 직접 호출PasswordHasher weak = new PasswordHasher(1_000), strong = new PasswordHasher(210_000);
char[] pw = "Correct-Horse-9".toCharArray();
String h1 = strong.hash(pw), h2 = strong.hash(pw);
System.out.println("같은 비밀번호, 다른 해시 (salt 가 다름):\n " + h1 + "\n " + h2);
System.out.println("verify(정답) = " + PasswordHasher.verify(pw, h1) + ", verify(오답) = " + PasswordHasher.verify("wrong".toCharArray(), h1));
for (PasswordHasher h : List.of(weak, strong)) {
long t0 = System.nanoTime(); String s = h.hash(pw); long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.printf("반복 %,7d회: %3d ms → 무차별 대입 1억 회 시도에 약 %,d 일%n", Integer.parseInt(s.split("\\$")[1]), ms, ms * 100_000_000L / 86_400_000L);
}
System.out.println("정책 강화 후 needsRehash(구 해시) = " + strong.needsRehash(weak.hash(pw)));
// 출력:
// 같은 비밀번호, 다른 해시 (salt 가 다름):
// pbkdf2$210000$j154VOn+eUEOmVY0ebeebA$qYMxGsPObrXSTnBgitbCN1z3UEe+DphASnyLbX75Le4
// pbkdf2$210000$/0VceORDS+au07lLbWFW1g$96F0FNUmpTZPB9lhUIUztJpTersYcCizKHmmU3pkr8w
// verify(정답) = true, verify(오답) = false
// 반복 1,000회: 26 ms → 무차별 대입 1억 회 시도에 약 30 일
// 반복 210,000회: 1227 ms → 무차별 대입 1억 회 시도에 약 1,420 일
// 정책 강화 후 needsRehash(구 해시) = true → 다음 로그인 성공 시 재해시같은 비밀번호에서 salt 부분(j154… 와 /0Vc…)이 다르고 해시도 완전히 다릅니다. DB 에서 해시가 같은 두 사용자를 찾아 "같은 비밀번호구나" 하는 것이 불가능합니다.
반복 횟수 210배에 시간은 약 47배입니다. 사용자는 로그인에 1초를 기다리지만 공격자는 1억 회에 4년입니다. 첫 번째 줄의 소요 시간은 JIT 워밍업 때문에 실제보다 크게 나옵니다.
비밀번호를 String 이 아닌 char[] 로 받는 이유는 사용 후 Arrays.fill(pw, '\0') 로 메모리에서 지울 수 있기 때문입니다. String 은 불변이라 GC 전까지 남습니다.
Map<String, Object> claims = Jwt.claims(AuthServer.ISSUER, "kim", 3600, Map.of("roles", List.of("ROLE_USER"), "dept", "AT"));
String token = Jwt.hs256(claims, secret);
Jwt.Parsed p = Jwt.parse(token); // 검증 없이 내용만
System.out.println("header = " + p.header());
System.out.println("payload = " + p.payload());
Map<String, Object> verified = Jwt.verifyHs256(token, secret);
// 출력:
// 토큰 (216자):
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWxlc3NvbiIsInN1YiI6ImtpbSIsImlhdCI6MTc4ODkyNzM0OSwiZXhwIjoxNzg4OTMwOTQ5LCJkZXB0IjoiQVQiLCJyb2xlcyI6WyJST0xFX1VTRVIiXX0.MTZ5ElrVIdW1fQu1oMdPlym6YRHKKEuIizgLwzUu4kU
// header = {alg=HS256, typ=JWT}
// payload = {iss=auth-lesson, sub=kim, iat=1788927349, exp=1788930949, dept=AT, roles=[ROLE_USER]} ← Base64Url 을 풀면 누구나 읽는다. 비밀은 넣지 않는다
// 서명 32 bytes (HMAC-SHA256)
// 검증 통과 → sub=kim, roles=[ROLE_USER], 남은 유효시간 3600초토큰의 첫 부분 eyJhbGciOi… 는 {"alg":… 의 Base64Url 이라 모든 JWT 가 eyJ 로 시작합니다. 로그에 eyJ 로 시작하는 문자열이 찍히면 토큰이 새고 있는 것입니다.
Jwt.parse 는 서명을 보지 않고 내용만 꺼냅니다. 클라이언트가 "내 토큰의 만료 시각" 을 표시할 때 쓰고, 서버는 반드시 verify 를 거칩니다.
토큰 216자 중 서명은 43자(32 bytes)이고 나머지는 클레임입니다. 클레임을 늘릴수록 모든 요청 헤더가 커지고, 권한 목록이 수십 개면 토큰이 수 KB 가 되어 헤더 크기 제한(보통 8KB)에 걸립니다.
KeyPair kp = KeyPairGenerator.getInstance("RSA").generateKeyPair(); // 2048 비트
String token = Jwt.rs256(Jwt.claims(ISSUER, "lee", 300, Map.of("roles", List.of("ROLE_USER"))), kp.getPrivate(), "2026-09-key1");
System.out.println("공개키로 검증 → " + Jwt.verifyRs256(token, kp.getPublic()).get("sub"));
try { Jwt.verifyRs256(token, otherKeyPair.getPublic()); } catch (Jwt.JwtException e) { System.out.println("다른 공개키로 검증 → " + e.getMessage()); }
// 출력:
// header = {alg=RS256, typ=JWT, kid=2026-09-key1} (kid 로 어느 키로 서명했는지 표시 → 키 회전)
// 토큰 길이 527자, 서명 256 bytes (RSA-2048)
// 공개키로 검증 → lee
// 다른 공개키로 검증 → 서명 불일치
// 공개키(X.509 DER) 294 bytes → 리소스 서버들에 배포(JWKS). 개인키는 인증 서버 밖으로 나가지 않는다HS256 토큰이 216자였는데 RS256 은 527자입니다. 서명이 256 bytes 라서입니다. 대신 검증에 필요한 것이 공개키뿐이라 리소스 서버 10대에 개인키를 복사할 필요가 없고, 어느 서버가 뚫려도 토큰을 위조할 수 없습니다.
실무에서 인증 서버는 /.well-known/jwks.json 으로 공개키 목록을 노출하고 리소스 서버는 kid 로 골라 캐시합니다. Spring 의 spring-security-oauth2-resource-server 는 jwk-set-uri 한 줄로 이것을 합니다.
String token = Jwt.hs256(Jwt.claims(ISSUER, "kim", 3600, Map.of("roles", List.of("ROLE_USER"))), secret);
String[] parts = token.split("\\.");
String forgedPayload = Jwt.encode(Map.of("iss", ISSUER, "sub", "kim", "roles", List.of("ROLE_ADMIN"), "exp", 4_000_000_000L));
expectFail("(a) 페이로드 변조", parts[0] + "." + forgedPayload + "." + parts[2], secret); // 권한만 바꾸고 서명은 원본
expectFail("(b) alg=none", Jwt.encode(Map.of("alg", "none", "typ", "JWT")) + "." + forgedPayload + ".", secret);
expectFail("(c) 만료", Jwt.hs256(Jwt.claims(ISSUER, "kim", -120, Map.of()), secret), secret); // exp = 2분 전
expectFail("(d) 다른 키 서명", Jwt.hs256(claims, "attacker-key".getBytes()), secret);
byte[] sig = Jwt.parse(token).signature(); sig[0] ^= 1;
expectFail("(e) 서명 1비트 변조", parts[0] + "." + parts[1] + "." + base64url(sig), secret);
// 출력:
// (a) 페이로드 변조 → 거부: 서명 불일치
// (b) alg=none → 거부: alg 불일치: 기대 HS256, 실제 none
// (c) 만료 → 거부: 만료됨 (exp=1788927233, now=1788927353)
// (d) 다른 키 서명 → 거부: 서명 불일치
// (e) 서명 1비트 변조 → 거부: 서명 불일치
// 정상 토큰 → 통과 (sub=kim)(a)가 JWT 의 존재 이유입니다. 페이로드는 누구나 읽고 바꿀 수 있지만, 바꾸면 서명이 안 맞습니다. (b)는 requireAlg 가 서명 검증보다 먼저 잡습니다. 헤더의 alg 를 믿는 구현이었다면 "none 이니까 서명 검증 생략" 으로 통과했을 것입니다.
(d)는 공격자가 비밀키를 모르는 한 위조가 불가능함을, (e)는 서명이 1비트만 달라도 거부됨을 보여 줍니다. 검증 실패 사유를 클라이언트에 자세히 알려 주면 공격자에게 힌트가 되므로 실무에서는 로그에만 남기고 응답은 invalid_token 으로 통일합니다.
AuthServer s = new AuthServer(0, new PasswordHasher(50_000), secret, 2); // TTL 2초: 만료 시연용
s.start();
// 이하 HttpClient 로 순서대로 호출 (call 메서드는 메서드·경로·본문·상태·응답을 한 줄로 출력)
// 출력:
// POST /signup {"username":"kim","password":"short"} → 400 {"error":"비밀번호는 8자 이상"}
// POST /signup {"username":"kim","password":"Kim-Pass-2026"} → 201 {"roles":["ROLE_USER","ROLE_ADMIN"],"username":"kim"}
// POST /signup {"username":"lee","password":"Lee-Pass-2026"} → 201 {"roles":["ROLE_USER"],"username":"lee"}
// POST /signup {"username":"lee","password":"Lee-Pass-2026"} → 409 {"error":"이미 존재하는 사용자"}
// POST /login {"username":"nobody","password":"x"} → 401 {"error":"아이디 또는 비밀번호가 올바르지 않습니다"}
// POST /login {"username":"lee","password":"wrong"} → 401 {"error":"아이디 또는 비밀번호가 올바르지 않습니다"}
// POST /login {"username":"lee","password":"Lee-Pass-2026"} → 200 {"expiresIn":2,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWxlc3NvbiIsInN1YiI6ImxlZSI…
// POST /login {"username":"kim","password":"Kim-Pass-2026"} → 200 {"expiresIn":2,"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhdXRoLWxlc3NvbiIsInN1YiI6ImtpbSI…
// GET /me → 401 {"error":"토큰 없음"}
// GET /me [Bearer] → 200 {"sub":"lee","roles":["ROLE_USER"],"exp":1788927255}
// GET /admin [Bearer] → 403 {"error":"ROLE_ADMIN 권한 필요"} ← lee: 인증됐지만 권한 없음
// GET /admin [Bearer] → 200 {"sub":"kim","message":"관리자 전용 데이터"} ← kim
// GET /me [Bearer] → 401 {"error":"유효하지 않은 토큰: 서명 불일치"} ← 토큰 끝에 x 를 붙임
// POST /login {"username":"lee","password":"wrong"} → 401 … (5회 반복)
// POST /login {"username":"lee","password":"Lee-Pass-2026"} → 423 {"error":"계정이 잠겼습니다. 관리자에게 문의"} ← 정답인데도
// ... 35초 대기 (TTL 2초 + leeway 30초) ...
// GET /me [Bearer] → 401 {"error":"유효하지 않은 토큰: 만료됨 (exp=1788927256, now=1788927291)"}로그인 기능의 전체 상태 전이가 한 화면에 나옵니다.
가입 정책 400 → 중복 409 → 없는 사용자·틀린 비밀번호 = 같은 401 → 발급 200
→ 토큰 없음 401 → 인증됨 200 → 권한 없음 403 / 있음 200 → 변조 401
→ 5회 실패 후 정답에도 잠금 423 → 만료 401실무 API 테스트 케이스 목록이 정확히 이 순서입니다. --serve 로 띄우고 curl 로 같은 흐름을 손으로 따라 해 보면 각 상태 코드의 의미가 몸에 붙습니다.
curl -s -X POST http://127.0.0.1:8080/login -H "Content-Type: application/json" -d "{\"username\":\"kim\",\"password\":\"Kim-Pass-2026\"}"
curl -s http://127.0.0.1:8080/me -H "Authorization: Bearer eyJ…"