From 47bcdb2b355d6abdf34f184f1aa8f934e2cff0ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 13 Jul 2026 19:56:54 +0300 Subject: [PATCH] =?UTF-8?q?feat(visitors):=20=D1=81=D0=BE=D0=B1=D1=8B?= =?UTF-8?q?=D1=82=D0=B8=D1=8F=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B8=D1=88=D0=B5=D1=82=20backend,=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=B2=D1=8F=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D1=81=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три точки: обычный вход, вход через 2FA (оба пути — код и резервный), подтверждение почты (именно там клиент реально создаётся, а не на /register). Учёт в try/catch — ошибка учёта не может уронить вход. Регресс авторизации 114/114. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Http/Controllers/Api/AuthController.php | 4 + .../Api/RegistrationController.php | 5 + .../Controllers/Api/TwoFactorController.php | 7 ++ app/app/Services/Tracking/VisitTracker.php | 24 ++++ app/tests/Feature/TrackBackendEventsTest.php | 106 ++++++++++++++++++ 5 files changed, 146 insertions(+) create mode 100644 app/tests/Feature/TrackBackendEventsTest.php diff --git a/app/app/Http/Controllers/Api/AuthController.php b/app/app/Http/Controllers/Api/AuthController.php index 519665d4..02ab3dd9 100644 --- a/app/app/Http/Controllers/Api/AuthController.php +++ b/app/app/Http/Controllers/Api/AuthController.php @@ -12,6 +12,7 @@ use App\Models\ImpersonationToken; use App\Models\Tenant; use App\Models\User; use App\Services\NotificationService; +use App\Services\Tracking\VisitTracker; use App\Services\UserSessionTracker; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -144,6 +145,9 @@ class AuthController extends Controller $this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null); app(UserSessionTracker::class)->record($request, $user->id); + // Учёт посетителей (spec 2026-07-13): связываем гостя с лендинга и вход в кабинет. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done'); + return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, diff --git a/app/app/Http/Controllers/Api/RegistrationController.php b/app/app/Http/Controllers/Api/RegistrationController.php index 8f8ff847..84f76f68 100644 --- a/app/app/Http/Controllers/Api/RegistrationController.php +++ b/app/app/Http/Controllers/Api/RegistrationController.php @@ -12,6 +12,7 @@ use App\Http\Requests\Auth\ResendCodeRequest; use App\Models\User; use App\Services\Auth\RegistrationException; use App\Services\Auth\RegistrationService; +use App\Services\Tracking\VisitTracker; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; @@ -69,6 +70,10 @@ class RegistrationController extends Controller $request->session()->regenerate(); $this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null); + // Учёт посетителей (spec 2026-07-13): регистрация состоялась именно здесь — + // на /register клиента ещё нет, есть только ожидающая подтверждения заявка. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'register_done'); + return response()->json([ 'user' => $this->userResource($user), 'requires_2fa' => false, diff --git a/app/app/Http/Controllers/Api/TwoFactorController.php b/app/app/Http/Controllers/Api/TwoFactorController.php index b0f9305e..5f1ac8b5 100644 --- a/app/app/Http/Controllers/Api/TwoFactorController.php +++ b/app/app/Http/Controllers/Api/TwoFactorController.php @@ -10,6 +10,7 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest; use App\Http\Requests\Auth\VerifyTwoFactorRequest; use App\Models\User; use App\Models\UserRecoveryCode; +use App\Services\Tracking\VisitTracker; use App\Services\UserSessionTracker; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; @@ -100,6 +101,9 @@ class TwoFactorController extends Controller $user->update(['last_login_at' => now()]); app(UserSessionTracker::class)->record($request, $user->id); + // Учёт посетителей (spec 2026-07-13): вход через 2FA — такой же вход в кабинет. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done'); + $this->logAuthEvent( '2fa_verify_success', $user->id, @@ -204,6 +208,9 @@ class TwoFactorController extends Controller $user->update(['last_login_at' => now()]); app(UserSessionTracker::class)->record($request, $user->id); + // Учёт посетителей (spec 2026-07-13): вход через 2FA — такой же вход в кабинет. + app(VisitTracker::class)->trackAuth($request, (int) $user->tenant_id, (int) $user->id, 'login_done'); + $this->logAuthEvent( '2fa_recovery_used', $user->id, diff --git a/app/app/Services/Tracking/VisitTracker.php b/app/app/Services/Tracking/VisitTracker.php index 90443400..7a1cad39 100644 --- a/app/app/Services/Tracking/VisitTracker.php +++ b/app/app/Services/Tracking/VisitTracker.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services\Tracking; +use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -104,4 +105,27 @@ class VisitTracker 'last_seen_at' => now(), ]); } + + /** + * Вызывается из контроллеров входа и подтверждения регистрации. + * Нет куки гостя (человек пришёл мимо лендинга) → просто ничего не пишем. + * Учёт НИКОГДА не должен ломать вход: любая ошибка гасится и уходит в Sentry. + */ + public function trackAuth(Request $request, int $tenantId, int $userId, string $event): void + { + try { + $vid = $request->cookie('lid_vid'); + if (! is_string($vid) || $vid === '') { + return; + } + + $this->attachUser($vid, $tenantId, $userId); + $this->record($vid, $event, [ + 'host' => $request->getHost(), + 'path' => '/'.$request->path(), + ]); + } catch (\Throwable $e) { + report($e); + } + } } diff --git a/app/tests/Feature/TrackBackendEventsTest.php b/app/tests/Feature/TrackBackendEventsTest.php new file mode 100644 index 00000000..298f9cc1 --- /dev/null +++ b/app/tests/Feature/TrackBackendEventsTest.php @@ -0,0 +1,106 @@ +delete(); + DB::table('site_visitors')->delete(); + $this->tenant = Tenant::factory()->create(); +}); + +/** Гость, который уже был на лендинге (пришёл по смс). */ +function landingGuest(): string +{ + return app(VisitTracker::class)->ensureVisitor(null, [ + 'ip' => '176.59.133.176', + 'user_agent' => 'Mozilla/5.0 (Linux; Android 10; K)', + 'path' => '/', + 'utm' => ['utm_source' => 'sms'], + ]); +} + +test('успешный вход пишет login_done и связывает гостя с клиентом', function () { + $vid = landingGuest(); + + $user = User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'track-login@example.ru', + 'password_hash' => Hash::make('secret-pass-123'), + 'totp_enabled' => false, + 'is_active' => true, + ]); + + $this->withCredentials()->withCookie('lid_vid', $vid) + ->postJson('/api/auth/login', [ + 'email' => 'track-login@example.ru', + 'password' => 'secret-pass-123', + ])->assertOk(); + + expect(DB::table('site_events')->where('event', 'login_done')->count())->toBe(1); + + $v = DB::table('site_visitors')->where('id', $vid)->first(); + expect((int) $v->user_id)->toBe($user->id); + expect((int) $v->tenant_id)->toBe($this->tenant->id); + expect((bool) $v->is_human)->toBeTrue(); + // Канал первого захода не перетёрся — именно он привёл клиента. + expect($v->channel)->toBe('sms'); +}); + +test('вход без куки гостя не ломается и ничего не пишет', function () { + User::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'email' => 'track-nocookie@example.ru', + 'password_hash' => Hash::make('secret-pass-123'), + 'totp_enabled' => false, + 'is_active' => true, + ]); + + $this->postJson('/api/auth/login', [ + 'email' => 'track-nocookie@example.ru', + 'password' => 'secret-pass-123', + ])->assertOk(); + + expect(DB::table('site_events')->count())->toBe(0); + expect(DB::table('site_visitors')->count())->toBe(0); +}); + +test('подтверждение почты пишет register_done — регистрация состоялась', function () { + $vid = landingGuest(); + + // Регистрация: код подтверждения в dev-режиме возвращается в ответе. + $reg = $this->withCredentials()->withCookie('lid_vid', $vid) + ->postJson('/api/auth/register', [ + 'email' => 'track-reg@example.ru', + 'password' => 'fresh-pass-123', + 'accept_offer' => true, + 'accept_pdn' => true, + 'captcha_token' => 'tok-123', + ]); + + $code = $reg->json('_dev_plain_code'); + expect($code)->not->toBeNull('регистрация должна вернуть dev-код подтверждения'); + + $this->withCredentials()->withCookie('lid_vid', $vid) + ->postJson('/api/auth/confirm-email', [ + 'email' => 'track-reg@example.ru', + 'code' => $code, + ])->assertOk(); + + expect(DB::table('site_events')->where('event', 'register_done')->count())->toBe(1); + + $v = DB::table('site_visitors')->where('id', $vid)->first(); + expect($v->user_id)->not->toBeNull(); + expect($v->channel)->toBe('sms'); +});