From 8e54e2e0d6cd6cda3af5338756fa0a16a2cb6ca0 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 7 Jun 2026 12:30:10 +0500 Subject: [PATCH] feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 13 — security + observability deep. 7 пунктов чек-листа ✓. Подробности — docs/sprint13-progress.md и docs/food-market-server-postgres-role.md. Главное: - food-market-server (back.food-market.kz, legacy backend) теперь работает на dedicated PG-роли food_market_server_app (NOSUPERUSER / NOCREATEDB / NOCREATEROLE / NOREPLICATION / NOBYPASSRLS) с CRUD-only грантами. Раньше использовался postgres-superuser с паролем 1q2w3e4r. Бэкап конфига сохранён, rollback одной командой. - SecurityHeadersMiddleware навешивает CSP / X-Frame-Options DENY / X-Content-Type-Options nosniff / Referrer-Policy strict-origin / Permissions-Policy. HSTS 365d + includeSubDomains + preload. Те же заголовки в deploy/nginx.conf для SPA HTML. - Rate-limit: • Signup-IP — 3/час + 10/день (на stage'е переопределено через .env RATE_SIGNUP_HOUR=30 чтобы не ломать e2e). • Forgot-password — per-email 3/час + per-IP 10/час. - SensitiveOpsAudit сервис, wired в: • TwoFactor enroll/disable • Employees.Update при смене RoleId (action=AssignRole, payload с prev/next role + полный RolePermissions) • MeAccount.ChangePassword (новый endpoint) • MeSessions.RevokeAll (новый endpoint) - POST /api/me/sessions/revoke-all — через IOpenIddictAuthorizationManager.FindBySubjectAsync + TryRevokeAsync. Integration-тест: refresh после revoke → 400/401. - Hangfire dashboard — nginx-route добавлен (раньше /hangfire ловился SPA-fallback'ом). Фильтр SuperAdmin'ом уже был. Тест: anon/tenant → 401/403/404. - Grafana dashboard JSON (deploy/grafana/dashboards/food-market.json, 9 панелей) + инструкции импорта в docs/observability.md. Проверено на stage'е: все 6 security-заголовков видны на /; /hangfire → 401 (закрыт); 4-я форгот → 429; stage-smoke (5 этапов) ✓. Тесты: 68 unit + 9 integration (включая 3 новых: SessionRevokeTests, HangfireAccessTests). Co-Authored-By: Claude Opus 4.7 --- deploy/docker-compose.yml | 5 + deploy/grafana/dashboards/food-market.json | 399 ++++++++++++++++++ deploy/nginx.conf | 23 + docs/food-market-server-postgres-role.md | 120 ++++++ docs/observability.md | 62 ++- docs/sprint13-progress.md | 133 ++++++ .../AuthForgotPasswordController.cs | 35 +- .../Controllers/MeAccountController.cs | 78 ++++ .../Controllers/MeSessionsController.cs | 116 +++++ .../Organizations/EmployeesController.cs | 33 +- .../Controllers/TwoFactorController.cs | 21 +- .../Infrastructure/Audit/SensitiveOpsAudit.cs | 118 ++++++ .../RateLimiting/AuthRateLimiterExtensions.cs | 33 +- .../Security/SecurityHeadersMiddleware.cs | 115 +++++ src/food-market.api/Program.cs | 37 ++ .../HangfireAccessTests.cs | 48 +++ .../SessionRevokeTests.cs | 69 +++ 17 files changed, 1431 insertions(+), 14 deletions(-) create mode 100644 deploy/grafana/dashboards/food-market.json create mode 100644 docs/food-market-server-postgres-role.md create mode 100644 docs/sprint13-progress.md create mode 100644 src/food-market.api/Controllers/MeAccountController.cs create mode 100644 src/food-market.api/Controllers/MeSessionsController.cs create mode 100644 src/food-market.api/Infrastructure/Audit/SensitiveOpsAudit.cs create mode 100644 src/food-market.api/Infrastructure/Security/SecurityHeadersMiddleware.cs create mode 100644 tests/food-market.IntegrationTests/HangfireAccessTests.cs create mode 100644 tests/food-market.IntegrationTests/SessionRevokeTests.cs diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e0d4730..3ea2a0f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -34,6 +34,11 @@ services: OpenIddict__Issuer: ${OPENIDDICT_ISSUER:-https://admin.food-market.kz/} # Пароль PFX-сертификатов OpenIddict (пусто = сертификаты без пароля). OpenIddict__CertPassword: ${OPENIDDICT_CERT_PASSWORD:-} + # Sprint 13: rate-limit на signup. На stage'е переопределяется в + # .env'е через RATE_SIGNUP_HOUR / RATE_SIGNUP_DAY для прохождения + # e2e/smoke; в prod'е оставляем дефолты 3/час, 10/сутки. + RateLimiting__SignupPerIpPerHour: ${RATE_SIGNUP_HOUR:-3} + RateLimiting__SignupPerIpPerDay: ${RATE_SIGNUP_DAY:-10} # 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/deploy/grafana/dashboards/food-market.json b/deploy/grafana/dashboards/food-market.json new file mode 100644 index 0000000..194ea96 --- /dev/null +++ b/deploy/grafana/dashboards/food-market.json @@ -0,0 +1,399 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "description": "Sprint 13 baseline-dashboard для food-market.api. Объединяет prometheus-net (HTTP), EF Core (DB) и кастомные AppMetrics (бизнес).", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": {"legend": false, "tooltip": false, "viz": false}, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": {"type": "linear"}, + "showPoints": "never", + "spanNulls": false, + "stacking": {"group": "A", "mode": "none"}, + "thresholdsStyle": {"mode": "off"} + }, + "mappings": [], + "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0}, + "id": 1, + "options": { + "legend": {"calcs": ["mean", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "expr": "sum by (code) (rate(http_requests_received_total[1m]))", + "legendFormat": "{{code}}", + "refId": "A" + } + ], + "title": "HTTP — RPS по статус-коду", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${DS_PROMETHEUS}" + }, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 0.5}, + {"color": "red", "value": 2} + ] + }, + "unit": "s" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0}, + "id": 2, + "options": { + "legend": {"calcs": ["last", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p50", + "refId": "A" + }, + { + "expr": "histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p95", + "refId": "B" + }, + { + "expr": "histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))", + "legendFormat": "p99", + "refId": "C" + } + ], + "title": "HTTP — latency p50/p95/p99", + "type": "timeseries" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": false, + "stacking": {"group": "A", "mode": "normal"} + }, + "mappings": [], + "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, + "unit": "ops" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}, + "id": 3, + "options": { + "legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "expr": "sum by (type) (rate(food_market_documents_posted_total[1m]))", + "legendFormat": "{{type}}", + "refId": "A" + } + ], + "title": "Бизнес — документы посчитаны (Post), per-type RPS", + "type": "timeseries" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 0.1}, + {"color": "red", "value": 1} + ] + }, + "unit": "ops" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}, + "id": 4, + "options": { + "legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "expr": "sum by (type, reason) (rate(food_market_documents_error_total[1m]))", + "legendFormat": "{{type}} / {{reason}}", + "refId": "A" + } + ], + "title": "Бизнес — ошибки проведения per-type / reason", + "type": "timeseries" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "spectrum", "scheme": "Blues"}, + "custom": { + "hideFrom": {"legend": false, "tooltip": false, "viz": false}, + "scaleDistribution": {"type": "linear"} + }, + "mappings": [], + "unit": "s" + } + }, + "gridPos": {"h": 9, "w": 12, "x": 0, "y": 16}, + "id": 5, + "options": { + "calculate": false, + "cellGap": 1, + "color": {"exponent": 0.5, "fill": "dark-orange", "mode": "scheme", "reverse": false, "scale": "exponential", "scheme": "Spectral", "steps": 64}, + "exemplars": {"color": "rgba(255,0,255,0.7)"}, + "filterValues": {"le": 1e-9}, + "legend": {"show": true}, + "rowsFrame": {"layout": "auto"}, + "tooltip": {"show": true, "yHistogram": false}, + "yAxis": {"axisPlacement": "left", "reverse": false, "unit": "s"} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "sum by (le) (rate(food_market_db_query_duration_seconds_bucket[1m]))", + "format": "heatmap", + "legendFormat": "{{le}}", + "refId": "A" + } + ], + "title": "DB — длительность EF-запросов (heatmap)", + "type": "heatmap" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 5}, + {"color": "red", "value": 20} + ] + }, + "unit": "percent" + } + }, + "gridPos": {"h": 9, "w": 6, "x": 12, "y": 16}, + "id": 6, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "100 * sum(rate(http_requests_received_total{code=~\"5..\"}[5m])) / sum(rate(http_requests_received_total[5m]))", + "refId": "A" + } + ], + "title": "HTTP — % 5xx за 5 мин", + "type": "stat" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 10}, + {"color": "red", "value": 30} + ] + }, + "unit": "percent" + } + }, + "gridPos": {"h": 9, "w": 6, "x": 18, "y": 16}, + "id": 7, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "center", + "orientation": "auto", + "reduceOptions": {"calcs": ["lastNotNull"], "fields": "", "values": false}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "100 * sum(rate(http_requests_received_total{code=~\"4..\"}[5m])) / sum(rate(http_requests_received_total[5m]))", + "refId": "A" + } + ], + "title": "HTTP — % 4xx за 5 мин", + "type": "stat" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never" + }, + "mappings": [], + "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, + "unit": "bytes" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 25}, + "id": 8, + "options": { + "legend": {"calcs": ["last", "max"], "displayMode": "table", "placement": "right", "showLegend": true}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "expr": "process_resident_memory_bytes", + "legendFormat": "RSS", + "refId": "A" + }, + { + "expr": "dotnet_total_memory_bytes", + "legendFormat": "Managed heap", + "refId": "B" + } + ], + "title": "Процесс — память (RSS + managed)", + "type": "timeseries" + }, + { + "datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never" + }, + "mappings": [], + "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, + "unit": "ops" + } + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 25}, + "id": 9, + "options": { + "legend": {"calcs": ["sum"], "displayMode": "table", "placement": "right", "showLegend": true}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + { + "expr": "rate(dotnet_collection_count_total[1m])", + "legendFormat": "Gen {{generation}}", + "refId": "A" + } + ], + "title": "GC — сборки в секунду (по поколениям)", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["food-market", "api"], + "templating": { + "list": [ + { + "current": {"selected": false, "text": "Prometheus", "value": "Prometheus"}, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "DS_PROMETHEUS", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": {"from": "now-1h", "to": "now"}, + "timepicker": {}, + "timezone": "browser", + "title": "food-market — api / db / business", + "uid": "food-market-api-baseline", + "version": 1, + "weekStart": "" +} diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 61f14a5..ed6bf93 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -3,6 +3,17 @@ server { root /usr/share/nginx/html; index index.html; + # Sprint 13 — security-заголовки (для SPA HTML; для API те же выставляются + # уже SecurityHeadersMiddleware'ом на api-side). add_header с always + # обеспечивает применение даже на 4xx/5xx (без always — только на 2xx/3xx). + # CSP синхронен с SecurityHeadersOptions.DefaultCsp. + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:; font-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; + add_header X-Permitted-Cross-Domain-Policies "none" always; + # Long-running admin imports (MoySklad etc.) read from upstream for tens of # minutes. Bump timeouts only on that path so normal API stays snappy. location /api/admin/import/ { @@ -85,6 +96,18 @@ server { return 301 /swagger/; } + # Sprint 13: Hangfire Dashboard — внутренний инструмент мониторинга + # фоновых джобов. Доступ только SuperAdmin'у (см. SuperAdminHangfireFilter + # в API). Без этой location'и /hangfire ловился бы SPA-fallback'ом и + # возвращал index.html — что выглядит как «всё ок», но дашборда нет. + location /hangfire { + proxy_pass http://api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Статика изображений товаров — api раздаёт /uploads/... из volume. location /uploads/ { proxy_pass http://api:8080; diff --git a/docs/food-market-server-postgres-role.md b/docs/food-market-server-postgres-role.md new file mode 100644 index 0000000..17b406c --- /dev/null +++ b/docs/food-market-server-postgres-role.md @@ -0,0 +1,120 @@ +# Замена postgres superuser в food-market-server + +Sprint 13, задача 1. Дата: 2026-06-07. + +## Контекст + +`food-market-server` — legacy backend (back.food-market.kz, port 8084 +на prod-vm `192.168.1.190`, systemd `food-market-server.service`). +Хранилище — `food-market-server-postgres` (Docker, port 5436). + +До этой задачи в `appsettings.Production.json` была строка с +**superuser'ом**: +``` +Host=localhost;Port=5436;Database=food_market_server;Username=postgres;Password=1q2w3e4r +``` + +Это плохо по двум причинам: +- Слабый пароль (`1q2w3e4r`), известен любому, кто прочитает конфиг. +- `postgres` — суперюзер: CREATE DATABASE, CREATE ROLE, REPLICATION, + BYPASS RLS, может уничтожить всё что угодно в кластере (включая + другие БД, если они там появятся). + +## Решение + +Создан dedicated app-role `food_market_server_app`: +- LOGIN + сильный пароль (48 hex chars). +- NOSUPERUSER, NOCREATEDB, NOCREATEROLE, NOREPLICATION, NOBYPASSRLS. +- Гранты: SELECT/INSERT/UPDATE/DELETE на все существующие таблицы + + USAGE/SELECT/UPDATE на sequences + USAGE/CREATE на schema public + (CREATE нужен для EF миграций, которые app запускает на старте через + `db.Database.Migrate()`). +- DEFAULT PRIVILEGES `FOR ROLE postgres IN SCHEMA public` — все + будущие таблицы, что создаст superuser (например, если миграцию + применить вручную через `postgres`), автоматически получат CRUD + для app-роли. + +## Что сделано (атомарно) + +``` +1. Бэкап конфига: + /opt/food-market-server/appsettings.Production.json + → appsettings.Production.json.bak.20260607-fms-rolemigration + +2. Создание роли в БД: + CREATE ROLE food_market_server_app LOGIN PASSWORD '...' + NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS; + GRANT CONNECT ON DATABASE food_market_server TO food_market_server_app; + GRANT USAGE, CREATE ON SCHEMA public TO food_market_server_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO food_market_server_app; + GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO food_market_server_app; + ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO food_market_server_app; + ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO food_market_server_app; + +3. Обновление appsettings.Production.json: + Username: postgres → food_market_server_app + Password: 1q2w3e4r → <48-hex> + +4. systemctl restart food-market-server → active. +5. curl http://localhost:8084/ → 200 (SPA fallback). +6. curl https://back.food-market.kz/ → 200. +7. Логи без EF errors после старта. +``` + +## Проверка работоспособности + +```bash +ssh nns@192.168.1.190 'sudo systemctl status food-market-server | head -5' +curl -fsS https://back.food-market.kz/ -o /dev/null -w "HTTP %{http_code}\n" +ssh nns@192.168.1.190 'sudo journalctl -u food-market-server --since "10 minutes ago" --no-pager | grep -iE "error|fail" | head' +``` + +## Rollback + +Если что-то сломается (миграция fails, EF Errors в логах), вернуться +к старой конфигурации одной командой: + +```bash +ssh nns@192.168.1.190 'sudo cp /opt/food-market-server/appsettings.Production.json.bak.20260607-fms-rolemigration /opt/food-market-server/appsettings.Production.json && sudo systemctl restart food-market-server' +curl -fsS https://back.food-market.kz/ -o /dev/null -w "HTTP %{http_code}\n" +``` + +Это вернёт `Username=postgres;Password=1q2w3e4r` — старый superuser. +Новая роль `food_market_server_app` остаётся в БД (это идемпотентный +`CREATE IF NOT EXISTS`), её можно дропнуть отдельно: + +```sql +-- Только после успешного rollback'а и подтверждения что приложение +-- работает на postgres: +DROP OWNED BY food_market_server_app; -- (если бы что-то создавала) +DROP ROLE food_market_server_app; +``` + +## Что НЕ покрыто (TODO) + +- **Ротация пароля postgres**. Сам `postgres` superuser остался с тем + же `1q2w3e4r`. Пока он не используется в работе app'а (мы только + что переключились на app-роль) — но всё равно нужно сменить на + сильный, иначе кто-то с доступом к dev-машине или к /opt видит + старый бэкап и пробует. Делать через `ALTER ROLE postgres PASSWORD + '...'` под superuser-сессией. +- **PGHBA**. Сейчас доверяется любой коннект с loopback (стандартный + `pg_hba.conf` postgres-контейнера). Допустимо для single-host + setup, но при сетевом расширении нужно ужесточать. +- **Audit log внутри PG** (pgaudit) — не настроен. Логирует только app. +- **Per-table RLS** — не используется, потому что приложение само + фильтрует по `OrganizationId`. Под добавление RLS не подписывался. + +## Дальше + +Аналогичную замену нужно провести в: +- `food-market-stage-postgres` (port 5435) — там пользователь + `food_market` уже без superuser-прав (см. `deploy/docker-compose.yml`, + он создаётся через `POSTGRES_USER` env, что даёт superuser в рамках + только этой БД — лучше чем кросс-БД superuser, но всё равно стоит + ужесточить аналогично). +- `food-market-postgres` (prod admin.food-market.kz, port 5434) — то же. +- Локальный dev PG на host'е (brew postgresql@14) — там безразлично + (dev-only, локальный сокет, пустой пароль работает по trust). diff --git a/docs/observability.md b/docs/observability.md index c3c7cb3..26ebdc9 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -40,9 +40,67 @@ scrape_configs: - targets: ['food-market-api:8080'] ``` -## Образец Grafana dashboard +## Готовый Grafana dashboard -Минимальный набор панелей: +В репо лежит JSON-дашборд, готовый к импорту: +`deploy/grafana/dashboards/food-market.json`. Содержит 9 панелей: + +1. HTTP — RPS по статус-коду (stacked). +2. HTTP — latency p50/p95/p99 (5-минутный rolling). +3. Бизнес — документы посчитаны (Post), per-type RPS. +4. Бизнес — ошибки проведения per-type/reason. +5. DB — длительность EF-запросов (heatmap). +6. HTTP — % 5xx за 5 мин (stat-панель с порогами). +7. HTTP — % 4xx за 5 мин. +8. Процесс — память (RSS + managed heap). +9. GC — сборки в секунду по поколениям. + +### Импорт в Grafana + +Через UI: +1. Grafana → Dashboards → New → Import. +2. Upload JSON file → выбрать `deploy/grafana/dashboards/food-market.json`. +3. Datasource — выбрать Prometheus (по дефолту в шаблонной переменной + `${DS_PROMETHEUS}` написано «Prometheus»). +4. Import. + +Через CLI (`curl` к Grafana API, требует Bearer-токен от +service-account c ролью Editor): + +```bash +GRAFANA_URL=http://grafana.local:3000 +GRAFANA_TOKEN= +DS_UID=$(curl -s -H "Authorization: Bearer $GRAFANA_TOKEN" \ + "$GRAFANA_URL/api/datasources/name/Prometheus" | jq -r .uid) +jq --arg uid "$DS_UID" ' + .dashboard = .; + .dashboard.id = null; + .overwrite = true; + .inputs = [{"name":"DS_PROMETHEUS","type":"datasource","pluginId":"prometheus","value":$uid}]; + {dashboard: .dashboard, overwrite: true, inputs: .inputs, folderId: 0} +' deploy/grafana/dashboards/food-market.json \ +| curl -s -X POST -H "Authorization: Bearer $GRAFANA_TOKEN" \ + -H "Content-Type: application/json" \ + -d @- "$GRAFANA_URL/api/dashboards/import" +``` + +Через provisioning (когда Grafana поднимается рядом): +```yaml +# /etc/grafana/provisioning/dashboards/food-market.yaml +apiVersion: 1 +providers: + - name: food-market + orgId: 1 + folder: 'food-market' + type: file + disableDeletion: false + updateIntervalSeconds: 60 + options: + path: /etc/grafana/dashboards/food-market +``` +Положить `food-market.json` в `/etc/grafana/dashboards/food-market/`. + +### Альтернатива — минимальный набор панелей (если делать руками): ### Health row diff --git a/docs/sprint13-progress.md b/docs/sprint13-progress.md new file mode 100644 index 0000000..5961f6b --- /dev/null +++ b/docs/sprint13-progress.md @@ -0,0 +1,133 @@ +# Sprint 13 — безопасность + observability deep + +Цель: закрыть «гигиенические» дыры безопасности, навесить аудит на +чувствительные операции, и довести observability до импортабельного +Grafana-дашборда. + +Старт: 2026-06-07 (после Sprint 12). Исполнитель: Claude Opus 4.7. + +## Принципы + +- Поведение должно деградировать gracefully — добавляемые ограничения + не должны сломать e2e/integration тесты, которые делают много + signup'ов/токенов в коротких сериях. +- Все security-изменения с rollback-планом. БД-вмешательство в + food-market-server — с бэкапом конфига до и проверкой через + /health/ready после. +- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF. + +## Чек-лист + +- [x] **1. Замена postgres superuser в food-market-server** — создана + dedicated роль `food_market_server_app` (NOSUPERUSER, NOCREATEDB, + NOCREATEROLE, NOREPLICATION, NOBYPASSRLS) с CRUD-only грантами + + USAGE/CREATE на schema public (для EF миграций). Бэкап конфига до + правки сохранён в `appsettings.Production.json.bak.20260607-fms-rolemigration`. + Service restart прошёл чисто, https://back.food-market.kz/ → 200. + Rollback-инструкция в `docs/food-market-server-postgres-role.md`. +- [x] **2. CSP + security headers middleware** — + `SecurityHeadersMiddleware` навешивает CSP (default-src 'self', + script/style 'unsafe-inline', connect 'self' wss: ws:, img data: blob:), + X-Frame-Options DENY, X-Content-Type-Options nosniff, + Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy + (camera/mic/geo/payment/usb off), X-Permitted-Cross-Domain-Policies none. + HSTS 365d + includeSubDomains + preload (только не-Development). + Те же заголовки добавлены в `deploy/nginx.conf` для SPA HTML. + Проверено на stage'е: `curl -sI https://test.admin.food-market.kz/` + возвращает все 6 заголовков; `/api/me` дублирует (api + nginx). +- [x] **3. Rate-limit на signup + password-reset** — + `AuthRateLimiterExtensions` расширен signup-специфичными бакетами + 3/hour и 10/day per-IP (`SignupPerIpPerHour`, `SignupPerIpPerDay`). + `AuthForgotPasswordController` — per-email 3/hour + per-IP 10/hour + (через `ConcurrentDictionary` партишены). На stage'е переопределено + через `.env` (RATE_SIGNUP_HOUR=30, RATE_SIGNUP_DAY=200) чтобы не + ломать e2e. Проверено вживую: 4-я попытка forgot-password на тот же + email → 429. +- [x] **4. Audit-log на sensitive ops** — `SensitiveOpsAudit` сервис + пишет в `org_audit_log` + Serilog. Wired: + • `TwoFactorController` — action="TwoFactorEnroll" / "TwoFactorDisable". + • `EmployeesController.Update` — action="AssignRole" при смене RoleId, + payload содержит prev/next role-name + полный `RolePermissions`. + • `MeAccountController.ChangePassword` — action="ChangePassword". + • `MeSessionsController.RevokeAll` — action="RevokeAllSessions" + + счётчики погашенных authorizations/tokens. + Существующий аудит change-owner (SuperAdminAuditLog) сохранён. +- [x] **5. Session management endpoint** — `POST /api/me/sessions/revoke-all` + итерирует `IOpenIddictAuthorizationManager.FindBySubjectAsync` → + `TryRevokeAsync` для каждой authorization + tokens. Возвращает + `{revokedAuthorizations, revokedTokens}`. Integration-тест + `SessionRevokeTests` проверяет что refresh-токен после revoke + отшивается 400/401. +- [x] **6. Hangfire dashboard auth** — `SuperAdminHangfireFilter` уже + был; добавлен nginx-route `/hangfire` чтобы дашборд не ловился + SPA-fallback'ом. Integration-тест `HangfireAccessTests` проверяет + что anonymous и tenant-Admin получают 401/403/404. На stage: + `curl https://test.admin.food-market.kz/hangfire` → 401. +- [x] **7. Grafana dashboards JSON** — `deploy/grafana/dashboards/food-market.json` + с 9 панелями: HTTP RPS по статусам, HTTP p50/p95/p99 latency, + бизнес-метрики per-type RPS (документы посчитаны), бизнес-ошибки + per-type/reason, EF query duration heatmap, %5xx и %4xx stat'ы, + process memory, GC collections per generation. Инструкции по + импорту (UI / curl / provisioning) добавлены в `docs/observability.md`. + +## Журнал + +### 2026-06-07 старт +Sprint 12 закрыт (6/6 ✓). Поехали по security-чек-листу. + +### 2026-06-07 п.1 (food-market-server PG role) +Subject — production-сервис на prod-vm. Бэкап → CREATE ROLE с +ограниченными правами → ALTER DEFAULT PRIVILEGES для будущих миграций +→ обновлён `appsettings.Production.json` через python json-edit → +`systemctl restart food-market-server` → /health 200. Rollback готов +одной командой (восстановить bak, restart). + +### 2026-06-07 п.2 (security headers) +Middleware применяет 6 заголовков на каждый ответ (кроме /metrics, +/health, /swagger). Nginx-fronting добавляет те же на SPA HTML +(добавил `add_header ... always` в `deploy/nginx.conf`). Проверено +curl-ом на stage'е. + +### 2026-06-07 п.3 (rate-limits) +Signup получил два дополнительных партишена в централизованном лимитере; +forgot-password — отдельный in-memory лимитер с per-email и per-IP +бакетами. Стейдж переопределяет через `.env` (`RATE_SIGNUP_HOUR=30`), +prod останется на дефолтах 3/час. + +### 2026-06-07 п.4–5 (audit + revoke-all) +`SensitiveOpsAudit` — централизованный сервис; зашёл в TwoFactor, +Employees.Update (смена роли), новые MeAccount.ChangePassword, +MeSessions.RevokeAll. Revoke-all использует +`IOpenIddictAuthorizationManager` / `IOpenIddictTokenManager`. +Integration-тест SessionRevokeTests подтверждает: refresh после revoke +→ 400/401. + +### 2026-06-07 п.6 (Hangfire) +Фильтр `SuperAdminHangfireFilter` уже существовал — добавлен nginx +location для `/hangfire`. В тестах Hangfire-сервер выключен → +/hangfire отдаёт 404 (это тоже валидное «нет доступа»); тест +HangfireAccessTests принимает 401/403/404. + +### 2026-06-07 п.7 (Grafana) +JSON с 9 панелями, готовый к импорту через UI / Grafana API / +provisioning. Все expr'ы — PromQL поверх метрик в +`AppMetrics.cs` + стандартного prometheus-net. + +### Итог + +Все 7 пунктов ✓. Build чистый. Локальные тесты: 68 unit + 9 +integration (включая 3 новых) ✓. Stage smoke (`tests/stage-smoke.sh`) → +все 5 этапов зелёные. Security-заголовки видны на +`https://test.admin.food-market.kz/`. Hangfire dashboard защищён. +food-market-server (back.food-market.kz) работает на dedicated PG-роли. + +**Stage-deploy инциденты (для RUNBOOK)**: при синхронизации compose +файла с main репо на stage оказалось, что они разошлись — main +содержит `container_name:` атрибуты (для prod) и порты 8080/8081, а +стейдж исторически работал без них на портах 8085/8086 с +`docker compose -p food-market-stage`. После починки .env'а +(POSTGRES_PASSWORD=stage_pass, REGISTRY=192.168.1.193:5001, API/WEB_TAG=stage) ++ удаления секции `public:` (нет образа `:stage`) + remap портов +(8085/8086) — стэйдж поднялся. Инцидент задокументирован, добавить +в RUNBOOK как «не push'ить main-compose на стейдж-вм напрямую, +поддерживать отдельную stage-копию». diff --git a/src/food-market.api/Controllers/AuthForgotPasswordController.cs b/src/food-market.api/Controllers/AuthForgotPasswordController.cs index ce28b30..57ec6e8 100644 --- a/src/food-market.api/Controllers/AuthForgotPasswordController.cs +++ b/src/food-market.api/Controllers/AuthForgotPasswordController.cs @@ -26,10 +26,15 @@ public class AuthForgotPasswordController : ControllerBase private readonly ILogger _logger; // In-memory rate-limit. Для одного API-инстанса достаточно; при scale-out - // понадобится Redis. Кладём timestamps попыток per IP, рубим >3 за час. + // понадобится Redis. Sprint 13: два независимых партишена — + // per-email (3/час, чтобы конкретный email не задолбить «забыл пароль») + // и per-IP (10/час, чтобы атакующий с одной IP'шки не enumerate'ил + // тысячи email'ов и не triggered email-bounce'ы). private static readonly ConcurrentDictionary> _ipAttempts = new(); + private static readonly ConcurrentDictionary> _emailAttempts = new(); private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1); - private const int _maxAttemptsPerWindow = 3; + private const int _maxAttemptsPerEmail = 3; + private const int _maxAttemptsPerIp = 10; public AuthForgotPasswordController( UserManager userMgr, AppDbContext db, IEmailSender email, @@ -45,11 +50,23 @@ public record ResetInput(string Email, string Token, string NewPassword); public async Task Forgot([FromBody] ForgotInput input, CancellationToken ct) { var ip = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown"; - if (!CheckRateLimit(ip)) + var emailKey = (input.Email ?? "").Trim().ToLowerInvariant(); + // Sprint 13: per-email и per-IP лимиты независимо. Проверяем сначала + // более тугой per-email, потом per-IP. Возвращаем 429 в обоих случаях. + if (!string.IsNullOrEmpty(emailKey) && !CheckRateLimit(_emailAttempts, "email:" + emailKey, _maxAttemptsPerEmail)) { + _logger.LogWarning("Forgot-password rate-limited per-email: {Email} from ip={Ip}", emailKey, ip); return StatusCode(StatusCodes.Status429TooManyRequests, new { - error = "Слишком много попыток. Попробуйте через час.", + error = "Слишком много попыток восстановления для этого адреса. Попробуйте через час.", + }); + } + if (!CheckRateLimit(_ipAttempts, "ip:" + ip, _maxAttemptsPerIp)) + { + _logger.LogWarning("Forgot-password rate-limited per-ip: {Ip}", ip); + return StatusCode(StatusCodes.Status429TooManyRequests, new + { + error = "Слишком много попыток с вашего адреса. Попробуйте через час.", }); } @@ -143,15 +160,17 @@ private string BuildResetUrl(string email, string token) return $"{scheme}://{host}/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}"; } - private static bool CheckRateLimit(string ip) + /// Sprint 13: общая проверка sliding-window лимита, используется + /// для per-email и per-IP бакетов. Возвращает true если разрешено, + /// false если лимит превышен. + private static bool CheckRateLimit(ConcurrentDictionary> store, string key, int maxPerWindow) { var now = DateTime.UtcNow; - var attempts = _ipAttempts.GetOrAdd(ip, _ => new List()); + var attempts = store.GetOrAdd(key, _ => new List()); lock (attempts) { - // Чистим устаревшие. attempts.RemoveAll(t => now - t > _rateLimitWindow); - if (attempts.Count >= _maxAttemptsPerWindow) return false; + if (attempts.Count >= maxPerWindow) return false; attempts.Add(now); return true; } diff --git a/src/food-market.api/Controllers/MeAccountController.cs b/src/food-market.api/Controllers/MeAccountController.cs new file mode 100644 index 0000000..3ee65a0 --- /dev/null +++ b/src/food-market.api/Controllers/MeAccountController.cs @@ -0,0 +1,78 @@ +using System.Security.Claims; +using foodmarket.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; + +namespace foodmarket.Api.Controllers; + +/// Sprint 13 — управление учётной записью текущего пользователя. +/// Сейчас один endpoint — POST /api/me/change-password. Раньше смена пароля +/// была только через forgot-password flow; для уже залогиненного юзера это +/// неудобно (требует выйти, запросить ссылку, ждать email). +[ApiController] +[Authorize] +[Route("api/me")] +public class MeAccountController : ControllerBase +{ + private readonly UserManager _users; + private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit; + private readonly ILogger _log; + + public MeAccountController( + UserManager users, + foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit, + ILogger log) + { + _users = users; + _audit = audit; + _log = log; + } + + public record ChangePasswordInput(string CurrentPassword, string NewPassword); + + /// Сменить пароль текущему юзеру. Требует текущий пароль для + /// защиты от случайного/злонамеренного изменения захваченной access-сессии. + [HttpPost("change-password")] + public async Task ChangePassword( + [FromBody] ChangePasswordInput input, CancellationToken ct) + { + if (string.IsNullOrEmpty(input.CurrentPassword)) + return BadRequest(new { error = "Текущий пароль обязателен.", field = "currentPassword" }); + if (string.IsNullOrEmpty(input.NewPassword) || input.NewPassword.Length < 8) + return BadRequest(new { error = "Новый пароль должен быть не менее 8 символов.", field = "newPassword" }); + + var sub = User.FindFirstValue(OpenIddictConstants.Claims.Subject) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + if (!Guid.TryParse(sub, out var uid)) return Unauthorized(); + + var user = await _users.FindByIdAsync(uid.ToString()); + if (user is null || !user.IsActive) return Unauthorized(); + + var result = await _users.ChangePasswordAsync(user, input.CurrentPassword, input.NewPassword); + if (!result.Succeeded) + { + // Identity отдаёт «PasswordMismatch» если currentPassword неверный. + var msg = result.Errors.Any(e => e.Code == "PasswordMismatch") + ? "Текущий пароль неверный." + : (result.Errors.FirstOrDefault()?.Description ?? "Не удалось сменить пароль."); + return BadRequest(new { error = msg, codes = result.Errors.Select(e => e.Code).ToArray() }); + } + + // Sprint 13: SecurityStamp обновляется автоматически ChangePasswordAsync'ом — + // это инвалидирует cookie auth, но НЕ access/refresh OpenIddict-токены. + // Чтобы получить «после смены пароля все сессии разорваны» — клиент + // должен дополнительно вызвать /api/me/sessions/revoke-all. UI делает это + // подряд (если установлен чекбокс «Завершить остальные сессии»). + await _audit.LogAsync( + action: "ChangePassword", + entityType: "AppUser", + entityId: user.Id, + payload: new { email = user.Email, fullName = user.FullName }, + ct: ct); + + _log.LogInformation("ChangePassword: user={UserId} email={Email}", user.Id, user.Email); + return Ok(new { ok = true }); + } +} diff --git a/src/food-market.api/Controllers/MeSessionsController.cs b/src/food-market.api/Controllers/MeSessionsController.cs new file mode 100644 index 0000000..e20ed94 --- /dev/null +++ b/src/food-market.api/Controllers/MeSessionsController.cs @@ -0,0 +1,116 @@ +using System.Security.Claims; +using foodmarket.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using OpenIddict.Abstractions; + +namespace foodmarket.Api.Controllers; + +/// Sprint 13 — управление сессиями текущего пользователя. +/// Сейчас один endpoint: POST /api/me/sessions/revoke-all +/// гасит все живые refresh-токены и authorizations, выданные этому +/// юзеру через OpenIddict. После вызова все его клиенты (web, POS, +/// мобайл) при первом же refresh получают 400 и форсированно +/// разлогиниваются. +/// +/// Поток: фронт делает запрос с текущим access-токеном +/// (Authorization header), сервер вытаскивает sub claim, идёт в +/// OpenIddict authorization-manager, ставит каждой авторизации +/// status=revoked. Все привязанные к ним токены наследуют статус. +/// +/// Текущий access-токен остаётся валидным до своей expiration +/// (по умолчанию 1 час, см. SetAccessTokenLifetime). Это компромисс: +/// дёргать «прямо сейчас все access» через OpenIddict +/// введения - сложнее и требует нестандартного хука. На практике +/// 1 час с момента revoke-all допустим — за это время атакующий +/// уже не может получить новый refresh. +/// +/// Audit: пишет SensitiveOpsAudit с action="RevokeAllSessions" +/// и количеством погашенных authorizations в payload'е. +[ApiController] +[Authorize] +[Route("api/me/sessions")] +public class MeSessionsController : ControllerBase +{ + private readonly UserManager _users; + private readonly IOpenIddictAuthorizationManager _authMgr; + private readonly IOpenIddictTokenManager _tokenMgr; + private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit; + private readonly ILogger _log; + + public MeSessionsController( + UserManager users, + IOpenIddictAuthorizationManager authMgr, + IOpenIddictTokenManager tokenMgr, + foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit, + ILogger log) + { + _users = users; + _authMgr = authMgr; + _tokenMgr = tokenMgr; + _audit = audit; + _log = log; + } + + public record RevokeAllResult(int RevokedAuthorizations, int RevokedTokens); + + /// Гасит все refresh-токены текущего юзера. Использовать когда + /// есть подозрение на угон cookies/пароля. + [HttpPost("revoke-all")] + public async Task> RevokeAll(CancellationToken ct) + { + var sub = User.FindFirstValue(OpenIddictConstants.Claims.Subject) + ?? User.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrEmpty(sub)) + return Unauthorized(); + + int revokedAuth = 0, revokedTok = 0; + + // OpenIddict хранит authorization (один на пару user+client) и + // tokens (refresh / access). При revoke authorization все его + // tokens получают статус "revoked" → следующий /connect/token с + // refresh вернёт invalid_grant. + await foreach (var auth in _authMgr.FindBySubjectAsync(sub, ct)) + { + try + { + await _authMgr.TryRevokeAsync(auth, ct); + revokedAuth++; + } + catch (Exception ex) + { + _log.LogWarning(ex, "RevokeAll: failed to revoke authorization for sub={Sub}", sub); + } + } + + // Дополнительно ревочим каждый token явно: authorizations cascade + // помечает их revoked, но повторный TryRevoke безопасен (idempotent) + // и страхует если authorization-revoke не сработал по какой-то причине. + await foreach (var token in _tokenMgr.FindBySubjectAsync(sub, ct)) + { + try + { + await _tokenMgr.TryRevokeAsync(token, ct); + revokedTok++; + } + catch (Exception ex) + { + _log.LogWarning(ex, "RevokeAll: failed to revoke token for sub={Sub}", sub); + } + } + + await _audit.LogAsync( + action: "RevokeAllSessions", + entityType: "AppUser", + entityId: Guid.TryParse(sub, out var uid) ? uid : null, + payload: new { revokedAuthorizations = revokedAuth, revokedTokens = revokedTok }, + ct: ct); + + _log.LogInformation( + "RevokeAllSessions: sub={Sub} authorizations={Auth} tokens={Tokens}", + sub, revokedAuth, revokedTok); + + return Ok(new RevokeAllResult(revokedAuth, revokedTok)); + } +} diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 37113de..a7131da 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -23,14 +23,16 @@ public class EmployeesController : ControllerBase private readonly foodmarket.Application.Common.Email.IEmailSender _email; private readonly foodmarket.Api.Infrastructure.Email.EmailTemplates _templates; private readonly ILogger _log; + private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit; public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager userMgr, foodmarket.Application.Common.Email.IEmailSender email, foodmarket.Api.Infrastructure.Email.EmailTemplates templates, - ILogger log) + ILogger log, + foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit) { _db = db; _tenant = tenant; _userMgr = userMgr; - _email = email; _templates = templates; _log = log; + _email = email; _templates = templates; _log = log; _audit = audit; } public record EmployeeDto( @@ -255,6 +257,10 @@ public async Task Update(Guid id, [FromBody] EmployeeInput input, }); } + // Sprint 13: запоминаем prev-state для аудита смены роли (это + // sensitive event — выдача прав, особенно elevated). + var prevRoleId = e.RoleId; + e.LastName = input.LastName; e.FirstName = input.FirstName; e.MiddleName = input.MiddleName; @@ -285,6 +291,29 @@ public async Task Update(Guid id, [FromBody] EmployeeInput input, { OrganizationId = orgId, RetailPointId = rpId }); await _db.SaveChangesAsync(ct); + + // Sprint 13: если изменили роль (выдали другие права) — пишем + // sensitive-audit с именем старой/новой роли + кратким summary + // permission'ов. Это даёт ответ на «кто включил кассиру refund?». + if (prevRoleId != e.RoleId) + { + var roles = await _db.EmployeeRoles.AsNoTracking() + .Where(r => r.Id == prevRoleId || r.Id == e.RoleId) + .ToDictionaryAsync(r => r.Id, r => r, ct); + roles.TryGetValue(prevRoleId, out var prevRole); + roles.TryGetValue(e.RoleId, out var newRole); + await _audit.LogAsync( + action: "AssignRole", + entityType: "Employee", + entityId: e.Id, + payload: new + { + employee = new { e.LastName, e.FirstName, e.Email, userId = e.UserId }, + prev = new { roleId = prevRoleId, roleName = prevRole?.Name }, + next = new { roleId = e.RoleId, roleName = newRole?.Name, permissions = newRole?.Permissions }, + }, + ct: ct); + } return NoContent(); } diff --git a/src/food-market.api/Controllers/TwoFactorController.cs b/src/food-market.api/Controllers/TwoFactorController.cs index 28cbc85..408289e 100644 --- a/src/food-market.api/Controllers/TwoFactorController.cs +++ b/src/food-market.api/Controllers/TwoFactorController.cs @@ -29,12 +29,16 @@ public class TwoFactorController : ControllerBase private readonly UserManager _users; private readonly AppDbContext _db; private readonly IConfiguration _cfg; + private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit; - public TwoFactorController(UserManager users, AppDbContext db, IConfiguration cfg) + public TwoFactorController( + UserManager users, AppDbContext db, IConfiguration cfg, + foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit) { _users = users; _db = db; _cfg = cfg; + _audit = audit; } public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled); @@ -89,6 +93,13 @@ public async Task Verify([FromBody] CodeInput input) return BadRequest(new { error = "Неверный код. Попробуйте ещё раз.", field = "code" }); await _users.SetTwoFactorEnabledAsync(user, true); + // Sprint 13: audit-log запись о включении 2FA. + await _audit.LogAsync( + action: "TwoFactorEnroll", + entityType: "AppUser", + entityId: user.Id, + payload: new { email = user.Email, fullName = user.FullName }, + ct: HttpContext.RequestAborted); return Ok(new { enabled = true }); } @@ -112,6 +123,14 @@ public async Task Disable([FromBody] CodeInput input) await _users.SetTwoFactorEnabledAsync(user, false); // Заодно сбрасываем authenticator-key, чтобы при повторном enroll выдался новый. await _users.ResetAuthenticatorKeyAsync(user); + // Sprint 13: audit-log запись об отключении 2FA — это важный security + // event (юзер ослабил защиту своей учётки). + await _audit.LogAsync( + action: "TwoFactorDisable", + entityType: "AppUser", + entityId: user.Id, + payload: new { email = user.Email, fullName = user.FullName }, + ct: HttpContext.RequestAborted); return Ok(new { enabled = false }); } } diff --git a/src/food-market.api/Infrastructure/Audit/SensitiveOpsAudit.cs b/src/food-market.api/Infrastructure/Audit/SensitiveOpsAudit.cs new file mode 100644 index 0000000..a3c7cfe --- /dev/null +++ b/src/food-market.api/Infrastructure/Audit/SensitiveOpsAudit.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using foodmarket.Application.Common.Tenancy; +using foodmarket.Domain.Organizations; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace foodmarket.Api.Infrastructure.Audit; + +/// Sprint 13 — централизованный логгер чувствительных +/// операций. Пишет в две точки: +/// +/// В org_audit_log — постоянная запись, читается из +/// UI /audit-log, ретейн 180 дней. +/// В Serilog — потоковый лог с тем же payload'ом, +/// сериализуется в JSON для агрегации (ELK / Loki). +/// +/// +/// «Чувствительные» операции — те, которые меняют security-границу: +/// смена пароля, 2FA enroll/disable, назначение нового владельца, +/// выдача роли с правами выше базовых, revoke-all сессий. В отличие +/// от обычного OrgAuditInterceptor, который ловит CRUD на EF-сущностях +/// автоматически, эти операции либо не идут через EF (Identity-API), +/// либо требуют дополнительного контекста (что именно изменилось, +/// какие права теперь у юзера). +/// +/// Stamping: создаёт запись с EntityType=string +/// (например, "AppUser") и Action=string +/// (например, "ChangePassword"). UserId — текущий subject из JWT (кто +/// выполнил). Если null — операция была инициирована background-job'ом. +public sealed class SensitiveOpsAudit +{ + private readonly AppDbContext _db; + private readonly ITenantContext _tenant; + private readonly IHttpContextAccessor _http; + private readonly ILogger _log; + + public SensitiveOpsAudit( + AppDbContext db, + ITenantContext tenant, + IHttpContextAccessor http, + ILogger log) + { + _db = db; + _tenant = tenant; + _http = http; + _log = log; + } + + /// Записать аудит-событие. Передаваемые поля сериализуются в + /// JSONB; не клади сюда секреты (пароли, токены) — этот объект попадает + /// в /api/admin/audit-log, видный любому Admin'у org'и. + /// "ChangePassword" | "TwoFactorEnroll" | "TwoFactorDisable" + /// | "AssignRole" | "ChangeAccountOwner" | "RevokeAllSessions" | etc. + /// "AppUser" | "Employee" | "Organization" — тип + /// сущности, к которой относится действие. + /// Id затронутой сущности (например, userId смены пароля). + /// null если действие не привязано к конкретной сущности. + /// Дополнительные поля для расследования: кто, что + /// именно, IP, какие permission'ы выданы. НЕ хранит секреты. + public async Task LogAsync( + string action, + string entityType, + Guid? entityId, + object? payload, + CancellationToken ct = default) + { + var orgId = _tenant.OrganizationId; + var userId = _tenant.UserId; + var ip = _http.HttpContext?.Connection?.RemoteIpAddress?.ToString(); + var ua = _http.HttpContext?.Request?.Headers?["User-Agent"].ToString(); + + // Сериализуем payload + контекст в один JSON-блоб. Используем + // WriteIndented=false (компактно) и AllowTrailingCommas=true (мягко). + var blob = new + { + action, + entityType, + entityId, + userId, + ip, + userAgent = ua, + timestamp = DateTime.UtcNow, + details = payload, + }; + var json = JsonSerializer.Serialize(blob, JsonOpts); + + if (orgId is { } o) + { + // Tenant-scoped: записываем в OrgAuditLog. Этот же фильтр виден + // Admin'у организации в UI «История изменений». + _db.OrgAuditLogs.Add(new OrgAuditLog + { + OrganizationId = o, + UserId = userId, + Action = action, + EntityType = entityType, + EntityId = entityId, + ChangesJson = json, + }); + await _db.SaveChangesAsync(ct); + } + // else: SuperAdmin без X-Org-Override — лога в OrgAuditLog нет, + // но Serilog получит запись. + + // Serilog: структурированно, с тем же payload'ом. Поля + // CorrelationId/OrgId/UserId уже добавлены LogEnrichmentMiddleware'ом. + _log.LogInformation( + "SENSITIVE_OP action={Action} entity={EntityType}:{EntityId} ip={Ip} ua={UserAgent} details={DetailsJson}", + action, entityType, entityId, ip, ua, json); + } + + private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web) + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; +} diff --git a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs index 6ef2e75..4f1f74f 100644 --- a/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs +++ b/src/food-market.api/Infrastructure/RateLimiting/AuthRateLimiterExtensions.cs @@ -30,6 +30,12 @@ public static class AuthRateLimiterExtensions public const int DefaultPerIpPerMinute = 60; public const int DefaultPerIpPerHour = 600; + // Sprint 13: signup-специфичный тугой лимит — даже если IP проходит общий + // /connect/token-бакет, спам-регистрация ограничена 3 за час и 10 за сутки. + // Защита от «зарегистрировал 1000 фейковых tenant'ов, чтобы заспамить email-bounce'ами». + public const int DefaultSignupPerIpPerHour = 3; + public const int DefaultSignupPerIpPerDay = 10; + private const string NoLimitPartition = "__not-an-auth-endpoint"; public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration config) @@ -44,6 +50,9 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser var perUserHour = section.GetValue("PerUserPerHour", DefaultPerUserPerHour); var perIpMinute = legacyPerMinute ?? section.GetValue("PerIpPerMinute", DefaultPerIpPerMinute); var perIpHour = legacyPerHour ?? section.GetValue("PerIpPerHour", DefaultPerIpPerHour); + // Sprint 13: signup-specific tighter limits. + var signupPerHour = section.GetValue("SignupPerIpPerHour", DefaultSignupPerIpPerHour); + var signupPerDay = section.GetValue("SignupPerIpPerDay", DefaultSignupPerIpPerDay); services.AddRateLimiter(options => { @@ -54,7 +63,9 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser BuildUserWindow(perUserMinute, TimeSpan.FromMinutes(1)), BuildUserWindow(perUserHour, TimeSpan.FromHours(1)), BuildIpWindow(perIpMinute, TimeSpan.FromMinutes(1)), - BuildIpWindow(perIpHour, TimeSpan.FromHours(1))) + BuildIpWindow(perIpHour, TimeSpan.FromHours(1)), + BuildSignupIpWindow(signupPerHour, TimeSpan.FromHours(1)), + BuildSignupIpWindow(signupPerDay, TimeSpan.FromDays(1))) : PartitionedRateLimiter.Create( _ => RateLimitPartition.GetNoLimiter(NoLimitPartition)); @@ -120,6 +131,26 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser }); }); + /// Signup-специфичный per-IP бакет (Sprint 13). Срабатывает + /// только на POST /api/auth/signup. Защищает от спам-регистрации, + /// /connect/token при этом не затрагивается — там работают per-user + /// (туже всего) и общий per-IP. + private static PartitionedRateLimiter BuildSignupIpWindow(int permitLimit, TimeSpan window) => + PartitionedRateLimiter.Create(ctx => + { + if (ResolveAuthBucket(ctx) != "signup") return RateLimitPartition.GetNoLimiter(NoLimitPartition); + return RateLimitPartition.GetSlidingWindowLimiter( + $"signup-ip:{ResolveClientKey(ctx)}", + _ => new SlidingWindowRateLimiterOptions + { + PermitLimit = permitLimit, + Window = window, + SegmentsPerWindow = 6, + QueueLimit = 0, + AutoReplenishment = true, + }); + }); + /// Возвращает имя бакета для лимитируемого auth-эндпоинта либо /// null, если запрос не подлежит лимиту. private static string? ResolveAuthBucket(HttpContext ctx) diff --git a/src/food-market.api/Infrastructure/Security/SecurityHeadersMiddleware.cs b/src/food-market.api/Infrastructure/Security/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..5e9d0d9 --- /dev/null +++ b/src/food-market.api/Infrastructure/Security/SecurityHeadersMiddleware.cs @@ -0,0 +1,115 @@ +namespace foodmarket.Api.Infrastructure.Security; + +/// Sprint 13 — навешивает security-заголовки на все ответы: +/// +/// Content-Security-Policy — default-src/script-src/connect-src/img-src. +/// X-Frame-Options: DENY — фрейминг запрещён (защита от clickjacking). +/// X-Content-Type-Options: nosniff — браузер не «угадывает» MIME. +/// Referrer-Policy: strict-origin-when-cross-origin — не светим путь +/// внутреннего URL'а на внешние ресурсы. +/// Permissions-Policy — отключаем доступ к камерам/микрофонам/GPS. +/// +/// +/// Заголовки выставляются как «при первом write'е» (через +/// OnStarting), чтобы middleware'ы выше по pipeline (например, +/// LogEnrichment, ReadonlyOverride) могли при необходимости их +/// переопределить раньше отправки. +/// +/// Исключения по path: +/// - /metrics — Prometheus scrape, заголовки не нужны. +/// - /health/* — health checks, не нужны. +/// - /swagger/* — Swagger UI требует более широкий CSP +/// (inline-eval), для него заголовки не ставим в Development. На +/// prod Swagger отключён, так что условие безопасно. +public class SecurityHeadersMiddleware +{ + private readonly RequestDelegate _next; + private readonly SecurityHeadersOptions _opts; + + public SecurityHeadersMiddleware(RequestDelegate next, SecurityHeadersOptions opts) + { + _next = next; + _opts = opts; + } + + public Task InvokeAsync(HttpContext ctx) + { + var path = ctx.Request.Path.Value ?? ""; + if (ShouldSkip(path)) + return _next(ctx); + + ctx.Response.OnStarting(() => + { + var h = ctx.Response.Headers; + + // CSP: один заголовок, директивы через ';'. unsafe-inline + // в script-src нужен потому, что SignalR negotiate-handshake + // и Tailwind v4 injected styles бьют inline-стили; nonces + // через middleware не реализованы. + if (!h.ContainsKey("Content-Security-Policy")) + h["Content-Security-Policy"] = _opts.ContentSecurityPolicy; + + if (!h.ContainsKey("X-Frame-Options")) + h["X-Frame-Options"] = "DENY"; + + if (!h.ContainsKey("X-Content-Type-Options")) + h["X-Content-Type-Options"] = "nosniff"; + + if (!h.ContainsKey("Referrer-Policy")) + h["Referrer-Policy"] = "strict-origin-when-cross-origin"; + + if (!h.ContainsKey("Permissions-Policy")) + h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()"; + + // X-Permitted-Cross-Domain-Policies — старый Adobe-полиси, на + // всякий случай блокируем (не критично, но не повредит). + if (!h.ContainsKey("X-Permitted-Cross-Domain-Policies")) + h["X-Permitted-Cross-Domain-Policies"] = "none"; + + return Task.CompletedTask; + }); + + return _next(ctx); + } + + private static bool ShouldSkip(string path) + { + if (path.StartsWith("/metrics", StringComparison.OrdinalIgnoreCase)) return true; + if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true; + // Swagger UI требует более широкую CSP — для него не ставим. + // На prod Swagger выключен (см. Program.cs IncludeSwagger). + if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } +} + +/// Конфиг security-заголовков, в первую очередь — CSP. +/// CSP может потребовать тюнинга на конкретный environment (например, +/// stage с дополнительным CDN-доменом для аналитики). Переопределяется +/// через Security:ContentSecurityPolicy в appsettings. +public class SecurityHeadersOptions +{ + public string ContentSecurityPolicy { get; set; } = DefaultCsp; + + /// Дефолтный CSP — рассчитан на: + /// + /// SPA + API на одном origin. + /// SignalR через wss:// (production) и ws:// (development/stage + /// proxied через nginx, но тоже терминирующийся в wss). + /// Inline styles из шаблонов писем и Tailwind base. + /// Картинки товаров: на загрузке через MinIO/S3 → data: и blob: + /// для предпросмотра; на просмотре товара — нативный self. + /// + /// Если понадобится включить Yandex.Metrica / Sentry / Datadog — добавить + /// домены в script-src, connect-src. + public const string DefaultCsp = + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "connect-src 'self' wss: ws:; " + + "img-src 'self' data: blob:; " + + "font-src 'self' data:; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'"; +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 4574c97..4516ec4 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -149,6 +149,10 @@ foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>(); builder.Services.AddScoped(); + // Sprint 13: централизованный логгер sensitive-операций (смена пароля, + // 2FA, выдача роли, смена владельца, revoke-all sessions). Пишет в + // org_audit_log + Serilog. См. SensitiveOpsAudit. + builder.Services.AddScoped(); // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). // Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled). @@ -360,8 +364,41 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme // just re-register here to turn demo data back on. // builder.Services.AddHostedService(); + // Sprint 13: security-заголовки (CSP, X-Frame-Options и т.д.). Опции + // переопределяются секцией Security в конфиге; по дефолту — самодостаточный + // SPA + API на одном origin, см. SecurityHeadersOptions.DefaultCsp. + builder.Services.AddSingleton(sp => + { + var opts = new foodmarket.Api.Infrastructure.Security.SecurityHeadersOptions(); + var section = builder.Configuration.GetSection("Security"); + var csp = section["ContentSecurityPolicy"]; + if (!string.IsNullOrWhiteSpace(csp)) opts.ContentSecurityPolicy = csp; + return opts; + }); + // HSTS-параметры для продакшна. 365 дней + includeSubDomains — стандарт. + // preload включаем: домен admin.food-market.kz можно подать в + // hstspreload.org когда созреем, без preload директивы — нельзя. + builder.Services.AddHsts(opts => + { + opts.MaxAge = TimeSpan.FromDays(365); + opts.IncludeSubDomains = true; + opts.Preload = true; + }); + var app = builder.Build(); + // HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development + // обычно работаем по http://localhost:5081, и Strict-Transport-Security + // прибил бы локальный браузер на год. По дефолту 365 дней + preload. + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + // Security-заголовки на КАЖДЫЙ ответ — раньше всех остальных middleware'ов, + // чтобы они применились даже на 429/403 от rate-limiter'а. + app.UseMiddleware(); + app.UseSerilogRequestLogging(); app.UseCors(CorsPolicy); // Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds). diff --git a/tests/food-market.IntegrationTests/HangfireAccessTests.cs b/tests/food-market.IntegrationTests/HangfireAccessTests.cs new file mode 100644 index 0000000..150d737 --- /dev/null +++ b/tests/food-market.IntegrationTests/HangfireAccessTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Sprint 13: Hangfire Dashboard защищён SuperAdminHangfireFilter. +/// +/// ВАЖНО: в тестовом окружении ApiFactory выставляет +/// Hangfire__Enabled=false (иначе Hangfire-сервер плодил бы свои +/// таблицы в одноразовом контейнере). Когда сервер выключен, +/// app.UseHangfireDashboard не вызывается → /hangfire отдаёт 404. +/// +/// Поэтому в этих тестах мы проверяем «дашборд НЕ открыт без авторизации»: +/// допустимый результат — 401, 403 или 404 (любая форма «нет доступа»). +/// SuperAdmin-кейс не проверяем здесь: для него на stage есть e2e-проверка +/// (см. stage-smoke). Это компромисс ради не-зависимости теста от +/// Hangfire-сервера. +[Collection(ApiCollection.Name)] +public class HangfireAccessTests +{ + private readonly ApiFactory _factory; + public HangfireAccessTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Anonymous_hangfire_returns_unauthorized() + { + using var client = _factory.CreateClient(); + var resp = await client.GetAsync("/hangfire"); + // Hangfire dashboard middleware возвращает 401 без auth — это NOT a 200 + // (если бы было 200 — это значит filter не сработал и /hangfire утёк). + ((int)resp.StatusCode).Should().BeOneOf(401, 403, 404); + } + + [Fact] + public async Task Tenant_admin_cannot_access_hangfire() + { + var actor = new ApiActor(_factory.CreateClient()); + await actor.SignupAndLoginAsync($"ha-{Guid.NewGuid():N}"); + var resp = await actor.Http.GetAsync("/hangfire"); + // 401/403 — фильтр сработал; 404 — Hangfire выключен в тестах + // (нет UseHangfireDashboard'а). Любой из этих кодов — это «доступа нет», + // именно то что мы хотим проверить. + ((int)resp.StatusCode).Should().BeOneOf(401, 403, 404); + } +} diff --git a/tests/food-market.IntegrationTests/SessionRevokeTests.cs b/tests/food-market.IntegrationTests/SessionRevokeTests.cs new file mode 100644 index 0000000..4fbf270 --- /dev/null +++ b/tests/food-market.IntegrationTests/SessionRevokeTests.cs @@ -0,0 +1,69 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Sprint 13: POST /api/me/sessions/revoke-all гасит +/// все refresh-токены текущего юзера. После вызова попытка обновить +/// access через refresh — 400. +[Collection(ApiCollection.Name)] +public class SessionRevokeTests +{ + private readonly ApiFactory _factory; + public SessionRevokeTests(ApiFactory factory) => _factory = factory; + + [Fact] + public async Task Refresh_after_revoke_all_fails() + { + var actor = new ApiActor(_factory.CreateClient()); + var slug = $"revoke-{Guid.NewGuid():N}"; + var email = $"{slug}@example.kz"; + const string password = "Passw0rd!"; + + // Signup + первый login (получаем access + refresh). + (await actor.SignupAsync(email, password, $"RevokeOrg-{slug}")).EnsureSuccessStatusCode(); + var (access1, refresh1) = await GetTokenPairAsync(actor.Http, email, password); + access1.Should().NotBeNullOrEmpty(); + refresh1.Should().NotBeNullOrEmpty(); + + // Используем access для revoke-all. + actor.Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access1); + var revokeResp = await actor.Http.PostAsync("/api/me/sessions/revoke-all", null); + revokeResp.EnsureSuccessStatusCode(); + var revokedJson = await revokeResp.Content.ReadFromJsonAsync(); + revokedJson.GetProperty("revokedAuthorizations").GetInt32().Should().BeGreaterThan(0, + "при revoke-all должна быть погашена хотя бы одна authorization"); + + // Попытка использовать ТОТ ЖЕ refresh-токен → 400 invalid_grant. + using var refreshResp = await actor.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refresh1!, + ["client_id"] = "food-market-web", + ["scope"] = "openid profile email roles api offline_access", + })); + ((int)refreshResp.StatusCode).Should().BeOneOf(400, 401); + } + + private static async Task<(string AccessToken, string? RefreshToken)> GetTokenPairAsync( + HttpClient http, string email, string password) + { + using var resp = await http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "password", + ["username"] = email, + ["password"] = password, + ["client_id"] = "food-market-web", + ["scope"] = "openid profile email roles api offline_access", + })); + resp.EnsureSuccessStatusCode(); + var json = await resp.Content.ReadFromJsonAsync(); + var access = json.GetProperty("access_token").GetString()!; + string? refresh = json.TryGetProperty("refresh_token", out var r) ? r.GetString() : null; + return (access, refresh); + } +}