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:
co-authored by
Claude Opus 5
parent
2109f726fe
commit
ab59130196
+2
-4
@@ -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개는 아직 없다.
|
||||
|
||||
|
||||
+3
-3
@@ -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를 건너뛰고 업스트림에 바로 붙을 수 있다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
|
||||
+6
-6
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user