Skip to Content
Authentication flow

Authentication flow

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

Содержание

Обзор

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

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

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, проверяет user-denylist (Redis SET banned_users), срезает любые клиентские identity-заголовки, подписывает HMAC-headers и проксирует во внутренний сервис по таблице маршрутов. Бэкенды internal-only. Решение и обоснование — ADR k8s-gitops/docs/adr/0002-api-gateway-forward-auth-proxy.JWT_SECRET, GATEWAY_HMAC_SECRET_PRIMARY, GATEWAY_HMAC_SECRET_PREVIOUS
user-serviceВыписывает JWT при логине/refresh, валидирует credentials (argon2id), отзывает токены через Redis-denylist.JWT_SECRET, GATEWAY_HMAC_SECRET_PRIMARY, GATEWAY_HMAC_SECRET_PREVIOUS
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 (denylist)Хранит список отозванных user_id / token_version.

Sequence diagram

Ключевое: на backend приходит не JWT, а набор headers с подписью. Никакой backend не знает JWT_SECRET и не парсит токен.

Что в JWT

access_token — JWT, подписанный HS256 на JWT_SECRET. Claims:

ClaimЗначение
subuser_id (BIGINT)
roleроль пользователя (user, moderator, admin)
iatissued-at, unix-seconds
expexpires-at, unix-seconds (iat + 15 min)
token_versionмонотонный счётчик инвалидации; растёт при смене пароля, ban’е, подозрении на компрометацию

TTL access_token~15 минут. Короткий срок — чтобы revoke через denylist быстро эффективен.

refresh_token — отдельный, выписывается вместе с access при логине. Формат: JWT с sub, iat, exp, token_family_id (uuid) или opaque (непрозрачный random-string, хранящийся на стороне user-service). TTL — ~30 дней. Хранится у клиента в secure storage (Keychain / Keystore). При использовании — ротация: выдаётся новый refresh, старый инвалидируется (detection компрометации по reuse).

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 во всех 6 middleware (media, notification, catalog, 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.Equalconstant-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 в user-service. user-service проверяет подпись, exp, наличие в denylist по token_family_id. Если что-то не ок — 401 с error_code = "refresh_expired" (или refresh_revoked). Клиент → логаут → редирект на login.

User banned

Модератор забанил пользователя → user-service добавил user_id в SADD revoked_users. На следующем запросе Gateway делает SISMEMBER revoked_users <user_id> → true → 401 от Gateway. Запрос не дойдёт до backend, даже если JWT формально валиден до exp.

Retention в denylist — пока token_version всех active-токенов не превысит зафиксированный. На практике: после ban’а denylist-запись держится 15 минут (TTL access’а), затем все старые токены всё равно протухли — запись безопасно удалить. Очистка — отдельный Job.

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_SECRETSecret manager (vault / k8s Secret)только user-service + Gateway
GATEWAY_HMAC_SECRET_PRIMARYSecret managerGateway + все backend-сервисы
GATEWAY_HMAC_SECRET_PREVIOUSSecret managerGateway + все backend-сервисы (окно ротации)
INTERNAL_API_TOKENSecret managerТолько сервисы, которые ходят друг в друга по /internal/*
REFRESH_TOKEN_SECRETSecret managerТолько user-service

Правила:

  • Секреты загружаются через 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 в user-service-handler’ах. user-service отдаёт токены, но валидация 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 + user-service.

Доверие 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 дней = denylist не работает (ban срабатывает только через неделю). TTL access — 15 минут, не больше. Revoke-window короткий по дизайну.

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

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

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

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

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

Last updated on