feat(auth): password reset writes auth_log (requested/completed/failed)

This commit is contained in:
Дмитрий
2026-05-22 17:17:07 +03:00
parent e1c4688c2d
commit a6a7a80045
2 changed files with 132 additions and 0 deletions
@@ -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([
@@ -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();
});