- EmailTemplates (invite/low-stock/weekly-summary): шапка с текстовым вордмарком «all market» (бренд-цвета, всегда рендерится — без внешних картинок и привязки к домену) + подпись «ALL MARKET — система учёта торговли». - Имя отправителя по умолчанию, темы/тела системных писем, экспорт-README, дневная сводка, тест SMTP → ALL MARKET. - Цвета писем и домены не менялись. Co-Authored-By: Claude Opus 5.5 <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" +
|
||
$"Кто-то запросил восстановление пароля для вашего аккаунта 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;
|
||
}
|
||
}
|
||
}
|