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); $this->logAuthEvent( '2fa_verify_failed', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), 'invalid_code', ); 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()]); app(UserSessionTracker::class)->record($request, $user->id); $this->logAuthEvent( '2fa_verify_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null, ); 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); $this->logAuthEvent( '2fa_recovery_failed', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), 'invalid_or_used', ); 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()]); app(UserSessionTracker::class)->record($request, $user->id); $this->logAuthEvent( '2fa_recovery_used', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null, ); // Кол-во оставшихся неиспользованных кодов — для 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, ]; } }