From e38a360e549c8b60f95f24c774c1de2b2653020e Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Wed, 6 May 2026 12:45:38 +0500 Subject: [PATCH] =?UTF-8?q?feat(auth):=20forgot/reset=20password=20?= =?UTF-8?q?=E2=80=94=20endpoints=20+=20UI=20+=20IP=20rate-limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пункты 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 с новым паролем → норма. --- .../AuthForgotPasswordController.cs | 159 ++++++++++++++++++ src/food-market.web/src/App.tsx | 4 + .../src/pages/ForgotPasswordPage.tsx | 82 +++++++++ src/food-market.web/src/pages/LoginPage.tsx | 4 + .../src/pages/ResetPasswordPage.tsx | 84 +++++++++ 5 files changed, 333 insertions(+) create mode 100644 src/food-market.api/Controllers/AuthForgotPasswordController.cs create mode 100644 src/food-market.web/src/pages/ForgotPasswordPage.tsx create mode 100644 src/food-market.web/src/pages/ResetPasswordPage.tsx diff --git a/src/food-market.api/Controllers/AuthForgotPasswordController.cs b/src/food-market.api/Controllers/AuthForgotPasswordController.cs new file mode 100644 index 0000000..ce28b30 --- /dev/null +++ b/src/food-market.api/Controllers/AuthForgotPasswordController.cs @@ -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; + +/// Восстановление пароля. Эндпоинты 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; + } + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 20a048e..cbedf78 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -36,6 +36,8 @@ import { ProtectedRoute } from '@/components/ProtectedRoute' import { NoOrganizationPage } from '@/pages/NoOrganizationPage' import { SuperAdminOrgEmployeesPage } from '@/pages/SuperAdminOrgEmployeesPage' import { SuperAdminPlatformSettingsPage } from '@/pages/SuperAdminPlatformSettingsPage' +import { ForgotPasswordPage } from '@/pages/ForgotPasswordPage' +import { ResetPasswordPage } from '@/pages/ResetPasswordPage' import { RoleGuard } from '@/components/RoleGuard' const queryClient = new QueryClient({ @@ -53,6 +55,8 @@ export default function App() { } /> + } /> + } /> } /> }> {/* Fallback для orphan AppUser без активной org / Employee. diff --git a/src/food-market.web/src/pages/ForgotPasswordPage.tsx b/src/food-market.web/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..9b57bc9 --- /dev/null +++ b/src/food-market.web/src/pages/ForgotPasswordPage.tsx @@ -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(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 ( +
+
+ + {!submitted ? ( +
+
+

Забыли пароль?

+

+ Укажите email — пришлём ссылку для восстановления. +

+
+ + setEmail(e.target.value)} + autoComplete="email" placeholder="name@example.kz" required /> + + +

+ ← Войти под другим аккаунтом +

+
+ ) : ( +
+

Проверьте почту

+

+ Если аккаунт с указанным email существует, мы отправили на него ссылку для восстановления пароля. + Письмо приходит в течение минуты; не забудьте проверить «Спам». +

+

+ Ссылка действительна 1 час. Если не дошло — попробуйте ещё раз через час. +

+ + ← К входу + +
+ )} +
+
+ ) +} diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index 671b1f2..15f5f09 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -96,6 +96,10 @@ export function LoginPage() { {loading ? 'Выполняется вход…' : 'Войти'} +

+ Забыли пароль? +

+

Dev admin: admin@food-market.local / Admin12345!

diff --git a/src/food-market.web/src/pages/ResetPasswordPage.tsx b/src/food-market.web/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..e8622b7 --- /dev/null +++ b/src/food-market.web/src/pages/ResetPasswordPage.tsx @@ -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(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 ( +
+
+ + {!done ? ( +
+
+

Новый пароль

+

+ Аккаунт {email ? {email} : 'не указан'}. +

+
+ + setPassword(e.target.value)} autoComplete="new-password" required /> + + + setConfirm(e.target.value)} autoComplete="new-password" required /> + + +

+ ← К входу +

+
+ ) : ( +
+

Пароль обновлён

+

+ Сейчас перенаправим на страницу входа… +

+
+ )} +
+
+ ) +}