216 lines
9.9 KiB
Markdown
216 lines
9.9 KiB
Markdown
## BFF가 Resource Server를 호출하는 흐름
|
|
|
|
:::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"
|
|
:::
|
|
|
|
브라우저는 BFF endpoint를 session cookie로 호출한다. BFF는 authorized client에서 access token을 가져와 Resource Server 요청의 `Authorization` 헤더를 만든다.
|
|
|
|
## 브라우저가 전송하는 session과 CSRF token
|
|
|
|
| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |
|
|
|---|---|---|
|
|
| AP3_SESSION | o | x |
|
|
| XSRF-TOKEN | o | o |
|
|
| access token | x | x |
|
|
| refresh token | x | x |
|
|
|
|
JavaScript는 `XSRF-TOKEN` cookie 값을 읽어 상태 변경 요청의 `X-XSRF-TOKEN` 헤더에 넣는다. 이 용도 때문에 `XSRF-TOKEN`에는 `HttpOnly`를 사용하지 않았다.
|
|
|
|
same-origin에서 악성 script가 실행되면 사용자의 session으로 BFF endpoint를 호출할 수 있고 `XSRF-TOKEN`도 읽을 수 있다. BFF 구조의 차이는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않는다는 점이다.
|
|
|
|
## Session으로 Authorized Client를 조회하는 과정
|
|
|
|
브라우저 요청에는 `Authorization` 헤더도 없고 코드에도 access token 지역 변수도 없다.
|
|
|
|
```http label="브라우저 입력 — cookie 하나"
|
|
GET http://localhost:8083/bff/api/me
|
|
Accept: application/json
|
|
Cookie: AP3_SESSION=<opaque-session-id>
|
|
```
|
|
|
|
cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.
|
|
|
|
```text label="cookie에서 Bearer까지"
|
|
AP3_SESSION
|
|
→ HttpSession
|
|
→ SecurityContext
|
|
→ Authentication.getName()
|
|
→ ("keycloak", principal name)
|
|
→ OAuth2AuthorizedClientService
|
|
→ access token + refresh token
|
|
```
|
|
|
|
`BffController.currentUser(Authentication)`는 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`를 호출한다. manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code와 refresh-token provider를 함께 사용하므로 만료된 access token의 갱신도 이 경로에서 처리한다.
|
|
|
|
없으면 401이 된다.
|
|
|
|
있으면 BFF의 `RestClient`가 downstream 입력을 **새로** 조립한다.
|
|
|
|
```http label="cookie로 조회된 토큰을 넣어서 조립"
|
|
GET http://app:8081/api/me
|
|
Authorization: Bearer <server-held-access-token>
|
|
```
|
|
|
|
`AP3_SESSION`은 downstream으로 전달되지 않는다.
|
|
BFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다.
|
|
두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다.
|
|
|
|
:::warning
|
|
|
|
Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.
|
|
|
|
:::
|
|
|
|
## browserTokenCount는 무엇을 증명하나
|
|
|
|
진단용 endpoint가 server custody를 boolean으로 보여 준다.
|
|
|
|
```json label="/bff/token-boundary 응답"
|
|
{
|
|
"pattern": "AP3-backend-for-frontend",
|
|
"principal": "regular-user",
|
|
"accessTokenStoredOnServer": true,
|
|
"refreshTokenStoredOnServer": true,
|
|
"browserTokenCount": 0,
|
|
"csrfProtectionEnabled": true
|
|
}
|
|
```
|
|
|
|
`browserTokenCount: 0`은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다.
|
|
|
|
밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.
|
|
|
|
```text label="같은 주장에 대한 두 종류의 근거"
|
|
self-report /bff/token-boundary → browserTokenCount: 0
|
|
external observation 브라우저 network → token endpoint 없음
|
|
Web Storage → token 문자열 없음
|
|
```
|
|
|
|
자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다.
|
|
|
|
이 endpoint는 manager의 `authorize()`를 호출하지 않고 `OAuth2AuthorizedClientService`를 직접 조회하므로 여기서는 refresh를 수행하지 않는다.
|
|
|
|
## cookie가 credential이면 CSRF가 필요하다
|
|
|
|
브라우저는 session cookie를 요청마다 자동으로 붙인다. `GET`만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다.
|
|
|
|
```http label="응답 헤더 — cookie에는 raw 값이 들어간다"
|
|
HTTP/1.1 200 OK
|
|
Cache-Control: no-store
|
|
Pragma: no-cache
|
|
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
|
|
```
|
|
|
|
```json label="응답 본문 — 여기 token은 가려진 값이다"
|
|
{
|
|
"headerName": "X-XSRF-TOKEN",
|
|
"parameterName": "_csrf",
|
|
"token": "<xor-masked-csrf-token>"
|
|
}
|
|
```
|
|
|
|
같은 endpoint가 두 값을 반환하게 되는데, 이 **둘은 같은 문자열이 아니다.**
|
|
|
|
:::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`을 찾아 헤더 값으로 넣는다.
|
|
|
|
```text label="CSRF token 표현 비교"
|
|
body.token masked token
|
|
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
|
|
|
|
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
|
X-XSRF-TOKEN: <raw-csrf-token>
|
|
```
|
|
|
|
```json label="요청 본문"
|
|
{
|
|
"theme":"dark"
|
|
}
|
|
```
|
|
|
|
`SpaCsrfTokenRequestHandler`가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다.
|
|
|
|
:::note
|
|
|
|
응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다.
|
|
|
|
:::
|
|
|
|
|
|
## SameSite와 CSRF token이 막는 입력
|
|
|
|
네 가지 입력으로 나눠 보면 둘이 갈린다.
|
|
|
|
| 입력 | 막는 것 | 응답 |
|
|
|---|---|---|
|
|
| same-origin, 헤더 없음 | CSRF token | 403 |
|
|
| same-site 다른 port, 헤더 없음 | CSRF token | 403 |
|
|
| cross-site POST | SameSite | cookie 누락 |
|
|
| same-origin, 값 일치 | 통과 | 200 |
|
|
|
|
앞의 두 요청에는 cookie가 포함되므로 CSRF token 검증이 필요하다. 셋째 요청은 cookie가 전송되지 않는다. **port가 달라도 site 계산상 같은 경우가 있어** SameSite만으로 둘째 요청을 차단할 수는 없다.
|
|
|
|
셋째 줄의 관측 지점은 최종 status가 아니라 **cookie가 요청에서 빠졌다는 부분**이다.
|
|
|
|
## BFF에서 관리해야 하는 항목
|
|
|
|
현재 BFF 구현에서 직접 관리하는 항목은 다음과 같다.
|
|
|
|
| 관리 항목 | 현재 구현 |
|
|
|---|---|
|
|
| 상태 변경 요청의 CSRF 검증 | o |
|
|
| 재시작 뒤 로그인 유지 | x |
|
|
| replica가 함께 쓰는 session | x |
|
|
| 저장 token 암호화 | x |
|
|
| logout 때 session과 authorized client 삭제 | x |
|
|
| downstream 오류를 화면 오류로 변환 | x |
|
|
| timeout · retry · circuit breaker | x |
|
|
| 경로별 인가 | x |
|
|
|
|
첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다.
|
|
|
|
저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는 **애플리케이션 수준 store**다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다.
|
|
|
|
|
|
## 확인한 것과 확인하지 않은 것
|
|
|
|
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.
|
|
|
|
| 항목 | 확인했나 |
|
|
|---|---|
|
|
| `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`으로 인증됐으며, 상태 변경 요청에는 CSRF token 검증을 적용했다. 현재 로그인 session과 authorized client는 BFF process memory에 저장된다.
|
|
|
|
브라우저에 OAuth token을 전달하지 않고 backend가 여러 API 호출을 조합해야 하는 요구에는 BFF가 맞다. 브라우저가 Resource Server를 직접 호출해야 한다면 SPA나 Mediator 구조를 검토한다.
|