Вход
В админку ведёт один путь, и он пускает одного рода участника — администратора платформы. Всем остальным отказывают на входе, а не на первом экране за ним.
Почему отказ на входе
«Вы вошли, но доступа у вас нет» — полезный ответ тому, кто перебирает пароли. Он сообщает, что учётная запись существует, и что пароль был верный, и сообщает это тому, кого всё равно не собирались пускать.
Поэтому роль проверяется внутри входа, между проверкой пароля и выдачей токенов. Три разных неудачи возвращаются одним ответом:
| Что произошло на самом деле | Что получает вызывающий |
|---|---|
| Такой почты нет ни у кого | 401 INVALID_CREDENTIALS · Invalid email or password |
| Неверный пароль | 401 INVALID_CREDENTIALS · Invalid email or password |
| Пароль верный, но это не администратор платформы | 401 INVALID_CREDENTIALS · Invalid email or password |
Тот же статус, тот же код, та же фраза — и ни одного токена в теле. Администратор команды стоит в третьей строке: он управляет одним арендатором, а управлять одним арендатором не значит управлять платформой.
Три эндпоинта
Все под admin/auth, поэтому PlatformAdminGuard накрывает их по объявлению.
| Маршрут | Защита | Зачем |
|---|---|---|
POST /admin/auth/login | @Public() | Вход. Отказывает не-администратору сам, до выдачи чего бы то ни было. |
POST /admin/auth/refresh | @Public() | Продление. Перечитывает роль из базы, поэтому её отзыв прекращает продления сразу. |
GET /admin/auth/me | гвардия | Вопрос админки на старте. 401 без токена, 403 для верного токена, за которым не администратор. |
Обе двери публичны потому, что у пришедшего к ним ещё нет токена, и требовать токен значило бы сделать вход невозможным. Это единственное отверстие, которое оставляет гвардия, и именно поэтому проверка обязана жить внутри обработчика.
Это не вторая система учётных записей
Администратор — обычный User с platformRole = 'admin'. Админка зовёт тот же AuthService, что и кабинет: то же сравнение bcrypt, тот же секрет, те же сроки жизни — и добавляет к результату один вопрос. Тела запроса и ответа — DTO кабинета, без изменений; поэтому же UserDto по-прежнему не несёт платформенную роль: второй пользовательской DTO, через которую она могла бы утечь, просто нет.
Почему продление переспрашивает
Роли нет в токене. Если бы продление проверяло только токен, отзыв роли вступал бы в силу с истечением refresh-токена — через месяц. POST /admin/auth/refresh перечитывает пользователя и отказывает бывшему администратору с 401 INVALID_TOKEN — тем же ответом, что и мёртвому токену, чтобы ответ не отличал «сессия кончилась» от «вас разжаловали».
Со стороны админки
Кука — не разрешение
Админка и кабинет клиента живут на одном хосте и говорят с одним api, который принимает токены обоих. Клиент, вошедший в кабинет и набравший адрес админки, приходит с действительным токеном. Проверка куки пропустила бы его и нарисовала оболочку.
Поэтому админка один раз за загрузку документа спрашивает GET /admin/auth/me — до того, как что-то отрисовано, — и действует по ответу. Это вежливость по отношению к честному браузеру, а не защита: код работает в браузере посетителя, и его можно обойти. Чужие данные от клиента держит гвардия в api.
401 и 403 — разные выходы
Они отличаются одним, и это существенно: помог бы свежий токен или нет.
- 401 — токена нет или он мёртв. Админка продлевает один раз, повторяет запрос один раз и заканчивает сеанс только если и повтор отвергнут. Это вымеренная петля кабинета (AGNT2-72): тупиковый отказ стоит двух обращений к api, а не 600+ пар «запрос/продление», как когда-то.
- 403 — токен в порядке, а человек не администратор. Новый токен провалится точно так же, поэтому продления не происходит вовсе. Сеанс заканчивается сразу.
Любой из выходов — полная навигация документа на /login, которая роняет все сторы, таймеры и запросы в полёте, принадлежавшие мёртвому сеансу, и несёт причину в query-строке:
/login?reason=expired | Your session has expired. Please sign in again. |
/login?reason=forbidden | This account is not an administrator of the platform. |
Причина едет в URL, а не в сторе, потому что навигация существует ровно затем, чтобы сторы уничтожить, — и, в отличие от sessionStorage, URL переживает перезагрузку, ссылается и проверяется тестом. Всё неопознанное в этом параметре не рисуется вовсе: форма входа не должна показывать фразу, которую выбрал посторонний.
Куки
agentfy_admin_access и agentfy_admin_refresh — намеренно не кабинетные agentfy_access / agentfy_refresh. На общем хосте одно имя означало бы, что выход из кабинета выкидывает и из админки, и — что хуже — что вход в кабинет тихо наполняет сеанс админки токеном, которого она не выдавала.
Регистрации нет
Администратором платформы никто не записывается сам: роль ставится записью в базе, вне приложения (см. страницу о роли). Маршрута /register в админке нет, и ссылки на него на экране входа тоже нет.
Как проверить руками
При поднятых api и админке:
# выдать себе роль (эндпоинта для этого нет — так задумано)
cd api && node scripts/platformAdmin.mjs grant you@example.com| Попробуйте | Ожидается |
|---|---|
| Открыть любой адрес админки, не входя | Вы оказываетесь на /login |
| Войти обычным пользователем с верным паролем | Остаётесь на /login, тост INVALID_CREDENTIALS · Invalid email or password |
| Войти им же с неверным паролем | Ровно тот же ответ |
| Войти администратором платформы | Вы внутри, и страница называет вашу учётную запись |
Отозвать роль (platformAdmin.mjs revoke) и перезагрузить | /login?reason=forbidden и фраза на экране |