Сессии браузерных клиентов
Как браузерный фронт (main-web, business-client, booking-client, консоль
модератора) держит сессию: refresh-токен живёт в HttpOnly-куке, которую
ставит identity, а скрипт страницы видит только короткий access-токен.
Мобильные клиенты не меняются. Общая картина JWT, HMAC и отзыва —
authentication-flow; здесь — только то, что
отличает браузер.
Содержание
- Что защищается
- Где что лежит
- Кука
- Контракт identity
- CSRF
- Транспорт: напрямую и через прокси фронта
- Выход и «выйти на остальных»
- Переезд клиента
- Порядок выкатки
- Откат
- Клиенты
- Почему не иначе
- Риски
Что защищается
Refresh-токен живёт 30 дней (JWT_REFRESH_TTL) и ротируется: повтор уже
обменянного токена вне окна отсрочки сжигает сессию. Пока он лежит в
localStorage или sessionStorage, любой XSS на странице уносит его и
пользуется сессией из своего процесса сколько угодно; первый же повтор
выкидывает владельца.
После переезда XSS по-прежнему может действовать от имени человека, пока
страница открыта: дёргать refresh и получать access-токен на 5 минут.
Унести сессию с собой он не может — refresh-токен скрипту недоступен. От XSS
как такового это не защищает; это делает CSP фронта, и обе меры нужны вместе.
Где что лежит
| Что | Где | Кто читает |
|---|---|---|
| refresh-токен | HttpOnly-кука __Secure-kazmaps-rt-<h> | браузер отдаёт её только identity: вход, обновление, выход |
| access-токен | память скрипта (Worker, модуль) — не Web Storage | Authorization: Bearer на каждом вызове API |
| признак «вход был» (необязательно) | localStorage, не секрет | фронт: не звать refresh у анонима на каждом старте |
Access-токен в куку не уезжает: остальное API аутентифицируется
Bearer, и поэтому ни один маршрут, кроме трёх ручек identity, не читает
куки — CSRF-поверхности за ними нет.
Кука
Ставит identity в ответ на вход и ротацию, когда клиент прислал
KazMaps-Session:
Set-Cookie: __Secure-kazmaps-rt-<h>=<refresh>; HttpOnly; Secure; SameSite=Strict[; Max-Age=<JWT_REFRESH_TTL>]| Атрибут | Значение | Почему |
|---|---|---|
| имя | __Secure-kazmaps-rt- + 16 hex SHA-256 от Origin в нижнем регистре | Origin ставит браузер, страница его не подделает: фронт, попавший под XSS, не прочтёт и не провернёт куку соседнего фронта — identity читает только куку своего происхождения |
HttpOnly | да | скрипт страницы её не видит |
Secure | да (префикс __Secure- это требует) | только HTTPS; localhost браузеры считают безопасным |
SameSite | Strict | межсайтовый запрос куки не несёт |
Domain | нет | кука остаётся на хосте, ответившем на вход: у каждого семейства хостов свой API (kazmaps.dev ↔ api.kazmaps.dev, kazmaps.kz ↔ api.kazmaps.kz), и фронт с ним одного сайта |
Path | нет | браузер берёт каталог адреса входа (RFC 6265 §5.1.4) — см. ниже |
Max-Age | JWT_REFRESH_TTL у persistent, нет у transient | transient живёт до закрытия браузера |
Почему без Path. Каталог адреса входа — ровно то место, куда клиент
потом шлёт refresh и logout:
https://api.kazmaps.dev/identity/v1/auth/login→ кука на/identity/v1/authхоста API;https://<хост консоли>/api/identity/v1/auth/login(прокси консоли) → на/api/identity/v1/authхоста консоли;https://booking.kazmaps.dev/api/gateway/identity/v1/auth/login→ на/api/gateway/identity/v1/auth.
Кука не уезжает ни на один бэкенд за шлюзом и ни на одну страницу фронта, а identity не нужно знать ни префикс шлюза, ни прокси фронтов. Требование к клиенту одно: вход, обновление и выход — через одну и ту же базу.
Контракт identity
Заголовок KazMaps-Session: persistent | transient на
POST /v1/auth/login, /v1/auth/refresh, /v1/auth/logout.
| Ручка | Без заголовка (как было, мобильные) | С заголовком |
|---|---|---|
login (оба гранта) | 200 {access_token, refresh_token, expires_at} | 200 {access_token, expires_at} + Set-Cookie |
refresh | тело {refresh_token} обязательно | токен из куки, тело не нужно; 401 стирает куку |
logout | тело {refresh_token} | токен из куки; 204 стирает куку (Max-Age=0) |
- Режиму нужен
Origin; без него или сnull—400 invalid_input. Значение заголовка вне словаря — тот же ответ. - В режиме куки тело
refreshчитается, только если куки нет: так клиент один раз переносит токен, который держал до переезда. - Ответы с токенами несут
Cache-Control: no-store. - Режим включает
SESSION_COOKIE_ENABLED(по умолчаниюfalse). Выключенный identity заголовок не читает и отвечает парой телом.
Граница в identity — INV-REFRESH-COOKIE, контракт — api/openapi.yaml
(параметр KazMapsSession, схема AccessGrant).
CSRF
Отдельного CSRF-токена (double-submit) нет: куку читают три ручки identity, и их закрывают четыре слоя.
SameSite=Strict— межсайтовый запрос куку не несёт.KazMaps-Session— нестандартный заголовок: кросс-доменный запрос с ним браузер не пошлёт без предполёта, а шлюз отвечает на предполёт только происхождениям изCORS_ALLOWED_ORIGINS. Без заголовка identity куку не читает вовсе — форма или<img>с чужого сайта ничего не сделают.- Имя куки от
Origin— разрешённый соседний фронт достаёт только свою. - Прокси фронта пускает в
/api/*только свою страницу (см. ниже).
Остальное API — Bearer, куки не читает, и CSRF к нему не применим. Шлюзу
своя CSRF-стратегия не нужна.
Транспорт: напрямую и через прокси фронта
Напрямую (main-web; business-client с NEXT_PUBLIC_GATEWAY_PROXY=0):
фронт зовёт api.kazmaps.dev кросс-доменно, но в пределах сайта.
- Три ручки identity —
credentials: "include"и заголовокKazMaps-Session; остальное — безcredentialsи сBearer. - Шлюз:
CORS_CREDENTIALS_PREFIXES=/identity/v1/auth—Access-Control-Allow-Credentials: trueтолько там и только происхождению из списка (INV-CREDENTIALS-SCOPEDв api-gateway). Происхождение фронта обязано быть вCORS_ALLOWED_ORIGINSсвоего стенда.
Через прокси фронта (консоль модератора, booking-client,
business-client с NEXT_PUBLIC_GATEWAY_PROXY=1): кука ложится на
происхождение фронта, CORS не участвует. Прокси обязан:
- пересылать вверх
Cookie,Origin,KazMaps-Session, а вниз —Set-Cookie, хотя бы наidentity/v1/auth/*; - пускать в
/api/*только свою страницу:Sec-Fetch-Site—same-origin, без него —Originсовпадает с хостом; - не пересылать вниз
Access-Control-*шлюза.
Вход через прокси фронта приходит к шлюзу с адреса пода, а не человека: шлюз берёт адрес только из заголовка края. Поминутный потолок входа и капча видят тогда один адрес на весь фронт. Это действует и сегодня, к куке отношения не имеет и чинится отдельно; прямой вызов этого недостатка лишён.
Выход и «выйти на остальных»
- Выход —
POST /v1/auth/logoutс заголовком: identity закрывает сессию и стирает куку. - «Выйти на остальных» —
DELETE /v1/me/sessions?except=currentпоBearer(сессию вызывающего identity знает поsid). Своя кука остаётся живой. - «Выйти везде» —
DELETE /v1/me/sessions, затемlogout: своя сессия тоже закрыта, иlogoutстирает её куку сразу, а не на следующем старте. - Отказ
refresh(401) — identity стирает куку, клиент сбрасывает память и показывает вход.
Переезд клиента
Оба режима живут параллельно бессрочно: без заголовка контракт прежний, на нём стоят мобильные клиенты. Клиенты переезжают по одному.
- Перестать писать refresh-токен в Web Storage.
- При старте: если в хранилище лежит прежний токен — один
refreshс ним телом и заголовком (identity примет его, только пока куки нет), затем стереть ключ после любого ответа, кроме 5xx. Иначе —refreshбез тела. remember me→persistent, иначеtransient. Выбор — не секрет и может лежать в Web Storage: заголовок повторяется на каждомrefresh.- Если identity ответил парой телом (его откатили), refresh-токен держать только в памяти: работать можно, F5 потребует нового входа.
Порядок выкатки
- identity (identity#161) — мерж без эффекта, флаг выключен.
- api-gateway (api-gateway#106) — мерж без эффекта, список префиксов
пуст.
KazMaps-SessionвAccess-Control-Allow-Headersбезвреден. - k8s-gitops — стенд, затем прод:
- ConfigMap identity:
SESSION_COOKIE_ENABLED=true; - ConfigMap api-gateway:
CORS_CREDENTIALS_PREFIXES=/identity/v1/auth(нужен до первого фронта, который ходит напрямую); CORS_ALLOWED_ORIGINSкаждого стенда содержит происхождения фронтов этого стенда (сегодня на стенде —kazmaps.dev,booking.,business.,mmc.,admin.,devhub.kazmaps.devиlocalhost). Сетевые политики и маршруты шлюза не меняются: новых вызовов внутри кластера нет,/identityужеauth: public.
- ConfigMap identity:
- Клиенты по одному: консоль модератора (moderator-console#47, эталон) → main-web → booking-client → business-client.
Откат
- Клиент — предыдущий образ: он снова пишет токен в хранилище; людям нужно войти один раз заново.
- identity —
SESSION_COOKIE_ENABLED=false: заголовок не читается, клиенты в режиме куки получают пару телом и держат её в памяти. Куки в браузерах остаются и снова заработают при включении. - api-gateway — пустой
CORS_CREDENTIALS_PREFIXES, но только после отката фронтов, которые ходят напрямую: иначе ихrefreshупадёт на CORS.
Клиенты
| Клиент | Сегодня | Режим | Что сделать |
|---|---|---|---|
| moderator-console | refresh в памяти Worker’а + sessionStorage | прокси Go-сервера, transient | сделано — moderator-console#47 |
| main-web | refresh в sessionStorage (kazmaps.core.refresh), access в памяти | напрямую | см. ниже |
| booking-client | refresh в localStorage/sessionStorage (kazmaps.auth.refresh) | прокси /api/gateway | см. ниже |
| business-client | refresh в localStorage/sessionStorage (kazmaps-business-refresh) | прокси или напрямую | см. ниже |
main-web.
src/shared/lib/auth/token-store.ts: exchange — credentials: "include",
KazMaps-Session, без тела; isTokenResponse принимает ответ без
refresh_token; refreshToken уходит из состояния.
src/shared/lib/auth/session-storage.ts: только чтение прежних ключей для
переноса, запись удалить. src/modules/session/api/real.ts: вход и выход с
заголовком и credentials: "include". src/modules/session/model/logout.ts:
выход без токена. Признак «вход был» в localStorage — чтобы аноним не
звал refresh на каждом старте. Выход сейчас правит открытый main-web#386 —
переезд после него.
booking-client.
src/app/api/gateway/[...path]/route.ts: для identity/v1/auth/*
пересылать cookie, origin, kazmaps-session и отдавать set-cookie
(сейчас уходит только Content-Type). src/shared/lib/auth/token-store.ts
и identity-client.ts: не писать kazmaps.auth.refresh, remember →
persistent | transient. host-identity.ts: встраивающая страница передаёт
только access-токен, refreshToken из postMessage и ?refreshToken= из
адреса не принимать. Прокси и ядро авторизации сейчас правят открытые
booking-client#156 и #157 — переезд после них.
business-client.
Те же правки прокси src/app/api/gateway/[...path]/route.ts при
NEXT_PUBLIC_GATEWAY_PROXY=1; при 0 — credentials: "include" на трёх
ручках identity. src/shared/lib/auth/token-store.ts: не писать
kazmaps-business-refresh; remember → persistent | transient. Открытые
business-client#222 и #224 трогают тот же код — переезд после них.
Почему не иначе
Свой BFF на каждом фронте — фронт сам держит refresh в своей куке и сам
зовёт identity. Кук на чужом хосте нет вовсе, но вход тогда идёт с адреса
пода: потолок входа и капча считают весь фронт одним человеком, а
шлюз адрес из заголовков клиента не принимает (INV-CLIENT-IP). Плюс
четыре копии ротации, окна отсрочки и стирания вместо одной в identity.
Режим куки identity эту схему покрывает: прокси фронта получает ту же куку
на своём происхождении без собственной логики.
Access-токен тоже в куке, шлюз читает куку вместо Bearer — тогда каждый
маршрут каждого бэкенда становится CSRF-поверхностью, кука едет во все
сервисы, нужен CSRF-токен на шлюзе. Выигрыш мал: access живёт 5 минут, а XSS
на открытой странице действует от имени человека при любом хранении.
Кука на Domain=.kazmaps.dev — её получали бы все поддомены, включая
статические сайты, не входящие в список CORS.
Риски
- Фронт и его API обязаны быть одного сайта. Фронт на другом сайте (или
встроенный в чужую страницу iframe) куку не получит: браузеры режут
межсайтовые куки. Такому клиенту —
Bearer-режим или токен от встраивающей страницы. - Вход, обновление и выход через разные базы — кука не найдётся,
refreshответит401, человек выйдет. Ловится e2e фронта. - Анонимный старт без признака «вход был» платит запросом
refreshс ответом401. Рядauth_refresh_total{result="invalid_credentials"}identity вырастет на число анонимных визитов. - Режим приватного просмотра или запрет кук — сессия не переживает перезагрузку, как и с запретом Web Storage сегодня.