food-market/src/food-market.api/Controllers/AuthForgotPasswordController.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

179 lines
9.4 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.Collections.Concurrent;
using System.Web;
using foodmarket.Application.Common.Email;
using foodmarket.Infrastructure.Identity;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace foodmarket.Api.Controllers;
/// <summary>Восстановление пароля. Эндпоинты anonymous, защищены простым
/// IP-rate-limit'ом (3 попытки в час на IP), чтобы не было spam-attack.
/// Ответ /forgot-password всегда 200 — анти-юзер-энумерация (не палим
/// существование email).</summary>
[ApiController]
[AllowAnonymous]
[Route("api/auth")]
public class AuthForgotPasswordController : ControllerBase
{
private readonly UserManager<User> _userMgr;
private readonly AppDbContext _db;
private readonly IEmailSender _email;
private readonly ILogger<AuthForgotPasswordController> _logger;
// In-memory rate-limit. Для одного API-инстанса достаточно; при scale-out
// понадобится 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 _maxAttemptsPerEmail = 3;
private const int _maxAttemptsPerIp = 10;
public AuthForgotPasswordController(
UserManager<User> userMgr, AppDbContext db, IEmailSender email,
ILogger<AuthForgotPasswordController> logger)
{
_userMgr = userMgr; _db = db; _email = email; _logger = logger;
}
public record ForgotInput(string Email);
public record ResetInput(string Email, string Token, string NewPassword);
[HttpPost("forgot-password")]
public async Task<IActionResult> Forgot([FromBody] ForgotInput input, CancellationToken ct)
{
var ip = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "unknown";
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 = "Слишком много попыток восстановления для этого адреса. Попробуйте через час.",
});
}
if (!CheckRateLimit(_ipAttempts, "ip:" + ip, _maxAttemptsPerIp))
{
_logger.LogWarning("Forgot-password rate-limited per-ip: {Ip}", ip);
return StatusCode(StatusCodes.Status429TooManyRequests, new
{
error = "Слишком много попыток с вашего адреса. Попробуйте через час.",
});
}
// Универсальный 200 чтобы не палить существование email.
// Реально письмо шлём только если юзер найден И активен И имеет email.
if (string.IsNullOrWhiteSpace(input.Email))
return Ok(new { ok = true });
try
{
var user = await _userMgr.FindByEmailAsync(input.Email.Trim());
if (user is not null && user.IsActive && !string.IsNullOrEmpty(user.Email))
{
var token = await _userMgr.GeneratePasswordResetTokenAsync(user);
var resetUrl = BuildResetUrl(user.Email!, token);
var body =
$"Здравствуйте.\n\n" +
$"Кто-то запросил восстановление пароля для вашего аккаунта Food Market ({user.Email}).\n" +
$"Если это были вы — перейдите по ссылке (действительна 1 час):\n\n" +
$"{resetUrl}\n\n" +
$"Если вы не запрашивали восстановление — просто игнорируйте это письмо.\n";
try
{
await _email.SendAsync(user.Email!, "Food Market — восстановление пароля", body, ct);
_logger.LogInformation("Forgot-password email sent to {Email}", user.Email);
}
catch (EmailNotConfiguredException ex)
{
// Не падаем 500 — это плохая UX. Просто логируем и
// отдаём 200, чтобы юзер не ждал зря; SuperAdmin увидит
// в логах что нужно настроить SMTP.
_logger.LogError(ex, "Forgot-password skipped: SMTP not configured");
}
catch (Exception ex)
{
_logger.LogError(ex, "Forgot-password email failed for {Email}", user.Email);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Forgot-password unexpected error");
}
return Ok(new { ok = true });
}
[HttpPost("reset-password")]
public async Task<IActionResult> Reset([FromBody] ResetInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Email) || string.IsNullOrWhiteSpace(input.Token))
return BadRequest(new { error = "Некорректная ссылка." });
if (string.IsNullOrWhiteSpace(input.NewPassword) || input.NewPassword.Length < 8)
return BadRequest(new { error = "Пароль должен быть не менее 8 символов." });
var user = await _userMgr.FindByEmailAsync(input.Email.Trim());
if (user is null || !user.IsActive)
return BadRequest(new { error = "Ссылка недействительна или истекла." });
var result = await _userMgr.ResetPasswordAsync(user, input.Token, input.NewPassword);
if (!result.Succeeded)
{
// ASP.NET Identity отдаёт «InvalidToken» если токен испорчен/истёк
// или если ResetPasswordAsync вызвали для другого юзера.
return BadRequest(new
{
error = result.Errors.Any(e => e.Code == "InvalidToken")
? "Ссылка недействительна или истекла. Запросите новую."
: string.Join("; ", result.Errors.Select(e => e.Description)),
});
}
// Revoke все активные refresh/access токены — старые пароли больше нерелевантны.
await _db.Database.ExecuteSqlRawAsync(
"UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Status\" = 'valid' AND \"Subject\" = {0}",
new object[] { user.Id.ToString() });
return Ok(new { ok = true });
}
private string BuildResetUrl(string email, string token)
{
// Frontend-host берём из Host заголовка через nginx — с учётом
// что админка на admin.food-market.kz. Если запрос пришёл с
// другого host (CORS-call) — фолбэк на admin-домен.
var host = HttpContext?.Request?.Host.Value;
var scheme = HttpContext?.Request?.Scheme ?? "https";
if (string.IsNullOrEmpty(host) || host.Contains("localhost"))
{
return $"https://admin.food-market.kz/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
}
return $"{scheme}://{host}/reset-password?email={HttpUtility.UrlEncode(email)}&token={HttpUtility.UrlEncode(token)}";
}
/// <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 = store.GetOrAdd(key, _ => new List<DateTime>());
lock (attempts)
{
attempts.RemoveAll(t => now - t > _rateLimitWindow);
if (attempts.Count >= maxPerWindow) return false;
attempts.Add(now);
return true;
}
}
}