Страж и труба: кто отказывает раньше и почему это важно
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Каркас, собранный в прошлой главе, умеет отвечать, но ничего не знает о том, кому можно. Звено конвейера, которое вы передаёте в createApp, знает о запросе только то, что приехало по проводу: способ, адрес, заголовки — и ни слова о том, какой контроллер и какой его метод выберет маршрутизатор. Поэтому «сюда пускать только с токеном» приходится писать вторым описанием маршрута, строкой адреса, и эта копия расходится с настоящей таблицей при первом же новом методе. В этой статье каркас получает два новых вида звеньев: страж отвечает «да» или «нет» и работает после выбора маршрута, но раньше всего, что делают со входом, а труба не пускает и не отказывает — она превращает вход в то, с чем будет работать обработчик. Разница между ними и есть весь урок: перепутать их — значит заплатить за разбор того, чего гостю вообще нельзя, и заодно рассказать ему, какие у вас поля.
Отказывать конвейер умеет и без всякого каркаса: звено, которое не позвало next(), отвечает само, и дальше по цепочке никто не идёт. Значит, проверку токена можно написать обычным звеном и передать её в createApp вместе с приложением. Так и сделаем — а потом посмотрим, на что этой проверки хватило.
Одно важно помнить заранее: звено стоит раньше маршрута. Маршрутизатор у каркаса — последнее звено конвейера, обработчик контроллера живёт за ним. Пока очередь доходит до вашего звена, каркас ещё никого не выбрал: ни контроллера, ни метода, ни даже params. На руках у звена один request. Поэтому мысль «запись в заметки — только с токеном» ему приходится выражать единственным доступным способом: через способ запроса и строку адреса.
В контроллере ниже три маршрута, и два из них пишут. Прогноз: какие из трёх запросов дойдут до обработчика и какой ответ получит каждый?
Защита сработала ровно там, где её описали строкой, — и нигде больше. POST /notes/1/pin — тот же контроллер, тот же способ запроса, та же правка данных — проехал внутрь без токена: в звене стоит request.target === "/notes", а цель у него другая. Строка «обработчик pin» в выводе и есть дыра, и заметна она только потому, что обработчик о себе напечатал.
Починить это в звене можно: сравнить префикс, написать регулярное выражение, завести список защищённых путей. Только каждая такая починка — вторая таблица маршрутов рядом с первой. Первая живёт над методами (@Post("/:id/pin")), вторая — в звене, и связи между ними нет никакой. Маршрут переименуют, добавят, перенесут в другой контроллер — звено об этом не узнает и будет честно охранять адрес, которого уже нет. Это ровно та болезнь, от которой каркас лечил маршруты: путь жил отдельно от метода. Теперь отдельно от метода живёт правило доступа.
Звено видит запрос, а не решение. Всё, что оно может сказать про адресата, — это догадка по строке адреса.
Лекарство то же, которым глава 5 лечила маршруты: пометить само место, а списки собрать при сборке. Правило доступа — это функция, которая отвечает «да» или «нет»; будем звать её стражем и держать в реестре на слабых ссылках, таком же, как prefixes и routes. Ключ реестра — класс контроллера или функция-обработчик, значение — список стражей.
Страж получает не голый запрос, а контекст: сам request и то, что маршрутизатор уже выяснил, — params, query, имя контроллера и имя выбранного метода. Возвращает boolean или промис с ним, и больше ничего: вход он не трогает. Страж отвечает на вопрос «можно ли этому запросу сюда», а не «что делать с телом».
@UseGuards(auth) — декоратор с аргументами: сначала вычисляется вызов UseGuards(auth), и только его результат становится декоратором. Декоратор при этом один и тот же и для класса, и для метода — разница лишь в том, чем окажется value: классом или функцией. Записывать-то нужно в обоих случаях одно и то же.
Ниже контроллер с четырьмя маршрутами: @UseGuards(auth) стоит над классом, @UseGuards(admin) — над remove, а над pin, как и в прошлом разделе, нет ничего, кроме маршрута. Прогноз: какие стражи достанутся pin и в каком порядке они встанут у remove?
Новый маршрут под замком, и ради этого не потребовалось ни строчки: страж класса достаётся всем его методам — в том числе тем, которых не было, когда правило писали. В этом и разница между «правило висит на маршруте» и «правило знает про строку адреса»: первое переезжает вместе с методом, второе остаётся там, где его напечатали.
Списки складываются по порядку: сперва стражи класса, потом стражи метода. Порядок @UseGuards и @Controller над классом при этом не важен — оба декоратора получают один и тот же класс, потому что ни один из них ничего не возвращает. А вот чего каркас не умеет — это вычитать. Открыть один маршрут в защищённом классе можно единственным способом: не вешать стража на класс вовсе, а расставить его по методам, которым он нужен. Nest решает ту же задачу иначе, и в конце статьи видно, чего для этого не хватает нашему контексту.
Страж отвечает «да» или «нет». Если ему захотелось что-то поправить в запросе — это уже не страж.
Второе, что просится наружу из обработчика, — разбор входа. Обрезать пробелы, отбраковать пустое, убедиться, что id — число: работы на три строки, но эти три строки одинаковы в каждом методе и не имеют отношения к заметкам. Вынесем их в трубу — функцию, которая получает вызов и возвращает вызов.
Отличие трубы от стража не в том, где она стоит, а в том, что она возвращает. Страж отвечает «да» и «нет», и его ответ никуда не едет: каркас либо идёт дальше, либо не идёт. Труба возвращает значение, и это значение едет дальше — сначала в следующую трубу, потом в обработчик. Поэтому трубы — цепочка, а не список проверок, и порядок в ней меняет результат.
Ниже три трубы и четыре случая. Прогноз: чем кончатся два разных порядка на теле из одних пробелов?
Порядок «сначала проверка» пропустил пустую заметку внутрь: notEmpty смотрел на строку из трёх пробелов, а три пробела — не пустая строка. Обрезка, которая шла следом, честно сделала из них пустоту, и обработчик получил тело «». Поменяли трубы местами — и тот же вход отвергнут. Ни одна из труб при этом не изменилась: изменилось только то, что попало на вход второй.
Отказ трубы — это HttpError(400): «вход не годится». Не 401 и не 403 — труба ничего не знает о том, кто пришёл, и знать не должна. А numericId заодно показывает, что труба — не только про тело: params и query тоже вход, и тоже приехали от клиента строками, которым верить не за что.
Труба возвращает значение. Функция, которая ничего не вернула, — это не труба, а проверка, которая притворилась трубой.
Теперь всё вместе. В каркасе меняется одно место — те две строки, которыми сборка заводила маршрут. Раньше в маршрутизатор уезжала функция, которая сразу звала метод контроллера; теперь она сперва спрашивает стражей, потом прогоняет трубы и только после этого зовёт обработчик. Списки и тех и других собираются один раз, при сборке: на каждый запрос остаётся только их обход.
Порядок внутри жёсткий, и он не произвол. Стражи идут до того, как каркас соберёт call: кому нельзя, тому незачем разбирать тело. Трубы — после стражей и перед обработчиком, потому что их работа и есть приготовить обработчику вход. А звенья, которые вы передали в createApp, — те самые, которые Nest зовёт прослойками, — остаются снаружи всего этого: маршрутизатор — последнее звено конвейера, и всё, что связано с выбранным маршрутом, происходит уже внутри него. Дальше в статье они так и зовутся прослойками: рядом со стражем и трубой слово «звено» стало бы общим на троих.
Ниже новый каркас целиком и приложение на нём: страж auth на классе, страж admin на remove, трубы trim и notEmpty на create, прослойка-журнал вокруг всего. Каждый участник печатает строку о себе. Прогноз: на каком из четырёх запросов труба notEmpty не выполнится вовсе и сколько строк напечатает первый запрос?
Первый запрос — три строки: прослойка, страж, прослойка. Ни труб, ни обработчика: страж вернул «нет», каркас бросил HttpError(403), и ловец перевёл её в ответ. Второй прошёл стража и умер на трубе — 400, и обработчик снова не выполнился. Третий дошёл до конца. Четвёртый интереснее всех: auth сказал «да», а admin — «нет», и видно, что стражи идут именно в порядке «класс, потом метод», а не наоборот.
Прослойка при этом отпечаталась на каждом запросе и в каждом увидела готовый ответ — включая 403 и 400. Исключение до неё не долетает: ловец каркаса стоит внутри, перед отправкой. Ровно то же было и с пятисоткой в прошлой главе, и это не совпадение, а одно и то же устройство: ваши звенья — снаружи, каркас — внутри.
Одного звена в этом порядке не хватает, и его отсутствие стоит проговорить. У Nest между стражем и трубой работает ещё и перехватчик: он обнимает обработчик с обеих сторон и умеет менять то, что тот вернул. Перехватчик и фильтр — следующая пара усилителей каркаса; после них порядок станет таким же, как в настоящем Nest.
Стражи и трубы собираются при сборке, а не на запросе. На запросе остаётся обход двух коротких массивов.
Порядок «страж, потом труба» выглядит как вкусовщина ровно до первого раза, когда его нарушили. В части IV он уже был назван словами: сначала дешёвые проверки, потом база, работа — последней. Страж и труба раскладывают то же правило по местам: страж — это «кто пришёл», труба — это «что прислали», и первое должно случиться раньше второго.
Нарушить порядок в каркасе не выйдет — он зашит в сборку. А вот обойти его легко, и обходят обычно не со зла: разбор входа кладут в прослойку. Прослойка кажется хорошим местом — она общая, она видит тело, она уже написана. Беда в том, что прослойка работает снаружи маршрута, а значит, и раньше любого стража: к моменту, когда каркас выяснит, кто пришёл, тело уже разобрано и ответ уже отправлен.
Ниже одно и то же приложение, собранное дважды: в первом разбор стоит прослойкой, во втором — трубой. Сам разбор — одна и та же функция, и она считает, сколько раз её позвали. Прогноз: что получит гость без токена в каждом случае и чему будет равен счётчик?
Гость, которому вообще нельзя писать заметки, получил подробный разбор своего тела: у вас есть поле title, и оно от 1 до 20 символов. Он не входил в систему, не подбирал токен — просто отправил пустой запрос и узнал форму данных. Во втором случае ему ответили «доступ закрыт», и счётчик не шелохнулся: разбор не выполнялся вовсе.
Дело не в том, что страж всегда дешевле трубы, — бывает и наоборот: страж, который идёт в базу за ролью, дороже любой обрезки пробелов. Дело в том, о чём говорит отказ. Отказ по праву не рассказывает ничего, кроме «нельзя». Отказ по форме обязан быть подробным, иначе клиент не поймёт, что чинить. Подробный ответ тому, кому нельзя, — это утечка, и единственный способ её не допустить — отказывать по праву раньше.
Гостю говорят «нельзя». Что именно не так с его телом — это разговор для того, кому уже можно.
Названия у Nest свои, устройство — то же самое: те же две пометки, на классе и на методе, те же два списка, собранных при старте, и тот же обход этих списков вокруг обработчика.
| Узел каркаса | Как это зовётся в Nest | Чем настоящий Nest отличается |
|---|---|---|
@UseGuards(auth) над классом или методом | тот же @UseGuards, и тоже над классом или методом | аргумент — класс со свойством canActivate, а не функция: Nest достаёт его из контейнера, и стражу доступно внедрение зависимостей |
реестры guardsOf и pipesOf на WeakMap | reflect-metadata по ключам __guards__ и __pipes__ | запись кладётся туда же, куда у нас: на класс или на саму функцию-обработчик |
страж вернул false — каркас бросил HttpError(403) | canActivate вернул false — ForbiddenException | текст ответа готовый — Forbidden resource; свой текст и свой код — своё исключение, как и у нас |
ctx с именами контроллера и обработчика | ExecutionContext | отдаёт не имена, а сам класс (getClass()) и саму функцию (getHandler()) — по ним Reflector читает пометки вроде @Public(), и так делается открытый маршрут в закрытом контроллере |
@UsePipes(trim, notEmpty) на методе | @UsePipes — и труба у параметра: @Body(new ValidationPipe()) | труба привязана к одному параметру и получает его значение с метаданными (какой тип объявлен, откуда взято, имя), а не весь вызов целиком |
| сперва общий на всё приложение, потом класса, потом метода | уровней три: глобальные, класса, метода | app.useGlobalGuards и app.useGlobalPipes дают ещё один адрес для того же правила; у труб есть и четвёртый — сам параметр |
Общего уровня усилителей в каркасе тоже нет — и он единственный, кто не требовал нового узла: стражи и трубы уже складываются в список перед маршрутом, осталось положить в начало ещё один. Третий аргумент createApp и есть этот список: guards и pipes на всё приложение. Ниже один и тот же страж помечен на общем уровне, на классе и на методе — журнал печатает, в каком порядке они обходятся.
Порядок тот же, что у усилителей класса и метода: общий сперва, потом класс, потом метод — и трубы в том же порядке. Слово global здесь — обычный вход функции, а не магическое хранилище: его читают один раз, при сборке маршрута.
Чего в каркасе по-прежнему нет — список короткий. Отмены: пометки «этот маршрут открыт» каркас не понимает, потому что его ctx отдаёт строки, а не саму функцию, у которой можно было бы спросить реестр. Трубы у параметра: обработчик получает один объект, и @Body с @Param у него нет. Ничего из этого не требует нового узла — только новых строк в уже написанных.
Функция закрывает собой всё, что ей нужно, ещё при объявлении. Пока страж проверяет заголовок, этого хватает; но настоящему стражу нужны ключ подписи из конфигурации, таблица пользователей, кеш ролей — то есть сервисы, которые собирает контейнер.
Класс решает это ровно тем же способом, что и контроллер: зависимости в конструкторе, экземпляр создаёт контейнер, а в @UseGuards едет сам класс — Nest найдёт его среди injectables того модуля, которому принадлежит контроллер. Отсюда и мелкая, но полезная подробность: @UseGuards(JwtGuard) и @UseGuards(new JwtGuard()) — разные вещи. Первому Nest соберёт зависимости, второй останется тем объектом, который вы создали руками.
Остальные усилители подъедут в своих статьях. Валидатор превратит трубу из «проверки на месте» в схему, которая заодно выводит тип; доменные коды заменят голый HttpError одним форматом ответа об ошибке; измеренный жизненный цикл поставит журнал этой статьи рядом с журналом настоящего Nest. А страж, который и правда проверяет личность, а не заголовок, — это глава про вход и доступ; там же выяснится, почему роль стоит перепроверять в своей таблице, а не верить тому, что написано в токене.
Страж и труба — не два способа отказать. Это два разных вопроса: «можно ли тебе» и «годится ли это».
- Звено конвейера стоит раньше маршрутизатора и знает только запрос. Правило доступа, выраженное через строку адреса, — вторая копия таблицы маршрутов, и новый метод контроллера в неё не попадает.
- Страж — функция, которая отвечает «да» или «нет». Она помечает класс или метод, список собирается при сборке, а зовётся после выбора маршрута и до разбора входа: «нет» каркас переводит в 403, а своё исключение стража — в свой код.
- Труба возвращает значение: следующая получает то, что вернула предыдущая, а обработчик — то, что вышло из последней. Поэтому порядок труб меняет результат, а отказ трубы — это 400 «вход не годится», а не «вам нельзя».
- Списки усилителей складываются от общего к частному — приложение, потом класс, потом метод — и только складываются. Открыть один маршрут в закрытом классе можно, лишь сняв стража с класса; Nest вместо этого помечает исключение и читает пометку
Reflector. - Порядок «прослойка, страж, труба, обработчик» — не косметика. Разбор входа, положенный в прослойку, случается раньше любого стража: гость платит за разбор и по тексту ошибки узнаёт форму ваших данных.
- В Nest это
CanActivateиPipeTransform— классы из контейнера, а не функции. Страж получаетExecutionContextс самим классом и самой функцией обработчика, труба привязывается ещё и к параметру, а уровней у обоих три: глобальный, контроллера и метода.
1/4Почему обычное звено конвейера не может защитить метод remove контроллера?
2/4Чем страж отличается от трубы?
3/4Над классом стоит @UseGuards(auth), над методом — @UseGuards(admin). Что достанется методу?
4/4Разбор тела положили в прослойку, а проверку токена оставили стражем. Что получит гость без токена, приславший пустое тело?
Одному маршруту — без замка
Контроллер целиком закрыт стражем auth, и это перебор: читать заметки должно быть можно кому угодно, а писать и удалять — только с токеном. Сейчас GET /notes/1 отвечает 403 доступ закрыт. Добейтесь 200 купить хлеб на чтении, не трогая ни каркас, ни самого стража и не пуская гостя в POST и DELETE. Помните, чего каркас не умеет: списки усилителей он только складывает.
Вычесть стража нельзя, поэтому единственный ход — не вешать его на класс. Снимаем пометку с NotesController и ставим её над теми двумя методами, которым она нужна.
Плата за это видна сразу: пометка размножилась, и следующий метод контроллера по умолчанию окажется открытым — забыть @UseGuards проще, чем написать. Поэтому в настоящем Nest поступают наоборот: стража оставляют на классе (а то и глобальным), а исключение помечают своим декоратором — @Public(), который кладёт в метаданные флаг. Страж читает этот флаг через Reflector и пропускает такой маршрут сам. Нашему стражу так не суметь: в ctx у него имя обработчика строкой, а не сама функция, по которой можно было бы спросить реестр.
Чужая заметка не должна просвечивать
Проверка владельца написана трубой: она достаёт заметку и на чужую отвечает 400 заметка чужая. Ответ честный и совершенно лишний — по нему видно, что заметка с таким номером существует. Перенесите проверку туда, где ей место, и сделайте так, чтобы чужая заметка отвечала тем же, чем несуществующая: 404 нет такой заметки. Трубу оставлять не нужно.
«Ваша ли это заметка» — вопрос про право, а не про форму входа, значит, это страж. Стражу не обязательно возвращать false: своё исключение он бросает сам, и тогда код ответа выбирает он, а не каркас.
Над методом вместо @UsePipes(ownerPipe) теперь @UseGuards(owner). Два случая слились в один ответ, и это не небрежность, а защита: пока ответы различались, любой желающий мог перебрать номера и узнать, какие заметки в системе есть, — чужие отвечали одним кодом, несуществующие другим. Тот же приём вы уже видели в части IV, в каркасе серверного действия: «задача не найдена» там стоит и на чужой задаче тоже.
И обратите внимание, что страж получился «дорогим» — он ходит в хранилище. Правило «страж раньше трубы» не про скорость: оно про то, что отказ по праву не должен ничего рассказывать о данных.
Проверки, которые остались в обработчике
Обработчик comment начинается с чтения заметки, а проверяет текст комментария уже после. Из-за этого хранилище читается даже там, где комментарий заведомо не годится. Перенесите обе проверки в трубу так, чтобы на испорченном вводе строки «хранилище: чтение 1» не было вовсе, ответы остались прежними, а в обработчике не осталось ни одного if.
Обе проверки — про вход, а не про заметки, значит, это труба. Она же заодно и обрезает пробелы, так что обработчику достаётся готовый текст.
В выводе остаются четыре строки вместо шести: два отказа без чтения и один удачный комментарий с чтением. Обработчик при этом ужался до одной строки и стал наконец про заметки. Это и есть смысл трубы: не «ещё одно место для проверок», а граница, за которой вход уже разобран и больше не обсуждается.