From a6a7a8004501f925c311bcaf8ef2e62f7cfc0abc 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: Fri, 22 May 2026 17:17:07 +0300 Subject: [PATCH] feat(auth): password reset writes auth_log (requested/completed/failed) --- .../Api/PasswordResetController.php | 34 +++++++ .../Feature/Auth/AuthLogCoverageTest.php | 98 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/app/app/Http/Controllers/Api/PasswordResetController.php b/app/app/Http/Controllers/Api/PasswordResetController.php index a470bf3e..76cb938d 100644 --- a/app/app/Http/Controllers/Api/PasswordResetController.php +++ b/app/app/Http/Controllers/Api/PasswordResetController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; +use App\Http\Controllers\Concerns\WritesAuthLog; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\ForgotPasswordRequest; use App\Http\Requests\Auth\ResetPasswordRequest; @@ -29,6 +30,7 @@ use Illuminate\Support\Facades\RateLimiter; */ class PasswordResetController extends Controller { + use WritesAuthLog; /** Лимит попыток в окне (ТЗ §22.4.4 + system_settings.login_max_attempts=5). */ private const LOGIN_MAX_ATTEMPTS = 5; @@ -69,6 +71,17 @@ class PasswordResetController extends Controller Password::sendResetLink(['email' => $email]); + $userId = User::where('email', $email)->value('id'); + $this->logAuthEvent( + 'password_reset_requested', + $userId, + null, + $email, + $request->ip(), + $request->userAgent(), + $userId === null ? 'unknown_email' : null, + ); + // Unified ответ независимо от наличия user'а. return response()->json([ 'message' => 'Если такой email зарегистрирован — мы отправили ссылку для сброса пароля.', @@ -120,12 +133,33 @@ class PasswordResetController extends Controller if ($status !== Password::PASSWORD_RESET) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); + $this->logAuthEvent( + 'password_reset_failed', + null, + null, + $email, + $request->ip(), + $request->userAgent(), + (string) $status, + ); + return response()->json([ 'message' => 'Ссылка для сброса недействительна или истекла. Запросите новую.', 'errors' => ['email' => ['Ссылка для сброса недействительна или истекла.']], ], 422); } + $completedUserId = User::where('email', $email)->value('id'); + $this->logAuthEvent( + 'password_reset_completed', + $completedUserId, + null, + $email, + $request->ip(), + $request->userAgent(), + null, + ); + RateLimiter::clear($throttleKey); return response()->json([ diff --git a/app/tests/Feature/Auth/AuthLogCoverageTest.php b/app/tests/Feature/Auth/AuthLogCoverageTest.php index 72397136..8cfaacc3 100644 --- a/app/tests/Feature/Auth/AuthLogCoverageTest.php +++ b/app/tests/Feature/Auth/AuthLogCoverageTest.php @@ -347,3 +347,101 @@ it('2fa regenerate recovery codes writes auth_log event=2fa_recovery_regenerated expect($row)->not->toBeNull() ->and((int) $row->tenant_id)->toBe($tenant->id); }); + +it('password_reset_requested writes auth_log with user_id for known email', function () { + \Illuminate\Support\Facades\Notification::fake(); + + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'pr-known-log@example.ru', + 'password_hash' => Hash::make('old-pass-1234'), + 'is_active' => true, + ]); + + $this->postJson('/api/auth/forgot', [ + 'email' => 'pr-known-log@example.ru', + ])->assertOk(); + + $row = DB::table('auth_log') + ->where('event', 'password_reset_requested') + ->where('email', 'pr-known-log@example.ru') + ->latest('id') + ->first(); + + expect($row)->not->toBeNull() + ->and((int) $row->user_id)->toBe($user->id) + ->and($row->failure_reason)->toBeNull(); +}); + +it('password_reset_requested writes auth_log with unknown_email failure_reason for unknown email', function () { + \Illuminate\Support\Facades\Notification::fake(); + + $this->postJson('/api/auth/forgot', [ + 'email' => 'no-such-pr-log@example.ru', + ])->assertOk(); + + $row = DB::table('auth_log') + ->where('event', 'password_reset_requested') + ->where('email', 'no-such-pr-log@example.ru') + ->latest('id') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->user_id)->toBeNull() + ->and($row->failure_reason)->toBe('unknown_email'); +}); + +it('password_reset_completed writes auth_log on successful token reset', function () { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'pr-completed-log@example.ru', + 'password_hash' => Hash::make('old-pass-1234'), + 'is_active' => true, + ]); + + $token = \Illuminate\Support\Facades\Password::createToken($user); + + $this->postJson('/api/auth/reset-password', [ + 'token' => $token, + 'email' => 'pr-completed-log@example.ru', + 'password' => 'new-strong-pass-1234', + 'password_confirmation' => 'new-strong-pass-1234', + ])->assertOk(); + + $row = DB::table('auth_log') + ->where('event', 'password_reset_completed') + ->where('user_id', $user->id) + ->latest('id') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->email)->toBe('pr-completed-log@example.ru'); +}); + +it('password_reset_failed writes auth_log on invalid token', function () { + $tenant = Tenant::factory()->create(); + User::factory()->create([ + 'tenant_id' => $tenant->id, + 'email' => 'pr-failed-log@example.ru', + 'password_hash' => Hash::make('old-pass-1234'), + 'is_active' => true, + ]); + + $this->postJson('/api/auth/reset-password', [ + 'token' => 'invalid-token-zzz', + 'email' => 'pr-failed-log@example.ru', + 'password' => 'new-strong-pass-1234', + 'password_confirmation' => 'new-strong-pass-1234', + ])->assertStatus(422); + + $row = DB::table('auth_log') + ->where('event', 'password_reset_failed') + ->where('email', 'pr-failed-log@example.ru') + ->latest('id') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->failure_reason)->not->toBeNull(); +});