172 lines
20 KiB
Markdown
172 lines
20 KiB
Markdown
# Redis Session 요청은 어디에서 멈추는가: Web 설정과 미완성 Repository
|
|
|
|
> **Redis 코드 상세 시리즈 17/20** · [전체 지도](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md) · 이전: [Redis Idempotency V2 상태 머신: Claim에서 Replay까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-idempotency-v2-code-walkthrough.md) · 다음: [같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-health-readiness-observability.md)
|
|
|
|
## 이 글이 답하는 코드 질문
|
|
|
|
`ca-skeleton.security.auth-mode=redis-session`으로 설정하면 어떤 web/security 객체가 생기며, HTTP session은 실제로 Redis에 저장됩니까? startup validator가 요구하는 `redisVersionedSessionRepository`는 어디에 구현되어 있습니까?
|
|
|
|
현행 답은 두 경계에서 멈춥니다. cookie, Spring Session filter activation annotation, primitive security-context repository, stateful session policy branch는 구현되어 있습니다. 그러나 production `SessionRepository` bean, 이름이 `redisVersionedSessionRepository`인 bean, Redis session adapter는 source에서 확인되지 않습니다. 별도로, 인증 snapshot이 없는 요청에서 최초 `Authentication`을 만드는 form login, HTTP Basic, custom authentication filter나 production login endpoint도 확인되지 않습니다. 따라서 Redis Session capability는 미완성이고 semantic capability composition은 cache/rate-limit/lease/idempotency V2의 4/5입니다.
|
|
|
|
## 먼저 보는 클래스 지도
|
|
|
|
| 코드 | 입력 | 출력 | 다음 호출 |
|
|
| --- | --- | --- | --- |
|
|
| [`RedisSessionWebConfig`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java:11) | auth-mode와 cookie settings | `CookieSerializer`, Spring Session filter configuration | 필요한 `SessionRepository` bean |
|
|
| [`SecurityConfig.filterChain`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java:66) | auth mode, error handlers, context repository | JWT stateless 또는 session stateful chain | Spring Security filters |
|
|
| [`PrimitiveSessionSecurityContextRepository`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:41) | `SecurityContext`, `HttpSession` | bounded byte snapshot 또는 empty context | session attribute |
|
|
| [`AuthenticationModeCompositionConfig`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java:14) | auth-mode, bean registry | startup pass/fail | 없음 |
|
|
| [`RedisActivationValidator`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RedisActivationValidator.java:24) | global switch와 role selectors | startup pass/fail | 없음 |
|
|
|
|
## 객체 조립에서 먼저 걸리는 두 validator
|
|
|
|
`RedisActivationValidator`는 auth mode `redis-session`을 Redis-selecting role로 등록합니다. `app.redis.enabled=false`인데 이 mode를 선택하면 startup에 모순으로 거절합니다. role selector가 Redis를 자동 활성화하지는 않습니다. [`REDIS_SELECTING_VALUES`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/runtime/RedisActivationValidator.java:27)
|
|
|
|
그 다음 `AuthenticationModeCompositionConfig`는 bean 이름으로 완성도를 검사합니다.
|
|
|
|
- JWT mode: `jwtDecoder`는 있어야 하고 session repository/filter는 없어야 합니다.
|
|
- REDIS_SESSION mode: `jwtDecoder`는 없어야 하고 `redisVersionedSessionRepository`, `springSessionRepositoryFilter`가 둘 다 있어야 합니다.
|
|
|
|
검사는 type이 아니라 `containsBean` 이름입니다. [`validate`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfig.java:22)
|
|
|
|
문제는 production source 전체에서 `redisVersionedSessionRepository`를 만드는 `@Bean`이나 `SessionRepository` 구현이 확인되지 않는다는 점입니다. 검색 결과는 validator와 그 unit test의 fake bean뿐입니다. 따라서 mode를 실제로 선택하면 web 설정이 활성화되더라도 composition validator가 repository와 filter가 갖춰지지 않았다고 판단해 startup을 거절하는 것이 현행 의도에 가까운 결과입니다.
|
|
|
|
## web 설정이 제공하는 것
|
|
|
|
`RedisSessionWebConfig`는 auth mode가 `redis-session`일 때만 활성화됩니다. `@EnableSpringHttpSession`은 Spring Session filter infrastructure를 import하지만, filter를 만들려면 `SessionRepository` bean이 필요합니다. 이 configuration 자체는 repository를 만들지 않습니다. [`RedisSessionWebConfig`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfig.java:12)
|
|
|
|
이 config의 유일한 explicit bean은 `CookieSerializer`입니다. 설정에서 cookie name, Secure, HttpOnly, SameSite, path를 읽고 max age -1, Base64 encoding을 적용합니다. domain/domain pattern을 지정하지 않으므로 host-only cookie입니다. cookie가 안전하게 구성됐다는 사실은 session data가 Redis에 저장된다는 증거가 아닙니다.
|
|
|
|
`adapter:inbound:web`은 `spring-session-core`만 의존합니다. Redis store 구현을 제공하는 Spring Data Redis dependency는 이 module에 없습니다. [`adapter/inbound/web/build.gradle`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/build.gradle:1)
|
|
|
|
## SecurityFilterChain의 mode 분기
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A[SecurityConfig.filterChain] --> B{authMode}
|
|
B -->|JWT| C[CSRF disabled]
|
|
C --> D[STATELESS]
|
|
D --> E[Bearer filter + JWT converter가 Authentication 생성]
|
|
B -->|REDIS_SESSION| F[Cookie CSRF repository]
|
|
F --> G[IF_REQUIRED + migrateSession]
|
|
G --> H[PrimitiveSecurityContext load/save]
|
|
G --> M{최초 Authentication mechanism?}
|
|
M -->|production source| N[form/basic/custom filter·login endpoint 미확인]
|
|
M -->|test 전용 controller| O[SecurityContext에 직접 설정]
|
|
O -.->|저장 대상 제공| H
|
|
H --> I[HttpSession primitive byte attribute]
|
|
I --> J[springSessionRepositoryFilter]
|
|
J --> K{SessionRepository bean?}
|
|
K -->|production source에서 없음| L[startup composition incomplete]
|
|
K -->|test MapSessionRepository| P[in-memory persistence]
|
|
```
|
|
|
|
JWT branch는 CSRF를 끄고 `SessionCreationPolicy.STATELESS`와 resource-server JWT converter를 설정합니다. session branch는 CSRF cookie/header, `IF_REQUIRED`, session fixation migration을 설정하고 `PrimitiveSessionSecurityContextRepository`를 Spring Security의 context repository로 지정합니다. [`SecurityConfig.filterChain`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java:102)
|
|
|
|
session CSRF cookie는 secure true, httpOnly false, configured SameSite/path입니다. JavaScript가 token을 읽어 header로 돌려보내는 double-submit 형태이므로 session ID cookie의 HttpOnly와 목적이 다릅니다.
|
|
|
|
`PrimitiveSessionSecurityContextRepository` bean도 auth mode 조건부입니다. session branch에서 `ObjectProvider.getObject()`를 호출하므로 mode는 session인데 bean이 없다면 filter chain 생성 자체가 실패합니다. 현행 조건은 같은 property를 쓰므로 정상적으로 함께 활성화됩니다.
|
|
|
|
### session mode의 세 층은 서로 다른 책임입니다
|
|
|
|
첫째, `springSessionRepositoryFilter`와 `SessionRepository`는 `HttpSession`을 provider storage에 저장하고 다시 읽습니다. 이 filter는 session persistence filter이지 사용자를 인증하는 filter가 아닙니다.
|
|
|
|
둘째, `PrimitiveSessionSecurityContextRepository`는 이미 존재하는 authenticated context를 bounded bytes로 저장하고, 다음 요청에서 그 snapshot을 `Authentication`으로 복원합니다. 기존 snapshot을 복원할 수 있다는 사실은 최초 snapshot을 만들 수 있다는 뜻이 아닙니다. [`load`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:103)
|
|
|
|
셋째, 인증 snapshot이 없는 요청에서는 credential이나 외부 identity를 검증해 최초 `Authentication`을 만드는 mechanism이 필요합니다. JWT branch는 `oauth2ResourceServer`와 JWT converter를 설정하지만 Redis-session branch는 CSRF, `IF_REQUIRED`, fixation migration, context repository만 설정합니다. `formLogin`, `httpBasic`, custom authentication filter, production login endpoint는 production source에서 확인되지 않습니다. [`SecurityConfig`의 두 mode 분기](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/SecurityConfig.java:102)
|
|
|
|
따라서 `redisVersionedSessionRepository`만 추가해 validator를 통과하더라도 persistence 조립만 채워집니다. 최초 인증 조립은 별도 공백으로 남습니다.
|
|
|
|
## primitive snapshot의 저장 형식
|
|
|
|
이 repository는 Spring Security의 `SecurityContext` object graph를 session에 그대로 넣지 않습니다. attribute 이름은 `dev.caskeleton.security.PRIMITIVE_SECURITY_CONTEXT_V1`이고 값은 `byte[]`입니다. [`SNAPSHOT_ATTRIBUTE`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:43)
|
|
|
|
binary layout은 다음 순서입니다.
|
|
|
|
1. magic `0x43534543`
|
|
2. version 1
|
|
3. length-prefixed principal ID
|
|
4. nullable email
|
|
5. role count와 정렬된 role strings
|
|
6. authority count와 정렬된 authority strings
|
|
|
|
credential은 저장하지 않습니다. principal은 `AuthenticatedPrincipal`만 허용합니다. 전체 snapshot은 16,384 bytes, principal 256 UTF-8 bytes, email 320 bytes, token 128 bytes, roles 64개, authorities 128개로 제한됩니다. [`encode`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:131)
|
|
|
|
load할 때 magic/version/길이/count/중복/trailing bytes를 검사합니다. 손상되거나 incompatible하면 exception을 밖으로 내보내지 않고 attribute를 삭제한 뒤 empty context를 반환합니다. 즉 corrupt session authentication은 authenticated로 복구되지 않습니다. [`load`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:103)
|
|
|
|
## request-time save와 load 순서
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant F as SecurityContext filter
|
|
participant P as Primitive repository
|
|
participant H as HttpSession
|
|
participant S as Spring Session filter
|
|
participant X as SessionRepository
|
|
F->>P: loadContext(holder)
|
|
P->>H: getSession(false), get snapshot
|
|
P-->>F: decoded authentication 또는 empty
|
|
Note over P,F: response/request wrapper 설치
|
|
F->>P: saveContext(final context)
|
|
alt authenticated AuthenticatedPrincipal
|
|
P->>H: getSession(true), set byte[]
|
|
else empty/anonymous
|
|
P->>H: remove attribute if session exists
|
|
end
|
|
H->>S: session mutation
|
|
S->>X: save session
|
|
Note over X: production Redis repository는 확인되지 않음
|
|
```
|
|
|
|
`loadContext`는 response에 `CommitSaveResponseWrapper`를 씌웁니다. response가 commit될 때 현재 context를 저장하되, 이후 explicit final save가 빈 context면 앞서 저장한 snapshot을 제거합니다. async가 시작되면 commit hook 저장을 끄고 final save까지 미룹니다. [`CommitSaveResponseWrapper`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepository.java:267)
|
|
|
|
인증이 없거나 anonymous면 기존 session을 새로 만들지 않고 attribute만 제거합니다. 인증된 context면 `getSession(true)`로 session을 만들고 bytes를 저장합니다. 이 시점의 `HttpSession`을 어느 backend에 persist할지는 Spring Session `SessionRepository`의 책임입니다.
|
|
|
|
## 정상과 실패 분기
|
|
|
|
구현된 web 경계의 정상 분기는 다음과 같습니다.
|
|
|
|
- JWT mode에는 session cookie serializer/filter가 생기지 않습니다.
|
|
- Redis-session mode에서 repository가 제공되면 Spring Session filter와 cookie serializer가 생깁니다. 이것만으로 새 사용자의 최초 인증이 생기지는 않습니다.
|
|
- authenticated primitive principal은 credential 없이 round-trip합니다.
|
|
- empty/anonymous context는 snapshot을 제거합니다.
|
|
- corrupt snapshot은 제거하고 unauthenticated 상태로 처리합니다.
|
|
- foreign principal graph, oversized authority count, control character·byte bound 위반은 save 시 `IllegalArgumentException`입니다.
|
|
|
|
현재 production 조립 실패는 Redis timeout이나 ambiguous write보다 앞에 있습니다. Redis로 session command를 보내는 repository 자체가 없으므로 Redis 명령, TTL, envelope/version migration, touch/save/delete certainty를 분석할 production code도 없습니다. repository를 보완한 뒤에도 최초 인증 mechanism이 없으면 새 unauthenticated 요청은 `anyRequest().authenticated()`에서 인증 entry point로 갈 뿐, 저장할 authenticated context를 만들지 못합니다.
|
|
|
|
`PrimitiveSessionSecurityContextRepository`의 이름에 Redis가 없다는 점도 중요합니다. 이 객체는 `HttpSession` attribute의 내용과 lifecycle만 소유하며 provider storage를 소유하지 않습니다.
|
|
|
|
## 테스트가 고정하는 계약
|
|
|
|
- [`RedisSessionWebConfigTest.jwtModeCreatesNoSessionFilterOrCookieSerializer`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfigTest.java:22)는 JWT에서 web session infrastructure가 비활성임을 검사합니다.
|
|
- 같은 test의 [`redisSessionModeWritesSecureHttpOnlySameSiteHostOnlyCookie`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/RedisSessionWebConfigTest.java:36)는 test가 직접 `MapSessionRepository`를 제공한 뒤 cookie flags와 host-only 속성을 확인합니다. Redis repository 검증이 아닙니다.
|
|
- [`PrimitiveSessionSecurityContextRepositoryTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java:24)는 primitive bytes round-trip과 credential/framework-object 배제를 검사합니다.
|
|
- 같은 test의 commit/final/async cases는 response commit 전에 session 생성이 필요한 경우와 최종 context가 앞선 snapshot을 교체·삭제하는 순서를 고정합니다. [`savesThePrimitiveSnapshotBeforeAResponseCommitRequiresANewSession`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java:60)
|
|
- corrupt/foreign test는 손상 bytes를 empty authentication으로 만들고 attribute를 제거하며 foreign principal save를 거절합니다. [`rejectsForeignPrincipalGraphsAndFailsClosedOnCorruptSnapshots`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/PrimitiveSessionSecurityContextRepositoryTest.java:170)
|
|
- [`SecurityModeWebContractTest.redisSessionSecurityFilterPersistsAndRestoresOnlyThePrimitiveAuthenticationSnapshot`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityModeWebContractTest.java:115)는 primitive snapshot round-trip을 검사합니다. 하지만 최초 인증은 test 전용 `/login-test` controller가 `SecurityContextHolder`에 authenticated token을 직접 넣어 만듭니다. [`loginForContract`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/auth/SecurityModeWebContractTest.java:201)
|
|
- [`AuthenticationModeCompositionConfigTest`](/home/donghyeon/workspace/desktop-server-git/clean-architecture-backend-template/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/security/AuthenticationModeCompositionConfigTest.java:14)는 이름만 가진 fake repository/filter bean으로 exclusive composition rule을 검사합니다. repository 기능을 입증하지 않습니다.
|
|
|
|
## 현재 구현 공백과 잘못 읽기 쉬운 지점
|
|
|
|
1. `redisVersionedSessionRepository` production bean 또는 구현은 확인되지 않습니다.
|
|
2. Redis session record의 key, value envelope, session TTL, save/touch/delete command나 Lua도 production source에 없습니다.
|
|
3. 인증 snapshot이 없는 요청에서 최초 `Authentication`을 만드는 production mechanism도 확인되지 않습니다. repository를 추가하는 것만으로 Redis Session 인증 mode가 완성되지 않습니다.
|
|
4. 따라서 Redis failure의 unavailable/indeterminate 분기와 session fail-closed 정책을 실행 코드 수준에서 확인할 수 없습니다.
|
|
5. `@EnableSpringHttpSession`은 repository 구현이 아닙니다. test는 `MapSessionRepository`를 주입해 filter/cookie 조립만 확인합니다.
|
|
6. `PrimitiveSessionSecurityContextRepository`는 이미 만들어진 security snapshot의 serializer/load-save 경계이며 provider repository나 최초 인증 mechanism이 아닙니다.
|
|
7. composition validator가 요구하는 bean 이름은 contract 역할을 하지만 type, 기능, 최초 인증 경로를 검사하지는 않습니다.
|
|
8. semantic capability 5개 중 production adapter가 조립되는 것은 cache, rate-limit, lease, idempotency V2의 4개입니다. Session은 미완성입니다.
|
|
9. real-server topology tests에는 Session repository flow가 없습니다. 이번 작성에서도 real-server lane을 실행하지 않았습니다.
|
|
|
|
## 다음에 열어볼 source 순서
|
|
|
|
`RedisSessionWebConfig` → `SecurityConfig`의 두 authentication branch → primitive repository → `SecurityModeWebContractTest`의 test-only login → composition validator 순으로 읽으면 “최초 인증”, “security-context snapshot”, “Redis persistence”를 섞지 않을 수 있습니다.
|
|
|
|
## 시리즈에서 이어 읽기
|
|
|
|
- 이전 글: [Redis Idempotency V2 상태 머신: Claim에서 Replay까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-idempotency-v2-code-walkthrough.md)
|
|
- 다음 글: [같은 Redis 장애가 DEGRADED와 DOWN으로 갈리는 코드](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-health-readiness-observability.md)
|
|
- 전체 흐름: [Redis를 범용 클라이언트가 아니라 정책 경계로 다루기](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-backend-policy-boundary.md)
|
|
- 운영 흐름: [Redis를 켠다는 말의 운영적 의미: 단일 활성화 스위치에서 Sentinel 쓰기 손실 검증까지](/home/donghyeon/workspace/ai-tool/document-haness/.run/redis/redis-platform-sre-operations.md)
|