feat(visitors): события регистрации и входа пишет backend, гость связывается с клиентом

Три точки: обычный вход, вход через 2FA (оба пути — код и резервный),
подтверждение почты (именно там клиент реально создаётся, а не на /register).
Учёт в try/catch — ошибка учёта не может уронить вход. Регресс авторизации 114/114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-13 19:56:54 +03:00
parent 481dc6e183
commit 47bcdb2b35
5 changed files with 146 additions and 0 deletions
@@ -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,
@@ -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,
@@ -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,
@@ -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);
}
}
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Services\Tracking\VisitTracker;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
// VisitTracker пишет через pgsql_supplier — шарим PDO, иначе записи не видны под транзакцией.
uses(SharesSupplierPdo::class);
beforeEach(function () {
DB::table('site_events')->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');
});