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:
@@ -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).
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user