diff --git a/docs/quick-login-progress.md b/docs/quick-login-progress.md new file mode 100644 index 0000000..2ed8f0f --- /dev/null +++ b/docs/quick-login-progress.md @@ -0,0 +1,61 @@ +# Quick Login — прогресс + +Задача владельца: на `test.admin.food-market.kz` не набирать логин/пароль руками. +Выбрать аккаунт из списка, пароль у всех один — `123456`. На `admin.food-market.kz` +этого нет никогда (несколько независимых предохранителей). + +Координатор отдельно проверил: на стейдже нет реальных секретов (МойСклад +токенов нет, ОФД-ключей нет, Telegram не привязан, SMTP пустой) — потому +открытый мастер-пароль на стенде безопасен. + +## Чек-лист + +- [x] 1. **Флаг конфигурации** — `QuickLogin:Enabled` (default false) + + `QuickLogin:MasterPassword` (default `123456`). Включается только через + env-переменные `QuickLogin__Enabled=true` в stage-compose. Жёсткий + предохранитель: если `OpenIddict:Issuer` указывает на admin.food-market.kz — + быстрый вход принудительно выключен и в лог Warning. +- [ ] 2. **Мастер-пароль** — password-grant: `QuickLogin:MasterPassword` + работает как второй вход рядом со своим паролем. Неактивные/без-org + пользователи — отшиваются как раньше. Rate-limit не ослаблять. +- [ ] 3. **API списка аккаунтов** — `GET /api/dev/quick-login/accounts` + (анонимный, 404 если выключен). Порядок: SuperAdmin → демо-* → остальные + «человеческие». Autotest-патéрны (cat31-*, visual-*, factory-smoke-*, mt6*, + quality-*, i18n*-*, auth2*-*, doc4*-*, rep5*-*, rt8*-*, twofa*-*, wiz9*-*, + a11y-*, mob1*-*, isoB*-*, axdet-*, smoke-*, ratetest-*, perf-*, probe-*, + load-*, s19-test-* и всё с суффиксом из ≥10 цифр) — скрыты. Лимит 50 + `?q=`. +- [ ] 4. **Демо-аккаунты** — идемпотентный сид «Демо-магазин» (через + существующий `DemoTenantSeeder`) + сотрудники с ролями Admin / Cashier / + Storekeeper (`demo-admin@food-market.local`, `demo-cashier@...`, + `demo-keeper@...`). Запускается только при Enabled=true. +- [ ] 5. **Страница входа (web)** — если `GET .../accounts` вернул 200 — над + обычной формой блок «Быстрый вход (тестовый стенд)» с карточками + поиск. + Клик — моментальный логин через мастер-пароль. На проде блока нет. +- [ ] 6. **Плашка «ТЕСТОВЫЙ СТЕНД»** — при Enabled=true жёлтая полоса + сверху + `` в index.html стенда. +- [ ] 7. **Чистка автотестового мусора** — SQL-скрипт для стейджа + расширение + `HousekeepingJobs.PruneQualityTestOrgsAsync` на новые autotest-патéрны, + чтобы cron 02:30 UTC гасил новые юзеры сам. Не трогать: SuperAdmin, + admin@demo-market.local, market@email.com, demo-* аккаунты. +- [ ] 8. **Тесты** — integration (3): Enabled=false → эндпоинт 404 + мастер- + пароль отвергается; Issuer=admin.food-market.kz → выключен даже с + Enabled=true; Enabled=true → мастер-пароль пускает активного и не пускает + неактивного. E2E stage: клик по демо-кассиру → меню кассира. Прод: `curl + https://admin.food-market.kz/api/dev/quick-login/accounts` → 404. +- [ ] 9. **Деплой + документация** — поднять env в stage-compose + (локальный репо + prod-vm `~/food-market-stage/deploy/docker-compose.yml`), + `~/deploy-stage.sh`, ретест, `docs/quick-login.md`. + +## Правила (сам себе) + +- Мастер-пароль и список аккаунтов — **никогда не на проде**. По умолчанию + выключено, Issuer=admin.* — выключено независимо от флага. +- После каждого пункта: build + соответствующие тесты + коммит порцией + + `[x]` в этом файле. +- Не трогать: прод admin.food-market.kz (кроме read-only проверки 404), + `global.json`, POS WPF, gateway nginx. + +## Условие остановки + +9 пунктов `[x]`, тесты зелёные, стейдж задеплоен, прод-эндпоинт 404 → +итог в этом файле + `touch ~/.fm-watchdog/DONE`. diff --git a/src/food-market.api/Infrastructure/QuickLoginOptions.cs b/src/food-market.api/Infrastructure/QuickLoginOptions.cs new file mode 100644 index 0000000..126d450 --- /dev/null +++ b/src/food-market.api/Infrastructure/QuickLoginOptions.cs @@ -0,0 +1,83 @@ +namespace foodmarket.Api.Infrastructure; + +/// Опции «быстрого входа» на тестовом стенде. По умолчанию выключен. +/// Включается ТОЛЬКО переменной окружения QuickLogin__Enabled=true +/// в stage-compose. В прод-compose этой переменной нет — флаг остаётся +/// в false и ни эндпоинт списка аккаунтов, ни «мастер-пароль» не включаются. +/// +/// Жёсткий предохранитель : даже если флаг +/// случайно пробросили в прод, но OpenIddict:Issuer указывает на +/// admin.food-market.kz — быстрый вход всё равно выключен. Это защищает от +/// ошибок конфигурации (ошибочный set-env на prod-env) — конфиг-опечатка +/// не должна открыть прод. +public sealed class QuickLoginOptions +{ + /// Главный флаг. При false любой код, относящийся к quick-login, + /// должен вести себя как в проде (эндпоинт 404, мастер-пароль не работает, + /// плашка не рендерится). + public bool Enabled { get; set; } = false; + + /// Единый пароль-ключ для стенда. При включённом флаге подходит + /// к ЛЮБОМУ активному аккаунту в /connect/token grant=password. + /// Собственный пароль пользователя тоже продолжает работать (чтобы не + /// ломать e2e, seeded admin и т.д.). + public string MasterPassword { get; set; } = "123456"; + + /// Эффективный флаг с учётом safety-override по Issuer. Используй + /// везде, где решается «пускать мастер-пароль / отдавать список». Чтение + /// сравнивает Issuer с prod-доменом admin.food-market.kz — если совпадает + /// (или Issuer пустой), быстрый вход считается выключенным и в лог + /// пишется Warning один раз на старте через . + public bool EffectivelyEnabled(string? issuer, out string reason) + { + if (!Enabled) + { + reason = "QuickLogin:Enabled=false"; + return false; + } + + // Issuer обязателен — если его нет, не рискуем: dev/stage без явного + // Issuer не должны открывать мастер-пароль, иначе любое неверно + // настроенное окружение превращается в стенд. + if (string.IsNullOrWhiteSpace(issuer)) + { + reason = "OpenIddict:Issuer пустой — не могу подтвердить, что это стенд"; + return false; + } + + // Прод-домен в lower-case: Uri сам нормализует хост, но подстрахуем. + if (!Uri.TryCreate(issuer, UriKind.Absolute, out var uri)) + { + reason = $"OpenIddict:Issuer '{issuer}' не парсится как Uri"; + return false; + } + var host = uri.Host.ToLowerInvariant(); + if (host == "admin.food-market.kz" || host.EndsWith(".admin.food-market.kz")) + { + reason = $"OpenIddict:Issuer host={host} — это ПРОД, быстрый вход принудительно выключен"; + return false; + } + + reason = $"OpenIddict:Issuer host={host} — стенд, быстрый вход включён"; + return true; + } + + /// Одноразовый лог при старте приложения. Пишется в лог, чтобы + /// в проде всегда было видно: «флаг пришёл, но Issuer=prod, выключили». + /// Вызывается из Program.cs после Build(). + public void LogSafetyDecision(ILogger logger, string? issuer) + { + var effective = EffectivelyEnabled(issuer, out var reason); + if (effective) + { + logger.LogWarning("QuickLogin включён для стенда: {Reason}. " + + "Это норма для test.admin.food-market.kz. В проде должно быть выключено.", reason); + } + else if (Enabled) + { + // Enabled=true, но effective=false — явный safety-переключатель. Это серьёзно. + logger.LogWarning("QuickLogin:Enabled=true, но быстрый вход НЕ активирован. {Reason}", reason); + } + // Если Enabled=false — молчим, это обычный прод. + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index dac798c..afe63b7 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -38,6 +38,11 @@ builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); + // Sprint 30: «быстрый вход» на тестовом стенде. Dev-only флаг, выключен + // по умолчанию, с safety-override по Issuer (см. QuickLoginOptions). + builder.Services.Configure( + builder.Configuration.GetSection("QuickLogin")); + // Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig. // Используется в контроллерах через ProjectToType(MapsterConfig.Config) // для SQL-friendly проекций. IMapper в DI не регистрируем, потому что @@ -477,6 +482,17 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme var app = builder.Build(); + // Sprint 30: один раз на старте проверяем решение по quick-login + // (Enabled+Issuer). Пишет Warning и в прод-env, и в стенде, чтобы в логах + // всегда было видно, в каком режиме стартанули. + { + var ql = app.Services.GetRequiredService>().Value; + var issuer = builder.Configuration["OpenIddict:Issuer"]; + ql.LogSafetyDecision(app.Services.GetRequiredService() + .CreateLogger("QuickLogin"), issuer); + } + // HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development // обычно работаем по http://localhost:5081, и Strict-Transport-Security // прибил бы локальный браузер на год. По дефолту 365 дней + preload.