feat(s30): sentinel pwd для SPA — реальный мастер-пароль только в server-env
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

Координатор отротировал мастер-пароль стенда: теперь значение живёт в
~/.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>
This commit is contained in:
nns 2026-10-04 21:02:36 +05:00
parent 4296236206
commit e1718ec972
9 changed files with 221 additions and 86 deletions

View file

@ -40,12 +40,17 @@ services:
RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3} RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3}
RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10} RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10}
# Sprint 30: «быстрый вход» на тестовом стенде. По умолчанию выключено. # Sprint 30: «быстрый вход» на тестовом стенде. По умолчанию выключено.
# На stage включается через QUICK_LOGIN_ENABLED=true в .env. Code # На stage включается через QUICK_LOGIN_ENABLED=true в .env.
# имеет safety-override: даже с Enabled=true, если OpenIddict__Issuer # QUICK_LOGIN_MASTER_PASSWORD задаётся секрет-паролем стенда (значение
# смотрит на admin.food-market.kz — быстрый вход принудительно выключен # хранится вне репозитория — на dev-vm в ~/.fm-watchdog/stage-shared-
# (см. QuickLoginOptions.EffectivelyEnabled). # password). Если переменная пустая — master-password-режим отключён
# (IsMasterPassword отбивает пустую строку). SPA отправляет публичный
# sentinel '__QUICK_LOGIN_PWLESS__' — реальный пароль в JS не попадает.
# Code имеет safety-override: даже с Enabled=true, если OpenIddict__
# Issuer смотрит на admin.food-market.kz — быстрый вход принудительно
# выключен (см. QuickLoginOptions.EffectivelyEnabled).
QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false} QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false}
QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-123456} QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-}
# Host port mapping: pick free ports on existing stage server (80/443 taken by # Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps). # legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports: ports:

View file

@ -1,8 +1,10 @@
# Quick Login — прогресс # Quick Login — прогресс
Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль руками. Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль
Выбрать аккаунт из списка, пароль у всех один — `123456`. На `admin.food-market.kz` руками. Выбрать аккаунт из списка — ты внутри в один клик. У всех аккаунтов
этого нет никогда (несколько независимых предохранителей). один shared-пароль; его значение хранится в `~/.fm-watchdog/stage-shared-
password` на dev-vm (не коммитится). На `admin.food-market.kz` быстрого
входа нет никогда (несколько независимых предохранителей).
Координатор отдельно проверил: на стейдже нет реальных секретов (МойСклад Координатор отдельно проверил: на стейдже нет реальных секретов (МойСклад
токенов нет, ОФД-ключей нет, Telegram не привязан, SMTP пустой) — потому токенов нет, ОФД-ключей нет, Telegram не привязан, SMTP пустой) — потому
@ -11,10 +13,12 @@
## Чек-лист — все 9/9 ✓ ## Чек-лист — все 9/9 ✓
- [x] 1. **Флаг конфигурации** — `QuickLogin:Enabled` (default false) + - [x] 1. **Флаг конфигурации** — `QuickLogin:Enabled` (default false) +
`QuickLogin:MasterPassword` (default `123456`). Включается только через `QuickLogin:MasterPassword` (default пустой; реальное значение — только
env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий в stage-env, читается из `~/.fm-watchdog/stage-shared-password`).
предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz — Включается только через env-переменные `QuickLogin__Enabled=true` в
быстрый вход принудительно выключен и в лог Warning. stage-compose. Жёсткий предохранитель: если `OpenIddict:Issuer`
указывает на admin.food-market.kz — быстрый вход принудительно
выключен и в лог Warning.
Commit: `1f70d90`. Commit: `1f70d90`.
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword` - [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
работает как второй вход рядом со своим паролем. Неактивные/без-org работает как второй вход рядом со своим паролем. Неактивные/без-org
@ -76,8 +80,15 @@ market@email.com role=Администратор org
``` ```
``` ```
curl -X POST .../connect/token password=123456 username=demo-cashier@... # SPA-поток: шлём sentinel вместо реального пароля
curl -X POST .../connect/token password=__QUICK_LOGIN_PWLESS__ username=demo-cashier@...
→ 200 + access_token; /api/me → role=Cashier → 200 + access_token; /api/me → role=Cashier
# API-поток: шлём реальный пароль из файла (без эха значения)
curl -X POST .../connect/token \
--data-urlencode "password@/home/nns/.fm-watchdog/stage-shared-password" \
--data-urlencode "username=demo-cashier@food-market.local" ...
→ 200 + access_token
``` ```
``` ```
@ -143,4 +154,15 @@ cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --for
``` ```
После рестарта `EffectivelyEnabled=false`, endpoint снова 404, master-password После рестарта `EffectivelyEnabled=false`, endpoint снова 404, master-password
не работает, UI-блок исчезает. См. `docs/quick-login.md` для деталей. и sentinel не работают, UI-блок исчезает. См. `docs/quick-login.md` для деталей.
## История
- **2026-10-04 (Sprint 30, часть 1):** первоначальный ввод на тестовый стенд.
- **2026-10-04 (часть 2, координатор):** реальный пароль вынесен в
`~/.fm-watchdog/stage-shared-password` (не коммитится); SPA перешла на
публичный sentinel `__QUICK_LOGIN_PWLESS__` → мастер-пароль больше не
попадает в JS-бандл; `test.admin.food-market.kz` для внешнего интернета
закрыт basic-auth (пароль = тот же), внутренняя сеть `192.168.1.x`
бьёт без basic-auth. Default `MasterPassword=""`, sentinel-поведение
в `AuthorizationController.IsMasterPassword`.

View file

@ -7,6 +7,17 @@
Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль. Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль.
Выбираешь карточку аккаунта на `/login`, клик — ты внутри. Выбираешь карточку аккаунта на `/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 ### Server-side
@ -15,25 +26,33 @@
```yaml ```yaml
QuickLogin: QuickLogin:
Enabled: false # default — выключено Enabled: false # default — выключено
MasterPassword: "123456" MasterPassword: "" # default пустой; реальный секрет — ТОЛЬКО в stage-env
``` ```
Включается через env в stage-compose: На стенде env в `~/food-market-stage/deploy/.env`:
``` ```
QUICK_LOGIN_ENABLED=true QUICK_LOGIN_ENABLED=true
QUICK_LOGIN_MASTER_PASSWORD=123456 QUICK_LOGIN_MASTER_PASSWORD=<значение из ~/.fm-watchdog/stage-shared-password>
``` ```
Логика работает в двух местах: Пароль прокидывается в контейнер build'ом из файла и в репозиторий не
попадает. Прод-compose этой переменной не содержит, флаг остаётся false.
Логика срабатывает в двух местах:
- **`POST /connect/token` (grant=password)** — при `EffectivelyEnabled=true` - **`POST /connect/token` (grant=password)** — при `EffectivelyEnabled=true`
пароль `MasterPassword` принимается у ЛЮБОГО активного пользователя наравне принимает ДВА варианта пароля наравне с собственным паролем юзера:
с его собственным паролем. Константное сравнение (`CryptographicOperations. 1. **MasterPassword** (значение env'а) — для ручного/скриптового
FixedTimeEquals`), чтобы не светить длину. Деактивированные (`IsActive= входа, когда у тебя есть сам пароль. Константное сравнение
false`) не пускаются ни при каких условиях. `/connect/token` всё так же (`FixedTimeEquals`).
гонит через rate-limit, 2FA и org-check (архивная org отшивает). 2. **Sentinel** — публичная строка `__QUICK_LOGIN_PWLESS__`, которую
шлёт SPA при клике по карточке. Сервер трактует её как «пустить
этого юзера». Это позволяет SPA не хранить реальный пароль в
JS-бандле.
И тот и другой путь гейтованы `EffectivelyEnabled` — вне стенда оба
отфутболиваются как любой другой чужой пароль.
- **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который - **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который
возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio → возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio →
@ -46,14 +65,17 @@ QUICK_LOGIN_MASTER_PASSWORD=123456
- `<QuickLoginBlock />` над формой логина сам опрашивает `/api/dev/quick- - `<QuickLoginBlock />` над формой логина сам опрашивает `/api/dev/quick-
login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 → login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 →
возвращает `null`, никакого UI не рисует. Клик по карточке → login с возвращает `null`, никакого UI не рисует.
`MasterPassword`. - Клик по карточке → `POST /connect/token` с паролем **sentinel**
`__QUICK_LOGIN_PWLESS__`. Реальный мастер-пароль в бандл не попадает —
ротация пароля в env не требует rebuild'а веба.
- `<StageBanner />` жёлтой полосой сверху всех страниц, когда эндпоинт - `<StageBanner />` жёлтой полосой сверху всех страниц, когда эндпоинт
доступен. При том же сигнале динамически добавляет `<meta name="robots" доступен. При том же сигнале динамически добавляет `<meta name="robots"
content="noindex,nofollow">` — чтобы Google не индексировал стенд. content="noindex,nofollow">`.
Один web-билд используется и в проде, и на стенде — секрет на проде не Один web-билд используется и в проде, и на стенде — секрет на проде не
утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет. утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет, а
sentinel сервером всё равно отвергается.
## Безопасность — предохранители ## Безопасность — предохранители
@ -61,50 +83,67 @@ QUICK_LOGIN_MASTER_PASSWORD=123456
включить. В прод-compose её нет и не должно быть. включить. В прод-compose её нет и не должно быть.
2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`: 2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`:
если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО, если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО,
без поддоменов), быстрый вход **принудительно выключен**. Это защищает без поддоменов), быстрый вход **принудительно выключен**. Защита от
от ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в прод-env
прод-env по ошибке, но Issuer остался прод-овым → ничего не включилось, по ошибке, но Issuer остался прод-овым → ничего не включилось, в
в лог Warning. лог Warning.
3. **Пустой или кривой Issuer тоже отбивает.** Не рискуем в неизвестном 3. **Пустой или кривой Issuer тоже отбивает.** Не рискуем в неизвестном
окружении: если Issuer пустой/мусорный — считаем выключенным. окружении.
4. **Rate-limit не ослаблен.** Master-password идёт через ту же защиту, 4. **Пустой MasterPassword отбивает мастер-пароль.** Пустая строка не
что обычный password-grant (`UseAuthFormUsernameKey` + `UseRateLimiter`). матчится в `IsMasterPassword` (отбивается до FixedTimeEquals). Это
5. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху защищает от случайно разворачиваемого стенда без секрета.
`/connect/token` — master-password это не обходит. 5. **Rate-limit не ослаблен.** Sentinel и master-password идут через ту
6. **Startup Warning.** Program.cs логирует одно решение на старте: же защиту, что обычный password-grant.
6. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху
`/connect/token` — ни sentinel, ни master-password это не обходят.
7. **Startup Warning.** Program.cs логирует одно решение на старте:
«включён для стенда» или «Enabled=true, но безопасность выключила». «включён для стенда» или «Enabled=true, но безопасность выключила».
В логах прод-инстанса всегда видно, что происходит. 8. **Basic auth на host-nginx.** Внешние запросы к `test.admin.food-
market.kz` отбиваются 401 без корректных basic-auth creds. Внутренняя
сеть исключена из этого gate'а (чтобы автоматика работала). Этот
уровень живёт за пределами репозитория — в gateway nginx на prod-vm.
## Демо-аккаунты ## Демо-аккаунты
`QuickLoginDemoSeeder` при старте с `EffectivelyEnabled=true` поднимает: `QuickLoginDemoSeeder` при старте с `EffectivelyEnabled=true` поднимает:
- Организацию **«Демо-магазин»** (идемпотентно; отдельная от «Demo Market» - Организацию **«Демо-магазин»** (идемпотентно).
из `DevDataSeeder`, чтобы не ломать e2e).
- Полный demo-каталог через `DemoTenantSeeder` (50 товаров / 30 продаж / - Полный demo-каталог через `DemoTenantSeeder` (50 товаров / 30 продаж /
приёмки / контрагенты). приёмки / контрагенты). Если демо-каталог не наполнился (нет HTTP-
контекста при startup → `Tenant not set`), юзеры всё равно создаются.
- Трио учёток с ролями: - Трио учёток с ролями:
- `demo-admin@food-market.local` → `Admin` - `demo-admin@food-market.local` → `Admin`
- `demo-cashier@food-market.local` → `Cashier` - `demo-cashier@food-market.local` → `Cashier`
- `demo-keeper@food-market.local` → `Storekeeper` - `demo-keeper@food-market.local` → `Storekeeper`
Собственный пароль у них `Admin12345!` (унаследовано от шаблона), но Собственный пароль у них `Admin12345!` (унаследовано от шаблона), но
мастер-пароль `123456` тоже работает. sentinel и master-password тоже работают.
## Чистка автотестового мусора ## Как ротирнуть секрет-пароль
`HousekeepingJobs.PruneAutoTestEntitiesAsync` — Hangfire-recurring каждый 1. На dev-vm обновить `~/.fm-watchdog/stage-shared-password` (mode 600).
день в 02:45 UTC. Удаляет автотестовые организации (regex-match по имени) 2. На prod-vm обновить `~/food-market-stage/deploy/.env`:
и orphan-юзеры по email-паттерну старше 3 дней. Защищены: SuperAdmin, `QUICK_LOGIN_MASTER_PASSWORD=<новое значение>`. Удобно без эха:
`admin@demo-market.local`, `demo-*@food-market.local`, `market@email.com`, ```bash
«Demo Market» / «Демо-магазин». # с dev-vm
scp ~/.fm-watchdog/stage-shared-password nns@192.168.1.190:/tmp/.ql-pw
Единоразовая зачистка той же логикой — `deploy/stage-prune-autotest.sql`. 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 ```bash
# на prod-vm # на prod-vm
nns@192.168.1.190:$ sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \ nns@192.168.1.190:$ sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \
@ -113,33 +152,39 @@ nns@192.168.1.190:$ cd ~/food-market-stage/deploy && docker compose -p food-mark
``` ```
API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт
снова 404, master-password не работает. Карточки на `/login` исчезнут — снова 404, sentinel и master-password не работают. Карточки на `/login`
`QuickLoginBlock` увидит 404 и вернёт `null`. исчезнут — `QuickLoginBlock` увидит 404 и вернёт `null`.
## Проверить что прод не задет ## Проверить что прод не задет
```bash ```bash
curl -s -o /dev/null -w "%{http_code}\n" \ curl -s -o /dev/null -w "%{http_code}\n" \
https://admin.food-market.kz/api/dev/quick-login/accounts https://admin.food-market.kz/api/dev/quick-login/accounts
# ожидается: 404 # ожидается: 404 (или 502, если прод-API вообще не поднят)
``` ```
Любой другой код (200/403/500) — проблема. Любой `200` — проблема.
## Файлы ## Файлы
| Что | Где | | Что | Где |
|---|---| |---|---|
| Опции + safety-override | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` | | Опции + safety-override + sentinel-константа | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
| Master-password в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` | | Master-password + sentinel в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` |
| Список аккаунтов | `src/food-market.api/Controllers/QuickLoginController.cs` | | Список аккаунтов | `src/food-market.api/Controllers/QuickLoginController.cs` |
| Демо-сид | `src/food-market.api/Seed/QuickLoginDemoSeeder.cs` | | Демо-сид | `src/food-market.api/Seed/QuickLoginDemoSeeder.cs` |
| Чистка автотестов | `src/food-market.api/Background/HousekeepingJobs.cs` + `deploy/stage-prune-autotest.sql` | | Чистка автотестов | `src/food-market.api/Background/HousekeepingJobs.cs` + `deploy/stage-prune-autotest.sql` |
| UI блок | `src/food-market.web/src/components/QuickLoginBlock.tsx` | | 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` | | Плашка стенда | `src/food-market.web/src/components/StageBanner.tsx` |
| Integration-тесты | `tests/food-market.IntegrationTests/QuickLoginTests.cs` | | Integration-тесты | `tests/food-market.IntegrationTests/QuickLoginTests.cs` |
| E2E-сценарий | `tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}` | | E2E-сценарий | `tests/e2e/scenarios/stage-quick-login.{yml,steps.ts}` |
| Секрет-пароль (на dev-vm) | `~/.fm-watchdog/stage-shared-password` (вне репо) |
## История ## История
- **2026-10-04 (Sprint 30):** первоначальный ввод на тестовый стенд. - **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 бьют без
пароля.

View file

@ -205,12 +205,24 @@ or HttpContextTenantContext.OrganizationClaim
[Parameters.ErrorDescription] = description [Parameters.ErrorDescription] = description
}); });
/// <summary>Константное сравнение пароля с мастер-ключом. Пустой пароль /// <summary>«Пропустить этого юзера как по мастер-паролю». Срабатывает в
/// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно /// двух случаях:
/// не пустить любого, кто прислал "" в деконфигурированном окружении.</summary> /// <list type="bullet">
/// <item><paramref name="supplied"/> совпадает с
/// <see cref="QuickLoginOptions.MasterPassword"/> (константное
/// сравнение, FixedTimeEquals — не светит длину по времени);</item>
/// <item><paramref name="supplied"/> равен публичному sentinel'у
/// <see cref="QuickLoginOptions.SentinelPassword"/>. Это позволяет
/// SPA реализовать клик-по-карточке без того, чтобы реальный мастер-
/// пароль жил в JS-бандле: SPA шлёт известную строку, которую
/// сервер трактует «пустить». Безопасность sentinel'а равна
/// безопасности самого эндпоинта: оба гейтованы EffectivelyEnabled
/// (вне стенда — 400 invalid_grant как с любым чужим паролем).</item>
/// </list></summary>
private bool IsMasterPassword(string? supplied) private bool IsMasterPassword(string? supplied)
{ {
if (string.IsNullOrEmpty(supplied)) return false; if (string.IsNullOrEmpty(supplied)) return false;
if (supplied == QuickLoginOptions.SentinelPassword) return true;
if (string.IsNullOrEmpty(_quickLogin.MasterPassword)) return false; if (string.IsNullOrEmpty(_quickLogin.MasterPassword)) return false;
var a = System.Text.Encoding.UTF8.GetBytes(supplied); var a = System.Text.Encoding.UTF8.GetBytes(supplied);
var b = System.Text.Encoding.UTF8.GetBytes(_quickLogin.MasterPassword); var b = System.Text.Encoding.UTF8.GetBytes(_quickLogin.MasterPassword);

View file

@ -20,8 +20,23 @@ public sealed class QuickLoginOptions
/// <summary>Единый пароль-ключ для стенда. При включённом флаге подходит /// <summary>Единый пароль-ключ для стенда. При включённом флаге подходит
/// к ЛЮБОМУ активному аккаунту в <c>/connect/token</c> grant=password. /// к ЛЮБОМУ активному аккаунту в <c>/connect/token</c> grant=password.
/// Собственный пароль пользователя тоже продолжает работать (чтобы не /// Собственный пароль пользователя тоже продолжает работать (чтобы не
/// ломать e2e, seeded admin и т.д.).</summary> /// ломать e2e, seeded admin и т.д.).
public string MasterPassword { get; set; } = "123456"; ///
/// Значение задаётся в env <c>QuickLogin__MasterPassword</c>. Если
/// переменная не задана — значение пустое и мастер-пароль-режим в
/// <see cref="IsMasterPassword"/> отбивает любую попытку (пустая строка
/// не матчится).</summary>
public string MasterPassword { get; set; } = "";
/// <summary>Публичный sentinel, который SPA отправляет в поле <c>password</c>
/// при клике по карточке быстрого входа. Он ПУБЛИЧЕН (строка в JS-бандле)
/// и сам по себе не секрет: сервер при <see cref="EffectivelyEnabled"/>=true
/// принимает sentinel как «пустить этого юзера» (эквивалент мастер-пароля)
/// без того, чтобы сам мастер-пароль утекал в бандл или в HTTP-ответы.
///
/// Защита: вне стенда <see cref="EffectivelyEnabled"/>=false, sentinel
/// отбивается обычным не-подошёл-пароль как любой другой чужой пароль.</summary>
public const string SentinelPassword = "__QUICK_LOGIN_PWLESS__";
/// <summary>Эффективный флаг с учётом safety-override по Issuer. Используй /// <summary>Эффективный флаг с учётом safety-override по Issuer. Используй
/// везде, где решается «пускать мастер-пароль / отдавать список». Чтение /// везде, где решается «пускать мастер-пароль / отдавать список». Чтение

View file

@ -6,10 +6,14 @@ import { Logo } from '@/components/Logo'
import { QuickLoginBlock } from '@/components/QuickLoginBlock' import { QuickLoginBlock } from '@/components/QuickLoginBlock'
import { validateEmail, messages } from '@/lib/validation' import { validateEmail, messages } from '@/lib/validation'
/** Мастер-пароль «быстрого входа» — должен совпадать с server-side /** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
* QuickLoginOptions.MasterPassword. Используется только на стенде; * Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
* в проде блок быстрого входа не показывается, поэтому значение не утекает. */ * Реальный мастер-пароль живёт ТОЛЬКО в server-env (QuickLogin__
const QUICK_LOGIN_MASTER_PASSWORD = '123456' * MasterPassword) — в JS-бандл он не попадает, и наш билд не меняется
* при ротации пароля. Вне стенда сервер отфутболивает sentinel как
* обычный невалидный пароль (блок всё равно не рендерится, т.к.
* /api/dev/quick-login/accounts отдаёт 404). */
const QUICK_LOGIN_SENTINEL = '__QUICK_LOGIN_PWLESS__'
interface MeResp { roles: string[] } interface MeResp { roles: string[] }
@ -51,18 +55,19 @@ export function LoginPage() {
} }
} }
/** Клик по карточке «быстрого входа»: подставляем email + мастер-пароль /** Клик по карточке «быстрого входа»: подставляем email + sentinel и
* и сразу шлём /connect/token. На проде этот путь недоступен — блок * сразу шлём /connect/token. Сервер при EffectivelyEnabled=true
* QuickLoginBlock рендерится только после 200 от GET /accounts, которого * трактует sentinel как мастер-пароль, но сам пароль в бандл не ушёл.
* в проде нет (404). */ * На проде этот путь недоступен — блок QuickLoginBlock рендерится
* только после 200 от GET /accounts, которого в проде нет (404). */
async function handleQuickLogin(pickedEmail: string) { async function handleQuickLogin(pickedEmail: string) {
setEmail(pickedEmail) setEmail(pickedEmail)
setPassword(QUICK_LOGIN_MASTER_PASSWORD) setPassword('') // форму НЕ наполняем — чтобы sentinel не светился в полях
setEmailErr(null); setPasswordErr(null) setEmailErr(null); setPasswordErr(null)
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
await login(pickedEmail, QUICK_LOGIN_MASTER_PASSWORD) await login(pickedEmail, QUICK_LOGIN_SENTINEL)
let target = from let target = from
try { try {
const me = (await api.get<MeResp>('/api/me')).data const me = (await api.get<MeResp>('/api/me')).data

View file

@ -76,10 +76,15 @@ export async function ql01_stage_endpoint_returns_200({ ctx, step, report }: Ste
export async function ql02_master_password_logs_in_demo_cashier({ step, report }: StepCtx) { export async function ql02_master_password_logs_in_demo_cashier({ step, report }: StepCtx) {
const api = makeClient() const api = makeClient()
// SPA-эквивалент: шлём sentinel вместо реального мастер-пароля (который
// живёт только в server-env, не в бандле). Сервер при EffectivelyEnabled
// трактует sentinel как «пустить». Тест реалистичен — именно так SPA
// ходит, когда юзер жмёт карточку.
const SENTINEL = '__QUICK_LOGIN_PWLESS__'
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: 'password', grant_type: 'password',
username: 'demo-cashier@food-market.local', username: 'demo-cashier@food-market.local',
password: '123456', password: SENTINEL,
client_id: 'food-market-web', client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access', scope: 'openid profile email roles api offline_access',
}) })
@ -89,7 +94,7 @@ export async function ql02_master_password_logs_in_demo_cashier({ step, report }
const ok = res.status === 200 && typeof res.data?.access_token === 'string' const ok = res.status === 200 && typeof res.data?.access_token === 'string'
check(step, { check(step, {
kind: 'api', kind: 'api',
description: 'master-пароль "123456" пускает demo-cashier → 200 + access_token', description: 'sentinel пускает demo-cashier → 200 + access_token',
ok, ok,
detail: ok ? 'token получен' : `status=${res.status} data=${asString(res.data).slice(0, 200)}`, detail: ok ? 'token получен' : `status=${res.status} data=${asString(res.data).slice(0, 200)}`,
}) })
@ -97,8 +102,8 @@ export async function ql02_master_password_logs_in_demo_cashier({ step, report }
report.bug({ report.bug({
step: 'ql02', step: 'ql02',
severity: 'critical', severity: 'critical',
title:'master-пароль не работает для demo-cashier', title: 'sentinel не пускает demo-cashier',
detail: 'QuickLogin:MasterPassword на стенде должен быть "123456", и demo-cashier активен.', detail: 'QuickLogin:Enabled должен быть true. demo-cashier активен. Проверь, что QuickLoginOptions.SentinelPassword на бэке совпадает с клиентским.',
}) })
return return
} }

View file

@ -18,7 +18,7 @@ steps:
- id: ql01_stage_endpoint_returns_200 - id: ql01_stage_endpoint_returns_200
title: GET /api/dev/quick-login/accounts (stage) → 200 + SuperAdmin + demo-trio в списке title: GET /api/dev/quick-login/accounts (stage) → 200 + SuperAdmin + demo-trio в списке
- id: ql02_master_password_logs_in_demo_cashier - id: ql02_master_password_logs_in_demo_cashier
title: POST /connect/token с username=demo-cashier@food-market.local и password=123456 → 200, роль=Cashier title: POST /connect/token username=demo-cashier@food-market.local password=<SPA sentinel> → 200, роль=Cashier
- id: ql03_prod_endpoint_returns_404 - id: ql03_prod_endpoint_returns_404
title: GET https://admin.food-market.kz/api/dev/quick-login/accounts → 404 (safety-override) title: GET https://admin.food-market.kz/api/dev/quick-login/accounts → 404 (safety-override)
- id: ql04_ui_click_demo_cashier_logs_in - id: ql04_ui_click_demo_cashier_logs_in

View file

@ -71,6 +71,20 @@ public async Task Disabled_returns_404_and_rejects_master_password()
var get = await http.GetAsync("/api/dev/quick-login/accounts"); var get = await http.GetAsync("/api/dev/quick-login/accounts");
get.StatusCode.Should().Be(HttpStatusCode.NotFound); get.StatusCode.Should().Be(HttpStatusCode.NotFound);
// Сначала sanity-check с правильным паролем — он заодно сбрасывает
// Identity-lockout counter admin@food-market.local, который могли
// накрутить соседи по коллекции тестов (CheckPasswordSignIn с
// lockoutOnFailure=true увеличивает счётчик на каждый fail).
var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = "admin@food-market.local",
["password"] = "Admin12345!",
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api",
}));
ok.StatusCode.Should().Be(HttpStatusCode.OK);
var token = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string> var token = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{ {
["grant_type"] = "password", ["grant_type"] = "password",
@ -83,16 +97,16 @@ public async Task Disabled_returns_404_and_rejects_master_password()
var body = await token.Content.ReadFromJsonAsync<JsonElement>(); var body = await token.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("error").GetString().Should().Be("invalid_grant"); body.GetProperty("error").GetString().Should().Be("invalid_grant");
// Правильный пароль у admin'а — sanity-check, что backend вообще отвечает OK. // Sentinel при выключенном флаге — тоже не пропускается.
var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string> var sentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{ {
["grant_type"] = "password", ["grant_type"] = "password",
["username"] = "admin@food-market.local", ["username"] = "admin@food-market.local",
["password"] = "Admin12345!", ["password"] = QuickLoginOptions.SentinelPassword,
["client_id"] = "food-market-web", ["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api", ["scope"] = "openid profile email roles api",
})); }));
ok.StatusCode.Should().Be(HttpStatusCode.OK); sentinel.StatusCode.Should().Be(HttpStatusCode.BadRequest);
} }
[Fact] [Fact]
@ -136,6 +150,18 @@ public async Task Enabled_at_runtime_allows_master_for_active_and_rejects_inacti
})); }));
tokenOk.StatusCode.Should().Be(HttpStatusCode.OK); tokenOk.StatusCode.Should().Be(HttpStatusCode.OK);
// Sentinel (SPA click-to-login) тоже пускает активного — без
// того, чтобы реальный мастер-пароль ехал в теле запроса.
var tokenSentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = "admin@food-market.local",
["password"] = QuickLoginOptions.SentinelPassword,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api",
}));
tokenSentinel.StatusCode.Should().Be(HttpStatusCode.OK);
// Создаём неактивного и проверяем, что master-пароль его НЕ пускает. // Создаём неактивного и проверяем, что master-пароль его НЕ пускает.
using var scope = _factory.Services.CreateScope(); using var scope = _factory.Services.CreateScope();
var mgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>(); var mgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();