# Quick Login — быстрый вход на тестовом стенде
**Статус:** включено на `https://test.admin.food-market.kz` (стенд).
**Прод:** `https://admin.food-market.kz` — выключено, попытки активации
принудительно блокируются safety-override'ом в коде.
Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль.
Выбираешь карточку аккаунта на `/login`, клик — ты внутри.
## Доступ к стенду
Стенд `test.admin.food-market.kz` для публичного интернета закрыт
HTTP basic-auth на host-уровне nginx (логин `food-market`). Из внутренней
сети `192.168.1.x` (dev-vm, prod-vm) basic-auth не требуется — e2e,
quality-watchdog и обычная разработка работают как раньше.
Пароль basic-auth = пароль quick-login (чтобы не плодить секреты). Хранится
на dev-vm в файле `~/.fm-watchdog/stage-shared-password` (mode 600, не
коммитится).
## Как это работает
### Server-side
Опции в `appsettings.json` / env:
```yaml
QuickLogin:
Enabled: false # default — выключено
MasterPassword: "" # default пустой; реальный секрет — ТОЛЬКО в stage-env
```
На стенде env в `~/food-market-stage/deploy/.env`:
```
QUICK_LOGIN_ENABLED=true
QUICK_LOGIN_MASTER_PASSWORD=<значение из ~/.fm-watchdog/stage-shared-password>
```
Пароль прокидывается в контейнер build'ом из файла и в репозиторий не
попадает. Прод-compose этой переменной не содержит, флаг остаётся false.
Логика срабатывает в двух местах:
- **`POST /connect/token` (grant=password)** — при `EffectivelyEnabled=true`
принимает ДВА варианта пароля наравне с собственным паролем юзера:
1. **MasterPassword** (значение env'а) — для ручного/скриптового
входа, когда у тебя есть сам пароль. Константное сравнение
(`FixedTimeEquals`).
2. **Sentinel** — публичная строка `__QUICK_LOGIN_PWLESS__`, которую
шлёт SPA при клике по карточке. Сервер трактует её как «пустить
этого юзера». Это позволяет SPA не хранить реальный пароль в
JS-бандле.
И тот и другой путь гейтованы `EffectivelyEnabled` — вне стенда оба
отфутболиваются как любой другой чужой пароль.
- **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который
возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio →
остальные. Автотестовые логины отсеиваются по regex-патéрнам (всё с
суффиксом из 10+ цифр или prefix cat31-/visual-/factory-smoke-/mt6*/
quality-/...). Поиск `?q=` фильтрует по email/ФИО/роли/магазину.
На проде эндпоинт отдаёт 404 — наличие пути не палит окружение.
### Client-side (SPA)
- `` над формой логина сам опрашивает `/api/dev/quick-
login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 →
возвращает `null`, никакого UI не рисует.
- Клик по карточке → `POST /connect/token` с паролем **sentinel**
`__QUICK_LOGIN_PWLESS__`. Реальный мастер-пароль в бандл не попадает —
ротация пароля в env не требует rebuild'а веба.
- `` жёлтой полосой сверху всех страниц, когда эндпоинт
доступен. При том же сигнале динамически добавляет ``.
Один web-билд используется и в проде, и на стенде — секрет на проде не
утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет, а
sentinel сервером всё равно отвергается.
## Безопасность — предохранители
1. **Enabled=false по умолчанию.** Нужна явная env-переменная, чтобы
включить. В прод-compose её нет и не должно быть.
2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`:
если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО,
без поддоменов), быстрый вход **принудительно выключен**. Защита от
ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в прод-env
по ошибке, но Issuer остался прод-овым → ничего не включилось, в
лог Warning.
3. **Пустой или кривой Issuer тоже отбивает.** Не рискуем в неизвестном
окружении.
4. **Пустой MasterPassword отбивает мастер-пароль.** Пустая строка не
матчится в `IsMasterPassword` (отбивается до FixedTimeEquals). Это
защищает от случайно разворачиваемого стенда без секрета.
5. **Rate-limit не ослаблен.** Sentinel и master-password идут через ту
же защиту, что обычный password-grant.
6. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху
`/connect/token` — ни sentinel, ни master-password это не обходят.
7. **Startup Warning.** Program.cs логирует одно решение на старте:
«включён для стенда» или «Enabled=true, но безопасность выключила».
8. **Basic auth на host-nginx.** Внешние запросы к `test.admin.food-
market.kz` отбиваются 401 без корректных basic-auth creds. Внутренняя
сеть исключена из этого gate'а (чтобы автоматика работала). Этот
уровень живёт за пределами репозитория — в gateway nginx на prod-vm.
## Демо-аккаунты
`QuickLoginDemoSeeder` при старте с `EffectivelyEnabled=true` поднимает:
- Организацию **«Демо-магазин»** (идемпотентно).
- Полный demo-каталог через `DemoTenantSeeder` (50 товаров / 30 продаж /
приёмки / контрагенты). Если демо-каталог не наполнился (нет HTTP-
контекста при startup → `Tenant not set`), юзеры всё равно создаются.
- Трио учёток с ролями:
- `demo-admin@food-market.local` → `Admin`
- `demo-cashier@food-market.local` → `Cashier`
- `demo-keeper@food-market.local` → `Storekeeper`
Собственный пароль у них `Admin12345!` (унаследовано от шаблона), но
sentinel и master-password тоже работают.
## Как ротирнуть секрет-пароль
1. На dev-vm обновить `~/.fm-watchdog/stage-shared-password` (mode 600).
2. На prod-vm обновить `~/food-market-stage/deploy/.env`:
`QUICK_LOGIN_MASTER_PASSWORD=<новое значение>`. Удобно без эха:
```bash
# с dev-vm
scp ~/.fm-watchdog/stage-shared-password nns@192.168.1.190:/tmp/.ql-pw
ssh nns@192.168.1.190 'python3 -c "
import pathlib, re
pw = pathlib.Path(\"/tmp/.ql-pw\").read_text().strip()
env = pathlib.Path(\"/home/nns/food-market-stage/deploy/.env\")
env.write_text(re.sub(r\"^QUICK_LOGIN_MASTER_PASSWORD=.*$\",
f\"QUICK_LOGIN_MASTER_PASSWORD={pw}\", env.read_text(), flags=re.M))" \
&& rm /tmp/.ql-pw && grep -c QUICK_LOGIN_MASTER_PASSWORD \
~/food-market-stage/deploy/.env'
```
3. На prod-vm также обновить basic-auth htpasswd для `test.admin.food-
market.kz` в nginx — тот же пароль с юзером `food-market`.
4. Рестартнуть API: `ssh nns@192.168.1.190 "cd ~/food-market-stage/deploy
&& docker compose -p food-market-stage up -d --force-recreate api"`.
5. Веб rebuild **не нужен** — sentinel не меняется.
## Как выключить обратно
```bash
# на prod-vm
nns@192.168.1.190:$ sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \
~/food-market-stage/deploy/.env
nns@192.168.1.190:$ cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --force-recreate api
```
API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт
снова 404, sentinel и master-password не работают. Карточки на `/login`
исчезнут — `QuickLoginBlock` увидит 404 и вернёт `null`.
## Проверить что прод не задет
```bash
curl -s -o /dev/null -w "%{http_code}\n" \
https://admin.food-market.kz/api/dev/quick-login/accounts
# ожидается: 404 (или 502, если прод-API вообще не поднят)
```
Любой `200` — проблема.
## Файлы
| Что | Где |
|---|---|
| Опции + safety-override + sentinel-константа | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
| Master-password + sentinel в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` |
| Список аккаунтов | `src/food-market.api/Controllers/QuickLoginController.cs` |
| Демо-сид | `src/food-market.api/Seed/QuickLoginDemoSeeder.cs` |
| Чистка автотестов | `src/food-market.api/Background/HousekeepingJobs.cs` + `deploy/stage-prune-autotest.sql` |
| UI блок (sentinel-flow) | `src/food-market.web/src/pages/LoginPage.tsx` + `src/food-market.web/src/components/QuickLoginBlock.tsx` |
| Плашка стенда | `src/food-market.web/src/components/StageBanner.tsx` |
| Integration-тесты | `tests/food-market.IntegrationTests/QuickLoginTests.cs` |
| E2E-сценарий | `tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}` |
| Секрет-пароль (на dev-vm) | `~/.fm-watchdog/stage-shared-password` (вне репо) |
## История
- **2026-10-04 (Sprint 30, часть 1):** первоначальный ввод на тестовый стенд.
- **2026-10-04 (Sprint 30, часть 2):** секрет-пароль вынесен в `~/.fm-
watchdog/stage-shared-password` (вне репо); SPA перешла на sentinel —
реальный пароль в JS-бандл больше не попадает; стенд закрыт basic-auth
на host-nginx для внешнего интернета, внутренние 192.168.1.x бьют без
пароля.