feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana

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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-07 12:30:10 +05:00
parent 97e26a65d5
commit 8e54e2e0d6
17 changed files with 1431 additions and 14 deletions

View file

@ -34,6 +34,11 @@ services:
OpenIddict__Issuer: ${OPENIDDICT_ISSUER:-https://admin.food-market.kz/} OpenIddict__Issuer: ${OPENIDDICT_ISSUER:-https://admin.food-market.kz/}
# Пароль PFX-сертификатов OpenIddict (пусто = сертификаты без пароля). # Пароль PFX-сертификатов OpenIddict (пусто = сертификаты без пароля).
OpenIddict__CertPassword: ${OPENIDDICT_CERT_PASSWORD:-} 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 # Host port mapping: pick free ports on existing stage server (80/443 taken by
# legacy nginx, 5000/5002/5005 taken by legacy .NET apps). # legacy nginx, 5000/5002/5005 taken by legacy .NET apps).
ports: ports:

View file

@ -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": ""
}

View file

@ -3,6 +3,17 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 # Long-running admin imports (MoySklad etc.) read from upstream for tens of
# minutes. Bump timeouts only on that path so normal API stays snappy. # minutes. Bump timeouts only on that path so normal API stays snappy.
location /api/admin/import/ { location /api/admin/import/ {
@ -85,6 +96,18 @@ server {
return 301 /swagger/; 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. # Статика изображений товаров api раздаёт /uploads/... из volume.
location /uploads/ { location /uploads/ {
proxy_pass http://api:8080; proxy_pass http://api:8080;

View file

@ -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).

View file

@ -40,9 +40,67 @@ scrape_configs:
- targets: ['food-market-api:8080'] - 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=<your-sa-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 ### Health row

133
docs/sprint13-progress.md Normal file
View file

@ -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 п.45 (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-копию».

View file

@ -26,10 +26,15 @@ public class AuthForgotPasswordController : ControllerBase
private readonly ILogger<AuthForgotPasswordController> _logger; private readonly ILogger<AuthForgotPasswordController> _logger;
// In-memory rate-limit. Для одного API-инстанса достаточно; при scale-out // 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<string, List<DateTime>> _ipAttempts = new(); private static readonly ConcurrentDictionary<string, List<DateTime>> _ipAttempts = new();
private static readonly ConcurrentDictionary<string, List<DateTime>> _emailAttempts = new();
private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1); 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( public AuthForgotPasswordController(
UserManager<User> userMgr, AppDbContext db, IEmailSender email, UserManager<User> userMgr, AppDbContext db, IEmailSender email,
@ -45,11 +50,23 @@ public record ResetInput(string Email, string Token, string NewPassword);
public async Task<IActionResult> Forgot([FromBody] ForgotInput input, CancellationToken ct) public async Task<IActionResult> Forgot([FromBody] ForgotInput input, CancellationToken ct)
{ {
var ip = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown"; 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 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)}"; return $"{scheme}://{host}/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
} }
private static bool CheckRateLimit(string ip) /// <summary>Sprint 13: общая проверка sliding-window лимита, используется
/// для per-email и per-IP бакетов. Возвращает true если разрешено,
/// false если лимит превышен.</summary>
private static bool CheckRateLimit(ConcurrentDictionary<string, List<DateTime>> store, string key, int maxPerWindow)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var attempts = _ipAttempts.GetOrAdd(ip, _ => new List<DateTime>()); var attempts = store.GetOrAdd(key, _ => new List<DateTime>());
lock (attempts) lock (attempts)
{ {
// Чистим устаревшие.
attempts.RemoveAll(t => now - t > _rateLimitWindow); attempts.RemoveAll(t => now - t > _rateLimitWindow);
if (attempts.Count >= _maxAttemptsPerWindow) return false; if (attempts.Count >= maxPerWindow) return false;
attempts.Add(now); attempts.Add(now);
return true; return true;
} }

View file

@ -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;
/// <summary>Sprint 13 — управление учётной записью текущего пользователя.
/// Сейчас один endpoint — POST /api/me/change-password. Раньше смена пароля
/// была только через forgot-password flow; для уже залогиненного юзера это
/// неудобно (требует выйти, запросить ссылку, ждать email).</summary>
[ApiController]
[Authorize]
[Route("api/me")]
public class MeAccountController : ControllerBase
{
private readonly UserManager<User> _users;
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
private readonly ILogger<MeAccountController> _log;
public MeAccountController(
UserManager<User> users,
foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit,
ILogger<MeAccountController> log)
{
_users = users;
_audit = audit;
_log = log;
}
public record ChangePasswordInput(string CurrentPassword, string NewPassword);
/// <summary>Сменить пароль текущему юзеру. Требует текущий пароль для
/// защиты от случайного/злонамеренного изменения захваченной access-сессии.</summary>
[HttpPost("change-password")]
public async Task<IActionResult> 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 });
}
}

View file

@ -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;
/// <summary>Sprint 13 — управление сессиями текущего пользователя.
/// Сейчас один endpoint: <c>POST /api/me/sessions/revoke-all</c>
/// гасит все живые refresh-токены и authorizations, выданные этому
/// юзеру через OpenIddict. После вызова все его клиенты (web, POS,
/// мобайл) при первом же refresh получают 400 и форсированно
/// разлогиниваются.
///
/// <para>Поток: фронт делает запрос с текущим access-токеном
/// (Authorization header), сервер вытаскивает sub claim, идёт в
/// OpenIddict authorization-manager, ставит каждой авторизации
/// status=revoked. Все привязанные к ним токены наследуют статус.</para>
///
/// <para>Текущий access-токен остаётся валидным до своей expiration
/// (по умолчанию 1 час, см. SetAccessTokenLifetime). Это компромисс:
/// дёргать «прямо сейчас все access» через OpenIddict
/// введения - сложнее и требует нестандартного хука. На практике
/// 1 час с момента revoke-all допустим — за это время атакующий
/// уже не может получить новый refresh.</para>
///
/// <para>Audit: пишет SensitiveOpsAudit с action="RevokeAllSessions"
/// и количеством погашенных authorizations в payload'е.</para></summary>
[ApiController]
[Authorize]
[Route("api/me/sessions")]
public class MeSessionsController : ControllerBase
{
private readonly UserManager<User> _users;
private readonly IOpenIddictAuthorizationManager _authMgr;
private readonly IOpenIddictTokenManager _tokenMgr;
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
private readonly ILogger<MeSessionsController> _log;
public MeSessionsController(
UserManager<User> users,
IOpenIddictAuthorizationManager authMgr,
IOpenIddictTokenManager tokenMgr,
foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit,
ILogger<MeSessionsController> log)
{
_users = users;
_authMgr = authMgr;
_tokenMgr = tokenMgr;
_audit = audit;
_log = log;
}
public record RevokeAllResult(int RevokedAuthorizations, int RevokedTokens);
/// <summary>Гасит все refresh-токены текущего юзера. Использовать когда
/// есть подозрение на угон cookies/пароля.</summary>
[HttpPost("revoke-all")]
public async Task<ActionResult<RevokeAllResult>> 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));
}
}

View file

@ -23,14 +23,16 @@ public class EmployeesController : ControllerBase
private readonly foodmarket.Application.Common.Email.IEmailSender _email; private readonly foodmarket.Application.Common.Email.IEmailSender _email;
private readonly foodmarket.Api.Infrastructure.Email.EmailTemplates _templates; private readonly foodmarket.Api.Infrastructure.Email.EmailTemplates _templates;
private readonly ILogger<EmployeesController> _log; private readonly ILogger<EmployeesController> _log;
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr, public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr,
foodmarket.Application.Common.Email.IEmailSender email, foodmarket.Application.Common.Email.IEmailSender email,
foodmarket.Api.Infrastructure.Email.EmailTemplates templates, foodmarket.Api.Infrastructure.Email.EmailTemplates templates,
ILogger<EmployeesController> log) ILogger<EmployeesController> log,
foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit)
{ {
_db = db; _tenant = tenant; _userMgr = userMgr; _db = db; _tenant = tenant; _userMgr = userMgr;
_email = email; _templates = templates; _log = log; _email = email; _templates = templates; _log = log; _audit = audit;
} }
public record EmployeeDto( public record EmployeeDto(
@ -255,6 +257,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
}); });
} }
// Sprint 13: запоминаем prev-state для аудита смены роли (это
// sensitive event — выдача прав, особенно elevated).
var prevRoleId = e.RoleId;
e.LastName = input.LastName; e.LastName = input.LastName;
e.FirstName = input.FirstName; e.FirstName = input.FirstName;
e.MiddleName = input.MiddleName; e.MiddleName = input.MiddleName;
@ -285,6 +291,29 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
{ OrganizationId = orgId, RetailPointId = rpId }); { OrganizationId = orgId, RetailPointId = rpId });
await _db.SaveChangesAsync(ct); 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(); return NoContent();
} }

View file

@ -29,12 +29,16 @@ public class TwoFactorController : ControllerBase
private readonly UserManager<User> _users; private readonly UserManager<User> _users;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IConfiguration _cfg; private readonly IConfiguration _cfg;
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
public TwoFactorController(UserManager<User> users, AppDbContext db, IConfiguration cfg) public TwoFactorController(
UserManager<User> users, AppDbContext db, IConfiguration cfg,
foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit)
{ {
_users = users; _users = users;
_db = db; _db = db;
_cfg = cfg; _cfg = cfg;
_audit = audit;
} }
public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled); public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled);
@ -89,6 +93,13 @@ public async Task<IActionResult> Verify([FromBody] CodeInput input)
return BadRequest(new { error = "Неверный код. Попробуйте ещё раз.", field = "code" }); return BadRequest(new { error = "Неверный код. Попробуйте ещё раз.", field = "code" });
await _users.SetTwoFactorEnabledAsync(user, true); 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 }); return Ok(new { enabled = true });
} }
@ -112,6 +123,14 @@ public async Task<IActionResult> Disable([FromBody] CodeInput input)
await _users.SetTwoFactorEnabledAsync(user, false); await _users.SetTwoFactorEnabledAsync(user, false);
// Заодно сбрасываем authenticator-key, чтобы при повторном enroll выдался новый. // Заодно сбрасываем authenticator-key, чтобы при повторном enroll выдался новый.
await _users.ResetAuthenticatorKeyAsync(user); 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 }); return Ok(new { enabled = false });
} }
} }

View file

@ -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;
/// <summary>Sprint 13 — централизованный логгер чувствительных
/// операций. Пишет в две точки:
/// <list type="bullet">
/// <item>В <c>org_audit_log</c> — постоянная запись, читается из
/// UI /audit-log, ретейн 180 дней.</item>
/// <item>В Serilog — потоковый лог с тем же payload'ом,
/// сериализуется в JSON для агрегации (ELK / Loki).</item>
/// </list>
///
/// <para>«Чувствительные» операции — те, которые меняют security-границу:
/// смена пароля, 2FA enroll/disable, назначение нового владельца,
/// выдача роли с правами выше базовых, revoke-all сессий. В отличие
/// от обычного OrgAuditInterceptor, который ловит CRUD на EF-сущностях
/// автоматически, эти операции либо не идут через EF (Identity-API),
/// либо требуют дополнительного контекста (что именно изменилось,
/// какие права теперь у юзера).</para>
///
/// <para><b>Stamping:</b> создаёт запись с <c>EntityType=string</c>
/// (например, "AppUser") и <c>Action=string</c>
/// (например, "ChangePassword"). UserId — текущий subject из JWT (кто
/// выполнил). Если null — операция была инициирована background-job'ом.</para></summary>
public sealed class SensitiveOpsAudit
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly IHttpContextAccessor _http;
private readonly ILogger<SensitiveOpsAudit> _log;
public SensitiveOpsAudit(
AppDbContext db,
ITenantContext tenant,
IHttpContextAccessor http,
ILogger<SensitiveOpsAudit> log)
{
_db = db;
_tenant = tenant;
_http = http;
_log = log;
}
/// <summary>Записать аудит-событие. Передаваемые поля сериализуются в
/// JSONB; не клади сюда секреты (пароли, токены) — этот объект попадает
/// в /api/admin/audit-log, видный любому Admin'у org'и.</summary>
/// <param name="action">"ChangePassword" | "TwoFactorEnroll" | "TwoFactorDisable"
/// | "AssignRole" | "ChangeAccountOwner" | "RevokeAllSessions" | etc.</param>
/// <param name="entityType">"AppUser" | "Employee" | "Organization" — тип
/// сущности, к которой относится действие.</param>
/// <param name="entityId">Id затронутой сущности (например, userId смены пароля).
/// null если действие не привязано к конкретной сущности.</param>
/// <param name="payload">Дополнительные поля для расследования: кто, что
/// именно, IP, какие permission'ы выданы. НЕ хранит секреты.</param>
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,
};
}

View file

@ -30,6 +30,12 @@ public static class AuthRateLimiterExtensions
public const int DefaultPerIpPerMinute = 60; public const int DefaultPerIpPerMinute = 60;
public const int DefaultPerIpPerHour = 600; 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"; private const string NoLimitPartition = "__not-an-auth-endpoint";
public static IServiceCollection AddAuthRateLimiting(this IServiceCollection services, IConfiguration config) 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 perUserHour = section.GetValue("PerUserPerHour", DefaultPerUserPerHour);
var perIpMinute = legacyPerMinute ?? section.GetValue("PerIpPerMinute", DefaultPerIpPerMinute); var perIpMinute = legacyPerMinute ?? section.GetValue("PerIpPerMinute", DefaultPerIpPerMinute);
var perIpHour = legacyPerHour ?? section.GetValue("PerIpPerHour", DefaultPerIpPerHour); 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 => services.AddRateLimiter(options =>
{ {
@ -54,7 +63,9 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
BuildUserWindow(perUserMinute, TimeSpan.FromMinutes(1)), BuildUserWindow(perUserMinute, TimeSpan.FromMinutes(1)),
BuildUserWindow(perUserHour, TimeSpan.FromHours(1)), BuildUserWindow(perUserHour, TimeSpan.FromHours(1)),
BuildIpWindow(perIpMinute, TimeSpan.FromMinutes(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<HttpContext, string>( : PartitionedRateLimiter.Create<HttpContext, string>(
_ => RateLimitPartition.GetNoLimiter(NoLimitPartition)); _ => RateLimitPartition.GetNoLimiter(NoLimitPartition));
@ -120,6 +131,26 @@ public static IServiceCollection AddAuthRateLimiting(this IServiceCollection ser
}); });
}); });
/// <summary>Signup-специфичный per-IP бакет (Sprint 13). Срабатывает
/// только на POST /api/auth/signup. Защищает от спам-регистрации,
/// /connect/token при этом не затрагивается — там работают per-user
/// (туже всего) и общий per-IP.</summary>
private static PartitionedRateLimiter<HttpContext> BuildSignupIpWindow(int permitLimit, TimeSpan window) =>
PartitionedRateLimiter.Create<HttpContext, string>(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,
});
});
/// <summary>Возвращает имя бакета для лимитируемого auth-эндпоинта либо /// <summary>Возвращает имя бакета для лимитируемого auth-эндпоинта либо
/// null, если запрос не подлежит лимиту.</summary> /// null, если запрос не подлежит лимиту.</summary>
private static string? ResolveAuthBucket(HttpContext ctx) private static string? ResolveAuthBucket(HttpContext ctx)

View file

@ -0,0 +1,115 @@
namespace foodmarket.Api.Infrastructure.Security;
/// <summary>Sprint 13 — навешивает security-заголовки на все ответы:
/// <list type="bullet">
/// <item>Content-Security-Policy — default-src/script-src/connect-src/img-src.</item>
/// <item>X-Frame-Options: DENY — фрейминг запрещён (защита от clickjacking).</item>
/// <item>X-Content-Type-Options: nosniff — браузер не «угадывает» MIME.</item>
/// <item>Referrer-Policy: strict-origin-when-cross-origin — не светим путь
/// внутреннего URL'а на внешние ресурсы.</item>
/// <item>Permissions-Policy — отключаем доступ к камерам/микрофонам/GPS.</item>
/// </list>
///
/// <para>Заголовки выставляются как «при первом write'е» (через
/// <c>OnStarting</c>), чтобы middleware'ы выше по pipeline (например,
/// LogEnrichment, ReadonlyOverride) могли при необходимости их
/// переопределить раньше отправки.</para>
///
/// <para>Исключения по path:
/// - <c>/metrics</c> — Prometheus scrape, заголовки не нужны.
/// - <c>/health/*</c> — health checks, не нужны.
/// - <c>/swagger/*</c> — Swagger UI требует более широкий CSP
/// (inline-eval), для него заголовки не ставим в Development. На
/// prod Swagger отключён, так что условие безопасно.</para></summary>
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;
}
}
/// <summary>Конфиг security-заголовков, в первую очередь — CSP.
/// CSP может потребовать тюнинга на конкретный environment (например,
/// stage с дополнительным CDN-доменом для аналитики). Переопределяется
/// через <c>Security:ContentSecurityPolicy</c> в appsettings.</summary>
public class SecurityHeadersOptions
{
public string ContentSecurityPolicy { get; set; } = DefaultCsp;
/// <summary>Дефолтный CSP — рассчитан на:
/// <list type="bullet">
/// <item>SPA + API на одном origin.</item>
/// <item>SignalR через wss:// (production) и ws:// (development/stage
/// proxied через nginx, но тоже терминирующийся в wss).</item>
/// <item>Inline styles из шаблонов писем и Tailwind base.</item>
/// <item>Картинки товаров: на загрузке через MinIO/S3 → data: и blob:
/// для предпросмотра; на просмотре товара — нативный self.</item>
/// </list>
/// Если понадобится включить Yandex.Metrica / Sentry / Datadog — добавить
/// домены в <c>script-src</c>, <c>connect-src</c>.</summary>
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'";
}

View file

@ -149,6 +149,10 @@
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>(); foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Sprint 13: централизованный логгер sensitive-операций (смена пароля,
// 2FA, выдача роли, смена владельца, revoke-all sessions). Пишет в
// org_audit_log + Serilog. См. SensitiveOpsAudit.
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit>();
// Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP).
// Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled). // Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled).
@ -360,8 +364,41 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// just re-register here to turn demo data back on. // just re-register here to turn demo data back on.
// builder.Services.AddHostedService<DemoCatalogSeeder>(); // builder.Services.AddHostedService<DemoCatalogSeeder>();
// 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(); 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<foodmarket.Api.Infrastructure.Security.SecurityHeadersMiddleware>();
app.UseSerilogRequestLogging(); app.UseSerilogRequestLogging();
app.UseCors(CorsPolicy); app.UseCors(CorsPolicy);
// Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds). // Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds).

View file

@ -0,0 +1,48 @@
using System.Net;
using System.Net.Http.Headers;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Sprint 13: Hangfire Dashboard защищён <c>SuperAdminHangfireFilter</c>.
///
/// ВАЖНО: в тестовом окружении ApiFactory выставляет
/// <c>Hangfire__Enabled=false</c> (иначе Hangfire-сервер плодил бы свои
/// таблицы в одноразовом контейнере). Когда сервер выключен,
/// <c>app.UseHangfireDashboard</c> не вызывается → /hangfire отдаёт 404.
///
/// Поэтому в этих тестах мы проверяем «дашборд НЕ открыт без авторизации»:
/// допустимый результат — 401, 403 или 404 (любая форма «нет доступа»).
/// SuperAdmin-кейс не проверяем здесь: для него на stage есть e2e-проверка
/// (см. stage-smoke). Это компромисс ради не-зависимости теста от
/// Hangfire-сервера.</summary>
[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);
}
}

View file

@ -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;
/// <summary>Sprint 13: <c>POST /api/me/sessions/revoke-all</c> гасит
/// все refresh-токены текущего юзера. После вызова попытка обновить
/// access через refresh — 400.</summary>
[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<JsonElement>();
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<string, string>
{
["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<string, string>
{
["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<JsonElement>();
var access = json.GetProperty("access_token").GetString()!;
string? refresh = json.TryGetProperty("refresh_token", out var r) ? r.GetString() : null;
return (access, refresh);
}
}