Аутентификация и авторизация
Как система устанавливает, кто обращается, и как решает, что этому обратившемуся разрешено. Документ задаёт раскладку: какая проверка живёт в каком слое, какие бывают удостоверения и сколько живёт каждое, как устроен единый вход между несколькими сервисами, как выражается правило доступа, как выглядит отказ и как убедиться, что код этой раскладке отвечает.
33 минутыКак система устанавливает, кто обращается, и как решает, что этому обратившемуся разрешено. Документ задаёт раскладку: какая проверка живёт в каком слое, какие бывают удостоверения и сколько живёт каждое, как устроен единый вход между несколькими сервисами, как выражается правило доступа, как выглядит отказ и как убедиться, что код этой раскладке отвечает.
Читатель предполагается знакомым со слоями бэкенда (BMBP) и с ролью шлюза (BMGP). Хранение ключа подписи и его плановая замена здесь не описываются: это общее правило для всех секретов — секреты.
Место в цепочке
| Откуда пришли | Этот документ | Куда ведёт |
|---|---|---|
бэкенд разложен по слоям, доступ объявляется требованием на входе эндпойнта, ответы уходят в конверте (BMBP); наружу опубликован только шлюз, он пробрасывает заголовок авторизации без изменений (BMGP); ключ подписи лежит в .secrets/ вне репозитория (секреты) | виды удостоверений и сроки их жизни, поток единого входа, место проверки владения объектом, ответы при отказе, проверки соответствия | тестирование: отказ доступа — такой же обязательный случай, как успешный путь; наблюдение: попытки входа и отказы видны в журнале, значения удостоверений в него не попадают |
Что предыдущий этап обязан обеспечить:
- единый каталог ошибок в фабрике эндпойнтов. Без него отказ выражается по-разному в каждом обработчике, и клиент не может отличить «продли удостоверение» от «этого нет»;
- явный перечень маршрутов на шлюзе (
404на всё неописанное). Ручка, не попавшая в перечень, всё равно существует у сервиса, и её защита — это её собственное требование доступа, а не умолчание шлюза; - ключ подписи вне репозитория и разный в разных средах. Общий ключ означает, что удостоверение, выпущенное в тестовой среде, действует в боевой.
Что этот документ оставляет следующему: набор удостоверений с известными сроками, один способ
выразить отказ и правило доступа, живущее в core. На этом строятся тестовые случаи (отказ
проверяется по коду, а не по тексту) и записи журнала (исход попытки без значения удостоверения).
Два разных вопроса
Аутентификация отвечает на вопрос «кто обращается». Результат — идентификатор учётной записи либо отказ. Авторизация отвечает на вопрос «разрешено ли этому обратившемуся вот это действие над вот этим объектом». Результат — «да» или отказ.
| Аутентификация | Авторизация | |
|---|---|---|
| Вопрос | кто это | что ему можно |
| Вход | удостоверение (токен, код, подпись) | проверенный обратившийся + действие + объект |
| Выход | идентификатор и роль либо отказ | разрешение либо отказ |
| Зависит от | способа входа (браузер, машинный клиент, соседний сервис) | предметной области, не от способа входа |
| Живёт в | api (граница) | core (бизнес-логика) |
Разделение нужно потому, что стороны меняются независимо. Способов входа со временем становится больше: форма с паролем, одноразовый код, внешний провайдер, встроенный мини-апп, машинный токен, внутренний вызов между сервисами. Правило доступа при этом одно и то же: «редактировать заказ может его владелец или сотрудник поддержки». Если правило записано внутри проверки удостоверения, каждый новый способ входа требует переписать все правила, а забытая ветка означает не отказ, а незамеченное разрешение.
Обратная зависимость даёт тот же результат с другой стороны: проверка подписи, знающая про заказы, не переиспользуется, а копируется в каждый сервис — и копии расходятся.
Где что живёт
| Слой | Что делает | Чего не делает |
|---|---|---|
| шлюз (BMGP) | пробрасывает заголовок авторизации без изменений; вырезает заголовки личности, пришедшие снаружи; держит лимиты частоты на ручки входа; отвечает 404 на неописанные маршруты | не выпускает удостоверений и не решает по существу, пускать ли к объекту |
api | разбирает удостоверение, превращает его в «проверенного обратившегося», при провале отдаёт стандартный отказ; объявляет требование на входе обработчика | не содержит правил доступа к объектам |
core | правило доступа: кто владелец, что можно в текущем состоянии объекта, какой уровень доступа требуется | не разбирает заголовки и не знает о транспорте |
infrastructure | выпуск и проверка подписи, хранение сессий и отпечатков машинных токенов, счётчики попыток, обращение к модулю авторизации | не решает, разрешено ли действие |
| интерфейс (BMFP) | скрывает то, что недоступно, и уводит на вход при отказе | не является местом проверки |
Поток запроса:
клиент ──заголовок авторизации──▶ шлюз ──без изменений──▶ api
│ разбор удостоверения
│ → проверенный обратившийся
▼
core ← правило доступа
│ (роль, владение, состояние)
▼
infrastructure → БДПроверенный обратившийся — доменный тип из core/domains/dtos, а не транспортная структура:
идентификатор учётной записи, роль, вид удостоверения. core принимает его параметром и потому
не зависит от способа входа; в тесте он собирается вручную, без выпуска токена.
Почему шлюз не проверяет по существу. Шлюз может выполнять подзапрос к модулю авторизации и пускать дальше только проверенные запросы. Здесь это не делается по трём причинам:
-
Шлюз — не единственный вход
В сервис приходят события из очереди, вызовы соседних сервисов, фоновые задачи и внутренние ручки, которые через шлюз не идут (BMBP: группа
internal). Проверка на шлюзе оставляет эти пути без проверки вовсе. -
Шлюзов много
Их по одному на фронт (BMGP). Правило, размещённое на шлюзе, размножается по числу фронтов и расходится между копиями.
-
Шлюз не знает предметной области
Он может проверить подпись, но не может проверить, что заказ принадлежит обратившемуся. Вторая проверка в сервисе всё равно нужна, а две проверки в разных местах расходятся.
Требование объявляется на каждом обработчике, а не группой роутеров. Группа выглядит удобнее («всё под этим префиксом закрыто»), но защита в этом случае — свойство места в дереве маршрутов. Перенос обработчика в другой роутер снимает её молча: код компилируется, тесты успешного пути проходят, ручка становится открытой. Требование на обработчике переезжает вместе с ним.
Виды удостоверений
| Вид | Кому выдаётся | Срок (ориентир) | Где хранится | Что делать при утечке |
|---|---|---|---|---|
| Токен доступа | человеку — на сеанс работы | 15–30 минут | у клиента (варианты и их риски — ниже), передаётся заголовком; на нашей стороне не хранится, проверяется подписью | отозвать сессию (см. следующую строку); сам токен доступа доживёт до конца срока, поэтому срок и делается коротким. Немедленный отзыв возможен только списком отозванных идентификаторов либо сменой ключа подписи — оба варианта платные, поэтому применяются к инцидентам, а не к обычному выходу |
| Токен продления | тому же клиенту | 14–30 дней | у клиента: cookie с признаками HttpOnly, Secure, SameSite; у нас: запись сессии в БД с идентификатором токена | удалить запись сессии — продление перестаёт работать сразу; после истечения текущего токена доступа сеанс закончится |
| Машинный токен | скрипту, агенту, интеграции — вместо пароля | месяцы, с обязательной датой окончания | у владельца: вводится один раз в настройках потребителя; у нас: отпечаток (sha-256), значение не хранится | удалить запись токена; значение восстановить нельзя — владелец выпускает новый |
| Внутренний токен | части системы для вызова другой части | до плановой замены | в окружении процесса (секреты); сравнивается в постоянное время | заменить значение по правилам ротации; до замены маршруты, принимающие его, остаются доступными тому, кто значение получил |
| Одноразовый код обмена | браузеру на один переход между доменами при едином входе | десятки секунд (ориентир — 60 с) | нигде: во временном хранилище на нашей стороне, удаляется при первом использовании | ничего: код либо уже использован, либо истёк. От перехвата защищает привязка к инициатору (раздел о едином входе) |
Пояснения к таблице:
Почему токен доступа не хранят в БД. Смысл подписанного токена в том, что проверка не требует обращения к хранилищу: любой сервис проверяет его локально. Цена — невозможность мгновенного отзыва. Поэтому срок жизни короткий, а долгоживущая часть сеанса (токен продления) хранится записью и отзывается удалением записи.
Где держать токен доступа в браузере — выбор с двумя разными рисками, а не один правильный вариант:
| Место | Риск | Что требуется дополнительно |
|---|---|---|
| память вкладки | теряется при перезагрузке страницы — восстанавливается продлением | ничего |
| постоянное хранилище браузера | читается любым сценарием, исполняемым на странице | политика источников сценариев |
cookie HttpOnly | сценарием не читается, но браузер прикладывает её к запросу автоматически, в том числе к запросу, инициированному чужой страницей | SameSite и сверка источника запроса на изменяющих операциях |
Токен продления хранится в cookie HttpOnly во всех вариантах: он ценнее токена доступа и
прикладного кода ему не нужен.
Почему пароль и машинный токен хешируются по-разному. Пароль придуман человеком, его перебирают
по словарю — нужна функция с настраиваемой стоимостью (argon2id, bcrypt, pbkdf2 с большим
числом итераций) и отдельной солью на запись. Машинный токен — случайное значение на 256 бит,
перебирать его нечем, а проверять приходится на каждом запросе; поэтому достаточно быстрого
sha-256 от значения, и запись ищется прямо по отпечатку. Обратное сочетание даёт либо
подбираемый пароль, либо медленный запрос.
Машинному токену полезен опознаваемый префикс (svc_…): по нему точка входа отличает его от
токена сессии, не пытаясь разобрать подпись, а поиск по репозиториям находит утёкшее значение.
Что внутри токена
Минимальный набор полей подписанного токена:
| Поле | Значение | Зачем |
|---|---|---|
sub | идентификатор учётной записи | кто |
typ | access или refresh | обязательно: без него токен продления принимается там, где ждут токен доступа, и живущее месяц удостоверение работает как живущее полчаса |
exp | момент окончания | срок |
iat | момент выпуска | по нему видно, что токен выпущен до понижения роли или до смены пароля |
jti | уникальный идентификатор | отзыв конкретной сессии; ключ записи в БД |
iss, aud | кто выпустил и для кого | токен, выпущенный для другой системы, не принимается в этой |
kid (в заголовке) | идентификатор ключа подписи | замена ключа без разлогинивания: проверяющая сторона держит несколько ключей (см. секреты, раздел о ротации) |
Роль в токене — снимок на момент выпуска. Понижение роли начинает действовать не сразу, а через срок жизни токена доступа. Варианта два: либо роль кладётся в токен и срок делается коротким, либо роль читается из хранилища при каждой проверке (тогда проверка перестаёт быть локальной). Выбор фиксируется явно, потому что от него зависит ответ на вопрос «через сколько подействует блокировка учётной записи».
Подпись. Симметричная (HS*) означает, что тот, кто проверяет, может и выпускать: она
применима внутри одного сервиса. Как только удостоверение проверяют несколько сервисов,
используется пара ключей (RS*, ES*): закрытый — только у модуля авторизации, открытый
раздаётся проверяющим. Открытый ключ доставляется файлом рядом с сервисом либо ручкой модуля,
отдающей набор открытых ключей; во втором случае он кешируется и обновляется по kid.
При проверке ожидаемый алгоритм задаётся списком в коде, а не берётся из заголовка токена. Иначе токен сам назначает способ своей проверки — подменив алгоритм на симметричный, предъявитель подписывает токен известным ему открытым ключом.
Срок жизни и продление
Схема — короткий доступ плюс отдельное продление:
вход ──▶ токен доступа (минуты) + токен продления (недели)
│ │
│ истёк │
▼ ▼
ответ 401 ─── клиент зовёт ─▶ продление ──▶ новая пара, прежний токен продления отозванПродление ротируется. В ответ на продление выдаётся не только новый токен доступа, но и новый токен продления, а прежняя запись удаляется. Тогда предъявление уже использованного токена продления — наблюдаемое событие: либо клиент потерял ответ, либо значение у кого-то ещё. Реакция на такое предъявление — отказ и завершение всей цепочки сессий этого клиента.
При истёкшем удостоверении сервис отвечает 401. Это единственный ответ, по которому клиент
понимает, что нужно продлить и повторить. Частая ошибка — вернуть 404: обработчик сначала ищет
объект «от имени неизвестного пользователя», ничего не находит и отдаёт «не найдено». Клиент в
ответ показывает пустой экран вместо продления, а в журнале это выглядит как отсутствие данных, а
не как истечение срока. Проверка удостоверения обязана выполняться до поиска объекта.
Порядок на стороне клиента (базовый клиент BMFP, раздел «Базовый клиент»):
- ответ
401→ одно продление → повтор исходного запроса; - параллельные запросы, получившие
401, делят одно продление, а не начинают по своему: при ротации первое продление отзывает токен, и остальные получают отказ, выбивающий рабочую сессию; 401от самой ручки продления не приводит к новому продлению — иначе получается цикл; он означает конец сеанса: хранилище чистится, пользователь уводится на вход.
Ориентиры сроков собраны в таблице видов удостоверений. Их значения задаются настройкой, а не константой в коде: срок — параметр установки, и в тестовой среде он другой.
Единый вход между несколькими сервисами
Когда сервисов несколько, аутентификация выносится в отдельный модуль авторизации. Причины — проверяемые:
- пароль вводится на одном домене. Каждый сервис со своей формой входа — это N мест, где вводится пароль, и N реализаций хранения отпечатка, ограничения попыток и восстановления доступа;
- способы входа добавляются один раз. Внешний провайдер, одноразовый код, вход из мини-аппа подключаются в модуле, а не в каждом сервисе;
- ключ подписи и его замена — в одном месте. Сервисы только проверяют подпись;
- сессия общая. Переход между сервисами не требует повторного входа.
Обмен устроен так, что удостоверение не передаётся через адресную строку: через неё идёт одноразовый код, который обменивается на удостоверение отдельным запросом.
Поток
потребитель браузер модуль авторизации
1. проверочное значение
и отпечаток от него
2. уход на вход ──▶ перенаправление ──▶ 3. сверка потребителя
(идентификатор, и адреса возврата
адрес возврата, state, 4. вход человека
отпечаток, метод) 5. выпуск кода
6. возврат с кодом ◀── перенаправление ◀── (на адрес возврата)
7. обмен: код + проверочное значение ───────────▶ 8. сверка отпечатка,
(прямой запрос, минуя браузер) код удаляется,
удостоверение ◀─────────────────────── выдаётся удостоверение
9. очистка адресной строки- Фронт потребителя создаёт случайное проверочное значение (43–128 символов), кладёт его в
хранилище своей вкладки и считает от него отпечаток
sha-256в кодировке base64url. - Уход на страницу входа модуля с параметрами: идентификатор потребителя, адрес возврата,
state(случайное значение для сверки при возврате), отпечаток проверочного значения и имя метода (S256). - Модуль проверяет: потребитель есть в реестре; переданный адрес возврата точной строкой совпадает с одним из перечисленных для этого потребителя. При расхождении — отказ на странице модуля, без перенаправления: перенаправление по непроверенному адресу и есть та самая уязвимость, от которой защищает перечень.
- Человек входит на домене модуля. Если у модуля уже есть действующая сессия, шаг проходит без ввода.
- Модуль создаёт одноразовый код — случайное значение и запись во временном хранилище: код → {учётная запись, потребитель, отпечаток проверочного значения, адрес возврата}. Срок записи — десятки секунд: код нужен ровно на один переход.
- Перенаправление на адрес возврата с
codeиstate. - Фронт потребителя сверяет
stateсо своим (иначе принимается возврат, начатый не им), затем отправляет код и проверочное значение на обмен — через свой шлюз, с того же источника. - Модуль сначала удаляет запись («прочитать и удалить» одной операцией), затем сверяет
потребителя и
sha-256от присланного проверочного значения с сохранённым отпечатком. Любое расхождение — отказ. Удаление до сверки, а не после, делает код одноразовым и при двух параллельных обменах. - Фронт убирает
codeиstateиз адресной строки заменой записи истории и удаляет проверочное значение: адрес со следами обмена не остаётся в истории браузера и в поле «источник перехода» следующих запросов.
Привязка кода к инициатору через отпечаток проверочного значения известна как PKCE (RFC 7636). Она отвечает на конкретную угрозу: код проходит через адресную строку, историю браузера, журналы промежуточных прокси и заголовок источника перехода, и перехваченный код без проверочного значения бесполезен.
Почему перечень адресов возврата — точные строки
Маска по поддомену (*.example.com) выглядит удобно: сервисы живут на поддоменах одного домена,
и перечень не нужно править при добавлении сервиса. Она же снимает защиту.
Привязка к инициатору не помогает в случае, когда инициатором выступает посторонний. Страница на любом поддомене, попадающем под маску, начинает вход со своим проверочным значением и своим адресом возврата. Человек, у которого в модуле уже есть действующая сессия, перенаправляется молча — ввод не требуется. Код приходит инициатору, и он обменивает его законным образом, потому что проверочное значение у него своё. Итог — сессия человека у постороннего.
Поддомен, попадающий под маску, появляется буднично: поддомен, выданный пользователям под их
страницы; поддомен, направленный записью CNAME на внешний сервис; поддомен, забытый после
закрытия проекта, чей адрес освободился и достался другому. Ни один из этих случаев не выглядит как
взлом, и ни один не заметен со стороны модуля авторизации.
Отсюда правила сверки:
- перечень — точные строки целиком, вместе со схемой, хостом, портом и путём. Не только хост:
открытый редирект на самом сервисе-потребителе (
/уйти?куда=…) уводит код дальше по цепочке; - сравнение — посимвольное, без нормализации и без «с хвостовым слэшем тоже подойдёт». Параметры запроса в адресе возврата либо запрещены, либо входят в сверяемую строку;
- перечень задаётся на каждого потребителя, а не общим списком: код, выданный одному потребителю, не должен уходить на адрес другого.
Реестр потребителей
| Поле | Что | Зачем |
|---|---|---|
| идентификатор | строка | по нему выбирается запись при уходе на вход и при обмене |
| адреса возврата | перечень точных строк | куда разрешено вернуть код |
| секрет потребителя | значение, предъявляемое при обмене | применимо к потребителям, у которых есть серверная часть и есть где хранить секрет; для потребителя, целиком живущего в браузере, секрет хранить негде, и его роль выполняет привязка к инициатору |
| состояние | включён / отключён | отключение потребителя не требует удаления записи и истории |
Реестр — данные, а не код. Если он задан переменной окружения, добавление потребителя требует перезапуска модуля, а история изменений не ведётся; таблица в БД снимает оба ограничения.
Что проверяет сервис-потребитель
Получив удостоверение, сервис проверяет его сам. Способа два:
| Способ | Что нужно | Цена |
|---|---|---|
| локально по открытому ключу | открытый ключ и kid | нет сетевого вызова; отзыв действует через срок жизни токена доступа |
| запросом к модулю авторизации | доступ к модулю по внутренней сети | отзыв действует сразу; сетевой вызов на каждый запрос — обычно с коротким кешем результата |
Ручки модуля, которые нужны соседним сервисам (проверка удостоверения, сведения об учётной записи), и ручки, которые нужны странице входа (вход, обмен, продление), — разные наборы. Они разводятся по разным шлюзам: шлюз для соседних сервисов наружу не публикуется и содержит только первый набор (BMGP: состав шлюза определяется со стороны потребителя).
Правила доступа
Роль и право
Роль — имя группы учётных записей (user, support, admin). Ею проверяются действия, право
на которые не зависит от конкретного объекта: «смотреть сводку», «заводить сотрудников». Проверка —
принадлежность роли перечню, объявленному на обработчике.
Уровень доступа к объекту — то, что выдаётся на конкретный объект или ветку дерева объектов:
чтение < запись < управление. Проверка — «уровень не ниже требуемого». Уровни наследуются вниз по
дереву: выдача на узел действует на всё, что под ним.
Ролей хватает, пока перечень действий короткий и однозначно делится между двумя-тремя группами. Как
только появляется раздача доступа к отдельным объектам (совместная работа, организации, команды),
роль остаётся признаком учётной записи целиком, а решение по объекту принимает уровень доступа.
Промежуточный вариант — именованные права (orders.refund), собранные в роли: роль тогда является
набором прав, а проверка идёт по праву. Он оправдан, когда набор действий большой, а групп
пользователей много; для трёх ролей он добавляет слой без выигрыша.
Роли и уровни — доменные перечисления в core/domains/enums, а не свободные строки. Свободная
строка допускает запись значения с опечаткой: проверка «роль в перечне» тихо перестаёт совпадать.
Владение объектом
Владение проверяется в core, вместе с чтением объекта, одним запросом:
SELECT … FROM orders WHERE id = :id AND owner_id = :account_idНе «прочитать объект, потом сравнить владельца в обработчике» — сравнение легко забыть в новом
сценарии, и api начинает содержать правило доступа. Не «спросить право отдельным запросом, потом
прочитать» — между двумя запросами объект может сменить владельца, и лишний проход по хранилищу
выполняется на каждом обращении.
Ноль строк в результате означает одно и то же для двух разных случаев: объекта нет и объект чужой. Это и есть требуемое поведение.
Одинаковый ответ на «чужой» и «нет»
Если чужой объект даёт 403, а несуществующий — 404, то перебор идентификаторов отвечает на
вопрос, какие объекты существуют. Этого достаточно, чтобы узнать число заказов в системе, темп их
появления, а по именам — состав чужих проектов. Содержимое при этом не раскрывается, раскрывается
факт существования — а скрывают обычно именно его.
Правило: для объекта, о существовании которого предъявитель не должен знать, ответ на «чужой» и
на «нет» одинаков — 404. Одинаков не только код, но и тело ответа: машинный код, текст,
заголовки.
Исключение определяется тем же критерием. Если предъявитель уже знает, что объект есть — он участник
организации, он видит объект в перечне, он получил ссылку от владельца, — скрывать существование
нечего, и правильный ответ 403: он объясняет, чего не хватает, и не выглядит как «страница
пропала».
| Ситуация | Ответ |
|---|---|
| приватный объект, обратившийся не имеет к нему доступа (в том числе аноним) | 404 |
| объект виден обратившемуся, но текущее действие требует более высокого уровня | 403 |
| объект виден, действие запрещено состоянием объекта (заказ уже оплачен) | 409 |
| ссылка на объект внутри чужого недоступного объекта | 404 — как у родителя |
При изменяющих операциях уровень доступа проверяется до проверки существования объекта. Иначе ответы на «нет прав, объект есть» и «нет прав, объекта нет» различаются, и правило нарушается тем же перебором.
Различие может проявиться и во времени ответа: проверка, выполняющая запрос к хранилищу только для существующих объектов, отвечает на них дольше. Это существенно там, где перебор дёшев; время имеет смысл сверять после того, как выровнены коды и тела.
Ответы при отказе
Форма — общий конверт (BMBP): { status, error_code, details }. Клиент ветвится по error_code,
человеку показывается details.
| Ситуация | Код | Машинный код |
|---|---|---|
| удостоверение не предъявлено | 401 | unauthorized |
| предъявлено, но недействительно или истекло | 401 | unauthorized |
| предъявлено и действительно, действие не разрешено (существование объекта не скрывается) | 403 | forbidden |
| объект недоступен, и знать о его существовании не положено | 404 | not_found |
| слишком много попыток входа или продления | 429 + Retry-After | rate_limit |
Разделение 401 и 403 держится строго: 401 означает «предъяви удостоверение или продли»,
403 — «удостоверение принято, этого всё равно нельзя». Клиент, получивший 403, продлевать не
должен: продление не изменит результат, а цикл продлений выбьет рабочую сессию.
Причина отказа во входе не уточняется. «Учётной записи нет» и «пароль не подошёл» — один и тот же ответ, иначе форма входа превращается в способ проверять, зарегистрирован ли адрес. То же относится к восстановлению доступа: ответ одинаков для известного и неизвестного адреса.
Заголовок WWW-Authenticate ставится там, где клиент — программа, знающая протокол (например,
клиент системы контроля версий): без него он не поймёт, что нужно предъявить учётные данные. Для
API, которым пользуется веб-интерфейс, заголовок обычно не ставят: браузер в ответ на него
показывает системное окно ввода поверх приложения.
Чего не делают
Не пишут свой алгоритм подписи и не изобретают формат удостоверения. Берут реализацию из библиотеки языка. Ошибки такого кода не видны на успешном пути: приём токена без подписи, приём симметричной подписи там, где ожидался открытый ключ, сравнение подписи с ранним выходом.
Не берут алгоритм проверки из самого токена. Список допустимых алгоритмов задаётся в коде.
Не держат секрет в коде — включая значение по умолчанию в конфигурации и правдоподобную
заглушку вида change_me_in_prod: с ней система запускается, и то, что ключ подписи известен
всем, обнаруживается при разборе инцидента. Правила хранения — в секретах.
Не доверяют заголовку, пришедшему снаружи. Заголовок вида X-User-Id, проставленный
внутренним компонентом, неотличим от такого же заголовка, присланного клиентом, — если он не
вырезан на границе. Заголовки личности обнуляются на шлюзе явно (proxy_set_header X-User-Id ""),
а сервис их не читает вовсе: единственный вход личности — заголовок авторизации, который сервис
проверяет сам.
Не считают проверку в интерфейсе проверкой. Скрытая кнопка убирает действие с экрана, но не из API: тот же запрос отправляется вручную. Интерфейс скрывает недоступное ради понятности, решение принимает сервер.
Не хранят пароли, машинные токены и одноразовые коды в открытом виде и не пишут их в журнал — ни в теле запроса, ни в тексте исключения (наблюдение). Одноразовый код, попавший в журнал, перестаёт быть одноразовым: журнал живёт дольше канала доставки кода.
Не сравнивают удостоверения обычным сравнением строк. Сравнение, прекращающееся на первом несовпавшем байте, занимает разное время в зависимости от того, сколько байтов совпало. Для значений, которые предъявитель может подбирать по байту (внутренний токен, отпечаток кода), используется сравнение в постоянное время.
Не генерируют коды и токены обычным генератором псевдослучайных чисел. Его вывод предсказуем по предыдущим значениям; используется криптостойкий источник.
Проверки соответствия
Ниже $base — адрес проверяемого сервиса, $token_* — заранее полученные удостоверения.
Проверки выполняются по сервису, а не по шлюзу: шлюз может закрывать ручку перечнем маршрутов, но
это не заменяет её собственного требования доступа.
1. Каждая изменяющая операция требует удостоверения
По спецификации API — операции, у которых требование не объявлено:
curl -s "$base/openapi.json" \
| jq -r '.paths | to_entries[] as $p | $p.value | to_entries[]
| select(.key | test("^(post|put|patch|delete)$"))
| select(((.value.security // []) | length) == 0)
| "\(.key | ascii_upcase) \($p.key)"'
# ожидается: пустой выводПустой вывод здесь означает «нечего показать», а не «всё закрыто»: если требование объявлено не зависимостью, а промежуточным слоем, оно в спецификацию не попадает. Прямая проверка — позвать каждую изменяющую операцию без удостоверения:
curl -s "$base/openapi.json" \
| jq -r '.paths | to_entries[] as $p | $p.value | to_entries[]
| select(.key | test("^(post|put|patch|delete)$")) | "\(.key) \($p.key)"' \
| while read -r method path; do
url="$base$(printf '%s' "$path" | sed 's/{[^}]*}/1/g')"
code=$(curl -s -o /dev/null -w '%{http_code}' -X "$(printf '%s' "$method" | tr 'a-z' 'A-Z')" "$url")
case "$code" in 401|429) ;; *) printf '%s %s → %s\n' "$method" "$path" "$code" ;; esac
done
# ожидается: пустой выводОтвет 422 в этом списке означает, что разбор тела выполняется раньше проверки удостоверения:
такую операцию проверяют отдельно, послав корректное тело. Ответ 200 или 404 — дефект: операция
выполняется или ищет объект без предъявленного удостоверения.
2. Приватный объект не отдаётся анонимно
curl -s -o /dev/null -w '%{http_code}\n' "$base/api/front/items/$private_id"
# ожидается: 404
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $token_owner" "$base/api/front/items/$private_id"
# ожидается: 200Ответ 401 на первый запрос корректен только тогда, когда ручка требует удостоверения для любого
чтения: он одинаков для существующего и несуществующего объекта. Если та же ручка отдаёт публичные
объекты анонимно, 401 на приватном отличает его от несуществующего — и это дефект.
3. Чужой объект неотличим от несуществующего
a=$(curl -s -H "Authorization: Bearer $token_other" "$base/api/front/items/$foreign_id")
b=$(curl -s -H "Authorization: Bearer $token_other" "$base/api/front/items/999999999")
[ "$a" = "$b" ] && echo "совпадает" || printf '%s\n%s\n' "$a" "$b"
# ожидается: совпадает4. Истёкшее удостоверение даёт 401, а не 404
Токен с истёкшим сроком берут из тестовой среды (выпуск с exp в прошлом) либо дожидаются
окончания срока.
curl -s -H "Authorization: Bearer $token_expired" "$base/api/front/orders" \
| jq -r '.error_code'
# ожидается: unauthorizedТот же случай проверяется тестом: подмена времени выпуска, ожидаемый код ответа 401. Ручной
проверки недостаточно — она не повторяется на каждом изменении.
5. Заголовок личности снаружи не проходит
# сервис не читает заголовки личности
grep -rniE 'x-(user|account|remote|auth[a-z]*)[-_]' app/ | grep -v '/tests/'
# ожидается: пустой вывод
# граница обнуляет их явно
grep -rn 'proxy_set_header X-User-Id' proxy/conf.d/
# ожидается: строка вида: proxy_set_header X-User-Id "";
# живая проверка через шлюз
curl -s -o /dev/null -w '%{http_code}\n' -H 'X-User-Id: 1' "https://example.com/api/profile"
# ожидается: 4016. Удостоверение одной среды не действует в другой
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $token_from_test_env" "https://example.com/api/front/orders"
# ожидается: 401Ответ 200 означает общий ключ подписи. Сами значения ключей при этом не сверяются: достаточно
этой проверки и сверки отпечатков (секреты, раздел о проверке значений).
7. Единый вход: чужой адрес возврата отвергается
curl -s -o /dev/null -w '%{http_code}\n' \
"https://example.com/authorize?client=demo&return_to=https://other.example.net/catch"
# ожидается: 400; ответ 302 означает, что перенаправление происходит до сверки
curl -s -o /dev/null -w '%{http_code}\n' \
"https://example.com/authorize?client=demo&return_to=https://sub.example.com/catch"
# ожидается: 400, если этой строки нет в перечне потребителяПовторный обмен тем же кодом:
curl -s -X POST "$base/api/front/sso/exchange" -H 'Content-Type: application/json' \
-d "{\"code\":\"$code\",\"client\":\"demo\",\"verifier\":\"$verifier\"}" -o /dev/null -w '%{http_code}\n'
curl -s -X POST "$base/api/front/sso/exchange" -H 'Content-Type: application/json' \
-d "{\"code\":\"$code\",\"client\":\"demo\",\"verifier\":\"$verifier\"}" -o /dev/null -w '%{http_code}\n'
# ожидается: 200, затем 4018. Право проверено на сервере, а не на кнопке
Берётся учётная запись без права и вызывается ручка напрямую, минуя интерфейс:
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
-H "Authorization: Bearer $token_without_right" "$base/api/front/items/$id"
# ожидается: 403 (или 404, если объект скрыт от этой учётной записи)Проверка выполняется по перечню: каждое действие, которое интерфейс скрывает или показывает в зависимости от роли или владения, вызывается напрямую под учётной записью, от которой оно скрыто. Перечень собирается по условиям отображения в интерфейсе — их обычно на порядок меньше, чем ручек.
Типичные отказы
| Признак | Причина | Что делать |
|---|---|---|
после истечения срока клиент показывает пустой экран, в ответах 404 | объект ищется до проверки удостоверения; «не найдено от имени никого» отдаётся как «не найдено» | проверять удостоверение на входе обработчика, до обращения в core; ответ 401 |
в ответах 500 вместо 401 | разбор токена бросает исключение библиотеки, оно не поймано и уходит в общий обработчик как непредвиденное | ловить ошибку разбора в месте проверки и переводить в стандартный отказ |
при отсутствующем заголовке ответ приходит вне конверта и с кодом 403 | отказ формирует схема безопасности фреймворка, минуя каталог ошибок | привести отказ схемы к общему конверту и коду 401; проверять по телу ответа, а не только по коду |
| приватный ответ достался другому пользователю | ответ закеширован на шлюзе с ключом без учёта удостоверения | включить заголовок авторизации в ключ кеша (BMGP, раздел о сквозном), ответы с личными данными помечать как непубличные |
| ограничение попыток срабатывает сразу на всех пользователей | адрес клиента берётся из соединения, а за шлюзом это адрес шлюза | брать адрес из проброшенного заголовка и доверять этому заголовку только от своего шлюза (заголовок от внешнего клиента вырезается на границе) |
| клиент бесконечно продлевает удостоверение | 401 от самой ручки продления обрабатывается тем же перехватчиком | исключить ручку продления из перехватчика; её 401 означает конец сеанса |
| после недолгого простоя вкладка разлогинивается | несколько параллельных запросов начали по своему продлению, ротация отозвала токен первого | одно продление на клиент: параллельные ожидают его результат |
| токен продления принимается вместо токена доступа | в токене нет поля типа, вид определяется по наличию других полей | добавить typ и сверять его при проверке |
| в браузере работает, из машинного клиента — отказ | правило смотрит на cookie, машинный клиент предъявляет заголовок | разбор удостоверения — одна точка, принимающая оба вида; правило работает с проверенным обратившимся, а не с транспортом |
| перебор идентификаторов показывает, какие объекты существуют | чужой объект даёт 403, несуществующий — 404 | для скрытых объектов один ответ 404; сверить коды и тела |
| действие, скрытое в интерфейсе, выполняется запросом вручную | право проверено только на кнопке | перенести правило в core, кнопку оставить как подсказку |
| удостоверение, выпущенное в тестовой среде, действует в боевой | одинаковый ключ подписи в средах | разные ключи по средам; проверка 6; при совпадении значение считать раскрытым и заменить |
| после замены ключа подписи все разлогинились | проверяющая сторона знает один ключ | ротация с несколькими действующими ключами и kid (секреты, раздел о ротации) |
| посторонний получил сессию, в журналах модуля вход выглядит обычным | перечень адресов возврата задан маской поддомена, вход инициирован чужим поддоменом | точный перечень строк на каждого потребителя; проверка 7 |
| код единого входа сработал дважды | запись кода удаляется после сверки, а не до | «прочитать и удалить» одной операцией до сверки |
| код виден в истории браузера и в поле источника перехода | адресная строка не очищена после обмена | заменить запись истории сразу после обмена |
| вход работает локально, в рабочем контуре cookie не ставится | признаки Secure и SameSite не соответствуют схеме и доменам | признаки задаются настройкой; на https — Secure; при разных доменах входа и приложения — соответствующий SameSite со сверкой источника |
пользователи жалуются на отказ входа после нескольких попыток, в ответе 403 | ограничение частоты отвечает кодом «запрещено» | 429 и Retry-After: клиент отличает «нельзя» от «подожди» |
| по ответу формы входа видно, зарегистрирован ли адрес | «нет учётной записи» и «неверный пароль» — разные ответы | один ответ на оба случая |
| сервис доступен в обход шлюза, и его ручки отвечают | сервис опубликован наружу | закрыть публикацию (сетевой контур); требование доступа на обработчиках при этом остаётся обязательным |
| в журнале обнаружились удостоверения | пишется тело запроса или заголовки целиком | убрать поля из записи, засветившиеся значения отозвать (наблюдение) |
Откройте исходник документа по ссылке «Предложить правку» — там же видно, что и когда в нём менялось.