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. Кладём timestamps попыток per IP, рубим >3 за час. private static readonly ConcurrentDictionary> _ipAttempts = new(); private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1); private const int _maxAttemptsPerWindow = 3; 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"; if (!CheckRateLimit(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 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)}"; } private static bool CheckRateLimit(string ip) { var now = DateTime.UtcNow; var attempts = _ipAttempts.GetOrAdd(ip, _ => new List()); lock (attempts) { // Чистим устаревшие. attempts.RemoveAll(t => now - t > _rateLimitWindow); if (attempts.Count >= _maxAttemptsPerWindow) return false; attempts.Add(now); return true; } } }