Files
document-haness/.playwright-mcp/page-2026-08-26T12-52-59-353Z.yml

746 lines
68 KiB
YAML

- generic [ref=f25e3]:
- link "본문으로 건너뛰기" [ref=f25e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f25e5]:
- generic [ref=f25e6]:
- link "TechLog Studio" [ref=f25e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f25e8]: Studio
- navigation "Studio 주 탐색" [ref=f25e10]:
- link "작업본" [ref=f25e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f25e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f25e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f25e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f25e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f25e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f25e17]
- main [ref=f25e18]:
- generic [ref=f25e19]:
- generic [ref=f25e20]:
- region [ref=f25e21]:
- generic [ref=f25e22]:
- paragraph [ref=f25e23]: CASE · VERSION 30
- heading "문서 편집" [level=1] [ref=f25e24]
- paragraph [ref=f25e25]: Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정
- region [ref=f25e26]:
- generic [ref=f25e27]:
- paragraph [ref=f25e28]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f25e29]
- generic [ref=f25e30]:
- generic [ref=f25e31]:
- generic [ref=f25e32]: 제목
- textbox "제목" [ref=f25e33]: Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정
- generic [ref=f25e34]:
- generic [ref=f25e35]: slug
- textbox "slug" [ref=f25e36]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- text: bff-session-csrf-responsibility
- generic [ref=f25e37]:
- generic [ref=f25e38]: 요약
- textbox "요약" [ref=f25e39]: 브라우저 network에서 OAuth token이 사라지고 AP3_SESSION cookie 하나만 남았다. 로그인 상태와 token은 BFF가 들고 있다. cookie가 credential이 되면서 상태 변경 요청에는 CSRF 검증이 붙었고, BFF로 넘어온 책임 중 지금 구현된 것은 거기까지다.
- generic [ref=f25e40]:
- generic [ref=f25e41]: Topic
- combobox "Topic" [ref=f25e42]:
- option "선택하지 않음"
- option "OAuth/OIDC 인증 경계" [selected]
- generic [ref=f25e43]:
- generic [ref=f25e44]: Project
- combobox "Project" [ref=f25e45]:
- option "미지정"
- option "Backend Clean Architecture"
- option "KeyCloak Patterns" [selected]
- option "Liner N + 1문제"
- group "관계" [ref=f25e46]:
- generic [ref=f25e48]:
- generic [ref=f25e49]:
- generic [ref=f25e50]: 관계 1 대상
- combobox "관계 1 대상" [ref=f25e51]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled]
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [selected]
- option "BFF가 OAuth Token을 관리하는 조건" [disabled]
- option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled]
- 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 "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조"
- generic [ref=f25e52]:
- generic [ref=f25e53]: 관계 1 이유
- textbox "관계 1 이유" [ref=f25e54]: 이 기준이 요구하는 항목 중 무엇이 구현됐고 무엇이 구현되지 않았는지
- generic [ref=f25e55]:
- button "위로" [disabled] [ref=f25e56]
- button "아래로" [ref=f25e57]
- button "삭제" [ref=f25e58]
- generic [ref=f25e59]:
- generic [ref=f25e60]:
- generic [ref=f25e61]: 관계 2 대상
- combobox "관계 2 대상" [ref=f25e62]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled]
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "BFF가 OAuth Token을 관리하는 조건" [disabled]
- option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled]
- 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 "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [selected]
- option "Public Client와 Confidential Client 구분 기준"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조"
- generic [ref=f25e63]:
- generic [ref=f25e64]: 관계 2 이유
- textbox "관계 2 이유" [ref=f25e65]: session cookie와 CSRF token, server-side token을 각각 다뤄야 하는 이유
- generic [ref=f25e66]:
- button "위로" [ref=f25e67]
- button "아래로" [ref=f25e68]
- button "삭제" [ref=f25e69]
- generic [ref=f25e70]:
- generic [ref=f25e71]:
- generic [ref=f25e72]: 관계 3 대상
- combobox "관계 3 대상" [ref=f25e73]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled]
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "BFF가 OAuth Token을 관리하는 조건" [disabled]
- option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled]
- 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 "OAuth/OIDC 인증 패턴 선택 기준" [selected]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조"
- generic [ref=f25e74]:
- generic [ref=f25e75]: 관계 3 이유
- textbox "관계 3 이유" [ref=f25e76]: token 비노출을 고른 자리에서 CSRF와 공유 저장소가 따라온다
- generic [ref=f25e77]:
- button "위로" [ref=f25e78]
- button "아래로" [ref=f25e79]
- button "삭제" [ref=f25e80]
- generic [ref=f25e81]:
- generic [ref=f25e82]:
- generic [ref=f25e83]: 관계 4 대상
- combobox "관계 4 대상" [ref=f25e84]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled]
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "BFF가 OAuth Token을 관리하는 조건" [selected]
- option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled]
- 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 "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조"
- generic [ref=f25e85]:
- generic [ref=f25e86]: 관계 4 이유
- textbox "관계 4 이유" [ref=f25e87]: 이 결정의 구조를 실제로 실행해 본 문서
- generic [ref=f25e88]:
- button "위로" [ref=f25e89]
- button "아래로" [ref=f25e90]
- button "삭제" [ref=f25e91]
- generic [ref=f25e92]:
- generic [ref=f25e93]:
- generic [ref=f25e94]: 관계 5 대상
- combobox "관계 5 대상" [ref=f25e95]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [selected]
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "BFF가 OAuth Token을 관리하는 조건" [disabled]
- option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [disabled]
- 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 "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조"
- generic [ref=f25e96]:
- generic [ref=f25e97]: 관계 5 이유
- textbox "관계 5 이유" [ref=f25e98]: 두 상태가 모두 process-local memory에 있다는 점이 질문의 시작이다
- generic [ref=f25e99]:
- button "위로" [ref=f25e100]
- button "아래로" [ref=f25e101]
- button "삭제" [ref=f25e102]
- generic [ref=f25e103]:
- generic [ref=f25e104]:
- generic [ref=f25e105]: 관계 6 대상
- combobox "관계 6 대상" [ref=f25e106]:
- option "대상 선택"
- option "인증 구조를 보안 성숙도 단계로 취급하지 않는다"
- option "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가" [disabled]
- option "Authorization Code Flow의 Endpoint와 Credential 이동 기준"
- option "BFF 인증 구조 설계 기준" [disabled]
- option "BFF가 OAuth Token을 관리하는 조건" [disabled]
- option "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정"
- option "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가" [selected]
- 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 "OAuth/OIDC 인증 패턴 선택 기준" [disabled]
- option "OAuth Token과 Application Session을 구분하는 기준" [disabled]
- option "Public Client와 Confidential Client 구분 기준"
- option "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출"
- option "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가"
- option "SPA가 Authorization Code를 직접 교환하고 Resource Server를 호출한 구조"
- generic [ref=f25e107]:
- generic [ref=f25e108]: 관계 6 이유
- textbox "관계 6 이유" [ref=f25e109]: session과 authorized client의 2가지 흐름
- generic [ref=f25e110]:
- button "위로" [ref=f25e111]
- button "아래로" [disabled] [ref=f25e112]
- button "삭제" [ref=f25e113]
- button "관계 추가" [ref=f25e114]
- region [ref=f25e115]:
- generic [ref=f25e116]:
- paragraph [ref=f25e117]: CASE
- heading "문제와 검증" [level=2] [ref=f25e118]
- generic [ref=f25e119]:
- generic [ref=f25e120]:
- generic [ref=f25e121]: 문제
- textbox "문제" [ref=f25e122]: BFF에서는 confidential-client인 BFF서버가 code를 교환하고 access token과 refresh token은 server-side authorized client에 관리하게 된다. 브라우저에는 HttpOnly AP3_SESSION만 전달된다. 그런데 브라우저는 여전히 요청마다 cookie를 보낸다. cookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. BFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다. 브라우저에서 token이 사라진 자리에 무엇이 새로 필요해지는지 확인해야 했다.
- generic [ref=f25e123]:
- generic [ref=f25e124]: 결론
- textbox "결론" [ref=f25e125]: "브라우저 요청에 남은 것은 cookie 두 개다. AP3_SESSION : HttpOnly, JavaScript 읽기 x XSRF-TOKEN : JavaScript 읽기 o cookie는 요청마다 자동으로 붙으므로 상태를 바꾸는 요청에는 의도를 확인할 값이 하나 더 필요하다. 그것이 XSRF-TOKEN이고, 그래서 이 값만 HttpOnly가 아니다. XSS는 그대로 남는다. same-origin 악성 script는 같은 session으로 BFF를 부를 수 있고 읽을 수 있는 XSRF cookie도 읽는다. 이 구조에서 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용되는 범위다. BFF로 옮겨진 책임 중 지금 구현된 것은 CSRF 검증이다. 재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x"
- generic [ref=f25e126]:
- generic [ref=f25e127]: 검증 환경
- textbox "검증 환경" [ref=f25e128]: "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=f25e129]:
- generic [ref=f25e130]: 재현 조건
- textbox "재현 조건" [ref=f25e131]: "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=f25e132]:
- generic [ref=f25e133]: 마지막 검증일
- textbox "마지막 검증일" [ref=f25e134]: 2026-08-25
- generic [ref=f25e135]:
- generic [ref=f25e136]: 본문 Markdown
- textbox "본문 Markdown" [ref=f25e137]: "## token의 호출 책임 BFF로 이전 :::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을 가지고 호출하지 않는다. 그 대신 BFF에서 authorized client가 해당 토큰을 관리하도록 하고, Resource Server로 호출하는 부분도 BFF에서 진행하게 된다. ## 브라우저에 남는 상태 | 무엇 | 브라우저에 있나 | JavaScript가 읽나 | |---|---|---| | AP3_SESSION | o | x | | XSRF-TOKEN | o | o | | access token | x | x | | refresh token | x | x | CSRF를 확인하려면 JavaScript가 읽을 수 있는 값이 하나 필요하다. 그래서 `XSRF-TOKEN`만 `HttpOnly`가 아니다. same-origin 악성 script는 이 두 cookie를 그대로 쓸 수 있다. 사용자의 session으로 BFF endpoint를 부를 수 있고 읽을 수 있는 XSRF cookie도 읽는다. 이 구조에서 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용되는 범위다. ## session이 Bearer로 바뀌는 자리 브라우저 요청에는 `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를 함께 쓴다. 그래서 BFF는 발급 이후의 수명주기까지 맡게 된다. 없으면 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=\"세 자리의 값이 서로 다르다\" 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가 로그인 상태와 token을 들고 있게 되면서 다음 항목이 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 호출과 `Authorization: Bearer`가 사라졌다. `/bff/api/me` 요청에 붙은 것은 `AP3_SESSION` 하나였다. 상태 변경 요청을 추가하자 이 cookie가 자동으로 실리기 때문에 CSRF 검증이 필요해졌고, 로그인 상태와 token은 BFF process memory에 남았다. 브라우저가 OAuth token을 받으면 안 되고 backend가 화면에 맞춰 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 보는 것이 목적이면 SPA 구조가, 브라우저의 직접 API 호출을 남겨야 하면 Mediator가 맞는다."
- group [ref=f25e138]:
- paragraph [ref=f25e139]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f25e140]
- paragraph [ref=f25e141]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f25e142]:
- generic [ref=f25e143]:
- generic [ref=f25e144]: 업로드 종류
- combobox "업로드 종류" [ref=f25e145]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f25e146]
- generic [ref=f25e147]:
- search [ref=f25e148]:
- generic [ref=f25e149]: Asset 검색
- generic [ref=f25e150]:
- searchbox "Asset 검색" [ref=f25e151]
- button "검색" [ref=f25e152]
- generic [ref=f25e153]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f25e154]
- generic [ref=f25e155]: 삽입할 때 크게 보기 허용
- status [ref=f25e156]: 삽입할 수 있는 Asset 8개
- list [ref=f25e157]:
- listitem [ref=f25e158]:
- button "ap4-edge-trust-1cff2399" [ref=f25e159]
- button "삭제" [ref=f25e160]
- listitem [ref=f25e161]:
- button "ap3-csrf-split-501dd1f7" [ref=f25e162]
- button "삭제" [ref=f25e163]
- listitem [ref=f25e164]:
- button "ap3-bff-custody-82fa18bd" [ref=f25e165]
- button "삭제" [ref=f25e166]
- listitem [ref=f25e167]:
- button "ap2-split-custody-779cb791" [ref=f25e168]
- button "삭제" [ref=f25e169]
- listitem [ref=f25e170]:
- button "ap1-custody-v3-6e0376d2" [ref=f25e171]
- button "삭제" [ref=f25e172]
- listitem [ref=f25e173]:
- button "ap1-custody-v2-e110bd98" [ref=f25e174]
- button "삭제" [ref=f25e175]
- listitem [ref=f25e176]:
- button "ap1-credential-custody-f5e0c027" [ref=f25e177]
- button "삭제" [ref=f25e178]
- listitem [ref=f25e179]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f25e180]
- button "삭제" [ref=f25e181]
- region [ref=f25e182]:
- generic [ref=f25e183]:
- paragraph [ref=f25e184]: LIVE
- heading "즉시 미리보기" [level=2] [ref=f25e185]
- generic [ref=f25e188]:
- generic [ref=f25e189]:
- navigation "문서 경로" [ref=f25e190]:
- link "Case" [ref=f25e191] [cursor=pointer]:
- /url: /explore/cases
- generic [ref=f25e192]: /
- generic [ref=f25e193]: OAuth/OIDC 인증 경계
- generic [ref=f25e194]: /
- link "KeyCloak Patterns" [ref=f25e195] [cursor=pointer]:
- /url: /projects/keycloak-patterns
- heading "Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정" [level=1] [ref=f25e196]
- paragraph [ref=f25e197]: 브라우저 network에서 OAuth token이 사라지고 AP3_SESSION cookie 하나만 남았다. 로그인 상태와 token은 BFF가 들고 있다. cookie가 credential이 되면서 상태 변경 요청에는 CSRF 검증이 붙었고, BFF로 넘어온 책임 중 지금 구현된 것은 거기까지다.
- region "문제와 결론" [ref=f25e198]:
- generic [ref=f25e199]:
- paragraph [ref=f25e200]: 문제
- paragraph [ref=f25e201]: BFF에서는 confidential-client인 BFF서버가 code를 교환하고access token과 refresh token은 server-side authorized client에 관리하게 된다.브라우저에는 HttpOnly AP3_SESSION만 전달된다.그런데 브라우저는 여전히 요청마다 cookie를 보낸다.cookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다.BFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다.브라우저에서 token이 사라진 자리에 무엇이 새로 필요해지는지 확인해야 했다.
- generic [ref=f25e202]:
- paragraph [ref=f25e203]: 결론
- paragraph [ref=f25e204]: "브라우저 요청에 남은 것은 cookie 두 개다.AP3_SESSION : HttpOnly, JavaScript 읽기 xXSRF-TOKEN : JavaScript 읽기 ocookie는 요청마다 자동으로 붙으므로 상태를 바꾸는 요청에는 의도를 확인할 값이 하나 더 필요하다. 그것이 XSRF-TOKEN이고, 그래서 이 값만 HttpOnly가 아니다.XSS는 그대로 남는다. same-origin 악성 script는 같은 session으로 BFF를 부를 수 있고 읽을 수 있는 XSRF cookie도 읽는다. 이 구조에서 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용되는 범위다.BFF로 옮겨진 책임 중 지금 구현된 것은 CSRF 검증이다.재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x"
- generic [ref=f25e205]:
- generic [ref=f25e206]:
- term [ref=f25e207]: 검증 환경
- definition [ref=f25e208]: "Keycloak 26.7.0realmsconfidential, client_secret_basicPKCE S256 : oprovider : authorization-code, refresh-tokenstore : memory oCSRF : o HTTP : o"
- generic [ref=f25e209]:
- term [ref=f25e210]: 검증 데이터
- definition [ref=f25e211]: "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=f25e212]:
- term [ref=f25e213]: 기록
- definition [ref=f25e214]: 게시 2026.08.25 · 마지막 검증 2026.08.25
- group [ref=f25e216]:
- generic "목차 · token의 호출 책임 BFF로 이전" [ref=f25e217] [cursor=pointer]
- article [ref=f25e219]:
- region [ref=f25e220]:
- heading [level=2] [ref=f25e221]:
- link "token의 호출 책임 BFF로 이전 바로가기" [ref=f25e222] [cursor=pointer]:
- /url: "#token의-호출-책임-bff로-이전"
- text: token의 호출 책임 BFF로 이전
- generic [ref=f25e223]: "#"
- figure [ref=f25e224]:
- button "ap3-bff-custody-82fa18bd 이미지 크게 보기" [ref=f25e225]:
- img "브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." [ref=f25e226]
- generic [ref=f25e227]: 크게 보기
- generic [ref=f25e228]: 브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다.
- paragraph [ref=f25e229]: 브라우저에서 이제 더 이상 OAuth token을 가지고 호출하지 않는다. 그 대신 BFF에서 authorized client가 해당 토큰을 관리하도록 하고, Resource Server로 호출하는 부분도 BFF에서 진행하게 된다.
- region [ref=f25e230]:
- heading [level=2] [ref=f25e231]:
- link "브라우저에 남는 상태 바로가기" [ref=f25e232] [cursor=pointer]:
- /url: "#브라우저에-남는-상태"
- text: 브라우저에 남는 상태
- generic [ref=f25e233]: "#"
- region "표" [ref=f25e234]:
- table [ref=f25e235]:
- caption [ref=f25e236]
- rowgroup [ref=f25e237]:
- row [ref=f25e238]:
- columnheader "무엇" [ref=f25e239]
- columnheader "브라우저에 있나" [ref=f25e240]
- columnheader "JavaScript가 읽나" [ref=f25e241]
- rowgroup [ref=f25e242]:
- row [ref=f25e243]:
- cell "AP3_SESSION" [ref=f25e244]
- cell "o" [ref=f25e245]
- cell "x" [ref=f25e246]
- row [ref=f25e247]:
- cell "XSRF-TOKEN" [ref=f25e248]
- cell "o" [ref=f25e249]
- cell "o" [ref=f25e250]
- row [ref=f25e251]:
- cell "access token" [ref=f25e252]
- cell "x" [ref=f25e253]
- cell "x" [ref=f25e254]
- row [ref=f25e255]:
- cell "refresh token" [ref=f25e256]
- cell "x" [ref=f25e257]
- cell "x" [ref=f25e258]
- paragraph [ref=f25e259]:
- text: CSRF를 확인하려면 JavaScript가 읽을 수 있는 값이 하나 필요하다. 그래서
- code [ref=f25e260]: XSRF-TOKEN
- text:
- code [ref=f25e261]: HttpOnly
- text: 가 아니다.
- paragraph [ref=f25e262]: same-origin 악성 script는 이 두 cookie를 그대로 쓸 수 있다. 사용자의 session으로 BFF endpoint를 부를 수 있고 읽을 수 있는 XSRF cookie도 읽는다. 이 구조에서 줄어드는 것은 token 원문이 유출돼 다른 client나 직접 API 호출에 재사용되는 범위다.
- region [ref=f25e263]:
- heading [level=2] [ref=f25e264]:
- link "session이 Bearer로 바뀌는 자리 바로가기" [ref=f25e265] [cursor=pointer]:
- /url: "#session이-bearer로-바뀌는-자리"
- text: session이 Bearer로 바뀌는 자리
- generic [ref=f25e266]: "#"
- paragraph [ref=f25e267]:
- text: 브라우저 요청에는
- code [ref=f25e268]: Authorization
- text: 헤더도 없고 코드에도 access token 지역 변수도 없다.
- figure "HTTP ·브라우저 입력 — cookie 하나 코드 복사" [ref=f25e269]:
- generic [ref=f25e270]:
- generic [ref=f25e271]: HTTP
- generic [ref=f25e272]: ·브라우저 입력 — cookie 하나
- button "코드 복사" [ref=f25e273] [cursor=pointer]: 복사
- region "브라우저 입력 — cookie 하나 코드" [ref=f25e274]:
- code [ref=f25e275]: "GET http://localhost:8083/bff/api/me Accept: application/json Cookie: AP3_SESSION=<opaque-session-id>"
- paragraph [ref=f25e277]: cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.
- figure "TEXT ·cookie에서 Bearer까지 코드 복사" [ref=f25e278]:
- generic [ref=f25e279]:
- generic [ref=f25e280]: TEXT
- generic [ref=f25e281]: ·cookie에서 Bearer까지
- button "코드 복사" [ref=f25e282] [cursor=pointer]: 복사
- region "cookie에서 Bearer까지 코드" [ref=f25e283]:
- code [ref=f25e284]: AP3_SESSION → HttpSession → SecurityContext → Authentication.getName() → ("keycloak", principal name) → OAuth2AuthorizedClientService → access token + refresh token
- paragraph [ref=f25e286]:
- code [ref=f25e287]: BffController.currentUser(Authentication)
- text:
- code [ref=f25e288]: OAuth2AuthorizeRequest
- text: 를 만들어
- code [ref=f25e289]: OAuth2AuthorizedClientManager.authorize()
- text: 를 부른다. manager bean은
- code [ref=f25e290]: AuthorizedClientServiceOAuth2AuthorizedClientManager
- text: 이고 authorization-code와 refresh-token provider를 함께 쓴다. 그래서 BFF는 발급 이후의 수명주기까지 맡게 된다.
- paragraph [ref=f25e291]: 없으면 401이 된다.
- paragraph [ref=f25e292]:
- text: 있으면 BFF의
- code [ref=f25e293]: RestClient
- text: 가 downstream 입력을
- strong [ref=f25e294]: 새로
- text: 조립한다.
- figure "HTTP ·cookie로 조회된 토큰을 넣어서 조립 코드 복사" [ref=f25e295]:
- generic [ref=f25e296]:
- generic [ref=f25e297]: HTTP
- generic [ref=f25e298]: ·cookie로 조회된 토큰을 넣어서 조립
- button "코드 복사" [ref=f25e299] [cursor=pointer]: 복사
- region "cookie로 조회된 토큰을 넣어서 조립 코드" [ref=f25e300]:
- code [ref=f25e301]: "GET http://app:8081/api/me Authorization: Bearer <server-held-access-token>"
- paragraph [ref=f25e303]:
- code [ref=f25e304]: AP3_SESSION
- text: 은 downstream으로 전달되지 않는다.BFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다.두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다.
- complementary "주의" [ref=f25e305]:
- paragraph [ref=f25e306]: 주의
- paragraph [ref=f25e307]: Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.
- region [ref=f25e308]:
- heading [level=2] [ref=f25e309]:
- link "browserTokenCount는 무엇을 증명하나 바로가기" [ref=f25e310] [cursor=pointer]:
- /url: "#browsertokencount는-무엇을-증명하나"
- text: browserTokenCount는 무엇을 증명하나
- generic [ref=f25e311]: "#"
- paragraph [ref=f25e312]: 진단용 endpoint가 server custody를 boolean으로 보여 준다.
- figure "JSON ·/bff/token-boundary 응답 코드 복사" [ref=f25e313]:
- generic [ref=f25e314]:
- generic [ref=f25e315]: JSON
- generic [ref=f25e316]: ·/bff/token-boundary 응답
- button "코드 복사" [ref=f25e317] [cursor=pointer]: 복사
- region "/bff/token-boundary 응답 코드" [ref=f25e318]:
- code [ref=f25e319]: "{ \"pattern\": \"AP3-backend-for-frontend\", \"principal\": \"regular-user\", \"accessTokenStoredOnServer\": true, \"refreshTokenStoredOnServer\": true, \"browserTokenCount\": 0, \"csrfProtectionEnabled\": true }"
- paragraph [ref=f25e321]:
- code [ref=f25e322]: "browserTokenCount: 0"
- text: 은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다.
- paragraph [ref=f25e323]: 밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.
- figure "TEXT ·같은 주장에 대한 두 종류의 근거 코드 복사" [ref=f25e324]:
- generic [ref=f25e325]:
- generic [ref=f25e326]: TEXT
- generic [ref=f25e327]: ·같은 주장에 대한 두 종류의 근거
- button "코드 복사" [ref=f25e328] [cursor=pointer]: 복사
- region "같은 주장에 대한 두 종류의 근거 코드" [ref=f25e329]:
- code [ref=f25e330]: "self-report /bff/token-boundary → browserTokenCount: 0 external observation 브라우저 network → token endpoint 없음 Web Storage → token 문자열 없음"
- paragraph [ref=f25e332]: 자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다.
- paragraph [ref=f25e333]:
- text: 이 endpoint는 manager의
- code [ref=f25e334]: authorize()
- text: 를 부르지 않고
- code [ref=f25e335]: OAuth2AuthorizedClientService
- text: 를 직접 조회한다. refresh를 수행하는 자리가 아니다.
- region [ref=f25e336]:
- heading [level=2] [ref=f25e337]:
- link "cookie가 credential이면 CSRF가 필요하다 바로가기" [ref=f25e338] [cursor=pointer]:
- /url: "#cookie가-credential이면-csrf가-필요하다"
- text: cookie가 credential이면 CSRF가 필요하다
- generic [ref=f25e339]: "#"
- paragraph [ref=f25e340]:
- text: 브라우저는 session cookie를 요청마다 자동으로 붙인다.
- code [ref=f25e341]: GET
- text: 만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다.
- figure "HTTP ·응답 헤더 — cookie에는 raw 값이 들어간다 코드 복사" [ref=f25e342]:
- generic [ref=f25e343]:
- generic [ref=f25e344]: HTTP
- generic [ref=f25e345]: ·응답 헤더 — cookie에는 raw 값이 들어간다
- button "코드 복사" [ref=f25e346] [cursor=pointer]: 복사
- region "응답 헤더 — cookie에는 raw 값이 들어간다 코드" [ref=f25e347]:
- code [ref=f25e348]: "HTTP/1.1 200 OK Cache-Control: no-store Pragma: no-cache Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/"
- figure "JSON ·응답 본문 — 여기 token은 가려진 값이다 코드 복사" [ref=f25e350]:
- generic [ref=f25e351]:
- generic [ref=f25e352]: JSON
- generic [ref=f25e353]: ·응답 본문 — 여기 token은 가려진 값이다
- button "코드 복사" [ref=f25e354] [cursor=pointer]: 복사
- region "응답 본문 — 여기 token은 가려진 값이다 코드" [ref=f25e355]:
- code [ref=f25e356]: "{ \"headerName\": \"X-XSRF-TOKEN\", \"parameterName\": \"_csrf\", \"token\": \"<xor-masked-csrf-token>\" }"
- paragraph [ref=f25e358]:
- text: 같은 endpoint가 두 값을 반환하게 되는데, 이
- strong [ref=f25e359]: 둘은 같은 문자열이 아니다.
- figure [ref=f25e360]:
- button "ap3-csrf-split-501dd1f7 이미지 크게 보기" [ref=f25e361]:
- 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=f25e362]
- generic [ref=f25e363]: 크게 보기
- generic [ref=f25e364]: 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=f25e365]:
- code [ref=f25e366]: CookieCsrfTokenRepository.withHttpOnlyFalse()
- text: 가 cookie에 raw 값을 넣는다.
- code [ref=f25e367]: XorCsrfTokenRequestAttributeHandler
- text: 가 request attribute용 token을 XOR와 Base64로 가리기 때문에 응답 본문에는 가려진 값이 보인다.
- paragraph [ref=f25e368]:
- text: SPA는 본문의
- code [ref=f25e369]: token
- text: 을 쓰지 않는다. 본문에서는
- code [ref=f25e370]: headerName
- text: 만 읽고,
- code [ref=f25e371]: document.cookie
- text: 에서 raw
- code [ref=f25e372]: XSRF-TOKEN
- text: 을 찾아 헤더 값으로 넣는다.
- figure "TEXT ·세 자리의 값이 서로 다르다 코드 복사" [ref=f25e373]:
- generic [ref=f25e374]:
- generic [ref=f25e375]: TEXT
- generic [ref=f25e376]: ·세 자리의 값이 서로 다르다
- button "코드 복사" [ref=f25e377] [cursor=pointer]: 복사
- region "세 자리의 값이 서로 다르다 코드" [ref=f25e378]:
- code [ref=f25e379]: body.token masked token cookie XSRF-TOKEN raw token X-XSRF-TOKEN raw token
- figure "HTTP ·다음 요청 헤더에 X-XSRF-TOKEN가 들어간다 코드 복사" [ref=f25e381]:
- generic [ref=f25e382]:
- generic [ref=f25e383]: HTTP
- generic [ref=f25e384]: ·다음 요청 헤더에 X-XSRF-TOKEN가 들어간다
- button "코드 복사" [ref=f25e385] [cursor=pointer]: 복사
- region "다음 요청 헤더에 X-XSRF-TOKEN가 들어간다 코드" [ref=f25e386]:
- code [ref=f25e387]: "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>"
- figure "JSON ·요청 본문 코드 복사" [ref=f25e389]:
- generic [ref=f25e390]:
- generic [ref=f25e391]: JSON
- generic [ref=f25e392]: ·요청 본문
- button "코드 복사" [ref=f25e393] [cursor=pointer]: 복사
- region "요청 본문 코드" [ref=f25e394]:
- code [ref=f25e395]: "{ \"theme\":\"dark\" }"
- paragraph [ref=f25e397]:
- code [ref=f25e398]: SpaCsrfTokenRequestHandler
- text: 가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다.
- complementary "참고" [ref=f25e399]:
- paragraph [ref=f25e400]: 참고
- paragraph [ref=f25e401]: 응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다.
- region [ref=f25e402]:
- heading [level=2] [ref=f25e403]:
- link "SameSite와 CSRF token이 막는 입력 바로가기" [ref=f25e404] [cursor=pointer]:
- /url: "#samesite와-csrf-token이-막는-입력"
- text: SameSite와 CSRF token이 막는 입력
- generic [ref=f25e405]: "#"
- paragraph [ref=f25e406]: 네 가지 입력으로 나눠 보면 둘이 갈린다.
- region "표" [ref=f25e407]:
- table [ref=f25e408]:
- caption [ref=f25e409]
- rowgroup [ref=f25e410]:
- row [ref=f25e411]:
- columnheader "입력" [ref=f25e412]
- columnheader "막는 것" [ref=f25e413]
- columnheader "응답" [ref=f25e414]
- rowgroup [ref=f25e415]:
- row [ref=f25e416]:
- cell "same-origin, 헤더 없음" [ref=f25e417]
- cell "CSRF token" [ref=f25e418]
- cell "403" [ref=f25e419]
- row [ref=f25e420]:
- cell "same-site 다른 port, 헤더 없음" [ref=f25e421]
- cell "CSRF token" [ref=f25e422]
- cell "403" [ref=f25e423]
- row [ref=f25e424]:
- cell "cross-site POST" [ref=f25e425]
- cell "SameSite" [ref=f25e426]
- cell "cookie 누락" [ref=f25e427]
- row [ref=f25e428]:
- cell "same-origin, 값 일치" [ref=f25e429]
- cell "통과" [ref=f25e430]
- cell "200" [ref=f25e431]
- paragraph [ref=f25e432]:
- text: 앞의 두 줄에서는 cookie가 실린다. 그래서 막는 것이 CSRF token이다. 셋째 줄에서는 cookie 자체가 요청에서 빠진다.
- strong [ref=f25e433]: port가 달라도 site 계산상 같은 경우가 있어
- text: SameSite만으로는 둘째 줄을 막아주지 못한다.
- paragraph [ref=f25e434]:
- text: 셋째 줄의 관측 지점은 최종 status가 아니라
- strong [ref=f25e435]: cookie가 요청에서 빠졌다는 부분
- text: 이다.
- region [ref=f25e436]:
- heading [level=2] [ref=f25e437]:
- link "서버로 넘어온 책임 바로가기" [ref=f25e438] [cursor=pointer]:
- /url: "#서버로-넘어온-책임"
- text: 서버로 넘어온 책임
- generic [ref=f25e439]: "#"
- paragraph [ref=f25e440]: BFF가 로그인 상태와 token을 들고 있게 되면서 다음 항목이 BFF의 책임이 됐다.
- region "표" [ref=f25e441]:
- table [ref=f25e442]:
- caption [ref=f25e443]
- rowgroup [ref=f25e444]:
- row [ref=f25e445]:
- columnheader "새로 생긴 책임" [ref=f25e446]
- columnheader "현재 구현에 있나" [ref=f25e447]
- rowgroup [ref=f25e448]:
- row [ref=f25e449]:
- cell "상태 변경 요청의 CSRF 검증" [ref=f25e450]
- cell "o" [ref=f25e451]
- row [ref=f25e452]:
- cell "재시작 뒤 로그인 유지" [ref=f25e453]
- cell "x" [ref=f25e454]
- row [ref=f25e455]:
- cell "replica가 함께 쓰는 session" [ref=f25e456]
- cell "x" [ref=f25e457]
- row [ref=f25e458]:
- cell "저장 token 암호화" [ref=f25e459]
- cell "x" [ref=f25e460]
- row [ref=f25e461]:
- cell "logout 때 session과 authorized client 삭제" [ref=f25e462]
- cell "x" [ref=f25e463]
- row [ref=f25e464]:
- cell "downstream 오류를 화면 오류로 변환" [ref=f25e465]
- cell "x" [ref=f25e466]
- row [ref=f25e467]:
- cell "timeout · retry · circuit breaker" [ref=f25e468]
- cell "x" [ref=f25e469]
- row [ref=f25e470]:
- cell "경로별 인가" [ref=f25e471]
- cell "x" [ref=f25e472]
- paragraph [ref=f25e473]: 첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다.
- paragraph [ref=f25e474]:
- text: 저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는
- strong [ref=f25e475]: 애플리케이션 수준 store
- text: 다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다.
- region [ref=f25e476]:
- heading [level=2] [ref=f25e477]:
- link "확인한 것과 확인하지 않은 것 바로가기" [ref=f25e478] [cursor=pointer]:
- /url: "#확인한-것과-확인하지-않은-것"
- text: 확인한 것과 확인하지 않은 것
- generic [ref=f25e479]: "#"
- paragraph [ref=f25e480]:
- text: 아래는
- strong [ref=f25e481]: 커밋된 자동 테스트가 확인하도록 정의한 부분
- text: 이다.
- region "표" [ref=f25e482]:
- table [ref=f25e483]:
- caption [ref=f25e484]
- rowgroup [ref=f25e485]:
- row [ref=f25e486]:
- columnheader "항목" [ref=f25e487]
- columnheader "확인했나" [ref=f25e488]
- rowgroup [ref=f25e489]:
- row [ref=f25e490]:
- cell [ref=f25e491]:
- code [ref=f25e492]: bff-confidential
- text: + S256 challenge
- cell "o" [ref=f25e493]
- row [ref=f25e494]:
- cell "브라우저 요청에 token endpoint 없음" [ref=f25e495]
- cell "o" [ref=f25e496]
- row [ref=f25e497]:
- cell "브라우저 요청에 8081 직접 호출 없음" [ref=f25e498]
- cell "o" [ref=f25e499]
- row [ref=f25e500]:
- cell [ref=f25e501]:
- code [ref=f25e502]: AP3_SESSION
- text: HttpOnly · SameSite=Lax
- cell "o" [ref=f25e503]
- row [ref=f25e504]:
- cell "Web Storage 비어 있음" [ref=f25e505]
- cell "o" [ref=f25e506]
- row [ref=f25e507]:
- cell "server access·refresh boolean이 true" [ref=f25e508]
- cell "o" [ref=f25e509]
- row [ref=f25e510]:
- cell [ref=f25e511]:
- code [ref=f25e512]: /bff/api/me
- text: 200 · username · audience
- cell "o" [ref=f25e513]
- row [ref=f25e514]:
- cell "CSRF 헤더 없는 POST 403" [ref=f25e515]
- cell "o" [ref=f25e516]
- row [ref=f25e517]:
- cell "raw 값을 헤더에 넣은 POST 200" [ref=f25e518]
- cell "o" [ref=f25e519]
- row [ref=f25e520]:
- cell "cross-site POST에서 cookie 누락" [ref=f25e521]
- cell "o" [ref=f25e522]
- row [ref=f25e523]:
- cell "preference의 사용자별 격리" [ref=f25e524]
- cell "x" [ref=f25e525]
- row [ref=f25e526]:
- cell "preference 영속성" [ref=f25e527]
- cell "x" [ref=f25e528]
- row [ref=f25e529]:
- cell "공유 session store" [ref=f25e530]
- cell "x" [ref=f25e531]
- row [ref=f25e532]:
- cell "저장 token 암호화" [ref=f25e533]
- cell "x" [ref=f25e534]
- row [ref=f25e535]:
- cell "logout" [ref=f25e536]
- cell "x" [ref=f25e537]
- row [ref=f25e538]:
- cell "downstream 401의 전달 모양" [ref=f25e539]
- cell "x" [ref=f25e540]
- row [ref=f25e541]:
- cell "timeout · 경로별 인가" [ref=f25e542]
- cell "x" [ref=f25e543]
- region [ref=f25e544]:
- heading [level=2] [ref=f25e545]:
- link "이 구조에서 관측한 것 바로가기" [ref=f25e546] [cursor=pointer]:
- /url: "#이-구조에서-관측한-것"
- text: 이 구조에서 관측한 것
- generic [ref=f25e547]: "#"
- paragraph [ref=f25e548]:
- text: 브라우저 network에서 Keycloak token endpoint 호출과
- code [ref=f25e549]: "Authorization: Bearer"
- text: 가 사라졌다.
- code [ref=f25e550]: /bff/api/me
- text: 요청에 붙은 것은
- code [ref=f25e551]: AP3_SESSION
- text: 하나였다. 상태 변경 요청을 추가하자 이 cookie가 자동으로 실리기 때문에 CSRF 검증이 필요해졌고, 로그인 상태와 token은 BFF process memory에 남았다.
- paragraph [ref=f25e552]: 브라우저가 OAuth token을 받으면 안 되고 backend가 화면에 맞춰 여러 API를 조합해야 한다면 이 구조를 고른다. OAuth 흐름을 브라우저에서 직접 보는 것이 목적이면 SPA 구조가, 브라우저의 직접 API 호출을 남겨야 하면 Mediator가 맞는다.
- complementary [ref=f25e553]:
- heading "작업 상태" [level=2] [ref=f25e554]
- status "편집 상태" [ref=f25e555]: 저장됨
- generic [ref=f25e556]:
- generic [ref=f25e557]:
- term [ref=f25e558]: 저장 버전
- definition [ref=f25e559]: "30"
- generic [ref=f25e560]:
- term [ref=f25e561]: 종류
- definition [ref=f25e562]: CASE
- paragraph [ref=f25e563]: 불완전한 초안도 저장할 수 있습니다. Ctrl+S 로도 저장합니다. 게시를 누르면 채워야 할 칸을 그 자리에 표시합니다.
- generic [ref=f25e564]:
- button "저장" [disabled] [ref=f25e565]
- button "게시" [ref=f25e566]
- paragraph [ref=f25e567]: 버전 30으로 저장했습니다.