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>
137 lines
5.9 KiB
C#
137 lines
5.9 KiB
C#
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 });
|
||
}
|
||
}
|