diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3ea2a0f..d670f4c 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -39,6 +39,13 @@ services: # e2e/smoke; в prod'е оставляем дефолты 3/час, 10/сутки. 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). + QuickLogin__Enabled: ${QUICK_LOGIN_ENABLED:-false} + QuickLogin__MasterPassword: ${QUICK_LOGIN_MASTER_PASSWORD:-123456} # 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.md b/docs/quick-login.md new file mode 100644 index 0000000..0e48dd7 --- /dev/null +++ b/docs/quick-login.md @@ -0,0 +1,145 @@ +# Quick Login — быстрый вход на тестовом стенде + +**Статус:** включено на `https://test.admin.food-market.kz` (стенд). +**Прод:** `https://admin.food-market.kz` — выключено, попытки активации +принудительно блокируются safety-override'ом в коде. + +Это dev-удобство: на стенде не нужно каждый раз вводить логин+пароль. +Выбираешь карточку аккаунта на `/login`, клик — ты внутри. + +## Как это работает + +### Server-side + +Опции в `appsettings.json` / env: + +```yaml +QuickLogin: + Enabled: false # default — выключено + MasterPassword: "123456" +``` + +Включается через env в stage-compose: + +``` +QUICK_LOGIN_ENABLED=true +QUICK_LOGIN_MASTER_PASSWORD=123456 +``` + +Логика работает в двух местах: + +- **`POST /connect/token` (grant=password)** — при `EffectivelyEnabled=true` + пароль `MasterPassword` принимается у ЛЮБОГО активного пользователя наравне + с его собственным паролем. Константное сравнение (`CryptographicOperations. + FixedTimeEquals`), чтобы не светить длину. Деактивированные (`IsActive= + false`) не пускаются ни при каких условиях. `/connect/token` всё так же + гонит через rate-limit, 2FA и org-check (архивная org отшивает). + +- **`GET /api/dev/quick-login/accounts`** — анонимный эндпоинт, который + возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio → + остальные. Автотестовые логины отсеиваются по regex-патéрнам (всё с + суффиксом из 10+ цифр или prefix cat31-/visual-/factory-smoke-/mt6*/ + quality-/...). Поиск `?q=` фильтрует по email/ФИО/роли/магазину. + На проде эндпоинт отдаёт 404 — наличие пути не палит окружение. + +### Client-side (SPA) + +- `` над формой логина сам опрашивает `/api/dev/quick- + login/accounts`. 200 → рендерит жёлтый блок с карточками + поиск. 404 → + возвращает `null`, никакого UI не рисует. Клик по карточке → login с + `MasterPassword`. +- `` жёлтой полосой сверху всех страниц, когда эндпоинт + доступен. При том же сигнале динамически добавляет `` — чтобы Google не индексировал стенд. + +Один web-билд используется и в проде, и на стенде — секрет на проде не +утекает: `/api/dev/quick-login/accounts` там 404, значит блока нет. + +## Безопасность — предохранители + +1. **Enabled=false по умолчанию.** Нужна явная env-переменная, чтобы + включить. В прод-compose её нет и не должно быть. +2. **Issuer safety-override.** В `QuickLoginOptions.EffectivelyEnabled`: + если `OpenIddict:Issuer` указывает на `admin.food-market.kz` (ТОЧНО, + без поддоменов), быстрый вход **принудительно выключен**. Это защищает + от ошибки конфигурации — поставили `QUICK_LOGIN_ENABLED=true` в + прод-env по ошибке, но Issuer остался прод-овым → ничего не включилось, + в лог Warning. +3. **Пустой или кривой Issuer тоже отбивает.** Не рискуем в неизвестном + окружении: если Issuer пустой/мусорный — считаем выключенным. +4. **Rate-limit не ослаблен.** Master-password идёт через ту же защиту, + что обычный password-grant (`UseAuthFormUsernameKey` + `UseRateLimiter`). +5. **Неактивные пользователи не пускаются.** Проверка `IsActive` сверху + `/connect/token` — master-password это не обходит. +6. **Startup Warning.** Program.cs логирует одно решение на старте: + «включён для стенда» или «Enabled=true, но безопасность выключила». + В логах прод-инстанса всегда видно, что происходит. + +## Демо-аккаунты + +`QuickLoginDemoSeeder` при старте с `EffectivelyEnabled=true` поднимает: + +- Организацию **«Демо-магазин»** (идемпотентно; отдельная от «Demo Market» + из `DevDataSeeder`, чтобы не ломать e2e). +- Полный demo-каталог через `DemoTenantSeeder` (50 товаров / 30 продаж / + приёмки / контрагенты). +- Трио учёток с ролями: + - `demo-admin@food-market.local` → `Admin` + - `demo-cashier@food-market.local` → `Cashier` + - `demo-keeper@food-market.local` → `Storekeeper` + +Собственный пароль у них `Admin12345!` (унаследовано от шаблона), но +мастер-пароль `123456` тоже работает. + +## Чистка автотестового мусора + +`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`. + +## Как выключить обратно + +На стенде: + +```bash +# на prod-vm +nns@192.168.1.190:$ sed -i '/^QUICK_LOGIN_ENABLED=/d; /^QUICK_LOGIN_MASTER_PASSWORD=/d' \ + ~/food-market-stage/deploy/.env +nns@192.168.1.190:$ cd ~/food-market-stage/deploy && docker compose -p food-market-stage up -d --force-recreate api +``` + +API перестартует, `EffectivelyEnabled` увидит `Enabled=false`, эндпоинт +снова 404, 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 +``` + +Любой другой код (200/403/500) — проблема. + +## Файлы + +| Что | Где | +|---|---| +| Опции + safety-override | `src/food-market.api/Infrastructure/QuickLoginOptions.cs` | +| Master-password в 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` | +| Плашка стенда | `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}` | + +## История + +- **2026-10-04 (Sprint 30):** первоначальный ввод на тестовый стенд. diff --git a/src/food-market.api/Infrastructure/QuickLoginOptions.cs b/src/food-market.api/Infrastructure/QuickLoginOptions.cs index 126d450..fab4e88 100644 --- a/src/food-market.api/Infrastructure/QuickLoginOptions.cs +++ b/src/food-market.api/Infrastructure/QuickLoginOptions.cs @@ -52,7 +52,10 @@ public bool EffectivelyEnabled(string? issuer, out string reason) return false; } var host = uri.Host.ToLowerInvariant(); - if (host == "admin.food-market.kz" || host.EndsWith(".admin.food-market.kz")) + // ТОЛЬКО точное совпадение с прод-хостом. Любой поддомен (test.admin.*, + // dev.admin.*) — это стенд, его трогать не надо. Если в будущем + // появится второй прод-хост, сюда дописывается новое условие. + if (host == "admin.food-market.kz") { reason = $"OpenIddict:Issuer host={host} — это ПРОД, быстрый вход принудительно выключен"; return false;