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; } } }