Skip to Content
Сессии браузерных клиентов

Сессии браузерных клиентов

Как браузерный фронт (main-web, business-client, booking-client, консоль модератора) держит сессию: refresh-токен живёт в HttpOnly-куке, которую ставит identity, а скрипт страницы видит только короткий access-токен. Мобильные клиенты не меняются. Общая картина JWT, HMAC и отзыва — authentication-flow; здесь — только то, что отличает браузер.

Содержание

Что защищается

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 StorageAuthorization: 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 браузеры считают безопасным
SameSiteStrictмежсайтовый запрос куки не несёт
Domainнеткука остаётся на хосте, ответившем на вход: у каждого семейства хостов свой API (kazmaps.dev ↔ api.kazmaps.dev, kazmaps.kz ↔ api.kazmaps.kz), и фронт с ним одного сайта
Pathнетбраузер берёт каталог адреса входа (RFC 6265 §5.1.4) — см. ниже
Max-AgeJWT_REFRESH_TTL у persistent, нет у transienttransient живёт до закрытия браузера

Почему без 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, и их закрывают четыре слоя.

  1. SameSite=Strict — межсайтовый запрос куку не несёт.
  2. KazMaps-Session — нестандартный заголовок: кросс-доменный запрос с ним браузер не пошлёт без предполёта, а шлюз отвечает на предполёт только происхождениям из CORS_ALLOWED_ORIGINS. Без заголовка identity куку не читает вовсе — форма или <img> с чужого сайта ничего не сделают.
  3. Имя куки от Origin — разрешённый соседний фронт достаёт только свою.
  4. Прокси фронта пускает в /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 стирает куку, клиент сбрасывает память и показывает вход.

Переезд клиента

Оба режима живут параллельно бессрочно: без заголовка контракт прежний, на нём стоят мобильные клиенты. Клиенты переезжают по одному.

  1. Перестать писать refresh-токен в Web Storage.
  2. При старте: если в хранилище лежит прежний токен — один refresh с ним телом и заголовком (identity примет его, только пока куки нет), затем стереть ключ после любого ответа, кроме 5xx. Иначе — refresh без тела.
  3. remember me → persistent, иначе transient. Выбор — не секрет и может лежать в Web Storage: заголовок повторяется на каждом refresh.
  4. Если identity ответил парой телом (его откатили), refresh-токен держать только в памяти: работать можно, F5 потребует нового входа.

Порядок выкатки

  1. identity (identity#161) — мерж без эффекта, флаг выключен.
  2. api-gateway (api-gateway#106) — мерж без эффекта, список префиксов пуст. KazMaps-Session в Access-Control-Allow-Headers безвреден.
  3. 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.
  4. Клиенты по одному: консоль модератора (moderator-console#47, эталон) → main-web → booking-client → business-client.

Откат

  • Клиент — предыдущий образ: он снова пишет токен в хранилище; людям нужно войти один раз заново.
  • identity — SESSION_COOKIE_ENABLED=false: заголовок не читается, клиенты в режиме куки получают пару телом и держат её в памяти. Куки в браузерах остаются и снова заработают при включении.
  • api-gateway — пустой CORS_CREDENTIALS_PREFIXES, но только после отката фронтов, которые ходят напрямую: иначе их refresh упадёт на CORS.

Клиенты

КлиентСегодняРежимЧто сделать
moderator-consolerefresh в памяти Worker’а + sessionStorageпрокси Go-сервера, transientсделано — moderator-console#47
main-webrefresh в sessionStorage (kazmaps.core.refresh), access в памятинапрямуюсм. ниже
booking-clientrefresh в localStorage/sessionStorage (kazmaps.auth.refresh)прокси /api/gatewayсм. ниже
business-clientrefresh в 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 сегодня.
Last updated on