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
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:
parent
4296236206
commit
e1718ec972
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -16,24 +27,32 @@
|
||||||
```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 бьют без
|
||||||
|
пароля.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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. Используй
|
||||||
/// везде, где решается «пускать мастер-пароль / отдавать список». Чтение
|
/// везде, где решается «пускать мастер-пароль / отдавать список». Чтение
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>>();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue