user-service: RS256 + JWKS и офлайн-валидация
Перед началом: Модуль 2 — топология поднята, nginx роутит по путям. Инструменты: Go, openssl, PostgreSQL, JWT-библиотека с RS256/JWKS. Трудоёмкость: ~5 ч.
🔥 Инцидент: «user-service перезапустился — и «встал» весь чат»
Первый вариант разделения сохранил из монолита две привычки. Первая: HS256 — секрет подписи лежал в конфигах всех сервисов, ведь проверять токен умел каждый. Вторая: «а проверим-ка токен у хозяина» — на каждый REST-запрос и на каждый WS-коннект сервис ходил в user-service за подтверждением. Деплой user-service занял двадцать секунд — и на эти двадцать секунд встало всё: REST отвечал 502, WS-клиенты не могли подключиться, потому что валидация токена до апгрейда ходила по сети. Чат «упал», хотя ни одно сообщение не пострадало. Разбор добавил второй вывод: общий HMAC-секрет — ключ от всего сразу; утеки из notification-worker достаточно, чтобы подделать токен любого пользователя.
Обе проблемы решаются одним ходом: подпись становится асимметричной (RS256), а проверка — локальной (JWKS). Тогда падение user-service — это деградация «нельзя войти», а не падение чата.
📖 Теория
3.1 Что забирает user-service
Bounded context Identity & Access: свой язык (аккаунт, токен, профиль, контакт, блокировка) и свой жизненный цикл — user меняется редко, а требования безопасности и аудита у него отдельные от ленты сообщений.
Владеет данными (база users_db из модуля 2): users (email,
password_hash, display_name, avatar, status), contacts, blocks,
сессии/токены.
API (примеры):
POST /api/v1/auth/register|login|refresh # выдача JWT (RS256)
GET /.well-known/jwks.json # публичный ключ для остальных
GET /api/v1/users/{id} # карточка (для каталогов)
GET /api/v1/contacts?search= # контакты для «Написать»
PATCH /api/v1/profile # display_name, avatar
Contacts остаются здесь до боли: вырастут в социальный граф (заявки, подписки) — это будущий отдельный контекст Social, а не резкий вынос.
3.2 RS256 + JWKS: подписывает один, проверяют все
Асимметрия меняет расстановку ключей: приватный ключ знает только user-service; messenger, event и notification проверяют подпись публичным. Компрометация потребителя больше не даёт подделывать токены — публичным ключом можно только проверять.
Публичные ключи публикуются в JWKS — JSON Web Key Set по
/.well-known/jwks.json. Заголовок токена несёт kid — идентификатор ключа,
которым подписано. Потребитель кэширует ключи, а на незнакомый kid делает
refetch: так работает ротация без redeploy.
gofunc JWTVerifier(keys *KeyCache) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tok, err := parseBearer(r, keys) // kid → ключ из кэша; miss → refetch JWKS if err != nil || !tok.Valid { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next.ServeHTTP(w, r.WithContext(withClaims(r.Context(), tok))) }) } }
Следствие для матрицы вызовов модуля 2: синхронных вызовов авторизации нет. Проверка токена — локальная криптография, микросекунды, работает при лежащем user-service. То же правило — для WS: event-service валидирует JWT из JWKS до апгрейда соединения.
3.3 Отказ user-service: деградация, а не падение
Что продолжает работать при лежащем user-service:
- приём и доставка сообщений — messenger и event на него не ходят;
- существующие токены — валидируются офлайн до конца своего TTL.
Что недоступно: register/login/refresh — новые сессии не начнутся.
Это деградация с понятным сообщением клиенту, а не outage переписки — то
самое «его падение не должно ронять доставку» из обоснования выделения.
Цена офлайн-валидации честная: отозвать токен мгновенно нельзя — он живёт до конца TTL. Компенсация курса: короткоживущий access-токен плюс refresh-поток в user-service (ротация refresh при каждом обмене). Мгновенный «разлогинить везде» — отдельная механика, и она стоит синхронных вызовов; в курсе она осознанно не выбрана.
3.4 События user.* и каталог
Наружу user-service публикует user.created, user.updated и
user.deactivated. Потребители (messenger, event, notification) не ходят за
именами синхронно — каждый ведёт каталог: read-model карточек
(display_name, avatar, status) по user_id.
Инвалидация каталога event-service — событие user.updated по pub/sub плюс
TTL-подстраховка; холодный промах закрывается одним GET /api/v1/users/{id}
у владельца. Целевой показатель прост: переименование видно каталогу
не позже чем через секунду — pub/sub догоняет мгновенно, TTL ловит
потерянное событие.
gofunc (d *Directory) OnUserUpdated(ctx context.Context, id uuid.UUID) { d.cards.Invalidate(id) // LRU + pub/sub; TTL подстрахует, если событие потеряно }
3.5 Миграция токенов: dual-accept и ротация
В boilerplate трека JWT подписан HS256 с общим секретом. Переезд на RS256 без logout-шторма делается тремя релизами:
- user-service начинает подписывать новые токены RS256 (
kid=rsa-2026-01), JWKS опубликован. Старые HS256-токены ещё живы. - Проверяющие переходят в режим dual-accept: принимают и RS256 (из JWKS), и HS256 (старый секрет). HS256 «доживает» свой TTL — форс-логаутов нет, никто не перелогинивается.
- Когда последний HS256-токен истёк (не дольше максимального TTL), приём HS256 выключается, секрет удаляется из всех конфигов.
Ротация RS256-ключей потом — по той же схеме: новый kid публикуется в JWKS
рядом со старым, подпись переключается на новый, старый убирается из JWKS,
когда истекут все выпущенные с ним токены. Потребители ничего не
перевыкатывают: незнакомый kid триггерит refetch.
💻 Практика
- Ключи (15 мин).
bashopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-rsa.pem openssl rsa -in jwt-rsa.pem -pubout -out jwt-rsa.pub.pem
Приватный ключ — только в user-service (секрет или том, не в git); публичный уедет в JWKS.
-
JWKS-эндпоинт (45 мин). Соберите JWK из публичного ключа:
nиeв base64url, стабильныйkid.GET /.well-known/jwks.jsonотдаёт{"keys": [...]}; зафиксируйте путь в OpenAPI user-service — тест «роуты ↔ спека» напомнит, если контракта нет. -
Офлайн-валидация в другом сервисе (1 ч). Middleware из §3.2: кэш ключей по
kid+ refetch при незнакомом. Тест инцидента: остановите user-service — токены продолжают проверяться, аlogin/refreshотдают 502/503. Чат при этом работает. -
Каталог и «≤ 1 с» (45 мин). Подпишитесь на
user.updated, инвалидируйте карточку. Тест: переименовали пользователя — в каталоге другое имя не позже чем через секунду. Выключите pub/sub-канал — TTL дотянет, но медленнее; зафиксируйте разницу в тесте.
Чек-лист модуля: приватный ключ существует в одном месте; JWKS отдаётся и описан в OpenAPI; при выключенном user-service чат работает, а вход — нет; переименование доезжает до каталога за ≤ 1 с; dual-accept покрыт тестом «старый HS256-токен принимается до конца TTL».
🧩 Quiz
✅ Итоги модуля
- Объяснить, почему приватный ключ — только у user-service, и что это даёт при компрометации потребителя.
- Отдать JWKS и валидировать токен офлайн: кэш ключей + refetch по
kid. - Показать деградацию при падении user-service: чат жив, вход — нет.
- Двигать каталоги событиями
user.updatedс досвечиванием ≤ 1 с. - Провести миграцию HS256 → RS256 через dual-accept без форс-логаутов.
🔗 Мостик к Модулю 4
Токены офлайн, каталоги двигаются событиями user.*. Но главное движение
данных — от messenger к event — пока описано словами «что-то в шине». В
следующем модуле фиксируем конверт события: Published Language,
recipients, версии и гарантии доставки — контракт, который переживёт рост
диалогов до тысячи участников.