- generic [ref=f20e3]: - link "본문으로 건너뛰기" [ref=f20e4] [cursor=pointer]: - /url: "#main-content" - banner [ref=f20e5]: - generic [ref=f20e6]: - link "TechLog Studio" [ref=f20e7] [cursor=pointer]: - /url: /studio - text: TechLog - generic [ref=f20e8]: Studio - navigation "Studio 주 탐색" [ref=f20e10]: - link "작업본" [ref=f20e11] [cursor=pointer]: - /url: /studio/documents - link "게시 기록" [ref=f20e12] [cursor=pointer]: - /url: /studio/publications - link "새 문서" [ref=f20e13] [cursor=pointer]: - /url: /studio/documents/new - link "주제·프로젝트" [ref=f20e14] [cursor=pointer]: - /url: /studio/taxonomy - link "릴리즈" [ref=f20e15] [cursor=pointer]: - /url: /studio/releases - link "공개 사이트 보기" [ref=f20e16] [cursor=pointer]: - /url: / - button "로그아웃" [ref=f20e17] - main [ref=f20e18]: - generic [ref=f20e19]: - generic [ref=f20e20]: - region [ref=f20e21]: - generic [ref=f20e22]: - paragraph [ref=f20e23]: CASE · VERSION 29 - heading "문서 편집" [level=1] [ref=f20e24] - paragraph [ref=f20e25]: BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정 - region [ref=f20e26]: - generic [ref=f20e27]: - paragraph [ref=f20e28]: DOCUMENT - heading "기본 정보" [level=2] [ref=f20e29] - generic [ref=f20e30]: - generic [ref=f20e31]: - generic [ref=f20e32]: 제목 - textbox "제목" [ref=f20e33]: BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정 - generic [ref=f20e34]: - generic [ref=f20e35]: slug - textbox "slug" [ref=f20e36]: - /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈) - text: bff-session-csrf-responsibility - generic [ref=f20e37]: - generic [ref=f20e38]: 요약 - textbox "요약" [ref=f20e39]: "로그인 후 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, BFF가 server-side authorized client에서 access token을 조회해 Resource Server를 호출한다. 상태 변경 요청에는 `XSRF-TOKEN`과 `X-XSRF-TOKEN` 검증을 추가했다." - generic [ref=f20e40]: - generic [ref=f20e41]: Topic - combobox "Topic" [ref=f20e42]: - option "선택하지 않음" - option "OAuth/OIDC 인증 경계" [selected] - generic [ref=f20e43]: - generic [ref=f20e44]: Project - combobox "Project" [ref=f20e45]: - option "미지정" - option "Backend Clean Architecture" - option "KeyCloak Patterns" [selected] - option "Liner N + 1문제" - group "관계" [ref=f20e46]: - generic [ref=f20e48]: - generic [ref=f20e49]: - generic [ref=f20e50]: 관계 1 대상 - combobox "관계 1 대상" [ref=f20e51]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled] - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [selected] - option "BFF가 OAuth Token을 관리하는 조건" [disabled] - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled] - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f20e52]: - generic [ref=f20e53]: 관계 1 이유 - textbox "관계 1 이유" [ref=f20e54]: 이 기준이 요구하는 항목 중 무엇이 구현됐고 무엇이 구현되지 않았는지 - generic [ref=f20e55]: - button "위로" [disabled] [ref=f20e56] - button "아래로" [ref=f20e57] - button "삭제" [ref=f20e58] - generic [ref=f20e59]: - generic [ref=f20e60]: - generic [ref=f20e61]: 관계 2 대상 - combobox "관계 2 대상" [ref=f20e62]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled] - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "BFF가 OAuth Token을 관리하는 조건" [disabled] - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled] - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [selected] - option "Public Client와 Confidential Client 구분 기준" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f20e63]: - generic [ref=f20e64]: 관계 2 이유 - textbox "관계 2 이유" [ref=f20e65]: session cookie와 CSRF token, server-side token을 각각 다뤄야 하는 이유 - generic [ref=f20e66]: - button "위로" [ref=f20e67] - button "아래로" [ref=f20e68] - button "삭제" [ref=f20e69] - generic [ref=f20e70]: - generic [ref=f20e71]: - generic [ref=f20e72]: 관계 3 대상 - combobox "관계 3 대상" [ref=f20e73]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled] - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "BFF가 OAuth Token을 관리하는 조건" [disabled] - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled] - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" [selected] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f20e74]: - generic [ref=f20e75]: 관계 3 이유 - textbox "관계 3 이유" [ref=f20e76]: BFF 구조에서 필요한 CSRF 검증과 server-side 상태 저장 기준을 함께 다룬다 - generic [ref=f20e77]: - button "위로" [ref=f20e78] - button "아래로" [ref=f20e79] - button "삭제" [ref=f20e80] - generic [ref=f20e81]: - generic [ref=f20e82]: - generic [ref=f20e83]: 관계 4 대상 - combobox "관계 4 대상" [ref=f20e84]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled] - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "BFF가 OAuth Token을 관리하는 조건" [selected] - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled] - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f20e85]: - generic [ref=f20e86]: 관계 4 이유 - textbox "관계 4 이유" [ref=f20e87]: 이 결정의 구조를 실제로 실행해 본 문서 - generic [ref=f20e88]: - button "위로" [ref=f20e89] - button "아래로" [ref=f20e90] - button "삭제" [ref=f20e91] - generic [ref=f20e92]: - generic [ref=f20e93]: - generic [ref=f20e94]: 관계 5 대상 - combobox "관계 5 대상" [ref=f20e95]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [selected] - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "BFF가 OAuth Token을 관리하는 조건" [disabled] - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled] - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f20e96]: - generic [ref=f20e97]: 관계 5 이유 - textbox "관계 5 이유" [ref=f20e98]: 두 상태가 모두 process-local memory에 있다는 점이 질문의 시작이다 - generic [ref=f20e99]: - button "위로" [ref=f20e100] - button "아래로" [ref=f20e101] - button "삭제" [ref=f20e102] - generic [ref=f20e103]: - generic [ref=f20e104]: - generic [ref=f20e105]: 관계 6 대상 - combobox "관계 6 대상" [ref=f20e106]: - option "대상 선택" - option "인증 구조를 보안 성숙도 단계로 취급하지 않는다" - option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled] - option "Authorization Code Flow의 Endpoint와 Credential 이동 기준" - option "BFF 인증 구조 설계 기준" [disabled] - option "BFF가 OAuth Token을 관리하는 조건" [disabled] - option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [selected] - option "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" - option "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가" - option "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유" - option "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건" - option "외부 IdP Federation을 별도의 인증 구조로 세지 않는다" - option "외부 IdP Federation과 Application 인증 경계" - option "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조" - option "OAuth/OIDC 인증 패턴 선택 기준" [disabled] - option "OAuth Token과 Application Session을 구분하는 기준" [disabled] - option "Public Client와 Confidential Client 구분 기준" - option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가" - option "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계" - generic [ref=f20e107]: - generic [ref=f20e108]: 관계 6 이유 - textbox "관계 6 이유" [ref=f20e109]: session과 authorized client의 2가지 흐름 - generic [ref=f20e110]: - button "위로" [ref=f20e111] - button "아래로" [disabled] [ref=f20e112] - button "삭제" [ref=f20e113] - button "관계 추가" [ref=f20e114] - region [ref=f20e115]: - generic [ref=f20e116]: - paragraph [ref=f20e117]: CASE - heading "문제와 검증" [level=2] [ref=f20e118] - generic [ref=f20e119]: - generic [ref=f20e120]: - generic [ref=f20e121]: 문제 - textbox "문제" [ref=f20e122]: BFF에서는 confidential-client인 BFF서버가 code를 교환하고 access token과 refresh token은 server-side authorized client에 관리하게 된다. 브라우저에는 HttpOnly AP3_SESSION만 전달된다. 그런데 브라우저는 여전히 요청마다 cookie를 보낸다. cookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. BFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다. BFF가 OAuth token을 관리하도록 구성한 뒤 브라우저 요청 방식과 CSRF 처리, server-side 저장 상태를 확인했다. - generic [ref=f20e123]: - generic [ref=f20e124]: 결론 - textbox "결론" [ref=f20e125]: "상태 변경 요청에서 브라우저가 보내는 cookie는 `AP3_SESSION`과 `XSRF-TOKEN`이다. AP3_SESSION : HttpOnly, JavaScript 읽기 x XSRF-TOKEN : JavaScript 읽기 o 브라우저는 session cookie를 요청에 자동으로 포함한다. 상태 변경 요청에서는 CSRF token을 별도로 검증하며, JavaScript가 헤더 값을 만들 수 있도록 `XSRF-TOKEN` cookie에는 HttpOnly를 사용하지 않았다. BFF를 사용해도 same-origin XSS는 별도로 막아야 한다. 악성 script가 실행되면 현재 session으로 BFF를 호출할 수 있고 JavaScript에서 읽을 수 있는 CSRF cookie에도 접근할 수 있다. 다만 OAuth token 원문을 브라우저 JavaScript에 전달하지는 않는다. 현재 구현에서는 BFF가 CSRF 검증까지 처리한다. 재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x" - generic [ref=f20e126]: - generic [ref=f20e127]: 검증 환경 - textbox "검증 환경" [ref=f20e128]: "Keycloak 26.7.0 realms confidential, client_secret_basic PKCE S256 : o provider : authorization-code, refresh-token store : memory o CSRF : o HTTP : o" - generic [ref=f20e129]: - generic [ref=f20e130]: 재현 조건 - textbox "재현 조건" [ref=f20e131]: "1. UI에서 로그인하고 authorization request를 확인. client_id : bff-confidential code_challenge_method : S256 2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인. 3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지, Web Storage가 비었는지 확인. 4. /bff/token-boundary를 호출. accessTokenStoredOnServer : true refreshTokenStoredOnServer : true browserTokenCount : 0 csrfProtectionEnabled : true 5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인. 6. GET /bff/csrf로 XSRF-TOKEN cookie와 token metadata를 받는거 확인. 응답 본문의 token과 cookie 값이 같은 문자열이 아님을 확인. 7. session cookie는 있고 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인. 8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인. 9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인." - generic [ref=f20e132]: - generic [ref=f20e133]: 마지막 검증일 - textbox "마지막 검증일" [ref=f20e134]: 2026-08-25 - generic [ref=f20e135]: - generic [ref=f20e136]: 본문 Markdown - textbox "본문 Markdown" [ref=f20e137]: "## 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= ``` 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 ``` `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=; Path=/ ``` ```json label=\"응답 본문 — 여기 token은 가려진 값이다\" { \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_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=; XSRF-TOKEN= X-XSRF-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 구조를 검토한다." - group [ref=f20e138]: - paragraph [ref=f20e139]: EVIDENCE - heading "본문에 Asset 삽입" [level=3] [ref=f20e140] - paragraph [ref=f20e141]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다. - generic [ref=f20e142]: - generic [ref=f20e143]: - generic [ref=f20e144]: 업로드 종류 - combobox "업로드 종류" [ref=f20e145]: - option "이미지" [selected] - option "다이어그램" - option "첨부파일" - button "Asset 업로드" [ref=f20e146] - generic [ref=f20e147]: - search [ref=f20e148]: - generic [ref=f20e149]: Asset 검색 - generic [ref=f20e150]: - searchbox "Asset 검색" [ref=f20e151] - button "검색" [ref=f20e152] - generic [ref=f20e153]: - checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f20e154] - generic [ref=f20e155]: 삽입할 때 크게 보기 허용 - status [ref=f20e156]: 삽입할 수 있는 Asset 8개 - list [ref=f20e157]: - listitem [ref=f20e158]: - button "ap4-edge-trust-1cff2399" [ref=f20e159] - button "삭제" [ref=f20e160] - listitem [ref=f20e161]: - button "ap3-csrf-split-501dd1f7" [ref=f20e162] - button "삭제" [ref=f20e163] - listitem [ref=f20e164]: - button "ap3-bff-custody-82fa18bd" [ref=f20e165] - button "삭제" [ref=f20e166] - listitem [ref=f20e167]: - button "ap2-split-custody-779cb791" [ref=f20e168] - button "삭제" [ref=f20e169] - listitem [ref=f20e170]: - button "ap1-custody-v3-6e0376d2" [ref=f20e171] - button "삭제" [ref=f20e172] - listitem [ref=f20e173]: - button "ap1-custody-v2-e110bd98" [ref=f20e174] - button "삭제" [ref=f20e175] - listitem [ref=f20e176]: - button "ap1-credential-custody-f5e0c027" [ref=f20e177] - button "삭제" [ref=f20e178] - listitem [ref=f20e179]: - button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f20e180] - button "삭제" [ref=f20e181] - region [ref=f20e182]: - generic [ref=f20e183]: - paragraph [ref=f20e184]: LIVE - heading "즉시 미리보기" [level=2] [ref=f20e185] - generic [ref=f20e188]: - generic [ref=f20e189]: - navigation "문서 경로" [ref=f20e190]: - link "Case" [ref=f20e191] [cursor=pointer]: - /url: /explore/cases - generic [ref=f20e192]: / - generic [ref=f20e193]: OAuth/OIDC 인증 경계 - generic [ref=f20e194]: / - link "KeyCloak Patterns" [ref=f20e195] [cursor=pointer]: - /url: /projects/keycloak-patterns - heading "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정" [level=1] [ref=f20e196] - paragraph [ref=f20e197]: "로그인 후 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, BFF가 server-side authorized client에서 access token을 조회해 Resource Server를 호출한다. 상태 변경 요청에는 `XSRF-TOKEN`과 `X-XSRF-TOKEN` 검증을 추가했다." - region "문제와 결론" [ref=f20e198]: - generic [ref=f20e199]: - paragraph [ref=f20e200]: 문제 - paragraph [ref=f20e201]: BFF에서는 confidential-client인 BFF서버가 code를 교환하고access token과 refresh token은 server-side authorized client에 관리하게 된다.브라우저에는 HttpOnly AP3_SESSION만 전달된다.그런데 브라우저는 여전히 요청마다 cookie를 보낸다.cookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다.BFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다.BFF가 OAuth token을 관리하도록 구성한 뒤 브라우저 요청 방식과 CSRF 처리, server-side 저장 상태를 확인했다. - generic [ref=f20e202]: - paragraph [ref=f20e203]: 결론 - paragraph [ref=f20e204]: "상태 변경 요청에서 브라우저가 보내는 cookie는 `AP3_SESSION`과 `XSRF-TOKEN`이다.AP3_SESSION : HttpOnly, JavaScript 읽기 xXSRF-TOKEN : JavaScript 읽기 o브라우저는 session cookie를 요청에 자동으로 포함한다. 상태 변경 요청에서는 CSRF token을 별도로 검증하며, JavaScript가 헤더 값을 만들 수 있도록 `XSRF-TOKEN` cookie에는 HttpOnly를 사용하지 않았다.BFF를 사용해도 same-origin XSS는 별도로 막아야 한다. 악성 script가 실행되면 현재 session으로 BFF를 호출할 수 있고 JavaScript에서 읽을 수 있는 CSRF cookie에도 접근할 수 있다. 다만 OAuth token 원문을 브라우저 JavaScript에 전달하지는 않는다.현재 구현에서는 BFF가 CSRF 검증까지 처리한다.재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x" - generic [ref=f20e205]: - generic [ref=f20e206]: - term [ref=f20e207]: 검증 환경 - definition [ref=f20e208]: "Keycloak 26.7.0realmsconfidential, client_secret_basicPKCE S256 : oprovider : authorization-code, refresh-tokenstore : memory oCSRF : o HTTP : o" - generic [ref=f20e209]: - term [ref=f20e210]: 검증 데이터 - definition [ref=f20e211]: "1. UI에서 로그인하고 authorization request를 확인.client_id : bff-confidentialcode_challenge_method : S2562. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인.3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지, Web Storage가 비었는지 확인.4. /bff/token-boundary를 호출.accessTokenStoredOnServer : truerefreshTokenStoredOnServer : truebrowserTokenCount : 0csrfProtectionEnabled : true5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인.6. GET /bff/csrf로 XSRF-TOKEN cookie와 token metadata를 받는거 확인.응답 본문의 token과 cookie 값이 같은 문자열이 아님을 확인.7. session cookie는 있고 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인.8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인.9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인." - generic [ref=f20e212]: - term [ref=f20e213]: 기록 - definition [ref=f20e214]: 게시 2026.08.25 · 마지막 검증 2026.08.25 - group [ref=f20e216]: - generic "목차 · BFF가 Resource Server를 호출하는 흐름" [ref=f20e217] [cursor=pointer] - article [ref=f20e219]: - region [ref=f20e220]: - heading [level=2] [ref=f20e221]: - link "BFF가 Resource Server를 호출하는 흐름 바로가기" [ref=f20e222] [cursor=pointer]: - /url: "#bff가-resource-server를-호출하는-흐름" - text: BFF가 Resource Server를 호출하는 흐름 - generic [ref=f20e223]: "#" - figure [ref=f20e224]: - button "ap3-bff-custody-82fa18bd 이미지 크게 보기" [ref=f20e225]: - img "브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." [ref=f20e226] - generic [ref=f20e227]: 크게 보기 - generic [ref=f20e228]: 브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다. - paragraph [ref=f20e229]: - text: 브라우저는 BFF endpoint를 session cookie로 호출한다. BFF는 authorized client에서 access token을 가져와 Resource Server 요청의 - code [ref=f20e230]: Authorization - text: 헤더를 만든다. - region [ref=f20e231]: - heading [level=2] [ref=f20e232]: - link "브라우저가 전송하는 session과 CSRF token 바로가기" [ref=f20e233] [cursor=pointer]: - /url: "#브라우저가-전송하는-session과-csrf-token" - text: 브라우저가 전송하는 session과 CSRF token - generic [ref=f20e234]: "#" - region "표" [ref=f20e235]: - table [ref=f20e236]: - caption [ref=f20e237] - rowgroup [ref=f20e238]: - row [ref=f20e239]: - columnheader "무엇" [ref=f20e240] - columnheader "브라우저에 있나" [ref=f20e241] - columnheader "JavaScript가 읽나" [ref=f20e242] - rowgroup [ref=f20e243]: - row [ref=f20e244]: - cell "AP3_SESSION" [ref=f20e245] - cell "o" [ref=f20e246] - cell "x" [ref=f20e247] - row [ref=f20e248]: - cell "XSRF-TOKEN" [ref=f20e249] - cell "o" [ref=f20e250] - cell "o" [ref=f20e251] - row [ref=f20e252]: - cell "access token" [ref=f20e253] - cell "x" [ref=f20e254] - cell "x" [ref=f20e255] - row [ref=f20e256]: - cell "refresh token" [ref=f20e257] - cell "x" [ref=f20e258] - cell "x" [ref=f20e259] - paragraph [ref=f20e260]: - text: JavaScript는 - code [ref=f20e261]: XSRF-TOKEN - text: cookie 값을 읽어 상태 변경 요청의 - code [ref=f20e262]: X-XSRF-TOKEN - text: 헤더에 넣는다. 이 용도 때문에 - code [ref=f20e263]: XSRF-TOKEN - text: 에는 - code [ref=f20e264]: HttpOnly - text: 를 사용하지 않았다. - paragraph [ref=f20e265]: - text: same-origin에서 악성 script가 실행되면 사용자의 session으로 BFF endpoint를 호출할 수 있고 - code [ref=f20e266]: XSRF-TOKEN - text: 도 읽을 수 있다. BFF 구조의 차이는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않는다는 점이다. - region [ref=f20e267]: - heading [level=2] [ref=f20e268]: - link "Session으로 Authorized Client를 조회하는 과정 바로가기" [ref=f20e269] [cursor=pointer]: - /url: "#session으로-authorized-client를-조회하는-과정" - text: Session으로 Authorized Client를 조회하는 과정 - generic [ref=f20e270]: "#" - paragraph [ref=f20e271]: - text: 브라우저 요청에는 - code [ref=f20e272]: Authorization - text: 헤더도 없고 코드에도 access token 지역 변수도 없다. - figure "HTTP ·브라우저 입력 — cookie 하나 코드 복사" [ref=f20e273]: - generic [ref=f20e274]: - generic [ref=f20e275]: HTTP - generic [ref=f20e276]: ·브라우저 입력 — cookie 하나 - button "코드 복사" [ref=f20e277] [cursor=pointer]: 복사 - region "브라우저 입력 — cookie 하나 코드" [ref=f20e278]: - code [ref=f20e279]: "GET http://localhost:8083/bff/api/me Accept: application/json Cookie: AP3_SESSION=" - paragraph [ref=f20e281]: cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다. - figure "TEXT ·cookie에서 Bearer까지 코드 복사" [ref=f20e282]: - generic [ref=f20e283]: - generic [ref=f20e284]: TEXT - generic [ref=f20e285]: ·cookie에서 Bearer까지 - button "코드 복사" [ref=f20e286] [cursor=pointer]: 복사 - region "cookie에서 Bearer까지 코드" [ref=f20e287]: - code [ref=f20e288]: AP3_SESSION → HttpSession → SecurityContext → Authentication.getName() → ("keycloak", principal name) → OAuth2AuthorizedClientService → access token + refresh token - paragraph [ref=f20e290]: - code [ref=f20e291]: BffController.currentUser(Authentication) - text: 는 - code [ref=f20e292]: OAuth2AuthorizeRequest - text: 를 만들어 - code [ref=f20e293]: OAuth2AuthorizedClientManager.authorize() - text: 를 호출한다. manager bean은 - code [ref=f20e294]: AuthorizedClientServiceOAuth2AuthorizedClientManager - text: 이고 authorization-code와 refresh-token provider를 함께 사용하므로 만료된 access token의 갱신도 이 경로에서 처리한다. - paragraph [ref=f20e295]: 없으면 401이 된다. - paragraph [ref=f20e296]: - text: 있으면 BFF의 - code [ref=f20e297]: RestClient - text: 가 downstream 입력을 - strong [ref=f20e298]: 새로 - text: 조립한다. - figure "HTTP ·cookie로 조회된 토큰을 넣어서 조립 코드 복사" [ref=f20e299]: - generic [ref=f20e300]: - generic [ref=f20e301]: HTTP - generic [ref=f20e302]: ·cookie로 조회된 토큰을 넣어서 조립 - button "코드 복사" [ref=f20e303] [cursor=pointer]: 복사 - region "cookie로 조회된 토큰을 넣어서 조립 코드" [ref=f20e304]: - code [ref=f20e305]: "GET http://app:8081/api/me Authorization: Bearer " - paragraph [ref=f20e307]: - code [ref=f20e308]: AP3_SESSION - text: 은 downstream으로 전달되지 않는다.BFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다.두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다. - complementary "주의" [ref=f20e309]: - paragraph [ref=f20e310]: 주의 - paragraph [ref=f20e311]: Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인. - region [ref=f20e312]: - heading [level=2] [ref=f20e313]: - link "browserTokenCount는 무엇을 증명하나 바로가기" [ref=f20e314] [cursor=pointer]: - /url: "#browsertokencount는-무엇을-증명하나" - text: browserTokenCount는 무엇을 증명하나 - generic [ref=f20e315]: "#" - paragraph [ref=f20e316]: 진단용 endpoint가 server custody를 boolean으로 보여 준다. - figure "JSON ·/bff/token-boundary 응답 코드 복사" [ref=f20e317]: - generic [ref=f20e318]: - generic [ref=f20e319]: JSON - generic [ref=f20e320]: ·/bff/token-boundary 응답 - button "코드 복사" [ref=f20e321] [cursor=pointer]: 복사 - region "/bff/token-boundary 응답 코드" [ref=f20e322]: - code [ref=f20e323]: "{ \"pattern\": \"AP3-backend-for-frontend\", \"principal\": \"regular-user\", \"accessTokenStoredOnServer\": true, \"refreshTokenStoredOnServer\": true, \"browserTokenCount\": 0, \"csrfProtectionEnabled\": true }" - paragraph [ref=f20e325]: - code [ref=f20e326]: "browserTokenCount: 0" - text: 은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다. - paragraph [ref=f20e327]: 밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다. - figure "TEXT ·같은 주장에 대한 두 종류의 근거 코드 복사" [ref=f20e328]: - generic [ref=f20e329]: - generic [ref=f20e330]: TEXT - generic [ref=f20e331]: ·같은 주장에 대한 두 종류의 근거 - button "코드 복사" [ref=f20e332] [cursor=pointer]: 복사 - region "같은 주장에 대한 두 종류의 근거 코드" [ref=f20e333]: - code [ref=f20e334]: "self-report /bff/token-boundary → browserTokenCount: 0 external observation 브라우저 network → token endpoint 없음 Web Storage → token 문자열 없음" - paragraph [ref=f20e336]: 자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다. - paragraph [ref=f20e337]: - text: 이 endpoint는 manager의 - code [ref=f20e338]: authorize() - text: 를 호출하지 않고 - code [ref=f20e339]: OAuth2AuthorizedClientService - text: 를 직접 조회하므로 여기서는 refresh를 수행하지 않는다. - region [ref=f20e340]: - heading [level=2] [ref=f20e341]: - link "cookie가 credential이면 CSRF가 필요하다 바로가기" [ref=f20e342] [cursor=pointer]: - /url: "#cookie가-credential이면-csrf가-필요하다" - text: cookie가 credential이면 CSRF가 필요하다 - generic [ref=f20e343]: "#" - paragraph [ref=f20e344]: - text: 브라우저는 session cookie를 요청마다 자동으로 붙인다. - code [ref=f20e345]: GET - text: 만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다. - figure "HTTP ·응답 헤더 — cookie에는 raw 값이 들어간다 코드 복사" [ref=f20e346]: - generic [ref=f20e347]: - generic [ref=f20e348]: HTTP - generic [ref=f20e349]: ·응답 헤더 — cookie에는 raw 값이 들어간다 - button "코드 복사" [ref=f20e350] [cursor=pointer]: 복사 - region "응답 헤더 — cookie에는 raw 값이 들어간다 코드" [ref=f20e351]: - code [ref=f20e352]: "HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache Set-Cookie: XSRF-TOKEN=; Path=/" - figure "JSON ·응답 본문 — 여기 token은 가려진 값이다 코드 복사" [ref=f20e354]: - generic [ref=f20e355]: - generic [ref=f20e356]: JSON - generic [ref=f20e357]: ·응답 본문 — 여기 token은 가려진 값이다 - button "코드 복사" [ref=f20e358] [cursor=pointer]: 복사 - region "응답 본문 — 여기 token은 가려진 값이다 코드" [ref=f20e359]: - code [ref=f20e360]: "{ \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"\" }" - paragraph [ref=f20e362]: - text: 같은 endpoint가 두 값을 반환하게 되는데, 이 - strong [ref=f20e363]: 둘은 같은 문자열이 아니다. - figure [ref=f20e364]: - button "ap3-csrf-split-501dd1f7 이미지 크게 보기" [ref=f20e365]: - img "BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." [ref=f20e366] - generic [ref=f20e367]: 크게 보기 - generic [ref=f20e368]: BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다. - paragraph [ref=f20e369]: - code [ref=f20e370]: CookieCsrfTokenRepository.withHttpOnlyFalse() - text: 가 cookie에 raw 값을 넣는다. - code [ref=f20e371]: XorCsrfTokenRequestAttributeHandler - text: 가 request attribute용 token을 XOR와 Base64로 가리기 때문에 응답 본문에는 가려진 값이 보인다. - paragraph [ref=f20e372]: - text: SPA는 본문의 - code [ref=f20e373]: token - text: 을 쓰지 않는다. 본문에서는 - code [ref=f20e374]: headerName - text: 만 읽고, - code [ref=f20e375]: document.cookie - text: 에서 raw - code [ref=f20e376]: XSRF-TOKEN - text: 을 찾아 헤더 값으로 넣는다. - figure "TEXT ·CSRF token 표현 비교 코드 복사" [ref=f20e377]: - generic [ref=f20e378]: - generic [ref=f20e379]: TEXT - generic [ref=f20e380]: ·CSRF token 표현 비교 - button "코드 복사" [ref=f20e381] [cursor=pointer]: 복사 - region "CSRF token 표현 비교 코드" [ref=f20e382]: - code [ref=f20e383]: body.token masked token cookie XSRF-TOKEN raw token X-XSRF-TOKEN raw token - figure "HTTP ·다음 요청 헤더에 X-XSRF-TOKEN가 들어간다 코드 복사" [ref=f20e385]: - generic [ref=f20e386]: - generic [ref=f20e387]: HTTP - generic [ref=f20e388]: ·다음 요청 헤더에 X-XSRF-TOKEN가 들어간다 - button "코드 복사" [ref=f20e389] [cursor=pointer]: 복사 - region "다음 요청 헤더에 X-XSRF-TOKEN가 들어간다 코드" [ref=f20e390]: - code [ref=f20e391]: "POST /bff/theme HTTP/1.1 Host: localhost:8083 Content-Type: application/json Cookie: AP3_SESSION=; XSRF-TOKEN= X-XSRF-TOKEN: " - figure "JSON ·요청 본문 코드 복사" [ref=f20e393]: - generic [ref=f20e394]: - generic [ref=f20e395]: JSON - generic [ref=f20e396]: ·요청 본문 - button "코드 복사" [ref=f20e397] [cursor=pointer]: 복사 - region "요청 본문 코드" [ref=f20e398]: - code [ref=f20e399]: "{ \"theme\":\"dark\" }" - paragraph [ref=f20e401]: - code [ref=f20e402]: SpaCsrfTokenRequestHandler - text: 가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다. - complementary "참고" [ref=f20e403]: - paragraph [ref=f20e404]: 참고 - paragraph [ref=f20e405]: 응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다. - region [ref=f20e406]: - heading [level=2] [ref=f20e407]: - link "SameSite와 CSRF token이 막는 입력 바로가기" [ref=f20e408] [cursor=pointer]: - /url: "#samesite와-csrf-token이-막는-입력" - text: SameSite와 CSRF token이 막는 입력 - generic [ref=f20e409]: "#" - paragraph [ref=f20e410]: 네 가지 입력으로 나눠 보면 둘이 갈린다. - region "표" [ref=f20e411]: - table [ref=f20e412]: - caption [ref=f20e413] - rowgroup [ref=f20e414]: - row [ref=f20e415]: - columnheader "입력" [ref=f20e416] - columnheader "막는 것" [ref=f20e417] - columnheader "응답" [ref=f20e418] - rowgroup [ref=f20e419]: - row [ref=f20e420]: - cell "same-origin, 헤더 없음" [ref=f20e421] - cell "CSRF token" [ref=f20e422] - cell "403" [ref=f20e423] - row [ref=f20e424]: - cell "same-site 다른 port, 헤더 없음" [ref=f20e425] - cell "CSRF token" [ref=f20e426] - cell "403" [ref=f20e427] - row [ref=f20e428]: - cell "cross-site POST" [ref=f20e429] - cell "SameSite" [ref=f20e430] - cell "cookie 누락" [ref=f20e431] - row [ref=f20e432]: - cell "same-origin, 값 일치" [ref=f20e433] - cell "통과" [ref=f20e434] - cell "200" [ref=f20e435] - paragraph [ref=f20e436]: - text: 앞의 두 요청에는 cookie가 포함되므로 CSRF token 검증이 필요하다. 셋째 요청은 cookie가 전송되지 않는다. - strong [ref=f20e437]: port가 달라도 site 계산상 같은 경우가 있어 - text: SameSite만으로 둘째 요청을 차단할 수는 없다. - paragraph [ref=f20e438]: - text: 셋째 줄의 관측 지점은 최종 status가 아니라 - strong [ref=f20e439]: cookie가 요청에서 빠졌다는 부분 - text: 이다. - region [ref=f20e440]: - heading [level=2] [ref=f20e441]: - link "BFF에서 관리해야 하는 항목 바로가기" [ref=f20e442] [cursor=pointer]: - /url: "#bff에서-관리해야-하는-항목" - text: BFF에서 관리해야 하는 항목 - generic [ref=f20e443]: "#" - paragraph [ref=f20e444]: 현재 BFF 구현에서 직접 관리하는 항목은 다음과 같다. - region "표" [ref=f20e445]: - table [ref=f20e446]: - caption [ref=f20e447] - rowgroup [ref=f20e448]: - row [ref=f20e449]: - columnheader "관리 항목" [ref=f20e450] - columnheader "현재 구현" [ref=f20e451] - rowgroup [ref=f20e452]: - row [ref=f20e453]: - cell "상태 변경 요청의 CSRF 검증" [ref=f20e454] - cell "o" [ref=f20e455] - row [ref=f20e456]: - cell "재시작 뒤 로그인 유지" [ref=f20e457] - cell "x" [ref=f20e458] - row [ref=f20e459]: - cell "replica가 함께 쓰는 session" [ref=f20e460] - cell "x" [ref=f20e461] - row [ref=f20e462]: - cell "저장 token 암호화" [ref=f20e463] - cell "x" [ref=f20e464] - row [ref=f20e465]: - cell "logout 때 session과 authorized client 삭제" [ref=f20e466] - cell "x" [ref=f20e467] - row [ref=f20e468]: - cell "downstream 오류를 화면 오류로 변환" [ref=f20e469] - cell "x" [ref=f20e470] - row [ref=f20e471]: - cell "timeout · retry · circuit breaker" [ref=f20e472] - cell "x" [ref=f20e473] - row [ref=f20e474]: - cell "경로별 인가" [ref=f20e475] - cell "x" [ref=f20e476] - paragraph [ref=f20e477]: 첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다. - paragraph [ref=f20e478]: - text: 저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는 - strong [ref=f20e479]: 애플리케이션 수준 store - text: 다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다. - region [ref=f20e480]: - heading [level=2] [ref=f20e481]: - link "확인한 것과 확인하지 않은 것 바로가기" [ref=f20e482] [cursor=pointer]: - /url: "#확인한-것과-확인하지-않은-것" - text: 확인한 것과 확인하지 않은 것 - generic [ref=f20e483]: "#" - paragraph [ref=f20e484]: - text: 아래는 - strong [ref=f20e485]: 커밋된 자동 테스트가 확인하도록 정의한 부분 - text: 이다. - region "표" [ref=f20e486]: - table [ref=f20e487]: - caption [ref=f20e488] - rowgroup [ref=f20e489]: - row [ref=f20e490]: - columnheader "항목" [ref=f20e491] - columnheader "확인했나" [ref=f20e492] - rowgroup [ref=f20e493]: - row [ref=f20e494]: - cell [ref=f20e495]: - code [ref=f20e496]: bff-confidential - text: + S256 challenge - cell "o" [ref=f20e497] - row [ref=f20e498]: - cell "브라우저 요청에 token endpoint 없음" [ref=f20e499] - cell "o" [ref=f20e500] - row [ref=f20e501]: - cell "브라우저 요청에 8081 직접 호출 없음" [ref=f20e502] - cell "o" [ref=f20e503] - row [ref=f20e504]: - cell [ref=f20e505]: - code [ref=f20e506]: AP3_SESSION - text: HttpOnly · SameSite=Lax - cell "o" [ref=f20e507] - row [ref=f20e508]: - cell "Web Storage 비어 있음" [ref=f20e509] - cell "o" [ref=f20e510] - row [ref=f20e511]: - cell "server access·refresh boolean이 true" [ref=f20e512] - cell "o" [ref=f20e513] - row [ref=f20e514]: - cell [ref=f20e515]: - code [ref=f20e516]: /bff/api/me - text: 200 · username · audience - cell "o" [ref=f20e517] - row [ref=f20e518]: - cell "CSRF 헤더 없는 POST 403" [ref=f20e519] - cell "o" [ref=f20e520] - row [ref=f20e521]: - cell "raw 값을 헤더에 넣은 POST 200" [ref=f20e522] - cell "o" [ref=f20e523] - row [ref=f20e524]: - cell "cross-site POST에서 cookie 누락" [ref=f20e525] - cell "o" [ref=f20e526] - row [ref=f20e527]: - cell "preference의 사용자별 격리" [ref=f20e528] - cell "x" [ref=f20e529] - row [ref=f20e530]: - cell "preference 영속성" [ref=f20e531] - cell "x" [ref=f20e532] - row [ref=f20e533]: - cell "공유 session store" [ref=f20e534] - cell "x" [ref=f20e535] - row [ref=f20e536]: - cell "저장 token 암호화" [ref=f20e537] - cell "x" [ref=f20e538] - row [ref=f20e539]: - cell "logout" [ref=f20e540] - cell "x" [ref=f20e541] - row [ref=f20e542]: - cell "downstream 401의 전달 모양" [ref=f20e543] - cell "x" [ref=f20e544] - row [ref=f20e545]: - cell "timeout · 경로별 인가" [ref=f20e546] - cell "x" [ref=f20e547] - region [ref=f20e548]: - heading [level=2] [ref=f20e549]: - link "이 구조에서 관측한 것 바로가기" [ref=f20e550] [cursor=pointer]: - /url: "#이-구조에서-관측한-것" - text: 이 구조에서 관측한 것 - generic [ref=f20e551]: "#" - paragraph [ref=f20e552]: - text: 브라우저 network에서는 Keycloak token endpoint를 직접 호출하지 않았고 - code [ref=f20e553]: /bff/api/me - text: 에도 - code [ref=f20e554]: "Authorization: Bearer" - text: 가 없었다. 해당 요청은 - code [ref=f20e555]: AP3_SESSION - text: 으로 인증됐으며, 상태 변경 요청에는 CSRF token 검증을 적용했다. 현재 로그인 session과 authorized client는 BFF process memory에 저장된다. - paragraph [ref=f20e556]: 브라우저에 OAuth token을 전달하지 않고 backend가 여러 API 호출을 조합해야 하는 요구에는 BFF가 맞다. 브라우저가 Resource Server를 직접 호출해야 한다면 SPA나 Mediator 구조를 검토한다. - complementary [ref=f20e557]: - heading "작업 상태" [level=2] [ref=f20e558] - status "편집 상태" [ref=f20e559]: 저장됨 - generic [ref=f20e560]: - generic [ref=f20e561]: - term [ref=f20e562]: 저장 버전 - definition [ref=f20e563]: "29" - generic [ref=f20e564]: - term [ref=f20e565]: 종류 - definition [ref=f20e566]: CASE - paragraph [ref=f20e567]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다. - generic [ref=f20e568]: - button "저장" [disabled] [ref=f20e569] - button "게시" [ref=f20e570] - paragraph [ref=f20e571]: 버전 29으로 저장했습니다.