joe-nassar-tech/laravel-exponential-lockout

Laravel package for implementing exponential lockout on failed authentication attempts with configurable contexts

v1.4.0 2025-08-06 19:33 UTC

This package is auto-updated.

Last update: 2025-08-06 22:23:13 UTC


README

A comprehensive Laravel package for implementing exponential lockout functionality on failed authentication attempts with configurable contexts and response handling.

Features

  • 🎯 Perfect Exponential Lockout: Grace attempts system - exactly 1 attempt allowed after each lockout period
  • 100% Automatic Middleware: Zero code changes needed - just add middleware to routes
  • Smart Delay Progression: Configurable delays (default: 1min → 5min → 15min → 30min → 2hr → 6hr → 12hr → 24hr)
  • Configurable Free Attempts: Set how many attempts before first lockout (default: 3)
  • Multiple Contexts: Different rules for login, otp, admin, pin, etc.
  • Flexible Key Extraction: Track by email, phone, username, IP, or custom logic
  • Auto-Detection: Automatically detects 4xx/5xx failures and 2xx success
  • Manual API Control: Full programmatic control when needed
  • Smart Response Handling: Auto-detect JSON/redirect responses with proper headers
  • Persistent Attempt History: Remembers failures across lockout periods
  • Cache-Based Storage: Uses Laravel's cache system (Redis, File, Database, etc.)
  • Artisan Commands: CLI tools for lockout management and debugging
  • Blade Directives: Template helpers for lockout status display
  • Laravel 9-12+ Compatible: Full support for all modern Laravel versions

Installation

Install via Composer:

composer require joe-nassar-tech/laravel-exponential-lockout

Publish the configuration file:

php artisan vendor:publish --tag=exponential-lockout-config

Configuration

The package comes with sensible defaults, but you can customize everything in config/exponential-lockout.php:

return [
    // Cache configuration
    'cache' => [
        'store' => null, // Uses default cache store
        'prefix' => 'exponential_lockout',
    ],

    // Default delay sequence (in seconds) - 1min → 5min → 15min → 30min → 2hr → 6hr → 12hr → 24hr
    'default_delays' => [60, 300, 900, 1800, 7200, 21600, 43200, 86400],

    // Response handling
    'default_response_mode' => 'auto', // 'auto', 'json', 'redirect', 'callback'
    'default_redirect_route' => 'login',

    // Context-specific configurations
    'contexts' => [
        'login' => [
            'enabled' => true,
            'key' => 'email',
            'delays' => null, // Uses default_delays
            'min_attempts' => 3, // Lock after 3 failed attempts (allow 2 free attempts)
            'reset_after_hours' => 24, // Reset attempt count after 24 hours
        ],
        'otp' => [
            'enabled' => true,
            'key' => 'phone',
            'delays' => [30, 60, 180, 300, 600], // Shorter delays for OTP
            'response_mode' => 'json',
            'min_attempts' => 3, // Lock after 3 failed attempts (allow 2 free attempts)
        ],
        // ... more contexts
    ],
];

How It Works

🎯 Perfect Exponential Lockout Behavior

The package implements a grace attempt system that provides exactly 1 attempt after each lockout period:

Attempt Result Behavior
1st-3rd ❌ Free attempts No lockout (configurable with min_attempts)
4th ❌ 🚫 Block 60s First lockout (using default delays)
After 60s 🎁 1 grace attempt Exactly 1 try allowed
Grace ❌ 🚫 Block 300s Second lockout (5 minutes)
After 300s 🎁 1 grace attempt Exactly 1 try allowed
Grace ❌ 🚫 Block 900s Third lockout (15 minutes)
Any Success ✅ 🔄 Complete Reset Back to 3 free attempts

🔑 Key Features:

  • Configurable free attempts (default: 3) before first lockout
  • Progressive delays that increase exponentially
  • Grace attempts - exactly 1 attempt allowed after each lockout expires
  • Automatic reset on any successful authentication
  • Persistent memory - remembers attempt history across sessions

Basic Usage

1. Middleware Protection

Protect routes with middleware:

use Illuminate\Support\Facades\Route;

// Login route protection
Route::post('/login', [LoginController::class, 'login'])
    ->middleware('exponential.lockout:login');

// OTP verification protection  
Route::post('/verify-otp', [OtpController::class, 'verify'])
    ->middleware('exponential.lockout:otp');

// PIN validation protection
Route::post('/validate-pin', [PinController::class, 'validate'])
    ->middleware('exponential.lockout:pin');

2. Manual Lockout Management

Use the Lockout facade for manual control:

use ExponentialLockout\Facades\Lockout;

class LoginController extends Controller
{
    public function login(Request $request)
    {
        // Check if locked out (optional - middleware handles this automatically)
        if (Lockout::isLockedOut('login', $request->email)) {
            $remaining = Lockout::getRemainingTime('login', $request->email);
            return response()->json(['error' => 'Locked', 'retry_after' => $remaining], 429);
        }
        
        $credentials = $request->only('email', 'password');
        
        if (Auth::attempt($credentials)) {
            // Clear lockout on successful login (optional - middleware does this automatically)
            Lockout::clear('login', $request->email);
            return response()->json(['success' => true], 200);
        }
        
        // Record failed attempt (optional - middleware does this automatically)
        Lockout::recordFailure('login', $request->email);
        return response()->json(['error' => 'Invalid credentials'], 401);
    }
}

3. OTP Verification Example

class OtpController extends Controller
{
    public function verify(Request $request)
    {
        $phone = $request->input('phone');
        $otp = $request->input('otp');
        
        if ($this->isValidOtp($phone, $otp)) {
            // Clear lockout on successful verification
            Lockout::clear('otp', $phone);
            
            return response()->json(['message' => 'OTP verified successfully']);
        }
        
        // Record failed attempt
        Lockout::recordFailure('otp', $phone);
        
        return response()->json([
            'error' => 'Invalid OTP',
            'attempts' => Lockout::getAttemptCount('otp', $phone)
        ], 401);
    }
}

Advanced Usage

Custom Key Extraction

Define custom key extractors in the config:

'key_extractors' => [
    'user_session' => function ($request) {
        return $request->session()->getId();
    },
    'device_fingerprint' => function ($request) {
        return hash('sha256', $request->userAgent() . $request->ip());
    },
],

'contexts' => [
    'admin_login' => [
        'key' => 'device_fingerprint',
        'delays' => [300, 900, 1800, 7200],
    ],
],

Custom Response Handling

Implement custom response logic:

'custom_response_callback' => function ($context, $key, $remainingTime) {
    return response()->json([
        'error' => 'Account temporarily locked',
        'context' => $context,
        'retry_after' => $remainingTime,
        'retry_after_human' => gmdate('H:i:s', $remainingTime),
    ], 429);
},

Check Lockout Status

Check if a user is locked out before processing:

if (Lockout::isLockedOut('login', $email)) {
    $remainingTime = Lockout::getRemainingTime('login', $email);
    
    return response()->json([
        'error' => 'Account locked',
        'retry_after' => $remainingTime
    ], 429);
}

Get Detailed Lockout Information

$info = Lockout::getLockoutInfo('login', $email);
/*
Returns:
[
    'context' => 'login',
    'key' => 'user@example.com',
    'attempts' => 3,
    'is_locked_out' => true,
    'remaining_time' => 840,
    'locked_until' => Carbon instance,
    'last_attempt' => Carbon instance,
]
*/

Blade Directives

Use Blade directives in your templates:

{{-- Check if user is locked out --}}
@lockout('login', $user->email)
    <div class="alert alert-warning">
        Your account is temporarily locked. Please try again later.
    </div>
@endlockout

{{-- Show content when NOT locked out --}}
@notlockout('login', $user->email)
    <form method="POST" action="/login">
        <!-- Login form -->
    </form>
@endnotlockout

{{-- Get lockout information --}}
@lockoutinfo($lockoutInfo, 'login', $user->email)
@if($lockoutInfo['is_locked_out'])
    <p>Locked for {{ gmdate('H:i:s', $lockoutInfo['remaining_time']) }} more</p>
@endif

{{-- Get remaining time --}}
@lockouttime($remainingSeconds, 'login', $user->email)
@if($remainingSeconds > 0)
    <p>Try again in {{ $remainingSeconds }} seconds</p>
@endif

Artisan Commands

Clear Specific Lockout

# Clear lockout for specific context and key
php artisan lockout:clear login user@example.com

# Clear with force (no confirmation)
php artisan lockout:clear login user@example.com --force

Clear All Lockouts for Context

# Clear all lockouts for a context
php artisan lockout:clear login --all

# With force flag
php artisan lockout:clear login --all --force

API Reference

Lockout Facade Methods

// Record a failed attempt
Lockout::recordFailure(string $context, string $key): int

// Check if locked out
Lockout::isLockedOut(string $context, string $key): bool

// Get remaining lockout time in seconds
Lockout::getRemainingTime(string $context, string $key): int

// Clear lockout
Lockout::clear(string $context, string $key): bool

// Clear all lockouts for context
Lockout::clearContext(string $context): bool

// Get attempt count
Lockout::getAttemptCount(string $context, string $key): int

// Extract key from request
Lockout::extractKeyFromRequest(string $context, Request $request): string

// Get detailed lockout information
Lockout::getLockoutInfo(string $context, string $key): array

Context Configuration

Each context can be configured independently:

'contexts' => [
    'login' => [
        'enabled' => true,                    // Enable/disable this context
        'key' => 'email',                     // Key extraction method
        'delays' => [60, 300, 900],          // Custom delay sequence
        'response_mode' => 'auto',            // Response handling mode
        'redirect_route' => 'login',          // Redirect route for web requests
        'max_attempts' => null,               // Max attempts (null = use delay sequence length)
    ],
],

Available Key Extractors

  • email - Extract from email input field
  • phone - Extract from phone input field
  • user_id - Extract from authenticated user ID
  • ip - Use client IP address
  • username - Extract from username input field
  • Custom callable - Define your own extraction logic

Response Modes

  • auto - Auto-detect JSON or redirect based on request
  • json - Always return JSON response
  • redirect - Always redirect to specified route
  • callback - Use custom callback function

Delay Sequences

Default sequence provides exponential backoff:

[60, 300, 900, 1800, 7200, 21600, 43200, 86400]
// 1min, 5min, 15min, 30min, 2hr, 6hr, 12hr, 24hr

Customize per context:

'contexts' => [
    'otp' => [
        'delays' => [30, 60, 180, 300, 600], // Shorter for OTP
    ],
    'admin' => [
        'delays' => [600, 1800, 7200, 21600], // Longer for admin
    ],
],

Error Handling

The package includes comprehensive error handling:

try {
    Lockout::recordFailure('invalid_context', $key);
} catch (InvalidArgumentException $e) {
    // Context not configured or disabled
    Log::error('Lockout error: ' . $e->getMessage());
}

Cache Considerations

Cache Store Selection

Configure the cache store in your config:

'cache' => [
    'store' => 'redis', // Use specific store
    'prefix' => 'app_lockout',
],

TTL Management

Cache entries automatically expire after lockout duration + 1 hour buffer.

Redis Optimization

For Redis, consider using a dedicated database:

// config/cache.php
'stores' => [
    'lockout_redis' => [
        'driver' => 'redis',
        'connection' => 'lockout',
    ],
],

// config/database.php
'redis' => [
    'lockout' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'database' => 2, // Dedicated database
    ],
],

Testing

The package includes comprehensive test coverage. Run tests with:

composer test

Testing Lockouts in Your App

class LoginTest extends TestCase
{
    public function test_user_gets_locked_out_after_failures()
    {
        // Simulate multiple failed attempts
        for ($i = 0; $i < 3; $i++) {
            $this->post('/login', ['email' => 'test@example.com', 'password' => 'wrong']);
        }
        
        // Verify lockout is active
        $this->assertTrue(Lockout::isLockedOut('login', 'test@example.com'));
        
        // Test lockout response
        $response = $this->post('/login', ['email' => 'test@example.com', 'password' => 'correct']);
        $response->assertStatus(429);
    }
}

Performance Considerations

  • Cache Efficiency: Uses single cache key per context/user combination
  • TTL Optimization: Automatic cleanup of expired lockouts
  • Memory Usage: Minimal data storage per lockout entry
  • Lookup Speed: O(1) cache lookups for lockout status

Security Best Practices

  1. Rate Limiting: Combine with Laravel's rate limiting for comprehensive protection
  2. IP Tracking: Use IP-based lockouts for anonymous endpoints
  3. Context Separation: Use different contexts for different authentication methods
  4. Cache Security: Secure your cache store (Redis AUTH, etc.)
  5. Key Hashing: Keys are automatically hashed for privacy

Troubleshooting

Common Issues

Lockouts not working:

  • Check context is enabled in config
  • Verify cache store is working
  • Ensure middleware is applied to routes

Lockouts not clearing:

  • Check cache connectivity
  • Verify context and key match exactly
  • Use Artisan command to manually clear

Wrong response format:

  • Check response_mode in context config
  • Verify request headers for JSON detection
  • Test with custom response callback

Debug Mode

Enable debug logging:

// In your controller
Log::info('Lockout status', [
    'context' => 'login',
    'key' => $email,
    'info' => Lockout::getLockoutInfo('login', $email)
]);

Contributing

Contributions are welcome! Please see CONTRIBUTING.md for details.

License

This package is open-sourced software licensed under the MIT license.

Changelog

See CHANGELOG.md for version history and updates.

Support

  • Documentation: This README and inline code comments
  • Issues: GitHub Issues for bug reports and feature requests
  • Discussions: GitHub Discussions for questions and community support

About the Developer

Joe Nassar
Email: joe.nassar.tech@gmail.com

Made with ❤️ for the Laravel community