food-market/docs/quick-login.md
nns e1718ec972
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
feat(s30): sentinel pwd для SPA — реальный мастер-пароль только в server-env
Координатор отротировал мастер-пароль стенда: теперь значение живёт в
~/.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>
2026-10-04 21:02:36 +05:00

11 KiB
Raw Blame History

Quick Login — быстрый вход на тестовом стенде

Статус: включено на 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

Опции в appsettings.json / env:

QuickLogin:
  Enabled: false        # default — выключено
  MasterPassword: ""    # default пустой; реальный секрет — ТОЛЬКО в stage-env

На стенде env в ~/food-market-stage/deploy/.env:

QUICK_LOGIN_ENABLED=true
QUICK_LOGIN_MASTER_PASSWORD=<значение из ~/.fm-watchdog/stage-shared-password>

Пароль прокидывается в контейнер build'ом из файла и в репозиторий не попадает. Прод-compose этой переменной не содержит, флаг остаётся false.

Логика срабатывает в двух местах:

  • POST /connect/token (grant=password) — при EffectivelyEnabled=true принимает ДВА варианта пароля наравне с собственным паролем юзера:

    1. MasterPassword (значение env'а) — для ручного/скриптового входа, когда у тебя есть сам пароль. Константное сравнение (FixedTimeEquals).
    2. Sentinel — публичная строка __QUICK_LOGIN_PWLESS__, которую шлёт SPA при клике по карточке. Сервер трактует её как «пустить этого юзера». Это позволяет SPA не хранить реальный пароль в JS-бандле. И тот и другой путь гейтованы EffectivelyEnabled — вне стенда оба отфутболиваются как любой другой чужой пароль.
  • GET /api/dev/quick-login/accounts — анонимный эндпоинт, который возвращает «человеческие» аккаунты для карточек: SuperAdmin → demo-trio → остальные. Автотестовые логины отсеиваются по regex-патéрнам (всё с суффиксом из 10+ цифр или prefix cat31-/visual-/factory-smoke-/mt6*/ quality-/...). Поиск ?q= фильтрует по email/ФИО/роли/магазину. На проде эндпоинт отдаёт 404 — наличие пути не палит окружение.

Client-side (SPA)

  • <QuickLoginBlock /> над формой логина сам опрашивает /api/dev/quick- login/accounts. 200 → рендерит жёлтый блок с карточками + поиск. 404 → возвращает null, никакого UI не рисует.
  • Клик по карточке → POST /connect/token с паролем sentinel __QUICK_LOGIN_PWLESS__. Реальный мастер-пароль в бандл не попадает — ротация пароля в env не требует rebuild'а веба.
  • <StageBanner /> жёлтой полосой сверху всех страниц, когда эндпоинт доступен. При том же сигнале динамически добавляет <meta name="robots" content="noindex,nofollow">.

Один web-билд используется и в проде, и на стенде — секрет на проде не утекает: /api/dev/quick-login/accounts там 404, значит блока нет, а sentinel сервером всё равно отвергается.

Безопасность — предохранители

  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 тоже отбивает. Не рискуем в неизвестном окружении.
  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-каталог через 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! (унаследовано от шаблона), но sentinel и master-password тоже работают.

Как ротирнуть секрет-пароль

  1. На dev-vm обновить ~/.fm-watchdog/stage-shared-password (mode 600).
  2. На prod-vm обновить ~/food-market-stage/deploy/.env: QUICK_LOGIN_MASTER_PASSWORD=<новое значение>. Удобно без эха:
    # с 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 не меняется.

Как выключить обратно

# на 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, sentinel и master-password не работают. Карточки на /login исчезнут — QuickLoginBlock увидит 404 и вернёт null.

Проверить что прод не задет

curl -s -o /dev/null -w "%{http_code}\n" \
  https://admin.food-market.kz/api/dev/quick-login/accounts
# ожидается: 404 (или 502, если прод-API вообще не поднят)

Любой 200 — проблема.

Файлы

Что Где
Опции + 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 блок (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, часть 1): первоначальный ввод на тестовый стенд.
  • 2026-10-04 (Sprint 30, часть 2): секрет-пароль вынесен в ~/.fm- watchdog/stage-shared-password (вне репо); SPA перешла на sentinel — реальный пароль в JS-бандл больше не попадает; стенд закрыт basic-auth на host-nginx для внешнего интернета, внутренние 192.168.1.x бьют без пароля.