From f2595f748f9f887e3848838770f785042a6cabe2 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 14:05:02 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20B-1=20=E2=80=94=20Redis=20moves=20the?= =?UTF-8?q?=20session=20and=20leaves=20the=20tokens=20behind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding Spring Session Redis grows the context by 81 beans and swaps sessionRepository for RedisSessionRepository, while authorizedClientService stays InMemoryOAuth2AuthorizedClientService. The user then reads as logged in with principal labuser while accessTokenStoredOnServer is false, which is worse than being logged out. Redis holds only the security context, serialized with Java native serialization, and the refresh token is not there to encrypt in the first place. Three problems on the way: Kubernetes service links overwrote REDIS_PORT with a tcp:// URL, the tests tried to reach Redis, and the resource server was never deployed so a DNS failure looked like a token failure. Co-Authored-By: Claude Opus 5 --- .../console-2026-09-04T05-00-51-720Z.log | 1 + .../console-2026-09-04T05-02-32-624Z.log | 2 + .../console-2026-09-04T05-02-45-161Z.log | 2 + .../page-2026-09-04T05-00-02-742Z.yml | 7 + .../page-2026-09-04T05-00-09-772Z.yml | 1 + .../page-2026-09-04T05-00-52-003Z.yml | 5 + .../page-2026-09-04T05-02-33-239Z.yml | 1 + .../page-2026-09-04T05-02-45-383Z.yml | 7 + .../page-2026-09-04T05-03-13-061Z.yml | 1 + bff/pom.xml | 13 + bff/src/main/resources/application.yml | 11 + .../bff/BffControllerTest.java | 3 + deploy/lab/k8s/bff-redis.yaml | 22 +- .../01-servicelinks-trap.txt | 5 + .../02-autoconfig-after.txt | 64 ++++ .../03-redis-contents.txt | 21 ++ .../evidence/b1-redis-session-store/README.md | 18 ++ .../b1-login-works-two-replicas.png | Bin 0 -> 18966 bytes .../b1-token-boundary-after-redis.png | Bin 0 -> 18966 bytes docs/experiment-b1-redis-session-store.md | 297 ++++++++++++++++++ 20 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 .playwright-mcp/console-2026-09-04T05-00-51-720Z.log create mode 100644 .playwright-mcp/console-2026-09-04T05-02-32-624Z.log create mode 100644 .playwright-mcp/console-2026-09-04T05-02-45-161Z.log create mode 100644 .playwright-mcp/page-2026-09-04T05-00-02-742Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T05-00-09-772Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T05-00-52-003Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T05-02-33-239Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T05-02-45-383Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T05-03-13-061Z.yml create mode 100644 docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt create mode 100644 docs/evidence/b1-redis-session-store/02-autoconfig-after.txt create mode 100644 docs/evidence/b1-redis-session-store/03-redis-contents.txt create mode 100644 docs/evidence/b1-redis-session-store/README.md create mode 100644 docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png create mode 100644 docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png create mode 100644 docs/experiment-b1-redis-session-store.md diff --git a/.playwright-mcp/console-2026-09-04T05-00-51-720Z.log b/.playwright-mcp/console-2026-09-04T05-00-51-720Z.log new file mode 100644 index 0000000..2f36cc5 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T05-00-51-720Z.log @@ -0,0 +1 @@ +[ 236ms] [ERROR] Failed to load resource: the server responded with a status of 500 () @ https://app1.hyeonworks.com/bff/api/me:0 diff --git a/.playwright-mcp/console-2026-09-04T05-02-32-624Z.log b/.playwright-mcp/console-2026-09-04T05-02-32-624Z.log new file mode 100644 index 0000000..9b14373 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T05-02-32-624Z.log @@ -0,0 +1,2 @@ +[ 7292ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/bff/token-boundary:0 +[ 7293ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=WWc76H7TY73Fbsdc41B2nRD5exkXqHcohLh4WdJB4AA%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=A4TXweuKS4Y5HdZ63rLJUez1ZOyI2em6zs3OIfTXLFo&code_challenge=wPr8PXG0lcUvie7Wo91YrVMhOUYq0KtEU4PxVJ0_CWA&code_challenge_method=S256:0 diff --git a/.playwright-mcp/console-2026-09-04T05-02-45-161Z.log b/.playwright-mcp/console-2026-09-04T05-02-45-161Z.log new file mode 100644 index 0000000..4317e3d --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T05-02-45-161Z.log @@ -0,0 +1,2 @@ +[ 6726ms] [ERROR] Access to fetch at 'https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256' (redirected from 'https://app1.hyeonworks.com/bff/api/me') from origin 'https://app1.hyeonworks.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ https://app1.hyeonworks.com/:0 +[ 6726ms] [ERROR] Failed to load resource: net::ERR_FAILED @ https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth?response_type=code&client_id=bff-confidential&scope=openid%20profile%20email&state=GGupuPr3ZklKp8ah99r7h7mNHEq9yTsEWZr85WyXevE%3D&redirect_uri=https://app1.hyeonworks.com/login/oauth2/code/keycloak&nonce=rPVvEOvG7rzssAjR7pP67qNvoY2W6ZVpIpKYz1LGoU8&code_challenge=qoKRLRrzB7z9CU_rlaAxJ7UYcRZswyqmDi8PgxmaWM0&code_challenge_method=S256:0 diff --git a/.playwright-mcp/page-2026-09-04T05-00-02-742Z.yml b/.playwright-mcp/page-2026-09-04T05-00-02-742Z.yml new file mode 100644 index 0000000..e300da4 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-00-02-742Z.yml @@ -0,0 +1,7 @@ +- main [ref=f29e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f29e3] + - paragraph [ref=f29e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f29e5] [cursor=pointer] + - button "token 경계 확인" [ref=f29e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f29e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f29e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-00-09-772Z.yml b/.playwright-mcp/page-2026-09-04T05-00-09-772Z.yml new file mode 100644 index 0000000..5330104 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-00-09-772Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f30e1]: "{\"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-00-52-003Z.yml b/.playwright-mcp/page-2026-09-04T05-00-52-003Z.yml new file mode 100644 index 0000000..65ef96e --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-00-52-003Z.yml @@ -0,0 +1,5 @@ +- generic [active] [ref=f31e1]: + - heading "Whitelabel Error Page" [level=1] [ref=f31e2] + - paragraph [ref=f31e3]: This application has no explicit mapping for /error, so you are seeing this as a fallback. + - generic [ref=f31e4]: Fri Sep 04 05:00:51 GMT 2026 + - generic [ref=f31e5]: There was an unexpected error (type=Internal Server Error, status=500). \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-02-33-239Z.yml b/.playwright-mcp/page-2026-09-04T05-02-33-239Z.yml new file mode 100644 index 0000000..0046daf --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-02-33-239Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f32e1]: "{\"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-02-45-383Z.yml b/.playwright-mcp/page-2026-09-04T05-02-45-383Z.yml new file mode 100644 index 0000000..b1ee469 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-02-45-383Z.yml @@ -0,0 +1,7 @@ +- main [ref=f33e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f33e3] + - paragraph [ref=f33e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f33e5] [cursor=pointer] + - button "token 경계 확인" [ref=f33e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f33e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f33e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-03-13-061Z.yml b/.playwright-mcp/page-2026-09-04T05-03-13-061Z.yml new file mode 100644 index 0000000..f892328 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-03-13-061Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f34e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":false,\"refreshTokenStoredOnServer\":false,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}" \ No newline at end of file diff --git a/bff/pom.xml b/bff/pom.xml index 093b952..bdb5ec7 100644 --- a/bff/pom.xml +++ b/bff/pom.xml @@ -29,6 +29,19 @@ org.springframework.boot spring-boot-starter-oauth2-client + + + + org.springframework.session + spring-session-data-redis + + + org.springframework.boot + spring-boot-starter-data-redis + org.springframework.boot spring-boot-starter-web diff --git a/bff/src/main/resources/application.yml b/bff/src/main/resources/application.yml index b2d21e4..e830a64 100644 --- a/bff/src/main/resources/application.yml +++ b/bff/src/main/resources/application.yml @@ -10,6 +10,17 @@ server: spring: application: name: keycloak-bff + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + session: + # Application Session 만 Redis 로 간다. OAuth2AuthorizedClient 는 + # 이 설정과 무관하며 여전히 InMemory 다 — 조회 키가 다르기 때문이다(B-0). + store-type: ${SPRING_SESSION_STORE_TYPE:redis} + timeout: ${SPRING_SESSION_TIMEOUT:30m} + redis: + namespace: bff:session security: oauth2: client: 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 6cd928e..a2ff6d0 100644 --- a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java +++ b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java @@ -24,6 +24,9 @@ import org.springframework.test.web.servlet.MockMvc; @SpringBootTest(properties = { "KEYCLOAK_CLIENT_SECRET=test-only-secret", + // 테스트는 Redis 를 띄우지 않는다. store-type=none 이면 자동구성이 + // 서블릿 컨테이너 기본 세션으로 되돌아가 컨텍스트가 뜬다. + "spring.session.store-type=none", "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 6974f98..3adbdb5 100644 --- a/deploy/lab/k8s/bff-redis.yaml +++ b/deploy/lab/k8s/bff-redis.yaml @@ -92,6 +92,14 @@ spec: whenUnsatisfiable: ScheduleAnyway labelSelector: matchLabels: { app: bff } + # 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의 + # 환경변수를 자동 주입한다: REDIS_PORT=tcp://10.43.57.116:6379. + # 그것이 application.yml 의 ${REDIS_PORT:6379} 를 덮어써서 기동이 실패했다. + # Failed to bind properties under 'spring.data.redis.port' to int: + # Value: "tcp://10.43.57.116:6379" + # 이 주입 자체를 끄는 것이 근본 처방이다. 이름을 바꿔 피하면 다음 사람이 + # 같은 함정에 다시 빠진다. + enableServiceLinks: false containers: - name: bff image: keycloak-pattern-bff:lab @@ -107,8 +115,11 @@ spec: value: https://auth.hyeonworks.com/realms/keycloak-patterns - name: KC_ISSUER_INTERNAL value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns + # echo 는 header-lab 네임스페이스의 8081 이다. 다른 네임스페이스의 + # 서비스는 ..svc 로 부른다. 이름을 틀리면 500 이 나는데 + # 원인은 UnresolvedAddressException 이지 토큰 문제가 아니다. - name: RESOURCE_API_BASE_URL - value: http://echo.keycloak-lab.svc:8080 + value: http://echo.header-lab.svc:8081 - name: KEYCLOAK_CLIENT_SECRET valueFrom: secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET } @@ -117,6 +128,15 @@ spec: # it builds comes back as http:// and Keycloak rejects it. - name: SERVER_FORWARD_HEADERS_STRATEGY value: native + # B-1: Application Session 을 Redis 로 옮긴다. + # OAuth2AuthorizedClient 는 이것으로 옮겨지지 않는다 — 조회 키가 + # 다르기 때문이며, B-0 에서 확인한 사실이다. + - name: SPRING_SESSION_STORE_TYPE + value: redis + - name: REDIS_HOST + value: redis.keycloak-lab.svc + - name: REDIS_PORT + value: "6379" - name: JAVA_TOOL_OPTIONS value: "-Xms128m -Xmx320m" readinessProbe: diff --git a/docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt b/docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt new file mode 100644 index 0000000..9ab6e15 --- /dev/null +++ b/docs/evidence/b1-redis-session-store/01-servicelinks-trap.txt @@ -0,0 +1,5 @@ +deployment.apps/bff configured +deployment "bff" successfully rolled out +bff-576d869c6d-bshvl true kc-lab-2 +bff-695646ddb-kzs9k true kc-lab-1 +bff-695646ddb-vjqzf true kc-lab-2 diff --git a/docs/evidence/b1-redis-session-store/02-autoconfig-after.txt b/docs/evidence/b1-redis-session-store/02-autoconfig-after.txt new file mode 100644 index 0000000..d805fa2 --- /dev/null +++ b/docs/evidence/b1-redis-session-store/02-autoconfig-after.txt @@ -0,0 +1,64 @@ +=== B-1 — Redis 를 붙인 뒤 자동구성이 실제로 바뀌었는가 === + 빈 수: 321 → 402 (+81) + + --- 세션 저장소 관련 (새로 생긴 것) --- + ★ cookieSerializer -> DefaultCookieSerializer + ★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration + ★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration + ★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration -> SessionAutoConfiguration + ★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration + ★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration + ★ org.springframework.boot.autoconfigure.session.SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration -> SessionAutoConfiguration$ServletSessionConfiguration$ServletSessionRepositoryConfiguration + ★ org.springframework.boot.autoconfigure.session.SessionRepositoryFilterConfiguration -> SessionRepositoryFilterConfiguration + ★ org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration -> SpringHttpSessionConfiguration + ★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration + ★ rememberMeServicesCookieSerializerCustomizer -> SessionAutoConfiguration$ServletSessionConfiguration$RememberMeServicesConfiguration$$Lambda/0x00007f364e69fa60 + ★ sessionEventHttpSessionListenerAdapter -> SessionEventHttpSessionListenerAdapter + ★ sessionRepository -> RedisSessionRepository + ★ sessionRepositoryFilterRegistration -> DelegatingFilterProxyRegistrationBean + ★ spring.session-org.springframework.boot.autoconfigure.session.SessionProperties -> SessionProperties + ★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties + ★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68 + ★ springSessionRepositoryFilter -> SessionRepositoryFilter + + --- OAuth2 authorized client — 바뀌었는가? --- + authorizedClientService + before: InMemoryOAuth2AuthorizedClientService + after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다 + authorizedClientRepository + before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository + after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 — Redis 로 안 옮겨졌다 + authorizedClientManager + before: AuthorizedClientServiceOAuth2AuthorizedClientManager + after : AuthorizedClientServiceOAuth2AuthorizedClientManager 그대로 — Redis 로 안 옮겨졌다 + + --- Redis 연결 빈 (새로 생긴 것) --- + ★ keyValueMappingContext -> RedisMappingContext + ★ lettuceMetrics -> LettuceMetricsAutoConfiguration$$Lambda/0x00007f364e56f4d0 + ★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisHealthContributorAutoConfiguration -> RedisHealthContributorAutoConfiguration + ★ org.springframework.boot.actuate.autoconfigure.data.redis.RedisReactiveHealthContributorAutoConfiguration -> RedisReactiveHealthContributorAutoConfiguration + ★ org.springframework.boot.actuate.autoconfigure.metrics.redis.LettuceMetricsAutoConfiguration -> LettuceMetricsAutoConfiguration + ★ org.springframework.boot.autoconfigure.data.redis.LettuceConnectionConfiguration -> LettuceConnectionConfiguration + ★ org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration -> RedisAutoConfiguration + ★ org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration -> RedisReactiveAutoConfiguration + ★ org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration -> RedisRepositoriesAutoConfiguration + ★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration -> RedisSessionConfiguration + ★ org.springframework.boot.autoconfigure.session.RedisSessionConfiguration$DefaultRedisSessionConfiguration -> RedisSessionConfiguration$DefaultRedisSessionConfiguration + ★ org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration -> RedisHttpSessionConfiguration + ★ reactiveRedisTemplate -> ReactiveRedisTemplate + ★ reactiveStringRedisTemplate -> ReactiveStringRedisTemplate + ★ redisConnectionDetails -> PropertiesRedisConnectionDetails + ★ redisConnectionFactory -> LettuceConnectionFactory + ★ redisConverter -> MappingRedisConverter + ★ redisCustomConversions -> RedisCustomConversions + ★ redisHealthContributor -> RedisReactiveHealthIndicator + ★ redisKeyValueAdapter -> RedisKeyValueAdapter + ★ redisKeyValueTemplate -> RedisKeyValueTemplate + ★ redisMappingConfiguration#0 -> MappingConfiguration + ★ redisReferenceResolver -> ReferenceResolverImpl + ★ redisTemplate -> RedisTemplate + ★ sessionRepository -> RedisSessionRepository + ★ spring.data.redis-org.springframework.boot.autoconfigure.data.redis.RedisProperties -> RedisProperties + ★ spring.session.redis-org.springframework.boot.autoconfigure.session.RedisSessionProperties -> RedisSessionProperties + ★ springBootSessionRepositoryCustomizer -> RedisSessionConfiguration$DefaultRedisSessionConfiguration$$Lambda/0x00007f364e6a4a68 + ★ stringRedisTemplate -> StringRedisTemplate diff --git a/docs/evidence/b1-redis-session-store/03-redis-contents.txt b/docs/evidence/b1-redis-session-store/03-redis-contents.txt new file mode 100644 index 0000000..a75383e --- /dev/null +++ b/docs/evidence/b1-redis-session-store/03-redis-contents.txt @@ -0,0 +1,21 @@ +=== Redis 에 무엇이 들어 있는가 === +bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae + 총 키 수: 1 + +=== 세션 키의 내용 — refresh token 이 있는가 (Q3 검증 2번) === + 키: bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae + 타입: hash + 필드: sessionAttr:SPRING_SECURITY_CONTEXT + 필드: sessionAttr:SPRING_SECURITY_SAVED_REQUEST + 필드: sessionAttr:SPRING_SECURITY_LAST_EXCEPTION + 필드: sessionAttr:org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST + 필드: lastAccessedTime + 필드: maxInactiveInterval + 필드: creationTime + +=== 필드 값에 토큰 문자열이 보이는가 === + 1) "sessionAttr:SPRING_SECURITY_CONTEXT" + 2) "\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x01L\x00\x0eauthenticationt\x002Lorg/springframework/security/core/Authentication;xpsr\x00Sorg.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken\x00\x00\x00\x00\x00\x00\x02l\x02\x00\x02L\x00\x1eauthorizedClientRegistrationIdt\x00\x12Ljava/lang/String;L\x00\tprincipalt\x00:Lorg/springframework/security/oauth2/core/user/OAuth2User;xr\x00Gorg.springframework.security.authentication.AbstractAuthenticationToken\xd3\xaa(~nGd\x0e\x02\x00\x03Z\x00\rauthenticatedL\x00\x0bauthoritiest\x00\x16Ljava/util/Collection;L\x00\adetailst\x00\x12Ljava/lang/Object;xp\x01sr\x00&java.util.Collections$UnmodifiableList\xfc\x0f%1\xb5\xec\x8e\x10\x02\x00\x01L\x00\x04listt\x00\x10Ljava/util/List;xr\x00,java.util.Collect + +=== TTL (Q3 검증 3번 — session TTL) === + TTL: 1772 초 diff --git a/docs/evidence/b1-redis-session-store/README.md b/docs/evidence/b1-redis-session-store/README.md new file mode 100644 index 0000000..902109e --- /dev/null +++ b/docs/evidence/b1-redis-session-store/README.md @@ -0,0 +1,18 @@ +# B-1 — Redis 세션 저장소 전환 증거 + +2026-09-04 14:50–15:05 KST +해설: [`docs/experiment-b1-redis-session-store.md`](../../experiment-b1-redis-session-store.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-servicelinks-trap.txt` | `enableServiceLinks: false` 적용 후 롤아웃 성공 — 쿠버네티스가 주입한 `REDIS_PORT=tcp://...` 가 설정을 덮어쓴 문제 | +| `02-autoconfig-after.txt` | **핵심** — 빈 321→402(+81). `sessionRepository → RedisSessionRepository` 로 바뀌었지만 **`authorizedClientService` 는 `InMemory` 그대로** | +| `03-redis-contents.txt` | Redis 키 1개, 필드는 `SPRING_SECURITY_CONTEXT` 뿐. **토큰 없음.** Java 직렬화(`\xac\xed`), TTL 1772초 | +| `b1-login-works-two-replicas.png` | 전환 직후 `accessTokenStoredOnServer: false` | +| `b1-token-boundary-after-redis.png` | 파드 전면 교체 후 — `principal: labuser` 는 살아남고 토큰만 사라진 상태 | + +## 핵심 세 줄 + +1. **세션은 옮겨졌고 토큰은 안 옮겨졌다.** 빈 81개가 늘었는데 authorized client 관련은 하나도 안 바뀌었다. +2. **refresh token 은 Redis 에 평문으로 있는 게 아니라 아예 없다.** 암호화를 고민하기 전에 이걸 알아야 한다. +3. **"로그인은 되어 있는데 아무것도 못 하는" 상태가 만들어진다** — 완전 로그아웃보다 나쁘다. diff --git a/docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png b/docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png new file mode 100644 index 0000000000000000000000000000000000000000..8cafd43aa0ba52ff9b71ee3b656c1538ad23b1b4 GIT binary patch literal 18966 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 는 포트 번호가 아니라 URL 형태다.** 이름이 겹치면 +애플리케이션 설정이 조용히 오염된다. + +```yaml +spec: + enableServiceLinks: false # 근본 처방 +``` + +> **환경변수 이름을 바꿔 피할 수도 있다.** 그러면 다음 사람이 같은 함정에 +> 다시 빠진다. **주입 자체를 끄는 쪽**을 골랐다. +> +> 이 함정은 Service 이름과 환경변수 이름이 겹칠 때만 나타나므로, +> `REDIS`, `POSTGRES`, `MYSQL` 처럼 **흔한 이름일수록 위험하다.** + +## 문제 ② — 테스트가 Redis 를 찾다가 죽었다 + +`spring-session-data-redis` 를 넣으면 컨텍스트 기동 시 Redis 에 붙으려 한다. +테스트에는 Redis 가 없다. + +```java +@SpringBootTest(properties = { + "KEYCLOAK_CLIENT_SECRET=test-only-secret", + // 테스트는 Redis 를 띄우지 않는다 + "spring.session.store-type=none", +}) +``` + +## 문제 ③ — 리소스 서버가 아예 없었다 + +API 호출이 `500` 이었다. 원인은 토큰이 아니었다. + +``` +java.nio.channels.UnresolvedAddressException +``` + +`RESOURCE_API_BASE_URL=http://echo.keycloak-lab.svc:8080` 인데 `echo` 는 +**`header-lab` 네임스페이스의 8081** 이었다. 배포조차 되어 있지 않았다. + +> **500 을 보고 "토큰이 없어서"라고 읽을 뻔했다.** 로그를 보니 DNS 였다. +> A층에서 반복해서 배운 것 — **증상과 원인을 붙이기 전에 로그를 본다.** + +```yaml +# 다른 네임스페이스의 서비스는 ..svc 로 부른다 +value: http://echo.header-lab.svc:8081 +``` + +--- + +## 2. 자동구성이 실제로 바뀌었는가 — B-0 의 방법을 다시 쓴다 + +```bash +kubectl -n keycloak-lab exec -- wget -qO- http://localhost:8083/actuator/beans +``` + +``` + 빈 수: 321 → 402 (+81) + + --- 세션 저장소 (새로 생긴 것) --- + ★ sessionRepository -> RedisSessionRepository + ★ springSessionRepositoryFilter -> SessionRepositoryFilter + ★ RedisHttpSessionConfiguration + ★ cookieSerializer -> DefaultCookieSerializer + + --- OAuth2 authorized client --- + authorizedClientService + before: InMemoryOAuth2AuthorizedClientService + after : InMemoryOAuth2AuthorizedClientService 그대로 — Redis 로 안 옮겨졌다 + authorizedClientRepository + before: AuthenticatedPrincipalOAuth2AuthorizedClientRepository + after : AuthenticatedPrincipalOAuth2AuthorizedClientRepository 그대로 +``` + +**빈 81개가 늘었는데 authorized client 는 하나도 안 바뀌었다.** + +> **"Redis 를 붙였다"가 "상태가 공유된다"를 뜻하지 않는다.** +> 무엇이 옮겨졌는지 **찍어서 확인**해야 한다. B-0 을 실험으로 만든 이유다. + +--- + +## 3. Redis 안에 무엇이 들어갔는가 → Q3 검증 2번 + +``` +=== Redis 키 === +bff:session:sessions:8963b6de-3564-4775-9ccd-1ee9616b83ae + dbsize: 1 + +=== 필드 === + sessionAttr:SPRING_SECURITY_CONTEXT + sessionAttr:SPRING_SECURITY_SAVED_REQUEST + sessionAttr:SPRING_SECURITY_LAST_EXCEPTION + sessionAttr:...HttpSessionOAuth2AuthorizationRequestRepository.AUTHORIZATION_REQUEST + lastAccessedTime / maxInactiveInterval / creationTime + +=== TTL === + 1772 초 ← spring.session.timeout=30m 과 일치 +``` + +### **refresh token 은 Redis 에 없다** + +Q3 는 *"저장소를 직접 열어 refresh token 이 평문으로 남는지 확인한다"* 를 +검증 항목으로 두었다. 답은 더 앞에 있었다 — **애초에 들어가지 않는다.** + +``` + Application Session ──▶ Redis (인증 상태, principal, 인가 요청) + OAuth2AuthorizedClient ─▶ 프로세스 메모리 (access token, refresh token) +``` + +**"토큰 암호화를 어떻게 할까"를 고민하기 전에, 토큰이 그 저장소에 가지도 +않는다는 것을 먼저 알아야 한다.** + +### 직렬화는 Java 네이티브다 + +``` +\xac\xed\x00\x05sr\x00=org.springframework.security.core.context.SecurityContextImpl +``` + +`\xac\xed` 는 **Java 직렬화 매직 넘버**다. JSON 이 아니다. + +| 결과 | | +|---|---| +| 사람이 못 읽는다 | 운영 중 디버깅이 어렵다 | +| **클래스 버전에 묶인다** | 애플리케이션을 올리면 **기존 세션이 역직렬화에 실패**할 수 있다 | +| 역직렬화 취약점 | 신뢰할 수 없는 데이터가 들어오면 위험한 형식이다 | + +**D-2(버전 업그레이드)에서 이것이 다시 나온다** — Spring Security 버전이 +바뀌면 Redis 에 남은 세션이 깨질 수 있다. + +--- + +## 4. 사용자에게 보이는 결과 — 가장 중요한 부분 + +![Redis 전환 후 token-boundary](evidence/b1-redis-session-store/b1-token-boundary-after-redis.png) + +```json +{"pattern":"AP3-backend-for-frontend", + "principal":"labuser", ← 세션은 Redis 에서 복원되었다 + "accessTokenStoredOnServer":false, ← 토큰은 사라졌다 + "refreshTokenStoredOnServer":false, + "browserTokenCount":0, + "csrfProtectionEnabled":true} +``` + +**파드가 전부 교체됐는데 로그인 상태는 살아남았다.** Redis 덕분이다. +**그런데 토큰은 같이 살아남지 못했다.** 인스턴스 메모리에 있었으니까. + +``` + 사용자 관점: 로그인되어 있다고 나온다 + 실제: BFF 가 사용자를 대신해 아무것도 못 한다 +``` + +**이것이 "부분적으로만 공유했을 때"의 실패 모양이다.** +완전히 로그아웃되는 편이 차라리 낫다 — 적어도 사용자가 다시 로그인한다. + +### B-0 과 나란히 놓으면 + +| | B-0 (Redis 없음, replica 1) | **B-1 (Redis 세션, replica 2)** | +|---|---|---| +| `principal` | labuser | labuser | +| `accessTokenStoredOnServer` | **true** | **false** | +| 파드 재시작 후 | 로그아웃 | **로그인 상태만 남고 토큰은 소실** | + +--- + +## 5. Q3 검증 항목 대조 + +| # | Q3 의 검증 | 결과 | +|---|---|---| +| 1 | 인스턴스 두 대에서 로그인 유지·재시작 복구 | **세션은 유지, 토큰은 소실** | +| 2 | 저장소를 열어 refresh token 이 평문인지 | **평문 이전에 존재하지 않는다** | +| 3 | session TTL 과 token 만료 어긋남 | TTL 1772초 관측. 토큰 만료(60초)와 **처음부터 어긋나 있다** | +| 4 | logout 뒤 두 store 잔여 항목 | **B-2 에서 이어서** | +| 5 | 저장소를 끊었을 때 오류 | **B-5 에서** | +| 6 | 같은 store vs 분리 | **분리가 기본값이었다** — 고르는 것이 아니라 이미 그렇다 | +| 7 | 저장소 지연이 화면 지연으로 | **B-2 이후** | + +**6번의 답이 이 실험의 요지다.** "두 상태를 같은 저장소에 둘지 나눌지"는 +선택지가 아니라 **이미 나뉘어 있고, 나뉜 채로 두면 깨진다.** + +--- + +## 6. 그래서 무엇을 해야 하는가 + +`OAuth2AuthorizedClientService` 를 공유 저장소로 옮기는 구현이 따로 필요하다. + +| 후보 | | +|---|---| +| `JdbcOAuth2AuthorizedClientService` | Spring Security 기본 제공. **PostgreSQL 이 이미 있다** | +| 직접 구현 (Redis) | `OAuth2AuthorizedClientService` 인터페이스를 Redis 로 구현 | +| 세션 안에 넣기 | `HttpSessionOAuth2AuthorizedClientRepository` 를 쓰면 세션과 함께 Redis 로 간다 | + +**세 번째가 흥미롭다** — 조회 키 문제(principal 기준)까지 같이 해결된다. +세션 단위로 저장되므로 **같은 사용자의 다른 브라우저가 서로를 덮어쓰지 않는다.** +대신 세션이 커진다. + +**B-2 에서 이 선택지를 비교한다.** + +--- + +## 7. 재현 절차 (명령어) + +```bash +# 1. 의존성 두 개를 함께 넣는다 (하나만 넣으면 조용히 in-memory 로 남는다) +# spring-session-data-redis + spring-boot-starter-data-redis + +# 2. 테스트는 Redis 를 안 띄우므로 store-type=none 을 준다 + +# 3. 배포 — enableServiceLinks: false 를 잊지 말 것 +kubectl apply -f deploy/lab/k8s/bff-redis.yaml + +# 4. 자동구성이 실제로 바뀌었는지 확인 (B-0 의 방법) +kubectl -n keycloak-lab exec -- wget -qO- http://localhost:8083/actuator/beans > after.json +# sessionRepository 가 RedisSessionRepository 인가 +# authorizedClientService 는 여전히 InMemory 인가 ← 이쪽이 핵심 + +# 5. Redis 를 직접 연다 +kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan +kubectl -n keycloak-lab exec deploy/redis -- redis-cli hkeys "bff:session:sessions:" +kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "bff:session:sessions:" + +# 6. 사용자 관점 확인 +# 브라우저로 https://app1.hyeonworks.com/bff/token-boundary +``` + +--- + +## 8. 다음 실험에 남기는 것 + +| 실험 | 이 실험이 준 것 | +|---|---| +| **B-2** 다중 인스턴스 | **authorized client 를 어디로 옮길지**가 남았다. 세 후보를 비교한다 | +| **B-3** refresh 경쟁 | 토큰이 공유되어야 경쟁이 재현된다 — **아직 공유되지 않았다** | +| **D-2** 업그레이드 | **Java 직렬화된 세션**이 버전 변경에 견디는가 | +| 운영 | `enableServiceLinks: false` — Service 이름과 환경변수 충돌 |