쿠키와 상태 관리
브라우저 쿠키 왕복과 서버 세션의 발급·만료·폐기·교체를 하나의 실행 가능한 MockMvc 계약으로 검증합니다.
HTTP 요청은 서로 독립적입니다. 로그인 상태가 이어지는 것처럼 보이는 이유는 사용자 에이전트가 응답의 Set-Cookie를 저장하고, 전송 조건에 맞는 다음 요청의 Cookie를 자동으로 구성하기 때문입니다.
이 경계에는 서로 다른 두 상태가 있습니다.
- 브라우저는 쿠키 이름·값과
Secure,HttpOnly,SameSite,Path, 수명을 관리합니다. - 서버는 불투명 토큰이 가리키는 회원, 만료 시각, 폐기 여부를 관리합니다.
쿠키에는 회원 ID나 권한 목록을 넣지 않습니다. 추측하기 어려운 원시 토큰만 브라우저에 전달하고, 서버 저장소의 키는 원시 토큰의 SHA-256 다이제스트로 만듭니다. 따라서 저장소의 값과 브라우저가 가진 값이 같지 않습니다.
쿠키 발급과 반환의 기본 표준은 RFC 6265입니다. __Host-와 SameSite를 포함한 확장 계약은 고정된 draft-ietf-httpbis-rfc6265bis-22를 참조합니다. 2026-10-01 공식 Datatracker의 상태는 RFC Editor 최종 검토 중인 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의 연속 왕복이다.
SET-COOKIE · COOKIE JAR · DIGEST LOOKUP · SAME BROWSER
원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다
브라우저에는 불투명한 원시 토큰만, 서버 저장에는 그 digest와 로그인 상태만 남깁니다. 같은 브라우저의 다음 요청에서 cookie jar가 범위가 맞는 쿠키를 자동 첨부하고, server는 hash lookup 결과가 활성일 때만 회원을 돌려줍니다.
01 · LOGIN RESPONSE
로그인 결과는 digest 저장과 원시 쿠키 전달로 갈라진다
- 같은 browser가
POST /api/login을 보냅니다. SessionController는digest(token)과 회원·만료·활성 상태를 session store에 저장합니다.204의Set-Cookie로 원시 토큰을 browser에 한 번 전달합니다.
02 · SAME BROWSER
cookie jar가 범위와 수명을 확인하고 다음 요청에 자동 첨부한다
- cookie jar가 허용되는
Set-Cookie를 받아 scope·expiry와 원시 토큰을 보관합니다. - 같은 browser의
GET /api/me에 맞는 쿠키를 선택합니다. Cookie: __Host-board-session=<raw token>을 요청에 첨부합니다.
03 · SERVER LOOKUP
server는 원시 값을 hash한 뒤 활성 session만 회원으로 해석한다
- controller가 받은 원시 토큰을 hash하고 digest로 조회합니다.
- 저장된 session이 활성이라면 회원과
200을 반환합니다. - 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의 연속 왕복이다.
SET-COOKIE · COOKIE JAR · DIGEST LOOKUP · SAME BROWSER
원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다
브라우저에는 불투명한 원시 토큰만, 서버 저장에는 그 digest와 로그인 상태만 남깁니다. 같은 브라우저의 다음 요청에서 cookie jar가 범위가 맞는 쿠키를 자동 첨부하고, server는 hash lookup 결과가 활성일 때만 회원을 돌려줍니다.
01 · LOGIN RESPONSE
로그인 결과는 digest 저장과 원시 쿠키 전달로 갈라진다
- 같은 browser가
POST /api/login을 보냅니다. SessionController는digest(token)과 회원·만료·활성 상태를 session store에 저장합니다.204의Set-Cookie로 원시 토큰을 browser에 한 번 전달합니다.
02 · SAME BROWSER
cookie jar가 범위와 수명을 확인하고 다음 요청에 자동 첨부한다
- cookie jar가 허용되는
Set-Cookie를 받아 scope·expiry와 원시 토큰을 보관합니다. - 같은 browser의
GET /api/me에 맞는 쿠키를 선택합니다. Cookie: __Host-board-session=<raw token>을 요청에 첨부합니다.
03 · SERVER LOOKUP
server는 원시 값을 hash한 뒤 활성 session만 회원으로 해석한다
- controller가 받은 원시 토큰을 hash하고 digest로 조회합니다.
- 저장된 session이 활성이라면 회원과
200을 반환합니다. - 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 | 저장소 상태에 따라 요청의 종료 결과가 갈립니다. |
실행 가능한 쿠키·세션 계약
다음 테스트 파일 하나에 컨트롤러, 쿠키 팩터리, 인증기, 토큰 발급기, 세션 저장소, 브라우저 역할의 쿠키 jar를 모두 정의합니다. 테스트의 일반 왕복 경로는 .cookie()를 직접 호출하지 않습니다. BrowserSession이 HTTPS URI의 모든 Set-Cookie 필드 라인을 java.net.CookieManager에 전달하고, jar가 만든 다음 Cookie 요청을 MockMvc 요청으로 옮깁니다.
공격자가 이미 원시 토큰을 복사했다고 가정하는 위조·재전송 테스트만 의도적으로 Cookie를 직접 주입합니다.
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, d 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("/")
.maxAge(ttl)
.secure(true)
.httpOnly(true)
.sameSite("Lax")
.build();
return withExpires(
cookie, session.expiresAt());
}
private String deleted() {
var cookie = ResponseCookie.from(NAME, "")
.path("/")
.secure(true)
.httpOnly(true)
.sameSite("Lax")
.build();
return withExpires(cookie, Instant.EPOCH);
}
private static String withExpires(
ResponseCookie 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-의 필수 조합은 6265bis-22 초안 §4.1.3.2를 따릅니다.
로그인과 모든 401 응답에는 Cache-Control: no-store를 두고, 401은 애플리케이션이 정의한 WWW-Authenticate: Session realm="board" 챌린지를 반환합니다. 이 챌린지 의무는 RFC 9110 §15.5.2를 기준으로 삼습니다. 이 문서는 실행하지 않은 성공 로그나 테스트 transcript를 제시하지 않습니다. 검증 결과는 실제 테스트 러너가 만든 결과만 근거로 삼습니다.
발급에서 폐기까지의 수명 주기
로그인은 고정된 30분 서버 TTL과 같은 Max-Age·Expires를 발급합니다. 저장소의 create는 같은 회원의 기존 세션을 폐기한 뒤 새 다이제스트 키를 넣으므로 이 예제의 정책은 회원당 활성 세션 하나입니다.
조회는 쿠키가 없거나, 다이제스트 키가 없거나, 폐기됐거나, 서버 시계가 expiresAt에 도달한 경우 모두 401을 반환합니다. 클라이언트 쿠키 수명과 별개로 서버 TTL을 다시 확인하기 때문에 복사된 원시 토큰도 만료 뒤 사용할 수 없습니다.
로그아웃은 원시 토큰이 없을 때도 안전하게 204를 반환합니다. 토큰이 있으면 서버 상태를 폐기하고 같은 이름과 경로에 과거 Expires를 둔 삭제 쿠키를 반환합니다. 이 예제는 6265bis-22 초안 §4.1.1의 서버 문법이 Max-Age에 양의 숫자만 허용하므로 관용적으로 처리되는 Max-Age=0을 생성하지 않고, §4.1.2의 과거 Expires 교체 규칙을 사용합니다. 브라우저 jar에서 쿠키가 사라지는 것과 공격자가 복사해 둔 토큰의 서버 효력이 사라지는 것은 서로 다른 검증 대상입니다.
교체는 활성인 이전 세션을 폐기하고 새 토큰을 발급하는 하나의 synchronized 임계 구역입니다. 같은 이전 토큰으로 두 스레드가 동시에 rotate해도 첫 호출만 새 세션을 만들고 두 번째 호출은 폐기 상태를 보아 실패합니다. 토큰 발급과 다이제스트 중복 검사까지 성공한 뒤 상태를 바꾸므로 발급 실패가 기존 세션을 먼저 끊지 않습니다. 재로그인도 같은 임계 구역에서 기존 세션을 폐기합니다. 이는 로그인 뒤 세션 식별자를 교체해 고정 공격을 줄이라는 6265bis-22 초안 §8.4의 경계와도 맞닿습니다.
쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다
쿠키 부재·저장·같은 scope 교체·삭제/만료의 client 상태를 보여 주되 서버 세션 계약과 구별한다.
CLIENT COOKIE · SERVER SESSION · ATOMIC ROTATION
쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다
같은 client cookie 교체 결과가 server의 같은 사건을 뜻하지는 않습니다. 재로그인은 create(member)로 같은 member의 prior sessions를 폐기한 뒤 새 session을 발급하고, 별도 POST /api/session/rotate는 active old 하나를 rotate(old)로 교체합니다.
01 · CLIENT · ISSUE AND REPLACE
같은 scope의 Set-Cookie가 client 저장값을 발급하거나 교체한다
- 로그인 성공은
Set-Cookie와Max-Age=1800으로 COOKIE ABSENT → STORED를 만듭니다. - 재로그인의
create(member)성공과 별도POST /api/session/rotate성공은 모두 같은 이름·scope의Set-Cookie로 저장값을 교체할 수 있습니다. - 이 공통 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을 가진다
- RELOGIN: credential 인증이 성공하면
create(member)가 같은 member의 prior sessions를 폐기하고 새 session 하나를 발급합니다. - ROTATE: 별도
POST /api/session/rotate는 제시된 old token 하나가 ACTIVE일 때만rotate(old)로 그 old만 폐기하고 새 session 하나를 원자적으로 발급합니다. - 두 성공 응답은 같은-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 상태를 보여 주되 서버 세션 계약과 구별한다.
CLIENT COOKIE · SERVER SESSION · ATOMIC ROTATION
쿠키와 서버 세션은 따로 움직이고 로그아웃이 둘을 함께 끝낸다
같은 client cookie 교체 결과가 server의 같은 사건을 뜻하지는 않습니다. 재로그인은 create(member)로 같은 member의 prior sessions를 폐기한 뒤 새 session을 발급하고, 별도 POST /api/session/rotate는 active old 하나를 rotate(old)로 교체합니다.
01 · CLIENT · ISSUE AND REPLACE
같은 scope의 Set-Cookie가 client 저장값을 발급하거나 교체한다
- 로그인 성공은
Set-Cookie와Max-Age=1800으로 COOKIE ABSENT → STORED를 만듭니다. - 재로그인의
create(member)성공과 별도POST /api/session/rotate성공은 모두 같은 이름·scope의Set-Cookie로 저장값을 교체할 수 있습니다. - 이 공통 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을 가진다
- RELOGIN: credential 인증이 성공하면
create(member)가 같은 member의 prior sessions를 폐기하고 새 session 하나를 발급합니다. - ROTATE: 별도
POST /api/session/rotate는 제시된 old token 하나가 ACTIVE일 때만rotate(old)로 그 old만 폐기하고 새 session 하나를 원자적으로 발급합니다. - 두 성공 응답은 같은-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 · 종료 원인: 삭제·보관 만료 | 다음 요청에 보낼 해당 쿠키가 없어집니다. |
이 테스트가 증명하는 범위
이 단위는 standalone MockMvc에서 다음 경계를 증명합니다.
- 컨트롤러가 올바른 상태 코드와 각
Set-Cookie,Cache-Control,WWW-Authenticate헤더를 만든다. - 동일한 저장소가 로그인·조회·로그아웃·교체에 사용되고, 원시 토큰 대신 다이제스트 키로 조회한다.
CookieManager가 HTTPS URI에 저장한 쿠키로 후속 요청을 구성하고 삭제 응답을 반영한다.- 가변 서버 시계와 결정적 토큰 큐로 만료·재로그인·동시 교체를 반복 가능하게 검증한다.
MockMvc의 secure(true)는 실제 TLS 연결이 아니며 java.net.CookieManager도 실제 브라우저 엔진이 아닙니다. 따라서 이 테스트는 인증서·프록시 HTTPS 설정, 브라우저의 __Host-·SameSite 집행, 실제 XSS 방어를 증명하지 않습니다. 특히 Secure의 검색 조건은 6265bis-22 초안 §5.8.3, Lax 집행은 6265bis-22 초안 §5.6.7.1에 정의된 사용자 에이전트 동작이므로 실제 브라우저 HTTPS 종단 간 테스트가 필요합니다.
standalone 설정에는 Spring Security 필터 체인이 없습니다. SameSite=Lax는 모든 CSRF를 없애는 보장이 아니며, 애플리케이션의 요청 형태와 신뢰 경계에 맞는 CSRF 토큰·Origin 검사·인가 규칙은 보안 통합 테스트에서 검증합니다. 쿠키와 CSRF의 관계는 6265bis-22 초안 §8.8.1과 §8.8.2를 참고합니다.
Path 변형의 규칙은 6265bis-22의 이름 접두사 조건을 따릅니다. __Secure-로 이름을 바꾸어도 Secure는 유지해야 하며, 삭제용 Set-Cookie는 변형의 이름·Path·Domain 범위와 맞춰야 해당 저장값을 제거할 수 있습니다.
저장 방식과 문서 소유권
서버 측 세션은 즉시 폐기, 장치 목록, 권한 변경 반영을 구현하기 쉽지만 여러 애플리케이션 인스턴스가 같은 저장소를 보도록 설계해야 합니다. 자체 완결형 서명 토큰은 서버 조회를 줄일 수 있어도 서명은 암호화가 아니며, 만료 전 폐기와 권한 변경 반영에는 별도 전략이 필요합니다. 브라우저에 큰 데이터나 장기 비밀을 넣지 않고, 원시 세션 토큰은 로그·데이터베이스·오류 메시지에 남기지 않습니다.
이 문서의 소유 범위는 브라우저 쿠키 왕복과 서버 세션 수명 주기입니다. 요청의 Accept·Content-Type 협상은 4-7장, 공개 응답의 캐시·ETag·무효화는 4-9장, Spring Security 필터 체인과 인증·인가·CSRF 통합은 8-1장에서 다룹니다.
브라우저 보관과 서버 만료를 따로 계산하기
서버가 시각 t0에 토큰 A를 발급하고 expiresAt을 t0 + 30분으로 정했다고 가정합니다. 서버 Clock만 이동시키며 요청에는 캡처해 둔 A를 직접 넣어 CookieManager의 실제 시간 경과와 분리합니다. 세 경우를 독립적으로 판단하세요. 첫째 t0 + 29분 59초에 A가 아직 폐기되지 않았습니다. 둘째 정확히 t0 + 30분입니다. 셋째 만료 전 A의 회전이 성공해 B를 발급한 뒤 A와 B를 각각 조회합니다.
활성 토큰 판정의 해설
첫 경우는 서버 저장소의 ACTIVE 기록과 만료 전 조건을 만족해 200입니다. 둘째는 서버 시각이 expiresAt에 도달했으므로 A를 갖고 있어도 401입니다. 셋째는 회전이 기존 A를 폐기했으므로 A는 401, 아직 만료되지 않은 새 B는 200입니다. cookie jar에 문자열이 남아 있는지와 서버가 그 문자열을 로그인 상태로 인정하는지는 별도 질문입니다. 이 풀이로 실제 브라우저의 시간·SameSite 집행을 검증했다고 보고하지 않습니다.
연습 문제
-
현재의 “회원당 활성 세션 하나” 정책을 “장치별 여러 세션” 정책으로 바꾸세요. 로그인 때 기존 세션을 자동 폐기하지 않고 장치 식별 메타데이터를 저장하며, 별도의 “모든 장치에서 로그아웃” 연산이 한 회원의 활성 세션만 원자적으로 폐기하는지 테스트하세요.
-
쿠키의
Path를/api로 좁힌 변형을 만들고CookieManager가/api/me에는 쿠키를 보내지만/assets/logo.svg에는 보내지 않는지 검증하세요.이 변형은 원 __Host-board-session을 그대로 좁히는 실험이 아닙니다. 원 __Host- 이름에는 Secure·Path=/·Domain 없음이 함께 필요하므로, 실제 규칙을 집행하는 브라우저는 Path=/api인 __Host- 쿠키를 거절합니다. 별도 변형에서는 이름을 __Secure-board-session으로 정하고 HTTPS·Secure와 Domain 없는 조건을 유지하세요. 발급·삭제·@CookieValue·테스트의 기대 이름도 같은 새 이름으로 맞춥니다. 원 완성 코드는 그대로 두고 복사한 실험에서만 바꿉니다. CookieManager의 경로 선택 테스트가 __Host- 접두사 집행까지 증명하는 것은 아닙니다.
서버 저장소의 인증 판단과 사용자 에이전트의 전송 범위 판단을 같은 주장으로 섞지 마세요. Path 매칭 규칙은 6265bis-22 초안 §4.1.2.4를 기준으로 삼습니다.
해설 보기
첫 문제에서는 저장소 키를 계속 토큰 다이제스트로 유지하되 회원 ID에서 활성 다이제스트 집합을 찾는 보조 인덱스를 둘 수 있습니다. 세션 생성, 개별 폐기, 회원 전체 폐기가 두 인덱스를 어긋나게 만들지 않도록 하나의 트랜잭션 경계를 정하고, 다른 회원의 세션이 영향을 받지 않는 반례도 함께 검증합니다.
둘째 문제는 HTTPS URI의 경로만 달리해 jar가 만든 Cookie 필드를 관찰하면 됩니다. 컨트롤러에 도달한 뒤 토큰을 거부하는 테스트가 아니라, 애초에 경로 조건이 맞지 않아 쿠키가 요청에 실리지 않는 테스트여야 합니다.
다음 문서에서는 개인화된 응답은 공유 캐시에 저장하지 않으면서 공개 표현은 재사용하고, ETag와 조건부 요청으로 본문 전송을 줄이며 변경 뒤 캐시를 안전하게 무효화합니다.
경계 사례 연습과 풀이
1. 새 토큰 발급 실패가 기존 세션을 끊는지 확인하기
독립된 저장소 두 개가 동일한 회원의 활성 토큰 A를 갖고 있으며 아직 만료 전입니다. 첫 저장소의 결정적 토큰 큐는 A 발급 뒤 비었고, 둘째 큐의 다음 값은 다시 A입니다. 각각 rotate("A")를 한 번 호출하면 실패 이후 A는 유효할까요? current.revoked = true를 tokens.next() 앞에 옮기면 어떤 반례가 생길까요?
풀이: 실패할 수 있는 준비를 끝낸 뒤 기존 상태를 바꾼다
| 다음 발급 조건 | 실패 위치 | 원 코드의 A |
|---|---|---|
| 큐가 비었음 | tokens.next()의 예외 | 활성 유지 |
| 이미 쓴 A가 다시 나옴 | ensureUnused(newKey)의 예외 | 활성 유지 |
원 코드에서는 활성 확인 뒤 발급·다이제스트 생성·중복 검사·만료 시각 계산을 마치고 나서 A를 폐기합니다. 두 실패는 모두 그 상태 변경 이전에 발생하므로 A를 조회할 수 있습니다. 순서를 바꾸면 새 세션도 없고 기존 A도 폐기된 상태가 생깁니다. 이 예외 경로가 어떤 HTTP 상태로 매핑되는지는 저장소 코드만으로 정해지지 않습니다.
검증에서는 각 시나리오에 새 저장소와 고정 시계를 사용하고, 예외 발생 뒤 findMember("A")가 회원을 반환하는지 확인하세요. 실제 무작위 토큰 발급기도 매번 충분히 예측 불가능한 새 토큰을 만들어야 하며 결정적 큐는 테스트용입니다. 다중 인스턴스로 옮길 때는 JVM의 synchronized만으로 공유 저장소의 원자성을 보장하지 못합니다. 기존 상태의 조건 확인과 새 기록 확정은 공유 저장소가 지원하는 트랜잭션·조건부 변경으로 검증해야 합니다.
2. 회전 성공 응답이 유실됐을 때 재시도 판단하기
브라우저 jar에는 A가 있고 서버에도 A가 활성입니다. 서버가 회전을 확정해 A를 폐기하고 B를 발급했지만, Set-Cookie가 담긴 성공 응답은 브라우저에 도착하지 않았습니다. 만료·재로그인·다른 요청은 없다고 가정합니다. 브라우저가 A로 /api/me를 요청하고 이어 A로 회전을 재시도하면 어떻게 될까요? 사용 편의를 위해 A를 다시 활성화해도 될까요?
풀이: 전달 실패와 서버의 확정 상태를 분리한다
| 시점 | 브라우저 저장값 | 서버 A | 서버 B |
|---|---|---|---|
| 회전 전 | A | 활성 | 없음 |
| 서버 확정 뒤 응답 유실 | A | 폐기 | 활성 |
| A로 조회·회전 재시도 | A | 폐기 유지 | 활성 유지 |
원 계약에서 A의 조회는 인증 실패이며, rotate("A")도 빈 결과를 반환해 컨트롤러가 401로 응답합니다. 재시도는 C라는 세 번째 세션을 발급하지 않습니다. B가 서버에서 활성이라는 사실만으로 브라우저가 B를 알 수는 없습니다. 이는 jar의 저장과 서버의 회전이 네트워크를 사이에 둔 별개 단계이기 때문입니다.
현재의 단순 계약에서는 사용자가 다시 인증하도록 처리합니다. 재로그인에 성공하면 create(member)가 B를 포함한 같은 회원의 이전 세션을 폐기하고 새 토큰을 발급합니다. 편의를 위해 A를 되살리면 “폐기된 토큰은 돌아오지 않는다”는 보안 계약을 깨뜨립니다. 응답 재전달이나 유예 시간을 지원하려면 그것을 새 계약으로 설계하고 재전송 공격·동시 요청·만료 경계까지 검증해야 합니다. 원 저장소의 성공을 실제 브라우저의 응답 수신 성공과 같은 뜻으로 보고하지 않습니다.
3. 삭제 쿠키의 경로가 발급 경로와 다르면 어떻게 되는가
기존 연습의 별도 __Secure-board-session 변형에서 HTTPS, Secure, Domain 없음은 유지하고 Path=/api로 발급했습니다. 로그아웃 응답만 실수로 Path=/의 과거 Expires를 보냈습니다. 서버 폐기는 성공했다고 가정합니다. jar는 기존 /api 쿠키를 제거했을까요? 이 실패가 토큰 재사용을 허용한다는 뜻일까요?
풀이: client 저장 식별자와 server 효력을 각각 확인한다
다른 Path로 보낸 삭제 필드는 원 /api 저장값을 교체하지 못합니다. jar에 A가 남아 다음 /api/me에 실릴 수 있지만, 서버에서 이미 폐기했다면 A를 인증 상태로 인정하지 않습니다. 반대로 jar에서만 제거하고 서버 폐기를 빠뜨리면 복사된 A가 계속 유효할 수 있습니다. 삭제 성공 기준을 두 효과로 나누어 확인해야 합니다. 쿠키 교체·삭제의 이름·도메인·경로 조건은 RFC 6265 §4.1.2를 따릅니다.
다음 독립 파일은 잘못된 Path 삭제 뒤에도 쿠키가 전송되고 올바른 Path 삭제 뒤에는 전송되지 않는지를 JDK jar에서 확인합니다. 토큰은 설명을 위한 고정 문자열이며 인증 저장소를 연결하지 않습니다.
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 21 이상에서 javac CookieDeletionScopeLab.java와 java CookieDeletionScopeLab으로 실행합니다. 이 실험은 jar의 저장·경로 선택·삭제만 다룹니다. __Secure-, SameSite, 실제 TLS를 브라우저가 집행했거나 서버가 A를 폐기했다는 증거는 아닙니다. 원 __Host-board-session은 그대로 Path=/를 유지하며, 이 변형을 사용할 때는 발급·삭제·조회 이름과 경로를 모두 맞춰야 합니다.