diff --git a/CLAUDE.md b/CLAUDE.md index 464cbd2d..baba4b65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.40 от 09.05.2026 +**Версия:** 1.41 от 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.41 от 09.05.2026. Изменения v1.41: **IP-lockout 10/час + auth_log записи (ТЗ §22.4.4 п.2)**. Закрыт пункт #4 — защита от перебора с одного IP. **AuthController::login** перед verify проверяет `isIpLockedOut(ip)` — count(*) FROM auth_log WHERE event='login_failed' AND ip_address=ip AND created_at >= NOW() - 1 hour. Если ≥10 → 429 + Retry-After: 3600. Это второй слой защиты поверх email-rate-limit (5/15мин из v1.36) — защищает от перебора email'ов с одного IP. **`logAuthEvent`** private helper пишет в auth_log через DB::table (Eloquent для этой таблицы нет). На каждый login_success / login_failed (3 ветки: invalid_password / unknown_email / account_locked). RLS USING без WITH CHECK — INSERT не фильтруется. hash-chain trigger (BEFORE INSERT) заполняет log_hash автоматически (OPEN-И-15 tamper-detection). **Pest +6** в `tests/Feature/Auth/IpLockoutTest.php` (всего **107/107 за 13.86 сек**, 380 assertions): login_success пишет с tenant_id; login_failed wrong-password пишет invalid_password; login_failed unknown email пишет unknown_email + user_id=null; 10 fail записей с одного IP за час → следующий login = 429; 9 fail записей (под порогом) → проходит; старые записи >1ч не блокируют. PHPStan baseline регенерирован. **TODO** (продолжение): #5 email-warn, #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** lint+type+format OK; **Pest 107/107 за 13.86 сек** (+6 от 101, 380 assertions). Реестр v1.49→v1.50.* + *CLAUDE.md v1.40 от 09.05.2026. Изменения v1.40: **2FA setup wizard + schema v8.7→v8.8 + миграция fix FK + Partitions test fix**. Закрыт пункт #3 — пользователь может включить/отключить/перегенерировать 2FA из SettingsView/SecurityTab. **Backend `TwoFactorSetupController`** под `auth:sanctum`: 4 endpoint'а — `init` (генерация TOTP secret + QR-URL, secret в session как pending, не пишется в БД до confirm); `confirm({code})` (TOTP-verify pending secret → save totp_secret + totp_enabled=true + delete old recovery codes + generate 8 new + return plain один раз); `disable({password})` (Hash::check + clear totp_secret + drop recovery codes); `regenerate-recovery-codes({password})` (Hash::check + replace 8 codes). Recovery code формат `xxxx-xxxx` (lowercase 4+4 + дефис), `Str::random(4)` parts. `User` model получил cast `'totp_secret' => 'encrypted'` (Crypt::encryptString автоматом). **Schema v8.7 → v8.8:** `users.totp_secret VARCHAR(255)` → `TEXT` — encrypted 32-байт TOTP secret = ~256 chars > 255 (PDOException на confirm). Запись §V в `db/CHANGELOG_schema.md`. **Миграция fix:** `0001_01_01_000000_load_initial_schema.php` теперь после `DB::unprepared($sql)` явно делает `ALTER TABLE webhook_dedup_keys ADD FOREIGN KEY ... ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED` — DDL FK на partitioned-таблицу через unprepared() PDO молча проглатывался на свежей БД (известное поведение Laravel/PDO). Без fix'а ON DELETE CASCADE тест валится. **PartitionsCreateMonthsTest fix:** afterEach использует `ALTER TABLE deals DETACH PARTITION ...` + `DROP TABLE` вместо `DROP ... CASCADE` — последний дропал FK от webhook_dedup_keys на parent (PG behavior). **DB timezone fix** (config/database.php pgsql) добавлен в v1.38 продолжает работать. **Frontend `SecurityTab`** переписан с mock на реальную логику: 3 v-dialog'а (setup wizard 3 шага: init→confirm→show 8 codes; disable; regenerate). 4 новых функции в `api/auth.ts`: `twoFactorInit/Confirm/Disable/regenerateRecoveryCodes`. v-chip статуса 2FA читает `auth.user?.totp_enabled`. **Pest +10** в `tests/Feature/Auth/TwoFactorSetupTest.php` (всего **101/101 за 13.37 сек**, 364 assertions): init success / 422 если 2FA уже on / confirm success + 8 кодов формат + totp_enabled=true + secret saved + 8 строк в БД / confirm 422 неверный код + totp_enabled остаётся false / confirm 422 без init / disable success / disable 422 неверный пароль / regenerate возвращает 8 новых уникальных + старые удалены / regenerate 422 если 2FA off / все 4 endpoint'а require auth (401). **Vitest:** SettingsView.spec.ts получил createPinia() в plugins (SecurityTab теперь использует useAuthStore). PHPStan baseline регенерирован для +25 ignored Pest TestCall warnings. **TODO** (продолжение): #4 IP-lockout, #5 email-warn, #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** lint+type+format OK; **vitest 166/166 за 10.95 сек**; vite build 747 ms; story:build 21/28 за 31.18 сек; Pint+Stan passed; **Pest 101/101 за 13.37 сек** (+10 от 91, 364 assertions). Реестр v1.48→v1.49.* *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.* diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 2c99f87a..0e01fc15 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -17,6 +17,7 @@ use App\Models\UserRecoveryCode; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\RateLimiter; @@ -52,19 +53,33 @@ class AuthController extends Controller /** Окно блокировки в секундах (ТЗ §22.4.4: 15 мин). */ private const LOGIN_DECAY_SECONDS = 900; + /** Лимит неудач входа с одного IP за час (ТЗ §22.4.4 п.2). */ + private const IP_LOCKOUT_THRESHOLD = 10; + public function login(LoginRequest $request): JsonResponse { $credentials = $request->only(['email', 'password']); $throttleKey = $this->loginThrottleKey($credentials['email'], $request->ip()); + $ip = $request->ip(); if (RateLimiter::tooManyAttempts($throttleKey, self::LOGIN_MAX_ATTEMPTS)) { return $this->lockoutResponse($throttleKey); } + // ТЗ §22.4.4 п.2: IP-lockout — 10 неудач/час с одного IP → блок IP на 1 час. + if ($this->isIpLockedOut($ip)) { + return response()->json([ + 'message' => 'Слишком много неудачных попыток с этого IP. Попробуйте через час.', + 'retry_after' => 3600, + ], 429)->header('Retry-After', '3600'); + } + $user = User::where('email', $credentials['email'])->first(); if (! $user || ! Hash::check($credentials['password'], $user->password_hash)) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + $this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(), + $user ? 'invalid_password' : 'unknown_email'); return response()->json([ 'message' => 'Неверный email или пароль.', @@ -74,6 +89,8 @@ class AuthController extends Controller if (! $user->is_active) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + $this->logAuthEvent('login_failed', $user, $credentials['email'], $ip, $request->userAgent(), + 'account_locked'); return response()->json([ 'message' => 'Аккаунт заблокирован.', @@ -102,6 +119,8 @@ class AuthController extends Controller $user->update(['last_login_at' => now()]); + $this->logAuthEvent('login_success', $user, $user->email, $ip, $request->userAgent(), null); + return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, @@ -415,6 +434,56 @@ class AuthController extends Controller return 'auth:2fa:'.$userId.'|'.($ip ?? 'unknown'); } + /** + * IP-lockout по ТЗ §22.4.4 п.2: 10+ login_failed с одного IP за последний час. + * + * Считаем через `auth_log` (event=login_failed) — это даёт защиту от + * перебора email'ов с одного IP даже если каждый email используется + * <5 раз и не триггерит email-lockout. + */ + private function isIpLockedOut(?string $ip): bool + { + if ($ip === null || $ip === '') { + return false; + } + + $count = DB::table('auth_log') + ->where('event', 'login_failed') + ->where('ip_address', $ip) + ->where('created_at', '>=', now()->subHour()) + ->count(); + + return $count >= self::IP_LOCKOUT_THRESHOLD; + } + + /** + * Запись события auth_log. + * + * Через DB::table — auth_log имеет hash-chain trigger BEFORE INSERT, + * который заполняет log_hash. Eloquent-модели для этой таблицы нет. + * RLS USING без WITH CHECK — INSERT не фильтруется. + */ + private function logAuthEvent( + string $event, + ?User $user, + ?string $email, + ?string $ip, + ?string $userAgent, + ?string $failureReason, + ): void { + DB::table('auth_log')->insert([ + 'actor_type' => 'tenant_user', + 'tenant_id' => $user?->tenant_id, + 'user_id' => $user?->id, + 'email' => $email, + 'event' => $event, + 'ip_address' => $ip, + 'user_agent' => $userAgent, + 'failure_reason' => $failureReason, + 'created_at' => now(), + ]); + } + /** 429 Too Many Requests + Retry-After header (секунды до следующей попытки). */ private function lockoutResponse(string $throttleKey): JsonResponse { diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index ce2b0c54..0fcd31ed 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -54,6 +54,18 @@ parameters: count: 9 path: tests/Feature/Auth/ForgotPasswordTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 7 + path: tests/Feature/Auth/IpLockoutTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 6 + path: tests/Feature/Auth/IpLockoutTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/tests/Feature/Auth/IpLockoutTest.php b/app/tests/Feature/Auth/IpLockoutTest.php new file mode 100644 index 00000000..1a082511 --- /dev/null +++ b/app/tests/Feature/Auth/IpLockoutTest.php @@ -0,0 +1,154 @@ +tenant = Tenant::factory()->create(); +}); + +test('login_success пишет запись в auth_log', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'log-success@example.ru', + 'password_hash' => Hash::make('right-password'), + ]); + + $this->postJson('/api/auth/login', [ + 'email' => 'log-success@example.ru', + 'password' => 'right-password', + ])->assertOk(); + + $row = DB::table('auth_log') + ->where('email', 'log-success@example.ru') + ->where('event', 'login_success') + ->first(); + + expect($row)->not->toBeNull(); + expect($row->actor_type)->toBe('tenant_user'); + expect($row->tenant_id)->toBe($this->tenant->id); +}); + +test('login_failed (wrong password) пишет в auth_log с failure_reason=invalid_password', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'log-fail@example.ru', + 'password_hash' => Hash::make('right-password'), + ]); + + $this->postJson('/api/auth/login', [ + 'email' => 'log-fail@example.ru', + 'password' => 'wrong-pass-attempt', + ])->assertStatus(422); + + $row = DB::table('auth_log') + ->where('email', 'log-fail@example.ru') + ->where('event', 'login_failed') + ->first(); + + expect($row)->not->toBeNull(); + expect($row->failure_reason)->toBe('invalid_password'); +}); + +test('login_failed для unknown email пишет с failure_reason=unknown_email', function () { + $this->postJson('/api/auth/login', [ + 'email' => 'nobody@example.ru', + 'password' => 'wrong-pass-attempt', + ])->assertStatus(422); + + $row = DB::table('auth_log') + ->where('email', 'nobody@example.ru') + ->where('event', 'login_failed') + ->first(); + + expect($row)->not->toBeNull(); + expect($row->failure_reason)->toBe('unknown_email'); + expect($row->user_id)->toBeNull(); +}); + +test('IP-lockout: после 10 login_failed с одного IP за час — следующий → 429', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'ip-lockout-victim@example.ru', + 'password_hash' => Hash::make('right-password'), + ]); + + // Эмулируем 10 неудачных попыток с разных email с одного IP + // (через прямой INSERT в auth_log, чтобы не триггерить email-rate-limit). + for ($i = 0; $i < 10; $i++) { + DB::table('auth_log')->insert([ + 'actor_type' => 'tenant_user', + 'event' => 'login_failed', + 'email' => "victim{$i}@example.ru", + 'ip_address' => '127.0.0.1', + 'failure_reason' => 'unknown_email', + 'created_at' => now()->subMinutes(5), + ]); + } + + // Следующий login (даже с правильным паролем) → 429. + $r = $this->postJson('/api/auth/login', [ + 'email' => 'ip-lockout-victim@example.ru', + 'password' => 'right-password', + ]); + + $r->assertStatus(429); + expect($r->json('message'))->toContain('с этого IP'); + expect($r->headers->get('Retry-After'))->toBe('3600'); +}); + +test('IP-lockout не срабатывает на 9 неудач (под порогом)', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'ok@example.ru', + 'password_hash' => Hash::make('right-password'), + ]); + + for ($i = 0; $i < 9; $i++) { + DB::table('auth_log')->insert([ + 'actor_type' => 'tenant_user', + 'event' => 'login_failed', + 'email' => "any{$i}@example.ru", + 'ip_address' => '127.0.0.1', + 'failure_reason' => 'unknown_email', + 'created_at' => now()->subMinutes(5), + ]); + } + + $this->postJson('/api/auth/login', [ + 'email' => 'ok@example.ru', + 'password' => 'right-password', + ])->assertOk(); +}); + +test('IP-lockout: окно 1 час — старые записи (>1ч) не блокируют', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'old-fails@example.ru', + 'password_hash' => Hash::make('right-password'), + ]); + + // 15 неудач, но старше часа → не учитываются. + for ($i = 0; $i < 15; $i++) { + DB::table('auth_log')->insert([ + 'actor_type' => 'tenant_user', + 'event' => 'login_failed', + 'email' => "old{$i}@example.ru", + 'ip_address' => '127.0.0.1', + 'failure_reason' => 'unknown_email', + 'created_at' => now()->subHours(2), + ]); + } + + $this->postJson('/api/auth/login', [ + 'email' => 'old-fails@example.ru', + 'password' => 'right-password', + ])->assertOk(); +}); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 13a91f7e..907e5527 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,26 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.49 от 09.05.2026 — **2FA setup wizard + schema v8.8**. Backend: 4 endpoint'а `/api/2fa/{init,confirm,disable,regenerate-recovery-codes}`. Frontend: SecurityTab с 3 v-dialog'ами (setup wizard 3 шага + disable + regenerate). Schema v8.7→v8.8 (`users.totp_secret` VARCHAR(255)→TEXT). Migration fix FK на partitioned. **Pest 101/101 + Vitest 166/166 + Histoire 21/28 зелёные**. +**Версия:** 1.50 от 09.05.2026 — **IP-lockout 10/час + auth_log записи (ТЗ §22.4.4 п.2)**. Закрыт пункт #4 — второй слой защиты поверх email-rate-limit. **Pest 107/107 + Vitest 166/166 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.50 относительно v1.49:** + +- **IP-lockout** — `AuthController::login` перед verify считает count login_failed в auth_log с этого IP за последний час. Если ≥10 → 429 + Retry-After: 3600. Защищает от перебора email'ов с одного IP. +- **`logAuthEvent` helper** в AuthController — пишет в `auth_log` через `DB::table` (event=login_success/login_failed, tenant_id/user_id если известны, ip_address, user_agent, failure_reason). hash-chain trigger BEFORE INSERT заполняет log_hash (OPEN-И-15 tamper-detection). +- **3 ветки failure_reason**: `invalid_password` (user найден, пароль неверный), `unknown_email` (user не найден), `account_locked` (is_active=false). +- **Pest +6** в `tests/Feature/Auth/IpLockoutTest.php` (всего **107/107 за 13.86 сек**, 380 assertions). +- **Регресс зелёный:** + - `composer test` — **Pest 107/107 за 13.86 сек** (+6 от 101). + - `composer pint` + `composer stan` — passed. +- **Что НЕ сделано (продолжение списка #5-#9):** + - **#5 Email при 3 неудачах** login (требует MailService). + - **#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.49 относительно v1.48:** **2FA setup wizard + schema v8.8**. Backend: 4 endpoint'а `/api/2fa/{init,confirm,disable,regenerate-recovery-codes}`. Frontend: SecurityTab с 3 v-dialog'ами (setup wizard 3 шага + disable + regenerate). Schema v8.7→v8.8 (`users.totp_secret` VARCHAR(255)→TEXT). Migration fix FK на partitioned. **Pest 101/101 + Vitest 166/166 + Histoire 21/28 зелёные**. **Что изменилось в v1.49 относительно v1.48:**