string('email')->toString()); $throttleKey = 'auth:forgot:'.$email.'|'.($request->ip() ?? 'unknown'); if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { return $this->lockoutResponse($throttleKey); } // Hit ставится ДО вызова Password::sendResetLink — чтобы счётчик // увеличивался даже при unknown email (иначе можно перебирать вечно). RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); Password::sendResetLink(['email' => $email]); // Unified ответ независимо от наличия user'а. return response()->json([ 'message' => 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.', ]); } /** * POST /api/auth/reset-password — установка нового пароля по токену. * * Token приходит из email-ссылки (формируется Laravel ResetPassword Notification). * `Password::reset()` под капотом: * 1. Ищет user по email. * 2. Проверяет hashed token из password_resets (TTL 60 мин по config/auth.php). * 3. Вызывает callback с (user, password) → пишем `password_hash` (наша колонка). * 4. Удаляет row из password_resets. * * После успешного reset — invalidate всех существующих сессий через * `Auth::logoutOtherDevices` (требовало бы пароль) — для MVP опускаем, * но регенерируем remember_token (если есть). * * Reset rate-limit (anti-token-brute) — 5 попыток / 15 мин по token-prefix + ip. */ public function resetPassword(ResetPasswordRequest $request): JsonResponse { $email = mb_strtolower($request->string('email')->toString()); $token = $request->string('token')->toString(); // Throttle key — token (полный, не префикс) + ip. Анти-перебор: попытки // менее чем за 15 мин по одному и тому же token блокируются. $throttleKey = 'auth:reset:'.substr(hash('sha256', $token), 0, 16).'|'.($request->ip() ?? 'unknown'); if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { return $this->lockoutResponse($throttleKey); } $status = Password::reset( [ 'email' => $email, 'password' => $request->string('password')->toString(), 'password_confirmation' => $request->string('password_confirmation')->toString(), 'token' => $token, ], function (User $user, string $password): void { $user->forceFill([ 'password_hash' => Hash::make($password), ])->save(); } ); if ($status !== Password::PASSWORD_RESET) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); return response()->json([ 'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.', 'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']], ], 422); } RateLimiter::clear($throttleKey); return response()->json([ 'message' => 'Пароль успешно изменён. Войдите с новым паролем.', ]); } /** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */ private function lockoutResponse(string $throttleKey): JsonResponse { $retryAfter = RateLimiter::availableIn($throttleKey); return response()->json([ 'message' => "Слишком много попыток. Попробуйте через {$retryAfter} сек.", 'retry_after' => $retryAfter, ], 429)->header('Retry-After', (string) $retryAfter); } }