phase2(recovery-code): POST /api/auth/2fa/recovery-use + UseRecoveryCodeView

- AuthController::useRecoveryCode перебирает unused codes через Hash::check, нормализация (lowercase + remove dash/space)
- UserRecoveryCode Eloquent (UPDATED_AT=null — schema без updated_at)
- Rate-limit auth:recovery:{pending_user_id}|{ip} (5/15мин)
- Returns recovery_codes_remaining для UI-warning'а (sessionStorage на frontend)
- UseRecoveryCodeView.vue → POST /api/auth/2fa/recovery-use, /recovery-use route, autocomplete=one-time-code
- TwoFactorView "резервный код" ссылка /recovery → /recovery-use
- Pest +6 RecoveryCodeTest (91/91 за 12.77с, 319 assertions)
- Vitest +6 (166/166 за 11.47с)
- TODO: #3 2FA setup wizard (после этого /recovery view получит реальный source данных)
- Регресс: lint+type+format OK; build 849ms; story:build 21/28 за 30.36с; Pint+Stan passed
- CLAUDE.md v1.38→v1.39, реестр v1.47→v1.48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-09 03:43:58 +03:00
parent 9c488122a1
commit c39d555e6f
15 changed files with 682 additions and 3 deletions
@@ -9,9 +9,11 @@ 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\Models\Tenant;
use App\Models\User;
use App\Models\UserRecoveryCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -209,6 +211,97 @@ class AuthController extends Controller
return response()->json(['message' => 'Вы вышли из системы.']);
}
/**
* 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,
]);
}
/**
* POST /api/auth/forgot запрос ссылки на сброс пароля (ТЗ §1.7).
*