docs(keycloak-session-store): remake all 28 diagrams through the techviz pipeline
The originating repository's SVGs were drawn by hand and every one of them
put a title, a subtitle and an explanation band inside the canvas. This
repository forbids both, so they could not be carried over — the whole set
was rebuilt through the skill's pipeline instead.
Each diagram went through prepare, references, prompt, a VizSpec 1.1 citing
document line ranges, lint, and render. All 28 pass lint and produce the
same eight formats the existing keycloak project has. Sentences moved out of
the canvas into <desc> and the paragraph beside each figure; the drawings
carry names only.
Two lint rules did real work rather than formatting work:
edge-through-node caught arrows crossing an unrelated
node and implying an adjacency that
does not exist — four diagrams had to
be restructured, not just relaid out
evidence-outside-prepared-context caught a diagram citing another
section; its anchor moved from B-0 to
B-1 so all three sections it draws on
are inside the prepared context
lab-topology also had to change profile: its context offers a different
candidate set, and query-fanout with shard roles is what the section
actually shows — one entry point spreading to two Keycloak nodes.
The document now carries all 28 inline, one per claim that needed one, and
the section recording what was still missing is updated: the diagram gap is
closed, Studio records remain.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b2963105a8
commit
75bed382c8
+47
-102
@@ -110,15 +110,16 @@ HTTP : o
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
이 기록은 브라우저에서 OAuth 토큰을 없앤 구조에서 브라우저에 무엇이 남고 서버가 무엇을 더 맡게 되는지 확인한 것이다. BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 서버이고, 여기서는 로그인과 토큰 보관까지 이 서버가 맡는다. 먼저 브라우저에 남는 값부터 보고, 그 값으로 시작한 요청이 보호 자원 서버(Resource Server)로 가는 Bearer 요청이 되기까지를 따라간 다음, 상태를 바꾸는 요청에서 새로 필요해진 검사와 아직 구현하지 않은 항목을 적는다.
|
||||
|
||||
## BFF가 Token을 보관하고 Resource Server를 호출하는 방식
|
||||
## 브라우저에는 쿠키 2개만 남는다
|
||||
|
||||
:::evidence key="ap3-bff-custody-82fa18bd" alt="브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
|
||||
:::
|
||||
|
||||
브라우저는 OAuth token으로 Resource Server를 호출하지 않는다. access token과 refresh token은 BFF의 authorized client가 보관하고 Resource Server 호출도 BFF가 수행한다.
|
||||
로그인은 브라우저가 BFF의 `/oauth2/authorization/keycloak`을 여는 것으로 시작한다. 인가 요청에 실리는 클라이언트는 `bff-confidential`이고 `code_challenge_method`는 `S256`이다. 가로챈 authorization code를 그대로 바꿔 가지 못하도록 PKCE(Proof Key for Code Exchange)를 함께 걸었다. 다만 그 코드를 토큰으로 바꾸는 쪽은 브라우저가 아니다. BFF가 서버끼리 통신하는 자리에서 `client_secret_basic`으로 토큰 엔드포인트를 부르고, 받은 액세스 토큰과 리프레시 토큰은 `OAuth2AuthorizedClientService`가 관리하는 authorized client에 남는다. 브라우저가 받는 것은 `/`로 돌아가는 리다이렉트와 `AP3_SESSION` 쿠키뿐이다.
|
||||
|
||||
## 브라우저에서 사용하는 값
|
||||
그래서 브라우저에 남는 값은 쿠키 2개다. `AP3_SESSION`에는 `HttpOnly`가 붙어 있어 브라우저에서 실행되는 스크립트가 값을 읽을 수 없고, 대신 BFF로 요청을 보낼 때마다 브라우저가 알아서 붙인다. `XSRF-TOKEN`에 `HttpOnly`를 붙이지 않은 것은 상태를 바꾸는 요청에서 스크립트가 이 값을 읽어 `X-XSRF-TOKEN` 헤더에 다시 넣어야 하기 때문이다. 액세스 토큰과 리프레시 토큰 2개는 어느 쪽으로도 브라우저에 내려가지 않는다.
|
||||
|
||||
| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |
|
||||
|---|---|---|
|
||||
@@ -127,17 +128,11 @@ HTTP : o
|
||||
| access token | x | x |
|
||||
| refresh token | x | x |
|
||||
|
||||
`AP3_SESSION`은 HttpOnly이기 때문에 JavaScript에서 직접 읽을 수 없다. 하지만 BFF로 요청을 보내면 브라우저가 cookie를 자동으로 포함한다.
|
||||
토큰 원문이 브라우저에서 사라졌다고 XSS(Cross-Site Scripting)까지 없어지는 것은 아니다. 같은 출처에서 실행되는 악성 스크립트는 `AP3_SESSION`을 직접 읽지 못해도 브라우저가 쿠키를 붙인 채 BFF를 부르게 만들 수 있고, 스크립트가 읽을 수 있는 `XSRF-TOKEN`에도 접근한다. 이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰 원문이 브라우저 스크립트에서 새어 나가 다른 클라이언트나 보호 자원 서버 직접 호출에 다시 쓰이는 범위다.
|
||||
|
||||
`XSRF-TOKEN`은 JavaScript가 읽은 값을 요청 헤더에도 넣어야 하기 때문에 HttpOnly가 아니다.
|
||||
### 쿠키 하나로 시작한 요청이 Bearer 요청이 된다
|
||||
|
||||
same-origin의 악성 script도 같은 방식으로 BFF를 호출할 수 있다. `AP3_SESSION`을 직접 읽지는 못해도 브라우저가 cookie를 요청에 붙이고, JavaScript에서 읽을 수 있는 `XSRF-TOKEN`에도 접근할 수 있다.
|
||||
|
||||
브라우저에 access token과 refresh token 원문을 전달하지 않는 것과 XSS를 막는 것은 별개의 문제다.
|
||||
|
||||
## AP3_SESSION으로 Access Token을 찾는 과정
|
||||
|
||||
브라우저가 `/bff/api/me`를 호출할 때는 `Authorization` 헤더가 없고 JavaScript에서도 access token을 다루지 않는다.
|
||||
브라우저가 `/bff/api/me`를 부를 때 요청에 붙는 자격 증명은 쿠키뿐이라, `Authorization` 헤더도 없고 브라우저 코드가 액세스 토큰을 변수에 담는 자리도 없다.
|
||||
|
||||
```http label="브라우저 입력 — cookie 하나"
|
||||
GET http://localhost:8083/bff/api/me
|
||||
@@ -145,7 +140,7 @@ Accept: application/json
|
||||
Cookie: AP3_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
`AP3_SESSION` 안에 token이 들어 있는 것은 아니다. 이 cookie로 HttpSession을 찾고, HttpSession에 저장된 `SecurityContext`에서 현재 사용자의 `Authentication`을 확인한다.
|
||||
`AP3_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키로 `HttpSession`을 찾고, 거기 저장된 `SecurityContext`에서 지금 로그인한 사용자의 `Authentication`을 꺼낸 다음, 그 이름으로 토큰 저장소를 조회한다.
|
||||
|
||||
```text label="cookie에서 Bearer까지"
|
||||
AP3_SESSION
|
||||
@@ -157,32 +152,26 @@ AP3_SESSION
|
||||
→ access token + refresh token
|
||||
```
|
||||
|
||||
`BffController.currentUser(Authentication)`는 `OAuth2AuthorizeRequest`를 만들고 `OAuth2AuthorizedClientManager.authorize()`를 호출한다.
|
||||
이 조회를 하는 코드가 `BffController.currentUser(Authentication)`인데, 클라이언트 등록 이름 `"keycloak"`과 현재 `Authentication`으로 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`에 넘긴다. 이 자리에 놓인 manager는 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고, 코드 교환과 refresh를 각각 맡는 provider 2개를 함께 쓴다. 그래서 액세스 토큰이 만료됐고 리프레시 토큰이 남아 있으면 서버끼리 갱신을 시도할 수 있다. 저장소에 그 항목이 없거나 쓸 수 있는 액세스 토큰이 없으면 여기서 401이 되고, 이유로는 `No authorized Keycloak client is available`가 붙는다.
|
||||
|
||||
manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code provider와 refresh-token provider가 함께 구성되어 있다.
|
||||
|
||||
authorized client나 필요한 access token을 찾을 수 없으면 401이 된다.
|
||||
|
||||
access token을 찾으면 BFF의 `RestClient`가 Resource Server 요청을 만든다.
|
||||
액세스 토큰을 얻으면 BFF의 `RestClient`가 보호 자원 서버로 보낼 요청을 새로 조립한다.
|
||||
|
||||
```http label="cookie로 조회된 토큰을 넣어서 조립"
|
||||
GET http://app:8081/api/me
|
||||
Authorization: Bearer <server-held-access-token>
|
||||
```
|
||||
|
||||
브라우저에서 받은 `AP3_SESSION`을 Resource Server에 전달하는 것은 아니다. BFF가 authorized client에서 access token을 찾은 다음 `Authorization: Bearer` 헤더를 새로 만들어 Resource Server에 보낸다.
|
||||
|
||||
`AP3_SESSION`은 브라우저와 BFF 사이에서 사용하고, Bearer access token은 BFF와 Resource Server 사이에서 사용한다.
|
||||
브라우저가 보낸 `AP3_SESSION`은 이 요청에 실리지 않는다. BFF가 세션을 애플리케이션 쪽 자격 증명으로 소비하고, 보호 자원 서버가 이해하는 Bearer 자격 증명으로 바꾸기 때문이다. 보호 자원 서버는 AP1·AP2와 같은 무상태 JWT 경로에서 서명과 issuer, 시각, `keycloak-pattern-api` audience를 확인한 뒤 `subject`·`username`·`issuer`·`audience` 필드 4개짜리 JSON을 돌려주고, BFF는 그 응답을 그대로 브라우저에 중계한다. 그래서 `AP3_SESSION`은 브라우저와 BFF 사이에서만 쓰이고 Bearer 액세스 토큰은 BFF와 보호 자원 서버 사이에서만 쓰인다.
|
||||
|
||||
:::warning
|
||||
|
||||
Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.
|
||||
Compose는 학습 편의를 위해 보호 자원 서버의 8081을 호스트 포트로도 내보낸다. 테스트가 확인하는 것은 AP3 화면이 8081을 직접 부르지 않는다는 것뿐이고, 모든 클라이언트가 BFF만 지나도록 네트워크로 강제했다는 뜻은 아니다.
|
||||
|
||||
:::
|
||||
|
||||
## `browserTokenCount: 0`만으로 확인할 수 없는 부분
|
||||
### `browserTokenCount: 0`이 증명하지 못하는 것
|
||||
|
||||
`/bff/token-boundary`는 server-side token 저장 상태를 다음과 같이 반환한다.
|
||||
`/bff/token-boundary`는 서버가 토큰을 들고 있는지를 필드 6개짜리 JSON으로 알려 준다.
|
||||
|
||||
```json label="/bff/token-boundary 응답"
|
||||
{
|
||||
@@ -195,9 +184,7 @@ Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish
|
||||
}
|
||||
```
|
||||
|
||||
여기서 `browserTokenCount: 0`은 브라우저를 직접 검사해서 나온 값이 아니다. controller에 들어 있는 literal 값이다.
|
||||
|
||||
그래서 이 값과 별도로 브라우저를 확인했다. 로그인 이후 개발자 도구에서 network 요청을 확인했을 때 Keycloak token endpoint 호출이 없었고 Resource Server의 8081을 직접 호출하는 요청도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.
|
||||
이 가운데 `browserTokenCount: 0`은 브라우저를 검사해서 센 값이 아니라 컨트롤러가 응답에 그대로 써 넣는 고정값이다. 그래서 이 필드 하나로는 브라우저에 토큰이 없다는 것을 보일 수 없어 브라우저 쪽을 따로 확인했다. 로그인 뒤 개발자 도구에서 요청 목록을 봤을 때 Keycloak 토큰 엔드포인트 호출이 없었고, 보호 자원 서버의 8081을 직접 부르는 요청도 없었다. `localStorage`와 `sessionStorage` 어디에도 `accessToken`·`refreshToken` 문자열이 없었다.
|
||||
|
||||
```text label="같은 주장에 대한 두 종류의 근거"
|
||||
self-report /bff/token-boundary → browserTokenCount: 0
|
||||
@@ -205,15 +192,13 @@ external observation 브라우저 network → token endpoint 없음
|
||||
Web Storage → token 문자열 없음
|
||||
```
|
||||
|
||||
`browserTokenCount: 0` 응답과 실제 브라우저에서 확인한 결과는 따로 기록한다.
|
||||
앞의 것은 서버가 자기 상태를 스스로 보고한 값이고 뒤의 것은 브라우저에서 관찰한 값이라, 두 근거는 따로 기록한다. 이 엔드포인트는 `OAuth2AuthorizedClientManager.authorize()`를 부르지 않고 `OAuth2AuthorizedClientService`에서 저장된 항목을 직접 조회한다. 그래서 여기를 호출하는 것만으로 액세스 토큰이 갱신되지는 않고, 인증된 세션인데 그 항목이 없으면 서버 보관 여부를 알리는 값 2개가 false인 200이 나올 수도 있다. 어느 경우에도 토큰 원문은 이 응답에 들어가지 않는다.
|
||||
|
||||
이 endpoint는 `OAuth2AuthorizedClientManager.authorize()`를 호출하지 않고 `OAuth2AuthorizedClientService`에서 authorized client를 직접 조회한다. 따라서 이 endpoint를 호출하는 과정에서 refresh를 수행하지 않는다.
|
||||
## 쿠키가 자동으로 붙는 요청을 CSRF 토큰으로 가른다
|
||||
|
||||
## 상태 변경 요청에서 CSRF를 확인하는 방식
|
||||
브라우저는 BFF로 가는 요청마다 세션 쿠키를 자동으로 붙이고 상태를 바꾸는 POST에서도 마찬가지라서, 세션 쿠키만 확인해서는 그 요청이 사용자가 보던 화면에서 시작한 것인지 알 수 없다. CSRF(Cross-Site Request Forgery) 토큰은 그 판단을 서버가 하려고 상태 변경 요청에서만 추가로 대조하는 값이다.
|
||||
|
||||
브라우저는 session cookie를 요청마다 자동으로 전송한다. 상태를 변경하는 POST 요청에서도 동일하게 cookie가 포함된다.
|
||||
|
||||
POST를 보내기 전에 `/bff/csrf`를 호출하면 다음 `XSRF-TOKEN` cookie를 받는다.
|
||||
POST를 보내기 전에 `/bff/csrf`를 부르면 응답 헤더로 `XSRF-TOKEN` 쿠키를 받는다.
|
||||
|
||||
```http label="응답 헤더 — cookie에는 raw 값이 들어간다"
|
||||
HTTP/1.1 200 OK
|
||||
@@ -222,7 +207,7 @@ Pragma: no-cache
|
||||
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
|
||||
```
|
||||
|
||||
응답 본문에도 CSRF 관련 정보가 들어간다.
|
||||
같은 응답의 본문에도 CSRF 정보가 들어 있는데, 본문의 `token`은 쿠키에 들어간 값과 같은 문자열이 아니다.
|
||||
|
||||
```json label="응답 본문 — 여기 token은 가려진 값이다"
|
||||
{
|
||||
@@ -232,14 +217,10 @@ Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
|
||||
}
|
||||
```
|
||||
|
||||
cookie의 `XSRF-TOKEN`과 응답 본문의 `token`은 같은 문자열이 아니다.
|
||||
|
||||
:::evidence key="ap3-csrf-split-501dd1f7" alt="BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." caption="" zoom="true"
|
||||
:::
|
||||
|
||||
`CookieCsrfTokenRepository.withHttpOnlyFalse()`가 cookie에 raw 값을 넣는다. `XorCsrfTokenRequestAttributeHandler`가 request attribute로 노출되는 token을 XOR와 Base64로 가리기 때문에 응답 본문에서는 다른 문자열이 보인다.
|
||||
|
||||
SPA에서는 응답 본문의 `token`을 요청 헤더 값으로 사용하지 않는다. 본문에서는 `headerName`을 확인하고 `document.cookie`에서 raw `XSRF-TOKEN` 값을 읽어 해당 헤더에 넣는다.
|
||||
값이 갈리는 것은 두 자리를 서로 다른 구성요소가 채우기 때문이다. 쿠키는 `CookieCsrfTokenRepository.withHttpOnlyFalse()`가 만들면서 원래 값을 그대로 넣는 반면, 본문에 실리는 값은 `XorCsrfTokenRequestAttributeHandler`가 요청 속성으로 노출하는 토큰이라 XOR와 Base64로 가려진 상태다. 그래서 브라우저 쪽 코드는 본문의 `token`을 헤더 값으로 쓰지 않고, 본문에서는 `headerName`만 읽은 다음 실제 값은 `document.cookie`에서 `XSRF-TOKEN`의 raw 값을 꺼내 그 헤더에 넣는다.
|
||||
|
||||
```text label="세 자리의 값이 서로 다르다"
|
||||
body.token masked token
|
||||
@@ -247,32 +228,36 @@ cookie XSRF-TOKEN raw token
|
||||
X-XSRF-TOKEN raw token
|
||||
```
|
||||
|
||||
```http label="다음 요청 헤더에 X-XSRF-TOKEN가 들어간다"
|
||||
POST /bff/theme HTTP/1.1
|
||||
Host: localhost:8083
|
||||
Content-Type: application/json
|
||||
같은 CSRF 자격 증명이 놓이는 자리는 3개이고 그중 같은 문자열인 것은 쿠키와 요청 헤더 2개라, 정상 요청은 세션 쿠키와 `XSRF-TOKEN` 쿠키에 더해 쿠키에서 읽은 값을 담은 `X-XSRF-TOKEN` 헤더를 함께 싣는다.
|
||||
|
||||
```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: <raw-csrf-token>
|
||||
X-XSRF-TOKEN: <same-raw-csrf-token>
|
||||
|
||||
theme=dark
|
||||
```
|
||||
|
||||
```json label="요청 본문"
|
||||
컨트롤러보다 Spring CSRF 필터가 먼저 돈다. 필터가 저장소의 기대값과 제출된 헤더를 비교해서 헤더가 없거나 값이 다르면 컨트롤러는 실행되지 않고 403이 되고, 통과한 요청만 `theme` 파라미터로 묶여 들어간다. 이 나눔을 맞추는 코드가 `SpaCsrfTokenRequestHandler`인데, 기대하는 헤더가 있으면 plain resolver로 제출된 raw 값을 읽고 없으면 XOR resolver 경로를 쓴다.
|
||||
|
||||
```json label="컨트롤러가 돌려주는 값"
|
||||
{
|
||||
"theme":"dark"
|
||||
"updated": true,
|
||||
"theme": "dark",
|
||||
"principal": "regular-user"
|
||||
}
|
||||
```
|
||||
|
||||
`SpaCsrfTokenRequestHandler`는 응답으로 노출하는 token 형태와 요청에서 확인하는 token 형태를 나눠 처리한다. 요청에서는 `X-XSRF-TOKEN` 헤더로 전달된 raw 값을 확인한다.
|
||||
|
||||
:::note
|
||||
|
||||
응답 본문의 token을 가리는 것은 BREACH 완화를 위한 처리다. HTTP 응답 압축 크기의 차이를 이용해 응답 안의 비밀값을 추측하는 것을 어렵게 하기 위해 응답에 노출되는 token 형태를 매번 다르게 만든다.
|
||||
|
||||
:::
|
||||
|
||||
## SameSite와 CSRF Token을 각각 확인한 경우
|
||||
### 쿠키를 빼는 것과 요청을 거절하는 것은 다른 방어선이다
|
||||
|
||||
네 가지 요청으로 동작을 확인했다.
|
||||
요청 4개로 두 방어선이 각각 어디서 걸리는지 확인했다.
|
||||
|
||||
| 입력 | 막는 것 | 응답 |
|
||||
|---|---|---|
|
||||
@@ -281,65 +266,25 @@ X-XSRF-TOKEN: <raw-csrf-token>
|
||||
| cross-site POST | SameSite | cookie 누락 |
|
||||
| same-origin, 값 일치 | 통과 | 200 |
|
||||
|
||||
same-origin과 same-site 다른 port 요청에는 session cookie가 포함됐다. CSRF 헤더가 없었기 때문에 두 요청은 403이 됐다.
|
||||
같은 출처에서 온 요청과 포트만 다른 same-site 요청에는 세션 쿠키가 그대로 실렸고, `X-XSRF-TOKEN` 헤더가 없었기 때문에 둘 다 403이 됐다. `SameSite`는 쿠키를 cross-site 요청에 실을지 브라우저가 정하는 정책이고, CSRF 토큰은 그 요청을 받아들일지 서버가 정하는 검사다. 포트가 달라도 site 계산상 같은 site가 되는 경우가 있어서, 쿠키가 빠지는 것만으로는 same-site 요청까지 막지 못한다. `127.0.0.1`에서 `localhost`로 보낸 cross-site POST에서는 `AP3_SESSION` 자체가 요청에 실리지 않았고, 이 요청에서 확인한 것은 최종 상태 코드가 아니라 쿠키가 빠졌다는 사실이다.
|
||||
|
||||
cross-site POST에서는 `AP3_SESSION` 자체가 요청에 포함되지 않았다.
|
||||
## 브라우저에서 덜어낸 일이 BFF로 옮겨 온다
|
||||
|
||||
port가 다르더라도 site 기준으로는 같은 site가 될 수 있기 때문에 SameSite만으로 same-site 요청까지 막는 것은 아니다.
|
||||
브라우저가 토큰을 들지 않게 되면서 BFF가 새로 맡는 일은 8개인데, 그중 지금 구현에 들어 있는 것은 상태 변경 요청의 CSRF 검증 1개다. 재시작 뒤 로그인 유지, 레플리카가 함께 쓰는 세션, 저장한 토큰 암호화, 로그아웃 때 세션과 authorized client 삭제, downstream 오류를 화면 오류로 바꾸는 처리, 타임아웃·재시도·서킷 브레이커, 경로별 인가 7개는 아직 없다.
|
||||
|
||||
cross-site POST에서는 최종 status보다 `AP3_SESSION` cookie가 요청에 포함되지 않았다는 부분을 확인했다.
|
||||
지금 `HttpSession`과 `OAuth2AuthorizedClientService`는 한 프로세스의 메모리를 쓰기 때문에, 재시작 뒤 로그인을 유지하는 것과 여러 레플리카가 같은 세션을 쓰는 것은 이 상태를 어디에 둘지 정해야 처리할 수 있다. 토큰을 찾는 키도 세션 ID가 아니라 클라이언트 등록 이름과 principal 이름이다. 그래서 같은 사용자가 여러 브라우저 세션에서 로그인하면 그 세션들이 같은 항목을 함께 쓰거나, 나중에 로그인한 쪽이 덮어쓸 수 있다.
|
||||
|
||||
## BFF에서 추가로 처리해야 하는 항목
|
||||
CSRF 검증을 눈으로 보려고 둔 `theme` 값도 사용자별 저장소에 들어가지 않는다. 컨트롤러 안의 `AtomicReference` 1개에 들어가기 때문에 한 사용자가 `dark`로 바꾸면 같은 프로세스의 다른 사용자도 그 값을 읽고, 재시작하면 기본값 `system`으로 돌아간다. 실제 사용자 상태로 쓰려면 인증된 subject를 키로 하는 저장소와 인가 경계로 옮겨야 한다.
|
||||
|
||||
현재 구조에서 확인한 항목은 다음과 같다.
|
||||
### 커밋된 테스트가 확인하도록 정의한 17개
|
||||
|
||||
| 새로 생긴 책임 | 현재 구현에 있나 |
|
||||
|---|---|
|
||||
| 상태 변경 요청의 CSRF 검증 | o |
|
||||
| 재시작 뒤 로그인 유지 | x |
|
||||
| replica가 함께 쓰는 session | x |
|
||||
| 저장 token 암호화 | x |
|
||||
| logout 때 session과 authorized client 삭제 | x |
|
||||
| downstream 오류를 화면 오류로 변환 | x |
|
||||
| timeout · retry · circuit breaker | x |
|
||||
| 경로별 인가 | x |
|
||||
이 기록에서 확인했다고 적은 것은 마지막 실행 성적표가 아니라 커밋된 자동 테스트가 확인하도록 정의한 계약이다. 항목은 17개이고 그중 10개를 확인했다.
|
||||
|
||||
현재 구현된 것은 CSRF 검증이다. 나머지 항목은 아직 구현하지 않았다.
|
||||
인가 요청에는 `bff-confidential` 클라이언트와 `S256` challenge가 있어야 하고, 로그인 뒤 브라우저 요청 목록에는 Keycloak 토큰 엔드포인트 호출과 8081 직접 호출이 없어야 한다. 쿠키는 `AP3_SESSION`이고 `HttpOnly`와 `SameSite=Lax`가 붙어 있어야 하며 Web Storage는 비어 있어야 한다. `/bff/token-boundary`의 서버 보관 값 2개는 true여야 하고 `/bff/api/me`는 200과 함께 `username`과 기대한 audience를 돌려줘야 한다. CSRF 헤더가 없는 POST는 403, 쿠키에서 읽은 값을 헤더에 넣은 POST는 200, cross-site POST에서는 쿠키가 빠져야 한다.
|
||||
|
||||
현재 HttpSession과 `OAuth2AuthorizedClientService`는 process-local memory를 사용한다.
|
||||
나머지 7개는 이 계약 밖이라, `theme` 값의 사용자별 격리와 영속성, 여러 레플리카가 함께 쓰는 세션 저장소, 저장한 토큰 암호화, 로그아웃, downstream 401을 어떤 모양으로 전달하는지, 타임아웃과 경로별 인가는 확인하지 않았다.
|
||||
|
||||
authorized client는 session ID로 찾는 것이 아니라 client registration 이름과 principal name으로 찾는다. 따라서 같은 principal이 여러 브라우저 session에서 로그인한 경우 같은 authorized client 항목을 공유하거나 덮어쓸 수 있다.
|
||||
|
||||
## 자동 테스트에서 확인한 범위
|
||||
|
||||
아래 항목은 커밋된 자동 테스트에서 확인하도록 정의한 내용이다.
|
||||
|
||||
| 항목 | 확인했나 |
|
||||
|---|---|
|
||||
| `bff-confidential` + S256 challenge | o |
|
||||
| 브라우저 요청에 token endpoint 없음 | o |
|
||||
| 브라우저 요청에 8081 직접 호출 없음 | o |
|
||||
| `AP3_SESSION` HttpOnly · SameSite=Lax | o |
|
||||
| Web Storage 비어 있음 | o |
|
||||
| server access·refresh boolean이 true | o |
|
||||
| `/bff/api/me` 200 · username · audience | o |
|
||||
| CSRF 헤더 없는 POST 403 | o |
|
||||
| raw 값을 헤더에 넣은 POST 200 | o |
|
||||
| cross-site POST에서 cookie 누락 | o |
|
||||
| preference의 사용자별 격리 | x |
|
||||
| preference 영속성 | x |
|
||||
| 공유 session store | x |
|
||||
| 저장 token 암호화 | x |
|
||||
| logout | x |
|
||||
| downstream 401의 전달 모양 | x |
|
||||
| timeout · 경로별 인가 | x |
|
||||
|
||||
## 이번 구현에서 확인한 결과
|
||||
|
||||
로그인 이후 브라우저 network에는 Keycloak token endpoint 호출이 없었고 `/bff/api/me` 요청에도 `Authorization: Bearer`가 없었다. 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, Resource Server에 보낼 access token은 BFF가 authorized client에서 찾아 사용했다.
|
||||
|
||||
상태 변경 요청에서는 session cookie가 자동으로 포함되기 때문에 CSRF token을 추가로 확인했다. 현재 session과 authorized client는 모두 BFF process memory에 저장된다.
|
||||
|
||||
브라우저가 OAuth token을 받으면 안 되고 backend가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA 구조가, 브라우저의 Resource Server 직접 호출을 유지해야 한다면 Mediator가 맞는다.
|
||||
브라우저가 OAuth 토큰을 받으면 안 되고 백엔드가 화면에 필요한 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 확인하는 것이 목적이면 SPA 구조가, 브라우저의 보호 자원 서버 직접 호출을 유지해야 한다면 Mediator가 맞는다.
|
||||
|
||||
대신 BFF는 요청을 넘겨 주기만 하는 프록시가 아니라 로그인 상태와 토큰을 든 보안 구성요소가 됐고, 화면의 모든 요청이 이곳을 지나므로 지연과 장애 지점도 여기로 모인다. 지금 구현은 그 비용을 단일 인스턴스 메모리와 검증 1개로만 보여 주고 있어서, 세션과 authorized client를 어디에 두고 저장한 토큰을 어떻게 암호화할지는 아직 정하지 않았다.
|
||||
<!-- body:end -->
|
||||
+4
-4
@@ -133,9 +133,9 @@ X-Internal-Auth-Token : attacker-controlled-token
|
||||
|
||||
문제는 같은 이름의 헤더를 브라우저도 직접 적어 보낼 수 있다는 점이다. upstream이 받는 요청에서 두 값은 이름도 형식도 같고, 어느 쪽이 붙였는지 적힌 자리가 없다. 그래서 이 구조의 문제는 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 점이다. 백엔드 포트가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면 공격자가 인증된 사용자처럼 보낼 수 있다.
|
||||
|
||||
### 위조 헤더를 얹은 요청이 200이면 뚫린 걸까?
|
||||
### 상태 코드가 아니라 응답의 user로 판정한다
|
||||
|
||||
로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹었다고 해 보자.
|
||||
로그인을 마친 브라우저가 정상 요청에 헤더 3개를 얹어 보냈다.
|
||||
|
||||
```http label="공격자가 보낸 요청"
|
||||
GET http://localhost:8088/api/edge
|
||||
@@ -149,7 +149,7 @@ X-Internal-Auth-Token: attacker-controlled-token
|
||||
|
||||
## 세 곳에서 나눠 막는다
|
||||
|
||||
헤더를 믿으려면 서로 독립된 세 곳에서 막아야 한다. 포트를 닫아 두었으면 헤더 검사는 없어도 되지 않을까? 세 곳은 각각 다른 구간을 맡고 있어서 어느 하나도 나머지 둘의 자리를 대신하지 못한다.
|
||||
헤더를 믿으려면 서로 독립된 세 곳에서 막아야 한다. 세 곳은 각각 다른 구간을 맡고 있어서 어느 하나도 나머지 둘의 자리를 대신하지 못한다.
|
||||
|
||||
### 밖에서 들어올 수 있는 길을 8088 하나로 줄인다
|
||||
|
||||
@@ -286,6 +286,6 @@ issuer는 요청을 보내기 위한 주소가 아니라 Keycloak이 발급한
|
||||
|
||||
지금 설정은 `/api/edge`와 `/`를 모두 `/edge/me`로 바꾸기 때문에 `/orders/123` 같은 임의 경로를 보존하는 범용 리버스 프록시가 아니고, 그래서 path와 method, body, streaming, websocket, 큰 헤더 동작은 입증하지 못했다.
|
||||
|
||||
지금까지 upstream이 `X-Auth-Request-User`의 출처를 구분할 수 없다는 문제와, 그것을 호스트 포트·헤더 덮어쓰기·내부 토큰 세 곳으로 나눠 막은 구성을 살펴봤다. 위조 헤더를 얹은 요청이 200을 받으면서도 응답의 `user`는 실제 사용자로 남는다는 것까지가 지금 확인한 범위이고, role 전달이나 시크릿 교체처럼 운영에서 먼저 정해야 할 6개는 그 밖에 있다.
|
||||
upstream이 OAuth를 몰라도 되는 대신, 이 구조에서 믿어야 하는 입력은 서명된 JWT 1개에서 3개로 늘었다. 그 3개를 지키는 일 가운데 내부 토큰 검사를 컨트롤러 밖 공통 경계로 옮기는 것과, 레플리카 사이에서 세션 시크릿을 배포하고 교체하는 방법을 정하는 것은 아직 남아 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
|
||||
Reference in New Issue
Block a user