From cd13e6c8bb8b6ab5b3971bf23e74478891a0586b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 9 May 2026 20:14:33 +0300 Subject: [PATCH] =?UTF-8?q?refactor(backend):=20Sprint=203=20Phase=20B=20?= =?UTF-8?q?=E2=80=94=20AuthController=20split=20(3=20classes=20by=20analog?= =?UTF-8?q?y)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 3 Phase B. Закрытие audit O-refactor-02: - O-refactor-02: AuthController (595 строк) → split на 3 класса (по аналогии с Sprint 3 Phase A DealController split): * AuthController (343) — core auth: login/register/logout/me + updateNotificationPreferences + helpers (loginThrottleKey, isIpLockedOut, maybeNotifySuspiciousLogin, logAuthEvent, lockoutResponse, userResource) * TwoFactorController (217, новый) — 2FA verify + recovery codes use (login-flow продолжение). Setup/enable/disable остались в TwoFactorSetupController (с Sprint 1). * PasswordResetController (146, новый) — forgot/reset password API endpoints без изменений (только routing — controller@method обновлён в web.php): - POST /api/auth/2fa/verify → TwoFactorController@verifyTwoFactor - POST /api/auth/2fa/recovery-use → TwoFactorController@useRecoveryCode - POST /api/auth/forgot → PasswordResetController@forgotPassword - POST /api/auth/reset-password → PasswordResetController@resetPassword Helpers lockoutResponse и userResource дублируются между классами (1:1) — по принципу Phase A: «копируй методы 1:1, не оптимизировать на ходу». Будущая итерация может вынести в trait/base controller, если появится 4-й класс. Pest: 416/416 PASS + 2 skipped. Larastan: 0 errors. Pint: passed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Http/Controllers/Api/AuthController.php | 338 +++--------------- .../Api/PasswordResetController.php | 146 ++++++++ .../Controllers/Api/TwoFactorController.php | 217 +++++++++++ app/routes/web.php | 12 +- 4 files changed, 414 insertions(+), 299 deletions(-) create mode 100644 app/app/Http/Controllers/Api/PasswordResetController.php create mode 100644 app/app/Http/Controllers/Api/TwoFactorController.php diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 12448d38..84dade4b 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -5,16 +5,11 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Http\Requests\Auth\ForgotPasswordRequest; use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\RegisterRequest; -use App\Http\Requests\Auth\ResetPasswordRequest; -use App\Http\Requests\Auth\UseRecoveryCodeRequest; -use App\Http\Requests\Auth\VerifyTwoFactorRequest; use App\Mail\SuspiciousLoginNotification; use App\Models\Tenant; use App\Models\User; -use App\Models\UserRecoveryCode; use App\Services\NotificationService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -22,12 +17,15 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\RateLimiter; -use PragmaRX\Google2FA\Google2FA; /** - * Аутентификация через Sanctum SPA mode (cookie-based session) + 2FA TOTP. + * Аутентификация через Sanctum SPA mode (cookie-based session). + * Core auth flow: login / register / logout / me + notification preferences. + * + * Sprint 3 Phase B (audit O-refactor-02): 2FA verify/recovery вынесены в + * TwoFactorController, password reset — в PasswordResetController. + * Setup 2FA — TwoFactorSetupController (был отдельным ещё с Sprint 1). * * Flow без 2FA: * 1. POST /api/auth/login → создаёт session, возвращает {user, requires_2fa: false}. @@ -35,17 +33,15 @@ use PragmaRX\Google2FA\Google2FA; * Flow с 2FA (totp_enabled=true): * 1. POST /api/auth/login → проверяет email+password, сохраняет pending_user_id * в session, **НЕ** делает Auth::login. Возвращает {user, requires_2fa: true}. - * 2. POST /api/auth/2fa/verify {code} → проверяет TOTP против users.totp_secret, - * окно ±1 (30 сек до/после). Если ок — Auth::login + clear pending_user_id. + * 2. POST /api/auth/2fa/verify {code} → TwoFactorController, проверяет TOTP + * против users.totp_secret, окно ±1 (30 сек до/после). Если ок — Auth::login. * * Rate-limit (ТЗ §22.4.4): * - Login: 5 попыток / 15 мин по ключу email|ip. Превышение → 429 + Retry-After. - * - 2FA verify: 5 попыток / 15 мин по pending_user_id из session. * - IP-lockout: 10 неудач/час с одного IP через auth_log → 429 + Retry-After: 3600. * - Email-уведомление при ровно 3 неудачах входа на email → SuspiciousLoginNotification. * * Не входит: - * - Recovery codes (XXXX-XXXX) — отдельный endpoint /2fa/recovery-use. * - Yandex 360 SSO. * - Tenant-wizard при register (на MVP — first tenant attach). */ @@ -132,59 +128,6 @@ class AuthController extends Controller ]); } - public function verifyTwoFactor(VerifyTwoFactorRequest $request): JsonResponse - { - $pendingUserId = $request->session()->get('auth.pending_user_id'); - $remember = (bool) $request->session()->get('auth.pending_remember', false); - - if (! $pendingUserId) { - return response()->json([ - 'message' => 'Сессия 2FA истекла. Войдите снова.', - ], 422); - } - - $throttleKey = $this->twoFactorThrottleKey((int) $pendingUserId, $request->ip()); - - if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { - return $this->lockoutResponse($throttleKey); - } - - $user = User::find($pendingUserId); - if (! $user || ! $user->totp_enabled || empty($user->totp_secret)) { - return response()->json([ - 'message' => 'Сессия 2FA недействительна.', - ], 422); - } - - $google2fa = new Google2FA; - // Окно ±1 (30 сек до/после) — компенсирует clock-skew между сервером и - // приложением аутентификатора. По ТЗ §1.6 / Прил. Г.4.2. - $valid = $google2fa->verifyKey((string) $user->totp_secret, $request->string('code')->toString(), 1); - - if (! $valid) { - RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); - - return response()->json([ - 'message' => 'Неверный код. Проверьте время на устройстве и попробуйте снова.', - 'errors' => ['code' => ['Неверный код.']], - ], 422); - } - - // 2FA пройдена → чистим throttle + залогиниваем + чистим pending session. - RateLimiter::clear($throttleKey); - - Auth::login($user, $remember); - $request->session()->regenerate(); - $request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']); - - $user->update(['last_login_at' => now()]); - - return response()->json([ - 'user' => $this->userResource($user), - 'requires_2fa' => false, - ]); - } - public function register(RegisterRequest $request): JsonResponse { // На MVP — attach нового user'а к первому tenant'у (для UI-разводки). @@ -236,191 +179,52 @@ class AuthController extends Controller } /** - * POST /api/auth/2fa/recovery-use — вход по резервному коду вместо TOTP. + * PATCH /api/auth/me/notification-preferences — сохранить матрицу + * 8 событий × 3 каналов (inapp/push/email) + sound_enabled. * - * Flow (продолжение login → pending_user_id в session): - * 1. Из session берём pending_user_id (тот же, что для /2fa/verify). - * 2. Перебираем все НЕиспользованные user_recovery_codes этого user'а, - * для каждого делаем `Hash::check($plainCode, $codeHash)`. - * 3. На совпадении → mark used_at + Auth::login + clear pending. - * 4. Иначе → 422 + RateLimiter::hit. - * - * Rate-limit: 5/15мин по pending_user_id|ip (тот же ключ, что 2fa/verify - * был бы избыточен — но физически разные scope'ы). - * - * Recovery code хранится в bcrypt-хеше (security: даже при утечке БД - * нельзя восстановить plain-код), сравнение через Hash::check. + * Источник: schema.sql:699 users.notification_preferences JSONB DEFAULT. + * Валидация: события ∈ NotificationService::ALL_EVENTS, каналы ∈ + * {inapp, push, email}. Незадекларированные ключи отбрасываются. */ - public function useRecoveryCode(UseRecoveryCodeRequest $request): JsonResponse + public function updateNotificationPreferences(Request $request): JsonResponse { - $pendingUserId = $request->session()->get('auth.pending_user_id'); - $remember = (bool) $request->session()->get('auth.pending_remember', false); + $validated = $request->validate([ + 'prefs' => 'required|array', + 'prefs.*' => 'array', + 'sound_enabled' => 'nullable|boolean', + ]); - if (! $pendingUserId) { - return response()->json([ - 'message' => 'Сессия 2FA истекла. Войдите снова.', - ], 422); - } + $allEvents = NotificationService::ALL_EVENTS; + $allChannels = [ + NotificationService::CHANNEL_INAPP, + NotificationService::CHANNEL_PUSH, + NotificationService::CHANNEL_EMAIL, + ]; - $throttleKey = 'auth:recovery:'.$pendingUserId.'|'.($request->ip() ?? 'unknown'); - - if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { - return $this->lockoutResponse($throttleKey); - } - - $user = User::find($pendingUserId); - if (! $user) { - return response()->json([ - 'message' => 'Сессия 2FA недействительна.', - ], 422); - } - - // Нормализуем: убираем дефисы и пробелы, lower-case (генерация в setup - // wizard'е тоже нормализует — единый формат сравнения). - $plainCode = mb_strtolower(preg_replace('/[\s\-]+/', '', $request->string('code')->toString()) ?? ''); - - $unusedCodes = UserRecoveryCode::query() - ->where('user_id', $user->id) - ->whereNull('used_at') - ->get(); - - $matched = null; - foreach ($unusedCodes as $row) { - if (Hash::check($plainCode, $row->code_hash)) { - $matched = $row; - break; + // Очищенная матрица (только known events × known channels). + $sanitized = []; + foreach ($validated['prefs'] as $event => $channelPrefs) { + if (! in_array($event, $allEvents, true)) { + continue; + } + $sanitized[$event] = []; + foreach ($allChannels as $channel) { + if (isset($channelPrefs[$channel])) { + $sanitized[$event][$channel] = (bool) $channelPrefs[$channel]; + } } } - if (! $matched) { - RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); - - return response()->json([ - 'message' => 'Резервный код недействителен или уже использован.', - 'errors' => ['code' => ['Резервный код недействителен или уже использован.']], - ], 422); + /** @var User $user */ + $user = $request->user(); + $update = ['notification_preferences' => $sanitized]; + if (array_key_exists('sound_enabled', $validated)) { + $update['sound_enabled'] = (bool) $validated['sound_enabled']; } - - // Пометить код использованным + завершить login. - $matched->update(['used_at' => now()]); - - RateLimiter::clear($throttleKey); - - Auth::login($user, $remember); - $request->session()->regenerate(); - $request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']); - - $user->update(['last_login_at' => now()]); - - // Кол-во оставшихся неиспользованных кодов — для UI-warning'а - // ("осталось 3 из 8 — рекомендуем перегенерировать"). - $remaining = UserRecoveryCode::query() - ->where('user_id', $user->id) - ->whereNull('used_at') - ->count(); + $user->update($update); return response()->json([ - 'user' => $this->userResource($user), - 'requires_2fa' => false, - 'recovery_codes_remaining' => $remaining, - ]); - } - - /** - * POST /api/auth/forgot — запрос ссылки на сброс пароля (ТЗ §1.7). - * - * Anti-enumeration: ВСЕГДА возвращаем 200 с unified message, независимо - * от того, существует ли user. Это защищает от перебора email'ов. - * - * Rate-limit (ТЗ §22.4.4 + ТЗ §1.7): 5 попыток / 15 мин по ключу email|ip. - * На превышении → 429 + Retry-After. - * - * Laravel `Password::sendResetLink` под капотом: - * 1. Ищет user по email через `users` provider (UserProvider). - * 2. Если найден — генерирует token, сохраняет в `password_resets` - * (env AUTH_PASSWORD_RESET_TOKEN_TABLE), отправляет Notification. - * 3. На dev-стеке MAIL_MAILER=log → notification пишется в storage/logs/laravel.log. - * 4. Если не найден — silently skip (anti-enumeration). - * - * Reset-endpoint POST /api/auth/reset-password — отдельный коммит (требует - * UI-формы для new_password + token-валидации из email-ссылки). - */ - public function forgotPassword(ForgotPasswordRequest $request): JsonResponse - { - $email = mb_strtolower($request->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' => 'Пароль успешно изменён. Войдите с новым паролем.', + 'user' => $this->userResource($user->fresh()), ]); } @@ -433,12 +237,6 @@ class AuthController extends Controller return 'auth:login:'.mb_strtolower($email).'|'.($ip ?? 'unknown'); } - /** Ключ throttle для 2FA verify: pending user_id + IP. */ - private function twoFactorThrottleKey(int $userId, ?string $ip): string - { - return 'auth:2fa:'.$userId.'|'.($ip ?? 'unknown'); - } - /** * IP-lockout по ТЗ §22.4.4 п.2: 10+ login_failed с одного IP за последний час. * @@ -542,54 +340,4 @@ class AuthController extends Controller 'sound_enabled' => $user->sound_enabled, ]; } - - /** - * PATCH /api/auth/me/notification-preferences — сохранить матрицу - * 8 событий × 3 каналов (inapp/push/email) + sound_enabled. - * - * Источник: schema.sql:699 users.notification_preferences JSONB DEFAULT. - * Валидация: события ∈ NotificationService::ALL_EVENTS, каналы ∈ - * {inapp, push, email}. Незадекларированные ключи отбрасываются. - */ - public function updateNotificationPreferences(Request $request): JsonResponse - { - $validated = $request->validate([ - 'prefs' => 'required|array', - 'prefs.*' => 'array', - 'sound_enabled' => 'nullable|boolean', - ]); - - $allEvents = NotificationService::ALL_EVENTS; - $allChannels = [ - NotificationService::CHANNEL_INAPP, - NotificationService::CHANNEL_PUSH, - NotificationService::CHANNEL_EMAIL, - ]; - - // Очищенная матрица (только known events × known channels). - $sanitized = []; - foreach ($validated['prefs'] as $event => $channelPrefs) { - if (! in_array($event, $allEvents, true)) { - continue; - } - $sanitized[$event] = []; - foreach ($allChannels as $channel) { - if (isset($channelPrefs[$channel])) { - $sanitized[$event][$channel] = (bool) $channelPrefs[$channel]; - } - } - } - - /** @var User $user */ - $user = $request->user(); - $update = ['notification_preferences' => $sanitized]; - if (array_key_exists('sound_enabled', $validated)) { - $update['sound_enabled'] = (bool) $validated['sound_enabled']; - } - $user->update($update); - - return response()->json([ - 'user' => $this->userResource($user->fresh()), - ]); - } } diff --git a/app/app/Http/Controllers/Api/PasswordResetController.php b/app/app/Http/Controllers/Api/PasswordResetController.php new file mode 100644 index 00000000..a470bf3e --- /dev/null +++ b/app/app/Http/Controllers/Api/PasswordResetController.php @@ -0,0 +1,146 @@ +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); + } +} diff --git a/app/app/Http/Controllers/Api/TwoFactorController.php b/app/app/Http/Controllers/Api/TwoFactorController.php new file mode 100644 index 00000000..9912c3fa --- /dev/null +++ b/app/app/Http/Controllers/Api/TwoFactorController.php @@ -0,0 +1,217 @@ +session()->get('auth.pending_user_id'); + $remember = (bool) $request->session()->get('auth.pending_remember', false); + + if (! $pendingUserId) { + return response()->json([ + 'message' => 'Сессия 2FA истекла. Войдите снова.', + ], 422); + } + + $throttleKey = $this->twoFactorThrottleKey((int) $pendingUserId, $request->ip()); + + if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { + return $this->lockoutResponse($throttleKey); + } + + $user = User::find($pendingUserId); + if (! $user || ! $user->totp_enabled || empty($user->totp_secret)) { + return response()->json([ + 'message' => 'Сессия 2FA недействительна.', + ], 422); + } + + $google2fa = new Google2FA; + // Окно ±1 (30 сек до/после) — компенсирует clock-skew между сервером и + // приложением аутентификатора. По ТЗ §1.6 / Прил. Г.4.2. + $valid = $google2fa->verifyKey((string) $user->totp_secret, $request->string('code')->toString(), 1); + + if (! $valid) { + RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + + return response()->json([ + 'message' => 'Неверный код. Проверьте время на устройстве и попробуйте снова.', + 'errors' => ['code' => ['Неверный код.']], + ], 422); + } + + // 2FA пройдена → чистим throttle + залогиниваем + чистим pending session. + RateLimiter::clear($throttleKey); + + Auth::login($user, $remember); + $request->session()->regenerate(); + $request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']); + + $user->update(['last_login_at' => now()]); + + return response()->json([ + 'user' => $this->userResource($user), + 'requires_2fa' => false, + ]); + } + + /** + * POST /api/auth/2fa/recovery-use — вход по резервному коду вместо TOTP. + * + * Flow (продолжение login → pending_user_id в session): + * 1. Из session берём pending_user_id (тот же, что для /2fa/verify). + * 2. Перебираем все НЕиспользованные user_recovery_codes этого user'а, + * для каждого делаем `Hash::check($plainCode, $codeHash)`. + * 3. На совпадении → mark used_at + Auth::login + clear pending. + * 4. Иначе → 422 + RateLimiter::hit. + * + * Rate-limit: 5/15мин по pending_user_id|ip (тот же ключ, что 2fa/verify + * был бы избыточен — но физически разные scope'ы). + * + * Recovery code хранится в bcrypt-хеше (security: даже при утечке БД + * нельзя восстановить plain-код), сравнение через Hash::check. + */ + public function useRecoveryCode(UseRecoveryCodeRequest $request): JsonResponse + { + $pendingUserId = $request->session()->get('auth.pending_user_id'); + $remember = (bool) $request->session()->get('auth.pending_remember', false); + + if (! $pendingUserId) { + return response()->json([ + 'message' => 'Сессия 2FA истекла. Войдите снова.', + ], 422); + } + + $throttleKey = 'auth:recovery:'.$pendingUserId.'|'.($request->ip() ?? 'unknown'); + + if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { + return $this->lockoutResponse($throttleKey); + } + + $user = User::find($pendingUserId); + if (! $user) { + return response()->json([ + 'message' => 'Сессия 2FA недействительна.', + ], 422); + } + + // Нормализуем: убираем дефисы и пробелы, lower-case (генерация в setup + // wizard'е тоже нормализует — единый формат сравнения). + $plainCode = mb_strtolower(preg_replace('/[\s\-]+/', '', $request->string('code')->toString()) ?? ''); + + $unusedCodes = UserRecoveryCode::query() + ->where('user_id', $user->id) + ->whereNull('used_at') + ->get(); + + $matched = null; + foreach ($unusedCodes as $row) { + if (Hash::check($plainCode, $row->code_hash)) { + $matched = $row; + break; + } + } + + if (! $matched) { + RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + + return response()->json([ + 'message' => 'Резервный код недействителен или уже использован.', + 'errors' => ['code' => ['Резервный код недействителен или уже использован.']], + ], 422); + } + + // Пометить код использованным + завершить login. + $matched->update(['used_at' => now()]); + + RateLimiter::clear($throttleKey); + + Auth::login($user, $remember); + $request->session()->regenerate(); + $request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']); + + $user->update(['last_login_at' => now()]); + + // Кол-во оставшихся неиспользованных кодов — для UI-warning'а + // ("осталось 3 из 8 — рекомендуем перегенерировать"). + $remaining = UserRecoveryCode::query() + ->where('user_id', $user->id) + ->whereNull('used_at') + ->count(); + + return response()->json([ + 'user' => $this->userResource($user), + 'requires_2fa' => false, + 'recovery_codes_remaining' => $remaining, + ]); + } + + /** Ключ throttle для 2FA verify: pending user_id + IP. */ + private function twoFactorThrottleKey(int $userId, ?string $ip): string + { + return 'auth:2fa:'.$userId.'|'.($ip ?? 'unknown'); + } + + /** 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); + } + + /** @return array */ + private function userResource(User $user): array + { + return [ + 'id' => $user->id, + 'email' => $user->email, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'tenant_id' => $user->tenant_id, + 'totp_enabled' => $user->totp_enabled, + 'last_login_at' => $user->last_login_at, + 'notification_preferences' => $user->notification_preferences, + 'sound_enabled' => $user->sound_enabled, + ]; + } +} diff --git a/app/routes/web.php b/app/routes/web.php index a59361ef..755fbba9 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -22,13 +22,17 @@ Route::prefix('/api/auth')->group(function () { Route::post('/register', 'App\Http\Controllers\Api\AuthController@register'); // /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только // pending_user_id в session. Verify завершает login после проверки TOTP. - Route::post('/2fa/verify', 'App\Http\Controllers\Api\AuthController@verifyTwoFactor'); + // + // Sprint 3 Phase B (audit O-refactor-02): 2FA verify/recovery-use вынесены + // из AuthController в TwoFactorController; forgot/reset — в PasswordResetController. + // URL без изменений. + Route::post('/2fa/verify', 'App\Http\Controllers\Api\TwoFactorController@verifyTwoFactor'); // /2fa/recovery-use — публичный (нет полноценной session-auth до verify). - Route::post('/2fa/recovery-use', 'App\Http\Controllers\Api\AuthController@useRecoveryCode'); + Route::post('/2fa/recovery-use', 'App\Http\Controllers\Api\TwoFactorController@useRecoveryCode'); // /forgot — публичный (anti-enumeration unified-ответ + rate-limit). - Route::post('/forgot', 'App\Http\Controllers\Api\AuthController@forgotPassword'); + Route::post('/forgot', 'App\Http\Controllers\Api\PasswordResetController@forgotPassword'); // /reset-password — публичный (deep-link из email с token+email+password). - Route::post('/reset-password', 'App\Http\Controllers\Api\AuthController@resetPassword'); + Route::post('/reset-password', 'App\Http\Controllers\Api\PasswordResetController@resetPassword'); Route::middleware('auth:sanctum')->group(function () { Route::get('/me', 'App\Http\Controllers\Api\AuthController@me'); Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');