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>
179 lines
9.4 KiB
C#
179 lines
9.4 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|