Когда сервисов много, серверная сессия в общем Redis превращается в узкое место: каждый сервис на каждый запрос ходит в одно хранилище. Отсюда — выделенный auth-сервис, который выпускает подписанные JWT: остальные сервисы валидируют токен локально по общему секрету, без сетевого похода. Но stateless-доступ не бесплатен: отзыв усложняется, и появляется вторая половина схемы — opaque refresh-токены с сессиями в Redis, ротацией и управлением. Здесь легко потеряться, поэтому важно держать границы простыми.
Это третья статья серии «Авторизация: токены, сессии, refresh» — сложный путь, обезличенный из реального многосервисного проекта (vog-auth).
В статье
- Топология: auth-сервис и потребители
- JWT access-токен: что класть в claims, а что нет
- Refresh-сессии в Redis:
rs:иrsu: - Обновление access-токена и где заканчивается демо
- Управление сессиями, logout и (не) переключение аккаунтов
- Цена stateless: что на самом деле значит «отозвать» JWT
- Врезка: HS256 vs RS256/JWKS
- Источники
Топология: auth-сервис и потребители
В первой статье серии карта выбора свелась к одной строке: пока сервис один, opaque-сессия в общем Redis — самый простой и самый правильный вариант. Проблема начинается, когда сервисов становится много, а сессия остаётся одна. Каждый запрос к каждому сервису — это поход в общее хранилище за проверкой. Redis быстрый, но не бесконечно: он превращается в узел, через который проходит вообще весь трафик системы, и в точку отказа, от которой зависят все сервисы разом.
Решение — развести две роли по разным процессам:
- auth-сервис — единственный, кто выпускает и обновляет токены. Он знает про Redis (refresh-сессии) и про источник правды по аккаунтам (в реальной системе — БД пользователей; в демо-стенде — детерминированная функция от email,
accountFromEmail, чтобы не тащить БД в учебный код). - сервисы-потребители — не хранят ничего своего про сессии. Они получают access-токен и проверяют его локально, не обращаясь к auth ни на первый, ни на сотый запрос.
В стенде (security/auth/multi-service) это два независимых бинарника: authservice (:8082) и consumer (:8083), связанные не сетевым вызовом, а общим секретом JWT_SECRET и общим пакетом multi-service/authmw. Consumer физически не умеет сходить в authservice за подтверждением токена — в его коде такого HTTP-клиента просто нет. Демо-эндпоинт /debug/auth-calls считает обращения к auth и после серии запросов к /me показывает {"auth_calls":0} — не потому что кто-то оптимизировал путь, а потому что пути туда нет вообще.
клиент ──login──> authservice (:8082) ──HSet/Expire──> Redis (rs:, rsu:)
│
│ app_at (JWT), app_rt (opaque refresh) — httpOnly cookies
▼
клиент ──GET /me + Authorization/cookie──> consumer (:8083)
│
└── jwtclaims.Parse(secret, token) — локально, без сети
Это и есть главная граница ответственности серии: auth не размазан по сервисам. Любой сервис, который начинает сам выпускать токены или сам трогать rs:/rsu:, перестаёт быть потребителем и становится вторым auth — а два источника правды про сессии хуже одного медленного. Consumer имеет право только на одну операцию: проверить подпись и claims присланного токена.
JWT access-токен: что класть в claims, а что нет
Claims access-токена в стенде — минимальный набор, без которого потребителю не обойтись:
// internal/jwtclaims/claims.go
type Claims struct {
AccountID int64 `json:"aid"`
Nick string `json:"nick"`
Roles []string `json:"roles,omitempty"`
jwt.RegisteredClaims
}
AccountID — идентификатор, ради которого всё затевалось: без него consumer не поймёт, кто сделал запрос. Roles — та же история: доступ к RequireRole("admin") в authmw проверяется из токена, без похода в БД прав на каждый запрос. Nick — единственное поле здесь без которого можно было бы обойтись: это чисто UX-удобство (показать имя без отдельного запроса), и оно же — первый кандидат на вылет, если вы захотите урезать размер токена. Плюс стандартные iss/sub/iat/exp из jwt.RegisteredClaims, которые выставляет Issue:
// internal/jwtclaims/claims.go
func Issue(secret []byte, c Claims, ttl time.Duration) (string, error) {
now := time.Now()
c.RegisteredClaims = jwt.RegisteredClaims{
Issuer: issuer, // "auth"
Subject: strconv.FormatInt(c.AccountID, 10),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
return tok.SignedString(secret)
}
Правило простое и в статье 1 уже прозвучало: claims читает кто угодно, кто перехватил токен — это кодирование, не шифрование. Значит, туда можно класть только то, разглашение чего не создаёт проблему: идентификаторы, роли, публичные атрибуты. Нельзя класть: email/телефон (персональные данные, которым незачем ехать в каждом запросе открытым текстом), любые секреты (пароли, ключи API), внутренние данные, по которым можно строить профиль пользователя третьей стороне, перехватившей трафик. Если сервису-потребителю нужны данные сверх aid/nick/roles — это повод для отдельного lookup по aid в его собственном хранилище, а не для раздувания токена.
Проверка на другой стороне — jwtclaims.Parse — защищает не только подпись, но и выбор алгоритма:
// internal/jwtclaims/claims.go
func Parse(secret []byte, raw string) (*Claims, error) {
var c Claims
_, err := jwt.ParseWithClaims(raw, &c, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrBadAlg
}
return secret, nil
}, jwt.WithIssuer(issuer), jwt.WithExpirationRequired())
if err != nil {
return nil, err
}
return &c, nil
}
Это защита от классической атаки alg confusion: если библиотека доверяет полю alg из заголовка токена (а не жёстко требует конкретный метод), атакующий может прислать токен с alg=none (подпись не проверяется вовсе) или, при смешанном HS256/RS256-окружении, подписать HS256-токен публичным RSA-ключом сервера как HMAC-секретом — ключ-то публичный, его знают все. keyfunc в Parse проверяет тип метода (*jwt.SigningMethodHMAC) до того, как отдаст secret наружу, и любой не-HMAC метод отбивается ErrBadAlg, не доходя до сравнения подписи. jwt.WithIssuer(issuer) и jwt.WithExpirationRequired() добавляют вторую линию: даже корректно подписанный чужой токен (iss не "auth") или токен без exp не пройдёт.
Извлечение токена из запроса — тоже общий код, authmw.Middleware:
// multi-service/authmw/authmw.go
func extractToken(r *http.Request) string {
if auth := r.Header.Get("Authorization"); auth != "" {
if tok, ok := strings.CutPrefix(auth, "Bearer "); ok {
return tok
}
}
if c, err := r.Cookie(cookieName); err == nil { // "app_at"
return c.Value
}
return ""
}
Порядок неслучаен: сначала Authorization: Bearer (для API-клиентов, мобильных приложений, серверных интеграций), потом httpOnly cookie app_at (для браузера). Один и тот же middleware, без ветвления по типу клиента.
И последнее по этому разделу — TTL. Access-токен в стенде живёт AccessTTL = 15 * time.Minute. Это число — не произвольное, оно прямое следствие того, что описано в разделе про цену stateless: отозвать выпущенный JWT нельзя, поэтому единственный рычаг ограничения ущерба от утечки — сделать окно жизни коротким.
Refresh-сессии в Redis: rs: и rsu:
Access stateless, но кто-то должен помнить, что сессия вообще существует — иначе не из чего будет посчитать список активных устройств или сделать глобальный logout. Эта память — refresh-сессия, opaque-токен с записью в Redis, ровно та модель из первой статьи, просто теперь она обслуживает не каждый запрос, а только обновление access.
// internal/session/session.go — CreateTokenPair (вызывается при логине)
refresh, err = token.OpaqueHex(32) // 32 байта из crypto/rand → 64 hex-символа
rsKey := "rs:" + refresh
rsuKey := "rsu:" + strconv.FormatInt(acc.ID, 10)
pipe := rdb.TxPipeline()
pipe.HSet(ctx, rsKey, map[string]interface{}{
"aid": acc.ID, "cat": now, "lat": now, "lm": loginMethod,
"nick": acc.Nick, "roles": string(rolesJSON),
})
pipe.Expire(ctx, rsKey, RefreshTTL) // 168h
pipe.HSet(ctx, rsuKey, refresh, "1")
pipe.Exec(ctx)
rdb.HExpire(ctx, rsuKey, RefreshTTL, refresh) // per-field TTL отдельной командой
rs:{refresh} — HASH с самой сессией: кому принадлежит (aid), когда создана и когда последний раз использовалась (cat/lat), каким методом вошли (lm), и — важная деталь — дублированные nick/roles. rsu:{accountID} — обратный индекс: HASH, где каждое поле — живой refresh-токен этого аккаунта, с собственным per-field TTL (HExpire). Он существует ровно для одной задачи: перечислить сессии аккаунта без SCAN по всему keyspace.
Зачем nick/roles дублируются в rs:, если они уже есть в access-токене? Это зафиксированный в README стенда факт, а не архитектурная прихоть — раньше их там не было, и это было багом (FIX I-1 в терминологии README): при /auth/refresh новый access выпускался только из aid, взятого из refresh-сессии, а nick/roles в неё не писались вовсе — свежий access приходил с пустыми ролями, и RequireRole на потребителях молча отклонял запросы 403 после первого же refresh, без внятной причины в логе. Демо-стенд без БД пользователей не может на /auth/refresh пойти и перечитать роли из источника правды — источника правды в нём просто нет. Поэтому nick/roles пишутся в rs: при CreateTokenPair и читаются обратно при обновлении:
// internal/session/session.go
func AccountFromSession(ctx context.Context, rdb *redis.Client, refresh string) (Account, error) {
data, err := rdb.HGetAll(ctx, "rs:"+refresh).Result()
// ... aid, nick — как есть; roles — json.Unmarshal обратно в []string
}
В системе с настоящей БД аккаунтов это, вероятно, было бы не нужно — роли можно перечитать из источника правды при каждом refresh. Здесь это компенсация за то, что источника правды в демо нет; переносить решение «дублировать roles в сессию» в прод стоит осознанно, а не по умолчанию.
ValidateAndTouch — вторая операция над rs:, вызываемая при каждом обмене refresh→access: проверяет, что сессия жива, и продлевает lat (last-active) — но не чаще, чем раз в touchInterval (5 минут), чтобы не писать в Redis на каждый запрос обновления:
// internal/session/session.go
if lat, ok := data["lat"]; ok {
if last, perr := time.Parse(time.RFC3339, lat); perr == nil {
if time.Since(last) < touchInterval {
return aid, nil // не трогаем Redis, если last-active свежий
}
}
}
Обновление access-токена и где заканчивается демо
POST /auth/refresh в authservice — самый короткий из трёх маршрутов auth-сервиса и намеренно простой:
// multi-service/authservice/http.go
func (h *authHandler) refresh(w http.ResponseWriter, r *http.Request) {
cookie, _ := r.Cookie(refreshCookieName) // "app_rt"
if _, err := session.ValidateAndTouch(r.Context(), h.rdb, cookie.Value); err != nil { /* 401 */ }
acc, err := session.AccountFromSession(r.Context(), h.rdb, cookie.Value)
// ...
access, err := jwtclaims.Issue(h.secret, jwtclaims.Claims{
AccountID: acc.ID, Nick: acc.Nick, Roles: acc.Roles,
}, session.AccessTTL)
h.setAccessCookie(w, r, access)
}
Это ровно то, что нужно для демонстрации связки JWT + refresh: проверили, что refresh жив, восстановили Account для полноты claims, выпустили новый access. Один и тот же refresh-токен при этом переиспользуется — он не ротируется на каждый обмен, и это осознанная граница демо, прямо прокомментированная в коде:
«Полная ротация refresh-токена с grace-периодом и блокировкой повторного использования — отдельный сервис (см. Task 5), здесь намеренно не реализована.»
Ротация — это когда каждый обмен refresh→access выдаёт новый refresh взамен старого, старый помечается использованным, а повторное предъявление уже потраченного refresh — сигнал компрометации (кто-то украл и переиспользовал токен раньше легитимного клиента). Здесь встают сразу два вопроса: как не разлогинить пользователя ложно из-за гонки нескольких вкладок за один и тот же старый refresh (grace-period, single-flight lock, distributed replay-detection), и как честно засчитать реальный повторный несанкционированный refresh как компрометацию, а не как гонку. Это отдельная, богатая деталями тема — ей целиком посвящена пятая статья серии, где ротация с grace/lock/replay-защитой (rl:{accountID} single-flight lock, rotated:{oldRefresh} grace-мост) даёт 0 ложных разлогинов против 17–18 из 20 у наивной ротации без этих механизмов — числа из живых concurrency-тестов стенда client-refresh.
Здесь, в третьей статье, важно зафиксировать другое: authservice в этом стенде не претендует на полную схему ротации — он показывает связку auth + JWT + refresh как таковую. Смешивать эти два стенда не нужно: multi-service про топологию auth/потребители и локальную валидацию, client-refresh про гонки клиента за refresh поверх той же модели токенов.
Управление сессиями, logout и (не) переключение аккаунтов
rsu:{accountID} — тот самый индекс, который делает список активных сессий бесплатным следствием модели, а не отдельной подсистемой:
// internal/session/session.go
func ListSessions(ctx context.Context, rdb *redis.Client, accountID int64) ([]SessionView, error) {
refreshes, _ := rdb.HKeys(ctx, "rsu:"+strconv.FormatInt(accountID, 10)).Result()
// pipeline HGetAll("rs:"+refresh) по каждому refresh…
// записи, чей rs: уже истёк по TTL, но поле осталось в rsu: — самоочищаются HDel
}
GET /auth/sessions в стенде отдаёт этот список за middleware authmw — эндпоинт защищён тем же access-токеном, что и остальные. Обратите внимание на самоочистку: если rs:{refresh} истёк по TTL раньше, чем поле убрали из rsu:, ListSessions тихо подчищает такие «осиротевшие» ссылки при следующем чтении — не требует отдельной фоновой задачи.
Logout в модели с refresh-сессиями — операция над Redis, не над JWT (сам JWT никуда не денется, живёт до exp): удалить rs:{refresh} конкретной сессии — это logout одного устройства; удалить все rs: из rsu:{accountID} — это глобальный logout со всех устройств разом. Оба варианта в стенде multi-service реализованы как операции с той же структурой данных, что и ListSessions/CreateTokenPair — отдельного «сервиса logout» не требуется, это ещё один аргумент за то, что refresh-хранилище — не техдолг, а рабочая часть архитектуры даже в «stateless» системе.
Насчёт переключения аккаунтов (несколько залогиненных аккаунтов в одном браузере, sel:-префикс в дизайне исходных проектов) нужно быть честным: в этом стенде такой механики нет. Это прямо зафиксировано в README: sel: упомянут в дизайне исходных приватных проектов, но за рамки демо не выносился — ни хранилища, ни эндпоинтов, ни клиентской части под него в multi-service не реализовано. Если вам это нужно в реальной системе — это отдельный архитектурный слой поверх refresh-сессий (список активных rs: на одном клиенте + активный выбор, какой из них подставлять в заголовок), не то, что можно взять готовым из этого демо.
Цена stateless: что на самом деле значит «отозвать» JWT
Возвращаемся к тому, с чего начиналась вся схема. Выигрыш локальной валидации — не абстрактный, а измеримый. Свежий бенч стенда (go test ./multi-service/bench/ -bench . -benchmem):
| Бенчмарк | ns/op | B/op | allocs/op |
|---|---|---|---|
BenchmarkLocalValidate |
4 681 | 2 864 | 50 |
BenchmarkRoundTrip |
1 509 925 | 4 803 | 57 |
Локальная проверка подписи (jwtclaims.Parse, то, что реально делает authmw на каждый запрос) — около 4.7 мкс. «Поход в auth» — около 1.51 мс, примерно в 322 раза дороже. Но здесь обязательна честная оговорка: BenchmarkRoundTrip не ходит по сети в реальный authservice — он поднимает httptest.Server в том же процессе и эмулирует работу удалённого сервиса искусственной задержкой в 1 миллисекунду:
// multi-service/bench/bench_test.go
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Millisecond)
w.WriteHeader(http.StatusOK)
}))
Это не измерение конкретной сетевой инфраструктуры (реальный round-trip между подами в кластере может быть и быстрее, и медленнее миллисекунды — зависит от сети, нагрузки на auth, TLS-хендшейка при переиспользовании соединений и т.д.), а порядковая оценка: поход в отдельный сервис за подтверждением токена ощутимо, на порядки дороже локальной проверки подписи. Число ×322 — не гарантия для вашей инфраструктуры, а обоснование архитектурного решения «валидировать локально», а не точный SLA.
А теперь — обратная сторона медали, ради которой вообще стоит читать этот раздел до конца, а не только цитировать «322 раза» в презентации. Stateless-валидация не бесплатна — она платит отзывом. У opaque-сессии (статья 2) отзыв мгновенный: удалил HASH s:{sid} — сессии больше нет прямо сейчас. У JWT отозвать выпущенный токен нельзя в принципе: сервер его не хранит, значит нечего удалять. Скомпрометированный access-токен — украденный из логов, перехваченный, скопированный из devtools — работает до exp, где бы он ни был, сколько бы вы ни «удаляли» сессию на сервере.
Отсюда и вытекает всё, что казалось произвольными числами выше:
- Короткий TTL access (15 минут в стенде) — не про UX, а про ограничение ущерба. Это единственный реальный рычаг для JWT: чем короче окно, тем меньше вреда от утечки одного токена.
- Настоящий отзыв живёт на уровне refresh, не access. Удалить
rs:{refresh}— значит, что при следующей попытке обновить access через/auth/refreshпользователь получит401 invalid_refresh. Но это не мгновенно останавливает уже выданный access — он всё равно доработает до своих 15 минут. Компрометация закрывается ротацией доступа: пока не истёк access, доступ есть. - Если нужен действительно мгновенный отзыв конкретного access-токена (не через 15 минут, а прямо сейчас — например, при обнаружении утечки в реальном времени) — единственный вариант поверх этой архитектуры — blacklist отозванных
jtiв быстром общем хранилище, который каждый потребитель обязан проверять на каждый запрос. Это возвращает то самое обращение к общему хранилищу на каждый запрос, от которого stateless-схема пыталась уйти — компромисс приходится делать осознанно, а не «и то, и другое бесплатно».
И последняя честная деталь из README: отказ Redis в стенде везде отдаёт 500 internal_error, а не 503 — стенд не различает «Redis временно недоступен, повторите» (ретраябельно) и «внутренняя ошибка сервиса» (не ретраябельно). В проде это разные сигналы и для клиента, и для алертинга, и их стоит разводить.
Врезка: HS256 vs RS256/JWKS
Весь стенд подписывает access-токены HS256 — симметричным HMAC с общим секретом JWT_SECRET. У этого выбора есть цена, о которой нужно знать заранее, даже если для трёх-пяти внутренних сервисов она приемлема.
| HS256 (общий секрет) | RS256 (пара ключей) | |
|---|---|---|
| Кто может проверять | любой, кто знает JWT_SECRET |
любой, у кого есть публичный ключ |
| Кто может выпускать | любой, кто знает JWT_SECRET — то есть все потребители |
только владелец приватного ключа (auth) |
| Распространение секрета | секрет реально нужно синхронизировать между всеми сервисами | публикуется открыто (JWKS-эндпоинт), приватный ключ нигде, кроме auth |
| Ротация | смена секрета одномоментно ломает валидацию везде, где не успели обновить | несколько ключей активны параллельно по kid, старый и новый пересекаются на переходный период |
| Подходит для | закрытый периметр из нескольких доверенных сервисов одной команды | много потребителей, разные команды/организации, публичные API |
Ключевая разница — не в криптостойкости самого алгоритма (оба варианта надёжны при правильной реализации), а в том, кто способен выпускать токены. В HS256-схеме этой статьи любой consumer, знающий JWT_SECRET (а знать его обязаны все — иначе не смогут проверять), технически способен и подписать свой собственный токен, выдав себя за auth. Для пяти сервисов одной команды за одним периметром это приемлемый риск при должной гигиене секретов (env vars, не в коде, ротация). Для системы, где потребители — не только ваши сервисы (партнёрские API, сторонние клиенты, публичный OIDC), это неприемлемо: нельзя раздавать секрет, которым можно подделать чужую личность, каждому, кому нужно всего лишь проверить токен.
RS256 разводит эти две способности: auth подписывает приватным ключом, который не покидает auth, а сервисы-потребители проверяют публичным ключом, который можно публиковать открыто — JWKS-эндпоинт (/.well-known/jwks.json), откуда потребители тянут актуальный набор ключей по kid из заголовка токена. Это же даёт бесшовную ротацию подписывающего ключа: новый ключ публикуется рядом со старым, токены, подписанные обоими, валидны, пока не истечёт TTL старых; никакого «одномоментно все сервисы должны обновить секрет одновременно», как с HS256.
В стенде серии эта механика — не абстракция: шестая статья про OAuth2/OIDC разбирает её на живом коде mockidp — учебном OIDC-провайдере, который подписывает id_token RS256-ключом и публикует его через JWKS, и клиенте oidc/, который тянет JWKS и валидирует подпись локально по kid, принимая только RS256 как метод подписи — та же защита от alg confusion, что и в jwtclaims.Parse этой статьи, только зеркально: там отклоняется не-HMAC, здесь отклоняется не-RSA (включая alg=none и попытку подсунуть HMAC с публичным RSA-модулем в роли секрета).
Источники
- RFC 7519 — JSON Web Token (JWT)
- RFC 7515 — JSON Web Signature (JWS)
- RFC 7517 — JSON Web Key (JWK)
- OWASP — JSON Web Token for Java Cheat Sheet (раздел про alg confusion применим к любой реализации, не только Java)
- Auth0 — Critical vulnerabilities in JSON Web Token libraries — история alg confusion как класса атак
- Первая статья серии: Модели сессий: opaque vs JWT
- Вторая статья серии: Простой путь: один сервис и opaque-сессии в Redis
- Пятая статья серии: Refresh на клиенте: несколько устройств и вкладок — полная ротация refresh с grace/lock/replay
- Шестая статья серии: OAuth2/OIDC вглубь — RS256, JWKS, ротация ключей на живом коде
- Живой стенд статьи:
security/auth/multi-service
Комментарии