Skip to Content
Authentication flow

Authentication flow

End-to-end описание, как запрос клиента проходит через Gateway до backend- сервиса и что именно кто проверяет на каждом шаге. Reference по JWT, HMAC и секретам — conventions/security. Процедура ротации — how-to/rotate-jwt-key. Эта страница отвечает на вопрос: кто владеет какой проверкой, где границы, что делает клиент в каждом сценарии ошибки.

Содержание

Обзор

Auth в нашей системе разделён на два уровня. JWT живёт в identity (который их выписывает и сам проверяет на своих маршрутах) и в Gateway (который их проверяет на каждом запросе). Остальные backend-сервисы не разбирают JWT; единственное исключение — track-service, который держит копию JWT_SECRET и читает из токена только sub. Вместо этого Gateway после успешной проверки JWT подписывает свой набор headers (X-User-Id, X-User-Role и т.п.) через HMAC-SHA256 и передаёт их backend’у. Backend валидирует HMAC, не JWT.

Такое разделение даёт две вещи: JWT-секрет не нужен в каждом сервисе (меньше утечек, проще ротация), и backend-сервисы не зависят от формата JWT — любое изменение claims’ов локализовано в identity + Gateway (и track-service, пока у него копия секрета).

Actors

УчастникЧто делаетКакие секреты знает
Client (mobile/web)Логинится, хранит access_token и refresh_token, шлёт Authorization: Bearer <access_token> в каждый защищённый запрос. Обновляет access через refresh при 401.—
Gateway (Cilium Gateway API + api-gateway — forward-auth прокси)api-gateway — единственный публичный вход: валидирует JWT (подпись, издатель kazmaps-auth, exp), сверяет отзыв в Redis — SET banned_users и водяной знак user:<id>:tv против iat, срезает любые клиентские identity-заголовки, подписывает HMAC-headers и проксирует во внутренний сервис по таблице маршрутов. Бэкенды internal-only. Решение и обоснование — ADR k8s-gitops/docs/adr/0002-api-gateway-forward-auth-proxy.JWT_SECRET, JWT_SECRET_PREVIOUS, GATEWAY_HMAC_SECRET_PRIMARY, GATEWAY_HMAC_SECRET_PREVIOUS
identityВыписывает access-токен (JWT) и вращающийся refresh-токен при логине и refresh, валидирует credentials (argon2id), ведёт сессии. Отзыв — сдвиг водяного знака tokens_valid_from и бан; оба публикует в Redis для шлюза. Свои маршруты (/identity/*, auth: public на шлюзе) проверяет сам по Bearer.JWT_SECRET, JWT_SECRET_PREVIOUS, REFRESH_GRACE_KEY
Backend services (review, media, notification, …)Проверяют HMAC-headers через middleware GatewayAuth, читают X-User-Id/X-User-Role, работают по бизнес-логике.GATEWAY_HMAC_SECRET_PRIMARY, GATEWAY_HMAC_SECRET_PREVIOUS, INTERNAL_API_TOKEN (если вызывают другой backend)
Redis (состояние отзыва)Два ключа, которые пишет identity и читает шлюз: user:<id>:tv — водяной знак в unix-секундах, SET banned_users — бессрочно забаненные.—

Sequence diagram

Ключевое: на backend приходит не JWT, а набор headers с подписью. Backend за шлюзом не знает JWT_SECRET и не парсит токен (кроме track-service, см. выше).

Что в JWT

access_token — JWT, подписанный HS256 на JWT_SECRET (проверка идёт JWT_SECRET, затем JWT_SECRET_PREVIOUS — так ротируется секрет). Claims:

ClaimЗначение
subuser_id строкой ("42")
roleроль пользователя (user, moderator, admin)
sidid сессии, которой выдан токен, строкой; ставится на логине и refresh (identity#156). Токены, выпущенные раньше, его не несут
tvнаследие, всегда 0; никем не читается
isskazmaps-auth — шлюз и track-service сверяют издателя
audтолько если задан JWT_AUDIENCE
iat, nbfмомент выпуска, unix-seconds
expiat + JWT_ACCESS_TTL (по умолчанию 5 минут)

Неизвестные claims шлюз, track-service и клиенты игнорируют, поэтому новый claim добавляется без согласованной выкатки.

Отзыв — уровня пользователя, через водяной знак tokens_valid_from: смена пароля, бан, смена роли и выход со всех устройств (DELETE /v1/me/sessions) двигают его вперёд, и access-токен с iat раньше знака шлюз отвергает. Знак один на человека, поэтому выход с одного устройства (DELETE /v1/me/sessions/{id}) и «выйти на остальных» (DELETE /v1/me/sessions?except=current) его не двигают: access-токены закрытых сессий доживают до exp, а их refresh перестаёт работать сразу. Короткий TTL access — то, что держит это окно маленьким.

refresh_token — непрозрачная случайная строка; identity хранит только её хеш, один неиспользованный на сессию. TTL — JWT_REFRESH_TTL (по умолчанию 720 часов, 30 дней). Хранится у клиента в secure storage (Keychain / Keystore). Каждый POST /v1/auth/refresh вращает его: выдаётся новая пара, прежний refresh гаснет. Повтор того же refresh в короткое окно отсрочки (обрыв сети на ответе) возвращает ту же новую пару; повтор вне окна — 401 invalid_credentials.

Сессии видны владельцу: GET /v1/me/sessions отдаёт их с полем current (сессия вызывающего токена, по sid).

HMAC-подписанные headers

После успешной валидации JWT Gateway формирует canonical string и подписывает HMAC-SHA256 на GATEWAY_HMAC_SECRET_PRIMARY:

canonical = X-User-ID + "|" + X-User-Role + "|" + X-Gateway-Iat X-Gateway-Signature = hex(HMAC-SHA256(GATEWAY_HMAC_SECRET_PRIMARY, canonical))

X-Gateway-Iat — момент подписи (unix seconds), который Gateway (api-gateway) проставляет на каждый запрос. Он входит в подпись, поэтому его нельзя подменить, и даёт bounded replay: каждый backend отвергает подпись, если |now − iat| > 60s.

:::note Anti-replay: окно свежести vs. одноразовость Реализовано окно свежести: iat в каноне + проверка ±60s во всех 5 middleware (media, notification, review, listings, indoor) и в api-gateway-подписи — перехваченную подпись нельзя переиграть позже окна. Полная одноразовость (single-use X-Request-Id/nonce со стором использованных id) пока не реализована — следующий хард; listings без Redis потребует общего nonce-стора. До тех пор X-Request-Id в подпись не входит. :::

Headers, передаваемые backend’у:

HeaderЧто в нём
X-User-IDuser_id (string, decimal)
X-User-Roleроль (user/moderator/admin)
X-Gateway-Iatмомент подписи Gateway, unix seconds (входит в подпись)
X-Gateway-Signaturehex HMAC-SHA256 над userID|role|iat

X-Gateway-Iat даёт bounded replay: backend отвергает запрос, если подпись старше окна свежести (±60s от своего now, толерантно к рассинхрону часов). X-Request-Id (сквозной correlation-id) может присутствовать для логов, но в подпись не входит и одноразовость пока не обеспечивает.

Валидация на backend

Middleware GatewayAuth стоит в chi-роутере сразу после middleware.RequestID и до бизнес-middleware. Скетч:

func GatewayAuth(primary, previous []byte) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rawSig, err := hex.DecodeString(r.Header.Get("X-Gateway-Signature")) if err != nil || len(rawSig) == 0 { writeErr(w, http.StatusUnauthorized, "missing or malformed gateway signature") return } iat := r.Header.Get("X-Gateway-Iat") canonical := []byte( r.Header.Get("X-User-Id") + "|" + r.Header.Get("X-User-Role") + "|" + iat, ) if !verifyHMAC(primary, canonical, rawSig) && !(len(previous) > 0 && verifyHMAC(previous, canonical, rawSig)) { writeErr(w, http.StatusUnauthorized, "invalid gateway signature") return } // Freshness: iat is authenticated above (it's in the signature), so it // can't be forged. Reject a captured signature outside a ±60s window. iatUnix, err := strconv.ParseInt(iat, 10, 64) if err != nil { writeErr(w, http.StatusUnauthorized, "invalid gateway iat") return } if d := time.Now().Unix() - iatUnix; d > 60 || d < -60 { writeErr(w, http.StatusUnauthorized, "stale gateway signature") return } uid, err := strconv.ParseInt(r.Header.Get("X-User-Id"), 10, 64) if err != nil { writeErr(w, http.StatusUnauthorized, "invalid user id") return } ctx := authctx.WithUser(r.Context(), authctx.User{ ID: uid, Role: r.Header.Get("X-User-Role"), }) next.ServeHTTP(w, r.WithContext(ctx)) }) } } func verifyHMAC(key, canonical, sig []byte) bool { mac := hmac.New(sha256.New, key) mac.Write(canonical) return hmac.Equal(sig, mac.Sum(nil)) }

Важные свойства:

  • hmac.Equal — constant-time сравнение на raw bytes. Никогда ==, никогда на hex-строках ([]byte(sig) == []byte(expected) — ошибка; даже hmac.Equal на hex-строках работает, но раздувает сравниваемую длину в 2 раза без пользы).
  • hex.DecodeString вызывается один раз на заголовок; дальше HMAC вычисляется на raw и сравнивается с raw.
  • Dual-key (PRIMARY + PREVIOUS) — оба валидны. Это нужно для безразрывной ротации. Детали — how-to/rotate-jwt-key.
  • authctx.WithUser кладёт user_id/role в контекст; handler’ы забирают через authctx.FromCtx(ctx).

Handler после middleware:

func (h *ReviewHandler) Create(w http.ResponseWriter, r *http.Request) { u, ok := authctx.FromCtx(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "no user in context") return } // u.ID, u.Role — гарантированно пришли от Gateway через HMAC ... }

Сценарии ошибок

Access token expired

Клиент шлёт запрос с просроченным access_token. Gateway парсит JWT, видит exp < now, возвращает 401. Клиент видит 401 → автоматически делает POST /v1/auth/refresh с refresh_token → получает новую пару (access, refresh) → повторяет исходный запрос.

Важно: при 401 клиент не логаутит пользователя, а сначала пробует refresh. Логаут — только если refresh тоже вернул 401.

Refresh token expired / revoked

Gateway форвардит refresh в identity. identity ищет сессию по хешу refresh’а и проверяет, что она жива, не истекла и не старше водяного знака. Если что-то не ок — 401 invalid_credentials, без уточнения причины. Клиент → логаут → редирект на login.

User banned

Модератор забанил пользователя → identity закрывает его сессии и публикует состояние: бессрочный бан попадает в SET banned_users, срочный — только сдвигает водяной знак user:<id>:tv (членство в SET истечь не может). На следующем запросе Gateway видит бан или iat раньше знака → 401 от Gateway. Запрос не дойдёт до backend, даже если JWT формально валиден до exp.

Публикация идёт после коммита и best-effort: если запись в Redis не удалась, отзыв вступает в силу не позже JWT_ACCESS_TTL. Разбан убирает пользователя из banned_users; отдельной очистки нет.

Invalid HMAC

Возможные причины:

  • Неправильная ротация ключа. Backend обновил секрет, Gateway ещё нет (или наоборот). Dual-key (PRIMARY + PREVIOUS) защищает от этого: оба ключа валидны в окне ротации. Если кто-то выпустил только один — тогда backend вернёт 401, алерт сработает.
  • Прямое обращение к pod’у backend’а мимо Gateway (например, debug через kubectl port-forward). Подпись отсутствует — 401. Это штатный режим: backend не доверяет никому, кроме Gateway.

Alert на частые 401 от GatewayAuth — важный сигнал: либо rotation сломана, либо кто-то пытается ломиться в обход Gateway.

Key rotation (dual-key)

При ротации HMAC-ключа действует двухфазный режим:

  1. Фаза 1: PRIMARY=new, PREVIOUS=old. Все принимают оба. Gateway подписывает new.
  2. Фаза 2 (после TTL access-токенов): PREVIOUS="", остаётся только PRIMARY=new.

Пошаговая процедура — how-to/rotate-jwt-key.

Internal endpoints

Для вызовов «backend → backend» (например, place-service зовёт review-service за /internal/places/42/rating) HMAC не используется — там нет user’а, вызов сервисный. Вместо этого /internal/* endpoint’ы защищены статическим токеном INTERNAL_API_TOKEN:

Authorization: Bearer <INTERNAL_API_TOKEN>

Middleware на стороне receiver’а — InternalAuth, отдельный от GatewayAuth. Constant-time сравнение токена, никакого HMAC, никакого HTTP-dispatch’а. В дополнение — NetworkPolicy в Kubernetes: на /internal/* принимаются запросы только из namespace=backend, что исключает обращение снаружи кластера даже если токен утёк.

Client при вызове другого сервиса:

req.Header.Set("Authorization", "Bearer "+cfg.InternalToken)

/internal/* никогда не проходит через Gateway и не имеет HMAC-headers. Разделение явное: /v1/* — user-facing через Gateway, /internal/* — service-to-service напрямую.

Secret boundaries

СекретГде живётКто читает
JWT_SECRET, JWT_SECRET_PREVIOUSSecret manager (vault / k8s Secret)identity (подписывает), Gateway (проверяет); копия JWT_SECRET — у track-service
GATEWAY_HMAC_SECRET_PRIMARYSecret managerGateway + все backend-сервисы
GATEWAY_HMAC_SECRET_PREVIOUSSecret managerGateway + все backend-сервисы (окно ротации)
INTERNAL_API_TOKENSecret managerТолько сервисы, которые ходят друг в друга по /internal/*
REFRESH_GRACE_KEYSecret managerТолько identity: шифрует сменный refresh на время окна отсрочки

Правила:

  • Секреты загружаются через env-переменные, никогда не хардкодятся. См. conventions/configuration.
  • .env.example содержит имена переменных с placeholder’ом, не реальные значения.
  • В Kubernetes секреты монтируются из Secret, не из ConfigMap.
  • Ротация HMAC — через dual-key, см. соответствующий how-to.

Тестирование auth

Unit

Backend-handler’ы не тестируют HMAC. Они полагаются, что если запрос дошёл до handler’а, middleware уже всё проверила. В тестах — helper, который кладёт authctx.User в контекст напрямую:

func ctxWithTestUser(id int64, role string) context.Context { return authctx.WithUser(context.Background(), authctx.User{ ID: id, Role: role, }) } func TestReviewHandler_Create(t *testing.T) { ctx := ctxWithTestUser(42, "user") // ... вызываем handler с этим ctx }

Middleware GatewayAuth тестируется отдельно: подставляешь известный GATEWAY_HMAC_SECRET_PRIMARY, формируешь headers, считаешь HMAC, проверяешь что middleware принимает. Плюс негативные: нет signature, невалидная signature, только PREVIOUS ключ подходит.

Integration

Docker-compose с Gateway + backend. Тест получает access_token (через login endpoint или shortcut-helper), шлёт запрос на Gateway, проверяет что backend отдаёт ожидаемое. Проверка round-trip HMAC: реальный Gateway с реальным секретом, реальный middleware у backend’а.

Что НЕ тестировать

  • Валидацию JWT в backend-сервисах. Backend JWT не парсит. Если появился тест TestJWTValidation в review-service — это баг теста.
  • HMAC в handler’ах identity. identity отдаёт токены и свои маршруты проверяет по Bearer сам; валидация HMAC — на Gateway и на остальных backend’ах.

Anti-patterns

Backend валидирует JWT сам

// Плохо: review-service парсит JWT token, err := jwt.Parse(r.Header.Get("Authorization"), keyFunc)

Последствия: JWT_SECRET приходится раздавать везде, ротация сложнее, изменение claims ломает все backend’ы одновременно. Правильно — GatewayAuth middleware, JWT только в Gateway + identity. track-service — известное исключение (копия JWT_SECRET), новых не заводим.

Доверие X-User-Id без HMAC-проверки

// Плохо: читаем header без валидации подписи uid := r.Header.Get("X-User-Id")

Если backend pod’у можно обратиться напрямую (port-forward, небрежная NetworkPolicy), злоумышленник подделает header. HMAC — обязателен.

Хардкод ключей в compose

# Плохо services: review: environment: GATEWAY_HMAC_SECRET_PRIMARY: "hardcoded-secret-value"

Секреты утекают в git. Используй env-подстановку из .env (локально) и из secret manager (прод). См. conventions/configuration.

Долгоживущий access_token

Access с TTL 7 дней = выход с одного устройства и «выйти на остальных» не действуют неделю: их водяной знак не выражает, токен доживает до exp. TTL access — JWT_ACCESS_TTL, по умолчанию 5 минут, не больше 15. Revoke-window короткий по дизайну.

Отсутствие X-Gateway-Signature в проверке

Middleware проверяет только X-User-Id != "" и разрешает запрос. Любой может поставить header и притвориться любым user’ом. Подпись — обязательна, и сравнение — constant-time.

Один общий INTERNAL_API_TOKEN на все сервисы

Утечка в одном месте компрометирует все /internal/*. Предпочтительно — per-caller токены, ротация независимая. Если пока один общий — минимум короткий TTL (сутки, ротация автоматом).

Связанные разделы

Last updated on