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;
/// Восстановление пароля. Эндпоинты anonymous, защищены простым
/// IP-rate-limit'ом (3 попытки в час на IP), чтобы не было spam-attack.
/// Ответ /forgot-password всегда 200 — анти-юзер-энумерация (не палим
/// существование email).
[ApiController]
[AllowAnonymous]
[Route("api/auth")]
public class AuthForgotPasswordController : ControllerBase
{
private readonly UserManager _userMgr;
private readonly AppDbContext _db;
private readonly IEmailSender _email;
private readonly ILogger _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> _ipAttempts = new();
private static readonly ConcurrentDictionary> _emailAttempts = new();
private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1);
private const int _maxAttemptsPerEmail = 3;
private const int _maxAttemptsPerIp = 10;
public AuthForgotPasswordController(
UserManager userMgr, AppDbContext db, IEmailSender email,
ILogger 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 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 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)}";
}
/// Sprint 13: общая проверка sliding-window лимита, используется
/// для per-email и per-IP бакетов. Возвращает true если разрешено,
/// false если лимит превышен.
private static bool CheckRateLimit(ConcurrentDictionary> store, string key, int maxPerWindow)
{
var now = DateTime.UtcNow;
var attempts = store.GetOrAdd(key, _ => new List());
lock (attempts)
{
attempts.RemoveAll(t => now - t > _rateLimitWindow);
if (attempts.Count >= maxPerWindow) return false;
attempts.Add(now);
return true;
}
}
}