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.

go
func 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 ловит потерянное событие.

go
func (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-шторма делается тремя релизами:

  1. user-service начинает подписывать новые токены RS256 (kid = rsa-2026-01), JWKS опубликован. Старые HS256-токены ещё живы.
  2. Проверяющие переходят в режим dual-accept: принимают и RS256 (из JWKS), и HS256 (старый секрет). HS256 «доживает» свой TTL — форс-логаутов нет, никто не перелогинивается.
  3. Когда последний HS256-токен истёк (не дольше максимального TTL), приём HS256 выключается, секрет удаляется из всех конфигов.

Ротация RS256-ключей потом — по той же схеме: новый kid публикуется в JWKS рядом со старым, подпись переключается на новый, старый убирается из JWKS, когда истекут все выпущенные с ним токены. Потребители ничего не перевыкатывают: незнакомый kid триггерит refetch.

💻 Практика

  1. Ключи (15 мин).
bash
openssl 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.

  1. JWKS-эндпоинт (45 мин). Соберите JWK из публичного ключа: n и e в base64url, стабильный kid. GET /.well-known/jwks.json отдаёт {"keys": [...]}; зафиксируйте путь в OpenAPI user-service — тест «роуты ↔ спека» напомнит, если контракта нет.

  2. Офлайн-валидация в другом сервисе (1 ч). Middleware из §3.2: кэш ключей по kid + refetch при незнакомом. Тест инцидента: остановите user-service — токены продолжают проверяться, а login/refresh отдают 502/503. Чат при этом работает.

  3. Каталог и «≤ 1 с» (45 мин). Подпишитесь на user.updated, инвалидируйте карточку. Тест: переименовали пользователя — в каталоге другое имя не позже чем через секунду. Выключите pub/sub-канал — TTL дотянет, но медленнее; зафиксируйте разницу в тесте.

Чек-лист модуля: приватный ключ существует в одном месте; JWKS отдаётся и описан в OpenAPI; при выключенном user-service чат работает, а вход — нет; переименование доезжает до каталога за ≤ 1 с; dual-accept покрыт тестом «старый HS256-токен принимается до конца TTL».

🧩 Quiz

1. Кто знает приватный ключ RS256?
2. user-service лежит две минуты. Что с мессенджером?
3. Как переехать с HS256 на RS256 без логаут-шторма?
4. Verifier встретил незнакомый kid. Что делать?
5. Почему каталоги двигаются событием user.updated, а не запросом имени по требованию?

✅ Итоги модуля

  • Объяснить, почему приватный ключ — только у user-service, и что это даёт при компрометации потребителя.
  • Отдать JWKS и валидировать токен офлайн: кэш ключей + refetch по kid.
  • Показать деградацию при падении user-service: чат жив, вход — нет.
  • Двигать каталоги событиями user.updated с досвечиванием ≤ 1 с.
  • Провести миграцию HS256 → RS256 через dual-accept без форс-логаутов.

🔗 Мостик к Модулю 4

Токены офлайн, каталоги двигаются событиями user.*. Но главное движение данных — от messenger к event — пока описано словами «что-то в шине». В следующем модуле фиксируем конверт события: Published Language, recipients, версии и гарантии доставки — контракт, который переживёт рост диалогов до тысячи участников.