쿠키와 상태 관리
브라우저 쿠키 왕복과 서버 세션의 발급·만료·폐기·교체를 하나의 실행 가능한 MockMvc 계약으로 검증합니다.
HTTP 요청은 서로 독립적입니다. 로그인 상태가 이어지는 것처럼 보이는 이유는 사용자 에이전트가 응답의 Set-Cookie를 저장하고, 전송 조건에 맞는 다음 요청의 Cookie를 자동으로 구성하기 때문입니다.
이 경계에는 서로 다른 두 상태가 있습니다.
- 브라우저는 쿠키 이름·값과
Secure,HttpOnly,SameSite,Path, 수명을 관리합니다. - 서버는 불투명 토큰이 가리키는 회원, 만료 시각, 폐기 여부를 관리합니다.
쿠키에는 회원 ID나 권한 목록을 넣지 않습니다. 추측하기 어려운 원시 토큰만 브라우저에 전달하고, 서버 저장소의 키는 원시 토큰의 SHA-256 다이제스트로 만듭니다. 따라서 저장소의 값과 브라우저가 가진 값이 같지 않습니다.
로그인 응답은 쿠키마다 독립된 Set-Cookie 필드 라인을 보냅니다. 여러 쿠키를 쉼표로 결합하거나 한 필드를 다음 줄로 접는 obs-fold를 만들지 않습니다. 쿠키 왕복과 필드 결합 제한은 RFC 10025 §3과 RFC 9110 §5.2, 송신자의 줄 접기 금지는 RFC 9112 §5.2를 기준으로 삼습니다.
SET-COOKIE · COOKIE JAR · DIGEST LOOKUP · SAME BROWSER
원시 쿠키는 브라우저를 왕복하고 서버 상태는 digest로 조회된다
브라우저에는 불투명한 원시 토큰만, 서버 저장에는 그 digest와 로그인 상태만 남깁니다. 같은 브라우저의 다음 요청에서 cookie jar가 범위가 맞는 쿠키를 자동 첨부하고, server는 hash lookup 결과가 활성일 때만 회원을 돌려줍니다.
01 · LOGIN RESPONSE
로그인 결과는 digest 저장과 원시 쿠키 전달로 갈라진다
- 같은 browser가
POST /api/login을 보냅니다. LoginController는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입니다.
쿠키는 서버 상태 자체가 아니라 다음 요청에서 그 상태를 찾기 위한 불투명 식별자입니다. 원시 값은 browser 왕복 경계에만 두고, server는 digest lookup과 expiry·active 판정으로 현재 로그인 상태를 결정합니다.
실행 가능한 쿠키·세션 계약
다음 테스트 파일 하나에 컨트롤러, 쿠키 팩터리, 인증기, 토큰 발급기, 세션 저장소, 브라우저 역할의 쿠키 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, "member@example.com");
private static final String LOGIN_JSON = """
{
"email": "member@example.com",
"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(
"member@example.com",
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가 우선한다는 규칙은 RFC 10025 §4.1.2.2, __Host-의 필수 조합은 RFC 10025 §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를 둔 삭제 쿠키를 반환합니다. 이 예제는 RFC 10025 §4.1.1의 서버 문법이 Max-Age에 양의 숫자만 허용하므로 관용적으로 처리되는 Max-Age=0을 생성하지 않고, §4.1.2의 과거 Expires 교체 규칙을 사용합니다. 브라우저 jar에서 쿠키가 사라지는 것과 공격자가 복사해 둔 토큰의 서버 효력이 사라지는 것은 서로 다른 검증 대상입니다.
교체는 활성인 이전 세션을 폐기하고 새 토큰을 발급하는 하나의 synchronized 임계 구역입니다. 같은 이전 토큰으로 두 스레드가 동시에 rotate해도 첫 호출만 새 세션을 만들고 두 번째 호출은 폐기 상태를 보아 실패합니다. 토큰 발급과 다이제스트 중복 검사까지 성공한 뒤 상태를 바꾸므로 발급 실패가 기존 세션을 먼저 끊지 않습니다. 재로그인도 같은 임계 구역에서 기존 세션을 폐기합니다. 이는 로그인 뒤 세션 식별자를 교체해 고정 공격을 줄이라는 RFC 10025 §8.4의 경계와도 맞닿습니다.
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는 별도 정책에 따라 남아 있을 수 있습니다.
- Server의 기한이 지나거나 session이 폐기되면 해당 token의
/api/me는401입니다. - 따라서 client의 STORED와 server의 ACTIVE를 하나의 상태로 합치지 않습니다.
03 · SERVER · TWO REISSUE EVENTS
재로그인과 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가 같은 상태라는 뜻은 아닙니다.
- 로그아웃을 반복해도 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를 포함합니다.
이 테스트가 증명하는 범위
이 단위는 standalone MockMvc에서 다음 경계를 증명합니다.
- 컨트롤러가 올바른 상태 코드와 각
Set-Cookie,Cache-Control,WWW-Authenticate헤더를 만든다. - 동일한 저장소가 로그인·조회·로그아웃·교체에 사용되고, 원시 토큰 대신 다이제스트 키로 조회한다.
CookieManager가 HTTPS URI에 저장한 쿠키로 후속 요청을 구성하고 삭제 응답을 반영한다.- 가변 서버 시계와 결정적 토큰 큐로 만료·재로그인·동시 교체를 반복 가능하게 검증한다.
MockMvc의 secure(true)는 실제 TLS 연결이 아니며 java.net.CookieManager도 실제 브라우저 엔진이 아닙니다. 따라서 이 테스트는 인증서·프록시 HTTPS 설정, 브라우저의 __Host-·SameSite 집행, 실제 XSS 방어를 증명하지 않습니다. 특히 Secure의 검색 조건은 RFC 10025 §5.8.3, Lax 집행은 RFC 10025 §5.6.7.1에 정의된 사용자 에이전트 동작이므로 실제 브라우저 HTTPS 종단 간 테스트가 필요합니다.
standalone 설정에는 Spring Security 필터 체인이 없습니다. SameSite=Lax는 모든 CSRF를 없애는 보장이 아니며, 애플리케이션의 요청 형태와 신뢰 경계에 맞는 CSRF 토큰·Origin 검사·인가 규칙은 보안 통합 테스트에서 검증합니다. 쿠키와 CSRF의 관계는 RFC 10025 §8.8.1과 §8.8.2를 참고합니다.
저장 방식과 문서 소유권
서버 측 세션은 즉시 폐기, 장치 목록, 권한 변경 반영을 구현하기 쉽지만 여러 애플리케이션 인스턴스가 같은 저장소를 보도록 설계해야 합니다. 자체 완결형 서명 토큰은 서버 조회를 줄일 수 있어도 서명은 암호화가 아니며, 만료 전 폐기와 권한 변경 반영에는 별도 전략이 필요합니다. 브라우저에 큰 데이터나 장기 비밀을 넣지 않고, 원시 세션 토큰은 로그·데이터베이스·오류 메시지에 남기지 않습니다.
이 문서의 소유 범위는 브라우저 쿠키 왕복과 서버 세션 수명 주기입니다. 요청의 Accept·Content-Type 협상은 4-7장, 공개 응답의 캐시·ETag·무효화는 4-9장, Spring Security 필터 체인과 인증·인가·CSRF 통합은 8-1장에서 다룹니다.
연습 문제
- 현재의 “회원당 활성 세션 하나” 정책을 “장치별 여러 세션” 정책으로 바꾸세요. 로그인 때 기존 세션을 자동 폐기하지 않고 장치 식별 메타데이터를 저장하며, 별도의 “모든 장치에서 로그아웃” 연산이 한 회원의 활성 세션만 원자적으로 폐기하는지 테스트하세요.
- 쿠키의
Path를/api로 좁힌 변형을 만들고CookieManager가/api/me에는 쿠키를 보내지만/assets/logo.svg에는 보내지 않는지 검증하세요. 서버 저장소의 인증 판단과 사용자 에이전트의 전송 범위 판단을 같은 주장으로 섞지 마세요. Path 매칭 규칙은 RFC 10025 §4.1.2.4를 기준으로 삼습니다.
해설 보기
첫 문제에서는 저장소 키를 계속 토큰 다이제스트로 유지하되 회원 ID에서 활성 다이제스트 집합을 찾는 보조 인덱스를 둘 수 있습니다. 세션 생성, 개별 폐기, 회원 전체 폐기가 두 인덱스를 어긋나게 만들지 않도록 하나의 트랜잭션 경계를 정하고, 다른 회원의 세션이 영향을 받지 않는 반례도 함께 검증합니다.
둘째 문제는 HTTPS URI의 경로만 달리해 jar가 만든 Cookie 필드를 관찰하면 됩니다. 컨트롤러에 도달한 뒤 토큰을 거부하는 테스트가 아니라, 애초에 경로 조건이 맞지 않아 쿠키가 요청에 실리지 않는 테스트여야 합니다.
다음 문서에서는 개인화된 응답은 공유 캐시에 저장하지 않으면서 공개 표현은 재사용하고, ETag와 조건부 요청으로 본문 전송을 줄이며 변경 뒤 캐시를 안전하게 무효화합니다.