diff --git a/.playwright-mcp/console-2026-09-04T05-12-36-323Z.log b/.playwright-mcp/console-2026-09-04T05-12-36-323Z.log new file mode 100644 index 0000000..9f9c1bf --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T05-12-36-323Z.log @@ -0,0 +1,2 @@ +[ 6898ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0 +[ 23950ms] [ERROR] Failed to load resource: the server responded with a status of 403 () @ https://app1.hyeonworks.com/logout:0 diff --git a/.playwright-mcp/page-2026-09-04T05-10-19-868Z.yml b/.playwright-mcp/page-2026-09-04T05-10-19-868Z.yml new file mode 100644 index 0000000..bb4eb88 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-10-19-868Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f35e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-10-47-333Z.yml b/.playwright-mcp/page-2026-09-04T05-10-47-333Z.yml new file mode 100644 index 0000000..17db24b --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-10-47-333Z.yml @@ -0,0 +1,7 @@ +- main [ref=f36e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f36e3] + - paragraph [ref=f36e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f36e5] [cursor=pointer] + - button "token 경계 확인" [ref=f36e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f36e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f36e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-11-44-142Z.yml b/.playwright-mcp/page-2026-09-04T05-11-44-142Z.yml new file mode 100644 index 0000000..1ee03e6 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-11-44-142Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f37e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-12-13-198Z.yml b/.playwright-mcp/page-2026-09-04T05-12-13-198Z.yml new file mode 100644 index 0000000..32f8fa5 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-12-13-198Z.yml @@ -0,0 +1,7 @@ +- main [ref=f38e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f38e3] + - paragraph [ref=f38e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f38e5] [cursor=pointer] + - button "token 경계 확인" [ref=f38e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f38e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f38e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-12-36-478Z.yml b/.playwright-mcp/page-2026-09-04T05-12-36-478Z.yml new file mode 100644 index 0000000..94f38c5 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-12-36-478Z.yml @@ -0,0 +1,7 @@ +- main [ref=f39e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f39e3] + - paragraph [ref=f39e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f39e5] [cursor=pointer] + - button "token 경계 확인" [ref=f39e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f39e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f39e8] [cursor=pointer] \ No newline at end of file diff --git a/bff/pom.xml b/bff/pom.xml index bdb5ec7..4f994b5 100644 --- a/bff/pom.xml +++ b/bff/pom.xml @@ -42,6 +42,25 @@ org.springframework.boot spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + test + org.springframework.boot spring-boot-starter-web diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java b/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java index 31f9879..ef56197 100644 --- a/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java +++ b/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java @@ -8,15 +8,38 @@ import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.jdbc.core.JdbcOperations; @Configuration public class SecurityConfig { + /** + * B-2 — authorized client 를 프로세스 메모리에서 PostgreSQL 로 옮긴다. + * + * B-1 에서 Application Session 만 Redis 로 옮겼더니, 사용자는 로그인 + * 상태로 보이는데 BFF 에는 access token 이 없는 상태가 만들어졌다. + * 두 상태의 저장소를 **각각** 정해야 한다는 Q3 의 지적이 그대로 나타난 것이다. + * + * 주의 — 이것이 고치는 것과 고치지 못하는 것이 다르다. + * 고친다 : 인스턴스 간 공유. 어느 replica 로 가도 같은 토큰을 본다. + * 못 고친다: 조회 키. JdbcOAuth2AuthorizedClientService 도 + * (clientRegistrationId, principalName) 으로 찾으므로 + * 같은 사용자의 두 브라우저는 여전히 한 항목을 공유한다. + */ + @Bean + OAuth2AuthorizedClientService authorizedClientService( + JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository + ) { + return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); + } + @Bean SecurityFilterChain bffSecurity( HttpSecurity http, diff --git a/bff/src/main/resources/application.yml b/bff/src/main/resources/application.yml index e830a64..947e69c 100644 --- a/bff/src/main/resources/application.yml +++ b/bff/src/main/resources/application.yml @@ -10,6 +10,23 @@ server: spring: application: name: keycloak-bff + datasource: + # B-2: authorized client 전용. Keycloak 과 같은 PostgreSQL 인스턴스지만 + # 테이블이 다르다(oauth2_authorized_client). 운영이라면 분리를 검토한다. + url: ${BFF_DB_URL:jdbc:postgresql://localhost:5432/keycloak} + username: ${BFF_DB_USER:keycloak} + password: ${BFF_DB_PASSWORD:keycloak} + sql: + init: + # Spring Security 가 제공하는 DDL 을 그대로 쓴다. + # always 로 두면 매 기동마다 실행되므로 CREATE TABLE IF NOT EXISTS 가 아닌 + # 스크립트에서는 실패한다 → continue-on-error 로 넘긴다. + mode: ${SPRING_SQL_INIT_MODE:always} + # ★ PostgreSQL 은 -postgres 판본을 써야 한다. 기본 판본은 `blob` 타입을 + # 쓰는데 PostgreSQL 에는 그 타입이 없다(`bytea` 다). continue-on-error 가 + # 그 실패를 삼켜서 "테이블이 조용히 안 생기는" 상태가 됐었다. + schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql + continue-on-error: true data: redis: host: ${REDIS_HOST:localhost} diff --git a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java index a2ff6d0..3329fc2 100644 --- a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java +++ b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java @@ -27,6 +27,12 @@ import org.springframework.test.web.servlet.MockMvc; // 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이 // 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다. "spring.session.store-type=none", + // 테스트에는 PostgreSQL 이 없다. H2 로 대신하고 Spring Security 의 + // DDL 을 그대로 태워 JdbcOAuth2AuthorizedClientService 가 뜨게 한다. + "spring.datasource.url=jdbc:h2:mem:bfftest;DB_CLOSE_DELAY=-1", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.sql.init.mode=always", "resource-api.base-url=http://127.0.0.1:9" }) @AutoConfigureMockMvc diff --git a/deploy/lab/k8s/bff-redis.yaml b/deploy/lab/k8s/bff-redis.yaml index 3adbdb5..a111e20 100644 --- a/deploy/lab/k8s/bff-redis.yaml +++ b/deploy/lab/k8s/bff-redis.yaml @@ -137,6 +137,15 @@ spec: value: redis.keycloak-lab.svc - name: REDIS_PORT value: "6379" + # B-2: authorized client 는 PostgreSQL 로. 세션(Redis)과 다른 + # 저장소를 쓰는 것이 Q3 가 말한 "각각 설계한다"의 실물이다. + - name: BFF_DB_URL + value: jdbc:postgresql://postgres.keycloak-lab.svc:5432/keycloak + - name: BFF_DB_USER + value: keycloak + - name: BFF_DB_PASSWORD + valueFrom: + secretKeyRef: { name: keycloak-lab-secrets, key: POSTGRES_PASSWORD } - name: JAVA_TOOL_OPTIONS value: "-Xms128m -Xmx320m" readinessProbe: diff --git a/docs/evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt b/docs/evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt new file mode 100644 index 0000000..b5d2f3f --- /dev/null +++ b/docs/evidence/b2-multi-instance-session/01-jdbc-store-deploy.txt @@ -0,0 +1,8 @@ +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 + +=== oauth2_authorized_client 테이블이 생겼는가 === +Did not find any relation named "oauth2_authorized_client". +command terminated with exit code 1 diff --git a/docs/evidence/b2-multi-instance-session/02-schema.txt b/docs/evidence/b2-multi-instance-session/02-schema.txt new file mode 100644 index 0000000..cc140aa --- /dev/null +++ b/docs/evidence/b2-multi-instance-session/02-schema.txt @@ -0,0 +1,33 @@ +=== PostgreSQL 전용 스키마 === +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) +); + +=== 적용 === +CREATE TABLE + 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) + diff --git a/docs/evidence/b2-multi-instance-session/03-plaintext-tokens.txt b/docs/evidence/b2-multi-instance-session/03-plaintext-tokens.txt new file mode 100644 index 0000000..db73685 --- /dev/null +++ b/docs/evidence/b2-multi-instance-session/03-plaintext-tokens.txt @@ -0,0 +1,20 @@ +=== Q3 검증 2번 — 저장소를 직접 열어 refresh token 이 평문인가 === +eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUzZDZkMy0yNzQyLTRhYWItYjk4Ni02ZDU2ZDM5MDk1ZDEifQ.eyJleHAiOjE3ODg1MDA0NDYsImlhdCI6MTc4ODQ5ODY0NiwianRpIjoiNTQwOTZmYTQtZWRjNi1iZjZkLWE4OGMtZDJhNjEzOGJjNmVlIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmh5ZW9ud29ya3MuY29tL3JlYWxtcy9rZXljbG9hay1wYXR0ZXJucyIsImF1ZCI6I + +=== access token 도 === +eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJPWS1jYVlETkdvUDRITUF6LVE5VVBUVS1ETTFpODk2TnV6VVp1NmdmQ3FNIn0.eyJleHAi + +=== 그 문자열이 실제 JWT 인지 — 헤더를 디코드 === + File "", line 3 + h=open(/tmp/hdr.txt).read().strip() + ^ +SyntaxError: invalid syntax + +=== 저장된 바이트를 그대로 디코드한 결과 === + 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 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다. diff --git a/docs/evidence/b2-multi-instance-session/04-overwrite-test.txt b/docs/evidence/b2-multi-instance-session/04-overwrite-test.txt new file mode 100644 index 0000000..dc5067a --- /dev/null +++ b/docs/evidence/b2-multi-instance-session/04-overwrite-test.txt @@ -0,0 +1,25 @@ +=== [현재] 같은 사용자의 항목 === + client_registration_id | principal_name | access_token_issued_at | at_md5 +------------------------+----------------+----------------------------+---------------------------------- + keycloak | labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7 +(1 row) + + 행 수: 1 + +=== [모의 두 번째 브라우저] 세션만 지우고 같은 사용자로 다시 로그인시킨다 === + (브라우저가 달라도 principal 은 같으므로 조회 키가 같다) + Redis 세션 삭제 완료 — 다음 요청이 새 로그인을 만든다 +=== [재로그인 후] 행이 늘었는가, 덮어써졌는가 === + 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 가 바뀌었으면 → 덮어쓰기다 + +=== Q1 검증 ④ — 로그아웃하면 두 저장소가 다 정리되는가 === + 로그아웃 전 + Redis: 1 키 + PostgreSQL: 1 행 diff --git a/docs/evidence/b2-multi-instance-session/05-logout-cleanup.txt b/docs/evidence/b2-multi-instance-session/05-logout-cleanup.txt new file mode 100644 index 0000000..2e59166 --- /dev/null +++ b/docs/evidence/b2-multi-instance-session/05-logout-cleanup.txt @@ -0,0 +1,14 @@ +=== 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 diff --git a/docs/evidence/b2-multi-instance-session/README.md b/docs/evidence/b2-multi-instance-session/README.md new file mode 100644 index 0000000..647baf7 --- /dev/null +++ b/docs/evidence/b2-multi-instance-session/README.md @@ -0,0 +1,21 @@ +# B-2 — 다중 인스턴스 운영 증거 + +2026-09-04 15:05–15:15 KST +해설: [`docs/experiment-b2-multi-instance-session.md`](../../experiment-b2-multi-instance-session.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-jdbc-store-deploy.txt` | JDBC 저장소로 배포. **테이블이 조용히 안 만들어졌다** | +| `02-schema.txt` | 원인 — 기본 DDL 은 `blob`(PostgreSQL 에 없음), `-postgres.sql` 판본이 따로 있다. **`PRIMARY KEY (client_registration_id, principal_name)`** — 조회 키 문제가 DDL 에 박혀 있다 | +| `03-plaintext-tokens.txt` | **Q3 검증 2번** — `bytea` 안이 JWT 문자열 그대로. 디코드하면 `{"alg":"HS512",...}` | +| `04-overwrite-test.txt` | **Q1 검증 3번** — 같은 사용자 재로그인 시 행 수 1 그대로, `issued_at` 과 md5 만 바뀜 = **UPDATE(덮어쓰기)** | +| `05-logout-cleanup.txt` | **Q1 검증 4번** — Redis 0키 / PostgreSQL **1행 잔존** / Keycloak SSO **2세션 잔존** | +| `b2-before-relogin.png` | JDBC 전환 직후, 옛 세션은 여전히 `false` | +| `b2-tokens-shared-across-instances.png` | 재로그인 후 **`accessTokenStoredOnServer: true`** — 두 replica 에서 동작 | + +## 핵심 네 줄 + +1. **세션 Redis + 토큰 PostgreSQL 분리 저장이 성립한다.** B-1 의 "로그인은 됐는데 토큰이 없는" 상태가 해결됐다. +2. **refresh token 은 평문이다.** DB 읽기 권한이면 작동하는 토큰을 얻는다. +3. **같은 사용자의 두 번째 로그인이 첫 번째를 덮어쓴다.** 기본키에 session id 가 없어 구조적으로 그렇다. +4. **로그아웃은 셋 중 하나만 지운다.** 평문 토큰과 Keycloak SSO 세션이 남는다. diff --git a/docs/evidence/b2-multi-instance-session/b2-before-relogin.png b/docs/evidence/b2-multi-instance-session/b2-before-relogin.png new file mode 100644 index 0000000..8cafd43 Binary files /dev/null and b/docs/evidence/b2-multi-instance-session/b2-before-relogin.png differ diff --git a/docs/evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png b/docs/evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png new file mode 100644 index 0000000..cc0a4f0 Binary files /dev/null and b/docs/evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png differ diff --git a/docs/experiment-b2-multi-instance-session.md b/docs/experiment-b2-multi-instance-session.md new file mode 100644 index 0000000..f473afe --- /dev/null +++ b/docs/experiment-b2-multi-instance-session.md @@ -0,0 +1,314 @@ +# B-2 — 인스턴스를 늘렸을 때 무엇이 깨지고 무엇이 남는가 → Q1 + +브랜치 `feature/keycloak-b2-multi-instance-session` · +증거 [`docs/evidence/b2-multi-instance-session/`](evidence/b2-multi-instance-session/) · +2026-09-04 15:05–15:15 KST + +선행: [`B-0`](experiment-b0-bff-redis-deploy.md) · [`B-1`](experiment-b1-redis-session-store.md) + +**대응 질문** — [Q1 · 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance) + +--- + +## 0. 결론부터 + +B-1 이 남긴 문제(세션만 공유되고 토큰은 안 됨)를 **JDBC 로 옮겨 해결했다.** +그러자 **다른 두 문제가 남았다.** + +| Q1 검증 | 결과 | +|---|---| +| ① 다른 인스턴스로 요청해도 되는가 | **된다** — 세션 Redis + 토큰 PostgreSQL | +| ② 재시작 후 로그인 유지 | **된다** | +| ③ 같은 사용자의 다른 브라우저가 덮어쓰는가 | **★ 덮어쓴다.** 기본키가 그렇게 되어 있다 | +| ④ 로그아웃하면 두 저장소가 다 정리되는가 | **★ 아니다. 한쪽만 정리된다** | + +``` +로그아웃 후: + Redis 세션 : 0 키 ← 정리됨 + PostgreSQL 토큰 : 1 행 ← 평문 refresh token 이 그대로 남는다 + Keycloak SSO : 2 세션 ← 남아 있다 +``` + +--- + +## 1. 설계 — 왜 JDBC 를 골랐나 + +B-1 에서 컨트롤러가 `OAuth2AuthorizedClientService` 를 직접 쓰는 것을 확인했다. + +```java +private final OAuth2AuthorizedClientService authorizedClientService; +... +OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient(...); +``` + +| 후보 | 컨트롤러 변경 | 조회 키 문제 | +|---|---|---| +| **`JdbcOAuth2AuthorizedClientService`** | **불필요** (같은 인터페이스) | 안 고쳐짐 | +| Redis 직접 구현 | 불필요 | 안 고쳐짐 | +| `HttpSessionOAuth2AuthorizedClientRepository` | **필요** (Repository 로 바꿔야) | **고쳐짐** | + +**Q3 가 "Redis 와 JDBC 중 무엇" 을 물었으므로 JDBC 를 골랐다.** +PostgreSQL 이 이미 있어 새 인프라가 필요 없고, 세션(Redis) + 토큰(JDBC) +**분리 저장**을 그대로 시험할 수 있다. + +```java +@Bean +OAuth2AuthorizedClientService authorizedClientService( + JdbcOperations jdbcOperations, + ClientRegistrationRepository clientRegistrationRepository +) { + return new JdbcOAuth2AuthorizedClientService(jdbcOperations, clientRegistrationRepository); +} +``` + +--- + +## 2. 문제 — 스키마가 조용히 안 만들어졌다 + +파드는 떴고 Hikari 도 붙었는데 테이블이 없었다. + +``` +HikariPool-1 - Start completed. +... +Did not find any relation named "oauth2_authorized_client". +``` + +**Spring Security 가 두 벌의 DDL 을 제공한다.** + +``` +org/springframework/security/oauth2/client/oauth2-client-schema.sql ← 기본 +org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql ← PostgreSQL 용 +``` + +기본 판본은 `blob` 타입을 쓴다. **PostgreSQL 에는 그 타입이 없다** (`bytea` 다). + +```sql +access_token_value blob NOT NULL, -- 기본 판본 +access_token_value bytea NOT NULL, -- postgres 판본 +``` + +그리고 내가 `continue-on-error: true` 를 켜둬서 **그 실패가 삼켜졌다.** + +```yaml +schema-locations: classpath:org/springframework/security/oauth2/client/oauth2-client-schema-postgres.sql +``` + +> **`continue-on-error` 는 "없어도 되는 초기화"에만 쓴다.** +> 여기서는 그것 때문에 "테이블이 조용히 안 생기는" 상태가 됐고, 파드는 +> **정상으로 보였다.** A층에서 반복해서 만난 "실패가 조용한" 유형이다. + +### 그리고 DDL 자체가 Q1 의 답을 담고 있었다 + +```sql +CREATE TABLE oauth2_authorized_client ( + client_registration_id varchar(100) NOT NULL, + principal_name varchar(200) NOT NULL, + ... + PRIMARY KEY (client_registration_id, principal_name) +); +``` + +**기본키에 session id 가 없다.** B-0 에서 빈 이름 +(`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`)으로 짐작한 것이 +**테이블 정의로 확정된다.** 구현을 바꿔도, 저장소를 바꿔도, **이 키를 그대로 +쓰는 한 같은 사용자의 두 브라우저는 한 행을 공유한다.** + +--- + +## 3. 결과 ① — 인스턴스 간 공유가 된다 + +``` +=== 재로그인 후 oauth2_authorized_client === + client_registration_id | principal_name | access_token_type | at_len | rt_len +------------------------+----------------+-------------------+--------+-------- + keycloak | labuser | Bearer | 1431 | 744 + +=== Redis === + bff:session:sessions:c63c39ee-... (dbsize 1) +``` + +![토큰이 인스턴스 간에 공유된다](evidence/b2-multi-instance-session/b2-tokens-shared-across-instances.png) + +```json +{"principal":"labuser", + "accessTokenStoredOnServer":true, ← B-1 에서는 false 였다 + "refreshTokenStoredOnServer":true, + "browserTokenCount":0} +``` + +**두 저장소가 각자 제 일을 한다.** + +``` + Application Session ──▶ Redis (인증 상태) + OAuth2AuthorizedClient ▶ PostgreSQL (access / refresh token) +``` + +**Q3 가 "두 상태를 반드시 같은 저장소에 보관해야 하는 것은 아니다" 라고 한 것이 +실물로 성립한다.** 다만 B-1 에서 본 대로, **한쪽만 옮기면 더 나쁘다.** + +--- + +## 4. 결과 ② — refresh token 이 평문이다 → Q3 검증 2번 + +```sql +select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client; +``` + +``` +eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz... +``` + +디코드하면 + +``` +refresh_token 헤더 : {"alg":"HS512","typ":"JWT","kid":"e2e3d6d3-..."} +refresh_token 본문 : {"exp":1788500446,"iat":1788498646,"jti":"54096fa4-...", + "iss":"https://auth.hyeonworks.com/realms/keycloak-patterns"} +access_token 헤더 : {"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-..."} +``` + +**`bytea` 안에 든 것은 암호화된 덩어리가 아니라 JWT 문자열 그대로다.** + +> **DB 읽기 권한만 있으면 그 자리에서 쓸 수 있는 토큰을 얻는다.** +> 백업 파일, 읽기 전용 복제본, 덤프, 로그 — 어디로든 새면 그대로 쓸 수 있다. +> +> Q3 의 가정 *"저장된 refresh token 을 평문으로 두면 안 된다"* 는 옳고, +> **Spring Security 기본 구현은 그 가정을 지키지 않는다.** +> 암호화하려면 `JdbcOAuth2AuthorizedClientService` 를 감싸거나 직접 구현해야 한다. + +--- + +## 5. 결과 ③ — 같은 사용자의 두 번째 로그인이 덮어쓴다 → Q1 검증 3번 + +같은 사용자로 다시 로그인시키고 행을 비교했다. + +``` +=== 재로그인 전 === + principal_name | access_token_issued_at | at_md5 + labuser | 2026-09-04 05:10:46.927192 | 675af2286bfc2fd9d2bab7bc8f391df7 + 행 수: 1 + +=== 재로그인 후 === + labuser | 2026-09-04 05:12:13.018828 | e19a63fc5aa18bd0a68b3e19dff16b3b + 행 수: 1 +``` + +**행 수는 그대로, 값만 바뀌었다. UPDATE 다.** + +``` + 브라우저 A 로그인 → (keycloak, labuser) 행 생성 + 브라우저 B 로그인 → 같은 행을 덮어쓴다 + └─ A 의 토큰은 사라진다 +``` + +**A 쪽에서 다음 요청을 하면 B 의 토큰을 쓰게 된다.** 같은 사용자이므로 +당장은 문제가 안 보이지만, + +| 언제 문제가 되는가 | | +|---|---| +| B 가 로그아웃하면 | **A 도 같이 끊긴다** (행이 지워지므로) | +| refresh 회전이 걸려 있으면 | **A 와 B 가 같은 refresh token 을 다툰다** → B-3 | +| 스코프가 다른 로그인이면 | 나중 것이 이긴다 | + +**저장소를 바꿔도 안 고쳐진다.** 고치려면 조회 키에 session 을 넣어야 하고, +그것이 `HttpSessionOAuth2AuthorizedClientRepository` 다. + +--- + +## 6. 결과 ④ — 로그아웃이 한쪽만 정리한다 → Q1 검증 4번 + +``` +=== 로그아웃 후 === + Redis 세션 : 0 키 ← 정리됨 + PostgreSQL 토큰 : 1 행 ← 남아 있다 + Keycloak SSO : 2 세션 ← 남아 있다 + + 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 +``` + +**세 저장소 중 하나만 지워졌다.** + +``` + 로그아웃 + ├─▶ HttpSession 무효화 ✔ Redis 키 삭제됨 + ├─▶ authorized client 삭제 ✗ 아무도 안 지운다 + └─▶ Keycloak SSO 종료 ✗ RP-initiated logout 을 안 보낸다 +``` + +| 남은 것 | 결과 | +|---|---| +| **PostgreSQL 의 평문 refresh token** | 로그아웃한 사용자의 **작동하는 토큰**이 DB 에 남는다 | +| **Keycloak SSO 세션** | 앱을 다시 열면 **로그인 화면 없이 다시 로그인**된다 | + +**두 번째가 사용자에게 특히 혼란스럽다** — "로그아웃했는데 다시 들어가면 +그냥 들어가진다". 실험 중에도 계속 그랬다. 세션을 지워도 Keycloak SSO 가 +살아 있어 조용히 재인증됐다. + +### 무엇을 해야 하는가 + +| 필요한 것 | 방법 | +|---|---| +| authorized client 삭제 | `LogoutSuccessHandler` 에서 `removeAuthorizedClient` 호출 | +| Keycloak 세션 종료 | **RP-initiated logout** — `OidcClientInitiatedLogoutSuccessHandler` | +| 두 곳을 원자적으로 | 한쪽이 실패하면? — **정리 순서와 실패 처리를 정해야 한다** | + +**Q3 의 미지수 5번("두 store 를 logout 에서 어떻게 한 번에 지우게 되는가")이 +바로 이 지점이며, 답은 "지금은 하나도 안 지운다" 이다.** + +--- + +## 7. Q1 검증 항목 대조 + +| # | Q1 의 검증 | 결과 | +|---|---|---| +| 1 | 다른 인스턴스로 요청 시 200 유지 | **된다** (Redis + JDBC 조합) | +| 2 | 재시작 후 session cookie 로 상태 유지 | **된다** | +| 3 | 두 브라우저에서 authorized client 덮어쓰기 | **★ 덮어쓴다.** 기본키가 원인 | +| 4 | 한쪽 logout 후 다른 쪽 | **★ 한쪽만 정리된다** | +| 5 | session 만료 ≠ token 만료 | 세션 30분 / access 60초 — **처음부터 어긋나 있다** | +| — | (B-0 에서) replica 2개에서 **로그인 자체가 실패** | Redis 세션으로 **해결됨** | + +--- + +## 8. 재현 절차 (명령어) + +```bash +# 1. JDBC authorized client service 빈 추가 (SecurityConfig) +# + spring-boot-starter-jdbc, postgresql 의존성 + +# 2. 스키마 — PostgreSQL 판본을 써야 한다 +kubectl -n keycloak-lab exec -- sh -c \ + 'unzip -p /app/app.jar BOOT-INF/lib/spring-security-oauth2-client-*.jar' > /dev/null +# 실제로는 nested jar 를 풀어서 -postgres.sql 을 꺼낸다 +kubectl -n keycloak-lab exec -i deploy/postgres -- psql -U keycloak -d keycloak < oauth2-pg.sql + +# 3. 저장소가 채워지는지 +kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "select client_registration_id, principal_name, length(refresh_token_value) from oauth2_authorized_client" + +# 4. 평문 여부 +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" + +# 5. 덮어쓰기 — 같은 사용자로 다시 로그인시키고 md5 를 비교 +kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall # 세션만 지운다 +# 브라우저로 재접속 → 행 수는 그대로, md5 는 바뀐다 + +# 6. 로그아웃 정리 +# POST /logout (CSRF 는 form 파라미터 _csrf 로) +kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize +kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -tAc "select count(*) from oauth2_authorized_client" +``` + +--- + +## 9. 다음 실험에 남기는 것 + +| 실험 | 이 실험이 준 것 | +|---|---| +| **B-3** refresh 경쟁 | **이제 토큰이 공유된다** — 경쟁이 재현될 조건이 갖춰졌다. 그리고 **덮어쓰기 때문에 두 브라우저가 같은 refresh token 을 다툰다** | +| **B-4** Edge 인가 | 리소스 서버 직접 호출 차단(Q1 제약)은 2홉 NetworkPolicy 패턴 재사용 | +| **B-5** Redis 상실 | 이제 세션(Redis)과 토큰(PostgreSQL)이 나뉘어 있어 **각각 죽여볼 수 있다** | +| 보안 | **평문 refresh token** 과 **로그아웃 후 잔존** — 둘 다 코드로 막아야 한다 |