feat(auth): password reset writes auth_log (requested/completed/failed)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user