From e1718ec972af9d4ddc03188e9d4390bf4dc91927 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 4 Oct 2026 21:02:36 +0500 Subject: [PATCH] =?UTF-8?q?feat(s30):=20sentinel=20pwd=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20SPA=20=E2=80=94=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80-=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=B2=20server-env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Координатор отротировал мастер-пароль стенда: теперь значение живёт в ~/.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 --- deploy/docker-compose.yml | 15 +- docs/quick-login-progress.md | 40 +++-- docs/quick-login.md | 141 ++++++++++++------ .../Controllers/AuthorizationController.cs | 18 ++- .../Infrastructure/QuickLoginOptions.cs | 19 ++- src/food-market.web/src/pages/LoginPage.tsx | 25 ++-- .../e2e/scenarios/stage-quick-login.steps.ts | 13 +- tests/e2e/scenarios/stage-quick-login.yml | 2 +- .../QuickLoginTests.cs | 34 ++++- 9 files changed, 221 insertions(+), 86 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index d670f4c..e90d4ea 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md index b533833..0422733 100644 --- a/docs/quick-login-progress.md +++ b/docs/quick-login-progress.md @@ -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`. diff --git a/docs/quick-login.md b/docs/quick-login.md index 0e48dd7..6fc6539 100644 --- a/docs/quick-login.md +++ b/docs/quick-login.md @@ -1,12 +1,23 @@ # Quick Login — быстрый вход на тестовом стенде -**Статус:** включено на `https://test.admin.food-market.kz` (стенд). +**Статус:** включено на `https://test.admin.food-market.kz` (стенд). **Прод:** `https://admin.food-market.kz` — выключено, попытки активации принудительно блокируются safety-override'ом в коде. Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль. Выбираешь карточку аккаунта на `/login`, клик — ты внутри. +## Доступ к стенду + +Стенд `test.admin.food-market.kz` для публичного интернета закрыт +HTTP basic-auth на host-уровне nginx (логин `food-market`). Из внутренней +сети `192.168.1.x` (dev-vm, prod-vm) basic-auth не требуется — e2e, +quality-watchdog и обычная разработка работают как раньше. + +Пароль basic-auth = пароль quick-login (чтобы не плодить секреты). Хранится +на dev-vm в файле `~/.fm-watchdog/stage-shared-password` (mode 600, не +коммитится). + ## Как это работает ### Server-side @@ -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 - `` над формой логина сам опрашивает `/api/dev/quick- login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 → - возвращает `null`, никакого UI не рисует. Клик по карточке → login с - `MasterPassword`. + возвращает `null`, никакого UI не рисует. +- Клик по карточке → `POST /connect/token` с паролем **sentinel** + `__QUICK_LOGIN_PWLESS__`. Реальный мастер-пароль в бандл не попадает — + ротация пароля в env не требует rebuild'а веба. - `` жёлтой полосой сверху всех страниц, когда эндпоинт доступен. При том же сигнале динамически добавляет `` — чтобы 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 бьют без + пароля. diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs index b9ba834..a3b4e82 100644 --- a/src/food-market.api/Controllers/AuthorizationController.cs +++ b/src/food-market.api/Controllers/AuthorizationController.cs @@ -205,12 +205,24 @@ or HttpContextTenantContext.OrganizationClaim [Parameters.ErrorDescription] = description }); - /// Константное сравнение пароля с мастер-ключом. Пустой пароль - /// и пустой мастер-ключ отфутболиваются ДО FixedTimeEquals, чтобы случайно - /// не пустить любого, кто прислал "" в деконфигурированном окружении. + /// «Пропустить этого юзера как по мастер-паролю». Срабатывает в + /// двух случаях: + /// + /// совпадает с + /// (константное + /// сравнение, FixedTimeEquals — не светит длину по времени); + /// равен публичному sentinel'у + /// . Это позволяет + /// SPA реализовать клик-по-карточке без того, чтобы реальный мастер- + /// пароль жил в JS-бандле: SPA шлёт известную строку, которую + /// сервер трактует «пустить». Безопасность sentinel'а равна + /// безопасности самого эндпоинта: оба гейтованы EffectivelyEnabled + /// (вне стенда — 400 invalid_grant как с любым чужим паролем). + /// 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); diff --git a/src/food-market.api/Infrastructure/QuickLoginOptions.cs b/src/food-market.api/Infrastructure/QuickLoginOptions.cs index fab4e88..e02090c 100644 --- a/src/food-market.api/Infrastructure/QuickLoginOptions.cs +++ b/src/food-market.api/Infrastructure/QuickLoginOptions.cs @@ -20,8 +20,23 @@ public sealed class QuickLoginOptions /// Единый пароль-ключ для стенда. При включённом флаге подходит /// к ЛЮБОМУ активному аккаунту в /connect/token grant=password. /// Собственный пароль пользователя тоже продолжает работать (чтобы не - /// ломать e2e, seeded admin и т.д.). - public string MasterPassword { get; set; } = "123456"; + /// ломать e2e, seeded admin и т.д.). + /// + /// Значение задаётся в env QuickLogin__MasterPassword. Если + /// переменная не задана — значение пустое и мастер-пароль-режим в + /// отбивает любую попытку (пустая строка + /// не матчится). + public string MasterPassword { get; set; } = ""; + + /// Публичный sentinel, который SPA отправляет в поле password + /// при клике по карточке быстрого входа. Он ПУБЛИЧЕН (строка в JS-бандле) + /// и сам по себе не секрет: сервер при =true + /// принимает sentinel как «пустить этого юзера» (эквивалент мастер-пароля) + /// без того, чтобы сам мастер-пароль утекал в бандл или в HTTP-ответы. + /// + /// Защита: вне стенда =false, sentinel + /// отбивается обычным не-подошёл-пароль как любой другой чужой пароль. + public const string SentinelPassword = "__QUICK_LOGIN_PWLESS__"; /// Эффективный флаг с учётом safety-override по Issuer. Используй /// везде, где решается «пускать мастер-пароль / отдавать список». Чтение diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index c44076d..3a17412 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -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('/api/me')).data diff --git a/tests/e2e/scenarios/stage-quick-login.steps.ts b/tests/e2e/scenarios/stage-quick-login.steps.ts index 12f8ae7..d62e220 100644 --- a/tests/e2e/scenarios/stage-quick-login.steps.ts +++ b/tests/e2e/scenarios/stage-quick-login.steps.ts @@ -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 } diff --git a/tests/e2e/scenarios/stage-quick-login.yml b/tests/e2e/scenarios/stage-quick-login.yml index 6fb2087..0a0cf97 100644 --- a/tests/e2e/scenarios/stage-quick-login.yml +++ b/tests/e2e/scenarios/stage-quick-login.yml @@ -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= → 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 diff --git a/tests/food-market.IntegrationTests/QuickLoginTests.cs b/tests/food-market.IntegrationTests/QuickLoginTests.cs index 7d68c00..2ae8b5c 100644 --- a/tests/food-market.IntegrationTests/QuickLoginTests.cs +++ b/tests/food-market.IntegrationTests/QuickLoginTests.cs @@ -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 + { + ["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 { ["grant_type"] = "password", @@ -83,16 +97,16 @@ public async Task Disabled_returns_404_and_rejects_master_password() var body = await token.Content.ReadFromJsonAsync(); body.GetProperty("error").GetString().Should().Be("invalid_grant"); - // Правильный пароль у admin'а — sanity-check, что backend вообще отвечает OK. - var ok = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + // Sentinel при выключенном флаге — тоже не пропускается. + var sentinel = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary { ["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 + { + ["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>();