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

2단계: Access/Refresh 토큰과 OAuth2 인가 코드

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

3. 코드 예제

소스: java-src/auth/02_oauth2_tokens (파일 9개, 순수 JDK). 1단계의 Jwt, MiniJson, PasswordHasher 를 그대로 가져왔고, Jwt 에는 시계 오차 허용치를 바꿀 수 있는 LEEWAY_SECONDS 만 추가했습니다.

text
cd java-src\auth\02_oauth2_tokens
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개 섹션 자동 시연 (약 12초)
C:\project\jdk-21.0.8\bin\java -Dstdout.encoding=UTF-8 Main --serve  # 9000/9001/9002 로 띄움. 브라우저에서 http://127.0.0.1:9002/login
파일 역할
TokenService Access(JWT, aud·jti) + Refresh(난수, family) 발급·회전·검증·폐기
Http 쿼리/폼/쿠키 파싱, JSON·302·쿠키 응답 (세 서버 공용)
AuthorizationServer 자사 로그인(/login /refresh /logout) + OAuth2(/authorize /token /revoke)
ResourceServer /api/profile, /api/orders. Access 의 서명·iss·aud·jti 만 검사
ClientApp /login → 인가 서버로 302, /callback → 코드 교환, /profile → API 호출 + 자동 갱신
Main 시연 + 브라우저 흉내(쿠키 저장, 302 추적)

예제 1: Access/Refresh 분리 · 회전 · 재사용 감지

java
public Tokens rotate(String refreshToken, String audience) {
    RefreshRecord r = refreshStore.get(refreshToken);
    if (r == null) throw new Jwt.JwtException("invalid_grant: 알 수 없는 refresh 토큰");
    if (revokedFamilies.contains(r.family())) throw new Jwt.JwtException("invalid_grant: 폐기된 세션(family)");
    if (r.used()) {                                        // 재사용 감지
        revokedFamilies.add(r.family());                   // 누가 진짜인지 모르니 둘 다 끊는다
        throw new Jwt.JwtException("invalid_grant: refresh 재사용 감지 → family 전체 폐기");
    }
    if (r.expiresAt() < now()) { refreshStore.remove(refreshToken); throw new Jwt.JwtException("invalid_grant: refresh 만료"); }
    refreshStore.put(refreshToken, new RefreshRecord(r.family(), r.subject(), r.roles(), r.expiresAt(), true));   // 구 토큰은 '사용됨' 으로 남긴다
    return issue(r.subject(), r.roles(), audience, r.family());
}

데모는 Access TTL 2초, 시계 오차 허용 0초로 돌립니다(실무는 각각 5~15분, 30초).

text
=== 1. Access/Refresh 분리 · 회전 · 재사용 감지 ===
access  (JWT 297자) payload = {iss=auth-server, sub=kim, iat=1788928947, exp=1788928949, jti=BN2eW-XO2P…, roles=[ROLE_USER], aud=resource-api}
refresh (난수 43자) = 9cuoAR3NLjlP5h52G1u-ST7gXbs57OhNiuu4vEmeveE   ← 내용 없음. 서버 저장소의 키일 뿐
검증 → sub=kim
만료 후 access 검증                     → 거부: 만료됨 (exp=1788928949, now=1788928950)
회전 → 새 access 검증 sub=kim, 새 refresh = tM7dRsd3YQW3…  (저장소 2건)
구 refresh 재사용 (탈취 시나리오)            → 거부: invalid_grant: refresh 재사용 감지 → family 전체 폐기
→ 같은 family 의 새 refresh 도          → 거부: invalid_grant: 폐기된 세션(family)
→ 정상 사용자도 끊긴다. 대신 '누군가 내 세션을 훔쳤다' 는 신호가 남고 재로그인으로 복구된다

구 토큰을 삭제하지 않고 "사용됨" 으로 남겨 두는 것이 재사용 감지의 전제입니다. 삭제하면 재사용이 "알 수 없는 토큰" 과 구분되지 않습니다. 저장소가 2건인 이유입니다.

예제 2: 로그아웃 — jti 거부 목록은 TTL 만큼만 산다

java
public void revoke(String accessToken, String refreshToken) {
    try {
        Map<String, Object> c = Jwt.parse(accessToken).payload();   // 서명 검증 없이 jti/exp 만 읽는다: 만료된 Access 로 로그아웃해도 refresh 는 끊어야 하므로
        revokedJti.put(String.valueOf(c.get("jti")), ((Number) c.get("exp")).longValue());
    } catch (RuntimeException ignore) {}
    RefreshRecord r = refreshStore.remove(refreshToken);
    if (r != null) revokedFamilies.add(r.family());
}
public void purgeExpired() { long now = now(); revokedJti.values().removeIf(exp -> exp < now); ... }
text
=== 2. 로그아웃: jti 거부 목록과 family 폐기 ===
로그아웃 직후 access (아직 exp 전)          → 거부: 로그아웃된 토큰
로그아웃 직후 refresh                    → 거부: invalid_grant: 알 수 없는 refresh 토큰
거부 목록 jti 1건 → 3초 대기 후 purgeExpired()
거부 목록 jti 0건. Access TTL 이 짧을수록 거부 목록은 작고 '즉시 무효화' 의 빈틈도 짧다

예제 3: PKCE — verifier 에서 challenge 만들기

java
public static String s256(String verifier) {
    return Base64.getUrlEncoder().withoutPadding()
        .encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
}

RFC 7636 부록 B 의 예제 값을 넣어 규격 기대값과 같은지 확인합니다.

text
=== 3. PKCE: code_verifier → code_challenge ===
code_verifier  = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk (클라이언트가 만들어 자기만 보관)
code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM (/authorize 로 보냄. 규격 기대값 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM)
challenge 는 공개돼도 verifier 를 역산할 수 없다(SHA-256). 코드를 가로챈 공격자는 /token 에서 verifier 를 못 내니 실패한다
verifier 한 글자 변경 → SJw0ZvrrwSlW…  (완전히 다른 값)

withoutPadding() 이 빠지면 끝에 = 가 붙어 기대값과 달라집니다. 인가 서버와 클라이언트가 서로 다른 라이브러리라면 이 한 글자 차이로 "PKCE 검증 실패" 가 납니다.

예제 4: 자사 로그인 — Access 는 본문, Refresh 는 HttpOnly 쿠키

java
private void respondWithCookie(HttpExchange ex, TokenService.Tokens t) throws IOException {
    ex.getResponseHeaders().add("Set-Cookie", Http.refreshCookie("refresh_token", t.refreshToken(), "/refresh", 14L * 86400));
    Http.json(ex, 200, Map.of("access_token", t.accessToken(), "token_type", "Bearer", "expires_in", t.expiresIn()));   // Refresh 는 본문에 없다
}
// Http.refreshCookie → name=value; HttpOnly; SameSite=Strict; Path=/refresh; Max-Age=...   (실무는 Secure 추가)
text
=== 4. 자사 로그인 HTTP: Access 는 본문, Refresh 는 HttpOnly 쿠키 ===
POST /login → 200 본문 {"expires_in":2,"token_type":"Bearer","access_token":"eyJhbG…
  Set-Cookie: refresh_token=<난수>; HttpOnly; SameSite=Strict; Path=/refresh; Max-Age=1209600
  브라우저 JS 가 아는 것: access_token 만. refresh 쿠키는 HttpOnly 라 document.cookie 에 안 보인다
POST /refresh (쿠키 자동 첨부) → 200, 새 Set-Cookie 있음 (회전)
POST /refresh (쿠키 없는 다른 브라우저) → 401 {"error":"refresh 쿠키 없음"}
POST /logout → 200, Set-Cookie: refresh_token=; HttpOnly; SameSite=Strict; Path=/refresh; Max-Age=0
POST /refresh (로그아웃 후) → 401 {"error":"refresh 쿠키 없음"}

로그아웃 응답의 Max-Age=0 이 브라우저에게 쿠키를 지우라는 신호입니다. 서버 쪽 폐기(family)와 브라우저 쪽 삭제를 둘 다 해야 합니다. 브라우저만 지우면 서버에 살아 있는 토큰이 남고, 서버만 지우면 브라우저가 죽은 쿠키를 계속 보냅니다.

예제 5: OAuth2 전체 흐름 — 세 서버, 네 번의 hop, 자동 갱신, 공격 6종

Main.Browser 가 브라우저를 흉내냅니다. 출처(host:port)별로 쿠키를 저장하고 302 를 따라가며 각 hop 을 출력합니다.

java
// ClientApp.callback — state 대조 후 뒷 채널로 코드 교환
if (s.state == null || !s.state.equals(q.get("state"))) { Http.text(ex, 400, "state 불일치 → CSRF 의심, 코드 교환 거부"); return; }
form.put("grant_type", "authorization_code"); form.put("code", q.get("code")); form.put("redirect_uri", redirectUri());
form.put("client_id", CLIENT_ID); form.put("code_verifier", s.verifier);       // 서버 → 서버. 브라우저를 거치지 않는다
HttpResponse<String> res = post(authBase + "/token", form);
...
s.state = null; s.verifier = null;                                              // 1회용 값은 즉시 버린다

// ClientApp.profile — 401 이면 refresh 로 회전 후 1회만 재시도
if (res.statusCode() == 401) {
    form.put("grant_type", "refresh_token"); form.put("refresh_token", s.refreshToken); ...
    if (r.statusCode() != 200) { sessions.values().remove(s); Http.text(ex, 401, "세션 만료, 다시 로그인: " + r.body()); return; }
    ...
}
text
=== 5. OAuth2 인가 코드 흐름: 클라이언트 → 인가 서버 → 콜백 → 리소스 서버 ===
인가 서버 http://127.0.0.1:54831 / 리소스 서버 http://127.0.0.1:54832 / 클라이언트 앱 http://127.0.0.1:54833

[정상 흐름] 브라우저가 클라이언트 앱의 /login 을 누른다
  1. GET :54833/login → 302
  2. GET :54831/authorize?response_type=code&client_id=my-web-app&redirect_uri=http%3A%2F%2F127.0.0.1%3A54833%2Fcallback&scope=profile+orders%3Aread&state=…&code_challenge=…&code_challenge_method=S256&user=kim → 302
  3. GET :54833/callback?code=…&state=… → 302
  4. GET :54833/profile → 200
최종 → 200 {"sub":"kim","roles":["ROLE_USER"],"remaining":2}
클라이언트 세션에 보관: access eyJhbGciOiJIUzI1NiIs…, refresh t-NzVheNwD-j…  / 브라우저 쿠키: sid 만

[자동 갱신] 3초 뒤 /profile → 리소스 서버 401 → 클라이언트가 refresh 회전 → 재시도
  1. GET :54833/profile → 200
최종 → 200 (refresh 후 재시도) {"sub":"kim","roles":["ROLE_USER"],"remaining":2}

[공격 시연]
(a) callback state 불일치             → 400 state 불일치 → CSRF 의심, 코드 교환 거부
(b) redirect_uri 변조                → 400 invalid_client_or_redirect_uri: 등록되지 않은 client_id/redirect_uri
(c) 가로챈 code + 틀린 verifier         → 400 {"error":"invalid_grant","error_description":"PKCE 검증 실패"}
    (정상 verifier 로 교환)             → 200 {"expires_in":2,"token_type":"Bearer","s…
(d) 같은 code 두 번째 교환                → 400 {"error":"invalid_grant","error_description":"code 재사용"}
(e) aud=billing-api 토큰             → 401 {"error":"aud 불일치: billing-api 용 토큰"}
(f) 구 refresh 재사용                  → 400 {"error":"invalid_grant","error_description":"refresh 재사용 감지 → family 전체 폐기"}
    → 신 refresh 도 폐기됨              → 400 {"error":"invalid_grant","error_description":"폐기된 세션(family)"}

2번 hop 의 URL 이 앞 채널에 노출되는 전부입니다. code_challenge 와 state 는 있지만 code_verifier 는 없습니다. 3번 hop 의 code 는 60초 1회용이고, 그것을 토큰으로 바꾸는 /token 호출은 hop 목록에 나타나지 않습니다. 클라이언트 서버가 뒷 채널로 직접 했기 때문입니다.

자동 갱신도 브라우저는 /profile 한 번만 불렀는데 내부에서 401 → 회전 → 재시도가 끝났습니다.

(b) 는 응답이 JSON 이 아니고 리다이렉트도 아닌 400 텍스트입니다. 2.8 에서 설명한 규칙입니다. (e) 만 401 인 이유는 리소스 서버의 응답이기 때문이고, 나머지 400 은 인가 서버 /token 의 규격 형식입니다.

예제 직접 실행

아래 폴더를 JDK 21 로 컴파일하고 실행합니다.

cd java-src\auth\02_oauth2_tokens
javac -encoding UTF-8 *.java && java Main
코드 예제
  • 예제 1: Access/Refresh 분리 · 회전 · 재사용 감지
  • 예제 2: 로그아웃 — jti 거부 목록은 TTL 만큼만 산다
  • 예제 3: PKCE — verifier 에서 challenge 만들기
  • 예제 4: 자사 로그인 — Access 는 본문, Refresh 는 HttpOnly 쿠키
  • 예제 5: OAuth2 전체 흐름 — 세 서버, 네 번의 hop, 자동 갱신, 공격 6종
이전 섹션2 핵심 원리3 / 7다음 섹션4 응용 변형 예제