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 живёт только в
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 | Значение |
|---|---|
sub | user_id (BIGINT) |
role | роль пользователя (user, moderator, admin) |
iat | issued-at, unix-seconds |
exp | expires-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-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 в 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:
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 | Secret manager (vault / k8s Secret) | только user-service + Gateway |
GATEWAY_HMAC_SECRET_PRIMARY | Secret manager | Gateway + все backend-сервисы |
GATEWAY_HMAC_SECRET_PREVIOUS | Secret manager | Gateway + все backend-сервисы (окно ротации) |
INTERNAL_API_TOKEN | Secret manager | Только сервисы, которые ходят друг в друга по /internal/* |
REFRESH_TOKEN_SECRET | Secret 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 (сутки, ротация автоматом).
Связанные разделы
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— где user-service и Gateway в общей картине.glossary— JWT, HMAC, denylist, token_version.