From 7c9ebc6d4de6436abebb816bb29900917f0f97eb Mon Sep 17 00:00:00 2001 From: ncoronel Date: Wed, 29 Jul 2026 10:47:26 -0300 Subject: [PATCH] feat(auth): implement login security features including account locking and login attempt tracking --- .env.example | 5 + .../Auth/Controllers/LoginController.php | 22 +-- .../Exceptions/AccountLockedException.php | 20 ++ app/Domains/Auth/Models/LoginAttempt.php | 40 ++++ app/Domains/Auth/Models/User.php | 9 + .../Auth/Requests/LoginUserRequest.php | 12 ++ .../Auth/Services/PasswordLoginService.php | 173 ++++++++++++++++ .../Services/ResetPasswordAttemptService.php | 3 + app/Domains/Auth/routes/api.php | 2 +- app/Providers/AppServiceProvider.php | 22 +++ bootstrap/app.php | 17 ++ config/login-security.php | 9 + ...d_login_security_fields_to_users_table.php | 29 +++ ..._29_000100_create_login_attempts_table.php | 36 ++++ lang/en/api.php | 1 + lang/es/api.php | 1 + tests/Feature/Auth/LoginControllerTest.php | 186 +++++++++++++++++- .../Auth/ResetPasswordControllerTest.php | 8 + 18 files changed, 578 insertions(+), 17 deletions(-) create mode 100644 app/Domains/Auth/Exceptions/AccountLockedException.php create mode 100644 app/Domains/Auth/Models/LoginAttempt.php create mode 100644 app/Domains/Auth/Services/PasswordLoginService.php create mode 100644 config/login-security.php create mode 100644 database/migrations/2026_07_29_000000_add_login_security_fields_to_users_table.php create mode 100644 database/migrations/2026_07_29_000100_create_login_attempts_table.php diff --git a/.env.example b/.env.example index 17693c1..c848aea 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,11 @@ GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback BCRYPT_ROUNDS=12 +AUTH_MAX_LOGIN_ATTEMPTS=5 +AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30 +AUTH_LOGIN_LOCK_MINUTES=15 +AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10 +AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30 LOG_CHANNEL=stack LOG_STACK=single diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php index bfdab97..2645b44 100644 --- a/app/Domains/Auth/Controllers/LoginController.php +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -2,35 +2,31 @@ namespace App\Domains\Auth\Controllers; -use App\Domains\Auth\Models\User; use App\Domains\Auth\Requests\LoginUserRequest; use App\Domains\Auth\Resources\UserResource; +use App\Domains\Auth\Services\PasswordLoginService; use App\Domains\Cart\Services\GuestCartMergeService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Cookie; -use Illuminate\Support\Facades\Hash; -use Illuminate\Validation\ValidationException; class LoginController extends Controller { public function __construct( private readonly GuestCartMergeService $guestCartMergeService, + private readonly PasswordLoginService $passwordLoginService, ) {} - /** - * @throws ValidationException - */ public function __invoke(LoginUserRequest $request): JsonResponse { $credentials = $request->validated(); - $user = User::query()->where('email', $credentials['email'])->first(); - - if (! $user || ! Hash::check($credentials['password'], $user->password)) { - throw ValidationException::withMessages([ - 'email' => __('api.auth.invalid_credentials'), - ]); - } + $user = $this->passwordLoginService->authenticate( + $credentials['email'], + $credentials['password'], + $credentials['tenant_codigo'], + $request->ip(), + $request->userAgent(), + ); $expirationMinutes = (int) config('sanctum.expiration'); $token = $user->createToken( diff --git a/app/Domains/Auth/Exceptions/AccountLockedException.php b/app/Domains/Auth/Exceptions/AccountLockedException.php new file mode 100644 index 0000000..3e925f2 --- /dev/null +++ b/app/Domains/Auth/Exceptions/AccountLockedException.php @@ -0,0 +1,20 @@ +diffInSeconds($this->lockedUntil, false)); + } +} diff --git a/app/Domains/Auth/Models/LoginAttempt.php b/app/Domains/Auth/Models/LoginAttempt.php new file mode 100644 index 0000000..1abe9d4 --- /dev/null +++ b/app/Domains/Auth/Models/LoginAttempt.php @@ -0,0 +1,40 @@ + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function casts(): array + { + return [ + 'user_id' => 'integer', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Domains/Auth/Models/User.php b/app/Domains/Auth/Models/User.php index 58364c2..226ff59 100644 --- a/app/Domains/Auth/Models/User.php +++ b/app/Domains/Auth/Models/User.php @@ -29,6 +29,12 @@ class User extends Authenticatable return $this->hasMany(ResetPasswordAttempt::class); } + /** @return HasMany */ + public function loginAttempts(): HasMany + { + return $this->hasMany(LoginAttempt::class); + } + /** * @return array */ @@ -37,6 +43,9 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'failed_login_attempts' => 'integer', + 'last_failed_login_at' => 'datetime', + 'locked_until' => 'datetime', ]; } } diff --git a/app/Domains/Auth/Requests/LoginUserRequest.php b/app/Domains/Auth/Requests/LoginUserRequest.php index a924e2d..899a7e7 100644 --- a/app/Domains/Auth/Requests/LoginUserRequest.php +++ b/app/Domains/Auth/Requests/LoginUserRequest.php @@ -3,6 +3,7 @@ namespace App\Domains\Auth\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Str; class LoginUserRequest extends FormRequest { @@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest return true; } + protected function prepareForValidation(): void + { + $email = $this->input('email'); + + if (is_string($email)) { + $this->merge([ + 'email' => Str::lower(trim($email)), + ]); + } + } + /** * @return array */ diff --git a/app/Domains/Auth/Services/PasswordLoginService.php b/app/Domains/Auth/Services/PasswordLoginService.php new file mode 100644 index 0000000..3cc42a1 --- /dev/null +++ b/app/Domains/Auth/Services/PasswordLoginService.php @@ -0,0 +1,173 @@ +where('email', $normalizedEmail) + ->lockForUpdate() + ->first(); + + if ($user?->locked_until?->isFuture()) { + $this->recordAttempt( + $user, + $normalizedEmail, + $tenantCode, + LoginAttempt::OUTCOME_ACCOUNT_LOCKED, + $ipAddress, + $userAgent, + ); + + return [ + 'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED, + 'user' => $user, + 'locked_until' => CarbonImmutable::instance($user->locked_until), + ]; + } + + if ($user !== null && $user->locked_until !== null) { + $user->forceFill([ + 'failed_login_attempts' => 0, + 'last_failed_login_at' => null, + 'locked_until' => null, + ])->save(); + } + + if ($user === null || ! Hash::check($password, $user->password)) { + if ($user !== null) { + $this->registerFailure($user, $now); + } + + $outcome = $user?->locked_until?->isFuture() + ? LoginAttempt::OUTCOME_ACCOUNT_LOCKED + : LoginAttempt::OUTCOME_INVALID_CREDENTIALS; + $this->recordAttempt( + $user, + $normalizedEmail, + $tenantCode, + $outcome, + $ipAddress, + $userAgent, + ); + + return [ + 'outcome' => $outcome, + 'user' => $user, + 'locked_until' => $user?->locked_until === null + ? null + : CarbonImmutable::instance($user->locked_until), + ]; + } + + $user->forceFill([ + 'failed_login_attempts' => 0, + 'last_failed_login_at' => null, + 'locked_until' => null, + ])->save(); + + $this->recordAttempt( + $user, + $normalizedEmail, + $tenantCode, + LoginAttempt::OUTCOME_SUCCESS, + $ipAddress, + $userAgent, + ); + + return [ + 'outcome' => LoginAttempt::OUTCOME_SUCCESS, + 'user' => $user, + 'locked_until' => null, + ]; + }); + + if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) { + throw new AccountLockedException($result['locked_until']); + } + + if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) { + throw ValidationException::withMessages([ + 'email' => __('api.auth.invalid_credentials'), + ]); + } + + return $result['user']; + } + + private function registerFailure(User $user, CarbonImmutable $now): void + { + $windowMinutes = max(1, (int) config('login-security.attempt_window_minutes')); + $maxAttempts = max(1, (int) config('login-security.max_attempts')); + $lockMinutes = max(1, (int) config('login-security.lock_minutes')); + + $withinAttemptWindow = $user->last_failed_login_at !== null + && $user->last_failed_login_at->gte($now->subMinutes($windowMinutes)); + $attempts = $withinAttemptWindow + ? $user->failed_login_attempts + 1 + : 1; + + $user->forceFill([ + 'failed_login_attempts' => $attempts, + 'last_failed_login_at' => $now, + 'locked_until' => $attempts >= $maxAttempts + ? $now->addMinutes($lockMinutes) + : null, + ])->save(); + } + + private function recordAttempt( + ?User $user, + string $normalizedEmail, + string $tenantCode, + string $outcome, + ?string $ipAddress, + ?string $userAgent, + ): void { + LoginAttempt::query()->create([ + 'user_id' => $user?->getKey(), + 'email_fingerprint' => hash_hmac( + 'sha256', + $normalizedEmail, + (string) config('app.key'), + ), + 'tenant_codigo' => $tenantCode, + 'outcome' => $outcome, + 'ip_address' => $ipAddress, + 'user_agent' => $userAgent === null + ? null + : mb_substr($userAgent, 0, 1024), + ]); + } +} diff --git a/app/Domains/Auth/Services/ResetPasswordAttemptService.php b/app/Domains/Auth/Services/ResetPasswordAttemptService.php index fdab4c5..201c1b4 100644 --- a/app/Domains/Auth/Services/ResetPasswordAttemptService.php +++ b/app/Domains/Auth/Services/ResetPasswordAttemptService.php @@ -138,6 +138,9 @@ class ResetPasswordAttemptService } $user->password = $password; + $user->failed_login_attempts = 0; + $user->last_failed_login_at = null; + $user->locked_until = null; $user->save(); $user->tokens()->delete(); diff --git a/app/Domains/Auth/routes/api.php b/app/Domains/Auth/routes/api.php index 7a46965..d3236a7 100644 --- a/app/Domains/Auth/routes/api.php +++ b/app/Domains/Auth/routes/api.php @@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController; use Illuminate\Support\Facades\Route; Route::post('/register', RegisterController::class); -Route::post('/login', LoginController::class); +Route::post('/login', LoginController::class)->middleware('throttle:login'); Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class) ->middleware('throttle:5,1'); Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 51a3683..f3f8e17 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,8 +11,11 @@ use App\Domains\Notification\Listeners\SendTicketsAvailableEmail; use App\Domains\Notification\Listeners\SendWelcomeEmail; use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -36,6 +39,25 @@ class AppServiceProvider extends ServiceProvider Event::listen(UserRegistered::class, SendWelcomeEmail::class); Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class); + RateLimiter::for('login', function (Request $request): array { + $normalizedEmail = mb_strtolower(trim((string) $request->input('email'))); + $emailFingerprint = hash_hmac( + 'sha256', + $normalizedEmail, + (string) config('app.key'), + ); + $ipAddress = $request->ip() ?? 'unknown'; + + return [ + Limit::perMinute( + max(1, (int) config('login-security.rate_limit_per_minute')) + )->by("login:identity:{$emailFingerprint}:{$ipAddress}"), + Limit::perMinute( + max(1, (int) config('login-security.ip_rate_limit_per_minute')) + )->by("login:ip:{$ipAddress}"), + ]; + }); + Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) { /** @var Builder $this */ $perPage = (int) request()->query('per_page', $defaultPerPage); diff --git a/bootstrap/app.php b/bootstrap/app.php index e5122d6..f240d99 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ __('api.auth.unauthenticated'), ], 401); }); + $exceptions->render(function (AccountLockedException $exception, Request $request) { + if (! $request->is('api/*')) { + return null; + } + + $retryAfter = $exception->retryAfterSeconds(); + + return response()->json([ + 'code' => 'auth.account_locked', + 'message' => __('api.auth.account_locked'), + 'retry_after' => $retryAfter, + 'locked_until' => $exception->lockedUntil->toIso8601String(), + ], 429, [ + 'Retry-After' => (string) $retryAfter, + ]); + }); $exceptions->render(function (AuthorizationException $exception, Request $request) { if (! $request->is('api/*')) { return null; diff --git a/config/login-security.php b/config/login-security.php new file mode 100644 index 0000000..c0cfd9b --- /dev/null +++ b/config/login-security.php @@ -0,0 +1,9 @@ + (int) env('AUTH_MAX_LOGIN_ATTEMPTS', 3), + 'attempt_window_minutes' => (int) env('AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES', 30), + 'lock_minutes' => (int) env('AUTH_LOGIN_LOCK_MINUTES', 15), + 'rate_limit_per_minute' => (int) env('AUTH_LOGIN_RATE_LIMIT_PER_MINUTE', 10), + 'ip_rate_limit_per_minute' => (int) env('AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE', 30), +]; diff --git a/database/migrations/2026_07_29_000000_add_login_security_fields_to_users_table.php b/database/migrations/2026_07_29_000000_add_login_security_fields_to_users_table.php new file mode 100644 index 0000000..d8bd128 --- /dev/null +++ b/database/migrations/2026_07_29_000000_add_login_security_fields_to_users_table.php @@ -0,0 +1,29 @@ +unsignedSmallInteger('failed_login_attempts')->default(0); + $table->timestamp('last_failed_login_at')->nullable(); + $table->timestamp('locked_until')->nullable()->index(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropIndex(['locked_until']); + $table->dropColumn([ + 'failed_login_attempts', + 'last_failed_login_at', + 'locked_until', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_29_000100_create_login_attempts_table.php b/database/migrations/2026_07_29_000100_create_login_attempts_table.php new file mode 100644 index 0000000..ff2b3cb --- /dev/null +++ b/database/migrations/2026_07_29_000100_create_login_attempts_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('user_id') + ->nullable() + ->constrained() + ->cascadeOnUpdate() + ->nullOnDelete(); + $table->string('email_fingerprint', 64); + $table->string('tenant_codigo')->nullable(); + $table->string('outcome', 32); + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent', 1024)->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index(['email_fingerprint', 'created_at']); + $table->index(['user_id', 'created_at']); + $table->index(['ip_address', 'created_at']); + $table->index(['outcome', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('login_attempts'); + } +}; diff --git a/lang/en/api.php b/lang/en/api.php index 99f6efd..4344376 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -4,6 +4,7 @@ return [ 'auth' => [ 'unauthenticated' => 'Unauthenticated.', 'invalid_credentials' => 'Email or password is incorrect.', + 'account_locked' => 'The account is temporarily locked. Please try again later.', 'login_success' => 'Signed in successfully.', 'logout_success' => 'Signed out successfully.', 'register_success' => 'User registered successfully.', diff --git a/lang/es/api.php b/lang/es/api.php index 5978123..33d3a3e 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -4,6 +4,7 @@ return [ 'auth' => [ 'unauthenticated' => 'No autenticado.', 'invalid_credentials' => 'Email o contraseña incorrectos.', + 'account_locked' => 'La cuenta está bloqueada temporalmente. Intenta nuevamente más tarde.', 'login_success' => 'Sesión iniciada correctamente.', 'logout_success' => 'Sesión cerrada correctamente.', 'register_success' => 'Usuario registrado correctamente.', diff --git a/tests/Feature/Auth/LoginControllerTest.php b/tests/Feature/Auth/LoginControllerTest.php index 37fd92f..ed7cbd8 100644 --- a/tests/Feature/Auth/LoginControllerTest.php +++ b/tests/Feature/Auth/LoginControllerTest.php @@ -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 diff --git a/tests/Feature/Auth/ResetPasswordControllerTest.php b/tests/Feature/Auth/ResetPasswordControllerTest.php index 888daf0..81b0201 100644 --- a/tests/Feature/Auth/ResetPasswordControllerTest.php +++ b/tests/Feature/Auth/ResetPasswordControllerTest.php @@ -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