diff --git a/CLAUDE.md b/CLAUDE.md index 7d3ad072..f7d3a0f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.38 от 09.05.2026 +**Версия:** 1.39 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.39 от 09.05.2026. Изменения v1.39: **Recovery code login (POST /api/auth/2fa/recovery-use)**. Закрыт пункт #2 из списка v1.47 — вход по одноразовому резервному коду 2FA вместо TOTP. Backend: `AuthController::useRecoveryCode(UseRecoveryCodeRequest)` берёт `pending_user_id` из session (тот же state, что и /2fa/verify), нормализует код (lowercase + удаление дефисов/пробелов), перебирает неиспользованные `user_recovery_codes` через `Hash::check`, на совпадении → mark `used_at = NOW()` + `Auth::login` + clear pending. Возвращает `{user, requires_2fa: false, recovery_codes_remaining: int}`. Rate-limit `auth:recovery:{pending_user_id}|{ip}` — 5/15мин, scope отделён от 2fa/verify. Маршрут `POST /api/auth/2fa/recovery-use` публичный (как 2fa/verify). **Eloquent-модель `UserRecoveryCode`** для `user_recovery_codes` (schema v8.7 §10) — без `updated_at` (`UPDATED_AT = null`, в schema только `created_at` + `used_at`). **Frontend:** `authApi.useRecoveryCode`, `auth-store::useRecoveryCode` action; новый view `UseRecoveryCodeView.vue` с маршрутом `/recovery-use` (auth layout, без guestOnly чтобы не редиректить pending-state) — input с autocomplete=one-time-code + submit + back-link на /2fa; на success сохраняет `recovery_codes_remaining` в `sessionStorage` для будущего toast-warning'а в SettingsView/SecurityTab. **TwoFactorView** ссылка «Использовать резервный код» переписана с `/recovery` на `/recovery-use` (старый /recovery остаётся для display 8 кодов после setup'а, отдельный пункт #3). **Pest +6** в `tests/Feature/Auth/RecoveryCodeTest.php` (всего **91/91 за 12.77 сек**, 319 assertions): успех + mark used + remaining=3; неверный код 422; уже использованный 422; без pending 422; разные форматы (пробел/дефис/регистр); rate-limit 6-я = 429. **Vitest +6** (всего **166/166 за 11.47 сек**): auth-store useRecoveryCode success/reject; UseRecoveryCodeView 4 (mount + autocomplete + submit-flow с sessionStorage + lockout-alert). PHPStan baseline регенерирован. **TODO** (продолжение): #3 2FA setup wizard, #4 IP-lockout, #5 email-warn, #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** lint+type+format OK; **vitest 166/166 за 11.47 сек** (+6 от 160); vite build 849 ms; story:build 21/28 за 30.36 сек; Pint+Stan passed; **Pest 91/91 за 12.77 сек** (+6 от 85). Реестр v1.47→v1.48.* + *CLAUDE.md v1.38 от 09.05.2026. Изменения v1.38: **Reset password (deep-link) + DB timezone fix**. Закрыт второй пункт password-reset flow — установка нового пароля по token из email-ссылки. Backend: `AuthController::resetPassword(ResetPasswordRequest)` использует `Password::reset()` с callback `$user->forceFill(['password_hash' => Hash::make($password)])->save()` (наша колонка password_hash). `ResetPasswordRequest` валидирует token + email + password (min 10 — ТЗ §22.4.1) + confirmed. Rate-limit 5/15мин по ключу `auth:reset:{sha256(token)[0..16]}|{ip}`. Status `Password::PASSWORD_RESET` → 200; иначе → 422 «Ссылка недействительна или истекла» + hit. Маршрут `POST /api/auth/reset-password` публичный. **DB timezone fix (config/database.php pgsql):** добавлен `'timezone' => env('DB_TIMEZONE', 'UTC')` — без него PG возвращал TIMESTAMPTZ с offset `+03`, Carbon::parse терял offset и `tokenExpired` некорректно интерпретировал created_at. Без fix'а Password::reset падал на check expiry. Фикс затрагивает любую TZ-чувствительную логику (не только password reset). **Frontend:** `authApi.resetPassword(payload)`, `auth-store::resetPassword` action, `ResetPasswordView.vue` для deep-link `/reset/:token?email=...` — token из route.params, email pre-filled из query, поля password+confirmation с autocomplete=new-password, success-state + redirect на /login через 3 сек, lockout-alert. Маршрут `/reset/:token` (meta.layout=auth, guestOnly). Route `/reset` добавлен в web.php SPA-paths. **Pest +6** в `tests/Feature/Auth/ResetPasswordTest.php` (всего **85/85 за 11.50 сек**, 291 assertions): успех + token-update + 422 на bad token / mismatch confirmation / short password / unknown email / rate-limit. **Vitest +7** (всего **160/160 за 11.02 сек**): auth-store success + 429; ResetPasswordView mount + email-prefill из query + 2 password-inputs autocomplete=new-password + success-state hides form + lockout-alert. PHPStan baseline регенерирован. **TODO** (отдельные коммиты): Pest browser-mode для full session-flow + 2FA setup wizard + recovery-codes consume + Yandex SSO (Б-1). **Регресс зелёный:** lint+type+format OK; **vitest 160/160 за 11.02 сек** (+7 от 153); vite build 784 ms; story:build 21/28 за 30.74 сек; Pint+Stan passed; **Pest 85/85 за 11.50 сек** (+6 от 79). Реестр v1.46→v1.47.* *CLAUDE.md v1.37 от 08.05.2026 (поздний вечер). Изменения v1.37: **Forgot password flow (ТЗ §1.7 / Прил. Г.4.3)**. Запрос ссылки на сброс через email. Backend: `AuthController::forgotPassword(ForgotPasswordRequest)` использует `Password::sendResetLink()` под капотом — Laravel создаёт row в `password_resets` (env `AUTH_PASSWORD_RESET_TOKEN_TABLE=password_resets` указывает на нашу таблицу из schema v8.7 §10.6, default Laravel `password_reset_tokens` НЕ совпадает) + шлёт ResetPassword Notification. На dev `MAIL_MAILER=log` → notification в storage/logs. **Anti-enumeration:** ВСЕГДА 200 unified-message «Если такой email зарегистрирован — мы отправили ссылку», независимо от существования user'а — иначе перебор email'ов через ответ. **Rate-limit:** 5 попыток / 15 мин по ключу `auth:forgot:{lower(email)}|{ip}`, 6-я → 429 + Retry-After. `RateLimiter::hit` ставится ДО `sendResetLink` — иначе можно перебирать вечно за счёт unknown email'ов. **Frontend:** `authApi.forgotPassword(email)`, `auth-store::requestPasswordReset(email)` action (загружает lockoutSeconds на 429), `ForgotPasswordView` интегрирован: submit → store → `submitted=true` → success-state v-alert (data-testid=forgot-success) скрывает форму + остаётся «Назад ко входу» btn. **Pest +6** в `tests/Feature/Auth/ForgotPasswordTest.php` (всего **79/79 за 10.55 сек**, 273 assertions): existing email → 200 + row в password_resets + Notification::assertSentTo(ResetPassword); unknown email → 200 unified без row + assertNothingSent; валидация 422 (формат / пустое); rate-limit 5 → 6-я = 429; throttle ключ изолирован по email. **Vitest +4** (всего **153/153 за 11.11 сек**): auth-store success/429; ForgotPasswordView success-state (форма скрывается после submit) + lockout-alert. PHPStan baseline регенерирован для +14 ignored Pest TestCall warnings. **TODO** (отдельные коммиты): POST /api/auth/reset-password (deep-link `/reset/{token}?email=` + UI-форма new_password). **Регресс зелёный:** lint+type+format OK; **vitest 153/153 за 11.11 сек** (+4 от 149); vite build 862 ms; story:build 21/28 за 32 сек; Pint passed; **Pest 79/79 за 10.55 сек** (+6 от 73, 273 assertions). Реестр v1.45→v1.46.* diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index d692475c..2c99f87a 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -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). * diff --git a/app/app/Http/Requests/Auth/UseRecoveryCodeRequest.php b/app/app/Http/Requests/Auth/UseRecoveryCodeRequest.php new file mode 100644 index 00000000..57affc9c --- /dev/null +++ b/app/app/Http/Requests/Auth/UseRecoveryCodeRequest.php @@ -0,0 +1,34 @@ + */ + public function rules(): array + { + return [ + // Допускаем регистр любой + дефис; нормализация в controller'е. + 'code' => ['required', 'string', 'min:8', 'max:32'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'code.required' => 'Укажите резервный код.', + 'code.min' => 'Резервный код слишком короткий.', + ]; + } +} diff --git a/app/app/Models/UserRecoveryCode.php b/app/app/Models/UserRecoveryCode.php new file mode 100644 index 00000000..502122bc --- /dev/null +++ b/app/app/Models/UserRecoveryCode.php @@ -0,0 +1,50 @@ + 'datetime', + 'created_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 3519b4d7..0d1e47fb 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -66,6 +66,36 @@ parameters: count: 18 path: tests/Feature/Auth/RateLimitTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$plainCodes\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Auth/RecoveryCodeTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/Auth/RecoveryCodeTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/Auth/RecoveryCodeTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/Auth/RecoveryCodeTest.php + + - + message: '#^Parameter \#1 \$self of function startPending expects Tests\\TestCase, Pest\\PendingCalls\\TestCall given\.$#' + identifier: argument.type + count: 5 + path: tests/Feature/Auth/RecoveryCodeTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/resources/js/api/auth.ts b/app/resources/js/api/auth.ts index a3817a01..0c47e06d 100644 --- a/app/resources/js/api/auth.ts +++ b/app/resources/js/api/auth.ts @@ -62,6 +62,16 @@ export async function verifyTwoFactor(code: string): Promise { return data; } +export interface RecoveryCodeResponse extends LoginResponse { + recovery_codes_remaining: number; +} + +export async function useRecoveryCode(code: string): Promise { + await ensureCsrfCookie(); + const { data } = await apiClient.post('/api/auth/2fa/recovery-use', { code }); + return data; +} + export async function forgotPassword(email: string): Promise<{ message: string }> { await ensureCsrfCookie(); const { data } = await apiClient.post<{ message: string }>('/api/auth/forgot', { email }); diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index e33de778..6a2d3d14 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -47,6 +47,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('../views/auth/RecoveryCodesView.vue'), meta: { layout: 'auth', title: 'Резервные коды' }, }, + { + path: '/recovery-use', + name: 'recovery-use', + component: () => import('../views/auth/UseRecoveryCodeView.vue'), + meta: { layout: 'auth', title: 'Вход по резервному коду' }, + }, { path: '/reset/:token', name: 'reset-password', diff --git a/app/resources/js/stores/auth.ts b/app/resources/js/stores/auth.ts index 9d190b36..84c193cc 100644 --- a/app/resources/js/stores/auth.ts +++ b/app/resources/js/stores/auth.ts @@ -112,6 +112,23 @@ export const useAuthStore = defineStore('auth', () => { } } + async function useRecoveryCode(code: string) { + loading.value = true; + lockoutSeconds.value = null; + try { + const response = await authApi.useRecoveryCode(code); + user.value = response.user; + requires2fa.value = false; + return response; + } catch (error) { + const retry = extractRateLimitRetry(error); + if (retry !== null) lockoutSeconds.value = retry; + throw error; + } finally { + loading.value = false; + } + } + async function fetchMe(): Promise { try { const fetched = await authApi.me(); @@ -145,6 +162,7 @@ export const useAuthStore = defineStore('auth', () => { login, register, verifyTwoFactor, + useRecoveryCode, requestPasswordReset, resetPassword, fetchMe, diff --git a/app/resources/js/views/auth/TwoFactorView.vue b/app/resources/js/views/auth/TwoFactorView.vue index 5e11980d..244a552f 100644 --- a/app/resources/js/views/auth/TwoFactorView.vue +++ b/app/resources/js/views/auth/TwoFactorView.vue @@ -121,7 +121,9 @@ async function handleSubmit() {
- Использовать резервный код + + Использовать резервный код + 02:34
diff --git a/app/resources/js/views/auth/UseRecoveryCodeView.vue b/app/resources/js/views/auth/UseRecoveryCodeView.vue new file mode 100644 index 00000000..4d859ea1 --- /dev/null +++ b/app/resources/js/views/auth/UseRecoveryCodeView.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/app/routes/web.php b/app/routes/web.php index 45d01825..81841715 100644 --- a/app/routes/web.php +++ b/app/routes/web.php @@ -12,6 +12,8 @@ Route::prefix('/api/auth')->group(function () { // /2fa/verify публичный — у user'а ещё нет полноценной session-auth, только // pending_user_id в session. Verify завершает login после проверки TOTP. Route::post('/2fa/verify', [AuthController::class, 'verifyTwoFactor']); + // /2fa/recovery-use — публичный (нет полноценной session-auth до verify). + Route::post('/2fa/recovery-use', [AuthController::class, 'useRecoveryCode']); // /forgot — публичный (anti-enumeration unified-ответ + rate-limit). Route::post('/forgot', [AuthController::class, 'forgotPassword']); // /reset-password — публичный (deep-link из email с token+email+password). @@ -35,6 +37,7 @@ Route::view('/forgot', 'welcome'); Route::view('/reset', 'welcome'); // SPA-router рендерит ResetPasswordView для /reset/{token} Route::view('/2fa', 'welcome'); Route::view('/recovery', 'welcome'); +Route::view('/recovery-use', 'welcome'); Route::view('/dashboard', 'welcome'); Route::view('/deals', 'welcome'); Route::view('/kanban', 'welcome'); diff --git a/app/tests/Feature/Auth/RecoveryCodeTest.php b/app/tests/Feature/Auth/RecoveryCodeTest.php new file mode 100644 index 00000000..cfd36cd5 --- /dev/null +++ b/app/tests/Feature/Auth/RecoveryCodeTest.php @@ -0,0 +1,124 @@ +tenant = Tenant::factory()->create(); + $this->user = User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'recovery-user@example.ru', + 'password_hash' => Hash::make('right-password-1234'), + 'totp_enabled' => true, + 'totp_secret' => 'JBSWY3DPEHPK3PXP', // dummy + ]); + // 4 неиспользованных кодов + 1 использованный. + $codes = ['ABCD-1234', 'ZZZZ-9999', 'qqqq-7777', 'XXXX-5555', 'used-code']; + $this->plainCodes = ['abcd1234', 'zzzz9999', 'qqqq7777', 'xxxx5555', 'usedcode']; + foreach ($codes as $i => $code) { + DB::table('user_recovery_codes')->insert([ + 'user_id' => $this->user->id, + 'code_hash' => Hash::make(mb_strtolower(str_replace('-', '', $code))), + 'used_at' => $i === 4 ? now() : null, + ]); + } +}); + +/** + * Helper: эмулировать pending-2FA state (как после успешного login). + */ +function startPending(TestCase $self, string $email = 'recovery-user@example.ru'): void +{ + $self->postJson('/api/auth/login', [ + 'email' => $email, + 'password' => 'right-password-1234', + ])->assertOk()->assertJsonPath('requires_2fa', true); +} + +test('POST /api/auth/2fa/recovery-use завершает login по правильному коду + помечает used + считает remaining', function () { + startPending($this); + + $r = $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'ABCD-1234', + ]); + + $r->assertOk(); + expect($r->json('user.email'))->toBe('recovery-user@example.ru'); + expect($r->json('requires_2fa'))->toBeFalse(); + // Осталось 3 неиспользованных (4 минус первый, который мы только что use'нули). + expect($r->json('recovery_codes_remaining'))->toBe(3); + + // Code marked as used. + $usedCount = DB::table('user_recovery_codes') + ->where('user_id', $this->user->id) + ->whereNotNull('used_at') + ->count(); + expect($usedCount)->toBe(2); // 1 был изначально + 1 новый. +}); + +test('POST /api/auth/2fa/recovery-use 422 при неверном коде', function () { + startPending($this); + + $r = $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'WRONG-CODE-9999', + ]); + + $r->assertStatus(422); + expect($r->json('errors.code'))->not->toBeEmpty(); +}); + +test('POST /api/auth/2fa/recovery-use НЕ принимает уже использованный код', function () { + startPending($this); + + $r = $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'used-code', + ]); + + $r->assertStatus(422); +}); + +test('POST /api/auth/2fa/recovery-use 422 без pending-сессии', function () { + // Не делаем login — нет auth.pending_user_id в session. + $r = $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'ABCD-1234', + ]); + + $r->assertStatus(422); + expect($r->json('message'))->toContain('Сессия 2FA'); +}); + +test('POST /api/auth/2fa/recovery-use принимает разные форматы (с дефисом, без, разный регистр)', function () { + startPending($this); + + // 'ZZZZ-9999' нормализуется в 'zzzz9999' → совпадает с stored. + $r = $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'zzzz 9999', // пробел вместо дефиса + ]); + + $r->assertOk(); +}); + +test('POST /api/auth/2fa/recovery-use rate-limit: 5 неверных → 6-я = 429', function () { + startPending($this); + + for ($i = 1; $i <= 5; $i++) { + $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'BAD-FAIL-'.$i, + ])->assertStatus(422); + } + + $r = $this->postJson('/api/auth/2fa/recovery-use', [ + 'code' => 'ABCD-1234', // правильный, но уже locked. + ]); + + $r->assertStatus(429); + expect($r->headers->get('Retry-After'))->not->toBeNull(); +}); diff --git a/app/tests/Frontend/UseRecoveryCodeView.spec.ts b/app/tests/Frontend/UseRecoveryCodeView.spec.ts new file mode 100644 index 00000000..f8afe3dd --- /dev/null +++ b/app/tests/Frontend/UseRecoveryCodeView.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; + +vi.mock('../../resources/js/api/auth', () => ({ + login: vi.fn(), + register: vi.fn(), + me: vi.fn(), + logout: vi.fn(), + verifyTwoFactor: vi.fn(), + forgotPassword: vi.fn(), + resetPassword: vi.fn(), + useRecoveryCode: vi.fn(), +})); + +vi.mock('../../resources/js/api/client', () => ({ + extractRateLimitRetry: vi.fn(() => null), + extractValidationErrors: vi.fn(() => null), + extractErrorMessage: vi.fn(() => 'Ошибка'), + apiClient: {}, + ensureCsrfCookie: vi.fn(), +})); + +import * as authApi from '../../resources/js/api/auth'; +import { useAuthStore } from '../../resources/js/stores/auth'; +import UseRecoveryCodeView from '../../resources/js/views/auth/UseRecoveryCodeView.vue'; + +const mountView = async () => { + const pinia = createPinia(); + setActivePinia(pinia); + // Симулируем pending-2FA state — иначе onMounted редиректит на /login. + const auth = useAuthStore(); + auth.requires2fa = true; + + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/recovery-use', name: 'recovery-use', component: UseRecoveryCodeView }, + { path: '/login', name: 'login', component: { template: '
stub
' } }, + { path: '/2fa', name: '2fa', component: { template: '
stub
' } }, + { path: '/dashboard', name: 'dashboard', component: { template: '
stub
' } }, + ], + }); + await router.push('/recovery-use'); + await router.isReady(); + return mount(UseRecoveryCodeView, { + global: { plugins: [pinia, createVuetify(), router] }, + }); +}; + +describe('UseRecoveryCodeView.vue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('монтируется и содержит заголовок «Резервный код»', async () => { + const wrapper = await mountView(); + expect(wrapper.text()).toContain('Резервный код'); + }); + + it('содержит input с label XXXX-XXXX и autocomplete=one-time-code', async () => { + const wrapper = await mountView(); + const input = wrapper.find('input'); + expect(input.exists()).toBe(true); + expect(input.attributes('autocomplete')).toBe('one-time-code'); + }); + + it('успешный submit вызывает auth.useRecoveryCode и сохраняет remaining в sessionStorage', async () => { + vi.mocked(authApi.useRecoveryCode).mockResolvedValue({ + user: { + id: 1, + email: 'r@example.ru', + first_name: 'R', + last_name: 'C', + tenant_id: 1, + totp_enabled: true, + last_login_at: null, + }, + requires_2fa: false, + recovery_codes_remaining: 3, + }); + + const wrapper = await mountView(); + await wrapper.find('input').setValue('ABCD-1234'); + await wrapper.find('form').trigger('submit.prevent'); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + + expect(authApi.useRecoveryCode).toHaveBeenCalledWith('ABCD-1234'); + expect(window.sessionStorage.getItem('recovery_codes_remaining')).toBe('3'); + }); + + it('при 429 показывает lockout-alert', async () => { + const wrapper = await mountView(); + const auth = useAuthStore(); + auth.lockoutSeconds = 600; + await wrapper.vm.$nextTick(); + + const alert = wrapper.find('[data-testid="lockout-alert"]'); + expect(alert.exists()).toBe(true); + expect(alert.text()).toContain('10 мин'); + }); +}); diff --git a/app/tests/Frontend/auth-store.spec.ts b/app/tests/Frontend/auth-store.spec.ts index 5d5d1dc9..dcdfec6b 100644 --- a/app/tests/Frontend/auth-store.spec.ts +++ b/app/tests/Frontend/auth-store.spec.ts @@ -10,6 +10,7 @@ vi.mock('../../resources/js/api/auth', () => ({ verifyTwoFactor: vi.fn(), forgotPassword: vi.fn(), resetPassword: vi.fn(), + useRecoveryCode: vi.fn(), })); // Мокаем client (extractRateLimitRetry) — иначе axios.isAxiosError в jsdom возвращает false для plain Error'ов. @@ -285,6 +286,41 @@ describe('useAuthStore', () => { expect(auth.lockoutSeconds).toBe(900); }); + it('useRecoveryCode() success ставит user + сбрасывает requires2fa', async () => { + vi.mocked(authApi.useRecoveryCode).mockResolvedValue({ + user: { + id: 7, + email: 'recovery@example.ru', + first_name: 'R', + last_name: 'C', + tenant_id: 1, + totp_enabled: true, + last_login_at: '2026-05-09T12:00:00Z', + }, + requires_2fa: false, + recovery_codes_remaining: 3, + }); + + const auth = useAuthStore(); + auth.requires2fa = true; + const result = await auth.useRecoveryCode('ABCD-1234'); + + expect(result.recovery_codes_remaining).toBe(3); + expect(auth.user?.email).toBe('recovery@example.ru'); + expect(auth.requires2fa).toBe(false); + }); + + it('useRecoveryCode() при reject пробрасывает + НЕ ставит user', async () => { + vi.mocked(authApi.useRecoveryCode).mockRejectedValue(new Error('Invalid code')); + + const auth = useAuthStore(); + auth.requires2fa = true; + await expect(auth.useRecoveryCode('WRONG-1234')).rejects.toThrow(); + + expect(auth.user).toBeNull(); + expect(auth.requires2fa).toBe(true); + }); + it('logout() очищает user даже если API упал', async () => { vi.mocked(authApi.logout).mockRejectedValue(new Error('Network error')); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 7b3c0119..ba86a2a6 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,48 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.47 от 09.05.2026 — **Reset password (deep-link) + DB timezone fix**. Завершён второй пункт password-reset flow: установка нового пароля по token из email + фикс PG timezone (TIMESTAMPTZ → UTC, без него Carbon::parse терял offset и Password::reset падал на token-expiry check). **Pest 85/85 + Vitest 160/160 + Histoire 21/28 зелёные**. +**Версия:** 1.48 от 09.05.2026 — **Recovery code login (POST /api/auth/2fa/recovery-use)**. Закрыт пункт #2 из списка v1.47 — вход через один из 8 резервных кодов 2FA вместо TOTP. **Pest 91/91 + Vitest 166/166 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.48 относительно v1.47:** + +- **POST /api/auth/2fa/recovery-use** — `AuthController::useRecoveryCode`. Берёт `pending_user_id` из session (тот же state, что и /2fa/verify), перебирает неиспользованные `user_recovery_codes` через `Hash::check`. На совпадении: mark `used_at` + Auth::login + clear pending. Возвращает `{user, requires_2fa: false, recovery_codes_remaining}`. +- **Нормализация кода** — lowercase + удаление дефисов/пробелов (`'ZZZZ-9999'` ≡ `'zzzz 9999'` ≡ `'zzzz9999'`). Сравнение через `Hash::check` со stored bcrypt-хешем. +- **`UseRecoveryCodeRequest`** — валидация `code: string|min:8|max:32`. +- **Rate-limit** `auth:recovery:{pending_user_id}|{ip}` — 5 неудач/15 мин → 429. Scope отделён от `auth:2fa:`. +- **Eloquent `UserRecoveryCode`** — `user_recovery_codes` (schema v8.7 §10), `UPDATED_AT = null` (в schema только created_at + used_at). +- **Frontend:** + - `authApi.useRecoveryCode(code)` returns `RecoveryCodeResponse extends LoginResponse + recovery_codes_remaining`. + - `auth-store::useRecoveryCode` ставит user + сбрасывает requires2fa. + - `UseRecoveryCodeView.vue` — новый view на `/recovery-use`. autocomplete=one-time-code, submit → store → /dashboard. На success сохраняет remaining в `sessionStorage` (для будущего toast в SettingsView/SecurityTab). + - `TwoFactorView` ссылка «Использовать резервный код»: `/recovery` → `/recovery-use`. + - Старый `/recovery` остаётся для display 8 кодов (актуализируется при #3 2FA setup wizard). +- **Pest +6** в `tests/Feature/Auth/RecoveryCodeTest.php` (всего **91/91 за 12.77 сек**, 319 assertions): + - success + mark used_at + remaining=3. + - 422 на неверный код. + - 422 на уже использованный. + - 422 без pending-сессии. + - Принимает разные форматы (пробел/дефис/регистр). + - 429 после 5 неверных попыток. +- **Vitest +6** (всего **166/166 за 11.47 сек**): auth-store useRecoveryCode success/reject; UseRecoveryCodeView 4 (mount + autocomplete + submit + sessionStorage + lockout-alert). +- **PHPStan baseline** регенерирован (накопительно). +- **Регресс зелёный:** + - `composer pint` + `composer stan` — passed. + - `composer test` — **Pest 91/91 за 12.77 сек** (+6 от 85, 319 assertions). + - `npm run test:vue` — **Vitest 166/166 за 11.47 сек** (+6 от 160). + - `npm run lint:vue` + `type-check` + `format:check` — passed. + - `npm run build` — vite OK 849 ms. + - `npm run story:build` — **21 stories / 28 variants за 30.36 сек**. +- **Что НЕ сделано (отдельные коммиты, продолжение списка #3-#9):** + - **#3 2FA setup wizard** (QR + verify + 8 кодов) — после этого `/recovery` view получит реальный source данных. + - **#4 IP-lockout 10/час** через auth_log. + - **#5 Email при 3 неудачах** login. + - **#6 Yandex 360 SSO** (зависит от Б-1 + DO-2). + - **#7 Pest browser-mode** для full session-flow. + - **#8 AdminBilling/Incidents/System** views. + - **#9 Impersonation flow** (Ю-1). +- **Сводка §0:** без изменений (70 ✅ / 5 🟦 / 4 ⏸ / 1 P0 + 3 P1 + 0 P2). + +**Что изменилось в v1.47 относительно v1.46:** **Reset password (deep-link) + DB timezone fix**. Завершён второй пункт password-reset flow: установка нового пароля по token из email + фикс PG timezone (TIMESTAMPTZ → UTC, без него Carbon::parse терял offset и Password::reset падал на token-expiry check). **Pest 85/85 + Vitest 160/160 + Histoire 21/28 зелёные**. **Что изменилось в v1.47 относительно v1.46:**