9.9 KiB
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 지역 변수도 없다.
GET http://localhost:8083/bff/api/me
Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>
cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.
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 입력을 새로 조립한다.
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으로 보여 준다.
{
"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 문자열이 없었다.
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/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
{
"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을 찾아 헤더 값으로 넣는다.
body.token masked token
cookie XSRF-TOKEN raw token
X-XSRF-TOKEN raw 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>
{
"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 구조를 검토한다.