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>
870 lines
40 KiB
Markdown
870 lines
40 KiB
Markdown
# B-2 재현 가이드 — 저장소를 옮겨도 안 고쳐지는 것을 직접 본다
|
||
|
||
해설 문서: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md) ·
|
||
증거 원문: [`docs/evidence/b2-multi-instance-session/`](../../evidence/b2-multi-instance-session/)
|
||
|
||
## 이 가이드가 끝나면
|
||
|
||
당신 터미널에서 이것들을 **직접 본다.**
|
||
|
||
| 보게 되는 것 | 어디서 |
|
||
|---|---|
|
||
| 파드는 `1/1 Running` 인데 테이블이 없는 상태 | `psql` 의 `Did not find any relation` |
|
||
| 조회 키에 session id 가 **없다**는 것 | `\d oauth2_authorized_client` 의 `PRIMARY KEY` |
|
||
| `bytea` 안에 든 **평문 JWT** | `convert_from(refresh_token_value,'UTF8')` |
|
||
| 두 번째 로그인이 같은 행을 덮어쓰는 것 | 행 수 1 그대로 · `md5` 만 바뀜 |
|
||
| 로그아웃이 **셋 중 하나만** 지우는 것 | Redis 0 키 · PostgreSQL 1 행 · Keycloak 2 세션 |
|
||
|
||
## 전제
|
||
|
||
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
|
||
- [`B-0`](b0-bff-redis-deploy.md) · [`B-1`](b1-redis-session-store.md)
|
||
이 끝나 **BFF 가 replica 2개**로 떠 있고 Redis 가 세션 저장소로 붙어 있다.
|
||
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl` 은 `sudo` 로 쓴다
|
||
(kubeconfig 를 사용자 홈에 복사해 뒀다면 `sudo` 는 빼도 된다).
|
||
- **브라우저가 필요하다.** BFF 는 authorization code 흐름이라 로그인을
|
||
`curl` 로 만들 수 없다. `https://app1.hyeonworks.com/` 에 붙어
|
||
`labuser` / `labpass` 로 들어간다. realm 은 `keycloak-patterns`.
|
||
- 터미널 하나와 브라우저 창 하나를 나란히 둔다. 브라우저에서 버튼을 누르고
|
||
터미널에서 저장소를 세는 왕복이 이 실험의 전부다.
|
||
|
||
## 주의 — 이건 상태를 바꾸는 실험이다
|
||
|
||
DDL 을 태우고, Redis 세션을 지우고, 로그아웃한다. **실험대에서만 한다.**
|
||
전 구간 약 25분이고, 되돌리는 방법은 매 단계에 적어 두었다.
|
||
중간에 그만두려면 브라우저에서 다시 로그인하면 원래 상태로 돌아온다.
|
||
|
||
## 표시 규약
|
||
|
||
| 표시 | 뜻 |
|
||
|---|---|
|
||
| **실측** | 2026-09-04 14:09–14:13 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 UUID·해시는 당신 것과 다르다 |
|
||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
|
||
|
||
세션 UUID·md5·타임스탬프는 **당신 환경에서 다르다.** 이 문서는 자리표시자
|
||
(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은
|
||
전부 위 실행 기록의 실제 값이다.
|
||
|
||
---
|
||
|
||
# 0. 왜 이 실험을 하는가
|
||
|
||
B-1 이 **Application Session 만** Redis 로 옮겼다. 그러자 사용자는 로그인
|
||
상태로 보이는데 **BFF 에는 access token 이 없는** 상태가 만들어졌다.
|
||
세션과 토큰이 서로 다른 것에 들어 있고, 한쪽만 옮겼기 때문이다.
|
||
|
||
토큰도 공유 저장소로 옮기면 그건 고쳐진다. 문제는 **무엇이 같이 고쳐지고
|
||
무엇이 안 고쳐지는가**다.
|
||
|
||
| | 예측 |
|
||
|---|---|
|
||
| 통념 | 공유 저장소로 옮기면 **다중 인스턴스 문제가 해결된다** |
|
||
| B-2 모델 | 인스턴스 간 공유만 해결되고 **브라우저 간 격리와 로그아웃 정리는 그대로** |
|
||
|
||
> **개념 — 「어디에 두는가」와 「어떻게 찾는가」는 독립이다.**
|
||
>
|
||
> ```
|
||
> 저장소 (where) 메모리 → PostgreSQL → Redis … ← 옮기면 인스턴스 간 공유가 된다
|
||
> 조회 키 (how) (clientRegistrationId, principalName) ← 옮겨도 그대로다
|
||
> ```
|
||
>
|
||
> 이 실험이 판정하는 것은 두 번째다. 그리고 **키는 코드가 아니라 스키마에
|
||
> 박혀 있다** — 그래서 「구현을 바꾸면 되겠지」로 넘어갈 수 없다.
|
||
> 1-3 에서 그 줄을 직접 본다.
|
||
|
||
같은 성질이 로그아웃에서도 나온다. 지워야 하는 것이 셋인데
|
||
**셋이 서로 다른 시스템에 있다.**
|
||
|
||
```
|
||
① HttpSession Redis Spring Security 가 지운다
|
||
② OAuth2AuthorizedClient PostgreSQL ★ 아무도 안 지운다
|
||
③ IdP SSO 세션 Keycloak ★ RP-initiated logout 을 보내야 한다
|
||
```
|
||
|
||
---
|
||
|
||
# 1. 기준선 — 두 번째 로그인을 만들기 전에
|
||
|
||
**시험군만 재는 측정은 측정이 아니다.** 덮어쓰기를 보려면 **덮어쓰이기 전의
|
||
행**이 있어야 하고, 로그아웃 정리를 보려면 **로그아웃 전의 세 숫자**가 있어야
|
||
한다. 넓은 것부터 좁혀 간다.
|
||
|
||
```
|
||
파드 → 테이블 존재 → 스키마(키) → 세 저장소 세기 → 브라우저 로그인 → 대조군 행
|
||
```
|
||
|
||
## 1-1. 파드와 노드
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab get pods -o wide
|
||
```
|
||
**형태** — IP 와 해시는 당신 것과 다르다
|
||
```
|
||
NAME READY STATUS RESTARTS AGE IP NODE
|
||
bff-555df79c97-6j86w 1/1 Running 0 44s 10.42.0.52 kc-lab-1
|
||
bff-555df79c97-vgg6g 1/1 Running 0 22s 10.42.1.124 kc-lab-2
|
||
postgres-... 1/1 Running 0 5d ... kc-lab-2
|
||
redis-... 1/1 Running 0 3d ... kc-lab-2
|
||
```
|
||
`10.42.0.52` 와 `10.42.1.124` 는 [B-5 의 증거](../../evidence/b5-redis-loss/03-health-groups.txt)
|
||
에 남은 실제 BFF 파드 IP 다. Redis 와 PostgreSQL 은 매니페스트가
|
||
`nodeSelector` 로 **`kc-lab-2` 에 고정**해 둔다.
|
||
|
||
**어디를 봐야 하는가**
|
||
|
||
- `bff` 가 **두 개**이고 `READY` 가 둘 다 `1/1`
|
||
- **`NODE` 가 서로 다르다** — 같은 노드에 몰려 있으면 「다른 인스턴스」가
|
||
같은 커널 위의 다른 프로세스일 뿐이다. 매니페스트의
|
||
`topologySpreadConstraints` 가 이걸 벌려 놓는다
|
||
- `RESTARTS` 가 `0` — 뒤에서 이 값이 오르면 내가 건드린 것이 엉뚱한 데 닿은 것이다
|
||
|
||
**이 결과가 의미하는 것** — 이 실험의 질문(Q1)은 **요청이 로그인을 처리하지
|
||
않은 인스턴스에 떨어질 수 있어서** 생긴다. replica 가 하나면 질문 자체가
|
||
성립하지 않는다.
|
||
|
||
파드 이름은 자주 바뀌므로 이름 대신 라벨로 부른다.
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||
```
|
||
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
|
||
```
|
||
deployment.apps/bff configured
|
||
deployment "bff" successfully rolled out
|
||
bff-555df79c97-6j86w 1/1 Running 0 44s
|
||
bff-555df79c97-vgg6g 1/1 Running 0 22s
|
||
```
|
||
위 두 줄은 배포 명령이 같이 찍은 것이다. 파드 줄만 나오면 정상이다.
|
||
|
||
## 1-2. 테이블이 실제로 있는가 — 없으면 여기서 멈춘다
|
||
|
||
**이 실험은 원래 여기서 한 번 넘어졌다.** 파드는 떴고 Hikari 도 붙었는데
|
||
테이블이 없었다. 그리고 **아무도 그것을 신고하지 않았다.**
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||
```
|
||
**실측** — [`01-jdbc-store-deploy.txt`](../../evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt)
|
||
```
|
||
=== oauth2_authorized_client 테이블이 생겼는가 ===
|
||
Did not find any relation named "oauth2_authorized_client".
|
||
command terminated with exit code 1
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 이 두 줄이 나오면 **아직 아무것도 저장되지 않는
|
||
상태**다. 테이블이 있으면 컬럼 표가 나온다(1-3).
|
||
|
||
**이 결과가 의미하는 것** — 스키마 초기화가 **조용히 실패**했다.
|
||
|
||
> **개념 — `blob` 은 PostgreSQL 에 없는 타입이다.**
|
||
>
|
||
> Spring Security 는 DDL 을 **두 벌** 번들한다.
|
||
>
|
||
> | 파일 | 토큰 컬럼 타입 |
|
||
> |---|---|
|
||
> | `oauth2-client-schema.sql` | `access_token_value blob NOT NULL` |
|
||
> | `oauth2-client-schema-postgres.sql` | `access_token_value bytea NOT NULL` |
|
||
>
|
||
> 기본 판본을 그대로 태우면 `blob` 에서 문법 오류가 난다. 그리고
|
||
> `spring.sql.init.continue-on-error: true` 가 켜져 있으면 **그 실패가
|
||
> 삼켜지고 파드는 정상으로 보인다.**
|
||
>
|
||
> `continue-on-error` 는 **「없어도 되는 초기화」에만** 쓴다. 여기서는
|
||
> 없으면 안 되는 초기화였다.
|
||
>
|
||
> **정정 노트** — 해설 문서의 이 절 제목은 처음에 "Liquibase 스키마의 방언
|
||
> 차이" 였다가 정정됐다. **Liquibase 가 아니다.** 여기서 스키마를 태우는
|
||
> 것은 Spring Boot 의 `spring.sql.init` 이고, DDL 은
|
||
> `spring-security-oauth2-client` jar 가 번들한 파일이다. Liquibase 는
|
||
> Keycloak 이 자기 스키마에 쓰며 D-2 의 주제다.
|
||
|
||
### 없으면 만든다 — 이건 명령이 아니라 파일이다
|
||
|
||
DDL 은 여러 줄이고 나중에 다시 쓸 것이므로 **파일로 만든다.** 터미널에
|
||
붙여 넣는 명령과 프로그램 원문을 섞지 않는다.
|
||
|
||
**하기**
|
||
```bash
|
||
vim /tmp/oauth2-pg.sql
|
||
```
|
||
```sql
|
||
-- file: /tmp/oauth2-pg.sql
|
||
-- spring-security-oauth2-client jar 의 oauth2-client-schema-postgres.sql 과 같다.
|
||
CREATE TABLE oauth2_authorized_client (
|
||
client_registration_id varchar(100) NOT NULL,
|
||
principal_name varchar(200) NOT NULL,
|
||
access_token_type varchar(100) NOT NULL,
|
||
access_token_value bytea NOT NULL,
|
||
access_token_issued_at timestamp NOT NULL,
|
||
access_token_expires_at timestamp NOT NULL,
|
||
access_token_scopes varchar(1000) DEFAULT NULL,
|
||
refresh_token_value bytea DEFAULT NULL,
|
||
refresh_token_issued_at timestamp DEFAULT NULL,
|
||
created_at timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||
PRIMARY KEY (client_registration_id, principal_name)
|
||
);
|
||
```
|
||
**실측** — 위 DDL 은 [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||
의 `=== PostgreSQL 전용 스키마 ===` 절 원문이다.
|
||
|
||
**하기** — 태운다
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec -i deploy/postgres -- \
|
||
psql -U keycloak -d keycloak < /tmp/oauth2-pg.sql
|
||
```
|
||
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||
```
|
||
=== 적용 ===
|
||
CREATE TABLE
|
||
```
|
||
|
||
**되돌리기** — **이 표는 B-3 이후로도 계속 쓰므로 평소에는 지우지 않는다.**
|
||
정말 처음 상태로 되돌리려면:
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||
psql -U keycloak -d keycloak -c 'drop table oauth2_authorized_client'
|
||
```
|
||
|
||
> `-i` 를 빼면 `<` 로 넘긴 파일이 파드 안으로 안 들어간다. 아무 일도 안
|
||
> 일어나고 오류도 안 난다 — `kubectl exec` 는 stdin 을 기본으로 연결하지
|
||
> 않는다.
|
||
|
||
## 1-3. 기본키를 눈으로 본다 — 이 실험의 답이 여기 박혀 있다
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||
psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'
|
||
```
|
||
**실측** — [`02-schema.txt`](../../evidence/b2-multi-instance-session/02-schema.txt)
|
||
```
|
||
Table "public.oauth2_authorized_client"
|
||
Column | Type | Collation | Nullable | Default
|
||
-------------------------+-----------------------------+-----------+----------+-------------------------
|
||
client_registration_id | character varying(100) | | not null |
|
||
principal_name | character varying(200) | | not null |
|
||
access_token_type | character varying(100) | | not null |
|
||
access_token_value | bytea | | not null |
|
||
access_token_issued_at | timestamp without time zone | | not null |
|
||
access_token_expires_at | timestamp without time zone | | not null |
|
||
access_token_scopes | character varying(1000) | | | NULL::character varying
|
||
refresh_token_value | bytea | | |
|
||
refresh_token_issued_at | timestamp without time zone | | |
|
||
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
|
||
Indexes:
|
||
"oauth2_authorized_client_pkey" PRIMARY KEY, btree (client_registration_id, principal_name)
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **맨 아래 `Indexes:` 줄** 하나다.
|
||
|
||
```
|
||
PRIMARY KEY, btree (client_registration_id, principal_name)
|
||
└── "keycloak" ──┘ └── "labuser" ──┘
|
||
세션 id 가 없다
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — 같은 사용자가 어떤 브라우저에서 로그인하든
|
||
`(keycloak, labuser)` 라는 **한 행**을 쓴다. B-0 에서 빈 이름
|
||
(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작했던 것이
|
||
**테이블 정의로 확정된다.**
|
||
|
||
**저장소를 Redis 로 바꿔도, 직접 구현해도 이 키를 그대로 쓰는 한 결과는 같다.**
|
||
4-1 에서 그것을 눈으로 확인한다.
|
||
|
||
## 1-4. 세 저장소를 세는 명령을 정해 둔다
|
||
|
||
4절에서 이 세 숫자를 **로그아웃 전후로** 비교한다. 지금 형태를 확정해 두고,
|
||
매번 같은 명령을 친다. 다른 명령으로 재면 비교가 아니다.
|
||
|
||
**확인 ①** — Redis 의 BFF 세션
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||
```
|
||
**형태** — [`03-redis-contents.txt`](../../evidence/b1-redis-session-store/03-redis-contents.txt)
|
||
의 B-1 측정과 같은 모양이다
|
||
```
|
||
bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae
|
||
```
|
||
|
||
> **`KEYS *` 대신 `--scan` 을 쓴다.** `KEYS` 는 Redis 를 잡아 두고 전 키를
|
||
> 훑는다. `--scan` 은 커서로 나눠 돌아 블로킹하지 않는다.
|
||
>
|
||
> **`dbsize` 는 이 실험에서 부정확하다.** Redis 하나를 BFF 와 oauth2-proxy
|
||
> (B-7)가 나눠 쓰므로 `dbsize` 에는 `_oauth2_proxy-…` 키도 섞인다.
|
||
> **접두어로 걸러 세는 것**이 맞다.
|
||
|
||
**확인 ②** — PostgreSQL 의 authorized client
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||
psql -U keycloak -d keycloak -c 'select count(*) from oauth2_authorized_client'
|
||
```
|
||
|
||
**확인 ③** — Keycloak 의 SSO 세션. 온라인 세션도 `offline_user_session` 에
|
||
`offline_flag = 0` 으로 들어 있다(B-3 에서 확인된 성질이다)
|
||
```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'
|
||
```
|
||
**미검증** — 원래 실행은 Keycloak 관리 API 로 셌고 증거에는 숫자만 남아 있다.
|
||
같은 숫자를 DB 쪽에서 보는 형태다. 관리 API 로 보려면:
|
||
```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)"
|
||
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get client-session-stats -r keycloak-patterns
|
||
```
|
||
|
||
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
|
||
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
|
||
> ```bash
|
||
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||
> ```
|
||
|
||
## 1-5. 브라우저로 로그인하고 대조군 행을 잡는다
|
||
|
||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 열고
|
||
**Keycloak 로그인** 을 눌러 `labuser` / `labpass` 로 들어간다. 그다음
|
||
**token 경계 확인** 을 누른다.
|
||
|
||
**실측** — 해설 문서 3절
|
||
```json
|
||
{"principal":"labuser",
|
||
"accessTokenStoredOnServer":true, ← B-1 에서는 false 였다
|
||
"refreshTokenStoredOnServer":true,
|
||
"browserTokenCount":0}
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `accessTokenStoredOnServer` 가 `true`.
|
||
`browserTokenCount` 가 `0` 인 것이 BFF 패턴의 정의다 — **토큰이 브라우저에
|
||
없다.**
|
||
|
||
**이 결과가 의미하는 것** — B-1 에서는 이 값이 `false` 로 나올 수 있었다.
|
||
authorized client 가 프로세스 메모리에 있어 **로그인을 처리하지 않은 replica**
|
||
가 답하면 아무것도 못 찾았기 때문이다. 지금은 두 replica 가 같은 PostgreSQL
|
||
행을 본다.
|
||
|
||
> 이 값이 지금도 `false` 로 나온다면 **테이블은 만들었는데 옛 세션을 쓰고
|
||
> 있는 것**이다. 증거의 [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png)
|
||
> 가 정확히 그 상태다. 로그아웃하고 다시 로그인한다.
|
||
|
||
**확인** — 지금 행을 잡아 둔다. **이 md5 와 `issued_at` 이 대조군이다**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select client_registration_id, principal_name, access_token_issued_at,
|
||
md5(access_token_value) as at_md5
|
||
from oauth2_authorized_client"
|
||
```
|
||
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||
```
|
||
=== [현재] 같은 사용자의 항목 ===
|
||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||
------------------------+----------------+----------------------------+----------------------------------
|
||
keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7
|
||
(1 row)
|
||
|
||
행 수: 1
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `(1 row)` 와 `at_md5`. **둘 다 종이에 적어 둔다.**
|
||
2절 뒤에 이 두 값을 다시 본다.
|
||
|
||
> **왜 토큰 값이 아니라 md5 인가.** 값 자체는 **지금 쓸 수 있는 자격증명**
|
||
> 이라 터미널 스크롤백에 남기면 안 된다. md5 는 「같은가 다른가」만 답하고
|
||
> 그게 이 절이 물어보는 전부다.
|
||
|
||
크기도 같이 봐 둔다.
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select client_registration_id, principal_name, access_token_type,
|
||
length(access_token_value) as at_len, length(refresh_token_value) as rt_len
|
||
from oauth2_authorized_client"
|
||
```
|
||
**실측** — 해설 문서 3절. (이 표는 `.txt` 증거에는 없고 문서에만 남아 있다)
|
||
```
|
||
client_registration_id | principal_name | access_token_type | at_len | rt_len
|
||
------------------------+----------------+-------------------+--------+--------
|
||
keycloak | labuser | Bearer | 1431 | 744
|
||
```
|
||
|
||
---
|
||
|
||
# 2. 주입 — 「두 번째 브라우저」를 만든다
|
||
|
||
여기부터 상태가 바뀐다. **되돌리는 방법을 먼저 읽어 둔다.**
|
||
|
||
**되돌리기** — 지운 세션은 되살릴 수 없다. 브라우저에서 다시 로그인하면
|
||
새 세션이 만들어지고 원래 상태로 돌아온다.
|
||
|
||
## 2-1. 실제로는 브라우저를 두 개 쓰지 않는다 — 왜 등가인가
|
||
|
||
증거 [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||
에는 이렇게 적혀 있다.
|
||
|
||
**실측**
|
||
```
|
||
=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 ===
|
||
(브라우저가 달라도 principal 은 같으므로 조회 키가 같다)
|
||
Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다
|
||
```
|
||
|
||
**「두 브라우저에서」가 아니라 「세션을 지우고 같은 사용자로 다시 로그인」
|
||
이었다.** 조회 키가 `(clientRegistrationId, principalName)` 이므로
|
||
**브라우저가 둘이든 하나든 같은 행을 쓴다는 점에서 등가**다.
|
||
|
||
> **다만 등가인 이유를 알고 쓰는 것과 모르고 쓰는 것은 다르다.**
|
||
> 해설 문서는 처음에 "두 브라우저에서" 라고 적었다가, **측정하지 않은 것을
|
||
> 측정한 것처럼 적었다**고 정정했다. 진짜로 두 브라우저를 쓰고 싶으면
|
||
> 시크릿 창을 하나 더 열어 같은 계정으로 로그인하면 된다 — 결과는 같아야
|
||
> 하고, 다르면 그게 더 중요한 발견이다.
|
||
|
||
## 2-2. Redis 의 BFF 세션만 지운다
|
||
|
||
**지우기 전에 무엇을 지울지 눈으로 본다.** 이 Redis 는 BFF 혼자 쓰는 것이
|
||
아니다.
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan
|
||
```
|
||
**형태**
|
||
```
|
||
bff:session:sessions:c63c39ee-...
|
||
bff:session:expires:c63c39ee-...
|
||
_oauth2_proxy-f6a9201fd534a047998278452001ccbf
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `_oauth2_proxy-` 로 시작하는 키가 섞여 있는지.
|
||
있으면 **`FLUSHALL` 을 치면 안 된다** — B-7 의 oauth2-proxy 세션까지 날아가
|
||
그쪽 실험이 오염된다. 접두어로 골라 지운다.
|
||
|
||
**하기**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*' \
|
||
| xargs -r sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del
|
||
date '+%H:%M:%S 세션 삭제'
|
||
```
|
||
**미검증** — 원래 실행은 스크립트였다. 이 형태는
|
||
[후속 문서 §3](../../experiment-followup-untested-items.md) 이 oauth2-proxy
|
||
세션을 지울 때 쓴 것과 같은 모양이다.
|
||
|
||
**형태**
|
||
```
|
||
(integer) 2
|
||
16:21:03 세션 삭제
|
||
```
|
||
|
||
**시각을 적어 둔다.** 뒤에서 `access_token_issued_at` 이 이 시각 뒤인지로
|
||
「새 로그인이 실제로 일어났는가」를 판정한다.
|
||
|
||
---
|
||
|
||
# 3. 주입이 실제로 걸렸는지 확인한다
|
||
|
||
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
|
||
|
||
## 3-1. Redis 에서 BFF 세션만 사라졌나
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 첫 명령은 **아무것도 안 나와야** 하고, 두 번째는
|
||
**아까와 같아야** 한다. 두 번째까지 비었으면 `FLUSHALL` 을 친 것이고,
|
||
B-7 세션을 날린 것이다.
|
||
|
||
## 3-2. 파드를 죽이지 않았나
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab get pods -l app=bff
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`RESTARTS` 가 여전히 0.** 세션을 지우는 것은
|
||
BFF 를 건드리지 않는다. 여기서 재시작이 올랐다면 Redis 쪽을 잘못 만진 것이고,
|
||
그 상태로 재면 「덮어쓰기」가 아니라 「파드 재시작」을 재게 된다.
|
||
|
||
## 3-3. 다음 요청이 정말 새 로그인을 만드는가
|
||
|
||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 새로고침하고
|
||
**token 경계 확인** 을 누른다.
|
||
|
||
**어디를 봐야 하는가** — **로그인 화면이 뜨지 않고 그냥 들어가진다.**
|
||
|
||
**이 결과가 의미하는 것** — Redis 세션은 지워졌지만 **Keycloak SSO 세션은
|
||
살아 있다.** 그래서 BFF 가 `/oauth2/authorization/keycloak` 으로 보내면
|
||
Keycloak 이 화면 없이 즉시 코드를 돌려주고, **새 로그인 한 벌이 조용히
|
||
만들어진다.** 이것이 「모의 두 번째 브라우저」다.
|
||
|
||
> **이 조용한 재인증이 6절에서 다시 나온다.** 여기서는 편리하지만
|
||
> 로그아웃 뒤에는 「로그아웃했는데 다시 들어가진다」로 보인다.
|
||
> **같은 성질의 양면**이다.
|
||
|
||
---
|
||
|
||
# 4. 효과를 관찰한다
|
||
|
||
## 4-1. 행이 늘었는가, 덮어써졌는가
|
||
|
||
**확인** — 1-5 와 **똑같은 명령**을 친다
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select client_registration_id, principal_name, access_token_issued_at,
|
||
md5(access_token_value) as at_md5
|
||
from oauth2_authorized_client"
|
||
```
|
||
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||
```
|
||
=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 ===
|
||
client_registration_id | principal_name | access_token_issued_at | at_md5
|
||
------------------------+----------------+----------------------------+----------------------------------
|
||
keycloak | labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b
|
||
(1 row)
|
||
|
||
행 수: 1
|
||
|
||
★ 행 수가 1 그대로이고 md5 가 바뀌었으면 → 덮어쓰기다
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 가지를 **한꺼번에** 본다.
|
||
|
||
| 값 | 대조군(1-5) | 지금 | 읽는 법 |
|
||
|---|---|---|---|
|
||
| 행 수 | `(1 row)` | `(1 row)` | **INSERT 가 아니다** |
|
||
| `at_md5` | `675af228…` | `e19a63fc…` | **내용은 바뀌었다** |
|
||
| `issued_at` | `05:10:46` | `05:12:13` | 2-2 의 삭제 시각 뒤 = 새 로그인 맞다 |
|
||
|
||
셋 중 하나만 보면 틀린다. 행 수만 보면 「아무 일도 없었다」로, md5 만 보면
|
||
「새 행이 생겼나?」로 읽힌다.
|
||
|
||
**이 결과가 의미하는 것** — **UPDATE 다.**
|
||
|
||
```
|
||
브라우저 A 로그인 → (keycloak, labuser) 행 생성
|
||
브라우저 B 로그인 → 같은 행을 덮어쓴다
|
||
└─ A 의 토큰은 사라진다
|
||
```
|
||
|
||
**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로
|
||
당장은 아무 증상이 없다. 증상은 나중에 나온다.
|
||
|
||
| 언제 문제가 되는가 | |
|
||
|---|---|
|
||
| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) |
|
||
| refresh 회전이 켜져 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → [B-3](b3-refresh-token-contention.md) |
|
||
| 스코프가 다른 로그인이면 | 나중 것이 이긴다 |
|
||
|
||
## 4-2. 저장소를 바꾸면 고쳐지나 — 안 고쳐진다
|
||
|
||
**1-3 의 `PRIMARY KEY` 줄을 다시 본다.** 그 줄이 답이다.
|
||
|
||
```
|
||
InMemory → PostgreSQL → Redis → 직접 구현
|
||
└────────── 전부 (clientRegistrationId, principalName) 로 찾는다 ──────────┘
|
||
```
|
||
|
||
**고치려면 조회 키에 session 을 넣어야 하고, 그것은 저장소가 아니라
|
||
`OAuth2AuthorizedClientRepository` 쪽 이야기다.**
|
||
|
||
| 후보 | 컨트롤러 변경 | 조회 키 문제 |
|
||
|---|---|---|
|
||
| `JdbcOAuth2AuthorizedClientService` | **불필요** (같은 인터페이스) | 안 고쳐짐 |
|
||
| Redis 직접 구현 | 불필요 | 안 고쳐짐 |
|
||
| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** |
|
||
|
||
**이 실험이 두 번째를 고르지 않은 이유**는 Q3 가 "Redis 와 JDBC 중 무엇"을
|
||
물었기 때문이고, 그 대가로 조회 키 문제가 남았다. **선택이 남긴 자국을
|
||
측정한 것**이지 실수가 아니다.
|
||
|
||
## 4-3. 저장된 것이 평문인가
|
||
|
||
**먼저 길이만 본다.** 값을 찍기 전에 「무엇을 찍게 될지」를 알아야 한다.
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select length(refresh_token_value) from oauth2_authorized_client"
|
||
```
|
||
**실측** — 해설 문서 3절의 `rt_len`
|
||
```
|
||
744
|
||
```
|
||
|
||
**744 바이트다.** 암호화된 덩어리라면 여기서 알 수 없다. 앞 몇 글자만 본다.
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select left(convert_from(refresh_token_value,'UTF8'), 40) from oauth2_authorized_client"
|
||
```
|
||
**미검증** — 원래 실행은 앞 200자 남짓을 통째로 찍었다. 이 형태는 **화면에
|
||
남는 양을 줄인** 것이다.
|
||
|
||
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
|
||
의 앞부분(원래 실행이 찍은 길이 그대로)
|
||
```
|
||
=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 ===
|
||
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **`eyJ` 로 시작한다.** 그것이 `{"` 의 base64 다.
|
||
JWT 는 예외 없이 이렇게 시작한다.
|
||
|
||
> **`convert_from` 이 성공한다는 것 자체가 답이다.** 암호화된 바이트라면
|
||
> UTF-8 로 디코드되지 않고 오류가 난다. **읽힌다 = 텍스트다.**
|
||
|
||
정말 JWT 인지 헤더를 풀어 본다.
|
||
|
||
**확인** — **미검증**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
|
||
"select convert_from(refresh_token_value,'UTF8') from oauth2_authorized_client limit 1" \
|
||
| cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||
```
|
||
**실측** — [`03-plaintext-tokens.txt`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)
|
||
```
|
||
=== 저장된 바이트를 그대로 디코드한 결과 ===
|
||
refresh_token 헤더 : {"alg":"HS512","typ" : "JWT","kid" : "e2e3d6d3-2742-4aab-b986-6d56d39095d1"}
|
||
refresh_token 페이로드(앞부분):
|
||
{"exp":1788500446,"iat":1788498646,"jti":"54096fa4-edc6-bf6d-a88c-d2a6138bc6ee","iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"
|
||
access_token 헤더 : {"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
|
||
|
||
→ bytea 에 들어 있는 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.
|
||
DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는
|
||
토큰을 얻는다.** 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로 새든
|
||
그대로 쓸 수 있다. `Spring Security 기본 구현은 저장 시 암호화하지 않는다.`
|
||
암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다.
|
||
|
||
> **원래 실행은 여기서 한 번 넘어졌다.** 증거 파일에 그 실패가 그대로 있다.
|
||
> ```
|
||
> === 그 문자열이 실제 JWT 인지 — 헤더를 디코드 ===
|
||
> File "<string>", line 3
|
||
> h=open(/tmp/hdr.txt).read().strip()
|
||
> ^
|
||
> SyntaxError: invalid syntax
|
||
> ```
|
||
> **파이썬 한 줄짜리로 디코드하려다 따옴표를 빠뜨린 것**이다. 셸 안에
|
||
> 프로그램을 밀어 넣으면 이렇게 된다 — 문법 오류가 측정 결과 자리에
|
||
> 남는다. `cut` 과 `base64 -d` 로 충분하고, 그건 문법이 틀릴 자리가 없다.
|
||
|
||
## 4-4. 로그아웃 — 세 저장소를 한 번에 센다
|
||
|
||
**로그아웃 전에 세 숫자를 먼저 잡는다.** 1-4 에서 정한 명령 그대로다.
|
||
|
||
**확인**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
|
||
psql -U keycloak -d keycloak -tAc 'select count(*) from oauth2_authorized_client'
|
||
```
|
||
**실측** — [`04-overwrite-test.txt`](../../evidence/b2-multi-instance-session/04-overwrite-test.txt)
|
||
```
|
||
=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 ===
|
||
로그아웃 전
|
||
Redis: 1 키
|
||
PostgreSQL: 1 행
|
||
```
|
||
|
||
**하기** — 로그아웃한다. **화면에 로그아웃 버튼이 없다** —
|
||
`index.html` 에는 로그인·조회 버튼만 있다. Spring Security 의 로그아웃은
|
||
CSRF 토큰이 붙은 `POST /logout` 이므로 브라우저 콘솔에서 친다
|
||
(`F12` → Console, 로그인된 app1 탭에서).
|
||
|
||
**미검증**
|
||
```js
|
||
const csrf = await (await fetch('/bff/csrf')).json();
|
||
const token = decodeURIComponent(
|
||
document.cookie.split('; ').find(c => c.startsWith('XSRF-TOKEN=')).split('=')[1]);
|
||
const r = await fetch('/logout', { method: 'POST', headers: { [csrf.headerName]: token } });
|
||
console.log(r.status, r.url);
|
||
```
|
||
|
||
> **왜 셸이 아니라 브라우저인가** — 세션 쿠키가 `HttpOnly` 라 `curl` 로
|
||
> 로그인 상태를 재현할 수 없다. `XSRF-TOKEN` 쿠키만 JS 가 읽을 수 있게
|
||
> 되어 있고(`CookieCsrfTokenRepository.withHttpOnlyFalse()`), 그래서 이
|
||
> 조각이 성립한다. 해설 문서 8절은 같은 일을 **form 파라미터 `_csrf`** 로
|
||
> 적었다 — 어느 쪽이든 `SpaCsrfTokenRequestHandler` 가 받아 준다.
|
||
|
||
**되돌리기** — 브라우저에서 다시 로그인한다.
|
||
|
||
**확인** — 로그아웃 후, **같은 세 명령**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern 'bff:session:*'
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select principal_name, access_token_issued_at, access_token_expires_at
|
||
from oauth2_authorized_client"
|
||
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'
|
||
```
|
||
**실측** — [`05-logout-cleanup.txt`](../../evidence/b2-multi-instance-session/05-logout-cleanup.txt)
|
||
```
|
||
=== Q1 검증 ④ — 로그아웃 후 두 저장소 상태 ===
|
||
Redis 세션 : 0 키
|
||
PostgreSQL 토큰 : 1 행
|
||
|
||
principal_name | access_token_issued_at | access_token_expires_at
|
||
----------------+----------------------------+----------------------------
|
||
labuser | 2026-09-04 05:12:13.018828 | 2026-09-04 05:13:13.018828
|
||
(1 row)
|
||
|
||
|
||
★ Redis 는 비었는데 PostgreSQL 에 행이 남아 있으면 → 한쪽만 정리된 것
|
||
|
||
=== Keycloak 쪽 SSO 세션은? ===
|
||
Keycloak 온라인 세션: 2
|
||
```
|
||
|
||
**어디를 봐야 하는가** — 세 숫자를 나란히 놓는다.
|
||
|
||
```
|
||
로그아웃 후:
|
||
Redis 세션 : 0 키 ← 정리됨
|
||
PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다
|
||
Keycloak SSO : 2 세션 ← 남아 있다
|
||
```
|
||
|
||
**이 결과가 의미하는 것** — **셋 중 하나만 지워졌다.**
|
||
|
||
```
|
||
로그아웃
|
||
├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨
|
||
├─▶ authorized client 삭제 ✗ 아무도 안 지운다
|
||
└─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다
|
||
```
|
||
|
||
**남은 행의 `access_token_expires_at` 이 `issued_at` 의 60초 뒤**인 것도 같이
|
||
본다(`accessTokenLifespan=60`). **access token 은 이미 만료됐지만 같은 행의
|
||
refresh token 은 아직 살아 있다** — 그리고 그건 4-3 에서 본 대로 평문이다.
|
||
|
||
## 4-5. 「로그아웃했는데 다시 들어가진다」
|
||
|
||
**하기** — 브라우저에서 `https://app1.hyeonworks.com/` 를 다시 연다.
|
||
|
||
**어디를 봐야 하는가** — **로그인 화면이 안 뜨고 그냥 들어가진다.**
|
||
3-3 에서 본 것과 같은 조용한 재인증이다.
|
||
|
||
**이 결과가 의미하는 것** — 애플리케이션 세션은 지웠는데 **IdP 세션은 그대로**
|
||
이므로 IdP 가 화면 없이 새 세션을 만들어 준다. 사용자 입장에서는
|
||
**로그아웃이 안 된 것**이다.
|
||
|
||
| 필요한 것 | 방법 |
|
||
|---|---|
|
||
| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 |
|
||
| Keycloak 세션 종료 | **RP-initiated logout** — `OidcClientInitiatedLogoutSuccessHandler` |
|
||
| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** |
|
||
|
||
**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이
|
||
바로 이 지점이며, 답은 「지금은 하나도 안 지운다」이다.**
|
||
|
||
---
|
||
|
||
# 5. 복구
|
||
|
||
## 5-1. 남은 행을 지운다
|
||
|
||
**하기**
|
||
```bash
|
||
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"delete from oauth2_authorized_client where principal_name = 'labuser'"
|
||
```
|
||
**형태**
|
||
```
|
||
DELETE 1
|
||
```
|
||
|
||
**되돌리기** — 브라우저에서 다시 로그인하면 행이 다시 만들어진다.
|
||
**표 자체는 지우지 않는다** — B-3 이 이 표를 쓴다.
|
||
|
||
## 5-2. Keycloak SSO 세션을 끊는다
|
||
|
||
**하기** — 브라우저에서 아래 주소를 연다. RP 가 안 보내 주니 사람이 직접 간다.
|
||
```
|
||
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'
|
||
```
|
||
|
||
**어디를 봐야 하는가** — `offline_flag = 0` 의 개수가 줄어드는지.
|
||
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.**
|
||
0 이 안 되어도 놀랄 일이 아니다.
|
||
|
||
## 5-3. Redis 세션을 되돌린다
|
||
|
||
지운 세션은 되돌아오지 않는다. **브라우저에서 다시 로그인하는 것이 복구다.**
|
||
|
||
## 5-4. 원상복구 확인표
|
||
|
||
| 항목 | 명령 | 돌아왔을 때 |
|
||
|---|---|---|
|
||
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=bff` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||
| 표 | `sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c '\d oauth2_authorized_client'` | 컬럼 표가 나온다 (지우면 안 된다) |
|
||
| BFF 세션 | `… redis-cli --scan --pattern 'bff:session:*'` | 다시 로그인했으면 키가 있다 |
|
||
| **B-7 세션** | `… redis-cli --scan --pattern '_oauth2_proxy-*'` | **2절 전과 같아야 한다** |
|
||
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
|
||
|
||
---
|
||
|
||
# 막히면
|
||
|
||
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
|
||
|
||
| 증상 | 원인 | 확인 |
|
||
|---|---|---|
|
||
| `Did not find any relation named "oauth2_authorized_client"` | 스키마 초기화가 **조용히 실패**했다. 기본 DDL 의 `blob` 은 PostgreSQL 에 없는 타입 | `-postgres.sql` 판본을 태운다 — 1-2 |
|
||
| 파드는 정상인데 토큰이 저장되지 않는다 | 같은 원인. `continue-on-error: true` 가 실패를 삼켰다 | 파드 로그에서 `Did not find any relation` 을 찾는다 |
|
||
| `token-boundary` 가 계속 `false` | 테이블은 만들었는데 **옛 세션**을 쓰고 있다 | 로그아웃 후 재로그인 — [`b2-before-relogin.png`](../../evidence/b2-multi-instance-session/b2-before-relogin.png) 가 그 상태다 |
|
||
| `psql ... < file` 이 아무 일도 안 한다 | `kubectl exec` 에 **`-i` 가 없다** | `exec -i deploy/postgres` |
|
||
| 행 수가 2 로 늘었다 | principal 이 다르다(다른 사용자로 로그인) | `select principal_name from oauth2_authorized_client` |
|
||
| md5 가 안 바뀌었다 | 재로그인이 안 일어났다. 세션이 안 지워졌거나 요청을 안 보냈다 | `access_token_issued_at` 이 삭제 시각 뒤인지 |
|
||
| B-7 실험이 갑자기 깨진다 | **`FLUSHALL` 을 쳤다.** 같은 Redis 를 나눠 쓴다 | 접두어로만 지운다 — 2-2 |
|
||
| 파이썬 한 줄로 디코드하다 `SyntaxError` | **원래 실행이 이 실수를 했다** ([`03`](../../evidence/b2-multi-instance-session/03-plaintext-tokens.txt)) | `cut -d. -f1 \| base64 -d` 로 충분하다 |
|
||
| 로그아웃 POST 가 `403` | CSRF 토큰이 없거나 이름이 틀렸다 | `/bff/csrf` 의 `headerName` 을 그대로 쓴다 |
|
||
| 로그아웃했는데 다시 들어가진다 | **버그가 아니다.** Keycloak SSO 세션이 살아 있다 | 4-5 · 5-2 |
|
||
| `dbsize` 와 세어 본 키 수가 다르다 | oauth2-proxy 키가 섞여 있다 | `--scan --pattern` 으로 나눠 센다 |
|
||
|
||
---
|
||
|
||
# 왜 이 가이드에는 스크린샷 대신 숫자가 있나
|
||
|
||
증거의 [`b2-tokens-shared-across-instances.png`](../../evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png)
|
||
는 **B-0 의 `b0-bff-token-boundary.png` 와 동일 파일**이다(md5 `9ed00537…`).
|
||
두 시점 모두 `accessTokenStoredOnServer: true` 인 같은 화면이라 바이트가 같다.
|
||
|
||
**그래서 그 png 는 「JDBC 전환으로 토큰이 공유된다」를 단독으로 증명하지
|
||
못한다.** 증명은 테이블이 생겼다는 것과 행에 토큰이 들어 있다는 것이 한다 —
|
||
이 가이드가 1-2·1-5 에서 재는 것이 그것이다.
|
||
|
||
> **같은 화면은 같은 증거가 아니다.** 화면이 같아도 그 아래 상태는 다를 수
|
||
> 있고, 다를 수 있다는 것이 곧 「그 화면으로는 판정 못 한다」는 뜻이다.
|
||
|
||
---
|
||
|
||
# 다음
|
||
|
||
| 실험 | B-2 가 남긴 질문 |
|
||
|---|---|
|
||
| [B-3](b3-refresh-token-contention.md) refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 덮어쓰기 때문에 **두 브라우저가 같은 refresh token 을 다툰다** |
|
||
| [B-4](b4-edge-authorization-scope.md) Edge 인가 | 헤더로 신원을 넘기는 구조에서는 이 문제가 **다른 얼굴**로 나온다 |
|
||
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있으므로 **각각 죽여볼 수 있다** |
|
||
| 코드 | **평문 refresh token** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |
|