feat(auth): implement login security features including account locking and login attempt tracking

This commit is contained in:
2026-07-29 10:47:26 -03:00
parent 45553dc514
commit 7c9ebc6d4d
18 changed files with 578 additions and 17 deletions

View File

@@ -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

View File

@@ -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