본문 바로가기

쿠키와 상태 관리

브라우저 쿠키 왕복과 서버 세션의 발급·만료·폐기·교체를 하나의 실행 가능한 MockMvc 계약으로 검증합니다.

HTTP 요청은 서로 독립적입니다. 그런데도 로그인 상태가 이어지는 것처럼 보이는 것은, 사용자 에이전트가 응답의 Set-Cookie를 저장해 두었다가 조건이 맞는 다음 요청에 Cookie를 자동으로 붙여 보내기 때문이며, 브라우저가 도메인과 경로, 보안 조건으로 쿠키를 고르는 과정은 쿠키와 세션에서 자세히 다룹니다. 여기에는 서로 다른 두 가지 상태가 있습니다. 브라우저는 쿠키의 이름과 값, 그리고 Secure, HttpOnly, SameSite, Path, 수명을 관리하고, 서버는 불투명한 토큰이 가리키는 회원과 만료 시각, 폐기 여부를 관리합니다.

쿠키에는 회원 식별자나 권한 목록을 넣지 않습니다. 추측하기 어려운 원시 토큰만 브라우저에 보내고, 서버 저장소의 키는 원시 토큰의 SHA-256 다이제스트로 만듭니다. 그래서 저장소의 값과 브라우저가 가진 값은 서로 다릅니다.

쿠키를 주고받는 기본 표준은 RFC 6265입니다. __Host- 접두사와 SameSite를 포함한 확장은 draft-ietf-httpbis-rfc6265bis-22를 기준으로 합니다. 2026년 11월 1일에 확인한 Datatracker에서 이 문서는 RFC 편집자의 대기열에 있는 Internet-Draft이므로, 아래에서는 아직 발행된 RFC로 부르지 않습니다. 로그인 응답은 쿠키마다 따로 Set-Cookie 필드 줄을 보내며, 여러 쿠키를 쉼표로 합치거나 한 필드를 다음 줄로 접는 obs-fold를 만들지 않습니다. 이 규칙은 6265bis-22 §3과 RFC 9110 §5.2, 줄 접기 금지는 RFC 9112 §5.2를 기준으로 합니다.

원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다

로그인·digest 저장·Set-Cookie·다음 GET Cookie·digest 조회·200/401의 연속 왕복이다.

같은 브라우저가 로그인 POST를 보내면 SessionController가 원시 토큰 대신 digest와 회원·만료·활성 상태를 session store에 저장하고, 204 Set-Cookie 응답을 받은 cookie jar는 전송 범위와 수명을 확인해 원시 토큰을 보관합니다. 다음 GET /api/me에서 cookie jar가 원시 토큰을 Cookie header에 자동 첨부하고, controller가 이를 hash하여 session store의 digest를 조회합니다. 활성 회원이면 200을, 저장소 miss·만료·폐기이면 WWW-Authenticate challenge가 있는 401을 반환합니다.

SET-COOKIE · COOKIE JAR · DIGEST LOOKUP · SAME BROWSER

원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다

브라우저에는 불투명한 원시 토큰만, 서버 저장에는 그 digest와 로그인 상태만 남깁니다. 같은 브라우저의 다음 요청에서 cookie jar가 범위가 맞는 쿠키를 자동 첨부하고, server는 hash lookup 결과가 활성일 때만 회원을 돌려줍니다.

로그인 쿠키 발급과 같은 브라우저의 인증 조회 브라우저, cookie jar, SessionController, session store 사이에서 로그인 POST, digest 저장, Set-Cookie 수락, 다음 GET의 Cookie 첨부, digest 조회가 순서대로 일어나며 활성 회원은 200, 저장소 miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝나는 시퀀스입니다. POST /api/loginSTORE digest204 · SET-COOKIEACCEPT + STORECOOKIE · RAWGET /api/me · COOKIELOOKUP digestMEMBER ACTIVE200 · MEMBER401 · INVALIDHASH(raw)ALT[active][invalid]browsersame user agentcookie jarraw token · scopeSessionController/api/login · /api/mesession storedigest · member · expiryLEGENDCALLRETURNACTIVE SUCCESS
로그인 POST와 digest 저장첫 단계의 세 참여자는 browser, SessionController, session store입니다. browser가 POST /api/login을 보내고 제어기가 digest와 회원·만료·활성 상태를 저장합니다. 204의 Set-Cookie는 원시 token을 브라우저로 반환합니다. 실제 URI와 필드는 원 본문의 계약대로이며 그림의 짧은 메시지명은 그 전달을 가리킵니다.로그인 · POST /api/login로그인digest쿠키204 · Set-Cookie브라우저Session제어기sessionstore
같은 브라우저의 cookie jar이 단계는 앞 204의 쿠키를 cookie jar가 범위·수명에 맞게 수락하고 보관한 뒤, 다음 GET /api/me에 맞는 원시 토큰을 자동 첨부하는 두 전달입니다. 서버의 digest 저장과 다른 client 상태이며, 이 jar의 선택 뒤에 서버가 별도로 인증을 판단합니다.같은 브라우저의 보관·선택허용 Set-Cookie요청 범위의 토큰GET /api/me에 첨부cookiejar브라우저
원시 토큰의 digest 조회와 두 결과앞 단계에서 Cookie에 원시 토큰을 붙인 GET /api/me를 제어기가 받아 hash하고 session store를 조회합니다. 활성 record는 회원과 200을 반환하고, miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝납니다. 둘은 ALT의 대안이므로 한 요청은 조건에 맞는 한 결과를 반환합니다. 기존 데스크톱과 같은 다섯 전달과 자기 호출 하나입니다.GET /api/me · 세션 조회GETdigest[활성]회원200[무효]401HASH브라우저Session제어기sessionstore

01 · LOGIN RESPONSE

로그인 결과는 digest 저장과 원시 쿠키 전달로 갈라진다

  1. 같은 browser가 POST /api/login을 보냅니다.
  2. SessionController는 digest(token)과 회원·만료·활성 상태를 session store에 저장합니다.
  3. 204의 Set-Cookie로 원시 토큰을 browser에 한 번 전달합니다.

02 · SAME BROWSER

  1. cookie jar가 허용되는 Set-Cookie를 받아 scope·expiry와 원시 토큰을 보관합니다.
  2. 같은 browser의 GET /api/me에 맞는 쿠키를 선택합니다.
  3. Cookie: __Host-board-session=<raw token>을 요청에 첨부합니다.

03 · SERVER LOOKUP

server는 원시 값을 hash한 뒤 활성 session만 회원으로 해석한다

  1. controller가 받은 원시 토큰을 hash하고 digest로 조회합니다.
  2. 저장된 session이 활성이라면 회원과 200을 반환합니다.
  3. miss·expired·revoked이면 로그인 상태로 인정하지 않고 401을 반환합니다.

COOKIE SCOPE · HOST + TRANSPORT

__Host-·Secure·Path=/·Domain 없음이 전송 범위를 고정한다

  • Secure는 user agent가 안전하다고 판단한 연결(일반적으로 HTTPS)에만 쿠키를 보냅니다.
  • Path=/은 전송 범위를 명시할 뿐 경로 사이의 보안 격리가 아닙니다.
  • __Host-와 Domain 없음은 host 경계를 유지하지만 port를 격리하지 않습니다.

COOKIE EXPOSURE · SCRIPT + SITE

HttpOnly와 SameSite=Lax는 서로 다른 browser 접근 경계를 줄인다

  • HttpOnly는 browser script가 쿠키 값을 읽는 경로를 제한합니다.
  • SameSite=Lax는 cross-site 전송을 줄이지만 top-level safe navigation은 허용합니다.
  • Lax는 CSRF 방어의 한 겹이며 별도 CSRF 대책을 대신하지 않습니다.

SESSION STATE · LIFETIME + STATUS

Max-Age=1800은 client 수명 상한이고 server expiry는 로그인 유효성을 판정한다

  • browser는 원시 토큰을 저장하되 Max-Age보다 일찍 제거할 수도 있습니다.
  • session store는 원시 토큰이 아니라 digest와 member·expiry·active 상태를 보관합니다.
  • active lookup만 200이며 나머지는 challenge가 있는 401입니다.

쿠키는 다음 요청에서 서버 상태를 찾는 불투명 식별자입니다. 문서의 결정적 토큰 큐·CookieManager·MockMvc는 왕복 계약을 검증하며, 실제 브라우저의 __Host-·SameSite 집행과 TLS·CSRF는 별도 검증 범위입니다. 원시 값은 browser 왕복 경계에만 두고, server는 digest lookup과 expiry·active 판정으로 현재 로그인 상태를 결정합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 브라우저, cookie jar, SessionController, session store 사이에서 로그인 POST, digest 저장, Set-Cookie 수락, 다음 GET의 Cookie 첨부, digest 조회가 순서대로 일어나며 활성 회원은 200, 저장소 miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝나는 시퀀스입니다.
  • 첫 단계의 세 참여자는 browser, SessionController, session store입니다. browser가 POST /api/login을 보내고 제어기가 digest와 회원·만료·활성 상태를 저장합니다. 204의 Set-Cookie는 원시 token을 브라우저로 반환합니다. 실제 URI와 필드는 원 본문의 계약대로이며 그림의 짧은 메시지명은 그 전달을 가리킵니다.
  • 이 단계는 앞 204의 쿠키를 cookie jar가 범위·수명에 맞게 수락하고 보관한 뒤, 다음 GET /api/me에 맞는 원시 토큰을 자동 첨부하는 두 전달입니다. 서버의 digest 저장과 다른 client 상태이며, 이 jar의 선택 뒤에 서버가 별도로 인증을 판단합니다.
  • 앞 단계에서 Cookie에 원시 토큰을 붙인 GET /api/me를 제어기가 받아 hash하고 session store를 조회합니다. 활성 record는 회원과 200을 반환하고, miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝납니다. 둘은 ALT의 대안이므로 한 요청은 조건에 맞는 한 결과를 반환합니다. 기존 데스크톱과 같은 다섯 전달과 자기 호출 하나입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
로그인과 digest 저장

로그인 POST에서 서버가 토큰 digest를 session store에 저장합니다.

요청: 로그인 POST · store: digest · 브라우저 쿠키: 아직 수락 전서버에 세션 식별용 digest가 생깁니다.
Set-Cookie 수락

응답의 Set-Cookie를 cookie jar가 수락합니다.

jar: 토큰 쿠키 보관 · store: digest · 응답: Set-Cookie브라우저 쪽 보관 상태가 생깁니다.
다음 요청에서 조회

별도 GET에 Cookie를 첨부하고 서버가 digest를 계산해 저장소에서 찾습니다.

요청: 다음 GET · 조회: digest · jar: 쿠키 첨부브라우저 보관 값이 새 요청의 인증 입력이 됩니다.
활성 세션 또는 401

활성 회원이면 200이고 miss·만료·폐기이면 challenge가 있는 401입니다.

활성 회원: 200 · miss·만료·폐기: 401 · 응답 헤더: WWW-Authenticate저장소 상태에 따라 요청의 종료 결과가 갈립니다.

원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다

로그인·digest 저장·Set-Cookie·다음 GET Cookie·digest 조회·200/401의 연속 왕복이다.

같은 브라우저가 로그인 POST를 보내면 SessionController가 원시 토큰 대신 digest와 회원·만료·활성 상태를 session store에 저장하고, 204 Set-Cookie 응답을 받은 cookie jar는 전송 범위와 수명을 확인해 원시 토큰을 보관합니다. 다음 GET /api/me에서 cookie jar가 원시 토큰을 Cookie header에 자동 첨부하고, controller가 이를 hash하여 session store의 digest를 조회합니다. 활성 회원이면 200을, 저장소 miss·만료·폐기이면 WWW-Authenticate challenge가 있는 401을 반환합니다.

SET-COOKIE · COOKIE JAR · DIGEST LOOKUP · SAME BROWSER

원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다

브라우저에는 불투명한 원시 토큰만, 서버 저장에는 그 digest와 로그인 상태만 남깁니다. 같은 브라우저의 다음 요청에서 cookie jar가 범위가 맞는 쿠키를 자동 첨부하고, server는 hash lookup 결과가 활성일 때만 회원을 돌려줍니다.

로그인 쿠키 발급과 같은 브라우저의 인증 조회 브라우저, cookie jar, SessionController, session store 사이에서 로그인 POST, digest 저장, Set-Cookie 수락, 다음 GET의 Cookie 첨부, digest 조회가 순서대로 일어나며 활성 회원은 200, 저장소 miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝나는 시퀀스입니다. POST /api/loginSTORE digest204 · SET-COOKIEACCEPT + STORECOOKIE · RAWGET /api/me · COOKIELOOKUP digestMEMBER ACTIVE200 · MEMBER401 · INVALIDHASH(raw)ALT[active][invalid]browsersame user agentcookie jarraw token · scopeSessionController/api/login · /api/mesession storedigest · member · expiryLEGENDCALLRETURNACTIVE SUCCESS
로그인 POST와 digest 저장첫 단계의 세 참여자는 browser, SessionController, session store입니다. browser가 POST /api/login을 보내고 제어기가 digest와 회원·만료·활성 상태를 저장합니다. 204의 Set-Cookie는 원시 token을 브라우저로 반환합니다. 실제 URI와 필드는 원 본문의 계약대로이며 그림의 짧은 메시지명은 그 전달을 가리킵니다.로그인 · POST /api/login로그인digest쿠키204 · Set-Cookie브라우저Session제어기sessionstore
같은 브라우저의 cookie jar이 단계는 앞 204의 쿠키를 cookie jar가 범위·수명에 맞게 수락하고 보관한 뒤, 다음 GET /api/me에 맞는 원시 토큰을 자동 첨부하는 두 전달입니다. 서버의 digest 저장과 다른 client 상태이며, 이 jar의 선택 뒤에 서버가 별도로 인증을 판단합니다.같은 브라우저의 보관·선택허용 Set-Cookie요청 범위의 토큰GET /api/me에 첨부cookiejar브라우저
원시 토큰의 digest 조회와 두 결과앞 단계에서 Cookie에 원시 토큰을 붙인 GET /api/me를 제어기가 받아 hash하고 session store를 조회합니다. 활성 record는 회원과 200을 반환하고, miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝납니다. 둘은 ALT의 대안이므로 한 요청은 조건에 맞는 한 결과를 반환합니다. 기존 데스크톱과 같은 다섯 전달과 자기 호출 하나입니다.GET /api/me · 세션 조회GETdigest[활성]회원200[무효]401HASH브라우저Session제어기sessionstore

01 · LOGIN RESPONSE

로그인 결과는 digest 저장과 원시 쿠키 전달로 갈라진다

  1. 같은 browser가 POST /api/login을 보냅니다.
  2. SessionController는 digest(token)과 회원·만료·활성 상태를 session store에 저장합니다.
  3. 204의 Set-Cookie로 원시 토큰을 browser에 한 번 전달합니다.

02 · SAME BROWSER

  1. cookie jar가 허용되는 Set-Cookie를 받아 scope·expiry와 원시 토큰을 보관합니다.
  2. 같은 browser의 GET /api/me에 맞는 쿠키를 선택합니다.
  3. Cookie: __Host-board-session=<raw token>을 요청에 첨부합니다.

03 · SERVER LOOKUP

server는 원시 값을 hash한 뒤 활성 session만 회원으로 해석한다

  1. controller가 받은 원시 토큰을 hash하고 digest로 조회합니다.
  2. 저장된 session이 활성이라면 회원과 200을 반환합니다.
  3. miss·expired·revoked이면 로그인 상태로 인정하지 않고 401을 반환합니다.

COOKIE SCOPE · HOST + TRANSPORT

__Host-·Secure·Path=/·Domain 없음이 전송 범위를 고정한다

  • Secure는 user agent가 안전하다고 판단한 연결(일반적으로 HTTPS)에만 쿠키를 보냅니다.
  • Path=/은 전송 범위를 명시할 뿐 경로 사이의 보안 격리가 아닙니다.
  • __Host-와 Domain 없음은 host 경계를 유지하지만 port를 격리하지 않습니다.

COOKIE EXPOSURE · SCRIPT + SITE

HttpOnly와 SameSite=Lax는 서로 다른 browser 접근 경계를 줄인다

  • HttpOnly는 browser script가 쿠키 값을 읽는 경로를 제한합니다.
  • SameSite=Lax는 cross-site 전송을 줄이지만 top-level safe navigation은 허용합니다.
  • Lax는 CSRF 방어의 한 겹이며 별도 CSRF 대책을 대신하지 않습니다.

SESSION STATE · LIFETIME + STATUS

Max-Age=1800은 client 수명 상한이고 server expiry는 로그인 유효성을 판정한다

  • browser는 원시 토큰을 저장하되 Max-Age보다 일찍 제거할 수도 있습니다.
  • session store는 원시 토큰이 아니라 digest와 member·expiry·active 상태를 보관합니다.
  • active lookup만 200이며 나머지는 challenge가 있는 401입니다.

쿠키는 다음 요청에서 서버 상태를 찾는 불투명 식별자입니다. 문서의 결정적 토큰 큐·CookieManager·MockMvc는 왕복 계약을 검증하며, 실제 브라우저의 __Host-·SameSite 집행과 TLS·CSRF는 별도 검증 범위입니다. 원시 값은 browser 왕복 경계에만 두고, server는 digest lookup과 expiry·active 판정으로 현재 로그인 상태를 결정합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 브라우저, cookie jar, SessionController, session store 사이에서 로그인 POST, digest 저장, Set-Cookie 수락, 다음 GET의 Cookie 첨부, digest 조회가 순서대로 일어나며 활성 회원은 200, 저장소 miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝나는 시퀀스입니다.
  • 첫 단계의 세 참여자는 browser, SessionController, session store입니다. browser가 POST /api/login을 보내고 제어기가 digest와 회원·만료·활성 상태를 저장합니다. 204의 Set-Cookie는 원시 token을 브라우저로 반환합니다. 실제 URI와 필드는 원 본문의 계약대로이며 그림의 짧은 메시지명은 그 전달을 가리킵니다.
  • 이 단계는 앞 204의 쿠키를 cookie jar가 범위·수명에 맞게 수락하고 보관한 뒤, 다음 GET /api/me에 맞는 원시 토큰을 자동 첨부하는 두 전달입니다. 서버의 digest 저장과 다른 client 상태이며, 이 jar의 선택 뒤에 서버가 별도로 인증을 판단합니다.
  • 앞 단계에서 Cookie에 원시 토큰을 붙인 GET /api/me를 제어기가 받아 hash하고 session store를 조회합니다. 활성 record는 회원과 200을 반환하고, miss·만료·폐기는 WWW-Authenticate challenge가 있는 401로 끝납니다. 둘은 ALT의 대안이므로 한 요청은 조건에 맞는 한 결과를 반환합니다. 기존 데스크톱과 같은 다섯 전달과 자기 호출 하나입니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
로그인과 digest 저장

로그인 POST에서 서버가 토큰 digest를 session store에 저장합니다.

요청: 로그인 POST · store: digest · 브라우저 쿠키: 아직 수락 전서버에 세션 식별용 digest가 생깁니다.
Set-Cookie 수락

응답의 Set-Cookie를 cookie jar가 수락합니다.

jar: 토큰 쿠키 보관 · store: digest · 응답: Set-Cookie브라우저 쪽 보관 상태가 생깁니다.
다음 요청에서 조회

별도 GET에 Cookie를 첨부하고 서버가 digest를 계산해 저장소에서 찾습니다.

요청: 다음 GET · 조회: digest · jar: 쿠키 첨부브라우저 보관 값이 새 요청의 인증 입력이 됩니다.
활성 세션 또는 401

활성 회원이면 200이고 miss·만료·폐기이면 challenge가 있는 401입니다.

활성 회원: 200 · miss·만료·폐기: 401 · 응답 헤더: WWW-Authenticate저장소 상태에 따라 요청의 종료 결과가 갈립니다.

그림은 같은 브라우저에서 일어나는 로그인과 다음 요청을 차례로 보여 줍니다. 로그인 POST에 대해 컨트롤러는 원시 토큰의 다이제스트와 회원, 만료, 활성 상태를 세션 저장소에 넣고, 204의 Set-Cookie로 원시 토큰을 브라우저에 한 번 보냅니다. 쿠키 저장소(cookie jar)는 범위와 수명을 확인해 그 값을 보관했다가 다음 GET /api/me에 붙이고, 컨트롤러는 받은 값을 해시해 저장소를 찾습니다. 활성 세션이면 회원과 200을, 없거나 만료되었거나 폐기되었으면 인증 요구가 있는 401을 돌려줍니다. 아래쪽 상자들은 __Host-, Secure, Path=/, Domain 없음이 전송 범위를 정하고, HttpOnly와 SameSite=Lax가 스크립트와 사이트 사이의 노출을 줄이며, Max-Age는 브라우저의 보관 상한일 뿐 로그인의 유효성은 서버의 만료가 정한다는 점을 정리합니다.


실행할 수 있는 쿠키·세션 테스트

다음 테스트 파일 하나에 컨트롤러와 쿠키 팩토리, 인증기, 토큰 발급기, 세션 저장소, 브라우저 역할을 하는 쿠키 저장소를 모두 정의합니다. 테스트의 보통 왕복 경로는 .cookie()를 직접 호출하지 않습니다. BrowserSession이 HTTPS URI로 받은 모든 Set-Cookie 필드 줄을 java.net.CookieManager에 넘기고, 쿠키 저장소가 만든 다음 Cookie를 MockMvc 요청에 옮겨 붙입니다. 공격자가 이미 원시 토큰을 복사해 두었다고 가정하는 위조와 재전송 테스트만 일부러 Cookie를 직접 넣습니다.

src/test/java/board/web/LoginCookieContractTest.java
package board.web;

import static java.nio.charset.StandardCharsets.UTF_8;
import static java.time.ZoneOffset.UTC;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.Cookie;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

class LoginCookieContractTest {
    private static final Duration SESSION_TTL =
            Duration.ofMinutes(30);
    private static final Instant START =
            Instant.parse("2030-01-01T00:00:00Z");
    private static final Member MEMBER =
            new Member(7L, "[email protected]");
    private static final String LOGIN_JSON = """
            {
              "email": "[email protected]",
              "password": "correct-horse-battery-staple"
            }
            """;
    private static final ObjectMapper JSON =
            new ObjectMapper();

    @Test
    void 로그인은_정확한_host_cookie_속성과_no_store를_반환한다()
            throws Exception {
        var fixture = fixture("login-001");

        var login = fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        var response = login.getResponse();
        var setCookie = response.getHeader(
                HttpHeaders.SET_COOKIE);
        var actualParts = Set.of(
                Objects.requireNonNull(setCookie)
                        .split(";\\s*"));
        var expectedParts = Set.of(
                "__Host-board-session=login-001",
                "Path=/",
                "Max-Age=1800",
                "Secure",
                "HttpOnly",
                "SameSite=Lax",
                "Expires=Tue, 1 Jan 2030 00:30:00 GMT");

        assertAll(
                () -> assertEquals(
                        204, response.getStatus()),
                () -> assertEquals(
                        1,
                        response.getHeaders(
                                HttpHeaders.SET_COOKIE).size()),
                () -> assertEquals(
                        expectedParts, actualParts),
                () -> assertEquals(
                        "no-store",
                        response.getHeader(
                                HttpHeaders.CACHE_CONTROL)),
                () -> assertFalse(
                        fixture.store()
                                .containsRawStorageKey(
                                        "login-001")),
                () -> assertTrue(
                        fixture.store()
                                .containsDigestFor(
                                        "login-001")),
                () -> assertEquals(
                        List.of(
                                "__Host-board-session="
                                        + "login-001"),
                        fixture.browser()
                                .cookieHeaderValues(
                                        "/api/me")));
    }

    @Test
    void 로그인_cookie는_jar를_거쳐_me_요청에_자동_첨부된다()
            throws Exception {
        var fixture = fixture("roundtrip-001");

        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        var me = fixture.browser().perform(
                fixture.mvc(),
                "/api/me",
                get("/api/me"));
        JsonNode body = JSON.readTree(
                me.getResponse().getContentAsByteArray());

        assertAll(
                () -> assertEquals(
                        200,
                        me.getResponse().getStatus()),
                () -> assertEquals(
                        "no-store",
                        me.getResponse().getHeader(
                                HttpHeaders.CACHE_CONTROL)),
                () -> assertEquals(
                        7L, body.get("id").longValue()),
                () -> assertEquals(
                        "[email protected]",
                        body.get("email").textValue()));
    }

    @Test
    void 없거나_위조된_cookie는_challenge가_있는_401이다()
            throws Exception {
        var fixture = fixture("unused-001");

        var absent = fixture.mvc().perform(
                get("/api/me").secure(true))
                .andReturn();
        var forged = fixture.mvc().perform(
                get("/api/me")
                        .secure(true)
                        .cookie(new Cookie(
                                SessionCookieFactory.NAME,
                                "forged-token")))
                .andReturn();

        assertUnauthorized(absent);
        assertUnauthorized(forged);
    }

    @Test
    void 로그아웃은_cookie를_삭제하고_같은_jar의_me를_401로_바꾼다()
            throws Exception {
        var fixture = fixture("logout-001");

        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        var logout = fixture.browser().perform(
                fixture.mvc(),
                "/api/logout",
                post("/api/logout"));
        var deletion = Objects.requireNonNull(
                logout.getResponse().getHeader(
                        HttpHeaders.SET_COOKIE));
        var me = fixture.browser().perform(
                fixture.mvc(),
                "/api/me",
                get("/api/me"));

        assertAll(
                () -> assertEquals(
                        204,
                        logout.getResponse().getStatus()),
                () -> assertEquals(
                        Set.of(
                                "__Host-board-session=",
                                "Path=/",
                                "Secure",
                                "HttpOnly",
                                "SameSite=Lax",
                                "Expires=Thu, 1 Jan 1970 "
                                        + "00:00:00 GMT"),
                        Set.of(deletion.split(";\\s*"))),
                () -> assertTrue(
                        fixture.browser()
                                .cookieHeaderValues(
                                        "/api/me")
                                .isEmpty()),
                () -> assertEquals(
                        401, me.getResponse().getStatus()));
    }

    @Test
    void 로그아웃_전에_복사한_token도_서버_폐기_뒤에는_401이다()
            throws Exception {
        var fixture = fixture("replay-001");

        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        fixture.browser().perform(
                fixture.mvc(),
                "/api/logout",
                post("/api/logout"));
        var replay = fixture.mvc().perform(
                get("/api/me")
                        .secure(true)
                        .cookie(new Cookie(
                                SessionCookieFactory.NAME,
                                "replay-001")))
                .andReturn();

        assertUnauthorized(replay);
    }

    @Test
    void cookie가_없는_로그아웃과_반복_로그아웃은_모두_204다()
            throws Exception {
        var fixture = fixture("repeat-001");

        var missing = fixture.browser().perform(
                fixture.mvc(),
                "/api/logout",
                post("/api/logout"));
        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        var first = fixture.browser().perform(
                fixture.mvc(),
                "/api/logout",
                post("/api/logout"));
        var repeated = fixture.browser().perform(
                fixture.mvc(),
                "/api/logout",
                post("/api/logout"));

        assertAll(
                () -> assertEquals(
                        204,
                        missing.getResponse().getStatus()),
                () -> assertEquals(
                        204,
                        first.getResponse().getStatus()),
                () -> assertEquals(
                        204,
                        repeated.getResponse().getStatus()));
    }

    @Test
    void 서버_TTL이_지나면_jar가_token을_보내도_401이다()
            throws Exception {
        var fixture = fixture("ttl-001");

        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        fixture.clock().advance(
                SESSION_TTL.plusSeconds(1));
        var beforeRequest = fixture.browser()
                .cookieHeaderValues("/api/me");
        var expired = fixture.browser().perform(
                fixture.mvc(),
                "/api/me",
                get("/api/me"));

        assertAll(
                () -> assertEquals(
                        List.of(
                                "__Host-board-session="
                                        + "ttl-001"),
                        beforeRequest),
                () -> assertEquals(
                        401,
                        expired.getResponse().getStatus()),
                () -> assertEquals(
                        SessionController.CHALLENGE,
                        expired.getResponse().getHeader(
                                HttpHeaders
                                        .WWW_AUTHENTICATE)));
    }

    @Test
    void rotate는_이전_token을_폐기하고_새_cookie만_유효하게_한다()
            throws Exception {
        var fixture = fixture(
                "rotate-old", "rotate-new");

        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        var rotate = fixture.browser().perform(
                fixture.mvc(),
                "/api/session/rotate",
                post("/api/session/rotate"));
        var oldReplay = fixture.mvc().perform(
                get("/api/me")
                        .secure(true)
                        .cookie(new Cookie(
                                SessionCookieFactory.NAME,
                                "rotate-old")))
                .andReturn();
        var current = fixture.browser().perform(
                fixture.mvc(),
                "/api/me",
                get("/api/me"));

        assertAll(
                () -> assertEquals(
                        204,
                        rotate.getResponse().getStatus()),
                () -> assertEquals(
                        List.of(
                                "__Host-board-session="
                                        + "rotate-new"),
                        fixture.browser()
                                .cookieHeaderValues(
                                        "/api/me")),
                () -> assertEquals(
                        401,
                        oldReplay.getResponse().getStatus()),
                () -> assertEquals(
                        200,
                        current.getResponse().getStatus()));
    }

    @Test
    void 재로그인은_같은_회원의_이전_session을_폐기한다()
            throws Exception {
        var fixture = fixture(
                "login-old", "login-new");

        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        fixture.browser().perform(
                fixture.mvc(),
                "/api/login",
                loginRequest());
        var oldReplay = fixture.mvc().perform(
                get("/api/me")
                        .secure(true)
                        .cookie(new Cookie(
                                SessionCookieFactory.NAME,
                                "login-old")))
                .andReturn();
        var current = fixture.browser().perform(
                fixture.mvc(),
                "/api/me",
                get("/api/me"));

        assertAll(
                () -> assertEquals(
                        401,
                        oldReplay.getResponse().getStatus()),
                () -> assertEquals(
                        List.of(
                                "__Host-board-session="
                                        + "login-new"),
                        fixture.browser()
                                .cookieHeaderValues(
                                        "/api/me")),
                () -> assertEquals(
                        200,
                        current.getResponse().getStatus()));
    }

    @Test
    void 동시에_같은_token을_rotate하면_정확히_하나만_성공한다()
            throws Exception {
        var fixture = fixture(
                "concurrent-old",
                "concurrent-new-a",
                "concurrent-new-b");
        fixture.store().create(MEMBER);
        var ready = new CountDownLatch(2);
        var start = new CountDownLatch(1);
        var pool = Executors.newFixedThreadPool(2);
        Callable<Optional<IssuedSession>> task = () -> {
            ready.countDown();
            if (!start.await(5, SECONDS)) {
                throw new IllegalStateException(
                        "rotate start timeout");
            }
            return fixture.store().rotate(
                    "concurrent-old");
        };

        try {
            var first = pool.submit(task);
            var second = pool.submit(task);
            assertTrue(ready.await(5, SECONDS));
            start.countDown();
            var outcomes = List.of(
                    first.get(5, SECONDS),
                    second.get(5, SECONDS));
            var winner = outcomes.stream()
                    .flatMap(Optional::stream)
                    .findFirst()
                    .orElseThrow();

            assertAll(
                    () -> assertEquals(
                            1L,
                            outcomes.stream()
                                    .filter(Optional::isPresent)
                                    .count()),
                    () -> assertTrue(
                            fixture.store()
                                    .findMember(
                                            winner.rawToken())
                                    .isPresent()),
                    () -> assertTrue(
                            fixture.store()
                                    .findMember(
                                            "concurrent-old")
                                    .isEmpty()));
        } finally {
            start.countDown();
            pool.shutdownNow();
        }
    }

    private static MockHttpServletRequestBuilder loginRequest() {
        return post("/api/login")
                .contentType(APPLICATION_JSON)
                .content(LOGIN_JSON);
    }

    private static void assertUnauthorized(
            MvcResult result) {
        assertAll(
                () -> assertEquals(
                        401,
                        result.getResponse().getStatus()),
                () -> assertEquals(
                        SessionController.CHALLENGE,
                        result.getResponse().getHeader(
                                HttpHeaders
                                        .WWW_AUTHENTICATE)),
                () -> assertEquals(
                        "no-store",
                        result.getResponse().getHeader(
                                HttpHeaders.CACHE_CONTROL)));
    }

    private static TestFixture fixture(
            String... tokens) {
        var clock = new MutableClock(START, UTC);
        var store = new InMemorySessionStore(
                clock,
                SESSION_TTL,
                new QueuedTokenIssuer(tokens));
        var cookies = new SessionCookieFactory(
                SESSION_TTL);
        var controller = new SessionController(
                new FixedAuthenticator(),
                store,
                cookies);
        var mvc = MockMvcBuilders
                .standaloneSetup(controller)
                .setMessageConverters(
                        new MappingJackson2HttpMessageConverter())
                .build();
        return new TestFixture(
                mvc,
                clock,
                store,
                new BrowserSession());
    }

    private record TestFixture(
            MockMvc mvc,
            MutableClock clock,
            InMemorySessionStore store,
            BrowserSession browser) {}

    private record LoginRequest(
            String email,
            String password) {}

    private record Member(
            long id,
            String email) {}

    private record MemberResponse(
            long id,
            String email) {
        private static MemberResponse from(
                Member member) {
            return new MemberResponse(
                    member.id(), member.email());
        }
    }

    private record IssuedSession(
            String rawToken,
            Instant expiresAt) {}

    private static final class FixedAuthenticator {
        private Optional<Member> authenticate(
                LoginRequest request) {
            if (request != null
                    && MEMBER.email().equals(
                            request.email())
                    && "correct-horse-battery-staple"
                            .equals(request.password())) {
                return Optional.of(MEMBER);
            }
            return Optional.empty();
        }
    }

    private interface TokenIssuer {
        String next();
    }

    private static final class QueuedTokenIssuer
            implements TokenIssuer {
        private final ArrayDeque<String> tokens;

        private QueuedTokenIssuer(
                String... tokens) {
            this.tokens = new ArrayDeque<>(
                    List.of(tokens));
        }

        @Override
        public synchronized String next() {
            if (tokens.isEmpty()) {
                throw new IllegalStateException(
                        "no deterministic token remains");
            }
            return tokens.removeFirst();
        }
    }

    private static final class StoredSession {
        private final Member member;
        private final Instant expiresAt;
        private boolean revoked;

        private StoredSession(
                Member member,
                Instant expiresAt) {
            this.member = member;
            this.expiresAt = expiresAt;
        }
    }

    private static final class InMemorySessionStore {
        private final Map<String, StoredSession>
                sessionsByDigest = new HashMap<>();
        private final Clock clock;
        private final Duration ttl;
        private final TokenIssuer tokens;

        private InMemorySessionStore(
                Clock clock,
                Duration ttl,
                TokenIssuer tokens) {
            this.clock = clock;
            this.ttl = ttl;
            this.tokens = tokens;
        }

        private synchronized IssuedSession create(
                Member member) {
            var rawToken = tokens.next();
            var key = digest(rawToken);
            ensureUnused(key);
            var expiresAt = clock.instant().plus(ttl);

            sessionsByDigest.values().stream()
                    .filter(session ->
                            session.member.id()
                                    == member.id())
                    .forEach(session ->
                            session.revoked = true);
            sessionsByDigest.put(
                    key,
                    new StoredSession(
                            member, expiresAt));
            return new IssuedSession(
                    rawToken, expiresAt);
        }

        private synchronized Optional<Member> findMember(
                String rawToken) {
            return Optional.ofNullable(
                            active(rawToken))
                    .map(session -> session.member);
        }

        private synchronized void revoke(
                String rawToken) {
            if (rawToken == null
                    || rawToken.isBlank()) {
                return;
            }
            var session = sessionsByDigest.get(
                    digest(rawToken));
            if (session != null) {
                session.revoked = true;
            }
        }

        private synchronized Optional<IssuedSession> rotate(
                String oldRawToken) {
            var current = active(oldRawToken);
            if (current == null) {
                return Optional.empty();
            }

            var newRawToken = tokens.next();
            var newKey = digest(newRawToken);
            ensureUnused(newKey);
            var expiresAt = clock.instant().plus(ttl);

            current.revoked = true;
            sessionsByDigest.put(
                    newKey,
                    new StoredSession(
                            current.member,
                            expiresAt));
            return Optional.of(
                    new IssuedSession(
                            newRawToken,
                            expiresAt));
        }

        private StoredSession active(
                String rawToken) {
            if (rawToken == null
                    || rawToken.isBlank()) {
                return null;
            }
            var session = sessionsByDigest.get(
                    digest(rawToken));
            if (session == null || session.revoked) {
                return null;
            }
            if (!clock.instant().isBefore(
                    session.expiresAt)) {
                session.revoked = true;
                return null;
            }
            return session;
        }

        private void ensureUnused(String key) {
            if (sessionsByDigest.containsKey(key)) {
                throw new IllegalStateException(
                        "token digest collision");
            }
        }

        private synchronized boolean
                containsRawStorageKey(String rawToken) {
            return sessionsByDigest.containsKey(
                    rawToken);
        }

        private synchronized boolean containsDigestFor(
                String rawToken) {
            return sessionsByDigest.containsKey(
                    digest(rawToken));
        }

        private static String digest(String rawToken) {
            try {
                var sha256 = MessageDigest.getInstance(
                        "SHA-256");
                return Base64.getUrlEncoder()
                        .withoutPadding()
                        .encodeToString(
                                sha256.digest(
                                        rawToken.getBytes(
                                                UTF_8)));
            } catch (NoSuchAlgorithmException exception) {
                throw new IllegalStateException(
                        "SHA-256 is required",
                        exception);
            }
        }
    }

    private static final class SessionCookieFactory {
        private static final String NAME =
                "__Host-board-session";
        private static final DateTimeFormatter COOKIE_DATE =
                DateTimeFormatter.ofPattern(
                                "EEE, dd MMM uuuu "
                                        + "HH:mm:ss 'GMT'",
                                Locale.US)
                        .withZone(UTC);
        private final Duration ttl;

        private SessionCookieFactory(Duration ttl) {
            this.ttl = ttl;
        }

        private String issued(IssuedSession session) {
            var cookie = ResponseCookie.from(
                            NAME, session.rawToken())
                    .path("/")
                    .secure(true)
                    .httpOnly(true)
                    .sameSite("Lax")
                    .build();
            return withExpires(
                    cookie + "; Max-Age=" + ttl.toSeconds(),
                    session.expiresAt());
        }

        private String deleted() {
            var cookie = ResponseCookie.from(NAME, "")
                    .path("/")
                    .secure(true)
                    .httpOnly(true)
                    .sameSite("Lax")
                    .build();
            return withExpires(cookie.toString(), Instant.EPOCH);
        }

        private static String withExpires(
                String cookie,
                Instant expiresAt) {
            return cookie
                    + "; Expires="
                    + COOKIE_DATE.format(expiresAt);
        }
    }

    @RestController
    @RequestMapping("/api")
    private static final class SessionController {
        private static final String CHALLENGE =
                "Session realm=\"board\"";
        private final FixedAuthenticator authenticator;
        private final InMemorySessionStore sessions;
        private final SessionCookieFactory cookies;

        private SessionController(
                FixedAuthenticator authenticator,
                InMemorySessionStore sessions,
                SessionCookieFactory cookies) {
            this.authenticator = authenticator;
            this.sessions = sessions;
            this.cookies = cookies;
        }

        @PostMapping(
                path = "/login",
                consumes = "application/json")
        private ResponseEntity<Void> login(
                @RequestBody LoginRequest request) {
            var member = authenticator.authenticate(
                    request);
            if (member.isEmpty()) {
                return unauthorized();
            }
            var session = sessions.create(
                    member.orElseThrow());
            return ResponseEntity.noContent()
                    .header(
                            HttpHeaders.SET_COOKIE,
                            cookies.issued(session))
                    .header(
                            HttpHeaders.CACHE_CONTROL,
                            "no-store")
                    .build();
        }

        @GetMapping("/me")
        private ResponseEntity<MemberResponse> me(
                @CookieValue(
                                name = SessionCookieFactory.NAME,
                                required = false)
                        String rawToken) {
            return sessions.findMember(rawToken)
                    .map(MemberResponse::from)
                    .map(member ->
                            ResponseEntity.ok()
                                    .header(
                                            HttpHeaders
                                                    .CACHE_CONTROL,
                                            "no-store")
                                    .body(member))
                    .orElseGet(
                            SessionController
                                    ::unauthorized);
        }

        @PostMapping("/logout")
        private ResponseEntity<Void> logout(
                @CookieValue(
                                name = SessionCookieFactory.NAME,
                                required = false)
                        String rawToken) {
            sessions.revoke(rawToken);
            return ResponseEntity.noContent()
                    .header(
                            HttpHeaders.SET_COOKIE,
                            cookies.deleted())
                    .header(
                            HttpHeaders.CACHE_CONTROL,
                            "no-store")
                    .build();
        }

        @PostMapping("/session/rotate")
        private ResponseEntity<Void> rotate(
                @CookieValue(
                                name = SessionCookieFactory.NAME,
                                required = false)
                        String rawToken) {
            return sessions.rotate(rawToken)
                    .map(session ->
                            ResponseEntity.noContent()
                                    .header(
                                            HttpHeaders.SET_COOKIE,
                                            cookies.issued(
                                                    session))
                                    .header(
                                            HttpHeaders
                                                    .CACHE_CONTROL,
                                            "no-store")
                                    .<Void>build())
                    .orElseGet(
                            SessionController
                                    ::unauthorized);
        }

        private static <T> ResponseEntity<T>
                unauthorized() {
            return ResponseEntity.status(UNAUTHORIZED)
                    .header(
                            HttpHeaders.WWW_AUTHENTICATE,
                            CHALLENGE)
                    .header(
                            HttpHeaders.CACHE_CONTROL,
                            "no-store")
                    .build();
        }
    }

    private static final class BrowserSession {
        private static final URI ORIGIN =
                URI.create("https://board.example");
        private final CookieManager cookies =
                new CookieManager(
                        null, CookiePolicy.ACCEPT_ALL);

        private MvcResult perform(
                MockMvc mvc,
                String path,
                MockHttpServletRequestBuilder request)
                throws Exception {
            var uri = ORIGIN.resolve(path);
            attachRequestCookies(uri, request);
            var result = mvc.perform(
                            request.secure(true)
                                    .header(
                                            HttpHeaders.HOST,
                                            uri.getHost()))
                    .andReturn();
            acceptEverySetCookie(uri, result);
            return result;
        }

        private List<String> cookieHeaderValues(
                String path) throws Exception {
            var values = new ArrayList<String>();
            cookies.get(
                            ORIGIN.resolve(path),
                            Map.of())
                    .forEach((name, lines) -> {
                        if (name != null
                                && name.equalsIgnoreCase(
                                        HttpHeaders.COOKIE)) {
                            values.addAll(lines);
                        }
                    });
            return List.copyOf(values);
        }

        private void attachRequestCookies(
                URI uri,
                MockHttpServletRequestBuilder request)
                throws Exception {
            cookies.get(uri, Map.of())
                    .forEach((name, lines) -> {
                        if (name == null
                                || !name.equalsIgnoreCase(
                                        HttpHeaders.COOKIE)) {
                            return;
                        }
                        lines.forEach(line ->
                                addCookieLine(request, line));
                    });
        }

        private void acceptEverySetCookie(
                URI uri,
                MvcResult result) throws Exception {
            var fields = result.getResponse()
                    .getHeaders(HttpHeaders.SET_COOKIE);
            if (!fields.isEmpty()) {
                cookies.put(
                        uri,
                        Map.of(
                                HttpHeaders.SET_COOKIE,
                                List.copyOf(fields)));
            }
        }

        private static void addCookieLine(
                MockHttpServletRequestBuilder request,
                String line) {
            for (var pair : line.split(";\\s*")) {
                var equals = pair.indexOf('=');
                if (equals <= 0
                        || pair.startsWith("$")) {
                    continue;
                }
                request.cookie(new Cookie(
                        pair.substring(0, equals),
                        pair.substring(equals + 1)));
            }
        }
    }

    private static final class MutableClock extends Clock {
        private Instant current;
        private final ZoneId zone;

        private MutableClock(
                Instant current,
                ZoneId zone) {
            this.current = current;
            this.zone = zone;
        }

        private synchronized void advance(
                Duration duration) {
            current = current.plus(duration);
        }

        @Override
        public ZoneId getZone() {
            return zone;
        }

        @Override
        public Clock withZone(ZoneId requestedZone) {
            return new MutableClock(
                    instant(), requestedZone);
        }

        @Override
        public synchronized Instant instant() {
            return current;
        }
    }
}

첫 테스트는 발급한 필드를 세미콜론 단위로 나눈 구성 요소 전체를 집합으로 비교합니다. 이름이 정확히 __Host-board-session인지, Path=/, Max-Age=1800, Expires, Secure, HttpOnly, SameSite=Lax가 있는지뿐 아니라, Domain이나 의도하지 않은 속성이 끼어들지 않았는지도 잡아냅니다. Max-Age와 Expires가 함께 있으면 Max-Age가 우선한다는 규칙은 6265bis-22 §4.1.2.2를, __Host-에 꼭 필요한 조합은 §4.1.3.2를 따릅니다. 로그인과 모든 401 응답에는 Cache-Control: no-store를 두고, 401은 애플리케이션이 정한 WWW-Authenticate: Session realm="board" 인증 요구를 돌려줍니다. 이 의무는 RFC 9110 §15.5.2를 기준으로 합니다.


발급에서 폐기까지의 수명 주기

로그인은 고정된 30분의 서버 유효 기간(TTL)과 같은 Max-Age, Expires로 쿠키를 발급합니다. 이때 Max-Age를 ResponseCookie의 maxAge로 정하지 않고 문자열로 직접 붙이는 것은, Spring 6.2의 ResponseCookie가 maxAge를 받으면 테스트의 서버 시계가 아니라 실행 순간의 시스템 시각으로 계산한 Expires를 함께 써서 한 필드에 Expires가 두 번 들어가게 되기 때문입니다. 저장소의 create는 같은 회원의 기존 세션을 폐기한 뒤 새 다이제스트 키를 넣으므로, 이 예제의 정책은 회원당 활성 세션 하나입니다. 조회는 쿠키가 없거나, 그 다이제스트 키가 없거나, 폐기되었거나, 서버의 시계가 expiresAt에 닿았으면 모두 401을 돌려줍니다. 브라우저의 쿠키 수명과 별개로 서버가 유효 기간을 다시 확인하므로, 누군가 복사한 원시 토큰도 만료 뒤에는 쓸 수 없습니다.

로그아웃은 원시 토큰이 없을 때도 안전하게 204를 돌려줍니다. 토큰이 있으면 서버의 상태를 폐기하고, 같은 이름과 경로에 과거의 Expires를 둔 삭제용 쿠키를 돌려줍니다. 6265bis-22 §4.1.1의 서버 쪽 문법은 Max-Age에 양수만 허용하므로, 이 예제는 관용적으로 처리되는 Max-Age=0을 만들지 않고 §4.1.2의 과거 Expires로 교체하는 방식을 씁니다. 브라우저의 쿠키 저장소에서 쿠키가 사라지는 것과, 공격자가 복사해 둔 토큰이 서버에서 효력을 잃는 것은 따로 확인해야 하는 일입니다.

세션 교체(rotation)는 활성인 이전 세션을 폐기하고 새 토큰을 발급하는 일을 하나의 synchronized 구간에서 합니다. 같은 이전 토큰으로 두 스레드가 동시에 rotate해도 첫 호출만 새 세션을 만들고, 두 번째 호출은 이미 폐기된 상태를 보고 실패합니다. 토큰 발급과 다이제스트의 중복 검사까지 성공한 뒤에 상태를 바꾸므로, 발급이 실패해도 기존 세션이 먼저 끊기지 않습니다. 재로그인도 같은 구간에서 기존 세션을 폐기하며, 이것은 로그인 뒤 세션 식별자를 바꾸어 세션 고정 공격을 줄이라는 6265bis-22 §8.4의 권고와도 이어집니다.

쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다

쿠키 부재·저장·같은 scope 교체·삭제/만료의 client 상태를 보여 주되 서버 세션 계약과 구별한다.

브라우저 쿠키는 없음과 저장됨 사이를 오가며 재로그인과 session 회전의 성공 응답 모두 같은 scope의 cookie를 교체할 수 있지만, server에서는 재로그인의 create(member)가 같은 member의 prior sessions를 폐기하고 새 session을 발급하며 별도 session rotate endpoint의 rotate(old)는 active old 하나만 원자적으로 교체합니다.

CLIENT COOKIE · SERVER SESSION · ATOMIC ROTATION

쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다

같은 client cookie 교체 결과가 server의 같은 사건을 뜻하지는 않습니다. 재로그인은 create(member)로 같은 member의 prior sessions를 폐기한 뒤 새 session을 발급하고, 별도 POST /api/session/rotate는 active old 하나를 rotate(old)로 교체합니다.

클라이언트 쿠키의 상태 전이client 쿠키의 두 상태입니다. 로그인 발급은 ABSENT에서 STORED로, 삭제 쿠키나 보관 만료는 제거로 이어집니다. 같은 이름·scope의 교체는 STORED를 유지하며 값만 바꿉니다. 재로그인과 서버 rotate의 성공 응답은 같은 쿠키 교체 결과를 만들 수 있지만, 서버에서는 각각의 계약으로 처리합니다. 브라우저가 조기에 제거하는 경우도 client 상태의 판단에 포함합니다.로그인 발급삭제·보관 만료같은 scope 교체COOKIE ABSENT해당 저장값 없음COOKIE STORED이름·scope의 원시 token
서버 회전은 old 폐기와 new 발급을 함께 확정한다POST /api/session/rotate는 old가 ACTIVE일 때만 회전을 시도합니다. 새 토큰 발급까지 성공한 정상 확정은 old 폐기와 별개 new ACTIVE 기록을 같은 임계 구역에서 만듭니다. 새 토큰 발급 실패의 예외 경로는 원 본문에 따르며, 그때는 기존 old를 먼저 폐기하지 않습니다. 무효한 old는 401로 거절합니다. create(member)가 회원의 prior 집합을 폐기하는 재로그인 흐름과 구별하며, old가 ACTIVE로 돌아오는 전이는 없습니다.활성무효요청의 old tokendigest로 기록 조회old가 ACTIVE인가?만료·폐기 확인원자적 회전 확정새 token 발급 성공 후같은 임계 구역old · REVOKED이전 token은401new · ACTIVE다른 새 token 발급401 · 회전 거절새 session을 발급하지 않음
클라이언트 쿠키의 상태 전이client 쿠키의 두 상태입니다. 로그인 발급은 ABSENT에서 STORED로, 삭제 쿠키나 보관 만료는 제거로 이어집니다. 같은 이름·scope의 교체는 STORED를 유지하며 값만 바꿉니다. 재로그인과 서버 rotate의 성공 응답은 같은 쿠키 교체 결과를 만들 수 있지만, 서버에서는 각각의 계약으로 처리합니다. 브라우저가 조기에 제거하는 경우도 client 상태의 판단에 포함합니다.발급제거교체COOKIE ABSENT저장된 토큰 없음COOKIE STORED이름·scope의 토큰
서버 회전은 old 폐기와 new 발급을 함께 확정한다POST /api/session/rotate는 old가 ACTIVE일 때만 회전을 시도합니다. 새 토큰 발급까지 성공한 정상 확정은 old 폐기와 별개 new ACTIVE 기록을 같은 임계 구역에서 만듭니다. 새 토큰 발급 실패의 예외 경로는 원 본문에 따르며, 그때는 기존 old를 먼저 폐기하지 않습니다. 무효한 old는 401로 거절합니다. create(member)가 회원의 prior 집합을 폐기하는 재로그인 흐름과 구별하며, old가 ACTIVE로 돌아오는 전이는 없습니다.활성무효요청의 old tokendigest 기록 조회old가 ACTIVE?만료·폐기 확인원자적 회전발급 성공 뒤 확정같은 임계 구역old · REVOKED이전 token은 401new · ACTIVE다른 새 token 발급401 · 회전 거절새 session 발급 안 함

01 · CLIENT · ISSUE AND REPLACE

  1. 로그인 성공은 Set-Cookie와 Max-Age=1800으로 COOKIE ABSENT → STORED를 만듭니다.
  2. 재로그인의 create(member) 성공과 별도 POST /api/session/rotate 성공은 모두 같은 이름·scope의 Set-Cookie로 저장값을 교체할 수 있습니다.
  3. 이 공통 client 결과가 두 server transition의 trigger와 affected set까지 같다는 뜻은 아닙니다.

02 · INDEPENDENT EXPIRY

Client 보관 수명과 server 유효 기한은 각각 판정한다

  • 브라우저의 cookie 보관이 끝나도 server record는 별도 정책에 따라 남아 있을 수 있습니다.
  • 서버의 expiresAt에 도달하거나 session이 폐기되면 해당 token의 /api/me는 401입니다.
  • 따라서 client의 STORED와 server의 ACTIVE를 하나의 상태로 합치지 않습니다.

03 · SERVER · 회원당 활성 세션 하나

재로그인과 session 회전은 서로 다른 precondition과 affected set을 가진다

  1. RELOGIN: credential 인증이 성공하면 create(member)가 같은 member의 prior sessions를 폐기하고 새 session 하나를 발급합니다.
  2. ROTATE: 별도 POST /api/session/rotate는 제시된 old token 하나가 ACTIVE일 때만 rotate(old)로 그 old만 폐기하고 새 session 하나를 원자적으로 발급합니다.
  3. 두 성공 응답은 같은-scope cookie를 교체할 수 있지만 server 사건은 합치지 않으며, 폐기된 token은 401, 새 token은 200입니다.

04 · LOGOUT · TWO EFFECTS

POST /api/logout은 server 폐기와 client 제거를 모두 수행한다

  • Server에서는 현재 token을 revoke(hash)하고, 응답에는 같은 이름·scope와 과거 Expires를 가진 삭제용 Set-Cookie를 넣습니다.
  • 두 효과는 한 use case에 연결되지만 COOKIE ABSENT와 SESSION REVOKED가 같은 상태라는 뜻은 아닙니다.
  • 해당 old token은 ACTIVE로 돌아오지 않습니다. 로그아웃을 반복해도 server는 REVOKED에 머물고 204를 반환하며 삭제 cookie를 다시 보낼 수 있습니다.

Cookie의 SAME-SCOPE REPLACE는 재로그인과 session 회전 성공이 공유할 수 있는 client 결과일 뿐입니다. Server에서는 create(member)가 same-member prior set을, rotate(old)가 active old 하나를 각각 다루며, 로그아웃은 STORED를 ABSENT로 되돌리면서 session을 별도로 폐기합니다. EXPIRED·REVOKED token에 보내는 401에는 WWW-Authenticate challenge를 포함합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • client 쿠키의 두 상태입니다. 로그인 발급은 ABSENT에서 STORED로, 삭제 쿠키나 보관 만료는 제거로 이어집니다. 같은 이름·scope의 교체는 STORED를 유지하며 값만 바꿉니다. 재로그인과 서버 rotate의 성공 응답은 같은 쿠키 교체 결과를 만들 수 있지만, 서버에서는 각각의 계약으로 처리합니다. 브라우저가 조기에 제거하는 경우도 client 상태의 판단에 포함합니다.
  • POST /api/session/rotate는 old가 ACTIVE일 때만 회전을 시도합니다. 새 토큰 발급까지 성공한 정상 확정은 old 폐기와 별개 new ACTIVE 기록을 같은 임계 구역에서 만듭니다. 새 토큰 발급 실패의 예외 경로는 원 본문에 따르며, 그때는 기존 old를 먼저 폐기하지 않습니다. 무효한 old는 401로 거절합니다. create(member)가 회원의 prior 집합을 폐기하는 재로그인 흐름과 구별하며, old가 ACTIVE로 돌아오는 전이는 없습니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
84두 성공 응답은 같은 scope의 cookie를 교체할 수 있지만 server 사건은 합치지 않으며, 폐기된 token은
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
쿠키 없는 상태

해당 이름과 scope의 client 쿠키가 없습니다.

client 상태: ABSENT · 서버 세션: 별도 계약쿠키 발급 전 출발 상태를 고정합니다.
발급 쿠키 보관

로그인 발급 응답을 수락하면 같은 범위의 쿠키를 보관합니다.

client 상태: STORED · 동작: Set-Cookie 수락client 상태가 ABSENT에서 STORED로 바뀝니다.
같은 범위 값 교체

같은 이름·scope의 새 쿠키가 오면 보관 상태를 유지하며 값만 바뀝니다.

client 상태: STORED · 쿠키 값: 교체 · 서버 처리: 재로그인·rotate 구분보관 여부는 같지만 client 값이 교체됩니다.
삭제 또는 보관 만료

삭제 쿠키나 client 보관 만료로 해당 쿠키가 제거됩니다.

client 상태: ABSENT · 종료 원인: 삭제·보관 만료다음 요청에 보낼 해당 쿠키가 없어집니다.

쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다

쿠키 부재·저장·같은 scope 교체·삭제/만료의 client 상태를 보여 주되 서버 세션 계약과 구별한다.

브라우저 쿠키는 없음과 저장됨 사이를 오가며 재로그인과 session 회전의 성공 응답 모두 같은 scope의 cookie를 교체할 수 있지만, server에서는 재로그인의 create(member)가 같은 member의 prior sessions를 폐기하고 새 session을 발급하며 별도 session rotate endpoint의 rotate(old)는 active old 하나만 원자적으로 교체합니다.

CLIENT COOKIE · SERVER SESSION · ATOMIC ROTATION

쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다

같은 client cookie 교체 결과가 server의 같은 사건을 뜻하지는 않습니다. 재로그인은 create(member)로 같은 member의 prior sessions를 폐기한 뒤 새 session을 발급하고, 별도 POST /api/session/rotate는 active old 하나를 rotate(old)로 교체합니다.

클라이언트 쿠키의 상태 전이client 쿠키의 두 상태입니다. 로그인 발급은 ABSENT에서 STORED로, 삭제 쿠키나 보관 만료는 제거로 이어집니다. 같은 이름·scope의 교체는 STORED를 유지하며 값만 바꿉니다. 재로그인과 서버 rotate의 성공 응답은 같은 쿠키 교체 결과를 만들 수 있지만, 서버에서는 각각의 계약으로 처리합니다. 브라우저가 조기에 제거하는 경우도 client 상태의 판단에 포함합니다.로그인 발급삭제·보관 만료같은 scope 교체COOKIE ABSENT해당 저장값 없음COOKIE STORED이름·scope의 원시 token
서버 회전은 old 폐기와 new 발급을 함께 확정한다POST /api/session/rotate는 old가 ACTIVE일 때만 회전을 시도합니다. 새 토큰 발급까지 성공한 정상 확정은 old 폐기와 별개 new ACTIVE 기록을 같은 임계 구역에서 만듭니다. 새 토큰 발급 실패의 예외 경로는 원 본문에 따르며, 그때는 기존 old를 먼저 폐기하지 않습니다. 무효한 old는 401로 거절합니다. create(member)가 회원의 prior 집합을 폐기하는 재로그인 흐름과 구별하며, old가 ACTIVE로 돌아오는 전이는 없습니다.활성무효요청의 old tokendigest로 기록 조회old가 ACTIVE인가?만료·폐기 확인원자적 회전 확정새 token 발급 성공 후같은 임계 구역old · REVOKED이전 token은401new · ACTIVE다른 새 token 발급401 · 회전 거절새 session을 발급하지 않음
클라이언트 쿠키의 상태 전이client 쿠키의 두 상태입니다. 로그인 발급은 ABSENT에서 STORED로, 삭제 쿠키나 보관 만료는 제거로 이어집니다. 같은 이름·scope의 교체는 STORED를 유지하며 값만 바꿉니다. 재로그인과 서버 rotate의 성공 응답은 같은 쿠키 교체 결과를 만들 수 있지만, 서버에서는 각각의 계약으로 처리합니다. 브라우저가 조기에 제거하는 경우도 client 상태의 판단에 포함합니다.발급제거교체COOKIE ABSENT저장된 토큰 없음COOKIE STORED이름·scope의 토큰
서버 회전은 old 폐기와 new 발급을 함께 확정한다POST /api/session/rotate는 old가 ACTIVE일 때만 회전을 시도합니다. 새 토큰 발급까지 성공한 정상 확정은 old 폐기와 별개 new ACTIVE 기록을 같은 임계 구역에서 만듭니다. 새 토큰 발급 실패의 예외 경로는 원 본문에 따르며, 그때는 기존 old를 먼저 폐기하지 않습니다. 무효한 old는 401로 거절합니다. create(member)가 회원의 prior 집합을 폐기하는 재로그인 흐름과 구별하며, old가 ACTIVE로 돌아오는 전이는 없습니다.활성무효요청의 old tokendigest 기록 조회old가 ACTIVE?만료·폐기 확인원자적 회전발급 성공 뒤 확정같은 임계 구역old · REVOKED이전 token은 401new · ACTIVE다른 새 token 발급401 · 회전 거절새 session 발급 안 함

01 · CLIENT · ISSUE AND REPLACE

  1. 로그인 성공은 Set-Cookie와 Max-Age=1800으로 COOKIE ABSENT → STORED를 만듭니다.
  2. 재로그인의 create(member) 성공과 별도 POST /api/session/rotate 성공은 모두 같은 이름·scope의 Set-Cookie로 저장값을 교체할 수 있습니다.
  3. 이 공통 client 결과가 두 server transition의 trigger와 affected set까지 같다는 뜻은 아닙니다.

02 · INDEPENDENT EXPIRY

Client 보관 수명과 server 유효 기한은 각각 판정한다

  • 브라우저의 cookie 보관이 끝나도 server record는 별도 정책에 따라 남아 있을 수 있습니다.
  • 서버의 expiresAt에 도달하거나 session이 폐기되면 해당 token의 /api/me는 401입니다.
  • 따라서 client의 STORED와 server의 ACTIVE를 하나의 상태로 합치지 않습니다.

03 · SERVER · 회원당 활성 세션 하나

재로그인과 session 회전은 서로 다른 precondition과 affected set을 가진다

  1. RELOGIN: credential 인증이 성공하면 create(member)가 같은 member의 prior sessions를 폐기하고 새 session 하나를 발급합니다.
  2. ROTATE: 별도 POST /api/session/rotate는 제시된 old token 하나가 ACTIVE일 때만 rotate(old)로 그 old만 폐기하고 새 session 하나를 원자적으로 발급합니다.
  3. 두 성공 응답은 같은-scope cookie를 교체할 수 있지만 server 사건은 합치지 않으며, 폐기된 token은 401, 새 token은 200입니다.

04 · LOGOUT · TWO EFFECTS

POST /api/logout은 server 폐기와 client 제거를 모두 수행한다

  • Server에서는 현재 token을 revoke(hash)하고, 응답에는 같은 이름·scope와 과거 Expires를 가진 삭제용 Set-Cookie를 넣습니다.
  • 두 효과는 한 use case에 연결되지만 COOKIE ABSENT와 SESSION REVOKED가 같은 상태라는 뜻은 아닙니다.
  • 해당 old token은 ACTIVE로 돌아오지 않습니다. 로그아웃을 반복해도 server는 REVOKED에 머물고 204를 반환하며 삭제 cookie를 다시 보낼 수 있습니다.

Cookie의 SAME-SCOPE REPLACE는 재로그인과 session 회전 성공이 공유할 수 있는 client 결과일 뿐입니다. Server에서는 create(member)가 same-member prior set을, rotate(old)가 active old 하나를 각각 다루며, 로그아웃은 STORED를 ABSENT로 되돌리면서 session을 별도로 폐기합니다. EXPIRED·REVOKED token에 보내는 401에는 WWW-Authenticate challenge를 포함합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • client 쿠키의 두 상태입니다. 로그인 발급은 ABSENT에서 STORED로, 삭제 쿠키나 보관 만료는 제거로 이어집니다. 같은 이름·scope의 교체는 STORED를 유지하며 값만 바꿉니다. 재로그인과 서버 rotate의 성공 응답은 같은 쿠키 교체 결과를 만들 수 있지만, 서버에서는 각각의 계약으로 처리합니다. 브라우저가 조기에 제거하는 경우도 client 상태의 판단에 포함합니다.
  • POST /api/session/rotate는 old가 ACTIVE일 때만 회전을 시도합니다. 새 토큰 발급까지 성공한 정상 확정은 old 폐기와 별개 new ACTIVE 기록을 같은 임계 구역에서 만듭니다. 새 토큰 발급 실패의 예외 경로는 원 본문에 따르며, 그때는 기존 old를 먼저 폐기하지 않습니다. 무효한 old는 401로 거절합니다. create(member)가 회원의 prior 집합을 폐기하는 재로그인 흐름과 구별하며, old가 ACTIVE로 돌아오는 전이는 없습니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
84두 성공 응답은 같은 scope의 cookie를 교체할 수 있지만 server 사건은 합치지 않으며, 폐기된 token은
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
쿠키 없는 상태

해당 이름과 scope의 client 쿠키가 없습니다.

client 상태: ABSENT · 서버 세션: 별도 계약쿠키 발급 전 출발 상태를 고정합니다.
발급 쿠키 보관

로그인 발급 응답을 수락하면 같은 범위의 쿠키를 보관합니다.

client 상태: STORED · 동작: Set-Cookie 수락client 상태가 ABSENT에서 STORED로 바뀝니다.
같은 범위 값 교체

같은 이름·scope의 새 쿠키가 오면 보관 상태를 유지하며 값만 바뀝니다.

client 상태: STORED · 쿠키 값: 교체 · 서버 처리: 재로그인·rotate 구분보관 여부는 같지만 client 값이 교체됩니다.
삭제 또는 보관 만료

삭제 쿠키나 client 보관 만료로 해당 쿠키가 제거됩니다.

client 상태: ABSENT · 종료 원인: 삭제·보관 만료다음 요청에 보낼 해당 쿠키가 없어집니다.

그림은 브라우저의 쿠키와 서버의 세션이 따로 움직인다는 것을 보여 줍니다. 쿠키는 로그인 발급으로 없음에서 저장됨이 되고, 같은 범위의 새 쿠키가 오면 값만 바뀌며, 삭제 쿠키나 보관 만료로 사라집니다. 재로그인과 세션 교체의 성공 응답은 쿠키에서는 똑같이 값을 바꾸지만, 서버에서는 재로그인의 create(member)가 같은 회원의 이전 세션을 모두 폐기하는 반면 rotate(old)는 활성인 이전 세션 하나만 교체합니다. 로그아웃은 서버의 폐기와 브라우저의 삭제를 함께 하지만, 두 결과가 같은 상태라는 뜻은 아닙니다.


이 테스트가 증명하는 범위

이 단위는 독립형 MockMvc에서 몇 가지를 확인합니다. 컨트롤러가 올바른 상태 코드와 각각의 Set-Cookie, Cache-Control, WWW-Authenticate 헤더를 만드는지, 같은 저장소가 로그인과 조회, 로그아웃, 교체에 쓰이며 원시 토큰 대신 다이제스트 키로 찾는지 확인합니다. 또 CookieManager가 HTTPS URI에 저장한 쿠키로 다음 요청을 만들고 삭제 응답을 반영하는지, 바꿀 수 있는 서버 시계와 미리 정한 토큰 큐로 만료와 재로그인, 동시 교체를 몇 번이든 같은 결과로 재현할 수 있는지 확인합니다. 다만 MockMvc의 가짜 응답은 Set-Cookie를 MockCookie로 해석한 뒤 다시 써서 보관하므로, 테스트가 읽는 필드는 컨트롤러가 만든 원래 문자열과 다를 수 있고 이름이 같은 속성이 두 번 있어도 하나로 합쳐 보입니다. 예를 들어 컨트롤러는 Expires의 일자를 01처럼 두 자리로 보내지만, MockCookie가 DateTimeFormatter.RFC_1123_DATE_TIME으로 다시 쓰므로 테스트는 Tue, 1 Jan 2030처럼 한 자리 일자를 읽습니다.

MockMvc의 secure(true)는 실제 TLS 연결이 아니고 java.net.CookieManager도 실제 브라우저 엔진이 아닙니다. 그러니 이 테스트는 인증서나 프록시의 HTTPS 설정, 브라우저가 __Host-와 SameSite를 실제로 지키는지, 실제 XSS 방어를 확인하지 않습니다. 특히 Secure 쿠키를 고르는 조건은 6265bis-22 §5.8.3에, Lax의 적용은 §5.6.7.1에 정의된 사용자 에이전트의 동작이므로, 실제 브라우저로 HTTPS 전체 경로를 시험해야 합니다. 독립형 설정에는 Spring Security 필터 체인도 없습니다. SameSite=Lax가 모든 CSRF(사이트 간 요청 위조)를 막아 주지는 않으므로, 요청의 형태와 신뢰 범위에 맞는 CSRF 토큰과 Origin 검사, 권한 규칙은 보안 통합 테스트에서 확인합니다. 쿠키와 CSRF의 관계는 6265bis-22 §8.8.1과 §8.8.2를 참고합니다.


저장 방식과 이 글의 범위

서버 쪽 세션은 즉시 폐기나 장치 목록, 권한 변경의 반영을 구현하기 쉽지만, 여러 애플리케이션 인스턴스가 같은 저장소를 보도록 설계해야 합니다. 스스로 정보를 담는 서명 토큰은 서버 조회를 줄일 수 있지만, 서명은 암호화가 아니며 만료 전 폐기나 권한 변경의 반영에는 따로 전략이 필요합니다. 브라우저에 큰 데이터나 오래 쓰는 비밀을 넣지 않고, 원시 세션 토큰은 로그나 데이터베이스, 오류 메시지에 남기지 않습니다.

이 글은 브라우저의 쿠키 왕복과 서버 세션의 수명 주기를 다룹니다. 요청의 Accept와 Content-Type 협상은 콘텐츠 협상에서, 공개 응답의 캐시와 ETag, 무효화는 HTTP 캐시와 조건부 요청에서, 로그인 쿠키의 취약점과 서블릿 세션, 필터로 인증을 확인하는 방법은 8장의 쿠키 로그인 취약점부터 다룹니다.


정리

  • 로그인 상태가 이어지는 것은 브라우저가 Set-Cookie를 저장해 다음 요청에 Cookie로 다시 보내기 때문이며, 브라우저의 쿠키와 서버의 세션은 서로 다른 상태입니다.
  • 쿠키에는 추측하기 어려운 원시 토큰만 넣고, 서버는 그 다이제스트로 회원과 만료, 폐기 여부를 찾습니다.
  • __Host- 접두사에는 Secure, Path=/, Domain 없음이 함께 필요하며, HttpOnly와 SameSite=Lax는 노출을 줄이는 한 겹의 방어입니다.
  • 서버는 쿠키의 수명과 별개로 유효 기간을 다시 확인하고, 로그아웃은 서버의 폐기와 삭제 쿠키를 함께 처리합니다.
  • 세션 교체는 새 토큰을 성공적으로 만든 뒤에 이전 세션을 폐기해, 발급 실패가 기존 로그인을 끊지 않게 합니다.

연습 문제

1. 브라우저 보관과 서버 만료

서버가 시각 t0에 토큰 A를 발급하고 expiresAt을 t0 + 30분으로 정했습니다. 서버의 Clock만 옮기고, 요청에는 미리 복사해 둔 A를 직접 넣어 CookieManager의 실제 시간 흐름과 분리합니다. ① t0 + 29분 59초에 A는 아직 폐기되지 않았습니다. ② 정확히 t0 + 30분입니다. ③ 만료 전에 A의 교체가 성공해 B가 발급된 뒤 A와 B를 각각 조회합니다. 각 결과를 판단하세요.

해설 보기

①은 서버 저장소의 활성 기록이고 만료 전이므로 200입니다. ②는 서버 시각이 expiresAt에 닿았으므로 A를 가지고 있어도 401입니다. ③은 교체가 A를 폐기했으므로 A는 401이고, 아직 만료되지 않은 새 B는 200입니다. 쿠키 저장소에 문자열이 남아 있는지와, 서버가 그 문자열을 로그인 상태로 인정하는지는 서로 다른 질문입니다.

2. 장치별 세션과 좁힌 경로

① 지금의 "회원당 활성 세션 하나" 정책을 "장치마다 여러 세션"으로 바꾸세요. 로그인할 때 기존 세션을 자동으로 폐기하지 않고 장치 정보를 저장하며, 따로 둔 "모든 장치에서 로그아웃" 연산이 한 회원의 활성 세션만 원자적으로 폐기하는지 테스트합니다. ② 쿠키의 Path를 /api로 좁힌 변형을 만들고, CookieManager가 /api/me에는 쿠키를 보내지만 /assets/logo.svg에는 보내지 않는지 확인하세요. 원래 __Host- 이름에는 Path=/가 꼭 필요하므로, 이 변형에서는 이름을 __Secure-board-session으로 바꾸고 HTTPS와 Secure, Domain 없음은 그대로 둡니다. 발급, 삭제, @CookieValue, 테스트의 기대 이름도 모두 새 이름으로 맞추고, 원래 코드는 그대로 둔 채 복사한 실험에서만 바꿉니다.

해설 보기

①은 저장소의 키를 계속 토큰 다이제스트로 두되, 회원 식별자로 활성 다이제스트들을 찾는 보조 인덱스를 둘 수 있습니다. 세션 생성과 개별 폐기, 회원 전체 폐기가 두 인덱스를 어긋나게 만들지 않도록 하나의 트랜잭션 범위를 정하고, 다른 회원의 세션이 영향을 받지 않는 반례도 함께 확인합니다.

②는 HTTPS URI의 경로만 바꾸어 쿠키 저장소가 만든 Cookie 필드를 관찰하면 됩니다. 컨트롤러에 닿은 뒤 토큰을 거부하는 테스트가 아니라, 경로 조건이 맞지 않아 쿠키가 처음부터 요청에 실리지 않는 테스트여야 합니다. 경로가 맞는지 판단하는 규칙은 6265bis-22 §4.1.2.4를 기준으로 합니다. 실제로 규칙을 지키는 브라우저는 Path=/api인 __Host- 쿠키를 거절하지만, CookieManager의 경로 선택 테스트가 그런 접두사 규칙까지 확인하는 것은 아닙니다.

3. 새 토큰 발급이 실패하면

독립된 저장소 두 개가 같은 회원의 활성 토큰 A를 가지고 있고 아직 만료 전입니다. 첫 저장소의 토큰 큐는 A를 발급한 뒤 비었고, 둘째 저장소의 큐는 다음 값으로 다시 A를 냅니다. 각각 rotate("A")를 한 번 호출하면 실패한 뒤에도 A는 유효할까요? current.revoked = true를 tokens.next() 앞으로 옮기면 어떤 반례가 생길까요?

해설 보기
다음 발급의 조건실패하는 곳원래 코드에서 A
큐가 비었음tokens.next()의 예외활성 유지
이미 쓴 A가 다시 나옴ensureUnused(newKey)의 예외활성 유지

원래 코드는 활성인지 확인한 뒤 발급과 다이제스트 생성, 중복 검사, 만료 시각 계산을 마치고 나서야 A를 폐기합니다. 두 실패 모두 그 상태 변경 전에 일어나므로 A를 계속 조회할 수 있습니다. 순서를 바꾸면 새 세션도 없고 기존 A도 폐기된 상태가 생깁니다. 확인할 때는 시나리오마다 새 저장소와 고정 시계를 쓰고, 예외가 난 뒤 findMember("A")가 회원을 돌려주는지 봅니다. 미리 정한 큐는 테스트용이며, 실제 발급기는 매번 충분히 예측할 수 없는 새 토큰을 만들어야 합니다. 여러 인스턴스로 옮길 때는 JVM의 synchronized만으로 공유 저장소의 원자성을 지킬 수 없으므로, 기존 상태의 조건 확인과 새 기록의 확정을 공유 저장소의 트랜잭션이나 조건부 변경으로 처리해야 합니다.

4. 교체 성공 응답이 사라지면

브라우저의 쿠키 저장소에는 A가 있고 서버에서도 A가 활성입니다. 서버가 교체를 확정해 A를 폐기하고 B를 발급했지만, Set-Cookie가 담긴 성공 응답은 브라우저에 닿지 않았습니다. 만료나 재로그인, 다른 요청은 없다고 가정합니다. 브라우저가 A로 /api/me를 요청하고 이어서 A로 교체를 다시 시도하면 어떻게 될까요? 편의를 위해 A를 다시 활성으로 되돌려도 될까요?

해설 보기
시점브라우저의 값서버의 A서버의 B
교체 전A활성없음
서버 확정 뒤 응답 유실A폐기활성
A로 조회와 교체 재시도A계속 폐기계속 활성

A의 조회는 인증 실패이고, rotate("A")도 빈 결과를 돌려주어 컨트롤러가 401로 응답합니다. 재시도가 세 번째 세션 C를 만들지는 않습니다. 서버에서 B가 활성이라고 브라우저가 B를 알게 되는 것은 아니며, 쿠키 저장소의 저장과 서버의 교체는 네트워크를 사이에 둔 별개의 단계입니다. 지금의 단순한 규칙에서는 사용자가 다시 인증하게 합니다. 재로그인에 성공하면 create(member)가 B를 포함한 같은 회원의 이전 세션을 폐기하고 새 토큰을 발급합니다. 편의를 위해 A를 되살리면 "폐기된 토큰은 돌아오지 않는다"는 보안 규칙이 깨집니다. 응답을 다시 전해 주거나 유예 시간을 두고 싶다면 새 규칙으로 설계하고, 재전송 공격과 동시 요청, 만료 시점까지 따로 확인해야 합니다.

5. 삭제 쿠키의 경로가 다르면

연습 2의 __Secure-board-session 변형에서 HTTPS와 Secure, Domain 없음을 유지하고 Path=/api로 발급했는데, 로그아웃 응답만 실수로 Path=/의 과거 Expires를 보냈습니다. 서버의 폐기는 성공했다고 가정합니다. 쿠키 저장소에서 기존 /api 쿠키가 지워졌을까요? 이 실수가 토큰의 재사용을 허용한다는 뜻일까요?

해설 보기

다른 경로로 보낸 삭제 필드는 원래 /api의 저장값을 바꾸지 못합니다. 쿠키 저장소에 A가 남아 다음 /api/me에 실릴 수 있지만, 서버가 이미 폐기했다면 A를 인증 상태로 인정하지 않습니다. 반대로 쿠키 저장소에서만 지우고 서버의 폐기를 빠뜨리면 복사된 A가 계속 유효할 수 있으므로, 삭제가 성공했는지는 두 효과를 나누어 확인해야 합니다. 쿠키의 교체와 삭제에서 이름과 도메인, 경로가 맞아야 한다는 조건은 RFC 6265 §4.1.2를 따릅니다. 다음 파일은 잘못된 경로로 삭제한 뒤에는 쿠키가 여전히 전송되고, 올바른 경로로 삭제한 뒤에는 전송되지 않는지를 JDK의 쿠키 저장소로 확인합니다. 토큰은 설명을 위한 고정 문자열이며 인증 저장소와 연결하지 않습니다.

CookieDeletionScopeLab.java
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.URI;
import java.util.List;
import java.util.Map;

public class CookieDeletionScopeLab {
    private static final URI LOGIN =
            URI.create("https://board.example/api/login");
    private static final String NAME = "__Secure-board-session";

    public static void main(String[] args) throws Exception {
        var jar = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
        jar.put(LOGIN, Map.of("Set-Cookie", List.of(
                NAME + "=A; Path=/api; Max-Age=1800; Secure; HttpOnly")));
        require(hasCookie(jar, "/api/me"), "issued cookie missing");
        require(!hasCookie(jar, "/assets/logo.svg"), "wrong request scope");

        jar.put(LOGIN, Map.of("Set-Cookie", List.of(
                NAME + "=; Path=/; Secure; "
                        + "Expires=Thu, 01 Jan 1970 00:00:00 GMT")));
        require(hasCookie(jar, "/api/me"), "different Path removed original");
        System.out.println("afterWrongPathDelete=true");

        jar.put(LOGIN, Map.of("Set-Cookie", List.of(
                NAME + "=; Path=/api; Secure; "
                        + "Expires=Thu, 01 Jan 1970 00:00:00 GMT")));
        require(!hasCookie(jar, "/api/me"), "matching Path failed to delete");
        System.out.println("afterMatchingPathDelete=false");
    }

    private static boolean hasCookie(CookieManager jar, String path)
            throws Exception {
        return jar.get(LOGIN.resolve(path), Map.of()).entrySet().stream()
                .filter(entry -> entry.getKey().equalsIgnoreCase("Cookie"))
                .flatMap(entry -> entry.getValue().stream())
                .anyMatch(line -> line.contains(NAME + "="));
    }

    private static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
}

JDK 17 이상에서 javac CookieDeletionScopeLab.java와 java CookieDeletionScopeLab으로 실행합니다. 이 실험은 쿠키 저장소의 저장과 경로 선택, 삭제만 다루며, 브라우저가 __Secure-나 SameSite, 실제 TLS를 지켰다거나 서버가 A를 폐기했다는 뜻은 아닙니다. 원래의 __Host-board-session은 그대로 Path=/를 유지하고, 이 변형을 쓸 때는 발급과 삭제, 조회의 이름과 경로를 모두 맞춰야 합니다.

다음 글 HTTP 캐시와 조건부 요청에서는 개인화된 응답은 공유 캐시에 저장하지 않으면서 공개 표현은 다시 쓰고, ETag와 조건부 요청으로 본문 전송을 줄이며, 변경 뒤에 캐시를 안전하게 무효화하는 방법을 다룹니다.