--- kind: CASE slug: orphan-sessions-and-the-ttl-that-finds-them title: 쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션 topic: trust-handed-over-at-the-edge topicName: 위조 신원 헤더와 로그아웃 전파 project: keycloak-session-store status: 게시 전 lastVerifiedOn: sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af source: - final/document.md#선택이-코드와-흐름에-반영되는-방식-b7-b7a assets: - key: b7-cookie-session-tradeoff file: ../../../final/assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg evidence: - ../../../final/evidence/raw/b7a-orphan-session__01-orphan-lifecycle.txt - ../../../final/evidence/raw/b7-cookie-secret__01-deploy.txt --- # 쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션 secret 을 바꾼 뒤 남은 고아 세션은 TTL 로 생성 시각을 역산해 골라 지웠고, 산 세션만 남았다. 프록시는 티켓을 못 풀어 Redis 키를 지우지 못했고, 값으로는 어느 것이 고아인지 구별할 수 없었다. 고아는 생성 후 정확히 1시간이면 사라졌다. ## 관계 - **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다** 같은 엣지 프록시를 쓰고, 그쪽은 세션이 로그인 시점의 스냅샷이라 클레임 변경이 늦게 반영되는 것을 쟀다. - **믿기 전에 그 헤더를 먼저 지운다** 엣지가 만든 인증 결과를 앱이 어디까지 믿을지 정하는 기준이고, 여기서는 그 결과를 담은 세션을 지우는 쪽을 다룬다. ## 문제 oauth2-proxy 의 cookie secret 은 값을 하나만 받는다. 옛 secret 도 당분간 받아 주는 구간을 만들 수 없으므로 교체하는 순간 모든 쿠키가 한꺼번에 무효가 된다. Redis 세션 저장소를 켜면 쿠키에는 티켓만 담기고 세션 본문은 Redis 에 있다. 그러면 secret 을 바꿨을 때 무엇이 막히는지가 달라진다. 프록시가 티켓을 못 풀고, 티켓 안에 세션 id 가 있으므로 어느 Redis 키를 지울지도 모른다. 프록시 로그에 남은 줄 : Error removing session: error decoding ticket to clear session B-7 은 여기서 멈췄다. 지우지 못한 키가 언제까지 살아 있는지, 운영자가 지울 수 있는지, 어느 것이 고아인지 구별할 수 있는지는 재지 않았다. ## 결론 고아 세션이 사라지는가 : o — 생성 후 정확히 1시간 TTL 이 요청으로 갱신되는가 : x — 기동 로그의 refresh:disabled 와 같다 운영자가 지울 수 있는가 : o — redis-cli del 뒤에도 산 세션은 200 Redis 값으로 고아를 구별할 수 있는가 : x 이름 · 타입 · 크기 : 같다 (3510바이트) 값 : 암호화되어 있다 고아를 고르는 방법 : TTL 로 생성 시각을 역산한다 역산한 시각과 로그의 AuthSuccess 차이 : 1초 그 기준으로 골라 지운 결과 : 산 세션만 남았다 지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니다. ## 검증 환경 인증 프록시 : oauth2-proxy 두 replica 세션 저장소 : Redis 쿠키 secret : --cookie-secret 하나. 옛 값과 새 값이 함께 유효한 구간 없음 호스트 이름 : 인증서 SAN(Subject Alternative Name) 에 auth · app1 · app2 셋뿐이라 Grafana 의 app2 를 빌렸다 실험대 : 베어메탈 test-server 한 대 위에 VM 두 대 test-server : Arch Linux, 12GB, WiFi only kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1 kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis 분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af 실행일 : 이 측정 기록에 적혀 있지 않다 ## 재현 조건 1. oauth2-proxy 를 두 replica 로 띄우고 Redis 세션 저장소를 켠다. 2. 로그인한 뒤 Redis 에 세션 키가 생겼는지 확인한다. kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' 3. --cookie-secret 을 새 값으로 바꾸고 배포한다. 4. 브라우저로 다시 접근한다. 옛 쿠키가 검증에 실패하고 새 세션이 생긴다. 5. Redis 키를 다시 조회해 각 키의 TTL 을 읽는다. 6. 일정 간격으로 TTL 을 다시 읽어 요청이 TTL 을 늘리는지 본다. 7. 생성시각 = 지금 − (cookie-expire − TTL) 로 각 키의 생성 시각을 구하고 secret 을 바꾼 시각과 견준다. 8. 회전 시각보다 이른 키를 지우고, 산 세션이 그대로 응답을 받는지 확인한다. ## 본문 ## 서버에 상태가 없으니 공유할 것도 없다 B층 실험의 주제는 BFF(Backend For Frontend) 가 서버에 들고 있던 세션과 토큰을 Redis 와 PostgreSQL 로 빼는 것이었다. oauth2-proxy 는 정반대다. 세션 전체가 쿠키에 있고 replica 는 같은 k8s Secret 을 읽을 뿐이므로, 인스턴스 사이에 맞출 상태가 없어 콜백이 다른 replica 로 가도 문제가 없다. 대신 `--cookie-secret` 이 값을 하나만 받는다. 「옛 secret 도 당분간 받아 준다」를 표현할 방법이 없으니 겹침 구간을 만들 수 없고, secret 을 교체하는 순간 발급돼 있던 쿠키가 한꺼번에 무효가 된다. 쿠키가 한꺼번에 무효가 되는 것은 사용자 쪽에서 잘 보이지 않는다. Keycloak 의 SSO 세션이 살아 있으면 애플리케이션 세션이 죽어도 로그인 화면 없이 조용히 다시 인증되기 때문이다. 세션이 두 겹이라 앱 쪽 한 겹만 끊긴다. ## 티켓을 못 풀면 어느 키를 지울지 모른다 Redis 세션 저장소를 켜면 쿠키에 담기는 것이 세션 전체에서 티켓으로 바뀐다. ```text label="쿠키에 담기는 티켓의 구조" 티켓 = <세션 ID>.<암호화 키> │ └─ 값을 복호화할 키 └─ Redis 키 이름을 만든다 → _oauth2_proxy- ``` 티켓 전체가 `--cookie-secret` 으로 암호화되어 있다. secret 을 바꾸면 프록시는 티켓을 열지 못하고, 세션 ID 를 읽지 못하니 Redis 키 이름도 만들지 못한다. 그래서 로그에 이 줄이 남는다. ```text label="secret 을 바꾼 뒤 프록시가 남긴 로그" Error removing session: error decoding ticket to clear session ``` ![세션이 쿠키에 담기고 replica 는 같은 Secret 만 읽는 구성. Redis 저장소를 켜면 쿠키에 티켓만 남고 서버에 세션이 생긴다.](../../../final/assets/b7-cookie-session-tradeoff/b7-cookie-session-tradeoff.svg) 그림에서 Redis 로 들어가는 화살표는 쿠키의 티켓에서만 나온다. 키 이름을 만드는 경로가 그 하나뿐이라, 티켓이 안 열리면 Redis 쪽에서 그 세션을 가리킬 방법이 없어진다. 이렇게 프록시가 지우지 못한 채 Redis 에 남은 세션이 고아 세션이다. ## Redis 값으로는 고아를 구별할 수 없었다 B-7 이 남긴 말은 「지우지 못했다」였다. 그 문장을 그대로 믿으면 고아는 어쩔 수 없는 것이 되는데, 못 지우는 주체가 프록시인지 Redis 인지는 거기서 갈리지 않았다. 그 갈래를 포함해 B-7a 가 셋을 이어서 쟀다. | 물음 | 잰 결과 | |---|---| | 고아는 정말 사라지는가 | 사라진다. 생성 후 정확히 1시간. TTL 이 갱신되지 않는다 | | 운영자가 지울 수 있는가 | 있다. `redis-cli del` 후에도 산 세션은 `200` | | 어느 것이 고아인지 아는가 | Redis 값으로는 모른다. 이름·타입·크기(3510바이트)가 같고 값은 암호화 | | 그럼 어떻게 고르는가 | TTL 로 생성 시각을 역산한다 | 첫 줄이 먼저 정해져야 나머지가 의미를 갖는다. 고아가 영영 쌓이는 것이라면 정리 규칙을 만드는 것과 별개로 저장소가 계속 커지기 때문이다. TTL 은 요청을 보내도 늘지 않았고, 이 구성의 기동 로그에 찍힌 `refresh:disabled` 와 맞는다. 고아가 생기는 시점도 secret 을 바꾸는 순간이 아니다. 회전 직후 Redis 의 키 수는 그대로였고, 브라우저가 옛 쿠키를 들고 다시 와서 프록시가 그것을 못 푼 다음에야 새 세션이 하나 더 생기면서 앞의 것이 고아가 됐다. 사람이 접근하지 않으면 고아도 안 생긴다. 회전을 한 번 더 걸었더니 1차 회전에서 살아남았던 세션이 이번에는 고아가 됐다. 회전 한 번이 그 시점에 로그인해 있던 사용자 수만큼 고아를 만드는 셈이고, 그 고아들도 생성 후 1시간이면 사라진다. 셋째 줄이 실제로 막혔던 곳이다. 키 이름은 접두사가 같고 뒤는 불투명한 값이며, 타입도 크기도 같고 값은 암호화되어 있다. 값의 md5 를 떠 봐도 둘이 다르다는 것만 알 뿐, 어느 쪽이 산 세션인지는 그 차이에서 나오지 않는다. 두 키를 나란히 놓고 보면 다른 것은 TTL 하나뿐이었다. ```bash label="Redis 에 남은 세션 키를 훑는다" kubectl exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy-*' ``` ## TTL 이 갱신되지 않으니 생성 시각을 되돌릴 수 있다 TTL 이 요청으로 갱신되지 않는다는 것이 구별의 근거가 됐다. 30초 간격으로 세 번 읽었더니 산 세션은 3557 · 3526 · 3494 였고 고아는 3479 · 3448 · 3417 이었다. 1초에 1초씩 줄기만 한다. 그래서 이 방법은 기동 로그의 `refresh:disabled` 한 단어에 통째로 매달려 있다. 재기 전에 그것부터 읽었다. 갱신이 없으면 남은 TTL 은 만료 설정에서 흘러간 시간을 뺀 값이므로, 거꾸로 계산하면 그 세션이 언제 만들어졌는지 나온다. ```text label="TTL 에서 생성 시각을 되돌린다" 생성시각 = 지금 − (cookie-expire − TTL) ``` 이 값이 secret 을 바꾼 시각보다 이르면 그 키는 고아다. 회전 뒤에 생긴 세션은 새 secret 으로 만들어졌으므로 유효하다. 시각은 전부 UTC(협정 세계시)로 다뤘다. 이 방법의 결론이 시각 계산이라 한국 시간과 한 번만 섞여도 9시간이 통째로 틀어진다. 역산이 맞는지는 로그와 견줘 확인했다. 계산한 `11:30:26` 과 로그의 `AuthSuccess 11:30:27` 이 1초 차였다. 그 기준으로 실제로 골라 지웠고 산 세션만 남았다. 지우지 못한 것은 oauth2-proxy 의 한계이고 Redis 의 한계가 아니었다 — 프록시는 티켓을 못 풀어 키 이름을 만들지 못하지만 운영자는 키 목록을 직접 본다. ## 이 방법이 성립하지 않는 구성 이 정리는 남의 세션을 실제로 지우는 일이다. 기준을 잘못 잡아 산 세션을 지우면 그 사람은 다시 인증해야 하는데, Keycloak 의 SSO 세션이 살아 있으면 로그인 화면 없이 조용히 지나가므로 잘못 지웠다는 것이 밖에서 보이지 않는다. `--cookie-refresh` 를 켜면 이 역산이 무너진다. TTL 이 요청마다 갱신되면 남은 TTL 이 생성 시각의 함수가 아니게 되고, 그러면 회전 시각과 견줄 값이 없다. 그 구성에서 고아를 어떻게 고를지는 재지 않았다. 회전 뒤 `FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 정직하다. 고아가 사라지기까지 잰 1시간도 이 구성에서 나온 값이다. 쿠키 만료를 다르게 잡은 구성에서는 다시 재지 않았다.