Проверка личности на каждом запросе
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
При входе сервер сверил пароль — не храня самих паролей. Дальше человек шлёт десятки запросов и в каждом прикладывает строку, подписанную секретом сервера, — а выданных строк сервер не помнит. Значит, у него нет памяти о вошедших, и кто пришёл он вычисляет заново на каждом запросе — из самого запроса и ниоткуда больше. Место такой проверки в каркасе уже есть: страж работает после выбора маршрута и до разбора входа. Сама проверка окажется не одной, а двумя: наш ли это токен — подпись и срок, — и правда ли то, что в нём написано, а на этот вопрос отвечает только ваша таблица. За месяц жизни токена учётку могли удалить, а роль понизить. В конце — мост: у браузера личность в куке, у телефона в заголовке, а проверка на сервере одна.
Сессий на сервере нет, таблицы выданных токенов нет — в этом и весь смысл токена. Значит, каждый запрос приходит незнакомцем, и знакомство начинается с нуля столько раз, сколько запросов прислал клиент. Открыл человек список заметок, пролистал, открыл одну, поправил — четыре запроса, четыре проверки токена. Это не расточительство: проверка подписи — один HMAC от пары сотен байт, дешевле любого обращения к диску.
Первое, что придётся решить, — каким кодом отказывать. Каркас переводит false из стража в 403 «доступ закрыт», и для стража роли это верно. Гостю без токена — нет: 403 означает «я знаю, кто ты, и тебе сюда нельзя», а знать о госте нечего, он не представился. Ему нужен 401: «я не знаю, кто ты». Поэтому страж личности не возвращает false, а бросает своё HttpError(401) — ловец каркаса переведёт его в ответ так же, как любое другое исключение обработчика.
Второе — куда девать найденную личность. Страж вход не трогает и значения не возвращает: только «да» или «нет». Поэтому разобранные поля токена он кладёт в сам запрос, а до обработчика их доносит труба — обработчику каркас передаёт call, а не request. В настоящем Nest ровно так и сделано: страж пишет request.user, а достаёт его оттуда декоратор параметра.
Ниже страж личности целиком: bearerOf достаёт токен из заголовка, claimsOf проверяет подпись и срок, identity связывает их и кладёт результат в запрос. Разделены они не для красоты — «откуда взять» в этой статье ещё поменяется, а «годится ли» останется тем же. Семь запросов: без заголовка, с чужой схемой, с огрызком строки, с токеном соседнего сервиса, с просроченным своим, со свежим своим и, последним, удаление под тем же свежим токеном. Прогноз: какой из семи ответов будет 403 — и какой не будет отказом вовсе?
Пять отказов подряд с одним кодом и двумя разными текстами. «Нужен токен» — заголовка не было или он не той схемы: проверять нечего. «Токен не годится» — проверка была и не прошла, и три очень разных случая слились в один ответ: обрывок строки, подпись чужим секретом и свой, но вчерашний токен. Так и надо. Клиенту довольно знать, что предъявленное удостоверение не годится; что именно с ним не так, пишут в журнал сервера, а не в ответ. Разница между «подпись не сошлась» и «срок истёк» в ответе помогает не клиенту, а тому, кто подбирает токен.
Последняя строка — единственная с 403, и это ровно тот случай, для которого 403 придуман. Личность известна: токен свой, срок не вышел, sub разобран. Отказ не про личность, а про право — роль в токене user, а admin просит другого. Пара «401 или 403» после этого читается просто: 401 — сначала представьтесь, 403 — представились, но нельзя. Клиент по этой разнице решает, просить ли новый вход: на 401 телефон «Подхода» ставит синхронизацию на паузу, а на 403 нового токена не просит — там дело не в токене.
Токен проверяется на каждом запросе, потому что сервер не помнит предыдущего. Единственное, что связывает два запроса одного человека, — сама строка токена.
Подпись сошлась, срок не вышел. Что именно это доказало? Что строку выдал этот сервер и с тех пор её не меняли — и ничего больше. Поля внутри — снимок того, что сервер знал о человеке в момент выдачи. У API автора токен живёт тридцать дней, и отозвать его нельзя: серверу нечего забывать, он токенов не помнит.
За тридцать дней в таблице пользователей случается всякое. Роль admin понизили до user — человек ушёл из модераторов. Учётку удалили — сотрудник уволился. Токен обо всём этом не узнает: он лежит на телефоне и продолжает предъявлять прошлое, подписанное настоящим секретом.
Лекарство ровно одно: после подписи — строка в таблице. Возьмём ту самую таблицу из главы 9 и заведём в ней уникальный индекс по id, чтобы перепроверка стоила один поиск по индексу, а не обход всех строк. Ниже два маршрута с одинаковым телом; отличаются они только первым стражем: первый верит токену, второй спрашивает таблицу. Токены выданы 29 дней назад, при сроке тридцать дней, — подпись верна, срок не вышел. За эти четыре недели ada перестала быть админом, а учётку eve стёрли. Прогноз: что ответит каждый из шести запросов и сколько раз страж сходит в таблицу?
Страж, который верит токену, впустил обоих: и понижённую ada, и удалённую eve. Заметку удалил человек, у которого в таблице роль user, и человек, которого в таблице вообще нет. Страж с перепроверкой отказал обоим — и заметьте, кодом 401, а не 403. Это не описка: вопрос не в праве, а в удостоверении. Предъявленное больше не описывает действительность, поэтому и ответ тот же, что на просроченный токен: представьтесь заново.
На несовпадении роли развилка, и обе ветки живые. Первая — отказать, как здесь: клиент заметит 401, попросит новый токен и получит его с настоящей ролью. Вторая — пустить, но взять роль из таблицы, а не из токена: тогда понижение роли не выбрасывает человека из системы, а просто сужает ему права. У автора выбран отказ — в боевой функции стоит if (!user || user.role !== payload.role). Плата за него видна: понизили роль — и человек на следующем запросе оказывается на экране входа. Плата за вторую ветку тоньше: токен и ответ начинают расходиться, и клиент, который рисует кнопки по роли из токена, показывает кнопки, которых сервер не даст нажать.
Последняя строка вывода — про цену. Три запроса, три похода в таблицу: перепроверка на каждом запросе, иначе она не перепроверка. Кешировать её можно, но недолго: кеш на минуту означает, что понижение роли вступает в силу через минуту, и это решение, а не оптимизация. Сам поход дешёвый — поиск по первичному ключу, — а вот его отсутствие обходится дорого: ровно тридцать дней прав, которых у человека уже нет.
Перепроверка роли — частный случай. В ту же строку смотрят ещё на три вопроса, и все три стоят тот же один поиск. Жив ли пользователь — поле «заблокирован» или «удалён», если строки не убирают физически: заблокированного надо остановить так же, как удалённого. Не менялся ли пароль — в строке держат время последней смены, и токен, выданный раньше этого времени, отвергают. Так одна смена пароля гасит все прежние токены сразу, а память серверу для этого не нужна — она уже есть в таблице.
Третий вопрос — не отозван ли именно этот токен. Для него в нагрузку кладут собственный идентификатор (jti в RFC 7519), а отозванные держат списком до их exp: после этого срока запись бесполезна, потому что токен и сам перестал действовать. Список отозванных — это та самая память, от которой токен избавлял; она мала и живёт недолго, но она есть. Тот же приём «секрет выведен из строки пользователя» служит и ссылке из письма: секрет ссылки восстановления у автора выведен из текущего хеша пароля, и смена пароля гасит ссылку без всяких списков.
Подпись отвечает на вопрос «наш ли токен». На вопрос «всё ли в нём ещё правда» отвечает только строка в таблице, и спрашивать её надо на каждом запросе.
«На каждом запросе» — это ещё и про то, что ни один маршрут не остался без стража. В каркасе усилитель вешают на класс или на метод, общего уровня у стражей нет: чтобы закрыть всё приложение, стража приходится вписать в каждый контроллер. А списки усилителей только складываются — вычесть стража у одного маршрута нельзя.
Отсюда история, которая случается почти всегда. Страж написан, контроллеры помечены, тесты зелёные. Через неделю появляется новый контроллер — выгрузка, служебная ручка, отчёт, — и @UseGuards над ним никто не ставит. Ничего не падает: ни сборка, ни тесты, ни журнал. Маршрут просто открыт всем, и узнают об этом не раньше, чем кто-нибудь его найдёт.
Лекарство — не дисциплина, а один декоратор. Если контроллер объявляют не через @Controller, а через свой @Secured, который внутри ставит и путь, и стража, и трубу, то забыть стража нельзя: забыть можно только сам декоратор, а без него каркас не соберётся — он потребует пометки. Открытые маршруты при этом перечисляются здесь же, рядом с объявлением: список коротких имён методов у того контроллера, которому они принадлежат.
Ниже три контроллера: заметки через @Secured с одним открытым маршрутом, выгрузка по-старому, отчёт снова через @Secured. Каждый маршрут спрашивается дважды — гостем и с токеном. Прогноз: где у гостя окажется 200?
Выгрузка всех заметок отдаётся гостю: 200, и никакого следа в журнале, кроме самой строки запроса. Отчёт, объявленный новым способом, закрыт, хотя о стражах в нём не сказано ни слова. Разница между этими двумя контроллерами — не в старательности автора, а в том, что у второго не было способа объявить себя открытым.
Почему открытые маршруты перечислены строками, а не помечены декоратором над методом? Потому что ctx каркаса отдаёт стражу имена контроллера и обработчика, а не сам класс и саму функцию, — а по имени пометку не прочесть. В настоящем Nest этого ограничения нет, и таблица различий в статье о страже и трубе это уже называла: ExecutionContext отдаёт getClass() и getHandler(), Reflector читает по ним пометку @Public(), а уровней у стражей три, и верхний ставит app.useGlobalGuards. Устройство при этом то же: закрыто по умолчанию, открытое помечено явно, и пометка живёт рядом с маршрутом, а не в списке адресов.
У открытого маршрута есть обратная сторона, и она стоит целой задачи. Страж на нём не зовётся вовсе — значит, личности у обработчика нет даже тогда, когда токен прислали. А лента заметок должна показывать гостю опубликованное, а вошедшему — ещё и его черновики. Значит, «опознать» и «не пустить» — два разных действия, и на открытом маршруте нужно первое без второго.
Правило «закрыто по умолчанию» проверяется не тестом на защищённый маршрут, а тестом на маршрут, о котором забыли. Первый пишут всегда, второй — почти никогда.
До части VI личность в учебнике приезжала двумя разными способами, и оба остаются в силе. В части IV «кто» приходил кукой: кука несла ключ сессии, а кто за этим ключом стоит, сервер искал у себя — и потому присланному «кто» места не было вовсе. Телефон части V держит токен в защищённом хранилище, а веб-версия того же приложения — в localStorage, потому что SecureStore в браузере пустая заглушка. И полка React и Next обещала, что свои сессии — это часть VI. Пора отдать обещание.
Носители отличаются одним: сколько они берутся рассказать о человеке. Кука с ключом сессии не знает ничего — ключ это указатель, и всё, что известно, лежит в таблице сессий. Токен рассказывает всё сам, и потому его рассказ приходится сверять. Общая у них вся вторая половина: найти строку пользователя и решить про право. Значит, и стражу незачем знать, какой дверью пришёл запрос: «кто пришёл» отвечает whoIs, а дальше идёт та же перепроверка, что и в прошлом разделе.
Ниже шесть запросов: с заголовком телефона, с кукой браузера, с подобранным ключом сессии, с ключом вчерашней сессии, без всего и, последним, с токеном, в котором роль уже устарела. Прогноз: какие два запроса, приехавшие разными дверями, получат дословно один и тот же ответ?
Ответ в первых двух строках дословно один и тот же — и это весь смысл раздела. Обработчику неважно, чем приехала личность: он получает call.user и не знает про носители ничего. Кука с подобранным ключом и кука вчерашней сессии получили 401 — в таблице сессий такой строки нет или её срок вышел. Последняя строка показывает, что перепроверка одна на оба носителя: токен с прежней ролью не проходит и здесь.
Сессия в таблице не хуже токена, она просто другая. Её можно отозвать, удалив строку, — и это делает выход из аккаунта настоящим, а не «забудьте строку на клиенте». Платит она запросом к таблице на каждый запрос — тем самым, который для перепроверки роли всё равно делается. Токен этот запрос экономит, если роли не сверять, — и ровно этой экономией покупает тридцать дней неотзываемости.
У куки есть свойство, которого нет у заголовка: браузер прикладывает её сам. К любому запросу на ваш адрес — в том числе к запросу, который отправила чужая страница. Заголовок Authorization так не поедет: его ставит код, а чужая страница до вашего токена в localStorage не дотянется. Поэтому всё, что ниже, нужно именно куке, и ничего из этого не нужно токену.
Первое — SameSite=Lax, выставленный явно, а не в надежде на умолчание: с чужого сайта такая кука не поедет с POST. Одного его мало, и по двум причинам: умолчания у браузеров разные, а проверить их можно только в браузере; и для SameSite поддомены одного сайта — свои, а веб-версия и API у многих проектов живут как раз на поддоменах. Поэтому второе — на всём, что меняет данные, проверяют заголовок Origin по списку своих источников или требуют свой заголовок, которого у простой формы быть не может. И третье — CORS с credentials открывают только своим источникам. Что при этом на самом деле разрешает заголовок и почему отражать присланный Origin хуже звёздочки, разобрано в настройке старта; здесь важно только то, что с куками этот выбор перестаёт быть делом вкуса: звёздочка с ними не работает вовсе, а отражение источника позволяет любому сайту читать ваши ответы от имени вошедшего человека и посылать запросы, которых простая форма не умеет.
У процедур tRPC здесь своя ловушка. Простая форма с чужого сайта умеет прислать POST только с тремя видами содержимого (text/plain, application/x-www-form-urlencoded, multipart/form-data) — зато для них браузер не спрашивает разрешения заранее. Прогон поднимает роутер tRPC, закрытый звеном по куке, и посылает ему шесть POST с той же кукой, какую приложил бы браузер: первый — как от своего клиента, с JSON; второй и третий — с двумя простыми видами содержимого; четвёртый — вовсе без заголовка типа; последние два — один и тот же multipart/form-data, но в две процедуры: у одной вход не проверяется, у другой разбирается схемой. Прогноз: сколько из шести выполнят мутацию?
import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; export const state = { saved: 0 }; const t = initTRPC.context().create(); // личность берётся только из куки сессии: браузерный клиент с сессией, как в части IV const authed = t.middleware(({ ctx, next }) => { if (ctx.userId === null) throw new TRPCError({ code: 'UNAUTHORIZED' }); return next(); }); export const appRouter = t.router({ notes: t.router({ // вход процедуры не проверяется: что прислали, то и приняли save: t.procedure .use(authed) .input((value) => value) .mutation(({ input }) => { state.saved += 1; return { saved: state.saved, title: input?.title ?? null }; }), // та же процедура, но вход разбирает схема saveChecked: t.procedure .use(authed) .input(z.object({ title: z.string().min(1) })) .mutation(({ input }) => { state.saved += 1; return { saved: state.saved, title: input.title }; }), }), }); export const createContext = ({ req }) => ({ userId: /(?:^|;\s*)sid=s-3f81c0(?:;|$)/.test(req.headers.cookie ?? '') ? 'u2' : null, });
application/json — обычный клиент: 200, заметка сохранена: true text/plain: 415, заметка сохранена: false x-www-form-urlencoded: 415, заметка сохранена: false без content-type: 415, заметка сохранена: false multipart/form-data, вход без схемы: 200, заметка сохранена: true multipart/form-data, вход по схеме: 400, заметка сохранена: false строк в таблице заметок: 2
Три запроса из шести получили 415 — text/plain, форма в теле и запрос вовсе без типа содержимого: tRPC их не разбирает, и до процедуры дело не дошло. А multipart/form-data дошёл — и у процедуры без схемы входа мутация выполнилась. У той же процедуры со схемой ответ 400: тело разобрали, к записи не приступили. Вывод, который стоит запомнить: от простой формы с чужого сайта процедуру защищает валидатор входа, и это довод за схему на каждой мутации помимо всех доводов про типы. Поэтому же sync.push и sync.pull в процедурах синхронизации куку не принимают вовсе: у синхронизации нет ни одного клиента с кукой, а принимать её значило бы открыть запись межсайтовому запросу.
Чего прогон не проверяет — поведения самого браузера: ни SameSite, ни CORS с credentials он не измеряет. Он посылает то, что послал бы браузер, и смотрит на ответ сервера. Первую половину — отправит ли браузер куку — проверяют в браузере, и это отдельная работа.
Один страж, два носителя. Если проверка личности написана дважды — по разу на носитель, — она и расходиться будет дважды.
У API учебника двенадцать процедур tRPC и два контроллера Nest. Личность там устроена в два слоя, и оба видны в коде. Первый слой собирает контекст на каждый запрос — и запрос не отклоняет.
Это и есть «опознать, но не пускать», то самое, чего не хватало открытому маршруту в третьем разделе. Негодный токен здесь не ошибка, а пустая личность: лента заметок отвечает и гостю, просто вошедший видит в ней ещё и свои черновики. Авторитетная проверка — подпись, строка пользователя и сверка роли — живёт отдельной функцией под звеном; её выдержку и разбор даёт статья о звеньях. Второй слой — страж того контроллера, у которого звеньев tRPC нет.
Страж загрузки проверяет только подпись: verifyToken бросает — 401, не бросает — пропуск. Ни существования пользователя, ни его роли он не спрашивает, и комментарий в файле честно говорит, почему: общего слоя у контроллеров Nest и процедур tRPC нет, а перепроверку делает функция под звеном. Следствие из этих двух листингов складывается само: токен удалённой учётки процедуры отвергнут, а загрузка фотографии примет — до конца тридцати дней. Лечится это тем же, что разобрано выше: страж должен спрашивать таблицу, как спрашивает её звено.
И одна подробность в verifyToken, мимо которой легко пройти: после сверки подписи там проверяется форма нагрузки — что sub строка, а роль из известного списка. Причина в комментарии: токен мог выпустить прошлая версия приложения, с другим набором полей и ролей. Нагрузка своего токена — такой же ввод, как тело запроса, просто подписанный: подпись говорит «это выдали мы», а не «это выдала нынешняя версия нас».
Остался третий вопрос доступа, и он не про личность. Роль решает, что человеку можно вообще, а владение — над чем именно: свой черновик править можно, чужой нельзя, и отвечать на чужой надо так, чтобы ответ не выдал его существования. Этим занимается следующая статья главы.
Две двери — две проверки. Пока они написаны отдельно, они будут расходиться: одна узнаёт об удалённой учётке, другая нет.
- Сервер не помнит выданных токенов, поэтому личность он вычисляет заново на каждом запросе. Место этой проверки — страж: он работает после выбора маршрута и до разбора входа.
- Страж отвечает «да» или «нет», и
falseкаркас переводит в 403. Гостю нужен 401 — «сначала представьтесь», — поэтому страж личности бросает исключение сам, а найденные поля токена кладёт в запрос: обработчику их доносит труба, в Nest — декоратор параметра. - Подпись доказывает, что токен выдали мы и не правили. За тридцать дней его жизни учётку могли удалить, а роль понизить, поэтому после подписи страж читает строку пользователя в таблице — один поиск по индексу на запрос. Несовпадение роли — это 401, а не 403: негодно удостоверение, а не право.
- Маршрут без стража не падает и не пишется в журнал — он просто открыт. Поэтому контроллер объявляют одним декоратором, который ставит и путь, и стража, а открытые маршруты перечисляют рядом; в Nest то же делают
useGlobalGuardsи пометка, прочитаннаяReflector. - На открытом маршруте страж не зовётся, значит, личности нет даже у того, кто прислал токен. «Опознать» и «не пустить» — разные действия: боевой контекст заполняет личность по-доброму, а требуют её отдельные звенья и стражи.
- Кука и токен — два носителя одной личности: у куки внутри только ключ сессии, у токена — рассказ о себе, который приходится сверять. Куке вдобавок нужны
SameSite, проверкаOriginи узкий CORS, а процедуру tRPC от простой формы с чужого сайта защищает схема входа.
1/4Страж проверил подпись токена и его срок — всё сошлось. Что именно это доказало?
2/4Гость без заголовка Authorization просит закрытый маршрут. Почему ему отвечают 401, а не 403?
3/4Маршрут пометили открытым, и страж личности на нём не зовётся вовсе. Что из этого следует?
4/4В прогоне простой запрос с чужого сайта выполнил мутацию только в одном виде — multipart/form-data. Что остановило его, когда у процедуры появилась схема входа?
Гость получает 403, хотя ещё не представился
Страж личности написан прямо, без исключений: токен есть и годится — «да», иначе «нет». Работает: своего пускает, чужого нет. Посмотрите на коды ответов и почините так, чтобы неизвестному гостю приходил 401, а известному, которому нельзя, — 403.
false из стража каркас переводит в 403 «доступ закрыт» — один код на все случаи. Гость и огрызок токена получают 403, хотя сервер понятия не имеет, кто это, а «свой, но не админ» получает тот же 403, хотя случай совсем другой. Клиент по такому ответу не решит, просить ли новый вход.
Страж личности вообще не пользуется возвратом false: у него нет случая «знаю, кто ты, и тебе нельзя» — это дело стража роли. Вывод после правки: гость: 401 нужен токен, огрызок токена: 401 токен не годится, свой: 200 черновики 2, свой, но не админ: 403 доступ закрыт — и последняя строка единственная, где 403 уместен.
Отдельно стоит заметить, что тексты у двух 401 разные, а код один. Это нарочно: клиенту довольно знать, что удостоверение не годится, а чем именно оно не годится — запись для журнала сервера.
Открытый список перестал узнавать своих
Лента опубликованных заметок открыта всем: её имя стоит в списке открытых маршрутов, и страж на ней не зовётся. Задумано было так: гость видит опубликованное, а вошедший — ещё и свой черновик. Запустите и посмотрите, что видит вошедший. Почините, не закрывая ленту от гостя.
Вошедший видит то же, что гость: OPEN.has(ctx.handler) срабатывает раньше, чем authenticate, и страж не зовётся вовсе — а личность появляется только как итог его работы. Открытый маршрут не должен требовать личности, но опознать пришедшего он обязан.
Вывод после правки: гость → /notes/published: 200 2 заметки, гость → /notes/mine: 401 нужен токен, свой → /notes/published: 200 2 заметки и 1 черновик, свой → /notes/mine: 200 черновики 2. Так устроен и боевой API: контекст заполняет личность «по-доброму», а требуют её отдельные звенья.
Одно решение здесь спорное и его стоит принять осознанно: негодный токен на открытом маршруте мы считаем отсутствием личности, а не ошибкой. Плюс — лента не ломается у клиента с протухшим токеном; минус — клиент не узнает, что пора войти заново, пока не попросит что-нибудь закрытое. Именно поэтому 401 ловят на закрытых запросах, а не на ленте.
Токен, приехавший в адресе
Ссылку с токеном удобно отправить письмом, вставить в <img> или открыть в браузере руками — заголовков там не поставить. Поэтому страж стал принимать токен ещё и из строки запроса. Запустите, посмотрите на последнюю строку и решите, что с этим делать.
Оба запроса прошли, и во втором токен целиком уехал в адрес. Адрес — самое публичное место запроса: его пишет журнал прокси и журнал приложения, он попадает в историю браузера, в закладки, в заголовок Referer при переходе на чужой сайт и в аналитику — в счётчике учебника значения token и code маскируют именно поэтому. Тело POST и заголовок в журналы не попадают.
После правки запрос с токеном в адресе получает 401 нужен токен. Если ссылка всё-таки нужна — например, письмо «подтвердите адрес», — токен для неё выдают отдельный: короткий, на одно действие и на один раз. Тогда утечка адреса в журнал стоит не тридцати дней доступа, а одного действия, которое к тому же уже выполнено.