feat: 문서 구조 변경 및 tech-visual 스킬 추가
This commit is contained in:
+98
@@ -0,0 +1,98 @@
|
||||
---
|
||||
id: 75c6c657-3e03-47a0-a9d0-5637fce9dd3f
|
||||
kind: CONCEPT
|
||||
slug: authorization-code-and-pkce
|
||||
title: Authorization Code와 PKCE가 보호하는 구간
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts
|
||||
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
|
||||
---
|
||||
|
||||
# Authorization Code와 PKCE가 보호하는 구간
|
||||
|
||||
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 access token으로 바꾸는 구간을 PKCE가 보호한다. authorization request에 넣은 code_challenge와 token request에 넣은 code_verifier가 맞아야 교환이 끝난다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 개념을 endpoint별 기준으로 정리한 기록이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
client 종류에 따라 token endpoint의 인증 방식이 달라진다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 code를 직접 교환한 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## code를 한 번 더 교환하는 이유
|
||||
|
||||
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth client가 code를 token endpoint에 제출해야 access token을 받는다.
|
||||
|
||||
교환을 나눈 덕분에 access token이 브라우저 주소창을 지나지 않는다. authorization request는 full-page navigation이라 URL이 주소창과 히스토리, Authorization Server 접근 로그에 남는다. 여기 남아도 되는 값만 code로 두고, token은 별도 요청의 body로 받는다.
|
||||
|
||||
## authorization request에 들어가는 challenge
|
||||
|
||||
oidc-client-ts가 만드는 요청의 핵심 모양은 다음과 같다.
|
||||
|
||||
```http label="authorization request"
|
||||
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
|
||||
?client_id=spa-public
|
||||
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
|
||||
&response_type=code
|
||||
&scope=openid%20profile%20email
|
||||
&state=<opaque-state>
|
||||
&code_challenge=<opaque-challenge>
|
||||
&code_challenge_method=S256
|
||||
```
|
||||
|
||||
`response_type=code`가 Authorization Code Flow를 쓴다는 표시이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다. `state`와 challenge 값은 요청마다 달라진다.
|
||||
|
||||
`state`와 PKCE verifier는 redirect를 건너야 하므로 브라우저에 남는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
|
||||
|
||||
## token request가 제출하는 verifier
|
||||
|
||||
callback으로 돌아온 code는 다음 요청으로 교환된다.
|
||||
|
||||
```http label="token request"
|
||||
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&client_id=spa-public
|
||||
&code=<authorization-code>
|
||||
&redirect_uri=http://localhost:8088/callback.html
|
||||
&code_verifier=<original-verifier>
|
||||
```
|
||||
|
||||
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 verifier가 대응하는지 확인하고 교환을 끝낸다. 이 대응이 authorization request를 시작한 client와 code를 교환하는 주체를 연결한다.
|
||||
|
||||
## S256과 plain의 차이
|
||||
|
||||
verifier에서 challenge를 만드는 방법이 두 가지다.
|
||||
|
||||
| method | challenge 값 | 중간에서 challenge를 본 경우 |
|
||||
|---|---|---|
|
||||
| `plain` | verifier 그대로 | 그대로 verifier로 쓸 수 있다 |
|
||||
| `S256` | verifier의 SHA-256 | verifier를 되돌릴 수 없다 |
|
||||
|
||||
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 수동 helper도 있어서 32 random bytes를 padding 없는 Base64URL verifier로 바꾸고 SHA-256 challenge와 `"S256"`을 반환한다. 다만 실제 `signinRedirect()`는 이 helper를 호출하지 않는다. helper는 UI의 PKCE demo button용이고 로그인은 pinned oidc-client-ts가 수행한다.
|
||||
|
||||
## PKCE가 막지 않는 것
|
||||
|
||||
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 access token을 숨기지는 않는다. 브라우저가 token을 직접 다루는 구성에서 실행 중 악성 script가 Bearer token을 보거나 사용자 권한으로 API를 부르는 문제는 PKCE 밖이다.
|
||||
|
||||
`state`도 PKCE와 다른 값이다. `state`는 callback이 원래 시작한 transaction의 것인지 대조하는 값이고, verifier는 code 교환 주체를 묶는 값이다.
|
||||
|
||||
## client 종류에 따라 달라지는 부분
|
||||
|
||||
`spa-public`은 secret이 없는 public client다. token endpoint에서 client 인증을 하지 않고 PKCE만 사용한다.
|
||||
|
||||
confidential client는 여기에 client 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 client를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 authorization request resolver에 장착해 framework가 state와 verifier를 만든다.
|
||||
|
||||
AP2 client 설정에는 S256을 강제하는 속성이 없고, AP2 테스트도 authorization request의 challenge를 검사하지 않는다. AP2에서 확인한 것은 Authorization Code Flow를 쓴다는 데까지이고, PKCE S256이 고정됐는지는 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
---
|
||||
id: 87000d59-b69f-4010-9481-0b71c8bde32d
|
||||
kind: CONCEPT
|
||||
slug: bearer-jwt-validation-chain
|
||||
title: Bearer JWT가 인증된 principal이 되기까지
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server
|
||||
studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit"
|
||||
---
|
||||
|
||||
# Bearer JWT가 인증된 principal이 되기까지
|
||||
|
||||
Resource Server가 받는 입력은 Authorization 헤더의 문자열 하나다. 이 문자열이 서명 검증, issuer와 시간 검증, audience 검증, role 변환을 차례로 지나 authenticated principal이 된다. 서명 검증을 통과해도 이 API를 위해 발급된 token인지는 audience 검증에서 따로 본다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
이 검증을 통과한 JWT와 애플리케이션 session은 다른 상태다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 JWT가 어느 endpoint에서 발급되는지 정리한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 이 헤더를 직접 만든 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## Resource Server가 받는 입력
|
||||
|
||||
브라우저나 BFF가 보내는 요청의 모양은 같다.
|
||||
|
||||
```http label="Resource Server 입력"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
Spring 쪽 입력은 raw Bearer string이다. 요청마다 JWT로 인증하고 application session을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
|
||||
|
||||
session을 만들지 않으므로 logout 순간에 지울 server 상태가 없다. 이미 발급된 self-contained JWT는 만료 전까지 유효하고, 짧은 TTL과 validator가 그 범위를 좁힌다.
|
||||
|
||||
## 변환 순서
|
||||
|
||||
custom code가 지나는 순서는 다음과 같다.
|
||||
|
||||
```text label="raw Bearer JWT가 principal이 되기까지"
|
||||
raw Bearer JWT
|
||||
→ NimbusJwtDecoder(JWK signature)
|
||||
→ default issuer + timestamp validators
|
||||
→ AudienceValidator("keycloak-pattern-api")
|
||||
→ validated Jwt
|
||||
→ KeycloakRealmRoleConverter
|
||||
→ authenticated principal + ROLE_* authorities
|
||||
```
|
||||
|
||||
Spring OAuth2 Resource Server가 헤더를 추출하고 JWT authentication provider를 거쳐 configured decoder를 호출한다. repository code가 Spring 내부 filter를 직접 만들지는 않으므로, DSL이 설치하는 framework integration과 custom bean 경계를 나눠 읽어야 한다.
|
||||
|
||||
## 서명을 통과한 뒤에 남는 확인
|
||||
|
||||
서명이 맞다는 것은 그 IdP가 발급했다는 뜻이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다. 그래서 서명만 확인하고 끝내지 않는다.
|
||||
|
||||
| 확인 단계 | 확인하는 것 | 통과해도 남는 질문 |
|
||||
|---|---|---|
|
||||
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 token인가 |
|
||||
| issuer | 기대한 realm인가 | 아직 유효한가 |
|
||||
| timestamp | 만료 전인가 | 이 API가 대상인가 |
|
||||
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
|
||||
| role converter | 어떤 권한을 갖는가 | — |
|
||||
|
||||
## expected issuer와 JWK URL이 다른 이유
|
||||
|
||||
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
|
||||
|
||||
```text label="issuer와 JWK URL"
|
||||
expected issuer = http://localhost:8080/realms/keycloak-patterns
|
||||
JWK URL = http://keycloak:8080/.../certs
|
||||
```
|
||||
|
||||
expected issuer는 token 안의 browser-visible 값이다. 브라우저가 도달하는 주소로 발급됐으므로 claim 검증 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 container network 경로다. Resource Server가 같은 Docker network 안에서 service name으로 Keycloak에 도달한다.
|
||||
|
||||
하나는 claim 검증 기준이고 하나는 network access 경로다. 두 값을 같게 맞추려다 issuer를 container 주소로 바꾸면 브라우저가 받은 token의 `iss`와 어긋난다.
|
||||
|
||||
## audience 검증
|
||||
|
||||
`AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 포함됐는지 확인한다. 누락되면 `invalid_token` 결과를 만든다.
|
||||
|
||||
같은 정상 JWT를 expected audience가 다른 진단용 Resource Server에 제출하면 401이 된다. issuer가 다른 서버도 마찬가지다. 두 서버가 같은 token에 401을 돌려준 것이 audience 검증과 issuer 검증이 실제로 걸린다는 관측이다.
|
||||
|
||||
## realm role이 authority가 되는 변환
|
||||
|
||||
`KeycloakRealmRoleConverter`는 `realm_access.roles`의 string을 골라 `ROLE_` prefix를 붙인다.
|
||||
|
||||
```text label="role 변환"
|
||||
realm_access.roles: ["user-role"]
|
||||
→ ROLE_user-role
|
||||
```
|
||||
|
||||
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role` authority를 찾기 때문에 prefix가 필요하다.
|
||||
|
||||
## 인증과 인가는 다른 endpoint에서 갈린다
|
||||
|
||||
`/api/me`는 특정 role을 요구하지 않고 `.authenticated()`만 요구한다. role claim이 없어서 converter 결과가 빈 list여도 JWT가 유효하면 `/api/me`는 통과한다.
|
||||
|
||||
`admin-role`의 효과는 `/api/admin`에서 나타난다. regular user는 403, admin user는 200이다. 로그인 성공과 role 인가를 같은 테스트로 확인하면 이 차이가 가려진다.
|
||||
|
||||
controller는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
|
||||
|
||||
```json label="ApiController가 반환하는 JSON"
|
||||
{
|
||||
"subject": "<keycloak-user-sub>",
|
||||
"username": "regular-user",
|
||||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||||
}
|
||||
```
|
||||
|
||||
<!-- body:end -->
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
---
|
||||
id: bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07
|
||||
kind: CONCEPT
|
||||
slug: browser-credential-storage
|
||||
title: 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts · oauth2-proxy 7.15.2
|
||||
studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07/edit"
|
||||
---
|
||||
|
||||
# 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
|
||||
브라우저에는 JavaScript memory, Session Storage, Local Storage, cookie가 있고 각각 수명과 접근 경로가 다르다. 어떤 credential이 어디에 있는지에 따라 새로고침 뒤 남는 것, JavaScript가 읽을 수 있는 것, 요청에 자동으로 붙는 것이 갈린다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
여기 있는 값들에 각각 다른 이름을 쓰는 기준이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
memory-only 구성을 실제로 확인한 기록이다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
브라우저에 session cookie만 두는 구조의 설계 항목이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 네 위치의 성질
|
||||
|
||||
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|
||||
|---|---|---|---|
|
||||
| JavaScript memory | 초기화 | 읽는다 | 붙지 않는다 |
|
||||
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
|
||||
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
|
||||
| HttpOnly cookie | 만료까지 유지 | 읽지 못한다 | 붙는다 |
|
||||
|
||||
자동으로 붙는다는 성질이 cookie를 credential로 쓸 때 CSRF 검증이 필요해지는 이유다.
|
||||
|
||||
## userStore와 stateStore를 나눈다
|
||||
|
||||
oidc-client-ts의 `UserManager`는 두 저장소를 따로 받는다.
|
||||
|
||||
```text label="AP1의 UserManager 저장소 설정"
|
||||
userStore = InMemoryWebStorage
|
||||
stateStore = sessionStorage
|
||||
```
|
||||
|
||||
`userStore`는 로그인 뒤 `User`와 token set을 보관한다. `stateStore`는 redirect를 건너야 하는 authorization transaction을 보관한다.
|
||||
|
||||
두 저장소의 내용도 성격이 다르다.
|
||||
|
||||
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|
||||
|---|---|---|
|
||||
| userStore | `User`, access·refresh·ID token, expiry, profile | 로그인 상태가 유지되는 동안 |
|
||||
| stateStore | `state`, PKCE verifier | callback 처리가 끝날 때까지 |
|
||||
|
||||
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 memory에 둘 수 없다. 이 값이 Session Storage에 있는 것과 token이 Web Storage에 있는 것은 다른 설정이다.
|
||||
|
||||
## memory-only가 뜻하는 범위
|
||||
|
||||
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다. 새로고침하면 `User`와 token이 초기화되고, Local Storage와 Session Storage에는 token 복사본이 남지 않는다.
|
||||
|
||||
memory-only는 persistent storage에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저 fetch를 hook하면 API 호출의 Bearer access token을 관측할 수 있다.
|
||||
|
||||
이 구성에서 관측한 두 결과는 다음과 같다.
|
||||
|
||||
```text label="함께 읽어야 하는 두 결과"
|
||||
Local Storage · Session Storage → access token 문자열 없음
|
||||
실행 중 fetch hook → Authorization: Bearer 관측됨
|
||||
```
|
||||
|
||||
AP2도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 된다. 세 경계를 지나는 동안 persistent storage에는 쓰이지 않는다.
|
||||
|
||||
## HttpOnly cookie
|
||||
|
||||
HttpOnly는 JavaScript가 cookie 값을 직접 읽지 못하게 하는 속성이다. `document.cookie`로 조회되지 않지만 브라우저는 요청마다 붙여 보낸다.
|
||||
|
||||
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. 브라우저 JavaScript에 OAuth token을 전달하지 않는 구조에서도 이 cookie는 남는다. 브라우저에 없는 것은 애플리케이션이 쓰는 OAuth token이고, 인증 상태 자체는 이 cookie로 남아 있다.
|
||||
|
||||
Keycloak 도메인의 SSO cookie도 별도로 존재할 수 있다. 애플리케이션 memory의 `User`가 사라진 것과 IdP session이 끝난 것은 다른 사건이다.
|
||||
|
||||
## opaque cookie
|
||||
|
||||
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
|
||||
|
||||
AP2와 AP3의 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 cookie 안에는 없다. cookie가 token map을 직렬화한다고 설명하면 구현이 틀리게 된다.
|
||||
|
||||
AP4에서 `session-cookie-minimal=true`를 쓰면 server-side session store 없이 edge가 필요한 최소 정보만 cookie 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속 보관한다고 말할 수 없다.
|
||||
|
||||
AP2와 AP3의 cookie는 server-side 상태를 찾는 열쇠이고, AP4의 cookie는 최소 상태를 담은 값이다. 두 cookie를 같은 문장으로 설명하지 않는다.
|
||||
|
||||
## 학습 환경의 cookie 속성을 일반화하지 않는다
|
||||
|
||||
지금 구성은 cookie 속성과 redirect를 눈으로 확인하려고 HTTPS가 아닌 HTTP를 쓴다. 그래서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
|
||||
|
||||
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다. 여기서 관측한 값을 운영 cookie 기본값으로 옮겨 적지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
|
||||
kind: CONCEPT
|
||||
slug: cookie-auth-csrf
|
||||
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
|
||||
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
|
||||
---
|
||||
|
||||
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
|
||||
session cookie는 브라우저가 요청마다 자동으로 붙인다. 그래서 상태를 바꾸는 요청이 사용자의 의도인지 서버가 따로 확인해야 한다. CSRF token이 그 확인이고, SameSite는 브라우저가 cookie를 언제 보낼지 정하는 별도의 정책이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 확인이 필요한 구조의 설계 항목이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
이 동작을 실제로 재현한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
session cookie와 CSRF token은 서로 다른 값이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## cookie가 credential이 되면 생기는 일
|
||||
|
||||
브라우저가 OAuth token을 받지 않는 구조에서도 인증 상태는 남는다. BFF는 HttpOnly session cookie로 로그인 상태를 찾는다.
|
||||
|
||||
이 cookie는 브라우저가 자동으로 붙인다. 다른 사이트가 만든 요청에도 붙을 수 있다는 뜻이다. `GET /bff/api/me`만 보면 이 문제가 드러나지 않으므로 상태를 바꾸는 요청을 따로 봐야 한다.
|
||||
|
||||
## token을 받아 오는 요청
|
||||
|
||||
브라우저가 먼저 CSRF material을 요청한다.
|
||||
|
||||
```http label="CSRF token 요청"
|
||||
GET http://localhost:8083/bff/csrf
|
||||
Accept: application/json
|
||||
Cookie: AP3_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` cookie를 path `/`에 만든다. controller는 다음 JSON을 반환한다.
|
||||
|
||||
```json label="CsrfController가 반환하는 JSON"
|
||||
{
|
||||
"headerName": "X-XSRF-TOKEN",
|
||||
"parameterName": "_csrf",
|
||||
"token": "<xor-masked-csrf-token>"
|
||||
}
|
||||
```
|
||||
|
||||
## body의 token과 cookie의 값은 다르다
|
||||
|
||||
같은 CSRF material이 세 자리에 서로 다른 형태로 놓인다.
|
||||
|
||||
| 위치 | 값 |
|
||||
|---|---|
|
||||
| 응답 body의 `token` | XOR와 Base64로 mask된 값 |
|
||||
| `XSRF-TOKEN` cookie | raw 값 |
|
||||
| POST의 `X-XSRF-TOKEN` 헤더 | cookie와 같은 raw 값 |
|
||||
|
||||
`XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 mask하기 때문에 controller JSON에는 masked 값이 보인다. SPA는 JSON에서 `headerName`만 읽고, 실제 값은 `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 쓴다.
|
||||
|
||||
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. expected 헤더가 있으면 plain resolver로 제출된 raw token을 읽고, 없으면 XOR resolver 경로를 쓴다.
|
||||
|
||||
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다. 노출 값과 제출 값이 다를 수 있다는 것을 클라이언트 코드가 알아야 한다.
|
||||
|
||||
## 검증이 controller보다 먼저 일어난다
|
||||
|
||||
정상 상태 변경 요청은 다음과 같다.
|
||||
|
||||
```http label="CSRF 검증을 통과하는 POST"
|
||||
POST http://localhost:8083/bff/api/preferences
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
||||
X-XSRF-TOKEN: <same-raw-csrf-token>
|
||||
|
||||
theme=dark
|
||||
```
|
||||
|
||||
Spring CSRF filter가 repository의 expected token과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 controller는 실행되지 않고 403이 된다. 검증 지점이 controller 앞이라 endpoint를 추가해도 같은 filter를 지난다.
|
||||
|
||||
## SameSite가 정하는 것과 CSRF token이 정하는 것
|
||||
|
||||
| | SameSite | CSRF token |
|
||||
|---|---|---|
|
||||
| 누가 판단하나 | 브라우저 | 서버 |
|
||||
| 무엇을 정하나 | cookie를 보낼지 | 요청을 받아들일지 |
|
||||
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
|
||||
|
||||
port가 달라도 site 계산상 같은 경우가 있어서, SameSite가 cookie를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
|
||||
|
||||
네 가지 입력에서 cookie와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
|
||||
|
||||
| 입력 | cookie 동작 | CSRF 동작 | 결과 |
|
||||
|---|---|---|---|
|
||||
| same-origin, CSRF 헤더 없음 | session cookie 붙음 | token 부재로 거부 | 403 |
|
||||
| same-origin, raw cookie와 헤더 일치 | session cookie 붙음 | token 일치 | 200 |
|
||||
| 다른 port지만 same-site, 헤더 없음 | cookie가 붙을 수 있음 | token 부재로 거부 | 403 |
|
||||
| cross-site POST | SameSite=Lax로 cookie 제외 | 이 지점 이후는 고정하지 않음 | cookie omission이 확인 지점 |
|
||||
|
||||
마지막 줄에서 확인하는 것은 최종 status가 아니라 cookie가 빠졌는지다.
|
||||
|
||||
## CSRF가 XSS를 대신하지 않는다
|
||||
|
||||
브라우저에 OAuth token을 주지 않아도 same-origin 악성 script는 피해자 session으로 BFF endpoint를 부를 수 있다. JavaScript가 읽을 수 있는 `XSRF-TOKEN`도 같이 읽을 수 있다.
|
||||
|
||||
이 구조가 줄이는 것은 access·refresh token 원문이 script에서 유출되어 다른 client나 직접 API 호출에 재사용되는 범위다. CSP, output encoding, 의존성 무결성, 애플리케이션 인가는 별도 방어선으로 남는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
---
|
||||
id: a3493786-d3fb-4b01-b1c5-ecb23c3d5497
|
||||
kind: CONCEPT
|
||||
slug: forward-auth-and-auth-request
|
||||
title: Forward-Auth와 Nginx auth_request의 동작
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: oauth2-proxy 7.15.2 · Nginx auth_request module
|
||||
studio: "https://hyeonworks.com/studio/documents/a3493786-d3fb-4b01-b1c5-ecb23c3d5497/edit"
|
||||
---
|
||||
|
||||
# Forward-Auth와 Nginx auth_request의 동작
|
||||
|
||||
forward-auth는 실제 요청을 upstream으로 넘기기 전에 별도의 인증 endpoint에 허용 여부를 묻는 방식이다. Nginx에서는 auth_request directive가 그 질문을 subrequest로 만든다. 인증 결과는 upstream 요청의 헤더로 바뀌고, upstream은 JWT 대신 그 헤더를 입력으로 받는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
||||
이 동작을 운영에서 신뢰하려면 무엇이 필요한지 정리한 기준이다.
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
헤더 위조를 실제로 재현한 기록이다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
이 구조를 언제 고르는지 비교한 기준이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 요청 하나가 두 번 평가된다
|
||||
|
||||
브라우저 요청이 들어오면 Nginx는 바로 upstream을 호출하지 않는다. `location /`에 다음 directive가 있다.
|
||||
|
||||
```nginx label="general location의 auth_request"
|
||||
auth_request /oauth2/auth;
|
||||
```
|
||||
|
||||
Nginx는 먼저 `/oauth2/auth`로 subrequest를 만들어 인증 결과를 받고, 그다음에 원래 요청을 처리한다. 한 번의 외부 요청이 인증 판단과 upstream 전달 두 단계로 나뉜다.
|
||||
|
||||
`location = /oauth2/auth`는 `internal`로 선언한다. Nginx가 만드는 subrequest만 들어갈 수 있고 브라우저가 같은 URL을 직접 호출하면 정상 auth endpoint로 쓸 수 없다. 외부에서 이 경로를 부르면 404가 된다.
|
||||
|
||||
## subrequest가 실어 보내는 것
|
||||
|
||||
subrequest는 body를 보내지 않고 `Content-Length`를 비운다. 원래 요청의 문맥은 헤더로 바뀐다.
|
||||
|
||||
| subrequest 헤더 | 값의 출처 |
|
||||
|---|---|
|
||||
| `X-Original-URL` | scheme, host와 original request URI |
|
||||
| `X-Real-IP` | client address |
|
||||
| `X-Forwarded-For` | proxy chain |
|
||||
| `X-Forwarded-Host` | original host |
|
||||
| `X-Forwarded-Proto` | original scheme |
|
||||
| `X-Forwarded-Uri` | original request URI |
|
||||
| `Cookie` | 브라우저에 cookie가 있을 때 원래 요청의 값 |
|
||||
|
||||
oauth2-proxy는 이 정보로 session이 유효한지 판단한다.
|
||||
|
||||
## 미인증 401의 응답이 경로마다 다르다
|
||||
|
||||
인증 결과가 401일 때 무엇을 돌려줄지는 location마다 다르다.
|
||||
|
||||
| 외부 입력 | 인증 상태 | 결과 |
|
||||
|---|---|---|
|
||||
| `GET /` | 미인증 | `/oauth2/start`로 302 |
|
||||
| `GET /api/edge` | 미인증 | `Location` 없는 401 JSON |
|
||||
|
||||
general location은 `@oauth2_signin`으로 이동해 로그인을 시작한다.
|
||||
|
||||
```http label="미인증 navigation의 응답"
|
||||
HTTP/1.1 302 Found
|
||||
Location: http://localhost:8088/oauth2/start?rd=http://localhost:8088/
|
||||
```
|
||||
|
||||
exact API location은 redirect 없이 401을 만든다. 브라우저 UX와 프로그램이 부르는 API UX를 나눈 구성이다. 이 분리는 해당 path에만 구성돼 있고 다른 path는 general location 규칙을 따른다.
|
||||
|
||||
## 인증 결과를 변수로 옮긴다
|
||||
|
||||
oauth2-proxy가 session을 유효하다고 판단하면 auth 응답에 사용자와 이메일이 들어 있다. Nginx는 `auth_request_set`으로 그 값을 local 변수에 복사한다.
|
||||
|
||||
```text label="auth_request_set 변수"
|
||||
$auth_user ← oauth2-proxy X-Auth-Request-User
|
||||
$auth_email ← oauth2-proxy X-Auth-Request-Email
|
||||
$auth_cookie ← oauth2-proxy Set-Cookie
|
||||
```
|
||||
|
||||
## upstream 요청을 새로 만든다
|
||||
|
||||
원래 요청을 그대로 전달하지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고 헤더는 Nginx가 만든 값으로 채워진다.
|
||||
|
||||
```http label="Nginx가 만드는 upstream 요청"
|
||||
GET http://app:8081/edge/me
|
||||
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
|
||||
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
|
||||
X-Internal-Auth-Token: <nginx-environment-secret>
|
||||
```
|
||||
|
||||
client가 보낸 같은 이름의 헤더를 merge하지 않고 덮어쓴다. 공격자가 `X-Auth-Request-User: spoofed-admin`을 보내도 upstream 입력은 oauth2-proxy가 확인한 실제 user가 된다.
|
||||
|
||||
upstream이 받는 요청에서 브라우저가 보낸 헤더와 edge가 만든 헤더는 구분되지 않는다. 그래서 이 덮어쓰기가 edge에서 끝나야 한다.
|
||||
|
||||
## upstream은 두 겹을 확인한다
|
||||
|
||||
Spring controller는 헤더 두 개를 함께 본다.
|
||||
|
||||
```text label="/edge/me의 확인 순서"
|
||||
1. X-Auth-Request-User가 blank인지 확인
|
||||
2. X-Internal-Auth-Token을 읽는다
|
||||
3. 설정된 token과 MessageDigest.isEqual로 비교
|
||||
4. 둘 다 유효하면 allowlist된 identity field만 응답에 넣는다
|
||||
```
|
||||
|
||||
`MessageDigest.isEqual`은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 비교다.
|
||||
|
||||
user 헤더가 없거나 internal token이 틀리면 401이다.
|
||||
|
||||
```json label="신뢰 조건을 만족하지 못한 응답"
|
||||
{
|
||||
"error": "trusted edge authentication is required"
|
||||
}
|
||||
```
|
||||
|
||||
이 검사는 Spring Security의 `/edge/**` rule이 아니라 controller가 직접 한다. 현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 있어서, 새 edge endpoint를 추가하면서 같은 검사를 부르지 않으면 보호가 자동으로 따라오지 않는다.
|
||||
|
||||
## 세 방어선이 각각 막는 것
|
||||
|
||||
```text label="AP4가 사용하는 세 방어선"
|
||||
network isolation : app 8081과 oauth2-proxy 4180을 host에 publish하지 않는다
|
||||
header overwrite : client가 보낸 동명 헤더를 Nginx 값으로 덮어쓴다
|
||||
internal token : upstream이 edge를 거쳤다는 추가 신호를 확인한다
|
||||
```
|
||||
|
||||
controller의 shared token만으로는 외부에서 app과 oauth2-proxy에 직접 닿지 못하게 할 수 없다. network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 경우를 걸러 내지 못한다.
|
||||
|
||||
## 지금 구성이 보여 주지 않는 것
|
||||
|
||||
general `location /`도 `proxy_pass http://app:8081/edge/me`를 쓴다. `/orders/123` 같은 임의 upstream path를 보존하는 범용 reverse proxy가 아니다. auth-request와 header trust를 관찰하는 fixture다.
|
||||
|
||||
실제 upstream을 붙이면 URI rewrite, request body, timeout, retry, response header, logout, 상태 변경 요청 보호를 따로 설계해야 한다. 현재 edge 응답은 user와 email만 전달하고 role, groups, tenant, token expiry는 전달하지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
---
|
||||
id: d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719
|
||||
kind: CONCEPT
|
||||
slug: idp-brokering
|
||||
title: 외부 IdP Brokering의 동작
|
||||
topic: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 identity brokering
|
||||
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
|
||||
---
|
||||
|
||||
# 외부 IdP Brokering의 동작
|
||||
|
||||
브로커는 외부 IdP의 응답을 검증해 자기 realm의 identity로 연결한 뒤, 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 code와 token은 언제나 브로커가 발급한 것이므로, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer는 바뀌지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **외부 IdP 연동과 Application 인증 구조의 경계**
|
||||
이 동작을 경계 기준으로 정리한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
upstream IdP session과 애플리케이션 상태를 구분하는 기준이다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
브로커가 발급하는 code가 지나는 endpoint다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 두 개의 OAuth 왕복이 이어진다
|
||||
|
||||
사용자가 브로커 로그인 화면에서 외부 IdP를 고르면 인증이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이이고, 뒤의 왕복은 애플리케이션과 브로커 사이다.
|
||||
|
||||
```text label="brokering 변환 순서"
|
||||
Google identity assertion
|
||||
→ Keycloak broker validation
|
||||
→ provider alias + upstream sub로 account identity 결정
|
||||
→ Keycloak local user/session
|
||||
→ Keycloak authorization code
|
||||
→ AP1·AP2·AP3·AP4 중 선택한 downstream 경계
|
||||
```
|
||||
|
||||
브라우저가 upstream authorization을 수행하고, 브로커가 그 응답을 검증해 local identity와 연결한다. 그다음 애플리케이션으로 나가는 데이터는 다시 브로커가 만든다.
|
||||
|
||||
## 애플리케이션이 상대하는 issuer는 그대로다
|
||||
|
||||
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|
||||
|---|---|---|
|
||||
| 외부 IdP | upstream identity assertion | 브로커 |
|
||||
| 브로커 | authorization code, access·ID token | 애플리케이션과 Resource Server |
|
||||
|
||||
AP1 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 교환하는 code의 issuer도 브로커이며, AP4의 oauth2-proxy가 연결하는 OIDC provider도 브로커다. 애플리케이션은 외부 IdP의 token을 받지 않는다.
|
||||
|
||||
그래서 소셜 로그인을 붙여도 브라우저가 token을 받는지, 어느 계층이 API를 부르는지는 바뀌지 않는다. 그 선택은 네 패턴 중 무엇을 골랐는지가 정한다.
|
||||
|
||||
## account identity를 정하는 key
|
||||
|
||||
브로커가 upstream 사용자를 local user와 연결할 때 쓰는 안정적인 key는 provider alias와 upstream `sub`의 조합이다.
|
||||
|
||||
email은 key가 아니다. upstream email이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 email의 소유권을 증명하지 않은 상태에서 계정이 합쳐진다. 계정 연결은 인증 구조와 분리된 별도 설계 항목이다.
|
||||
|
||||
## 경계를 섞으면 생기는 일
|
||||
|
||||
외부 IdP를 애플리케이션 인증 구조 하나로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다. 두 경계는 검증 방법이 다르다.
|
||||
|
||||
비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
|
||||
|
||||
경계가 새는지는 다음 지점에서 본다. UI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.
|
||||
|
||||
외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다.
|
||||
|
||||
## 현재 검증한 범위
|
||||
|
||||
지금 자동화는 controllable mock OIDC provider로 broker와 claim mapping 계약을 확인한다. 실제 Google 계정, public HTTPS callback, consent와 production domain policy를 통과했다는 뜻은 아니다.
|
||||
|
||||
upstream IdP 검증 범위와 애플리케이션 credential 경계를 분리해서 적어야 이 사실 경계가 유지된다.
|
||||
|
||||
<!-- body:end -->
|
||||
Reference in New Issue
Block a user