Created
May 21, 2025 06:07
-
-
Save shivajichalise/cb6c5327f5d7c6f517b8ca54943d5ca4 to your computer and use it in GitHub Desktop.
Rate limiting with Exponential Backoff in Laravel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| namespace App\Actions; | |
| use App\Models\User; | |
| use Illuminate\Support\Facades\Cache; | |
| use Illuminate\Support\Facades\Hash; | |
| use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; | |
| use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; | |
| final class AttemptLoginAction | |
| { | |
| /** | |
| * Number of free login attempts before rate limiting kicks in. | |
| */ | |
| private int $freeAttempts = 3; | |
| /** | |
| * Cache key used to track failed attempts per user+IP. | |
| */ | |
| private string $attemptKey; | |
| /** | |
| * Cache key used to store lockout expiration per user+IP. | |
| */ | |
| private string $lockKey; | |
| /** | |
| * Handle the login logic: verify credentials, track attempts, | |
| * and apply lockout with exponential delay if needed. | |
| * | |
| * @throws TooManyRequestsHttpException If the user is locked out | |
| * @throws UnauthorizedHttpException If the credentials are invalid | |
| */ | |
| public function handle(array $fields, string $ip): User | |
| { | |
| // Generate unique cache keys based on user email and IP | |
| $this->attemptKey = "login:attempts:{$fields['email']}-{$ip}"; | |
| $this->lockKey = "login:lock:{$fields['email']}-{$ip}"; | |
| // If the user is currently locked out, throw an exception with wait time | |
| if ($seconds = $this->secondsUntilUnlock()) { | |
| throw new TooManyRequestsHttpException( | |
| $seconds, | |
| "Too many attempts. Try again in {$seconds} seconds.", | |
| null, | |
| 429 | |
| ); | |
| } | |
| $user = User::where('email', $fields['email'])->first(); | |
| if (! $user || ! Hash::check($fields['password'], $user->password)) { | |
| // Increase the failed attempt count | |
| $attempts = $this->incrementAttempts(); | |
| // If attempts exceed the free limit, apply exponential backoff | |
| if ($attempts > $this->freeAttempts) { | |
| $delay = min(60, pow(2, $attempts - $this->freeAttempts)); // exponential: 2, 4, 8, ... | |
| $this->lockFor($delay); | |
| throw new TooManyRequestsHttpException( | |
| $delay, | |
| "Too many attempts. Try again in {$delay} seconds.", | |
| null, | |
| 429 | |
| ); | |
| } | |
| // Inform the user how many tries are left | |
| $remaining = $this->freeAttempts - $attempts; | |
| if ($remaining > 1) { | |
| throw new UnauthorizedHttpException('Basic', "Invalid credentials. {$remaining} attempts left.", null, 401); | |
| } | |
| if ($remaining === 1) { | |
| throw new UnauthorizedHttpException('Basic', 'Invalid credentials. Last attempt remaining.', null, 401); | |
| } | |
| throw new UnauthorizedHttpException( | |
| 'Basic', | |
| 'Invalid credentials. Account will be locked on next failure.', | |
| null, | |
| 401, | |
| ); | |
| } | |
| // Login successful – clear previous attempt and lockout data | |
| Cache::forget($this->attemptKey); | |
| Cache::forget($this->lockKey); | |
| return $user; | |
| } | |
| /** | |
| * Increment the number of failed attempts. | |
| * If it's the first attempt, create the cache entry. | |
| */ | |
| private function incrementAttempts(): int | |
| { | |
| if (! Cache::has($this->attemptKey)) { | |
| // First failure – set initial count to 1 for 1 minute | |
| Cache::put($this->attemptKey, 1, now()->addMinute()); | |
| return 1; | |
| } | |
| // Otherwise, just increment the count | |
| return Cache::increment($this->attemptKey); | |
| } | |
| /** | |
| * Check how many seconds remain until the lockout expires. | |
| */ | |
| private function secondsUntilUnlock(): int | |
| { | |
| $unlockTimestamp = Cache::get($this->lockKey); | |
| // If not locked, allow login | |
| if (! $unlockTimestamp) { | |
| return 0; | |
| } | |
| // Calculate remaining time from now | |
| return max(1, $unlockTimestamp - time()); | |
| } | |
| /** | |
| * Lock the user out for a specific duration. | |
| */ | |
| private function lockFor(int $seconds): void | |
| { | |
| // Store the unlock time with expiration | |
| Cache::put($this->lockKey, time() + $seconds, $seconds); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| use App\Actions\AttemptLoginAction; | |
| use Illuminate\Http\JsonResponse; | |
| use Illuminate\Http\Request; | |
| use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; | |
| use Throwable; | |
| /** | |
| * Handle the login request with validation and rate limiting. | |
| */ | |
| public function login(Request $request, AttemptLoginAction $action): JsonResponse | |
| { | |
| $fields = $request->validate([ | |
| 'email' => ['required', 'email:rfc'], | |
| 'password' => ['required', 'string'], | |
| ]); | |
| try { | |
| $user = $action->handle($fields, $request->ip()); | |
| return response()->json([ | |
| 'status' => true, | |
| 'message' => 'Login successful', | |
| 'user' => [ | |
| 'id' => $user->id, | |
| 'name' => $user->name, | |
| 'email' => $user->email, | |
| ], | |
| ]); | |
| } catch (Throwable $th) { | |
| $statusCode = $th instanceof HttpExceptionInterface | |
| ? $th->getStatusCode() | |
| : 500; | |
| $message = config('app.env') === 'production' | |
| ? 'Something went wrong. Please try again later.' | |
| : $th->getMessage(); | |
| return response()->json([ | |
| 'status' => false, | |
| 'message' => $message, | |
| ], $statusCode); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment