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:
parent
97e26a65d5
commit
8e54e2e0d6
|
|
@ -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:
|
||||
|
|
|
|||
399
deploy/grafana/dashboards/food-market.json
Normal file
399
deploy/grafana/dashboards/food-market.json
Normal 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": ""
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
120
docs/food-market-server-postgres-role.md
Normal file
120
docs/food-market-server-postgres-role.md
Normal 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).
|
||||
|
|
@ -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=<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
|
||||
|
||||
|
|
|
|||
133
docs/sprint13-progress.md
Normal file
133
docs/sprint13-progress.md
Normal 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 п.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-копию».
|
||||
|
|
@ -26,10 +26,15 @@ public class AuthForgotPasswordController : ControllerBase
|
|||
private readonly ILogger<AuthForgotPasswordController> _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<string, List<DateTime>> _ipAttempts = new();
|
||||
private static readonly ConcurrentDictionary<string, List<DateTime>> _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<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)
|
||||
{
|
||||
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)
|
||||
/// <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 attempts = _ipAttempts.GetOrAdd(ip, _ => new List<DateTime>());
|
||||
var attempts = store.GetOrAdd(key, _ => new List<DateTime>());
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
78
src/food-market.api/Controllers/MeAccountController.cs
Normal file
78
src/food-market.api/Controllers/MeAccountController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
116
src/food-market.api/Controllers/MeSessionsController.cs
Normal file
116
src/food-market.api/Controllers/MeSessionsController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<EmployeesController> _log;
|
||||
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
|
||||
|
||||
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr,
|
||||
foodmarket.Application.Common.Email.IEmailSender email,
|
||||
foodmarket.Api.Infrastructure.Email.EmailTemplates templates,
|
||||
ILogger<EmployeesController> log)
|
||||
ILogger<EmployeesController> 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<IActionResult> 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<IActionResult> 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,12 +29,16 @@ public class TwoFactorController : ControllerBase
|
|||
private readonly UserManager<User> _users;
|
||||
private readonly AppDbContext _db;
|
||||
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;
|
||||
_db = db;
|
||||
_cfg = cfg;
|
||||
_audit = audit;
|
||||
}
|
||||
|
||||
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" });
|
||||
|
||||
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<IActionResult> 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
src/food-market.api/Infrastructure/Audit/SensitiveOpsAudit.cs
Normal file
118
src/food-market.api/Infrastructure/Audit/SensitiveOpsAudit.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HttpContext, string>(
|
||||
_ => 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-эндпоинта либо
|
||||
/// null, если запрос не подлежит лимиту.</summary>
|
||||
private static string? ResolveAuthBucket(HttpContext ctx)
|
||||
|
|
|
|||
|
|
@ -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'";
|
||||
}
|
||||
|
|
@ -149,6 +149,10 @@
|
|||
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
|
||||
|
||||
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).
|
||||
// Лимиты конфигурируемы через 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<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();
|
||||
|
||||
// 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.UseCors(CorsPolicy);
|
||||
// Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds).
|
||||
|
|
|
|||
48
tests/food-market.IntegrationTests/HangfireAccessTests.cs
Normal file
48
tests/food-market.IntegrationTests/HangfireAccessTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
69
tests/food-market.IntegrationTests/SessionRevokeTests.cs
Normal file
69
tests/food-market.IntegrationTests/SessionRevokeTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue