docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.
Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.
The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:
A-0 emptying the session table without a restart leaves cache entries
that get counted as replication arriving
A-2 dropping -o /dev/null fuses body and status into one string
A-3 presence of "ready to accept connections" instead of its timestamp
B-2 row count alone reads an UPDATE as nothing having happened
B-4 tr ',' '\n' splits ["admin","editor"] so only admin is seen
B-7 no login screen means the cookie died and SSO re-authenticated
C-1 counting sessions without joining realm counts your own kcadm one
D-1 kubectl exec without -i restores nothing and still exits 0
D-4a "ran with error output" is what success looks like
Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.
Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.
Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8062cc9a19
commit
6f6ab86345
@@ -0,0 +1,734 @@
|
||||
# C-2 재현 가이드 — 로그아웃이 왜 안 퍼지는지 양쪽에서 확인한다
|
||||
|
||||
해설 문서: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md) ·
|
||||
증거 원문: [`docs/evidence/c2-backchannel-logout/`](../../evidence/c2-backchannel-logout/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 두 클라이언트 어디에도 `backchannel.logout.url` 이 없는 것 | `kcadm get clients` |
|
||||
| BFF 소스에 `oidcLogout` 이 한 줄도 없는 것 | `grep -rn` |
|
||||
| 후보 경로 셋이 전부 `302` 인 것 — **핸들러가 없다는 뜻** | `curl` |
|
||||
| **IdP 쪽만 설정해도 앱 세션이 그대로 남는 것** | Redis |
|
||||
| Keycloak 이 앱 공개 URL 에 `200` 으로 **닿는** 것 | 임시 curl 파드 |
|
||||
| 로그에 `backchannel` 이 **0줄**인 것, 그리고 그것으로 단정하면 안 되는 이유 | Keycloak 로그 |
|
||||
| **끊을 세션이 없는 상태로 시험해 무의미해지는 것** | 로그아웃 전 세션 수 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`C-1`](c1-multi-app-sso.md) 이 끝나 있다. app1(BFF)·app2(oauth2-proxy)가
|
||||
둘 다 살아 있고, **IdP 로그아웃이 앱에 전파되지 않는다**를 이미 관측했다.
|
||||
이 실험은 **그 원인을 찾는** 실험이다.
|
||||
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다. 끝나면 되돌린다 —
|
||||
[5-3](#5-3-빌린-것을-돌려준다).
|
||||
- **BFF 소스 트리**(`bff/src/main/java/`)를 볼 수 있어야 한다. 1-2 가 그것을 읽는다.
|
||||
- **브라우저가 필요하다.** 살아 있는 세션을 만들어야 시험이 성립한다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- **Keycloak 이미지에는 `curl` 도 `wget` 도 없다**(`exit 127`). `kcadm.sh` 는
|
||||
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.** 도달성 시험은
|
||||
**임시 curl 파드**로 한다.
|
||||
- 이 실험대에는 **`jq` 가 없다.**
|
||||
|
||||
## 주의 — 이건 클라이언트 설정을 바꾸는 실험이다
|
||||
|
||||
`bff-confidential` 클라이언트의 **`attributes` 를 통째로 교체한다.**
|
||||
JSON 으로 주는 방식이라 **기존 속성이 같이 날아갈 수 있다.**
|
||||
그래서 [2-1](#2-1-지금-attributes-를-먼저-저장해-둔다) 의 첫 명령이 백업이다.
|
||||
|
||||
세션도 지운다. 실험대에서만 한다. 전 구간 약 20분.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:50–14:53 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||||
|
||||
> **★ 출처 하나에 주의가 붙어 있다.** 해설 문서 2절이 인쇄한 「설정이 들어갔다」
|
||||
> 확인 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
> 에서 나온 것이 **아니다.** 그 파일에는 **`command terminated with exit code 1`**
|
||||
> 이 남아 있다 — 점 표기로 시도한 **실패한 첫 시도**다. 성공 출력은 그 뒤 별도로
|
||||
> 실행한 조회에서 나왔다. **실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은
|
||||
> 잘못이었고**, 이 가이드는 3-1 에서 그 둘을 갈라 적는다.
|
||||
|
||||
클라이언트 UUID·IP 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
[C-1](c1-multi-app-sso.md) 이 이것을 관측했다.
|
||||
|
||||
```
|
||||
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
|
||||
```
|
||||
|
||||
**왜 안 퍼졌는지는 안 물었다.** 후보가 셋 있다.
|
||||
|
||||
| 후보 | 판정하는 법 |
|
||||
|---|---|
|
||||
| ① IdP 에 **보낼 주소**가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
|
||||
| ② 앱에 **받을 엔드포인트**가 없다 | 소스와 실제 경로를 본다 |
|
||||
| ③ IdP 가 앱에 **못 닿는다** (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 예상 | 셋 중 하나가 원인일 것 |
|
||||
| **실측** | **①과 ②가 둘 다 없었다.** ③은 문제가 아니었다(`HTTP 200`) |
|
||||
|
||||
**C-1 이 관측한 「로그아웃이 안 퍼진다」의 원인은 단순했다 — 아무도
|
||||
구현하지 않았다.**
|
||||
|
||||
그리고 이 실험이 실제로 증명하는 것은 그 다음이다.
|
||||
|
||||
```
|
||||
①만 고친다 → 여전히 안 퍼진다
|
||||
```
|
||||
|
||||
**양쪽이 다 있어야 동작한다.** 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는
|
||||
것이 이 주제에서 가장 흔한 실패다. 이 가이드는 **그 실패를 일부러 재현한다.**
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 어느 쪽에도 없다
|
||||
|
||||
넓은 것부터 좁혀 간다.
|
||||
|
||||
```
|
||||
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
|
||||
```
|
||||
|
||||
## 1-1. IdP 쪽 — 클라이언트 속성을 본다
|
||||
|
||||
**kcadm 을 먼저 로그인시킨다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||||
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||||
```
|
||||
|
||||
**확인** — 두 클라이언트를 각각 본다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
|
||||
```
|
||||
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||
```
|
||||
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
|
||||
--- bff-confidential ---
|
||||
"frontchannelLogout" : false,
|
||||
--- oauth2-proxy ---
|
||||
"frontchannelLogout" : false,
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **있는 것이 아니라 없는 것을 본다.**
|
||||
`backchannel.logout.url` 이 목록에 **없다.** 나온 것은 `frontchannelLogout` 뿐이다.
|
||||
|
||||
> **「없다」를 확인하는 법.** `grep backchannel` 로 걸러서 빈 출력을 보면
|
||||
> 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다 —
|
||||
> B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다.
|
||||
> **`--fields attributes` 로 통째로 받아 눈으로 훑는다.**
|
||||
> 다른 값(`frontchannelLogout`)이 보이는 것이 「명령은 먹었다」의 증거다.
|
||||
|
||||
## 1-2. 앱 쪽 — 소스에 받을 자리가 있는가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
|
||||
```
|
||||
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||
```
|
||||
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
|
||||
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **아무것도 안 나온다.** 헤더 아래가 비어 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — Spring Security 6.2+ 는 백채널 로그아웃을
|
||||
**지원하지만 명시적으로 켜야 한다.**
|
||||
|
||||
```java
|
||||
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
|
||||
```
|
||||
|
||||
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
|
||||
**생기지 않는다.** 소스에 없으니 경로도 없다.
|
||||
|
||||
> `grep` 이 빈 출력을 줄 때는 **경로가 맞는지 먼저 의심한다.**
|
||||
> `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를
|
||||
> 뒤져도 `grep` 은 조용히 0건을 준다.
|
||||
|
||||
## 1-3. 소스 말고 **실제로** 그 경로가 있는지 친다
|
||||
|
||||
**소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이다.** 직접 친다.
|
||||
|
||||
**확인** — 먼저 응답을 통째로 한 번 본다
|
||||
```bash
|
||||
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 상태줄과 `Location` 헤더. **302 라면 어디로 보내는가.**
|
||||
로그인 페이지로 보내면 「인증이 필요한 요청으로 처리됐다」는 뜻이고,
|
||||
**그런 핸들러가 없어서 기본 규칙에 걸린 것**이다.
|
||||
|
||||
이제 후보 셋을 나란히 잰다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
|
||||
curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
|
||||
done
|
||||
```
|
||||
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
|
||||
```
|
||||
=== 실제로 그 경로가 있는가 ===
|
||||
/logout/connect/back-channel/keycloak HTTP 302
|
||||
/backchannel-logout HTTP 302
|
||||
/oauth2/sign_out HTTP 302
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **셋 다 302.**
|
||||
|
||||
| 응답 | 뜻 |
|
||||
|---|---|
|
||||
| `302` | **그런 핸들러가 없어서 인증 요구로 떨어졌다** |
|
||||
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
|
||||
| `404` | 라우팅 자체가 없다 |
|
||||
|
||||
**이 결과가 의미하는 것** — 302 는 **「없다」의 증거**다. 엔드포인트가 있었다면
|
||||
POST 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다.
|
||||
|
||||
**후보 ②가 확정됐다.** ①은 1-1 에서 확정됐다.
|
||||
|
||||
## 1-4. ★ 끊을 세션이 있기는 한가 — 이걸 안 보면 실험이 무의미해진다
|
||||
|
||||
**원래 실행이 여기서 한 번 헛돌았다.**
|
||||
|
||||
**확인** — C-1 에서 배운 대로 **realm 을 조인해서** 센다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== 로그아웃 전 상태 ===
|
||||
Redis: 2 키
|
||||
keycloak-patterns 세션: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **IdP 세션이 0 이다.** Redis 에는 키가 2개 있는데
|
||||
Keycloak 쪽은 비어 있다.
|
||||
|
||||
**이 결과가 의미하는 것** — **이 상태에서 로그아웃을 걸면 아무 일도 안 난다.**
|
||||
끊을 대상이 없기 때문이다. 그리고 「앱 세션이 그대로다」를 보고
|
||||
**「전파가 안 되는구나」로 결론지을 뻔했다.**
|
||||
|
||||
> **★ 이것이 이 실험에서 가장 빠지기 쉬운 함정이다.** 주입은 정상적으로
|
||||
> 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. **틀린 것은 전제뿐이다.**
|
||||
> A층 내내 반복한 교훈 — **주입 대상이 실제로 존재하는지 먼저 확인한다.**
|
||||
|
||||
**하기** — 그러니 세션을 만든다. 브라우저에서
|
||||
```
|
||||
https://app1.hyeonworks.com/ → labuser / labpass
|
||||
```
|
||||
|
||||
**확인** — 다시 센다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt) 의 두 번째 시험
|
||||
```
|
||||
=== 로그아웃 전 — 실제 세션이 있는가 ===
|
||||
keycloak-patterns 세션: 1
|
||||
Redis: 1 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **세션 수가 1 이상.** 여기서 0 이면 로그인이 안 된 것이다.
|
||||
**0 인 채로 2절로 넘어가지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — IdP 쪽에만 설정한다
|
||||
|
||||
**의도적으로 한쪽만 고친다.** 「①만 있으면 되는가」가 이 실험의 질문이다.
|
||||
|
||||
## 2-1. 지금 attributes 를 먼저 저장해 둔다
|
||||
|
||||
**되돌리기가 이 백업에 달렸다.** JSON 으로 통째로 넣는 방식이라 기존 속성이
|
||||
덮인다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
CID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
|
||||
--format csv --noquotes | tail -1)
|
||||
echo "$CID"
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get "clients/$CID" -r keycloak-patterns --fields attributes \
|
||||
| tee ~/c2-bff-attributes-backup.json
|
||||
```
|
||||
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
```
|
||||
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
|
||||
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `echo "$CID"` 가 **UUID 한 줄**인가.
|
||||
비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고,
|
||||
그 상태로 다음 명령을 치면 **엉뚱한 클라이언트를 고친다.**
|
||||
|
||||
> 이 UUID 는 [C-1 3-2](c1-multi-app-sso.md#3-2-어느-클라이언트가-붙었는가) 에서
|
||||
> `bff-confidential` 로 확인한 바로 그 값이다(`9055fa46-…`).
|
||||
> **당신 환경의 값은 다르다. 위 명령이 뽑아 준다.**
|
||||
|
||||
## 2-2. ★ 점 표기는 안 먹는다
|
||||
|
||||
**하기** — 원래 실행이 처음에 친 것. **실패한다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns \
|
||||
-s "attributes.backchannel.logout.url=https://app1.hyeonworks.com/logout/connect/back-channel/keycloak"
|
||||
```
|
||||
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
```
|
||||
command terminated with exit code 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **종료코드 1.** 이건 조용한 실패가 **아니다** —
|
||||
실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려
|
||||
**「왜」는 안 보인다.**
|
||||
|
||||
**왜 안 되나** — 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`)
|
||||
`kcadm` 의 점 표기와 충돌한다. **JSON 으로 통째로 준다.**
|
||||
|
||||
## 2-3. JSON 으로 넣는다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns \
|
||||
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
|
||||
"backchannel.logout.session.required":"true"}'
|
||||
```
|
||||
|
||||
**되돌리기** — 2-1 의 백업을 보고 원래 값으로 다시 `update` 한다.
|
||||
백업이 `{ }` 처럼 비어 있었다면
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 걸렸는지 확인한다
|
||||
|
||||
## 3-1. 설정이 실제로 들어갔는가
|
||||
|
||||
**확인** — 1-1 과 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
|
||||
```
|
||||
**실측** — 해설 문서 2절이 인쇄한 값. **위 「출처 주의」가 붙는 자리다**
|
||||
```
|
||||
backchannel.logout.session.required = true
|
||||
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 두 속성이 **둘 다** 있는가.
|
||||
`url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
|
||||
|
||||
> **이 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
> 에 없다.** 그 파일은 2-2 의 실패로 끝나고, 위 값은 **그 뒤 별도로 실행한
|
||||
> 조회**에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 자리이므로
|
||||
> **당신은 지금 직접 재 두는 편이 낫다.**
|
||||
|
||||
## 3-2. 이 시점의 앱 상태를 적어 둔다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
|
||||
```
|
||||
=== 로그인 상태를 만든다 ===
|
||||
(브라우저에 이미 세션이 있다)
|
||||
Keycloak 세션: 2
|
||||
Redis: 2 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **키 이름을 그대로 적어 둔다.** 4-3 에서 **글자 하나까지
|
||||
같은지**를 볼 것이다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 관찰 — 로그아웃을 걸고 앱 세션을 본다
|
||||
|
||||
## 4-1. 시각을 적고 로그아웃한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
date '+%H:%M:%S 로그아웃'
|
||||
USERID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get users -r keycloak-patterns -q username=labuser --fields id \
|
||||
--format csv --noquotes | tail -1)
|
||||
echo "$USERID"
|
||||
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
create "users/$USERID/logout" -r keycloak-patterns
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== ★ IdP 로그아웃 → 백채널 알림 ===
|
||||
시각: 14:54:21
|
||||
```
|
||||
|
||||
**시각이 필요한 이유** — 뒤에서 로그를 뒤질 때 **「이 순간 전후」로 좁히기
|
||||
위해서**다. `--since` 만으로는 어느 시도인지 안 갈린다.
|
||||
|
||||
## 4-2. IdP 쪽은 끊겼는가
|
||||
|
||||
**확인** — 1-4 와 같은 쿼리
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||||
"select count(*) from offline_user_session us join realm r on r.id=us.realm_id
|
||||
where r.name='keycloak-patterns' and us.offline_flag='0'"
|
||||
```
|
||||
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||
```
|
||||
=== IdP 세션은 실제로 끊겼는가 ===
|
||||
keycloak-patterns 세션: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **0.** 로그아웃 자체는 동작했다.
|
||||
|
||||
**이 결과가 의미하는 것** — **주입은 성공했다.** 이제 앱 쪽을 볼 자격이 생겼다.
|
||||
여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 남아 있어도 그건 당연한
|
||||
결과라 아무것도 판정하지 못한다.
|
||||
|
||||
## 4-3. ★ 앱 세션은 그대로다
|
||||
|
||||
**확인** — 3-2 와 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== 앱 세션이 정리되었는가 ===
|
||||
Redis: 2 키
|
||||
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
|
||||
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
|
||||
```
|
||||
그리고 두 번째 시험(세션이 실제로 1개 있던 판)에서도
|
||||
```
|
||||
=== 앱 세션 ===
|
||||
Redis: 1 키
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **개수도 이름도 그대로다.**
|
||||
|
||||
**이 결과가 의미하는 것** — **IdP 쪽만 설정해도 소용없다.**
|
||||
①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과다.
|
||||
|
||||
## 4-4. 로그에 흔적이 있는가 — 그리고 그것으로 무엇을 말할 수 있는가
|
||||
|
||||
**확인** — Keycloak 양쪽 노드
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
|
||||
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
|
||||
```
|
||||
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||
```
|
||||
=== Keycloak 로그 전체에서 backchannel 흔적 ===
|
||||
keycloak-0: 0 줄
|
||||
keycloak-1: 0 줄
|
||||
```
|
||||
|
||||
**확인** — 앱 쪽
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
|
||||
```
|
||||
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
|
||||
```
|
||||
=== BFF 로그 — 백채널 요청이 도착했는가 ===
|
||||
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 양쪽 다 비어 있다.
|
||||
|
||||
**★ 그런데 여기서 결론을 넓히면 안 된다.**
|
||||
|
||||
| 이 출력이 말하는 것 | 말하지 않는 것 |
|
||||
|---|---|
|
||||
| 로그에 `backchannel` 문자열이 없다 | **Keycloak 이 요청을 안 보냈다** |
|
||||
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
|
||||
|
||||
**로그 레벨이 DEBUG 였다면 안 찍혔을 수 있다.** 「0줄」은 「안 보냈다」의
|
||||
증거가 아니라 **「기본 로그 레벨에서는 안 보인다」**일 뿐이다.
|
||||
|
||||
> **확실한 것은 앱 세션이 남았다는 관측이다.** 그것은 직접 봤다(4-3).
|
||||
> **관측한 것과 추론한 것을 섞지 않는다.** 로그 0줄을 근거로 「Keycloak 이
|
||||
> 안 보냈다」고 쓰면, 나중에 DEBUG 를 켜서 보냈다는 게 밝혀졌을 때
|
||||
> 결론 전체의 신뢰가 무너진다.
|
||||
|
||||
## 4-5. 네트워크 문제인가 — 후보 ③을 판정한다
|
||||
|
||||
**앱 세션이 안 지워지는 이유가 「요청이 못 닿아서」일 수도 있다.**
|
||||
그러면 구현이 아니라 네트워크를 고쳐야 한다. **갈라야 한다.**
|
||||
|
||||
Keycloak 파드에는 `curl` 이 없으므로 **같은 네임스페이스에 임시 파드**를 띄운다.
|
||||
|
||||
**하기** — **미검증** (원래 실행의 명령 원문은 기록에 없다. 출력은 실측이다)
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
|
||||
--image=curlimages/curl:8.11.1 --command -- sh
|
||||
```
|
||||
|
||||
파드 안에서
|
||||
```sh
|
||||
nslookup app1.hyeonworks.com
|
||||
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
|
||||
```
|
||||
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
|
||||
```
|
||||
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
|
||||
DNS 해석:
|
||||
Address: 100.83.212.4
|
||||
|
||||
Non-authoritative answer:
|
||||
|
||||
HTTPS 도달:
|
||||
HTTP 200 (0 이면 못 닿음)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가**
|
||||
|
||||
| 값 | 뜻 |
|
||||
|---|---|
|
||||
| `Address: 100.83.212.4` | 클러스터 안에서 **공개 이름이 풀린다** |
|
||||
| **`HTTP 200`** | **실제로 닿는다** |
|
||||
| `HTTP 000` | curl 이 연결조차 못 했다 = **네트워크가 원인** |
|
||||
|
||||
**이 결과가 의미하는 것** — **후보 ③은 원인이 아니다.**
|
||||
네트워크는 열려 있고, 그래도 세션은 남았다.
|
||||
|
||||
`exit` 으로 파드에서 나온다. `--rm` 이 지워 준다.
|
||||
|
||||
> **임시 파드는 Keycloak 파드의 완전한 대역이 아니다.** 같은 네임스페이스라
|
||||
> DNS 와 대체로 같은 경로를 타지만, **NetworkPolicy 나 사이드카가 걸려 있으면
|
||||
> 결과가 갈릴 수 있다.** 이 실험대에는 그런 것이 없어서 대역이 성립했다.
|
||||
> 확인: `sudo kubectl -n keycloak-lab get networkpolicy` 가 비어 있는가.
|
||||
|
||||
**그리고 이 200 은 이 실험대의 특수 사정이다.**
|
||||
|
||||
이 실험대는 **tailnet + split DNS** 구성이라 클러스터 안에서 공개 이름을
|
||||
불러도 되돌아온다(헤어핀). **운영에서는 안 되는 경우가 흔하다.**
|
||||
|
||||
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
|
||||
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
|
||||
> **설정을 해도 도달하지 못한다. 그때는 로그도 안 남고 조용히 실패한다.**
|
||||
|
||||
## 4-6. 그래서 왜 안 퍼졌는가
|
||||
|
||||
```
|
||||
IdP 로그아웃
|
||||
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (2절에서 설정함)
|
||||
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다 (1-2·1-3)
|
||||
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
|
||||
```
|
||||
|
||||
**②와 ③이 없다. ①만 설정해도 받을 사람이 없다.**
|
||||
|
||||
이것이 이 실험의 결론이고, **「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서
|
||||
확인한 것**이 이 가이드의 값이다.
|
||||
|
||||
## 4-7. 개념 — 백채널 로그아웃의 구조
|
||||
|
||||
```
|
||||
사용자가 어느 앱에서든 로그아웃
|
||||
│
|
||||
▼
|
||||
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
|
||||
│
|
||||
├──POST──▶ app1 의 backchannel.logout.url
|
||||
└──POST──▶ app2 의 backchannel.logout.url
|
||||
본문: logout_token (JWT)
|
||||
{ "sid": "...", "sub": "...", "events": {...} }
|
||||
```
|
||||
|
||||
### `sid` 가 여기서 쓰인다
|
||||
|
||||
**무엇인가.** `sid` 는 Keycloak 의 user session 식별자다.
|
||||
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
|
||||
|
||||
**왜 여기 나오나.** logout token 에 실려 오는 것이 `sid` 이고,
|
||||
**앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.**
|
||||
|
||||
```
|
||||
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
|
||||
```
|
||||
|
||||
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
|
||||
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다.
|
||||
|
||||
**없거나 틀리면.** 엔드포인트가 있어도 **어느 세션을 지울지 모른다.**
|
||||
그리고 **여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다** —
|
||||
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.** BFF 는 replica 2개다.
|
||||
|
||||
### 부분 실패는 어떻게 되는가
|
||||
|
||||
```
|
||||
app1 로그아웃 성공, app2 는 응답 없음
|
||||
└─ Keycloak 은 재시도하는가? 얼마나?
|
||||
└─ 사용자는 app2 에서 여전히 로그인 상태다
|
||||
```
|
||||
|
||||
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 「일부만 로그아웃된 상태」가
|
||||
생길 확률이 올라간다. **이 실험은 그 재시도 동작을 측정하지 않았다.**
|
||||
|
||||
## 4-8. 구현하려면 무엇이 필요한가
|
||||
|
||||
| 계층 | 할 일 | 이 실험대의 상태 |
|
||||
|---|---|---|
|
||||
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 | **완료** (2절) |
|
||||
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
|
||||
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿) | 없음 |
|
||||
| **네트워크** | IdP → 앱 공개 URL 도달 | **됨** (4-5). 운영은 확인 필요 |
|
||||
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 | — |
|
||||
|
||||
**마지막 줄이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
|
||||
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다 다르게
|
||||
동작하게 된다.** C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데,
|
||||
**그 공유가 로그아웃까지는 안 간다.**
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. 클라이언트 속성을 되돌린다
|
||||
|
||||
**하기** — 2-1 의 백업을 먼저 읽는다
|
||||
```bash
|
||||
cat ~/c2-bff-attributes-backup.json
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 원래 무엇이 있었는가. 비어 있었으면 빈 객체로,
|
||||
값이 있었으면 그 값으로 되돌린다.
|
||||
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
|
||||
```
|
||||
|
||||
**확인** — 1-1 과 같은 명령으로 사라졌는지 본다.
|
||||
|
||||
> **그대로 둬도 무방하다.** 받을 엔드포인트가 없으므로 이 설정 하나로는
|
||||
> 아무 일도 안 일어난다 — **그게 이 실험의 결론이었다.**
|
||||
> 다만 나중에 앱을 고쳤을 때 **왜 갑자기 동작하는지 모르게 되므로**,
|
||||
> 실험이 남긴 설정이라는 것을 기억하거나 지운다.
|
||||
|
||||
## 5-2. 세션을 정리한다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
|
||||
-c "delete from offline_client_session" -c "delete from offline_user_session"
|
||||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
|
||||
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
|
||||
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
|
||||
```
|
||||
|
||||
브라우저 쿠키(`auth`·`app1`·`app2`)도 지우거나 시크릿 창을 새로 연다.
|
||||
|
||||
## 5-3. 빌린 것을 돌려준다
|
||||
|
||||
**app2 는 Grafana 의 이름이다.** C 층이 끝났으면 여기서 되돌린다.
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
|
||||
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
|
||||
curl -sI https://app2.hyeonworks.com/ | head -3
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
|
||||
백업 파일이 없으면 [B-7 1-1](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다)
|
||||
을 다시 읽는다 — **그때 떠 뒀어야 하는 파일이다.**
|
||||
|
||||
oauth2-proxy 배포까지 걷어내려면
|
||||
```bash
|
||||
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
|
||||
```
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| 클라이언트 속성 | 1-1 의 `get clients … --fields attributes` | `backchannel.logout.url` 이 **없다** (지웠다면) |
|
||||
| Keycloak 세션 | 1-4 의 realm 조인 카운트 | `0` |
|
||||
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
|
||||
| 임시 파드 | `sudo kubectl -n keycloak-lab get pod c2probe` | `NotFound` (없어야 정상) |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` |
|
||||
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||||
|
||||
임시 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
|
||||
```
|
||||
|
||||
> **이 실험이 재지 않은 것**
|
||||
> · ②·③을 실제로 **구현한 뒤** 전파가 되는지 — 코드를 고쳐야 한다
|
||||
> · Keycloak 이 요청을 보내기는 했는지 (DEBUG 로그를 켜지 않았다)
|
||||
> · 부분 실패 시 **재시도 정책** (4-7)
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| **로그아웃했는데 아무 변화가 없다** | **로그아웃 전 세션이 이미 0 이었다** | realm 조인해서 먼저 센다 — 1-4 |
|
||||
| `kcadm -s "attributes.backchannel.logout.url=…"` 이 `exit 1` | **점 표기가 안 먹는다** | JSON 으로 통째로 — 2-2·2-3 |
|
||||
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 **통째로 교체**한다 | 먼저 백업 — 2-1 |
|
||||
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 — 2-1 |
|
||||
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다 — 1-4 (C-1 과 같은 실수) |
|
||||
| `grep -rn … bff/src/main/java/` 가 빈 출력 | **정말 없거나**, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 — 1-2 |
|
||||
| 후보 경로가 `404` 가 아니라 `302` | **핸들러가 없어 인증 요구로 떨어진 것** | 302 도 「없다」의 신호다 — 1-3 |
|
||||
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | **DEBUG 레벨이면 안 찍힌다** | 판정 근거로 쓰지 않는다 — 4-4 |
|
||||
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | **운영에서는 그게 정상일 수 있다** — 4-5 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 — 4-5 |
|
||||
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 — 1-1 |
|
||||
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | **인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다** | 새 탭에서 주소부터 다시 연다 |
|
||||
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 5-3 |
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| | C-2 가 남긴 것 |
|
||||
|---|---|
|
||||
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` **공유 저장소** (BFF replica 2개) |
|
||||
| **oauth2-proxy** | 백채널 로그아웃 미지원 — **SSO 안에서 앱마다 동작이 갈린다** |
|
||||
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** — 로그도 안 남는다 |
|
||||
| [C-1](c1-multi-app-sso.md) 과 연결 | 두 앱이 user session 을 공유한다. **그 공유가 로그아웃까지는 안 간다** |
|
||||
| [B-2](../../experiment-b2-multi-instance-session.md) 와 연결 | 로그아웃이 지우는 것은 지금도 **세 곳 중 하나뿐**이다 |
|
||||
| 방법론 | **주입 대상이 실제로 존재하는지 먼저 확인한다.** 이 실험이 그걸로 한 번 헛돌았다 |
|
||||
Reference in New Issue
Block a user