food-market/src/food-market.api/Controllers/TwoFactorController.cs
nns 8e54e2e0d6 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>
2026-06-07 12:30:10 +05:00

137 lines
5.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Web;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers;
/// <summary>TOTP-2FA для текущего пользователя. Использует встроенный
/// <c>AuthenticatorTokenProvider</c> ASP.NET Identity — RFC 6238 (Google
/// Authenticator, Authy, 1Password OTP).
///
/// Flow:
/// 1. POST <c>/api/me/2fa/enroll</c> — генерирует секрет (если ещё нет) и
/// возвращает QR-URI (otpauth://) + сам ключ. Клиент рисует QR.
/// 2. POST <c>/api/me/2fa/verify</c> с code из приложения — проверяет и
/// включает <c>TwoFactorEnabled</c>. После этого password-flow требует
/// otp_code.
/// 3. POST <c>/api/me/2fa/disable</c> с code — выключает.
///
/// Дополнительной фичи (backup-коды) пока нет — добавим если попросят.</summary>
[ApiController]
[Authorize]
[Route("api/me/2fa")]
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,
foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit)
{
_users = users;
_db = db;
_cfg = cfg;
_audit = audit;
}
public record EnrollResult(string SharedKey, string AuthenticatorUri, bool AlreadyEnabled);
public record CodeInput(string Code);
public record StatusDto(bool Enabled);
[HttpGet("status")]
public async Task<ActionResult<StatusDto>> Status()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return new StatusDto(await _users.GetTwoFactorEnabledAsync(user));
}
[HttpPost("enroll")]
public async Task<ActionResult<EnrollResult>> Enroll()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var already = await _users.GetTwoFactorEnabledAsync(user);
// Если уже включено — не отдаём секрет; сначала disable.
if (already)
return Ok(new EnrollResult("", "", true));
var key = await _users.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _users.ResetAuthenticatorKeyAsync(user);
key = await _users.GetAuthenticatorKeyAsync(user);
}
var issuer = _cfg["App:Name"] ?? "food-market";
var label = $"{issuer}:{user.Email ?? user.UserName}";
// otpauth-URI согласно RFC: otpauth://totp/{label}?secret={key}&issuer={issuer}.
var uri = $"otpauth://totp/{HttpUtility.UrlEncode(label)}?secret={key}&issuer={HttpUtility.UrlEncode(issuer)}";
return Ok(new EnrollResult(key!, uri, false));
}
[HttpPost("verify")]
public async Task<IActionResult> Verify([FromBody] CodeInput input)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(input.Code))
return BadRequest(new { error = "Не указан код подтверждения.", field = "code" });
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
if (!ok)
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 });
}
[HttpPost("disable")]
public async Task<IActionResult> Disable([FromBody] CodeInput input)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.GetTwoFactorEnabledAsync(user))
return Ok(new { enabled = false });
// Защита от внезапного отключения 2FA: требуем текущий code из приложения.
if (string.IsNullOrWhiteSpace(input.Code))
return BadRequest(new { error = "Подтверди отключение текущим кодом из приложения.", field = "code" });
var provider = _users.Options.Tokens.AuthenticatorTokenProvider;
var ok = await _users.VerifyTwoFactorTokenAsync(user, provider, input.Code.Trim());
if (!ok)
return BadRequest(new { error = "Неверный код.", field = "code" });
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 });
}
}