19. Authentication. JWT

В программировании распределенных систем существует элегантная аналогия: традиционная сессия похожа на гардеробный номерок в театре — вы отдаете пальто служителю, получаете пластиковый жетон с числом, а ваше пальто висит на складе (в Redis или базе данных). Сервер обязан помнить, что скрывается за каждым номерком.

JSON Web Token (JWT) устроен принципиально иначе. Это не гардеробный жетон, а посадочный талон на самолет или дипломатическая депеша с сургучной печатью. Вся информация (ваше имя, номер рейса, класс обслуживания и срок действия) напечатана прямо на бланке. Любой контролер у выхода на посадку может прочесть этот текст глазами. Но подделать его нельзя: цифровая криптографическая подпись авиакомпании на бланке гарантирует, что пассажир не переписал шариковой ручкой место 38B в эконом-классе на 1A в первом.

Философия и структура JWT

JWT (JSON Web Token, RFC 7519) — это открытый стандарт для создания компактных и самодостаточных токенов безопасности, используемый для stateless-аутентификации в распределенных системах. В отличие от классических сессий, где состояние хранится на сервере (в памяти, Redis или БД), JWT переносит состояние на клиента. Это устраняет необходимость синхронизации сессий между инстансами, упрощает горизонтальное масштабирование и снижает latency за счет отсутствия сетевых вызовов к хранилищу состояний на каждый входящий HTTP-запрос.

Токен представляет собой компактную текстовую строку, состоящую из трех частей, разделенных точками: header.payload.signature. Все части кодируются в Base64URL.

flowchart LR
    A["Заголовок (Header)<br>alg, typ"] --> B["Полезная нагрузка (Payload)<br>sub, exp, iat, roles"]
    B --> C["Криптографическая<br>подпись HMAC/RSA"]
    C --> D["Итоговая строка<br>header.payload.signature"]
    D --> E["Клиент хранит в<br>Memory/Cookie/Storage"]
    E --> F["Сервер проверяет<br>только подпись"]

    classDef token fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef client fill:#334155,stroke:#818cf8,color:#f8fafc;
    classDef verify fill:#0f172a,stroke:#10b981,color:#f8fafc;
    class A,B,C,D token;
    class E client;
    class F verify;
  
  1. Header (Заголовок): JSON-объект, содержащий метаданные о типе токена ("typ": "JWT") и криптографическом алгоритме подписи ("alg": "HS256", "RS256", "ES256").
  2. Payload (Полезная нагрузка): JSON-объект с набором утверждений (claims) о субъекте (sub — идентификатор пользователя), времени создания (iat), времени истечения (exp) и произвольных прикладных ролях (roles).
  3. Signature (Цифровая подпись): Хэш от конкатенации закодированного заголовка и полезной нагрузки, подписанный секретным или приватным ключом.

Криптография под капотом

Подпись JWT генерируется и проверяется с использованием криптографических примитивов из стандартной библиотеки crypto. Выбор алгоритма напрямую влияет на производительность и архитектуру.

  • HMAC-SHA256 (HS256): Симметричный алгоритм. Один общий секрет используется для подписи и проверки. Быстрый, но требует безопасной доставки ключа на все инстансы. Если скомпрометирован хотя бы один сервис-верификатор, атакующий получает возможность выпускать поддельные токены для всей системы.
  • RSA/ECDSA (RS256/ES256): Асимметричные алгоритмы. Приватный ключ подписывает на Auth-сервисе, публичный ключ проверяет на микросервисах. Безопаснее в распределенных кластерах, но дороже по CPU.
Go
// Пример подписи на стороне Auth-сервиса
import (
    "crypto"
    "crypto/rand"
    "crypto/rsa"
    "crypto/sha256"
    "math/big"
)

func signRS256(header, payload []byte, priv *rsa.PrivateKey) ([]byte, error) {
    data := append(header, []byte(".")...)
    data = append(data, payload...)

    hash := sha256.Sum256(data)

    // RSA-SHA256 PKCS#1 v1.5 подпись
    return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hash[:])
}

Идиоматичная реализация в Go

Стандарт де-факто — пакет github.com/golang-jwt/jwt/v5. Он строго типизирован, поддерживает кастомные claims и безопасен по умолчанию (rejects alg: none, проверяет exp).

Ниже представлена надежная реализация проверки JWT через HTTP middleware:

Go
package auth

import (
    "context"
    "errors"
    "fmt"
    "net/http"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type contextKey string

const claimsContextKey contextKey = "claims"

type Claims struct {
    UserID int64  `json:"sub"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

var (
    ErrInvalidToken  = errors.New("invalid token")
    ErrExpiredToken  = errors.New("token expired")
    ErrMissingBearer = errors.New("missing Bearer token")
)

// JWTMiddleware проверяет токен и инжектирует Claims в контекст
func JWTMiddleware(secret []byte) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            tokenStr := extractBearerToken(r)
            if tokenStr == "" {
                http.Error(w, ErrMissingBearer.Error(), http.StatusUnauthorized)
                return
            }

            claims := &Claims{}
            token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
                // Защита от атаки смены алгоритма (Algorithm Confusion)
                if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                    return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
                }
                return secret, nil
            })

            if err != nil {
                if errors.Is(err, jwt.ErrTokenExpired) {
                    http.Error(w, ErrExpiredToken.Error(), http.StatusUnauthorized)
                    return
                }
                http.Error(w, ErrInvalidToken.Error(), http.StatusUnauthorized)
                return
            }

            if !token.Valid {
                http.Error(w, ErrInvalidToken.Error(), http.StatusUnauthorized)
                return
            }

            // Инжекция в контекст для следующих слоев через типизированный ключ
            ctx := context.WithValue(r.Context(), claimsContextKey, claims)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func extractBearerToken(r *http.Request) string {
    auth := r.Header.Get("Authorization")
    if !strings.HasPrefix(auth, "Bearer ") {
        return ""
    }
    return strings.TrimPrefix(auth, "Bearer ")
}

// ClaimsFromContext безопасно извлекает утверждения из контекста
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
    claims, ok := ctx.Value(claimsContextKey).(*Claims)
    return claims, ok
}

Проблема отзыва и Refresh Token Pattern

JWT stateless по дизайну. Сервер не хранит список активных токенов, поэтому мгновенно отозвать конкретный токен невозможно (до истечения exp). Это фундаментальный компромисс. Если учетная запись пользователя скомпрометирована или права отозваны, выданный JWT остается действительным вплоть до секунды, указанной в поле exp.

Решение в production — дуальная архитектура Access и Refresh токенов:

  1. Короткоживущие Access Token (exp: 15m), передаваемые клиентом в каждом запросе.
  2. Долгоживущие Refresh Token (exp: 30d), хранящиеся в защищенной БД/Redis на стороне бэкенда.
  3. При истечении Access Token клиент шлет Refresh Token на эндпоинт /auth/refresh.
  4. Сервер проверяет Refresh Token, сверяет с БД, генерирует новую пару токенов.
  5. При смене пароля или подозрении на взлом Refresh Token инвалидируется в БД (или помещается в blacklist).
flowchart TD
    A["Клиент: Access Token истек"] --> B["Запрос к /auth/refresh<br>с Refresh Token"]
    B --> C{"Сервер: токен валиден<br>и не в blacklist?"}
    C -->|"Да"| D["Генерация новой пары<br>Access + Refresh"]
    D --> E["Возврат клиенту<br>Обновление хранилища"]
    C -->|"Нет"| F["Отказ 401<br>Требуется повторный логин"]
    F --> G["User принудительно<br>разлогинен"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef ok fill:#10b981,stroke:#059669,color:#ffffff;
    classDef fail fill:#f43f5e,stroke:#e11d48,color:#ffffff;
    class A,B,D,E normal;
    class C decision;
    class G fail;
    class F fail;
  

Производительность и Mechanical Sympathy

Парсинг JWT — операция с измеримой стоимостью:

  • jwt.ParseWithClaims создает map[string]interface{} для header, аллоцирует Claims, парсит JSON payload (json.Unmarshal), проверяет время и подпись.
  • На 100 000 RPS это генерирует ~5-8 аллокаций на запрос, попадающих в молодое поколение GC.
  • Escape Analysis: claims и временные буферы уходят в кучу, так как возвращаются из функций парсинга или передаются через interface{}.

Оптимизация для Highload:

  1. Избегайте json.Unmarshal в hot-path, если claims известны на этапе компиляции. Используйте кастомный декодер или pre-allocated структуры.
  2. Кешируйте публичные ключи для RS256/ES256 в памяти с sync.RWMutex или atomic.Pointer. Не загружайте JWKS-эндпоинт на каждый запрос.
  3. Используйте sync.Pool для временных буферов Base64URL-декодирования, если пишете кастомный парсер.

Сравнение с сессиями и другими экосистемами

Аспект PHP / Java Sessions Go + JWT
Хранение состояния Server-side (Redis, Files, DB) Client-side (в токене)
Масштабирование Требует sticky-sessions или shared storage Stateless, легко масштабируется
Отзыв Мгновенный (удаление из хранилища) Задержка до истечения exp или blocklist
Размер заголовка Только PHPSESSID / JSESSIONID 200-2000 байт (Base64 payload)
CPU overhead Минимальный (lookup по ID) Выше (crypto verify + JSON parse)

В монолитах сессии удобнее. В микросервисах на Go JWT предпочтительнее из-за отсутствия зависимостей от общего хранилища состояний и упрощения сетевого взаимодействия.

Итог

  1. JWT обеспечивает stateless-аутентификацию, но требует строгой проверки алгоритма и подписи.
  2. Никогда не храните секреты или конфиденциальные данные в payload.
  3. Используйте короткий exp + Refresh Token pattern для контролируемого отзыва.
  4. golang-jwt/jwt/v5 безопасен по умолчанию, но требует явной проверки t.Method в KeyFunc.
  5. На highload парсинг создает аллокации; кешируйте ключи и минимизируйте payload.
  6. Храните токены в HttpOnly cookies или memory, избегая localStorage при рисках XSS.

Следующая статья: 20. Authorization. RBAC