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,835 @@
|
||||
# B-3 재현 가이드 — 같은 refresh token 을 다섯 번 동시에 던지고 세션이 사라지는 것을 본다
|
||||
|
||||
해설 문서: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md) ·
|
||||
증거 원문: [`docs/evidence/b3-refresh-contention/`](../../evidence/b3-refresh-contention/)
|
||||
|
||||
## 이 가이드가 끝나면
|
||||
|
||||
당신 터미널에서 이것들을 **직접 본다.**
|
||||
|
||||
| 보게 되는 것 | 어디서 |
|
||||
|---|---|
|
||||
| 다섯 중 하나만 `200` 이고 나머지는 `400` | 파드 안 `curl` · `&` 와 `wait` |
|
||||
| 오류 메시지가 **두 종류**인 것 | `Maximum allowed refresh token reuse exceeded` / `Session doesn't have required client` |
|
||||
| **이긴 요청이 받은 토큰조차 못 쓰는 것** | 그 토큰으로 한 번 더 갱신 → `400` |
|
||||
| user session 은 남고 **client session 만 사라진** 것 | PostgreSQL, 정상 세션과 나란히 |
|
||||
| `refreshTokenMaxReuse` 를 올려도 안 되는 것 | 구성 A/B/C 비교 |
|
||||
|
||||
## 전제
|
||||
|
||||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||||
- [`B-2`](b2-multi-instance-session.md) 가 끝나 있다. **토큰이 공유되어야
|
||||
경쟁이 성립한다** — 다만 이 실험은 Keycloak 쪽 동작만 분리해 보려고
|
||||
**BFF 를 거치지 않고** 토큰 엔드포인트를 직접 친다.
|
||||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다.
|
||||
- **브라우저는 필요 없다.** direct grant(`grant_type=password`)로 토큰을
|
||||
만들므로 전 구간을 터미널에서 한다.
|
||||
- realm 은 `keycloak-patterns`, 사용자는 `labuser` / `labpass`,
|
||||
클라이언트는 `bff-confidential`.
|
||||
|
||||
## 주의 — 이건 realm 설정을 바꾸는 실험이다
|
||||
|
||||
`revokeRefreshToken` 을 켠다. **realm 전체에 걸린다** — 그 realm 을 쓰는 다른
|
||||
실험(B-2 의 BFF 로그인 포함)이 이 설정의 영향을 받는다. **실험대에서만 한다.**
|
||||
전 구간 약 20분이고, 되돌리는 명령은 [2-2](#2-2-적용) 와 [5-1](#5-1-realm-설정을-되돌린다)
|
||||
에 있다. 중간에 그만두려면 5-1 의 한 줄이면 된다.
|
||||
|
||||
## 표시 규약
|
||||
|
||||
| 표시 | 뜻 |
|
||||
|---|---|
|
||||
| **실측** | 2026-09-04 14:16–14:17 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 sid·길이는 당신 것과 다르다 |
|
||||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||||
|
||||
sid·토큰 길이는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
|
||||
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위
|
||||
실행 기록의 실제 값이다.
|
||||
|
||||
---
|
||||
|
||||
# 0. 왜 이 실험을 하는가
|
||||
|
||||
B-2 가 토큰을 PostgreSQL 로 옮겼다. 그래서 **두 replica 가 같은 행을 본다.**
|
||||
그리고 조회 키에 session id 가 없으니 **같은 사용자의 두 브라우저도 같은 행을
|
||||
본다.** 그 행에는 refresh token 이 하나 들어 있다.
|
||||
|
||||
**둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.**
|
||||
|
||||
| | 예측 |
|
||||
|---|---|
|
||||
| 통념 | **하나는 성공하고 하나는 실패한다.** 실패한 쪽이 새 토큰을 다시 읽어 재시도하면 된다 |
|
||||
| B-3 이 재는 것 | 진짜 그런가. **그리고 이긴 쪽은 멀쩡한가** |
|
||||
|
||||
이 구별이 설계를 가른다.
|
||||
|
||||
```
|
||||
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
|
||||
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
|
||||
```
|
||||
|
||||
**재시도로 회복되면 lock 이 필요 없고, 회복 안 되면 lock 말고 답이 없다.**
|
||||
그러니 재야 할 것은 「몇 개가 성공했나」가 아니라 **「이긴 요청의 토큰을 다시
|
||||
쓸 수 있나」**다. 4-4 가 그 자리다.
|
||||
|
||||
> **개념 — 재사용 탐지(reuse detection)란 무엇인가.**
|
||||
>
|
||||
> 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화한다. 그런데
|
||||
> 무효화된 옛 토큰이 **다시 들어오면** 두 가지 중 하나다.
|
||||
>
|
||||
> ```
|
||||
> ① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
|
||||
> ② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
|
||||
> ```
|
||||
>
|
||||
> **서버는 둘을 구별할 수 없다.** 그래서 OAuth 2.0 보안 권고는 **안전한
|
||||
> 쪽으로 가정하고 세션 전체를 무효화**하라고 말한다. 이 실험이 보는
|
||||
> 파괴는 **버그가 아니라 그 규격이 시키는 대로 동작한 결과**다.
|
||||
> 그래서 "고쳐 달라"가 아니라 "겹치지 않게 하라"가 답이 된다.
|
||||
|
||||
---
|
||||
|
||||
# 1. 기준선 — 회전을 켜기 전에
|
||||
|
||||
**시험군만 재는 측정은 측정이 아니다.** 회전이 꺼진 상태에서 같은 명령을
|
||||
먼저 돌려 두어야, 나중에 나오는 400 이 「원래 그런 것」인지 「내가 켠 것」
|
||||
때문인지 구별된다.
|
||||
|
||||
```
|
||||
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
|
||||
```
|
||||
|
||||
## 1-1. 파드가 정상인가
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -o wide
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
NAME READY STATUS RESTARTS AGE IP NODE
|
||||
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
|
||||
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
|
||||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — Keycloak 이 **둘 다** `1/1`. 하나가 NotReady 면
|
||||
Service 가 전부 한쪽으로 보내고, 그러면 **동시성이 한 노드 안에서만** 생긴다.
|
||||
이 실험은 그래도 재현되지만 「replica 를 넘는 경쟁」이라고 말할 수 없게 된다.
|
||||
|
||||
## 1-2. realm 이 지금 무엇으로 설정되어 있나
|
||||
|
||||
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 get secret keycloak-lab-secrets \
|
||||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||||
> ```
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
**실측**
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 세 값 전부.
|
||||
|
||||
| 값 | 뜻 | 지금 |
|
||||
|---|---|---|
|
||||
| `revokeRefreshToken` | **회전 스위치** | `false` — **꺼져 있다** |
|
||||
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
|
||||
| `accessTokenLifespan` | access token 수명(초) | `60` |
|
||||
|
||||
**이 결과가 의미하는 것** — **기본값은 회전이 꺼져 있다.** Q2 는
|
||||
*"realm 이 refresh token rotation 과 재사용 허용 0회를 쓰게 되어서"* 를
|
||||
전제로 하므로, **그 전제를 만드는 것이 이 실험의 주입**이다. 지금 그대로
|
||||
재면 Q2 와 다른 것을 재게 된다.
|
||||
|
||||
`accessTokenLifespan=60` 은 B-0 에서 **이 실험을 위해** 넣어 둔 값이다.
|
||||
만료를 기다리는 시간이 짧아야 재현이 된다.
|
||||
|
||||
## 1-3. 상주 탐침 파드를 띄운다
|
||||
|
||||
Keycloak 이미지에는 `curl` 도 `wget` 도 없다(`exit 127`). 그리고 이 실험은
|
||||
**토큰을 단계 사이로 넘겨야** 한다 — 발급받은 토큰을 뒤 단계에서 써야 하므로
|
||||
`--rm` 임시 파드로는 안 된다. **파드를 하나 띄워 두고 `exec` 로 이어간다.**
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
|
||||
--restart=Never \
|
||||
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
|
||||
--env="CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
|
||||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
|
||||
--command -- sleep 7200
|
||||
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
pod/b3-probe condition met
|
||||
```
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
**확인** — 환경변수가 들어갔나. **값이 아니라 길이만 본다**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
|
||||
```
|
||||
**형태**
|
||||
```
|
||||
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
|
||||
```
|
||||
`CS길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
|
||||
|
||||
> **왜 Service 로 가는가.** A-1·A-2 는 「어느 노드가 답했나」가 질문이라 파드
|
||||
> IP 로 직접 쳤다. 여기는 반대다 — **replica 를 넘는 경쟁**이 질문이므로
|
||||
> Service 가 요청을 흩는 것이 오히려 필요한 조건이다.
|
||||
|
||||
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec -it b3-probe -- sh
|
||||
```
|
||||
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit` — **파드는 안 지워진다**
|
||||
(`--rm` 이 없다).
|
||||
|
||||
## 1-4. 토큰 하나를 발급받고 sid 를 뽑는다
|
||||
|
||||
**하기 ①** — 파드 안에서. **처음 한 번은 응답을 통째로 본다**
|
||||
```sh
|
||||
curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
|
||||
```
|
||||
**형태** — 한 줄 JSON 이 나온다
|
||||
```json
|
||||
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
|
||||
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `expires_in` 이 60. 1-2 에서 본 `accessTokenLifespan`
|
||||
그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct
|
||||
grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름/비밀번호다.
|
||||
|
||||
**하기 ②** — 변수에 담고 sid 를 뽑는다
|
||||
```sh
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 SID=$SID"
|
||||
```
|
||||
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
|
||||
```
|
||||
=== [1] refresh token 하나 확보 ===
|
||||
토큰 길이: 811
|
||||
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
|
||||
sid: BvFiB01Rntz1FcLdf7zG4BNt
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`SID` 를 종이에 적어 둔다.** 4-5 에서 DB 를 뒤질 때
|
||||
이 값이 필요하고, 그때는 **파드 밖**이라 변수가 안 넘어간다.
|
||||
|
||||
> `sid` 가 빈 줄이면 base64 패딩 때문이다. `sed 's/$/==/'` 가 그 보정이고,
|
||||
> 그래도 안 나오면 **base64url 문자(`-` `_`)** 때문일 수 있다. 그때는:
|
||||
> ```sh
|
||||
> echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||||
> | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||||
> ```
|
||||
> **미검증** — 원래 실행은 위쪽 형태를 썼다. 페이로드 전체가 나오면 그 안에서
|
||||
> `"sid"` 를 눈으로 찾는다.
|
||||
|
||||
## 1-5. 대조군 — 지금은 무엇이 되는가
|
||||
|
||||
### ① 순차로 다섯 번 갱신한다
|
||||
|
||||
**하기** — 파드 안에서. `&` 없이, 한 번에 하나씩
|
||||
```sh
|
||||
for i in 1 2 3 4 5; do
|
||||
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$RT")
|
||||
echo "순차 $i: $(echo "$R" | tail -1)"
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
done
|
||||
```
|
||||
**미검증** — 증거 파일에는 순차 실행 기록이 없다. 해설 문서는
|
||||
**"순차 실행이면 재현되지 않는다"** 고 말하며, 이 절은 그것을 당신 손으로
|
||||
확인하는 자리다.
|
||||
|
||||
**어디를 봐야 하는가** — 다섯 줄 전부 `200` 이어야 한다.
|
||||
|
||||
> **회전이 켜지면 `RT` 를 매번 다시 담아야 한다.** 위 루프가 그렇게 되어
|
||||
> 있다. 옛 것을 계속 쓰면 뒤에 나오는 400 이 「경쟁」 때문인지 「내가 옛
|
||||
> 토큰을 썼기」 때문인지 구별이 안 된다. **이 실험에서 가장 흔한 자기오염이다.**
|
||||
|
||||
### ② 경쟁을 겪지 않은 세션은 어떻게 생겼나
|
||||
|
||||
4-5 에서 볼 DB 모양을 **지금 미리 본다.** 이게 없으면 나중에 나오는 `0` 이
|
||||
「경쟁 때문」인지 「원래 그런 표」인지 모른다.
|
||||
|
||||
**확인** — **파드 밖**(kc-lab-1)에서. 아래의 sid 자리에는 **1-4 에서 적어 둔
|
||||
당신의 `SID`** 를 넣는다. 여기 실린 값은 원래 실행의 대조군 세션 것이다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'JT-XuepgutWcE273QwAnIXta'"
|
||||
```
|
||||
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
|
||||
의 대조군 부분
|
||||
```
|
||||
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
|
||||
새 sid: JT-XuepgutWcE273QwAnIXta
|
||||
user_session_id | client_sessions
|
||||
--------------------------+-----------------
|
||||
JT-XuepgutWcE273QwAnIXta | 1
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`client_sessions = 1`.** 정상 세션은 이렇게 생겼다.
|
||||
|
||||
> **개념 — user session 과 client session 은 다른 것이다.**
|
||||
>
|
||||
> ```
|
||||
> user session "이 브라우저는 labuser 로 로그인함"
|
||||
> ├─ client session : bff-confidential
|
||||
> └─ client session : oauth2-proxy
|
||||
> ```
|
||||
>
|
||||
> 사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 **user session 하나
|
||||
> 아래에 client session 이 여럿** 달린다. 그게 SSO 다.
|
||||
> **재사용 탐지는 이 중 client session 만 제거한다** — 4-5 에서 그것을 본다.
|
||||
>
|
||||
> 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데,
|
||||
> `offline_flag` 열이 그것을 가른다. 위 출력의 `offline_flag = 0` 이
|
||||
> 「온라인 세션」이다.
|
||||
|
||||
---
|
||||
|
||||
# 2. 주입 — 회전을 켠다
|
||||
|
||||
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
|
||||
|
||||
**되돌리기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||
```
|
||||
|
||||
## 2-1. 무엇을 켜는가 — 이름이 헷갈린다
|
||||
|
||||
> **개념 — `revokeRefreshToken` 이 회전 스위치다.**
|
||||
>
|
||||
> 이름이 「회전(rotation)」이 아니라 **「취소(revoke)」**다. 켜면 새 토큰을
|
||||
> 줄 때 **옛 토큰을 무효화**한다. 그 결과가 회전이다.
|
||||
>
|
||||
> | 설정 | 뜻 |
|
||||
> |---|---|
|
||||
> | `revokeRefreshToken` | **회전 스위치.** 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
|
||||
> | `refreshTokenMaxReuse` | 그 위에서 **몇 번까지 봐줄 것인가** |
|
||||
>
|
||||
> **`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다.**
|
||||
> 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니
|
||||
> 「봐줄 횟수」를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색인 이유다.
|
||||
|
||||
## 2-2. 적용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||
date '+%H:%M:%S 회전 켬'
|
||||
```
|
||||
**형태** — 성공하면 아무 말도 안 한다(무소식이 좋은 소식이다)
|
||||
```
|
||||
14:16:12 회전 켬
|
||||
```
|
||||
|
||||
**시각을 적어 둔다.** 4절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다.
|
||||
**켜기 전에 발급한 토큰으로 재면 안 된다** — 발급 시점의 정책이 아니라 검증
|
||||
시점의 정책이 적용되므로 섞여서 해석이 안 된다.
|
||||
|
||||
---
|
||||
|
||||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||||
|
||||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||||
|
||||
## 3-1. 설정이 실제로 바뀌었나
|
||||
|
||||
**확인** — 1-2 와 **똑같은 명령**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — `revokeRefreshToken` 이 `true`. `false` 그대로면
|
||||
`update` 가 다른 realm 에 갔거나 kcadm 세션이 만료된 것이다. kcadm 은
|
||||
**실패해도 조용할 때가 있다** — 반드시 다시 읽어서 확인한다.
|
||||
|
||||
## 3-2. 엉뚱한 것을 죽이지 않았나
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab get pods -l app=keycloak
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** realm 설정 변경은 재시작을
|
||||
일으키지 않는다. 여기서 재시작이 올랐다면 다른 것을 건드린 것이고, 그 상태로
|
||||
재면 「경쟁」이 아니라 「재시작」을 재게 된다.
|
||||
|
||||
## 3-3. 회전이 실제로 동작하는가 — 한 번만 갱신해 본다
|
||||
|
||||
**동시성을 넣기 전에, 회전 자체가 도는지 확인한다.**
|
||||
|
||||
**하기** — 파드 안에서. 새 토큰을 하나 받고, **한 번 갱신한 뒤 옛 것을 다시 쓴다**
|
||||
```sh
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
|
||||
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||
|
||||
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$OLD"
|
||||
```
|
||||
**미검증** — 이 절은 이 가이드가 덧붙인 사전 확인이다. 증거 파일에는 없다.
|
||||
|
||||
**어디를 봐야 하는가** — **1회차 `200`, 2회차 `400`.**
|
||||
|
||||
**이 결과가 의미하는 것** — 옛 토큰이 무효화된다 = 회전이 켜졌다.
|
||||
2회차도 `200` 이면 **회전이 안 켜진 것**이고, 그 상태로 4절을 돌리면 다섯 개가
|
||||
전부 200 으로 나온다 — 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.
|
||||
|
||||
---
|
||||
|
||||
# 4. 효과를 관찰한다
|
||||
|
||||
## 4-1. 깨끗한 토큰을 하나 새로 받는다
|
||||
|
||||
3-3 에서 쓴 토큰은 이미 무효다. **새로 시작한다.**
|
||||
|
||||
**하기** — 파드 안에서
|
||||
```sh
|
||||
R=$(curl -s -X POST "$KC" \
|
||||
-d grant_type=password -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
|
||||
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
|
||||
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
|
||||
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
|
||||
echo "refresh=${#RT}자 SID=$SID"
|
||||
```
|
||||
|
||||
**`SID` 를 다시 적어 둔다.** 4-5 에서 쓴다.
|
||||
|
||||
## 4-2. ★ 동시에 다섯 개 — `&` 와 `wait` 이 없으면 재현되지 않는다
|
||||
|
||||
**이 절이 이 실험의 전부다.** 순차로 돌리면 아무 일도 안 일어난다(1-5 에서
|
||||
직접 봤다). 진짜로 겹쳐야 한다.
|
||||
|
||||
**하기** — 파드 안에서
|
||||
```sh
|
||||
i=1
|
||||
while [ $i -le 5 ]; do
|
||||
( curl -s -o /tmp/b$i -w '%{http_code}' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
|
||||
i=$((i+1))
|
||||
done
|
||||
wait
|
||||
for i in 1 2 3 4 5; do
|
||||
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
|
||||
done
|
||||
```
|
||||
**미검증** — 원래 실행은 스크립트였다. 이 형태는 손으로 치기 좋게 고친 것이고,
|
||||
**본문과 응답 코드를 파일로 갈라 놓아 순서대로 다시 읽을 수 있게** 했다.
|
||||
다섯 개를 동시에 띄우면 출력이 뒤섞여 어느 줄이 어느 요청인지 알 수 없다 —
|
||||
그래서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
|
||||
|
||||
**어디를 봐야 하는가** — 셸 문법 세 조각이 전부다.
|
||||
|
||||
```
|
||||
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
|
||||
wait 띄운 것이 전부 끝날 때까지 기다린다
|
||||
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
|
||||
```
|
||||
|
||||
**`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다.**
|
||||
`wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다.
|
||||
|
||||
## 4-3. 결과 — 오류가 두 종류다
|
||||
|
||||
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
|
||||
```
|
||||
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
|
||||
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
|
||||
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
요청 5: HTTP 200 {"access_token":"...(발급됨)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **성공 개수가 아니라 오류 메시지가 두 종류인 것.**
|
||||
|
||||
| 메시지 | 뜻 |
|
||||
|---|---|
|
||||
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
|
||||
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
|
||||
|
||||
**이 결과가 의미하는 것** — 만약 「하나만 이기고 나머지는 진다」였다면 지는
|
||||
쪽 메시지는 **전부 같아야** 한다. 두 종류라는 것은 **중간에 상태가 바뀌었다**
|
||||
는 뜻이다. 그 바뀐 상태가 무엇인지가 4-5 다.
|
||||
|
||||
> 성공한 번호는 당신 환경에서 다르다. 증거에서는 5번이었지만 순서는
|
||||
> 스케줄링에 달렸다. **몇 번이 이겼는가는 아무 의미가 없다.**
|
||||
|
||||
## 4-4. ★ 이긴 요청의 토큰을 다시 써 본다 — 여기서 진짜 답이 나온다
|
||||
|
||||
**하기** — 파드 안에서. 다섯 응답 중 `refresh_token` 이 들어 있는 것을 꺼낸다
|
||||
```sh
|
||||
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
|
||||
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
|
||||
echo "새 refresh token 길이: ${#NEW}"
|
||||
|
||||
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d "client_secret=$CS" -d "refresh_token=$NEW"
|
||||
```
|
||||
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||
```
|
||||
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
|
||||
새 refresh token 길이: 810
|
||||
그 토큰으로 다시 갱신: HTTP 400
|
||||
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`400`.** 그리고 메시지가
|
||||
`Session doesn't have required client`.
|
||||
|
||||
**이 결과가 의미하는 것** — **이긴 요청조차 쓸 수 없는 토큰을 받았다.**
|
||||
|
||||
```
|
||||
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
|
||||
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
|
||||
```
|
||||
|
||||
**오류가 지연되어 나타난다.** 200 을 받은 코드는 성공했다고 믿고 토큰을
|
||||
저장한다. 끊긴 것은 **그다음 요청에서** 안다. 로그를 볼 때 원인 시각과 증상
|
||||
시각이 어긋나 보이는 이유가 이것이다.
|
||||
|
||||
> **여기서 「재시도하면 되지 않나」가 무너진다.** 새 토큰을 다시 읽어
|
||||
> 재시도해도 **그 토큰이 이미 무효**다. 재시도할 대상이 없다.
|
||||
|
||||
## 4-5. 기제 확정 — 무엇이 사라졌는가
|
||||
|
||||
**확인** — **파드 밖**에서. `SID` 는 4-1 에서 적어 둔 값이다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag, us.last_session_refresh
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||
```
|
||||
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||
```
|
||||
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
|
||||
user_session_id | offline_flag | last_session_refresh
|
||||
--------------------------+--------------+----------------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **행이 남아 있다.** 세션이 통째로 지워진 것이 아니다.
|
||||
그러면 왜 `Session doesn't have required client` 인가. **client session 을 센다.**
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id, us.offline_flag,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us
|
||||
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
|
||||
```
|
||||
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
|
||||
```
|
||||
=== user session 과 client session 을 나눠서 본다 ===
|
||||
user_session_id | offline_flag | client_sessions
|
||||
--------------------------+--------------+-----------------
|
||||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`client_sessions = 0`.** 1-5 의 대조군은 `1` 이었다.
|
||||
**같은 명령, 다른 결과 — 그것이 이 실험의 판정이다.**
|
||||
|
||||
**이 결과가 의미하는 것**
|
||||
|
||||
```
|
||||
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
|
||||
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
|
||||
```
|
||||
|
||||
그래서 오류 문구가 정확히 그 말을 한다 — **세션은 있는데 그 클라이언트 몫이
|
||||
없다.** 메시지를 오해해서 「세션이 만료됐다」로 읽으면 엉뚱한 곳을 고치게 된다.
|
||||
|
||||
## 4-6. 폐기 목록에 실린 것이 아니다
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select count(*) as revoked_count from revoked_token"
|
||||
```
|
||||
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
|
||||
```
|
||||
=== [5] revoked_token 테이블 ===
|
||||
revoked_count
|
||||
---------------
|
||||
0
|
||||
(1 row)
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **`0`.**
|
||||
|
||||
**이 결과가 의미하는 것** — 「토큰을 블랙리스트에 올려서 막는다」가 아니다.
|
||||
**client session 이 사라져서 검증할 대상이 없어진 것**이다. 토큰을 지우는
|
||||
방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 **그 client 에 대한
|
||||
모든 토큰이 한꺼번에 죽는다.** 4-4 의 결과가 그것이다.
|
||||
|
||||
## 4-7. 시간선 — 왜 이긴 쪽도 죽는가
|
||||
|
||||
```
|
||||
t0 5개가 동시에 도착
|
||||
t1 하나가 처리를 시작 → 새 토큰 발급 준비
|
||||
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
|
||||
t3 ★ client session 제거
|
||||
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
|
||||
t5 그 토큰을 쓰면 → client session 이 없다 → 400
|
||||
```
|
||||
|
||||
**t3 와 t4 의 순서가 전부다.** 응답을 만들던 요청은 이미 「성공」이 확정된
|
||||
상태로 나가고, 그 사이 바닥이 빠진다.
|
||||
|
||||
## 4-8. 정책을 바꿔 비교한다
|
||||
|
||||
**한 번 더 재기 전에 세션을 새로 만든다.** 파괴된 세션으로 재면 전부 400 이다.
|
||||
|
||||
### 구성 B — 회전 OFF
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false
|
||||
```
|
||||
파드 안에서 **4-1 → 4-2 → 4-4 → 4-5 를 그대로 반복**한다.
|
||||
|
||||
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
|
||||
```
|
||||
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
|
||||
sid=iW1CGyO7COdyJLryIrCt3njk
|
||||
1: 200
|
||||
2: 200
|
||||
3: 200
|
||||
4: 200
|
||||
5: 200
|
||||
성공 5 / 5
|
||||
이긴 토큰 재사용: HTTP 200
|
||||
남은 client_session: 1
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — **전부 200 이고 세션도 멀쩡하다.**
|
||||
|
||||
**이 결과가 의미하는 것** — 같은 refresh token 을 계속 쓸 수 있으므로
|
||||
**경쟁 자체가 성립하지 않는다.** 대신 잃는 것 — 토큰이 유출되면 **만료까지
|
||||
계속 쓸 수 있다.** 회전의 목적이 그 창을 좁히는 것이었다.
|
||||
|
||||
### 구성 C — 회전 ON · 재사용 1회 허용
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
|
||||
```
|
||||
다시 반복한다.
|
||||
|
||||
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
|
||||
```
|
||||
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
|
||||
sid=72c04JCdr0NpCHGQmXWW2wM8
|
||||
1: 200
|
||||
2: 400 "error_description":"Session doesn't have required client"
|
||||
3: 200
|
||||
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
|
||||
5: 400 "error_description":"Session doesn't have required client"
|
||||
성공 2 / 5
|
||||
이긴 토큰 재사용: HTTP 400
|
||||
남은 client_session: 0
|
||||
```
|
||||
|
||||
**어디를 봐야 하는가** — 성공이 1에서 2로 늘었지만 **`남은 client_session: 0`**
|
||||
은 그대로다.
|
||||
|
||||
### 세 구성을 나란히
|
||||
|
||||
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|
||||
|---|---|---|---|
|
||||
| **A** 회전 ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
|
||||
| **B** 회전 OFF | **5 / 5** | 200 | **1 — 생존** |
|
||||
| **C** 회전 ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
|
||||
|
||||
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
|
||||
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 **회전의 보안
|
||||
> 목적이 사라진다.** 값을 올려 버티려는 시도는 "몇 개까지 동시에 올
|
||||
> 것인가"를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
|
||||
|
||||
**그래서 답은 lock 이다.** 그리고 lock 은 **저장소 쪽**에 있어야 한다 —
|
||||
프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
|
||||
|
||||
| 후보 | |
|
||||
|---|---|
|
||||
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE` — **A-0 에서 Keycloak 자신이 쓰는 방식** |
|
||||
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
|
||||
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
|
||||
|
||||
**잠금의 수명이 연결의 수명과 묶이는 것**이 DB 잠금의 이점이다. 프로세스가
|
||||
죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis lock 은 TTL 이 짧으면
|
||||
**중복 갱신**, 길면 **정지**다 — 그 약점은 [B-5](b5-redis-loss-persistence.md)
|
||||
에서 다시 만난다.
|
||||
|
||||
---
|
||||
|
||||
# 5. 복구
|
||||
|
||||
## 5-1. realm 설정을 되돌린다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||||
date '+%H:%M:%S 회전 끔'
|
||||
```
|
||||
|
||||
**확인** — 1-2 와 똑같은 명령으로 다시 읽는다
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
**형태**
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**1-2 의 실측과 세 값이 전부 같아야 한다.** `accessTokenLifespan` 이 60 이
|
||||
아니면 다른 것도 건드린 것이다.
|
||||
|
||||
## 5-2. 탐침 파드를 지운다
|
||||
|
||||
**하기**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||||
```
|
||||
|
||||
## 5-3. 실험이 만든 세션을 정리한다
|
||||
|
||||
파괴된 세션의 `user_session_id` 행은 그대로 남는다. **TTL 로 스스로 사라지지만**
|
||||
바로 치우고 싶으면 브라우저에서 아래를 연다.
|
||||
```
|
||||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||||
```
|
||||
**미검증** — 이 실험은 여기까지 재지 않았다.
|
||||
|
||||
**확인**
|
||||
```bash
|
||||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||||
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
|
||||
```
|
||||
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.** 0 이 안 되어도
|
||||
놀랄 일이 아니다.
|
||||
|
||||
## 5-4. 원상복구 확인표
|
||||
|
||||
| 항목 | 명령 | 돌아왔을 때 |
|
||||
|---|---|---|
|
||||
| realm | 위 5-1 의 `get realms/...` | `revokeRefreshToken : false` |
|
||||
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||||
| 탐침 | `sudo kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
|
||||
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
|
||||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
|
||||
|
||||
---
|
||||
|
||||
# 막히면
|
||||
|
||||
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
|
||||
|
||||
| 증상 | 원인 | 확인 |
|
||||
|---|---|---|
|
||||
| **다섯 개가 전부 `200`** | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &` 와 `wait` 이 있는지 — 4-2 |
|
||||
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 3-1 로 다시 읽는다. 3-3 이 `200/400` 이어야 한다 |
|
||||
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
|
||||
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 — 4-2 |
|
||||
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 — 1-5 |
|
||||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 — 1-3 |
|
||||
| `CS길이=0` | secret 이름/키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
|
||||
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
|
||||
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 — 1-2 |
|
||||
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 — 1-4 |
|
||||
| 4-5 에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
|
||||
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 5-1 |
|
||||
|
||||
---
|
||||
|
||||
# 왜 이 가이드에는 부하 도구가 없나
|
||||
|
||||
동시성 5는 `ab` 도 `k6` 도 필요 없다. **셸의 `&` 와 `wait` 이면 충분하고,
|
||||
그 편이 무엇이 일어났는지 더 잘 보인다** — 요청 다섯 개의 본문을 전부 파일로
|
||||
갖고 있으니 나중에 다시 읽을 수 있다.
|
||||
|
||||
부하 도구는 **개수를 늘려야 할 때** 쓴다. 이 실험이 묻는 것은 개수가 아니라
|
||||
**「겹치면 무엇이 부서지는가」**이고, 그건 둘만 겹쳐도 답이 나온다.
|
||||
|
||||
> **재현에 필요한 최소치를 찾는 것이 실험 설계다.** 다섯 개를 쓴 이유는
|
||||
> 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지, 다섯이 필요해서가
|
||||
> 아니다.
|
||||
|
||||
---
|
||||
|
||||
# 다음
|
||||
|
||||
| 실험 | B-3 이 남긴 질문 |
|
||||
|---|---|
|
||||
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다.** 그 약점을 직접 본다 |
|
||||
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **왜 두 브라우저가 같은 토큰을 다투는가** — 조회 키가 원인이다 |
|
||||
| A-6 지연 주입 | 거기서 낙관적 락 충돌이 0 이었던 이유가 여기서 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **같은 항목을 갱신할 때**다 |
|
||||
| 설계 | **재시도로 회복되지 않는다 → lock.** 그리고 lock 은 저장소 쪽, 가급적 DB 행 잠금 |
|
||||
Reference in New Issue
Block a user