food-market/src/food-market.api/Controllers/AuthForgotPasswordController.cs
nns 03bdf4d4f1 rebrand: письма и серверные строки ALL MARKET
- EmailTemplates (invite/low-stock/weekly-summary): шапка с текстовым
  вордмарком «all market» (бренд-цвета, всегда рендерится — без внешних
  картинок и привязки к домену) + подпись «ALL MARKET — система учёта торговли».
- Имя отправителя по умолчанию, темы/тела системных писем, экспорт-README,
  дневная сводка, тест SMTP → ALL MARKET.
- Цвета писем и домены не менялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:38 +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" +
$"Кто-то запросил восстановление пароля для вашего аккаунта ALL MARKET ({user.Email}).\n" +
$"Если это были вы — перейдите по ссылке (действительна 1 час):\n\n" +
$"{resetUrl}\n\n" +
$"Если вы не запрашивали восстановление — просто игнорируйте это письмо.\n";
try
{
await _email.SendAsync(user.Email!, "ALL 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;
}
}
}