Перейти к содержанию

Authentication flow

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

Содержание

Обзор

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.Equalconstant-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. Фаза 1: PRIMARY=new, PREVIOUS=old. Все принимают оба. Gateway подписывает new.
  2. Фаза 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:

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
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 сам

// Плохо: 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:
      AUTH_HMAC_PRIMARY: "hardcoded-secret-value"

Секреты утекают в 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 (сутки, ротация автоматом).

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