Authentication flow¶
End-to-end описание, как запрос клиента проходит через Gateway до backend-
сервиса и что именно кто проверяет на каждом шаге. Reference по JWT,
HMAC и секретам — conventions/security.md.
Процедура ротации — how-to/rotate-jwt-key.md.
Эта страница отвечает на вопрос: кто владеет какой проверкой, где
границы, что делает клиент в каждом сценарии ошибки.
Содержание¶
- Обзор
- 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 (Traefik + middleware) | Роутит, валидирует JWT, проверяет user-denylist в Redis, подписывает HMAC-headers, форвардит запрос в backend. | JWT_SECRET, AUTH_HMAC_PRIMARY, AUTH_HMAC_PREVIOUS |
| user-service | Выписывает JWT при логине/refresh, валидирует credentials (argon2id), отзывает токены через Redis-denylist. | JWT_SECRET, AUTH_HMAC_PRIMARY, AUTH_HMAC_PREVIOUS |
| Backend services (review, media, notification, …) | Проверяют HMAC-headers через middleware GatewayAuth, читают X-User-Id/X-User-Role, работают по бизнес-логике. |
AUTH_HMAC_PRIMARY, AUTH_HMAC_PREVIOUS, INTERNAL_API_TOKEN (если вызывают другой backend) |
| Redis (denylist) | Хранит список отозванных user_id / token_version. | — |
Sequence diagram¶
sequenceDiagram
autonumber
participant C as Client
participant G as Gateway
participant U as user-service
participant R as Redis (denylist)
participant S as Backend (e.g. review)
Note over C,U: Логин
C->>G: POST /v1/auth/login {email, password}
G->>U: POST /v1/auth/login (passthrough)
U->>U: Validate credentials (argon2id)
U-->>G: {access_token, refresh_token}
G-->>C: 200 OK + tokens
Note over C,S: Запрос к защищённому endpoint
C->>G: GET /v1/reviews<br/>Authorization: Bearer <access_token>
G->>G: Parse JWT, validate signature (JWT_SECRET)
G->>R: SISMEMBER revoked_users <user_id>
R-->>G: ok
G->>G: Sign headers (HMAC)
G->>S: GET /v1/reviews<br/>X-User-Id, X-User-Role, X-User-Iat, X-Request-Id, X-Gateway-Signature
S->>S: GatewayAuth middleware: validate HMAC
S->>S: handler
S-->>G: 200 OK
G-->>C: 200 OK
Ключевое: на 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 на AUTH_HMAC_PRIMARY:
canonical = X-User-Id + "\n" +
X-User-Role + "\n" +
X-User-Iat + "\n" +
X-Request-Id
X-Gateway-Signature = hex(HMAC-SHA256(AUTH_HMAC_PRIMARY, canonical))
Headers, передаваемые backend'у:
| Header | Что в нём |
|---|---|
X-User-Id |
user_id (string, decimal) |
X-User-Role |
роль (user/moderator/admin) |
X-User-Iat |
issued-at, epoch seconds |
X-Request-Id |
uuid запроса, сквозной для correlation |
X-Gateway-Signature |
hex HMAC-SHA256 |
X-User-Iat защищает от replay старыми заголовками после revoke:
backend сравнивает с минимально допустимым iat (опционально, если
включён анти-replay).
Валидация на 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) {
sig := r.Header.Get("X-Gateway-Signature")
if sig == "" {
writeErr(w, http.StatusUnauthorized, "missing gateway signature")
return
}
canonical := r.Header.Get("X-User-Id") + "\n" +
r.Header.Get("X-User-Role") + "\n" +
r.Header.Get("X-User-Iat") + "\n" +
r.Header.Get("X-Request-Id")
expected := hex.EncodeToString(hmacSHA256(primary, canonical))
if !hmac.Equal([]byte(sig), []byte(expected)) {
// fallback на PREVIOUS ключ (rotation window)
expected = hex.EncodeToString(hmacSHA256(previous, canonical))
if !hmac.Equal([]byte(sig), []byte(expected)) {
writeErr(w, http.StatusUnauthorized, "invalid 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))
})
}
}
Важные свойства:
hmac.Equal— constant-time сравнение. Никогда==.- Dual-key (PRIMARY + PREVIOUS) — оба валидны. Это нужно для
безразрывной ротации. Детали —
how-to/rotate-jwt-key.md. 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.md.
Internal endpoints¶
Для вызовов «backend → backend» (например, place-service зовёт
review-service за /internal/places/42/rating) HMAC не используется —
там нет user'а, вызов сервисный. Вместо этого /internal/* endpoint'ы
защищены статическим токеном INTERNAL_API_TOKEN:
Middleware на стороне receiver'а — InternalAuth, отдельный от
GatewayAuth. Constant-time сравнение токена, никакого HMAC, никакого
HTTP-dispatch'а. В дополнение — NetworkPolicy в Kubernetes: на
/internal/* принимаются запросы только из namespace=backend,
что исключает обращение снаружи кластера даже если токен утёк.
Client при вызове другого сервиса:
/internal/* никогда не проходит через Gateway и не имеет
HMAC-headers. Разделение явное: /v1/* — user-facing через Gateway,
/internal/* — service-to-service напрямую.
Secret boundaries¶
| Секрет | Где живёт | Кто читает |
|---|---|---|
JWT_SECRET |
Secret manager (vault / k8s Secret) | только user-service + Gateway |
AUTH_HMAC_PRIMARY |
Secret manager | Gateway + все backend-сервисы |
AUTH_HMAC_PREVIOUS |
Secret manager | Gateway + все backend-сервисы (окно ротации) |
INTERNAL_API_TOKEN |
Secret manager | Только сервисы, которые ходят друг в друга по /internal/* |
REFRESH_TOKEN_SECRET |
Secret manager | Только user-service |
Правила:
- Секреты загружаются через env-переменные, никогда не хардкодятся.
См.
conventions/configuration.md. .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 тестируется отдельно: подставляешь
известный AUTH_HMAC_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 сам¶
Последствия: JWT_SECRET приходится раздавать везде, ротация сложнее, изменение claims ломает все backend'ы одновременно. Правильно — GatewayAuth middleware, JWT только в Gateway + user-service.
Доверие X-User-Id без HMAC-проверки¶
Если backend pod'у можно обратиться напрямую (port-forward, небрежная NetworkPolicy), злоумышленник подделает header. HMAC — обязателен.
Хардкод ключей в compose¶
Секреты утекают в git. Используй env-подстановку из .env (локально)
и из secret manager (прод). См.
conventions/configuration.md.
Долгоживущий 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.md— JWT,GatewayAuth,InternalAuth, argon2id для паролей, rate-limit.how-to/rotate-jwt-key.md— пошаговая процедура ротации HMAC-ключа без downtime.conventions/configuration.md— как секреты читаются из env,Redacted-типы,.env.example.conventions/http-api.md— middleware stack,/v1/*vs/internal/*, где GatewayAuth в цепочке.architecture-overview.md— где user-service и Gateway в общей картине.glossary.md— JWT, HMAC, denylist, token_version.