Authentication flow
End-to-end описание, как запрос клиента проходит через Gateway до backend-
сервиса и что именно кто проверяет на каждом шаге. Reference по JWT,
HMAC и секретам — conventions/security.
Процедура ротации — how-to/rotate-jwt-key.
Эта страница отвечает на вопрос: кто владеет какой проверкой, где
границы, что делает клиент в каждом сценарии ошибки.
Содержание
- Обзор
- Actors
- Sequence diagram
- Что в JWT
- HMAC-подписанные headers
- Валидация на backend
- Сценарии ошибок
- Internal endpoints
- Secret boundaries
- Тестирование auth
- Anti-patterns
- Связанные разделы
Обзор
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 | Значение |
|---|---|
sub | user_id строкой ("42") |
role | роль пользователя (user, moderator, admin) |
sid | id сессии, которой выдан токен, строкой; ставится на логине и refresh (identity#156). Токены, выпущенные раньше, его не несут |
tv | наследие, всегда 0; никем не читается |
iss | kazmaps-auth — шлюз и track-service сверяют издателя |
aud | только если задан JWT_AUDIENCE |
iat, nbf | момент выпуска, unix-seconds |
exp | iat + 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-ID | user_id (string, decimal) |
X-User-Role | роль (user/moderator/admin) |
X-Gateway-Iat | момент подписи Gateway, unix seconds (входит в подпись) |
X-Gateway-Signature | hex 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:
PRIMARY=new,PREVIOUS=old. Все принимают оба. Gateway подписываетnew. - Фаза 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_PREVIOUS | Secret manager (vault / k8s Secret) | identity (подписывает), Gateway (проверяет); копия JWT_SECRET — у track-service |
GATEWAY_HMAC_SECRET_PRIMARY | Secret manager | Gateway + все backend-сервисы |
GATEWAY_HMAC_SECRET_PREVIOUS | Secret manager | Gateway + все backend-сервисы (окно ротации) |
INTERNAL_API_TOKEN | Secret manager | Только сервисы, которые ходят друг в друга по /internal/* |
REFRESH_GRACE_KEY | Secret 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 (сутки, ротация автоматом).
Связанные разделы
conventions/security— JWT,GatewayAuth,InternalAuth, argon2id для паролей, rate-limit.how-to/rotate-jwt-key— пошаговая процедура ротации HMAC-ключа без downtime.conventions/configuration— как секреты читаются из env,Redacted-типы,.env.example.conventions/http-api— middleware stack,/v1/*vs/internal/*, где GatewayAuth в цепочке.architecture-overview— где identity и Gateway в общей картине.glossary— JWT, HMAC, denylist.