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

4. 응용 변형 예제

변형 1: 기밀 클라이언트 — client_secret 을 Basic 인증으로

이 레슨의 클라이언트는 비밀 없이 PKCE 만 쓰는 공개 클라이언트입니다. 서버 사이드 앱은 인가 서버에 등록한 client_secret 을 뒷 채널에서만 제시합니다. 규격은 폼 필드보다 HTTP Basic 을 권장합니다.

java
// ClientApp.post 에 추가
String basic = Base64.getEncoder().encodeToString((CLIENT_ID + ":" + CLIENT_SECRET).getBytes(StandardCharsets.UTF_8));
builder.header("Authorization", "Basic " + basic);

// AuthorizationServer.token 에 추가
String auth = ex.getRequestHeaders().getFirst("Authorization");
String[] idSecret = new String(Base64.getDecoder().decode(auth.substring(6)), StandardCharsets.UTF_8).split(":", 2);
if (!MessageDigest.isEqual(registeredSecret(idSecret[0]).getBytes(), idSecret[1].getBytes())) { Http.json(ex, 401, Map.of("error", "invalid_client")); return; }

기밀 클라이언트라도 PKCE 는 유지합니다. 비밀은 "이 요청이 우리 앱 서버에서 왔다" 를 증명하고, PKCE 는 "이 코드를 받은 그 세션이다" 를 증명합니다. 서로 다른 것을 막습니다. client_secret 은 브라우저 JS 번들이나 모바일 앱 바이너리에 절대 넣지 않습니다. 디컴파일 한 번이면 끝입니다.

변형 2: scope 로 API 권한 나누기

인가 요청의 scope=profile orders:read 가 토큰에 실려 리소스 서버에 도착합니다. 리소스 서버는 엔드포인트마다 필요한 scope 를 검사합니다. 사용자의 역할(ROLE_ADMIN)과 클라이언트 앱에 허락한 범위(orders:read)는 다른 축입니다. 관리자가 로그인했어도 "프로필만 읽기" 로 동의한 앱은 주문을 못 봅니다.

java
// TokenService.issue 에 scope 를 클레임으로 추가한 뒤, ResourceServer 에서
private void protectedApi(HttpExchange ex, String requiredScope, Api api) throws IOException {
    Map<String, Object> c = tokens.verifyAccess(bearer, AUDIENCE);
    Set<String> scopes = Set.of(String.valueOf(c.getOrDefault("scope", "")).split(" "));
    if (requiredScope != null && !scopes.contains(requiredScope)) {
        ex.getResponseHeaders().add("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"" + requiredScope + "\"");
        Http.json(ex, 403, Map.of("error", "scope 부족: " + requiredScope)); return;   // 인증은 됐으니 401 이 아니라 403
    }
    ...
}

Spring 에서는 .requestMatchers("/api/orders/**").hasAuthority("SCOPE_orders:read") 한 줄입니다. Resource Server 가 scope 클레임을 SCOPE_ 접두어 권한으로 자동 변환합니다.

변형 3: 소셜 로그인 뒤 자사 계정 연결

구글 인가 서버에서 토큰을 받았다고 로그인이 끝난 게 아닙니다. 그 토큰은 구글 API 용이지 우리 API 용이 아닙니다.

java
// ClientApp.callback 의 토큰 교환 직후
Map<String, Object> me = MiniJson.read(get(providerUserInfoUrl, providerAccessToken).body());   // {"sub":"1093...","email":"[email protected]","email_verified":true}
if (!Boolean.TRUE.equals(me.get("email_verified"))) { Http.text(ex, 403, "이메일 미검증 계정"); return; }
String localUser = accounts.findByProvider("google", (String) me.get("sub"))                       // provider + provider sub 로 연결. 이메일로 연결하면 이메일 재사용 시 계정 탈취
                          .orElseGet(() -> accounts.createFrom("google", me));
TokenService.Tokens ours = ourTokens.issue(localUser, roles(localUser), "resource-api");          // 우리 토큰 발급 → 이후는 자사 로그인과 동일

연결 키는 이메일이 아니라 provider 이름 + provider 의 sub 입니다. 이메일은 바뀌고 재사용되지만 sub 는 그 제공자 안에서 영구 고유값입니다.

OIDC(OpenID Connect) 를 쓰면 이 사용자 정보가 id_token(JWT) 으로 토큰 응답에 같이 옵니다. 그 경우 id_token 의 서명을 제공자 공개키(JWKS)로 검증하고 aud 가 우리 client_id 인지, nonce 가 우리가 보낸 값인지 확인합니다.

변형 4: 토큰 저장소를 DB 로

refreshStore 는 Map 입니다. 서버가 둘 이상이거나 재시작되면 쓸 수 없습니다. 실무는 DB 나 Redis 입니다.

sql
CREATE TABLE refresh_token (
    token_hash   VARCHAR2(64)  PRIMARY KEY,    -- SHA-256(token). 원본은 저장하지 않는다. DB 가 털려도 토큰은 못 쓴다
    family_id    VARCHAR2(43)  NOT NULL,
    username     VARCHAR2(50)  NOT NULL,
    expires_at   TIMESTAMP     NOT NULL,
    used_at      TIMESTAMP,                    -- NULL 이면 미사용
    created_at   TIMESTAMP     DEFAULT SYSTIMESTAMP
);
CREATE INDEX ix_refresh_family ON refresh_token(family_id);
-- 회전: UPDATE ... SET used_at = SYSTIMESTAMP WHERE token_hash = ? AND used_at IS NULL  → 갱신 건수 0 이면 재사용 → family 전체 폐기
-- 청소: 배치(@Scheduled) 가 DELETE WHERE expires_at < SYSDATE - 1

해시해서 저장하는 이유는 비밀번호와 같습니다. 저장소 유출이 곧 세션 탈취가 되지 않게 합니다. 조회는 SHA-256(받은 토큰) 으로 하면 되니 성능 부담도 없습니다.

회전을 UPDATE ... WHERE used_at IS NULL 하나로 처리하면 두 요청이 동시에 와도 한쪽만 성공하는 원자성이 DB 에서 보장됩니다. Redis 라면 키에 TTL 을 걸어 청소 배치가 필요 없습니다. jti 거부 목록도 Redis SET jti 1 EX 남은초 가 정석입니다.

응용 변형 예제
  • 변형 1: 기밀 클라이언트 — client_secret 을 Basic 인증으로
  • 변형 2: scope 로 API 권한 나누기
  • 변형 3: 소셜 로그인 뒤 자사 계정 연결
  • 변형 4: 토큰 저장소를 DB 로
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)