Вход, владение, роли, лимиты
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Проект дошёл до двери. Прошлая статья собрала домен целиком — репозиторий, сервис, процедуры, — но номер читателя приезжал в роутер параметром, и взять его было неоткуда: кто пришёл, приложение не знало. Теперь узнает. Всё, чем это делается, уже написано вашими руками: PBKDF2 и строка хеша, HMAC и подпись токена, страж личности с перепроверкой в таблице, правило про чужой черновик, ограничитель частоты. Новых механизмов здесь нет ни одного — есть четыре решения о том, где каждый из них стоит: где берут личность, где читают уровень доступа, где проверяют владение и где стучится ограничитель. Проверяется каждое из них не вызовом, который защитили, а тем, что случается с вызовом, о котором забыли.
В таблице users из прошлой статьи две строки, и в обеих на месте пароля стоит заглушка: домену было довольно того, что у заметки есть автор. Входу нужен настоящий хеш. Обе процедуры входа — регистрация и вход — гостевые: токена они не требуют, потому что их и зовут затем, чтобы токен получить.
Регистрация делает три вещи, и порядок у них не любой: сначала хеш — строка PHC, которая помнит свою стоимость, — потом вставка, и только потом токен. Занятость почты при этом не проверяется отдельным запросом: её проверяет уникальный индекс, а почему проверка перед вставкой не спасает, разобрано там же, где этот индекс заведён. Отказ приезжает кодом 23505 с именем ограничения, и сервис переводит его в доменный код одной строкой.
У входа своя тонкость, и она не про криптографию. Отказать можно двумя разными словами: «такой почты нет» и «пароль не тот». Два слова — это два ответа, а два ответа на один вопрос превращают форму входа в список зарегистрированных: подробный отказ тому, кому нельзя, — это утечка. Поэтому ответ один. И одинаковым он обязан быть не только на словах: если неизвестной почте отвечать сразу, а известной — после PBKDF2, разницу видно по часам. Отсюда хеш-пустышка — соль и хеш из нулей при той же стоимости, — и до предела то же правило доводит форма восстановления пароля, где одинаковым обязан быть и ответ, и время ответа.
Заодно у сервиса учёток появляется вторая обязанность — быть единственной дверью к таблице пользователей: транспорту сейчас понадобится искать человека по номеру из токена, и ходить в таблицу мимо сервиса он не будет.
И последнее перед прогнозом. Новых кодов отказа в статье четыре, и все четыре — строки в таблице, которую завела глава про свой конвейер: перевод кода в статус живёт в одном месте, и код доступа дописывается туда, а не заводит второго перевода рядом.
Прогноз: восемь строк. Регистрация Веры, потом та же почта второй раз и почта Ани из прошлой статьи, потом три входа — верным паролем, чужим паролем и по почте, которой в таблице нет. Дальше строка хеша из таблицы. Последняя строка — сколько раз за эти шесть попыток сервер посчитал PBKDF2; её стоит предсказать точным числом, она понадобится в четвёртом разделе.
Регистрация прошла, и Вере достался третий номер: две строки в таблице уже были. Обе повторные регистрации получили 409 с одним и тем же кодом, и почта Ани попала под то же правило — индексу нет разницы, кто и когда её занял. Вход верным паролем ответил 200, оба неверных — 401 и одним словом, по которому не догадаться, что именно не сошлось.
Строка хеша в таблице настоящая: имя алгоритма, стоимость, шестнадцать байт соли и тридцать два байта хеша — всё в одном поле. Пустышка устроена так же, поэтому неизвестная почта стоит ровно столько же: та же функция и те же сто тысяч итераций.
А последняя строка — счёт, из-за которого в проекте вообще появится ограничитель. Шесть попыток — шесть PBKDF2, причём две из них потрачены на регистрации, которые всё равно отказали: хеш считается до вставки, потому что до вставки его надо положить в строку. Значит, перебор почт стоит столько же, сколько перебор паролей, и закрывать надо обе процедуры, а не одну.
Одинаковый ответ на неизвестную почту и на неверный пароль — не вежливость, а требование. Одинаковый по времени — то же требование, только про него забывают.
Личность есть — осталось её спросить. Спрашивать будет транспорт, и спрашивать по договору: первая статья главы положила рядом со схемой входа поле access, и в собранном приложении его до сих пор никто не читал. Прочитать его — вся работа этого раздела.
Договор в проекте — единственный список процедур, и это не красивая фраза, а проверяемое свойство: обработчик, которого в договоре нет, недоступен никому. В роутере ниже есть notes.remove; он написан и работает, а в договоре его нет. Что ответят на него админу, стоит предсказать отдельно.
Сама проверка личности — два разных вопроса, и в проекте они разъезжаются по двум функциям. Первый: наш ли это токен — подпись и срок, чистый счёт, ни одного обращения к таблице. Второй: правда ли то, что в нём написано, — роль в токене это прошлое, и сверить её можно только со строкой. Разделены они не для красоты: между ними встанет ограничитель, и в четвёртом разделе будет видно, почему именно там.
Токена нет вовсе — это не отказ. Гостю открыты список и карточка, и, чтобы их отдать, опознавать некого. А вот если токен прислан, но негоден, отвечать гостевой лентой нельзя: человек с истёкшим токеном увидел бы ленту без своих черновиков и решил, что заметки пропали. Опознать и не пустить — два разных действия, и первое бывает нужно без второго.
Отказов у уровня доступа два. 401 — «предъявите удостоверение»: личности нет или она больше не описывает действительность, и новый токен поможет. 403 — «удостоверение годное, а права не те», и новый токен не поможет.
Прогноз: шесть строк. Сначала три человека — гость, Вера с честным токеном и модератор — против четырёх процедур: открытый список, создание заметки, служебная ручка со списком учёток и та самая notes.remove, которой нет в договоре. Потом два негодных токена: у первого подделана подпись, у второго учётку успели удалить. Последняя строка — что вообще отдаёт служебная ручка.
Матрица собралась из одного поля договора, и ни одного if о правах в обработчиках нет. Гость получил 401 и на создании, и на служебной ручке — в обоих случаях его не опознали. Вера на служебной ручке получила 403: опознали, но роль не та. А notes.remove ответила 404 всем троим, включая админа: обработчик есть, договор о нём молчит, и для транспорта такой процедуры не существует.
Это и есть «закрыто по умолчанию», только у процедур оно достаётся почти бесплатно. В каркасе с контроллерами ради него заводили свой декоратор вместо @Controller, чтобы стража нельзя было забыть; здесь забыть можно только запись в договоре — а без неё процедуры просто нет.
Оба негодных токена ответили 401 и одним кодом. Разными их делать незачем: клиенту в обоих случаях нужно войти заново, а «учётки больше нет» рядом с «подпись не сошлась» рассказывало бы подбирателю, какие номера в таблице заняты.
И про роутер. Собирается он на каждый запрос по прежней причине — ради замыкания с номером читателя, разобранного прошлой статьёй. Новое здесь одно: схему каждая процедура берёт из договора — процедура как значение тем и удобна, что схема лежит рядом с обработчиком, а в проекте это «рядом» указывает в договор.
Потому что читателей у него четыре, и обработчик — не главный. Транспорт решает, пускать ли. Клиент по тому же полю прячет кнопку, которую всё равно не дадут нажать. Описание API ставит по нему пометку о токене. А человек, который смотрит, что открыто наружу, читает один объект вместо сорока обработчиков — притом что обработчик правят чаще всего, и правило, живущее в его теле, переживает эти правки до первой невнимательной.
Список процедур в приложении должен быть один. Второй список — это дверь, о которой забудут.
Роль теперь спрашивается, и половина работы сделана. Вторая половина — про строки. Правило о чужом черновике и чужой опубликованной заметке в проекте уже написано, и написал его сервис заметок прошлой статьи: не видно на чтение — NOT_FOUND, видно — NOT_YOUR_NOTE. Здесь важно другое: роль его не отменяет.
Соблазн выглядит так: модератор на то и модератор, чтобы править чужое. В проекте учебника это неверно намеренно. Роль admin открывает процедуры, у которых строк с авторами нет вовсе: список учёток, журнал, выгрузка. Как только роль начинает обходить владение, правило «править может только автор» превращается в «может автор, а ещё вот эти», и второй список растёт сам собой.
Отсюда и разделение, которое видно в коде транспорта. Уровень доступа решает, пустят ли к процедуре, и решает это транспорт — до всякого чтения. Владение решает, пустят ли к строке, и решает это сервис — сразу после того, как строка прочитана. Сложить их в одно место нельзя физически: у транспорта на своём шаге строки ещё нет.
Второй сюжет раздела — цена перепроверки. Токен живёт тридцать дней и всё это время предъявляет прошлое, подписанное настоящим секретом. О понижении роли знает только строка в таблице, поэтому её и спрашивают на каждом запросе.
Прогноз: шесть строк. Сначала Вера и модератор делают одно и то же: читают чужой черновик, читают чужую опубликованную заметку, пробуют её опубликовать и стучатся в служебную ручку. Потом модератора выводят из модераторов — прямо в таблице, как это и бывает, — и он повторяет служебную ручку и открытый список тем же токеном. Где их ответы совпадут, а где разойдутся?
Первые три строки у Веры и у модератора совпали до кода: чужой черновик — 404, чужая опубликованная — 200 на чтение и 403 на публикацию. Роль не дала модератору ничего, потому что владение про неё не спрашивает. Разошлись они на четвёртой строке — на процедуре, у которой строк с авторами нет.
Две последние строки — про понижение. Служебная ручка ответила 401, и это ожидаемо: роль в токене больше не совпадает со строкой. А вот открытый список ответил тем же 401, хотя гостю он открыт. Дело в том, что гость — это отсутствие токена, а не негодный токен: модератор токен прислал, и на нём споткнулась перепроверка. Плата за такой выбор видна сразу — понизили роль, и человек на следующем же запросе оказывается на экране входа, пока клиент не сходит за новым токеном.
Тогда это не обход владения, а отдельная процедура: не notes.publish от чужого имени, а своя — со своим входом, своим доменным кодом и своей записью в журнал. Разница не формальная. У обычной правки нет места, куда записать, что правку сделал не автор, и заводить его незачем: автору такая запись не нужна. У процедуры модерации это место есть по условию, и отвечает за неё роль, а не владение.
Роль открывает процедуру, а не строку. Как только роль начинает открывать строки, правило владения кончилось.
Ограничитель в проекте нужнее всего одной паре процедур — гостевым. Вход и регистрация токена не требуют, а стоят дорого: PBKDF2 дорог намеренно, и счёт из первого раздела это показал. Устройство ограничителя — ключ, окно и пауза — уже разобрано, и числа здесь те же: десять вызовов, окно в минуту, пауза в минуту. Новых вопросов два: где он стоит и что считает.
Место у него единственное. После подписи — потому что из подписи читается ключ. До похода в таблицу — потому что иначе перебор оплачивался бы запросами к базе, то есть тем самым, что ограничитель и защищает. Между двумя вопросами о личности оказалась ровно та щель, для которой их и разделили.
Ключ выбирает уровень доступа. Гостевую процедуру считают по адресу: человека у неё нет по определению. Процедуру с личностью — по человеку, и адрес на её счёт не влияет вовсе. У процедур синхронизации так и сделано: за одним NAT адрес общий, и предел делился бы там на всех. Числа у синхронизации тоже свои — многостраничная выдача упирается в потолок из десяти вызовов на одиннадцатой странице, — а в проекте потолок один на все процедуры.
Имя процедуры входит в ключ в обоих случаях, и это не мелочь: на общем ключе перебор пароля закрыл бы и восстановление пароля — единственную дверь, через которую жертва могла бы вернуться. Это и есть третья задача статьи.
И оговорка, без которой ограничитель — украшение: адрес берётся из запроса, а настоящий адрес клиента там оказывается только если сервер настроен верить заголовку обратного прокси. Где проходит эта граница — в главе про выкладку; без доверия сюда приедет адрес контейнера прокси, один на весь интернет.
Прогноз: семь строк. Двенадцать попыток входа подряд с одного адреса — строкой статусы, потом сколько PBKDF2 за них посчитано и что лежит в теле последнего ответа. Дальше тот же вход с другого адреса, открытый список с первого адреса и вход через две минуты модельных часов. Последняя строка — одиннадцать созданий заметки одним токеном, но с двух разных адресов поочерёдно.
Десять попыток отказали по паролю, одиннадцатая — по частоте, двенадцатая попала уже в паузу и до счётчика не дошла: считать её значило бы продлевать паузу стуком в неё. PBKDF2 за эти двенадцать попыток посчитан десять раз — два последних запроса обошлись без хеша вовсе, и ровно ради этого ограничитель стоит там, где стоит.
Следующие три строки — про первую половину ключа. Тот же вход с другого адреса начинает со своего первого вызова. Открытый список с уже наказанного адреса проходит: имя процедуры входит в ключ, и перебор пароля не закрыл ничего, кроме входа. А через две минуты модельных часов истекли и пауза, и окно, и вход снова отвечает по паролю.
Последняя строка — про вторую половину. Одиннадцать созданий ушли с двух адресов поочерёдно, а предел сработал так, будто адрес один: у процедуры с личностью его в ключе нет. То же свойство читается и в обратную сторону — двое за одним адресом не делят предел, — и именно из-за этого ключ у гостевых и негостевых процедур разный.
Край окна у проекта остался таким же дырявым, каким его посчитала глава 8: окно открывается первым вызовом и живёт ровно минуту, поэтому девять вызовов в конце одного окна и десять в начале следующего проходят подряд. Скользящее окно и корзина с пополнением проекту дороже своей пользы.
Ограничитель, поставленный после похода в базу, защищает не базу, а обработчик. Перебор всё равно оплачивается запросами, просто платит за них не тот, кто перебирает.
Всё собрано. Соберём и сценарий — последовательность запросов, какую делает живой клиент: гость приходит на пустом месте, получает ленту, натыкается на отказ, регистрируется под именем Веры, входит, работает со своими и чужими заметками, а в конце с чужого адреса начинают подбирать её пароль.
Одну строку стоит предсказать отдельно, и она не про доступ. Вера регистрируется и тут же входит тем же паролем. Токен, выданный при регистрации, и токен, полученный при входе, — это одна строка или две?
Прогноз: двенадцать строк. Что ответит гость на создании заметки, что увидит Вера в списке после того, как заведёт свою, что ответит чужой черновик и на какой по счёту попытке подбор пароля получит отказ.
Токен оказался одной строкой, и это не совпадение: сервер не помнит выданных токенов, а строка — это подписанное значение полей, и при тех же полях и том же секрете она та же. Заодно видна и цена такого устройства: отозвать его нечем, и именно поэтому роль в нём перепроверяется на каждом запросе.
Остальное читается по журналу. Гостю на создании ответили 401, и дальше не работало ничто: ни разбор входа, ни обработчик, ни база. Чужой черновик ответил Вере 404 — тем же, чем ответил бы номер, которого никогда не было. Её заметка получила шестой номер и встала в списке первой, потому что новее остальных; гость её не увидит, пока она черновик. А перебор с чужого адреса отказал на одиннадцатой попытке и вход самой Веры не задел: адрес другой, и счёт у каждого свой.
Стоит заметить и то, чего в этом выводе нет: ни одного if про права в обработчиках, ни второго места, где отказ превращается в статус, ни одной проверки владения вне сервиса — и ни одной процедуры, которую можно позвать, не назвав в договоре. Ради последнего свойства договор и писался раньше кода.
На этом проект умеет всё, что обещал договор, и делает это на коде, написанном читателем от таблицы до подписи токена. Осталось довести его до адреса: последняя статья главы собирает образ, проводит миграции при выкате и разбирает первое падение.
Сценарий — это не тест. Тест проверяет одно место, сценарий показывает, что места сложились в порядок.
- Регистрация считает хеш до вставки, а занятость почты проверяет уникальный индекс, а не отдельный запрос: отказ приезжает кодом
23505и становится доменнымEMAIL_TAKENодной строкой. - Вход отвечает одним словом и на неизвестную почту, и на неверный пароль — и одинаково по времени: неизвестная почта сверяется с хешем-пустышкой той же стоимости. Два разных ответа превратили бы форму входа в список зарегистрированных.
- Личность — два разных вопроса: наш ли токен (подпись и срок, без базы) и правда ли написанное в нём (строка в таблице). Между ними и встаёт ограничитель: ключ читается из подписи, а поход в таблицу перебором не оплачивается.
- Уровень доступа — свойство процедуры и живёт в договоре, а договор — единственный список процедур приложения. Поэтому обработчик без записи в нём отвечает
404даже админу. - Роль открывает процедуру, владение — строку. Модератор проходит уровень доступа и получает
403на чужой заметке: правило «править может автор» роль не отменяет, а служебные ручки — это процедуры, у которых строк с авторами нет вовсе. - Ограничитель считает гостевые процедуры по адресу, а процедуры с личностью — по человеку, и имя процедуры входит в ключ в обоих случаях: на общем ключе перебор пароля закрыл бы и регистрацию, и восстановление.
1/4Вход отвечает одинаково и на неизвестную почту, и на неверный пароль. Зачем при неизвестной почте сервер всё равно считает PBKDF2 — по хешу-пустышке?
2/4Модератор с ролью admin получил 403 на попытке опубликовать чужую заметку. Что это значит?
3/4Модератора понизили до обычного читателя. Его прежний токен получает 401 даже на открытом списке, который отдают гостю. Почему?
4/4Процедура notes.remove написана в роутере и работает, но в договоре её нет. Что ответит транспорт админу, который её позвал?
Роль, присланная клиентом
Регистрацию написали в первый день, и в схеме её входа есть role: роль у пользователя есть, значит, её надо передать. Посмотрите, что после этого отвечает служебная ручка тому, кто только что зарегистрировался, и закройте дыру. Искать надо в схеме входа и в обработчике, а не в страже: страж здесь ни при чём, он ответил на свой вопрос верно.
Роль — не поле ввода, и в схеме входа её быть не должно вовсе. Того, чего в схеме нет, object в разобранное значение не переносит — присланное клиентом role не доедет до обработчика даже случайно, и второй проверки «а можно ли тебе такую роль» не понадобится.
После правки та же попытка получает 403 с кодом WRONG_ROLE: учётка завелась, а роль у неё обычная. Повышение до модератора — отдельное действие с уровнем доступа admin или рука администратора в базе; в проекте такой процедуры нет намеренно.
В боевом API учебника то же самое сделано схемой таблицы: у колонки role стоит default('user'), регистрация её не присылает вовсе — репозиторий получает только почту, хеш и имя, — и на это есть отдельная проверка в тестах сервиса, которая смотрит роль свежей учётки.
Две учётки на одну почту
Вера зарегистрировалась, потом ввела свою почту с маленькой буквы и зарегистрировалась снова. Уникальный индекс не возразил. Посмотрите, сколько теперь в таблице строк с её почтой и что после этого отвечает вход, и сделайте так, чтобы второй регистрации не случилось. Индекс правьте последним: сначала решите, что вообще попадает в таблицу.
Уникальный индекс сравнивает то, что ему дали, и Вера@example.com с вера@example.com для него разные строки. Значит, приводить почту к одному виду надо до него — схемой, и той же схемой в обоих местах, где почта приходит от клиента: в регистрации и во входе. Исправить только регистрацию хуже, чем не исправить ничего: учётка будет одна, а войти по ней получится не всегда.
С ней вторая регистрация получает 409 и код EMAIL_TAKEN, строка с этой почтой в таблице одна, а вход принимает почту в любом написании. Схема при этом не отменяет ограничения в базе: правило живёт в таблице, а не в обработчике, и уникальность обязана держаться по тому же приведённому виду — иначе двойника заведёт первая же дверь, которую напишут мимо схемы.
В боевом API учебника это ровно так и написано: и регистрация, и вход приводят почту к нижнему регистру, а в комментарии рядом сказано, что того же требует ограничение в таблице — иначе Ivan@mail.ru и ivan@mail.ru стали бы разными учётными записями.
Ограничитель, у которого ключ — адрес
Ограничитель написан, окно и предел верные, а ключ — только адрес. Одиннадцать попыток входа с одного адреса выбирают предел; посмотрите, что после этого отвечает регистрация с того же адреса, и исправьте ключ, не поднимая предел.
В ключ входит имя процедуры, а у процедуры с личностью вместо адреса встаёт номер из подписанного токена: перебор входа тогда закрывает вход и только его, а люди за одним адресом не делят предел.
Проверять это надо не на входе, а на соседе: после одиннадцати попыток регистрация с того же адреса обязана пройти. Дыра, которую закрывает имя процедуры, на самой процедуре не видна вовсе — она видна только на второй.
В боевом API учебника у функции ключа в комментарии сказано, что общий на все процедуры ключ был бы хуже дважды: перебор входа закрывал бы восстановление пароля, то есть единственную дверь, через которую жертва могла бы вернуться, — а вдобавок хранилище счёта держит флаг блокировки на самом ключе, и снятие паузы у одного адреса задевало бы счётчики остальных.