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), ]); } }