food-market/docs/quick-login.md
nns e1718ec972
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(s30): sentinel pwd для SPA — реальный мастер-пароль только в server-env
Координатор отротировал мастер-пароль стенда: теперь значение живёт в
~/.fm-watchdog/stage-shared-password на dev-vm (не коммитится) и в
QUICK_LOGIN_MASTER_PASSWORD в stage-env на prod-vm. Старый дефолт '123456'
на обоих сторонах убран — он мог вводить в заблуждение и был записан в
бандл.

Клиент-серверный контракт:
- Server (AuthorizationController.IsMasterPassword) принимает ДВА пароля
  как мастер-ключ: (a) QuickLoginOptions.MasterPassword (константное
  сравнение, FixedTimeEquals); (b) публичный sentinel
  QuickLoginOptions.SentinelPassword = '__QUICK_LOGIN_PWLESS__'. Оба
  гейтованы EffectivelyEnabled — вне стенда отбиваются как любой чужой
  пароль.
- SPA (LoginPage.handleQuickLogin) при клике карточки шлёт sentinel в
  поле password. Реальный пароль в JS-бандл больше не попадает; ротация
  секрета больше не требует rebuild'а веба.
- Форму мы НЕ наполняем sentinel'ом — поле password остаётся пустым,
  чтобы ни одно значение не светилось в DOM.

Default QuickLoginOptions.MasterPassword = "" (пустая строка не
матчится в IsMasterPassword). deploy/docker-compose.yml дефолт
QUICK_LOGIN_MASTER_PASSWORD тоже пустой.

Тесты:
- integration (3/3 ok) — добавлены sentinel-кейсы: пропускает активного
  при Enabled=true, отвергается при Enabled=false. Admin12345!-санити-
  логин в Disabled-тесте перенесён вперёд, чтобы снять lockout-счётчик
  от соседей по коллекции (Identity lockoutOnFailure=true).
- e2e stage-quick-login (4/4 ok) — ql02 шлёт sentinel вместо '123456'
  (симулирует реальный SPA flow).

docs/quick-login.md:
- без упоминания реального пароля; хранилище — ~/.fm-watchdog/
  stage-shared-password; ротация-рецепт через python читает файл
  в-процессе (не экспонирует значение в ps/логи).
- раздел «Доступ к стенду» — host-nginx basic-auth для внешнего
  интернета, 192.168.1.x бьёт без неё.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 21:02:36 +05:00

191 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)
- `<QuickLoginBlock />` над формой логина сам опрашивает `/api/dev/quick-
login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 →
возвращает `null`, никакого UI не рисует.
- Клик по карточке → `POST /connect/token` с паролем **sentinel**
`__QUICK_LOGIN_PWLESS__`. Реальный мастер-пароль в бандл не попадает —
ротация пароля в env не требует rebuild'а веба.
- `<StageBanner />` жёлтой полосой сверху всех страниц, когда эндпоинт
доступен. При том же сигнале динамически добавляет `<meta name="robots"
content="noindex,nofollow">`.
Один 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 бьют без
пароля.