chore: 이전 세션이 남긴 변경을 커밋한다

이번 파이프라인 작업과 무관하게 작업 트리에 남아 있던 것을 그대로 올린다.
사용자가 「전부 커밋」으로 정했고, 이번 작업과 섞이지 않게 커밋만 나눴다.

대부분은 clean-architecture-backend-template 의 그림 정본 재배치다 —
final/assets/diagrams/<이름>/ 에 있던 것이 CLAUDE.md 가 적은 배치인
final/assets/<이름>/ 로 옮겨졌고 .techviz/<이름>/ 이 함께 들어왔다.
삽입 줄의 대부분(3.15M)이 그 .techviz context.json 이다.

그 밖에 ca-tmpl·document-haness 의 정리, .claude/agents/ 열한 개,
writing-practitioner-guides 스킬, .playwright-mcp 세션 산출물,
scripts/check-ssot-facts.py 와 그 시험이 들어 있다.

이 커밋의 내용은 내가 만든 것이 아니라 이전 세션이 남긴 것이고 검증하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-17 11:02:02 +09:00
co-authored by Claude Opus 5
parent 2109f726fe
commit ab59130196
1524 changed files with 3160026 additions and 8369 deletions
@@ -31,8 +31,6 @@ source:
BFF 구조에서는 브라우저가 access token도 refresh token도 받지 않는다. confidential client인 BFF 서버가 code를 교환해 두 토큰을 server-side authorized client에 넣어 두고, 로그인이 끝난 브라우저는 HttpOnly가 붙은 AP3_SESSION 쿠키 하나로 BFF를 부른다.
브라우저가 토큰 대신 세션 쿠키를 받게 되면서 상태를 바꾸는 요청에도 쿠키를 자동으로 붙이게 됐고, 그래서 그 요청이 사용자가 보던 화면에서 시작한 것인지 가려낼 CSRF 검증이 새로 필요해졌다. 이때 쓰는 XSRF-TOKEN 값은 스크립트가 읽어 헤더에 다시 넣어야 해서 이 쿠키에는 HttpOnly를 붙이지 않았다. 지금 구현에서 확인한 것은 여기까지이고, 재시작한 뒤 로그인을 유지하는 것과 여러 replica가 세션을 함께 쓰는 것은 아직 구현하지 않았다.
## 관계
- **BFF 인증 구조 설계 기준**
@@ -175,7 +173,7 @@ GET http://app:8081/api/me
Authorization: Bearer <server-held-access-token>
```
브라우저가 보낸 `AP3_SESSION`은 이 요청에 실리지 않는다. BFF가 세션을 애플리케이션 쪽 자격 증명으로 소비하고, 보호 자원 서버가 이해하는 Bearer 자격 증명으로 바꾸기 때문이다. 보호 자원 서버는 AP1·AP2와 같은 무상태 JWT 경로에서 서명과 issuer, 시각, `keycloak-pattern-api` audience를 확인한 뒤 `subject`·`username`·`issuer`·`audience` 필드 4개짜리 JSON을 돌려주고, BFF는 그 응답을 그대로 브라우저에 중계한다. 그래서 `AP3_SESSION`은 브라우저와 BFF 사이에서만 쓰이고 Bearer 액세스 토큰은 BFF와 보호 자원 서버 사이에서만 쓰인다.
브라우저가 보낸 `AP3_SESSION`은 이 요청에 실리지 않는다. BFF가 세션을 애플리케이션 쪽 자격 증명으로 소비하고, 보호 자원 서버가 이해하는 Bearer 자격 증명으로 바꾸기 때문이다. 보호 자원 서버는 AP1·AP2와 같은 무상태 JWT 경로에서 서명과 issuer, 시각, `keycloak-pattern-api` audience를 확인한 뒤 `subject`·`username`·`issuer`·`audience` 필드 4개짜리 JSON을 돌려준다. BFF는 그 응답을 그대로 브라우저에 중계한다. 그래서 `AP3_SESSION`은 브라우저와 BFF 사이에서만 쓰이고 Bearer 액세스 토큰은 BFF와 보호 자원 서버 사이에서만 쓰인다.
:::warning
@@ -283,7 +281,7 @@ theme=dark
## 세션과 토큰을 서버가 들면 무엇을 더 해야 하나
이 구성을 실행했을 때 브라우저 쪽 JavaScript가 받는 응답에는 OAuth 토큰이 없었다. BFF 코드를 따라가 보니 BFF가 세션에서 authorized client를 찾아 액세스 토큰을 붙이고 내부 API를 대신 부르고 있었다.
이 구성을 실행했을 때 브라우저 쪽 JavaScript가 받는 응답에는 OAuth 토큰이 없었다. 처음에는 토큰을 다루는 일도 함께 사라진 것처럼 보였다. BFF 코드를 따라가 보니 BFF가 세션에서 authorized client를 찾아 액세스 토큰을 붙이고 내부 API를 대신 부르고 있었다.
브라우저가 토큰을 들지 않게 되면서 BFF가 새로 맡는 일 가운데 지금 구현에 들어 있는 것은 상태 변경 요청의 CSRF 검증뿐이다. 재시작 뒤 로그인 유지, 레플리카가 함께 쓰는 세션, 저장한 토큰 암호화, 로그아웃 때 세션과 authorized client 삭제, downstream 오류를 화면 오류로 바꾸는 처리, 타임아웃·재시도·서킷 브레이커, 경로별 인가 7개는 아직 없다.
@@ -29,8 +29,6 @@ source:
앞단에 세운 프록시가 로그인을 대신 받으면, 업스트림은 요청에 붙어 온 X-Auth-Request-User 헤더 하나만 보고 누가 보낸 요청인지 정한다. 이 헤더는 인증을 마친 프록시가 붙일 수도 있고 브라우저가 직접 적어 보낼 수도 있는데, 업스트림에 도착한 요청만으로는 둘을 가려낼 수 없다.
그래서 이 구성에서는 헤더를 믿을 조건을 세 곳에 나눠 두었다. 밖에서 들어오는 길을 Nginx 8088 하나로 줄이고, Nginx가 클라이언트의 동명 헤더를 자기 값으로 덮어쓰고, 업스트림이 사용자 정보 헤더와 함께 내부 토큰까지 대조한다. 호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다. 업스트림이 내부 토큰을 따로 확인하는 것은 그 요청을 걸러 내기 위해서다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
@@ -58,7 +56,9 @@ source:
Nginx 헤더 덮어쓰기 : 클라이언트가 보낸 동명 헤더를 합치지 않고 덮어쓴다
업스트림 내부 토큰 : 엣지를 거치지 않은 내부 요청을 막는다
네트워크를 나눠 두는 것만으로는 내부 서비스가 보낸 요청이나 Nginx가 잘못 넘긴 헤더까지 걸러 내지는 못한다. 공유 토큰만 두고 포트를 열어 두면, 그 토큰을 얻은 쪽이 Nginx를 건너뛰고 업스트림에 바로 붙을 수 있다.
호스트 포트를 닫아도 같은 Compose 네트워크 안에서는 app의 8081에 닿을 수 있고 그 요청은 Nginx를 거치지 않으니, 덮어쓰기도 함께 지나친다.
이렇게 네트워크를 나눠 두는 것만으로는 내부 서비스가 보낸 요청도, Nginx가 잘못 넘긴 헤더도 걸러 내지 못한다. 공유 토큰만 두고 포트를 열어 두면, 그 토큰을 얻은 쪽이 Nginx를 건너뛰고 업스트림에 바로 붙을 수 있다.
## 검증 환경
@@ -29,8 +29,6 @@ source:
AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 액세스 토큰, 리프레시 토큰, ID 토큰은 Web Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
그래서 새로고침 뒤에는 토큰이 사라진다. 다만 페이지가 열려 있는 동안에는 JavaScript가 토큰을 그대로 다루고, Resource Server를 부를 때도 액세스 토큰이 `Authorization` 헤더에 실린다. memory-only는 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)가 같은 origin의 사용자 권한으로 API를 부르는 것을 막지 못한다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
@@ -44,7 +42,7 @@ AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고
AP1에서는 토큰을 Local Storage나 Session Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
확인하고 싶었던 것은 토큰을 Web Storage에 쓰지 않는 것만으로 실행 중 XSS까지 막을 수 있는지였다.
확인하고 싶었던 것은 토큰을 Web Storage에 쓰지 않는 것만으로 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)까지 막을 수 있는지였다.
그래서 SPA가 authorization code를 직접 교환한 뒤 토큰을 어디에 들고 있는지, API를 부를 때 액세스 토큰이 어느 곳을 지나는지 따라갔다. PKCE가 실제로 어느 구간에 적용되는지도 같이 봤다.
@@ -144,7 +142,7 @@ GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
API를 부르는 동안에는 액세스 토큰이 요청의 `Authorization` 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
그래서 액세스 토큰은 JavaScript 메모리 안에만 머무르지 않고, API를 부르는 동안 요청 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
@@ -154,7 +152,7 @@ Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서
리프레시 토큰 rotation, 재사용 허용 : x
issuer·audience : 검증
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 뒤에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸는 작업이 아니라, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 뒤에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기려면 저장 위치만 바꿔서는 안 되고, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
실행 중 스크립트 자체의 위험은 CSP(Content Security Policy)와 의존성 무결성 검사로 따로 줄인다.
@@ -197,6 +195,8 @@ PKCE가 걸리는 구간은 authorization code를 토큰으로 교환하는 곳
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | `automaticSilentRenew`의 실제 갱신 경로 |
표에서 fetch를 가로채 Bearer 토큰을 본 항목은 노출의 한계를 일부러 재현한 것이다. Local Storage와 Session Storage에 토큰 문자열이 없다는 항목과 함께 봐야, 브라우저 저장소에는 없지만 실행 중 JavaScript 경계에는 있다는 것이 확인된다.
authorization request 쪽은 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 봤지만, token request body에 실제로 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값이 서로 어떻게 대조됐는지는 아직 확인하지 않았다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 필드를 같은 증거로 쓸 수 없다.
서명이 깨진 JWT와 만료된 JWT도 전용 테스트로 넣지 않았다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 내는 것은 봤지만, 그것이 실제 Nimbus 서명 검증과 issuer 검증을 지났다는 증거는 아니다.
@@ -221,6 +221,6 @@ SPA가 실제로 쓰는 callback은 `/callback.html` 하나인데 등록된 목
SPA : `/callback.html`만 o
exact callback만 허용하는 운영 가드레일, 잘못된 redirect를 거부하는 검사 : x
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거치고, 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 절대 URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거친다. 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
<!-- body:end -->
@@ -22,10 +22,6 @@ source:
BFF(Backend for Frontend)는 화면에 필요한 API를 브라우저 대신 호출하고 결과만 돌려주는 백엔드다. 애플리케이션 계층에서 API 응답 조합과 인가를 처리하면서도 브라우저 JavaScript에는 OAuth 토큰을 노출하지 않아야 한다면 이 구조를 선택할 수 있다.
이 경우 BFF가 authorization code를 토큰으로 교환해 액세스 토큰과 리프레시 토큰을 서버에 보관한다.
브라우저는 OAuth 토큰 대신 애플리케이션 세션으로 BFF를 호출하고,
BFF는 보관한 액세스 토큰으로 downstream Resource Server를 호출한다.
## 근거
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
@@ -47,19 +43,22 @@ BFF는 보관한 액세스 토큰으로 downstream Resource Server를 호출한
브라우저에 OAuth 토큰을 전달하지 않으려면 서버가 authorization code를 교환하고 액세스 토큰으로 downstream API를 호출해야 한다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하다 보니 액세스 토큰을 /token/access 응답으로 내보내고,
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하다 보니 액세스 토큰을 /token/access 응답으로 내보낸다.
그래서 브라우저에 OAuth 토큰을 주지 않는다는 요구에는 맞지 않는다.
Forward-Auth 구조도 브라우저에 OAuth 토큰을 전달하지 않을 수 있지만, upstream은 JWT를 직접 검증하지 않고 edge가 넘겨준 identity header를 사용한다.
Forward-Auth 구조도 이 요구는 만족할 수 있지만, 업스트림은 JWT를 직접 검증하지 않고 엣지가 넘겨준 identity header를 사용한다.
애플리케이션이 액세스 토큰으로 여러 Resource Server를 직접 호출하거나 사용자별 API 조합을 처리해야 한다면 BFF 쪽이 요구에 더 잘 맞는다.
브라우저가 Resource Server를 직접 호출하는 경로를 남겨야 한다면 Mediator 구조가 그 요구에 맞고, OAuth 흐름을 브라우저에서 직접 봐야 한다면 브라우저가 authorization code를 직접 교환하는 구조가 맞다.
이 결정은 그 두 가지를 포기하는 대신 브라우저에서 OAuth 토큰을 없앤다.
처리량과 장애 복구 시간, session failover, secret rotation 절차는 확인하지 못해서 이 판단의 근거가 아니다.
## 영향
- BFF가 로그인 상태와 액세스 토큰, 리프레시 토큰을 보관하는 보안 구성요소가 되므로, 요청을 그대로 넘기는 프록시처럼 다루지 않는다.
- 상태를 바꾸는 요청마다 CSRF 검증이 붙는다. 클라이언트에 내려가는 값과 실제로 제출해야 하는 값이 다를 수 있어서 클라이언트 코드도 그 차이를 알아야 한다.
- 재시작과 replica 이동을 견딜 공유 저장소, 저장한 토큰의 암호화, 암호화 키 교체를 함께 설계해야 한다. 이 세 가지는 아직 정하지 못했다.
- 재시작과 레플리카 이동을 견딜 공유 저장소, 저장한 토큰의 암호화, 암호화 키 교체를 함께 설계해야 한다. 이 세 가지는 아직 정하지 못했다.
- 로그아웃은 애플리케이션 세션과 authorized client를 함께 지워야 하는데, 두 상태를 따로 보관하다 보니 한 번의 삭제로 둘이 같이 지워지지 않는다.
- 모든 UI 요청이 BFF를 지나므로 지연과 단일 장애 지점을 함께 준비해야 한다.
- 브라우저에서 토큰을 없애도 XSS는 막히지 않는다. 같은 origin에서 실행되는 악성 script는 사용자 세션으로 BFF를 호출할 수 있어서 XSS 방어는 따로 세워야 한다.
- 브라우저에서 토큰을 없애도 XSS는 막히지 않는다. 같은 origin에서 실행되는 악성 스크립트는 사용자 세션으로 BFF를 호출할 수 있어서 XSS 방어는 따로 세워야 한다.
@@ -20,20 +20,7 @@ source:
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
현재 BFF의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
BFF(Backend for Frontend)가 서버에 들고 있는 상태는 둘이다. 하나는 브라우저의 로그인 세션이고,
다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
세션은 `session ID`로 찾지만 Authorized Client는 `client registration 이름``principal name`으로 찾는다.
찾는 열쇠가 이렇게 다르니 두 상태를 반드시 한 저장소에 담아야 하는 것은 아니다.
각각의 조회 방식과 운영 요구사항에 맞게 저장 구조를 따로 정할 수 있다.
공유 저장소 후보로 Redis를 우선 보고 있지만 아직 고르지 않았다.
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 토큰을 어떤 방식으로 암호화할지, 세션과 토큰의 만료 시간을 어떻게 맞출지,
로그아웃할 때 세션과 Authorized Client가 모두 지워지는지를 확인해야 하는데 아직 확인하지 않았다.
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
현재 BFF(Backend for Frontend)의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
## 관계
@@ -48,13 +35,19 @@ BFF(Backend for Frontend)가 서버에 들고 있는 상태는 둘이다. 하나
## 사실
- BFF가 서버에 들고 있는 상태는 둘이다.
하나는 브라우저의 로그인 세션이고, 다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
JavaScript 응답에 OAuth 토큰이 보이지 않았을 때는 토큰을 다루는 일도 같이 사라진 것처럼 보였지만,
BFF 코드를 따라가 보니 BFF가 세션에서 Authorized Client를 찾아 액세스 토큰을 붙여 내부 API를 대신 불렀다.
- 현재 구성에는 Spring Session도, Redis도, JDBC 저장소도, 토큰을 암호화해 담는 저장소도 없다.
- HttpSession은 서블릿 컨테이너의 메모리 구현을 쓰기 때문에, 그 프로세스가 종료되면 세션 데이터도 같이 사라진다.
- OAuth2AuthorizedClientService도 Spring Boot 자동구성이 고르는 메모리 구현이다.
- 세션은 session ID로 조회하 Authorized Client는 registration 이름과 principal name으로 조회한다.
- 세션은 session ID로 조회하지만 Authorized Client는 registration 이름과 principal name으로 조회한다.
두 저장 구조를 공유 저장소로 옮길 때는 각각 따로 설계해야 한다.
- Authorized Client 매니저에는 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
저장소를 공유하면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있다.
- 여기까지는 한 대에서 실행한 학습 환경에서 코드와 테스트로 확인한 것이다.
여러 인스턴스가 세션과 Authorized Client를 공유하는지는 실행해 확인하지 못했다.
## 가정
@@ -78,6 +71,7 @@ BFF(Backend for Frontend)가 서버에 들고 있는 상태는 둘이다. 하나
- Authorized Client를 찾을 때는 session ID를 쓰지 않아서, 세션만 공유해도 같은 사용자의 여러 세션이 같은 토큰을 본다.
- 현재 테스트에는 저장소 계약이 없다.
- 모든 화면 요청이 BFF를 지나기 때문에 저장소가 느려지면 화면도 바로 느려진다.
다만 이 학습 환경에서는 처리량을 재지 못해서, 어느 지점부터 느려지는지는 숫자로 말할 수 없다.
## 선택지
@@ -137,4 +131,6 @@ Redis와 관계형 DB를 둘 다 인증 경로에서 쓰게 되므로 모니터
4. 로그아웃 뒤 두 저장소에 잔여 항목이 없는지 확인한다.
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
암호화 키 교체 절차는 후보를 고른 뒤에 따로 설계한다.
@@ -21,10 +21,6 @@ source:
지금 엣지는 인증된 사용자의 `user``email`만 헤더로 넘기고, 업스트림 애플리케이션은 역할(role)을 보고 인가를 판단하지 않는다.
그래서 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지 아직 정해 두지 않았다. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지를 정해야 한다.
역할이나 권한처럼 애플리케이션 기능에 가까운 값이 계속 늘어난다면, 그 값을 엣지 헤더에 계속 더하는 대신 인가를 애플리케이션이 맡는 구조가 나은지도 함께 검토한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
@@ -49,8 +45,10 @@ source:
## 미지수
- 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지.
- 역할이 여러 개일 때 어떤 구분자와 이스케이프 규칙으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 어떻게 되는지. 프록시가 잘라 내는지, 요청 자체가 거부되는지.
현재 fixture는 `/api/edge``/`를 모두 `/edge/me`로 바꿔서 큰 헤더가 실린 요청을 통과시켜 본 적이 없다.
- 역할이 바뀌었을 때 프록시 세션과 다운스트림 인가에 언제 반영되는지. 권한을 바꾸고 몇 분 뒤에 반영되는지.
- 업스트림이 헤더가 있는지만 볼지, 값과 내부 서비스 식별값(service identity)까지 함께 볼지.
@@ -95,7 +93,7 @@ source:
이러면 엣지는 애플리케이션의 역할과 테넌트, 권한 정책을 알 필요가 없다. BFF가 사용자와 권한을 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 불러 결과를 합칠 수 있다.
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 하고, 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙어서 값을 바꾸는 요청에는 사용자가 의도한 것인지 확인하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 한다. 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙으니, 값을 바꾸는 요청에는 사용자가 의도한 것인지 확인하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
이 구조를 골랐다가 다시 엣지 쪽으로 되돌린다면 BFF가 맡던 사용자별 인가를 업스트림이나 별도 정책 서비스로 다시 옮겨야 한다.
@@ -21,8 +21,6 @@ source:
Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAuth 토큰을 서로 다른 저장소에 둔다. 지금 구현은 두 저장소를 모두 애플리케이션 서버의 메모리에 두기 때문에, 서버 프로세스가 끝나면 담아 둔 상태도 같이 사라진다.
운영에서 서버를 여러 인스턴스로 띄우려면 여기에 설계를 더해야 한다. 사용자의 요청이 로그인할 때와 다른 인스턴스로 가도 세션과 토큰을 찾을 수 있어야 하고, 서버를 재시작한 뒤에도 로그인 상태를 이어 갈지 정해야 한다. 로그아웃할 때 여러 인스턴스에 흩어져 있는 세션과 토큰을 어떻게 함께 지울지도 정해야 한다.
## 관계
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
@@ -55,15 +53,15 @@ Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAu
- 요청이 다른 인스턴스로 가도 같은 세션을 찾는가.
- 같은 사용자의 여러 세션이 Authorized Client 항목 하나를 함께 쓰는가, 아니면 서로 덮어쓰는가. 한쪽에서 로그아웃하면 다른 쪽 로그인도 끊기는가.
- 저장한 Refresh Token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽는가.
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가.
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가. 여러 인스턴스에 흩어져 있는 세션과 토큰은 어떻게 함께 지우는가.
- 세션 만료와 토큰 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 무엇이 보이는가.
- OAuth2-Proxy 구조의 레플리카들은 같은 Cookie Secret을 어떻게 나눠 갖고 어떻게 바꾸는가. 바꾸는 동안 이미 로그인해 있던 사람은 어떻게 되는가.
## 제약
- 지금은 인스턴스 하나로만 실행하고 있어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다.
- 지금은 인스턴스 하나로만 실행하는 학습 환경이어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다. 장애 복구에 걸리는 시간과 비밀값 교체 절차도 확인한 범위에 넣지 못했다.
- Authorized Client는 세션 ID로 저장하지도 조회하지도 않는다. 그래서 여러 인스턴스가 같은 세션을 쓰도록 세션 저장소만 공유 저장소로 바꿔서는 부족하다. 로그인 세션을 여러 인스턴스가 나눠 쓰는 방법과, OAuth 토큰이 들어 있는 Authorized Client를 어디에 어떻게 저장할지는 각각 따로 설계해야 한다.
- Resource Server의 8081이 host에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
- Resource Server의 8081이 호스트에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
## 선택지
@@ -79,7 +77,7 @@ Sticky Session은 같은 세션에서 온 요청을 되도록 같은 애플리
다만 그 인스턴스가 종료되면 그 메모리에 있던 로그인 세션과 OAuth 토큰도 함께 사라진다. 배포나 오토스케일링으로 인스턴스가 자주 바뀌는 환경이라면 Sticky Session만으로 로그인 상태를 지키기 어렵고, 인스턴스가 사라졌을 때 상태를 어떻게 되살릴지 따로 설계해야 한다.
### 3. 브라우저가 token을 들고 API를 직접 부른다
### 3. 브라우저가 토큰을 들고 API를 직접 부른다
서버에 로그인 세션이나 OAuth 토큰을 두지 않는 구조로 바꾸면 여러 인스턴스가 나눠 가질 상태 자체가 없어서, 공유 저장소도 session affinity도 필요하지 않다. Resource Server는 요청마다 실려 온 Access Token을 검증해서 처리한다.
@@ -22,8 +22,6 @@ source:
BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호출해 주는 백엔드다. 이 구조에서는 OAuth 토큰을 서버가 보관하고, 브라우저는 토큰 대신 세션 쿠키로 BFF에 요청한다.
쿠키로 인증하는 요청을 지킬 CSRF 검증, OAuth 토큰을 보관할 인가된 클라이언트(authorized client) 저장소, 로그아웃할 때 세션과 토큰을 함께 지우는 방법, BFF가 호출한 Resource Server에서 오류가 났을 때 이를 브라우저에 무엇으로 바꿔 돌려줄지를 같이 설계해야 한다.
## 관계
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
@@ -37,10 +35,12 @@ BFF(Backend For Frontend)는 화면에 필요한 API를 브라우저 대신 호
## 목적
BFF 구조에서는 BFF가 authorization code를 토큰으로 교환하고 그 액세스 토큰으로 Resource Server를 호출하므로, 브라우저의 로그인 상태를 담은 세션과 토큰을 보관하는 인가된 클라이언트를 함께 관리해야 한다.
BFF 구조에서는 BFF가 authorization code를 토큰으로 교환하고 그 액세스 토큰으로 Resource Server를 호출한다. 브라우저의 로그인 상태는 세션이 담고 OAuth 토큰은 인가된 클라이언트(authorized client)가 보관하므로, 이 둘을 함께 관리해야 한다. 브라우저 응답에서 토큰이 보이지 않는다고 토큰을 다루는 일까지 없어지지는 않는다. BFF는 요청을 넘겨 주는 프록시가 아니라 로그인 상태와 토큰을 가진 보안 구성요소가 된다.
쿠키가 자격 증명이 되면 브라우저가 요청마다 자동으로 붙여 보내기 때문에, 값을 바꾸는 요청은 사용자가 의도한 것인지 따로 확인해야 한다. 서버를 재시작하거나 요청이 다른 레플리카로 가더라도 로그인 상태를 이어 갈 저장소도 함께 필요하다.
쿠키로 인증하는 요청을 지킬 CSRF 검증, OAuth 토큰을 보관할 인가된 클라이언트 저장소, 로그아웃할 때 세션과 토큰을 함께 지우는 방법을 같이 설계해야 한다. BFF가 호출한 Resource Server에서 오류가 났을 때 이를 브라우저에 무엇으로 바꿔 돌려줄지도 함께 정해야 한다.
## 규칙
### 1. 브라우저에는 OAuth 토큰을 전달하지 않는다
@@ -22,8 +22,6 @@ source:
애플리케이션이 프록시가 붙인 사용자 정보 헤더만 보고 로그인한 사용자를 정하는 구조라면, 그 헤더가 정말 신뢰할 수 있는 프록시에서 온 것인지를 보장해야 한다.
그러려면 외부 사용자가 애플리케이션에 직접 닿지 못하도록 네트워크 경로를 좁히고, 사용자가 같은 이름의 헤더를 실어 보내도 프록시가 그 값을 지우거나 자기가 확인한 값으로 덮어써야 한다. 프록시를 거친 요청인지 따로 확인해야 하면 내부용 자격 증명도 같이 검증한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
@@ -91,6 +89,8 @@ Nginx에서는 해당 location에 internal을 설정해 외부에서 직접 부
운영 환경에서는 여기에 더 얹어야 한다. 내부 자격 증명 같은 비밀값은 Secret Manager 등으로 안전하게 주입하고 주기적으로 교체할 수 있어야 하고, Network Policy 등으로 모든 요청이 정해진 인증 경로를 지나게 제한해야 한다. 서비스 사이를 더 강하게 묶어야 한다면 mTLS나 Workload Identity를 적용하는 방법도 검토할 수 있다.
이 네 가지를 확인한 것은 Nginx 설정 하나에서다. 같은 조건이 Traefik ForwardAuth 같은 다른 forward-auth 구현에서도 그대로 성립하는지는 확인하지 못했다. 대안 설정으로 같은 속성을 내려면 전달 헤더를 믿지 않도록 끄는 설정, 인증 응답 헤더를 허용 목록대로만 복사하는 처리, 별도의 로그인 리다이렉트 흐름, 업스트림 내부 자격 증명이나 더 강한 Workload Identity 주입이 함께 필요했다. 살펴본 대안 설정에는 마지막 항목이 없어서 같은 속성을 낸다고 입증하지 못했다.
## 적용 조건
- 업스트림에 OAuth 클라이언트나 JWT 검증 코드를 넣기 어려울 때
@@ -18,9 +18,7 @@ source:
# 외부 IdP 연동과 Application 인증 구조의 경계
Google 로그인을 붙였다고 해서 다섯 번째 인증 구조가 새로 생기는 것은 아니다. 사용자가 Google에서 인증을 마치면 Keycloak이 그 결과를 받아 사용자를 확인하고, 애플리케이션에는 Keycloak 자신이 발급한 Authorization Code를 전달한다.
그다음 흐름은 기존과 같다. 애플리케이션은 여전히 Keycloak을 기준으로 인증을 처리하고, 토큰을 브라우저에서 관리할지 서버에서 관리할지에 따라 앞에서 구분한 네 가지 구조 중 하나를 쓴다.
Google 로그인을 붙였다고 해서 SPA, Mediator, BFF, OAuth2-Proxy에 이어 다섯 번째 인증 구조가 새로 생기는 것은 아니다. 사용자가 Google에서 인증을 마치면 Keycloak이 그 결과를 받아 사용자를 확인하고, 애플리케이션에는 Keycloak 자신이 발급한 Authorization Code를 전달한다.
## 관계
@@ -17,8 +17,6 @@ source:
네 패턴의 경계가 실제로 성립하는지 보려면 스택을 띄우고 브라우저로 로그인까지 해 봐야 하는데, 패턴별 검증 절차는 스택을 다시 만들기 전에 볼륨을 초기화한다. 보존해야 할 realm이나 데이터베이스가 같은 Compose 프로젝트에 있으면 검증을 돌리는 사이에 그 데이터를 잃는다.
그래서 대상 환경이 일회용인지 확정한 다음에 첫 검사를 돌리고, 마지막에는 테스트용 스택을 내린 뒤 원래 환경을 복구해 다시 확인한다. 중간에 하나라도 어긋나면 나머지를 계속 돌려 전체 통과를 만들지 않는다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
@@ -21,10 +21,6 @@ source:
IdP의 SSO 세션, 액세스 토큰, 리프레시 토큰, 애플리케이션의 세션 쿠키, 인증 프록시의 세션 쿠키는 만드는 쪽과 쓰는 쪽이 각각 다르고 유효 시간도 서로 다르다.
액세스 토큰이 만료되었다고 해서 애플리케이션 세션이나 IdP의 SSO 세션까지 같이 만료된 것은 아니고, 애플리케이션 세션을 삭제했다고 해서 IdP의 SSO 세션이나 이미 발급된 토큰이 사라지는 것도 아니다.
그래서 어떤 세션과 토큰이 아직 살아 있는지, 무엇이 만료되었는지, 로그아웃할 때 어떤 상태를 삭제하거나 무효화해야 하는지를 종류마다 나눠서 확인한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
@@ -38,9 +34,9 @@ IdP의 SSO 세션, 액세스 토큰, 리프레시 토큰, 애플리케이션의
## 목적
SPA(Single Page Application, 단일 페이지 애플리케이션), Mediator, BFF(Backend For Frontend) 구성에서는 Resource Server가 액세스 토큰을 검증한 뒤 JWT의 클레임에서 사용자 정보를 얻을 수 있다. Forward-Auth 구성에서는 애플리케이션이 인증 프록시가 전달한 사용자 정보 헤더를 쓴다. 두 경로가 같은 사용자 이름을 내놓더라도 한쪽은 액세스 토큰을 검증해서 얻은 값이고, 다른 한쪽은 신뢰하기로 정한 프록시가 전달한 값이다.
SPA(Single Page Application, 단일 페이지 애플리케이션), Mediator, BFF(Backend For Frontend) 구성에서는 Resource Server가 액세스 토큰을 검증한 뒤 JWT의 클레임에서 사용자 정보를 얻을 수 있다. Forward-Auth 구성에서는 애플리케이션이 인증 프록시가 전달한 사용자 정보 헤더를 쓴다. 두 경로가 같은 사용자 이름을 내놓더라도 한쪽은 액세스 토큰을 검증해서 얻은 값이고, 다른 한쪽은 신뢰하기로 정한 프록시가 전달한 값이다. 예를 들어 액세스 토큰의 `preferred_username`과 프록시가 만든 `X-Auth-Request-User`에는 모두 `regular-user`가 들어갈 수 있고, 이름만으로는 어느 쪽을 검증해서 얻은 값인지 구분되지 않는다.
로그아웃과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO 세션, 액세스 토큰과 리프레시 토큰, 애플리케이션 세션은 서로 다른 주체가 관리하고 수명도 다르다. 그래서 로그아웃할 때 무엇을 삭제하거나 무효화할지, 어떤 자격 증명이 만료되었을 때 어떤 상태를 계속 쓸 수 있는지를 각각 나눠서 설계한다.
로그아웃과 만료 처리에서도 같은 구분이 필요하다. IdP의 SSO 세션, 액세스 토큰과 리프레시 토큰, 애플리케이션 세션은 서로 다른 주체가 관리하고 수명도 다르다. 액세스 토큰이 만료되었다고 해서 애플리케이션 세션이나 IdP의 SSO 세션까지 같이 만료된 것은 아니다. 그래서 로그아웃할 때 무엇을 삭제하거나 무효화할지, 어떤 자격 증명이 만료되었을 때 어떤 상태를 계속 쓸 수 있는지를 각각 나눠서 설계한다.
## 규칙
@@ -72,7 +68,7 @@ IdP SSO 세션, OAuth 액세스 토큰, OAuth 리프레시 토큰, 애플리케
OAuth 토큰을 JavaScript 메모리에만 두면 Local Storage나 Session Storage 같은 Web Storage에 토큰을 계속 저장하지 않을 수 있다. 다만 실행 중인 브라우저 JavaScript에서 토큰에 접근할 수 없다는 뜻은 아니다.
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면 실행 중에는 토큰 값이 JavaScript가 다루는 메모리에 있다. 같은 Origin에서 악성 스크립트가 실행될 수 있는 상황이라면 토큰 응답이나 애플리케이션이 토큰을 처리하는 경로가 공격 대상이 될 수 있으므로, Web Storage에 토큰을 저장하지 않는 것만으로는 XSS(Cross-Site Scripting, 사이트 간 스크립팅)로 토큰에 접근하는 것까지 막지 못한다.
애플리케이션이 Token Endpoint의 응답을 JavaScript로 받아 처리한다면 실행 중에는 토큰 값이 JavaScript가 다루는 메모리에 있다. 같은 Origin에서 악성 스크립트가 실행될 수 있는 상황이라면 토큰 응답이나 애플리케이션이 토큰을 처리하는 경로가 공격 대상이 될 수 있다. 그래서 Web Storage에 토큰을 저장하지 않는 것만으로는 XSS(Cross-Site Scripting, 사이트 간 스크립팅)로 토큰에 접근하는 것까지 막지 못한다.
### 6. 로그아웃 범위를 상태별로 적는다