open atlas
↑ К треку
NestJS с нуля до senior NEST · 05 · 03

Refresh-токены, ротация и обнаружение повторного использования

JWT access-токен нельзя отозвать до exp, поэтому держи его коротким (~15 мин) и сочетай с долгим refresh-токеном, который ротируется одноразово. Повтор израсходованного refresh-токена значит, что им владеют двое — отзови всю семью и заставь переавторизацию.

NEST Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

В постмортеме инцидента была одна строка, от которой комната замерла: «у атакующего была валидная сессия 26 дней». После первого часа никого больше не фишили. Произошло проще и хуже — refresh-токен утёк через лог-сток, захватывавший тела запросов, а время жизни токена было 30 дней. Каждый раз, когда access-токен атакующего истекал, тот тихо POST-ил тот же самый refresh-токен на /auth/refresh и получал назад свежий 15-минутный access-токен. Ни одна тревога не сработала, потому что в этом запросе ничего не выглядело неправильно: валидный токен обменивался ровно так, как задумано. Refresh-токен был 30-дневной отмычкой, и у системы не было способа заметить, что им пользуются двое сразу. Этот урок — про жизненный цикл токенов, который закрывает эту дыру: почему ты носишь два токена, почему долгоживущий обязан ротироваться при каждом использовании и как ротация превращает украденный токен в растяжку вместо отмычки.

Почему два токена: проблема отзыва

JWT access-токен самодостаточен: сервер проверяет его подпись и доверяет клеймам внутри без обращения к базе. Эта без-состоятельность и есть весь смысл — она масштабируется, и любой сервис с публичным ключом может его валидировать. Но у неё есть жёсткое следствие: ты не можешь отозвать JWT до его exp. Нет серверной записи, которую можно удалить; токен валиден до истечения, и точка. Так что если он утёк, твой единственный рычаг — насколько коротким ты сделал exp.

Это диктует дизайн. Держи access-токен коротким — ~15 минут — чтобы украденный был 15-минутной проблемой, а не вечной. Но нельзя заставлять пользователей логиниться каждые 15 минут, поэтому ты сочетаешь его с refresh-токеном — долгоживущим (~7–30 дней) — чья единственная работа в том, чтобы быть обменянным на /auth/refresh на новый access-токен.

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class TokenService {
  constructor(private readonly jwt: JwtService) {}

  // two tokens, two secrets, two very different TTLs
  async issuePair(userId: string, familyId: string) {
    const accessToken = await this.jwt.signAsync(
      { sub: userId },
      { secret: process.env.ACCESS_SECRET, expiresIn: '15m' },   // short: caps theft blast radius
    );
    const refreshToken = await this.jwt.signAsync(
      { sub: userId, familyId },
      { secret: process.env.REFRESH_SECRET, expiresIn: '30d' },  // long: avoids constant re-login
    );
    return { accessToken, refreshToken };
  }
}

Refresh-токен теперь — чувствительная учётка: он живёт неделями и чеканит access-токены по требованию. Поэтому он обязан быть серверно-отслеживаемым (в отличие от без-состоятельного access-токена) и никогда не должен касаться доступного из JS хранилища. Он путешествует как httpOnly, Secure, SameSite cookie: httpOnly держит его вне document.cookie, чтобы XSS не мог его прочитать, Secure прикрепляет его к HTTPS, SameSite притупляет CSRF. Access-токен может лежать в памяти; refresh-токен сидит в закалённой cookie.

Ротация: сделай каждый refresh одноразовым

Наивный дизайн возвращает тот же refresh-токен на всю его 30-дневную жизнь. Это ровно та отмычка из инцидента: одна утечка покупает 30 дней тихого доступа. Ротация чинит проблему времени жизни: при каждом вызове /auth/refresh ты выпускаешь совершенно новый refresh-токен и инвалидируешь старый. Каждый refresh-токен одноразовый.

async refresh(presentedToken: string) {
  const record = await this.findFamilyRecord(presentedToken); // looks up by hash (below)
  if (!record || record.revoked) throw new UnauthorizedException();

  // ROTATION: the old token is now spent; mint a fresh pair on the same family
  await this.markConsumed(record.id);
  return this.issuePair(record.userId, record.familyId);
}

Ротация делает две вещи. Она сжимает окно валидности — утёкший refresh-токен годен только до следующего refresh легитимного клиента, часто минуты, а не недели. И, что критично, она даёт тебе сигнал обнаружения: раз токен ротирован, предъявление его снова аномально, а на аномалии ты можешь реагировать.

Обнаружение повторного использования: израсходованный токен — это растяжка

Вот основной механизм. Ротированный refresh-токен одноразовый, так что его никогда не должны увидеть снова. Если израсходованный refresh-токен всё же предъявлен второй раз, есть только одно объяснение: им владеют двое — легитимный клиент и вор. Утечка уже случилась; повторное предъявление — это система, говорящая тебе об этом.

Сложность в том, что ты не можешь сказать, кто из них кто. Запрос от атакующего и запрос от жертвы выглядят одинаково — оба несут когда-то валидный токен. Так что ты делаешь единственное безопасное допущение: компрометация. Ты отзываешь всю семью токенов — каждый refresh-токен, происходящий от исходного логина, — что заставляет полную переаутентификацию.

Моделируй это как семью. Каждый логин начинает семью (familyId); каждая ротация прицепляет следующий токен к той же семье. Запись семьи отслеживает, какой токен сейчас жив и отозвана ли семья.

// При refresh: если предъявленный токен УЖЕ был использован — это повторное использование → убить семью
async refresh(presentedToken: string) {
  const record = await this.findByHash(this.hash(presentedToken));
  if (!record) throw new UnauthorizedException();

  if (record.consumed) {
    // REUSE DETECTED: a single-use token came back. Two holders exist.
    await this.revokeFamily(record.familyId);  // nuke every descendant of this login
    throw new UnauthorizedException('token reuse detected');
  }

  await this.markConsumed(record.id);
  const familyId = record.familyId;
  return this.issuePairAndStore(record.userId, familyId);
}

Теперь переиграй инцидент с ротацией на месте. Атакующий крадёт refresh-токен и использует его, чтобы отчеканить access-токен — ладно, он ротировал его, теперь держит токен v2. Позже настоящий пользователь, его клиент, делает refresh с токеном, который он всё ещё держит (тот, что атакующий уже израсходовал, v1). Сервер видит израсходованный токен, предъявленный снова → повтор обнаружен → он отзывает всю семью. И v2 атакующего, и любой будущий refresh умирают. Пользователя выбрасывает на экран логина, он переаутентифицируется и начинает свежую семью; атакующий заблокирован. Окно в 26 дней схлопывается до «до следующего refresh пользователя».

Почему это работает

Почему отзывать ВСЮ семью при повторе, а не только токен, который переиграли? Потому что раз одноразовый токен появился снова, у тебя есть доказательство, что утечка произошла, но нет способа отличить запрос атакующего от запроса жертвы — оба предъявляют когда-то валидный токен, и нет сигнала, который их различал бы. Если отозвать только предъявленный токен, ты можешь отозвать копию жертвы и оставить атакующего с только что ротированным — ты заблокируешь легитимного пользователя и оставишь вора залогиненным, ровно наоборот тому, что ты хочешь. Единственное безопасное допущение — что вся цепочка, происходящая от того логина, скомпрометирована, так что ты отзываешь каждый токен в семье и заставляешь чистую переаутентификацию. Это намеренный размен: один утёкший токен стоит пользователю единственного релогина, а взамен атакующий гарантированно выкинут.

Хранение: хешируй refresh-токен, никогда не храни сырым

Запись семьи живёт в твоей базе — но ты не должен хранить в ней сырой refresh-токен. Если бы хранил, утечка базы (дамп, бэкап, оставленный в бакете, read-only SQLi) вручила бы атакующему каждый живой refresh-токен в открытом виде: мгновенный угон аккаунтов в масштабе, без подделки подписи. Вместо этого храни только хеш refresh-токена — argon2/bcrypt или простой SHA-256, раз токен уже высокоэнтропийный — ровно как хранил бы пароль. При refresh хешируй предъявленный токен и сравнивай с сохранённым значением.

import { createHash } from 'node:crypto';

// the token is high-entropy random already, so a fast SHA-256 is fine here
private hash(token: string): string {
  return createHash('sha256').update(token).digest('hex');
}

private async issuePairAndStore(userId: string, familyId: string) {
  const { accessToken, refreshToken } = await this.tokens.issuePair(userId, familyId);
  await this.familyRepo.save({
    familyId,
    userId,
    tokenHash: this.hash(refreshToken), // store the HASH, never the raw token
    consumed: false,
    revoked: false,
  });
  return { accessToken, refreshToken };
}

Обвязка в Nest зеркалит access-путь, но остаётся отдельной: JwtRefreshStrategy (отдельная от access-овой JwtStrategy) читает refresh-токен из cookie и валидирует его подпись refresh-секретом; эндпоинт /auth/refresh ограждён ею; а сервис делает stateful-часть — ищет семью по хешу, проверяет consumed/revoked, ротирует, сохраняет новый хеш, возвращает новую пару. @nestjs/jwt подписывает оба токена, но разными секретами и разными TTL, так что access-токен никогда не может быть переигран как refresh-токен и наоборот.

Выбери лучший вариант

Refresh-токен неизбежно однажды утечёт — через лог-сток, XSS-баг или украденное устройство. Как ограничить ущерб от украденного refresh-токена?

Викторина

Почему access-токен держат коротким (~15 мин) вместо того, чтобы просто выдать один долгоживущий JWT?

Викторина

Refresh-токен, который уже был ротирован (израсходован), предъявлен на /auth/refresh второй раз. Что должен сделать сервер?

Вспомните перед уходом
  1. 01
    Почему ты носишь два токена и каковы верные время жизни, хранение и свойства каждого?
  2. 02
    Пройди ротацию и обнаружение повтора: что делает ротация, что значит переигранный израсходованный токен и почему ты отзываешь всю семью.
Итог

Жизненный цикл токенов существует, чтобы решить один жёсткий факт: самодостаточный JWT access-токен нельзя отозвать до его exp, потому что нет серверной записи для удаления. Так что ты держишь access-токен коротким (~15 мин), чтобы ограничить радиус поражения кражи, и сочетаешь его с долгоживущим refresh-токеном (~7–30 дней), который обменивается на /auth/refresh на новые access-токены — refresh-токен при этом чувствительная учётка, носимая в httpOnly, Secure, SameSite cookie и хранимая в БД только как хеш, так что утечка не даёт ничего пригодного. Ротация делает каждый refresh одноразовым: каждый вызов выпускает новый refresh-токен и инвалидирует старый, сжимая валидность утёкшего токена до минут и создавая сигнал обнаружения. Обнаружение повтора — основной механизм: израсходованный одноразовый токен, предъявленный снова, значит, что им владеют двое, а раз ты не можешь отличить атакующего от жертвы, ты допускаешь компрометацию и отзываешь всю семью токенов (всех потомков того логина, сцепленных по familyId), заставляя переаутентификацию. В NestJS это отдельная JwtRefreshStrategy, читающая cookie, ограждённый эндпоинт /auth/refresh и сервис, который валидирует хеш предъявленного токена против записи семьи, ротирует и возвращает новую пару — с @nestjs/jwt, подписывающим оба токена разными секретами и TTL. Итоговый эффект: ротация плюс обнаружение повтора превращают украденный refresh-токен из многонедельной отмычки в самоотзывающуюся растяжку.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.