feat(auth): implement login security features including account locking and login attempt tracking
This commit is contained in:
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
@@ -102,11 +103,190 @@ class LoginControllerTest extends TestCase
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
$this->withHeader('User-Agent', 'Shopit login test')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
|
||||
$user = User::query()->where('email', 'grace@example.com')->sole();
|
||||
|
||||
$this->assertSame(1, $user->failed_login_attempts);
|
||||
$this->assertNotNull($user->last_failed_login_at);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Shopit login test',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_locks_an_account_after_the_maximum_failed_attempts(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 3,
|
||||
'login-security.lock_minutes' => 15,
|
||||
'login-security.rate_limit_per_minute' => 100,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
$this->travelTo(now()->startOfSecond());
|
||||
|
||||
$tenant = $this->createTenant('locked');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'locked@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$payload = [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
];
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
}
|
||||
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertJsonPath('code', 'auth.account_locked');
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(3, $user->failed_login_attempts);
|
||||
$this->assertTrue($user->locked_until->equalTo(now()->addMinutes(15)));
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
...$payload,
|
||||
'password' => 'secret123',
|
||||
])
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After', '900')
|
||||
->assertJsonPath('code', 'auth.account_locked')
|
||||
->assertJsonPath('retry_after', 900);
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 4);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_successful_login_resets_failures_and_is_audited(): void
|
||||
{
|
||||
$tenant = $this->createTenant('successful');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'successful@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 2,
|
||||
'last_failed_login_at' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_an_expired_lock_allows_login_again(): void
|
||||
{
|
||||
$tenant = $this->createTenant('expired-lock');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'expired@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now()->subMinutes(20),
|
||||
'locked_until' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_failures_outside_the_attempt_window_start_a_new_count(): void
|
||||
{
|
||||
config(['login-security.attempt_window_minutes' => 30]);
|
||||
|
||||
$tenant = $this->createTenant('attempt-window');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'window@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 4,
|
||||
'last_failed_login_at' => now()->subMinutes(31),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$this->assertSame(1, $user->refresh()->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_unknown_emails_are_audited_without_storing_the_email(): void
|
||||
{
|
||||
$tenant = $this->createTenant('unknown');
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'missing@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$attempt = LoginAttempt::query()->sole();
|
||||
$this->assertNull($attempt->user_id);
|
||||
$this->assertSame(LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $attempt->outcome);
|
||||
$this->assertSame(64, strlen($attempt->email_fingerprint));
|
||||
$this->assertStringNotContainsString('missing@example.com', $attempt->email_fingerprint);
|
||||
}
|
||||
|
||||
public function test_login_is_rate_limited_by_email_and_ip(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 100,
|
||||
'login-security.rate_limit_per_minute' => 2,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
|
||||
$tenant = $this->createTenant('rate-limit');
|
||||
$payload = [
|
||||
'email' => 'rate-limited@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
];
|
||||
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After');
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 2);
|
||||
}
|
||||
|
||||
public function test_it_validates_required_login_fields(): void
|
||||
|
||||
@@ -18,6 +18,11 @@ class ResetPasswordControllerTest extends TestCase
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'OldSecret!123',
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now(),
|
||||
'locked_until' => now()->addMinutes(15),
|
||||
])->save();
|
||||
$user->createToken('existing-session');
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
@@ -38,6 +43,9 @@ class ResetPasswordControllerTest extends TestCase
|
||||
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
||||
|
||||
Reference in New Issue
Block a user