Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m6s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 1m17s
Docker Web / Build + push Web (push) Successful in 36s
Docker API / Deploy API on stage (push) Failing after 37s
Docker Web / Deploy Web on stage (push) Successful in 12s
CI / POS (WPF, Windows) (push) Has been cancelled
Пункты 5 + 6 пакета SMTP-настроек.
API (AuthForgotPasswordController, anonymous):
- POST /api/auth/forgot-password { email }
· IP rate-limit: 3 попытки в час (in-memory ConcurrentDictionary
с per-IP списком timestamps; для одного API-инстанса хватает,
при scale-out — Redis).
· ВСЕГДА возвращает 200 (анти-юзер-энумерация). Реально шлёт письмо
только если юзер найден И активен И имеет email; иначе тихо
логирует и отдаёт 200.
· Использует UserManager.GeneratePasswordResetTokenAsync (Identity
AddDefaultTokenProviders уже подключён в Program.cs).
· Письмо: ссылка вида https://admin.food-market.kz/reset-password
?email=...&token=... (1 час валидна).
· Если SMTP не настроен — ловит EmailNotConfiguredException,
логирует и всё равно отдаёт 200 (UX-friendly).
- POST /api/auth/reset-password { email, token, newPassword }
· UserManager.ResetPasswordAsync. На InvalidToken — понятный
«Ссылка недействительна или истекла».
· После успеха revoke всех valid-OpenIddict tokens юзера
(UPDATE OpenIddictTokens SET Status='revoked' WHERE Subject=...).
UI:
- /forgot-password — anonymous, форма с email; submit → 200 «проверьте
почту» (одинаковый текст независимо от существования email).
- /reset-password — anonymous, читает email/token из query-string;
поля «новый пароль» + «повторите»; после успеха — auto-redirect
через 2.5 секунды на /login.
- LoginPage: добавлена ссылка «Забыли пароль?» под кнопкой «Войти».
Smoke-флоу:
1. SuperAdmin → /super-admin/platform-settings → SMTP creds + test-send.
2. Юзер → /login → «Забыли пароль?» → /forgot-password → email.
3. Письмо с ссылкой → /reset-password?email&token → новый пароль.
4. Login со старым паролем — отказ (revoked refresh + новый pwd).
5. Login с новым паролем → норма.
160 lines
7.7 KiB
C#
160 lines
7.7 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. Кладём timestamps попыток per IP, рубим >3 за час.
|
||
private static readonly ConcurrentDictionary<string, List<DateTime>> _ipAttempts = new();
|
||
private static readonly TimeSpan _rateLimitWindow = TimeSpan.FromHours(1);
|
||
private const int _maxAttemptsPerWindow = 3;
|
||
|
||
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";
|
||
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<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)}";
|
||
}
|
||
|
||
private static bool CheckRateLimit(string ip)
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
var attempts = _ipAttempts.GetOrAdd(ip, _ => new List<DateTime>());
|
||
lock (attempts)
|
||
{
|
||
// Чистим устаревшие.
|
||
attempts.RemoveAll(t => now - t > _rateLimitWindow);
|
||
if (attempts.Count >= _maxAttemptsPerWindow) return false;
|
||
attempts.Add(now);
|
||
return true;
|
||
}
|
||
}
|
||
}
|