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 0000000..8cafd43 Binary files /dev/null and b/docs/evidence/b1-redis-session-store/b1-login-works-two-replicas.png differ diff --git a/docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png b/docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png new file mode 100644 index 0000000..8cafd43 Binary files /dev/null and b/docs/evidence/b1-redis-session-store/b1-token-boundary-after-redis.png differ diff --git a/docs/experiment-b1-redis-session-store.md b/docs/experiment-b1-redis-session-store.md new file mode 100644 index 0000000..2040926 --- /dev/null +++ b/docs/experiment-b1-redis-session-store.md @@ -0,0 +1,297 @@ +# B-1 — Redis 를 붙이면 무엇이 옮겨지고 무엇이 안 옮겨지는가 → Q3 + +브랜치 `feature/keycloak-b1-redis-session-store` · +증거 [`docs/evidence/b1-redis-session-store/`](evidence/b1-redis-session-store/) · +2026-09-04 14:50–15:05 KST + +선행: [`B-0`](experiment-b0-bff-redis-deploy.md) + +**대응 질문** — [Q3 · BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store) + +--- + +## 0. 결론부터 + +| | before | after | | +|---|---|---|---| +| `sessionRepository` | (없음, Tomcat 기본) | **`RedisSessionRepository`** | **옮겨졌다** | +| `authorizedClientService` | `InMemoryOAuth2AuthorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **그대로다** | +| `authorizedClientRepository` | `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` | **동일** | **그대로다** | + +그 결과 사용자에게는 이렇게 보인다. + +```json +{"principal":"labuser", ← 로그인은 되어 있다 + "accessTokenStoredOnServer":false, ← 그런데 토큰이 없다 + "refreshTokenStoredOnServer":false, + "browserTokenCount":0} +``` + +**"로그인은 되어 있는데 아무것도 못 하는" 상태**가 만들어진다. +Q1 이 *"Session Store 를 공유 저장소로 변경하는 것만으로는 충분하지 않다"* 고 +쓴 것의 실물이다. + +--- + +## 1. 문제 ① — 쿠버네티스가 내 환경변수를 덮어썼다 + +배포하자마자 파드가 안 떴다. + +``` +Failed to bind properties under 'spring.data.redis.port' to int: + Property: spring.data.redis.port + Value: "${REDIS_PORT:6379}" + Reason: failed to convert java.lang.String to int + (caused by NumberFormatException: For input string: "tcp://10.43.57.116:6379") +``` + +**쿠버네티스가 `REDIS_PORT=tcp://10.43.57.116:6379` 를 주입했다.** + +### 개념 — Service Links + +쿠버네티스는 같은 네임스페이스의 **모든 Service 마다** Docker link 시절의 +환경변수를 파드에 자동으로 넣는다. + +``` + Service 이름이 redis 이면 + REDIS_SERVICE_HOST=10.43.57.116 + REDIS_SERVICE_PORT=6379 + REDIS_PORT=tcp://10.43.57.116:6379 ← 이게 문제 + REDIS_PORT_6379_TCP=tcp://10.43.57.116:6379 + REDIS_PORT_6379_TCP_ADDR=10.43.57.116 + ... +``` + +**`_PORT` 는 포트 번호가 아니라 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 이름과 환경변수 충돌 |