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 /> + + +

+ ← К входу +

+
+ ) : ( +
+

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

+

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

+
+ )} +
+
+ ) +}