feat(auth): forgot/reset password — endpoints + UI + IP rate-limit
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 с новым паролем → норма.
This commit is contained in:
nns 2026-05-06 12:45:38 +05:00
parent ab13a89617
commit e38a360e54
5 changed files with 333 additions and 0 deletions

View file

@ -0,0 +1,159 @@
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;
}
}
}

View file

@ -36,6 +36,8 @@ import { ProtectedRoute } from '@/components/ProtectedRoute'
import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { NoOrganizationPage } from '@/pages/NoOrganizationPage'
import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage'
import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage' import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage'
import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage'
import { ResetPasswordPage } from '@/pages/ResetPasswordPage'
import { RoleGuard } from '@/components/RoleGuard' import { RoleGuard } from '@/components/RoleGuard'
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@ -53,6 +55,8 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/auth-bridge" element={<AuthBridgePage />} /> <Route path="/auth-bridge" element={<AuthBridgePage />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
{/* Fallback для orphan AppUser без активной org / Employee. {/* Fallback для orphan AppUser без активной org / Employee.

View file

@ -0,0 +1,82 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Logo } from '@/components/Logo'
import { Button } from '@/components/Button'
import { Field, TextInput } from '@/components/Field'
import { validateEmail } from '@/lib/validation'
import axios from 'axios'
/** Anonymous-страница «Забыли пароль?». Не требует логина открыта по
* прямой ссылке /forgot-password. Сервер всегда отвечает 200 (анти-юзер-
* энумерация), поэтому UI показывает одинаковое сообщение независимо от
* того, есть юзер с таким email или нет. */
export function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)
const [submitted, setSubmitted] = useState(false)
const [busy, setBusy] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
const err = validateEmail(email)
if (err) { setError(err); return }
setError(null); setBusy(true)
try {
// Делаем чистым axios-call без api-interceptor'ау юзера нет токена.
await axios.post('/api/auth/forgot-password', { email })
setSubmitted(true)
} catch (e2) {
const err2 = e2 as { response?: { status?: number, data?: { error?: string } } }
if (err2.response?.status === 429) {
setError(err2.response.data?.error ?? 'Слишком много попыток. Попробуйте через час.')
} else {
// Любая другая ошибка — обобщаем, чтобы не палить детали.
setSubmitted(true)
}
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
<Logo />
{!submitted ? (
<form onSubmit={submit} noValidate className="space-y-4">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Забыли пароль?</h1>
<p className="text-sm text-slate-500 mt-1">
Укажите email пришлём ссылку для восстановления.
</p>
</div>
<Field label="Email" error={error ?? undefined}>
<TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)}
autoComplete="email" placeholder="name@example.kz" required />
</Field>
<Button onClick={() => {}} disabled={busy} className="w-full justify-center">
{busy ? 'Отправляю…' : 'Отправить ссылку'}
</Button>
<p className="text-xs text-center text-slate-500">
<Link to="/login" className="text-[var(--color-brand)] hover:underline"> Войти под другим аккаунтом</Link>
</p>
</form>
) : (
<div className="space-y-3">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Проверьте почту</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Если аккаунт с указанным email существует, мы отправили на него ссылку для восстановления пароля.
Письмо приходит в течение минуты; не забудьте проверить «Спам».
</p>
<p className="text-xs text-slate-500">
Ссылка действительна 1 час. Если не дошло попробуйте ещё раз через час.
</p>
<Link to="/login" className="block text-center text-sm text-[var(--color-brand)] hover:underline">
К входу
</Link>
</div>
)}
</div>
</div>
)
}

View file

@ -96,6 +96,10 @@ export function LoginPage() {
{loading ? 'Выполняется вход…' : 'Войти'} {loading ? 'Выполняется вход…' : 'Войти'}
</button> </button>
<p className="text-xs text-center">
<a href="/forgot-password" className="text-[var(--color-brand)] hover:underline">Забыли пароль?</a>
</p>
<p className="text-xs text-slate-400 text-center"> <p className="text-xs text-slate-400 text-center">
Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code> Dev admin: <code>admin@food-market.local</code> / <code>Admin12345!</code>
</p> </p>

View file

@ -0,0 +1,84 @@
import { useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { Logo } from '@/components/Logo'
import { Button } from '@/components/Button'
import { Field, TextInput } from '@/components/Field'
import { validatePassword } from '@/lib/validation'
import axios from 'axios'
/** Anonymous-страница приёма ссылки восстановления. URL вида
* /reset-password?email=...&token=...; токен короткоживущий (1 час),
* сгенерирован Identity GeneratePasswordResetTokenAsync. */
export function ResetPasswordPage() {
const [params] = useSearchParams()
const navigate = useNavigate()
const email = params.get('email') ?? ''
const token = params.get('token') ?? ''
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [done, setDone] = useState(false)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
if (!email || !token) {
setError('Ссылка некорректна. Запросите новую.')
return
}
const pwErr = validatePassword(password)
if (pwErr) { setError(pwErr); return }
if (password !== confirm) { setError('Пароли не совпадают.'); return }
setBusy(true)
try {
await axios.post('/api/auth/reset-password', { email, token, newPassword: password })
setDone(true)
setTimeout(() => navigate('/login', { replace: true }), 2500)
} catch (e2) {
const err = e2 as { response?: { data?: { error?: string } } }
setError(err.response?.data?.error ?? 'Не удалось обновить пароль.')
} finally {
setBusy(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
<div className="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-5">
<Logo />
{!done ? (
<form onSubmit={submit} noValidate className="space-y-4">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">Новый пароль</h1>
<p className="text-sm text-slate-500 mt-1">
Аккаунт {email ? <strong>{email}</strong> : 'не указан'}.
</p>
</div>
<Field label="Новый пароль">
<TextInput type="password" value={password}
onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
</Field>
<Field label="Повторите пароль" error={error ?? undefined}>
<TextInput type="password" value={confirm}
onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
</Field>
<Button onClick={() => {}} disabled={busy} className="w-full justify-center">
{busy ? 'Сохраняю…' : 'Установить пароль'}
</Button>
<p className="text-xs text-center text-slate-500">
<Link to="/login" className="text-[var(--color-brand)] hover:underline"> К входу</Link>
</p>
</form>
) : (
<div className="space-y-3">
<h1 className="text-xl font-bold text-emerald-700 dark:text-emerald-400">Пароль обновлён</h1>
<p className="text-sm text-slate-600 dark:text-slate-400">
Сейчас перенаправим на страницу входа
</p>
</div>
)}
</div>
</div>
)
}