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__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10}
|
||||
# Sprint 30: «быстрый вход» на тестовом стенде. По умолчанию выключено.
|
||||
# На stage включается через QUICK_LOGIN_ENABLED=true в .env. Code
|
||||
# имеет safety-override: даже с Enabled=true, если OpenIddict__Issuer
|
||||
# смотрит на admin.food-market.kz — быстрый вход принудительно выключен
|
||||
# (см. QuickLoginOptions.EffectivelyEnabled).
|
||||
# На stage включается через QUICK_LOGIN_ENABLED=true в .env.
|
||||
# QUICK_LOGIN_MASTER_PASSWORD задаётся секрет-паролем стенда (значение
|
||||
# хранится вне репозитория — на dev-vm в ~/.fm-watchdog/stage-shared-
|
||||
# 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__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
|
||||
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# Quick Login — прогресс
|
||||
|
||||
Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль руками.
|
||||
Выбрать аккаунт из списка, пароль у всех один — `123456`. На `admin.food-market.kz`
|
||||
этого нет никогда (несколько независимых предохранителей).
|
||||
Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль
|
||||
руками. Выбрать аккаунт из списка — ты внутри в один клик. У всех аккаунтов
|
||||
один shared-пароль; его значение хранится в `~/.fm-watchdog/stage-shared-
|
||||
password` на dev-vm (не коммитится). На `admin.food-market.kz` быстрого
|
||||
входа нет никогда (несколько независимых предохранителей).
|
||||
|
||||
Координатор отдельно проверил: на стейдже нет реальных секретов (МойСклад
|
||||
токенов нет, ОФД-ключей нет, Telegram не привязан, SMTP пустой) — потому
|
||||
|
|
@ -11,10 +13,12 @@
|
|||
## Чек-лист — все 9/9 ✓
|
||||
|
||||
- [x] 1. **Флаг конфигурации** — `QuickLogin:Enabled` (default false) +
|
||||
`QuickLogin:MasterPassword` (default `123456`). Включается только через
|
||||
env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий
|
||||
предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz —
|
||||
быстрый вход принудительно выключен и в лог Warning.
|
||||
`QuickLogin:MasterPassword` (default пустой; реальное значение — только
|
||||
в stage-env, читается из `~/.fm-watchdog/stage-shared-password`).
|
||||
Включается только через env-переменные `QuickLogin__Enabled=true` в
|
||||
stage-compose. Жёсткий предохранитель: если `OpenIddict:Issuer`
|
||||
указывает на admin.food-market.kz — быстрый вход принудительно
|
||||
выключен и в лог Warning.
|
||||
Commit: `1f70d90`.
|
||||
- [x] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword`
|
||||
работает как второй вход рядом со своим паролем. Неактивные/без-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
|
||||
|
||||
# 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
|
||||
не работает, 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-удобство: на стенде не нужно каждый раз вводить логин+пароль.
|
||||
Выбираешь карточку аккаунта на `/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
|
||||
|
|
@ -15,25 +26,33 @@
|
|||
|
||||
```yaml
|
||||
QuickLogin:
|
||||
Enabled: false # default — выключено
|
||||
MasterPassword: "123456"
|
||||
Enabled: false # default — выключено
|
||||
MasterPassword: "" # default пустой; реальный секрет — ТОЛЬКО в stage-env
|
||||
```
|
||||
|
||||
Включается через env в stage-compose:
|
||||
На стенде env в `~/food-market-stage/deploy/.env`:
|
||||
|
||||
```
|
||||
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`
|
||||
пароль `MasterPassword` принимается у ЛЮБОГО активного пользователя наравне
|
||||
с его собственным паролем. Константное сравнение (`CryptographicOperations.
|
||||
FixedTimeEquals`), чтобы не светить длину. Деактивированные (`IsActive=
|
||||
false`) не пускаются ни при каких условиях. `/connect/token` всё так же
|
||||
гонит через rate-limit, 2FA и org-check (архивная org отшивает).
|
||||
принимает ДВА варианта пароля наравне с собственным паролем юзера:
|
||||
1. **MasterPassword** (значение env'а) — для ручного/скриптового
|
||||
входа, когда у тебя есть сам пароль. Константное сравнение
|
||||
(`FixedTimeEquals`).
|
||||
2. **Sentinel** — публичная строка `__QUICK_LOGIN_PWLESS__`, которую
|
||||
шлёт SPA при клике по карточке. Сервер трактует её как «пустить
|
||||
этого юзера». Это позволяет SPA не хранить реальный пароль в
|
||||
JS-бандле.
|
||||
И тот и другой путь гейтованы `EffectivelyEnabled` — вне стенда оба
|
||||
отфутболиваются как любой другой чужой пароль.
|
||||
|
||||
- **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который
|
||||
возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio →
|
||||
|
|
@ -46,14 +65,17 @@ QUICK_LOGIN_MASTER_PASSWORD=123456
|
|||
|
||||
- `<QuickLoginBlock />` над формой логина сам опрашивает `/api/dev/quick-
|
||||
login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 →
|
||||
возвращает `null`, никакого UI не рисует. Клик по карточке → login с
|
||||
`MasterPassword`.
|
||||
возвращает `null`, никакого UI не рисует.
|
||||
- Клик по карточке → `POST /connect/token` с паролем **sentinel**
|
||||
`__QUICK_LOGIN_PWLESS__`. Реальный мастер-пароль в бандл не попадает —
|
||||
ротация пароля в env не требует rebuild'а веба.
|
||||
- `<StageBanner />` жёлтой полосой сверху всех страниц, когда эндпоинт
|
||||
доступен. При том же сигнале динамически добавляет `<meta name="robots"
|
||||
content="noindex,nofollow">` — чтобы Google не индексировал стенд.
|
||||
content="noindex,nofollow">`.
|
||||
|
||||
Один web-билд используется и в проде, и на стенде — секрет на проде не
|
||||
утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет.
|
||||
утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет, а
|
||||
sentinel сервером всё равно отвергается.
|
||||
|
||||
## Безопасность — предохранители
|
||||
|
||||
|
|
@ -61,50 +83,67 @@ QUICK_LOGIN_MASTER_PASSWORD=123456
|
|||
включить. В прод-compose её нет и не должно быть.
|
||||
2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`:
|
||||
если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО,
|
||||
без поддоменов), быстрый вход **принудительно выключен**. Это защищает
|
||||
от ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в
|
||||
прод-env по ошибке, но Issuer остался прод-овым → ничего не включилось,
|
||||
в лог Warning.
|
||||
без поддоменов), быстрый вход **принудительно выключен**. Защита от
|
||||
ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в прод-env
|
||||
по ошибке, но Issuer остался прод-овым → ничего не включилось, в
|
||||
лог Warning.
|
||||
3. **Пустой или кривой Issuer тоже отбивает.** Не рискуем в неизвестном
|
||||
окружении: если Issuer пустой/мусорный — считаем выключенным.
|
||||
4. **Rate-limit не ослаблен.** Master-password идёт через ту же защиту,
|
||||
что обычный password-grant (`UseAuthFormUsernameKey` + `UseRateLimiter`).
|
||||
5. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху
|
||||
`/connect/token` — master-password это не обходит.
|
||||
6. **Startup Warning.** Program.cs логирует одно решение на старте:
|
||||
окружении.
|
||||
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 Market»
|
||||
из `DevDataSeeder`, чтобы не ломать e2e).
|
||||
- Организацию **«Демо-магазин»** (идемпотентно).
|
||||
- Полный 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!` (унаследовано от шаблона), но
|
||||
мастер-пароль `123456` тоже работает.
|
||||
sentinel и master-password тоже работают.
|
||||
|
||||
## Чистка автотестового мусора
|
||||
## Как ротирнуть секрет-пароль
|
||||
|
||||
`HousekeepingJobs.PruneAutoTestEntitiesAsync` — Hangfire-recurring каждый
|
||||
день в 02:45 UTC. Удаляет автотестовые организации (regex-match по имени)
|
||||
и orphan-юзеры по email-паттерну старше 3 дней. Защищены: SuperAdmin,
|
||||
`admin@demo-market.local`, `demo-*@food-market.local`, `market@email.com`,
|
||||
«Demo Market» / «Демо-магазин».
|
||||
|
||||
Единоразовая зачистка той же логикой — `deploy/stage-prune-autotest.sql`.
|
||||
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' \
|
||||
|
|
@ -113,33 +152,39 @@ nns@192.168.1.190:$ cd ~/food-market-stage/deploy && docker compose -p food-mark
|
|||
```
|
||||
|
||||
API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт
|
||||
снова 404, master-password не работает. Карточки на `/login` исчезнут —
|
||||
`QuickLoginBlock` увидит 404 и вернёт `null`.
|
||||
снова 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
|
||||
# ожидается: 404 (или 502, если прод-API вообще не поднят)
|
||||
```
|
||||
|
||||
Любой другой код (200/403/500) — проблема.
|
||||
Любой `200` — проблема.
|
||||
|
||||
## Файлы
|
||||
|
||||
| Что | Где |
|
||||
|---|---|
|
||||
| Опции + safety-override | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` |
|
||||
| Master-password в token-grant | `src/food-market.api/Controllers/AuthorizationController.cs` |
|
||||
| Опции + 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 блок | `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` |
|
||||
| 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):** первоначальный ввод на тестовый стенд.
|
||||
- **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
|
||||
});
|
||||
|
||||
/// <summary>Константное сравнение пароля с мастер-ключом. Пустой пароль
|
||||
/// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно
|
||||
/// не пустить любого, кто прислал "" в деконфигурированном окружении.</summary>
|
||||
/// <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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(supplied)) return false;
|
||||
if (supplied == QuickLoginOptions.SentinelPassword) return true;
|
||||
if (string.IsNullOrEmpty(_quickLogin.MasterPassword)) return false;
|
||||
var a = System.Text.Encoding.UTF8.GetBytes(supplied);
|
||||
var b = System.Text.Encoding.UTF8.GetBytes(_quickLogin.MasterPassword);
|
||||
|
|
|
|||
|
|
@ -20,8 +20,23 @@ public sealed class QuickLoginOptions
|
|||
/// <summary>Единый пароль-ключ для стенда. При включённом флаге подходит
|
||||
/// к ЛЮБОМУ активному аккаунту в <c>/connect/token</c> grant=password.
|
||||
/// Собственный пароль пользователя тоже продолжает работать (чтобы не
|
||||
/// ломать e2e, seeded admin и т.д.).</summary>
|
||||
public string MasterPassword { get; set; } = "123456";
|
||||
/// ломать e2e, seeded admin и т.д.).
|
||||
///
|
||||
/// Значение задаётся в 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. Используй
|
||||
/// везде, где решается «пускать мастер-пароль / отдавать список». Чтение
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import { Logo } from '@/components/Logo'
|
|||
import { QuickLoginBlock } from '@/components/QuickLoginBlock'
|
||||
import { validateEmail, messages } from '@/lib/validation'
|
||||
|
||||
/** Мастер-пароль «быстрого входа» — должен совпадать с server-side
|
||||
* QuickLoginOptions.MasterPassword. Используется только на стенде;
|
||||
* в проде блок быстрого входа не показывается, поэтому значение не утекает. */
|
||||
const QUICK_LOGIN_MASTER_PASSWORD = '123456'
|
||||
/** Публичный sentinel-«пароль» для клика по карточке быстрого входа.
|
||||
* Должен совпадать с server-side QuickLoginOptions.SentinelPassword.
|
||||
* Реальный мастер-пароль живёт ТОЛЬКО в server-env (QuickLogin__
|
||||
* MasterPassword) — в JS-бандл он не попадает, и наш билд не меняется
|
||||
* при ротации пароля. Вне стенда сервер отфутболивает sentinel как
|
||||
* обычный невалидный пароль (блок всё равно не рендерится, т.к.
|
||||
* /api/dev/quick-login/accounts отдаёт 404). */
|
||||
const QUICK_LOGIN_SENTINEL = '__QUICK_LOGIN_PWLESS__'
|
||||
|
||||
interface MeResp { roles: string[] }
|
||||
|
||||
|
|
@ -51,18 +55,19 @@ export function LoginPage() {
|
|||
}
|
||||
}
|
||||
|
||||
/** Клик по карточке «быстрого входа»: подставляем email + мастер-пароль
|
||||
* и сразу шлём /connect/token. На проде этот путь недоступен — блок
|
||||
* QuickLoginBlock рендерится только после 200 от GET /accounts, которого
|
||||
* в проде нет (404). */
|
||||
/** Клик по карточке «быстрого входа»: подставляем email + sentinel и
|
||||
* сразу шлём /connect/token. Сервер при EffectivelyEnabled=true
|
||||
* трактует sentinel как мастер-пароль, но сам пароль в бандл не ушёл.
|
||||
* На проде этот путь недоступен — блок QuickLoginBlock рендерится
|
||||
* только после 200 от GET /accounts, которого в проде нет (404). */
|
||||
async function handleQuickLogin(pickedEmail: string) {
|
||||
setEmail(pickedEmail)
|
||||
setPassword(QUICK_LOGIN_MASTER_PASSWORD)
|
||||
setPassword('') // форму НЕ наполняем — чтобы sentinel не светился в полях
|
||||
setEmailErr(null); setPasswordErr(null)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await login(pickedEmail, QUICK_LOGIN_MASTER_PASSWORD)
|
||||
await login(pickedEmail, QUICK_LOGIN_SENTINEL)
|
||||
let target = from
|
||||
try {
|
||||
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) {
|
||||
const api = makeClient()
|
||||
// SPA-эквивалент: шлём sentinel вместо реального мастер-пароля (который
|
||||
// живёт только в server-env, не в бандле). Сервер при EffectivelyEnabled
|
||||
// трактует sentinel как «пустить». Тест реалистичен — именно так SPA
|
||||
// ходит, когда юзер жмёт карточку.
|
||||
const SENTINEL = '__QUICK_LOGIN_PWLESS__'
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
username: 'demo-cashier@food-market.local',
|
||||
password: '123456',
|
||||
password: SENTINEL,
|
||||
client_id: 'food-market-web',
|
||||
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'
|
||||
check(step, {
|
||||
kind: 'api',
|
||||
description: 'master-пароль "123456" пускает demo-cashier → 200 + access_token',
|
||||
description: 'sentinel пускает demo-cashier → 200 + access_token',
|
||||
ok,
|
||||
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({
|
||||
step: 'ql02',
|
||||
severity: 'critical',
|
||||
title:'master-пароль не работает для demo-cashier',
|
||||
detail: 'QuickLogin:MasterPassword на стенде должен быть "123456", и demo-cashier активен.',
|
||||
title: 'sentinel не пускает demo-cashier',
|
||||
detail: 'QuickLogin:Enabled должен быть true. demo-cashier активен. Проверь, что QuickLoginOptions.SentinelPassword на бэке совпадает с клиентским.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ steps:
|
|||
- id: ql01_stage_endpoint_returns_200
|
||||
title: GET /api/dev/quick-login/accounts (stage) → 200 + SuperAdmin + demo-trio в списке
|
||||
- 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
|
||||
title: GET https://admin.food-market.kz/api/dev/quick-login/accounts → 404 (safety-override)
|
||||
- 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");
|
||||
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>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
|
|
@ -83,16 +97,16 @@ public async Task Disabled_returns_404_and_rejects_master_password()
|
|||
var body = await token.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().Should().Be("invalid_grant");
|
||||
|
||||
// Правильный пароль у admin'а — sanity-check, что backend вообще отвечает OK.
|
||||
var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
// Sentinel при выключенном флаге — тоже не пропускается.
|
||||
var sentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "password",
|
||||
["username"] = "admin@food-market.local",
|
||||
["password"] = "Admin12345!",
|
||||
["password"] = QuickLoginOptions.SentinelPassword,
|
||||
["client_id"] = "food-market-web",
|
||||
["scope"] = "openid profile email roles api",
|
||||
}));
|
||||
ok.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
sentinel.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -136,6 +150,18 @@ public async Task Enabled_at_runtime_allows_master_for_active_and_rejects_inacti
|
|||
}));
|
||||
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-пароль его НЕ пускает.
|
||||
using var scope = _factory.Services.CreateScope();
|
||||
var mgr = scope.ServiceProvider.GetRequiredService<UserManager<User>>();
|
||||
|
|
|
|||
Loading…
Reference in a new issue