Node.js: обновление токенов и обнаружение повтора
Короткий access-токен DeskQueue истекает через десять минут. Просить пароль после каждого истечения неудобно, а продлевать один JWT на месяцы опасно. Добавим отдельный refresh-токен: случайную строку, по которой сервер может выдать новый access-токен. При каждом обновлении прежняя строка становится использованной, а клиент получает следующую. Сервер сохраняет связь между ними, чтобы обнаружить повтор.
В stages/18 появляются refresh.js и миграция 003-refresh.sql. Это собственная учебная схема, а не полный OAuth-сервер. Общий принцип ротации и обнаружения повторного использования описан в рекомендациях IETF по защите refresh-токенов. Здесь применяем его к нашему HTTP-клиенту и отдельной таблице PostgreSQL.
Семья и секретная строка
Семья объединяет последовательность токенов одного входа. У неё есть владелец, признак активности и абсолютный срок семь дней. Каждый refresh-токен связан с семьёй и имеет признак использования. В базе хранится SHA-256 от случайной строки, а не сама строка. В отличие от пользовательского пароля, refresh-токен создаётся из 32 случайных байтов и обладает высокой энтропией; правила обработки двух секретов поэтому различаются.
Полная миграция создаёт две таблицы. Использованные строки не удаляем сразу: они нужны для распознавания повтора до окончания срока семьи.
BEGIN;
CREATE TABLE token_families (
id uuid PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id),
active boolean NOT NULL DEFAULT true,
expires_at timestamptz NOT NULL
);
CREATE TABLE refresh_tokens (
token_hash text PRIMARY KEY,
family_id uuid NOT NULL REFERENCES token_families(id),
used boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMIT;
При входе создаём новую семью и первую строку в одной транзакции. Ответ содержит accessToken и refreshToken. Значения не записываются в журнал и не передаются в URL. Длинная строка в URL может попасть в историю, журнал прокси или ссылки. Наш учебный клиент отправляет обновление в JSON-теле POST /auth/refresh; браузерные cookies потребовали бы отдельного договора CSRF и атрибутов.
В access-токен теперь добавляется утверждение sid с идентификатором семьи. Middleware после криптографической проверки загружает пользователя и активную, неистёкшую семью. Поэтому отзыв семьи прекращает и использование access-токенов на следующих запросах. Это сознательно добавляет обращение к базе: итоговая система уже не является полностью автономной проверкой JWT.
Атомарное обновление
Ротация состоит из чтения старого токена, проверки семьи, пометки использования и вставки новой строки. Эти действия выполняются одним клиентом PostgreSQL в транзакции. Сначала блокируем строку семьи через FOR UPDATE; затем читаем токен внутри этого порядка. Все операции семьи используют одинаковый порядок блокировок, чтобы параллельные запросы не создавали две независимые успешные ветви.
В модуле refresh.js последовательность представлена обычными функциями. Следующий сокращённый фрагмент показывает смысл критического участка; полный файл в архиве включает поиск, обработку отсутствия, фиксацию и возврат клиента.
const family = await client.query(
'SELECT * FROM token_families WHERE id=$1 FOR UPDATE', [familyId]
);
const previous = await client.query(
'SELECT used FROM refresh_tokens WHERE token_hash=$1', [hash]
);
if (previous.rows[0].used) {
await client.query('UPDATE token_families SET active=false WHERE id=$1', [familyId]);
// Сначала фиксируем отзыв, затем возвращаем ошибку клиенту.
} else {
await client.query('UPDATE refresh_tokens SET used=true WHERE token_hash=$1', [hash]);
await client.query(
'INSERT INTO refresh_tokens(token_hash,family_id) VALUES($1,$2)', [nextHash,familyId]
);
}
Особенно важна ветка повтора. Если изменить active=false, затем сразу бросить исключение и выполнить общий ROLLBACK, отзыв исчезнет. Поэтому функция сначала фиксирует отказ и только после завершения транзакции формирует 401. Ошибка клиента не обязательно означает откат всех служебных последствий: обнаруженный повтор должен оставить семью отозванной.
Параллельность и потерянный ответ
Представим два запроса с одной старой строкой. Первый блокирует семью, отмечает токен использованным и создаёт следующий. Второй ждёт блокировку, затем видит used=true и отзывает семью. В итоге даже новый токен первого запроса больше не разрешён. Сервер не знает, были ли это злоумышленник и пользователь или два параллельных действия одного клиента. Это ограничение выбранной строгой политики.
Поэтому клиент должен объединять одновременные обновления в один запрос и давать остальным операциям ждать его результат. Если каждая вкладка самостоятельно обновляет одну семью без координации, законный пользователь может получить отзыв. Более сложные системы вводят отдельные договоры повторов и небольшие окна допуска, но такой компромисс нельзя добавлять незаметно: он меняет возможность обнаружения кражи.
Потеря ответа также значима. Сервер мог успешно заменить токен, но клиент не получил новую строку. Повтор старой приводит к отзыву, после чего требуется новый вход с паролем. Мы выбираем понятное безопасное восстановление для учебного клиента. Не обещаем, что ротация решает все последствия сетевой неопределённости; она лишь сохраняет проверяемое состояние последовательности.
Срок семь дней является абсолютным пределом семьи, а не автоматически продлеваемым временем после каждого обновления. Такой выбор ограничивает жизнь одного входа. При необходимости можно отдельно хранить последний доступ и относительный срок бездействия, однако это добавит ещё одно правило. В текущей схеме новый токен не делает старую семью бессрочной.
Выход и наблюдение результата
POST /auth/logout требует действующий access-токен и переводит его семью в неактивную. Выход относится к одному входу, а не ко всем устройствам пользователя. Для общего выхода нужен запрос по всем его семьям и соответствующее разрешение. Смена пароля также должна иметь осмысленную политику отзывов; учебная серия пока не реализует этот отдельный маршрут.
Ожидаемая последовательность на стенде: войти, обновить один раз, убедиться, что новая пара работает, затем отправить прежний refresh-токен. Последний запрос должен получить 401, а следующая операция с новым access-токеном тоже должна быть отвергнута из-за неактивной семьи. Это ожидаемое наблюдение, не выполненная здесь проверка.
При очистке старых строк нельзя удалять использованные токены действующей семьи, иначе повтор превратится в неопознанную строку и перестанет отзывать её. Когда абсолютный срок истёк, семью и связанные токены можно удалить согласно политике хранения. Порядок удаления учитывает внешние ключи. Саму очистку не размещаем в пути каждого HTTP-запроса.
Неизвестная случайная строка не отзывается по всему пользователю: у сервера нет подтверждённой связи с семьёй. Невозможность найти hash даёт обычную ошибку входа. Только ранее известный использованный токен позволяет определить, какую последовательность нужно прекратить. Это различие не даёт произвольному посетителю отзывать чужие сеансы одним выдуманным значением.
Теперь срок access-токена, серверная семья и ротация образуют один явный договор. Он поддерживает обновление, выход и обнаружение повтора, сохраняя ограничения параллельного клиента. Следующая глава применит те же права владения к загрузке вложения заявки.
После отзыва уже выполняющийся запрос может завершиться, если проверка прошла раньше изменения семьи. Мы обещаем запрет на следующих проверках, а не отмену любой начатой операции внутри базы. Для более строгой границы разрешение пришлось бы связать с транзакцией самой операции и удержанием соответствующего ресурса. Такой механизм дороже и требует предметного обоснования. В DeskQueue обычная проверка перед маршрутом сохраняет понятный и достаточный учебный договор.
Полные файлы учебного проекта. Примеры при подготовке текста не запускались; ответы и вывод в уроке описывают ожидаемый результат читателя.