diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index b16d547..9d71bdc 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -10,21 +10,22 @@ jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
- fail-fast: true
+ fail-fast: false
matrix:
- os: [ubuntu-latest, windows-latest]
- php: [8.1]
- laravel: [10.*]
- stability: [prefer-lowest, prefer-stable]
+ os: [ubuntu-latest]
+ php: [8.2, 8.3]
include:
- - laravel: 10.*
- testbench: 8.0
+ - laravel: 11.*
+ testbench: ^9.0
+ - laravel: 12.*
+ testbench: ^10.0
+ stability: [prefer-stable]
- name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
+ name: PHP ${{ matrix.php }} - Laravel ${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }}
steps:
- name: Checkout code
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9dcfb88..f22effe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ All notable changes to `lockout` will be documented in this file
## [Unreleased]
+## [6.0.0] - 2025-01-XX
+
+### Added
+- Laravel 11 and Laravel 12 Support
+- PHP 8.2+ Support
+
+### Fixed
+- Fixed middleware logic bug where allow_login check was inside locked_types loop
+- Fixed method case sensitivity issues in whitelist and locked_types handling
+- Fixed pages array validation to handle non-array values gracefully
+- Improved type hints and code quality
+
+### Changed
+- Updated PHPUnit to v11
+- Updated PHPUnit configuration for PHPUnit 11 compatibility
+- Improved test coverage with 11 additional test cases
+
## [5.0.0] - 2023-04-11
### Added
diff --git a/composer.json b/composer.json
index 63ce7a4..da24172 100644
--- a/composer.json
+++ b/composer.json
@@ -18,12 +18,12 @@
}
],
"require": {
- "php": "^8.1",
- "illuminate/support": "^10.0"
+ "php": "^8.2",
+ "illuminate/support": "^11.0 || ^12.0"
},
"require-dev": {
- "orchestra/testbench": "^8.0",
- "phpunit/phpunit": "^9.5.10"
+ "orchestra/testbench": "^9.0 || ^10.0",
+ "phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
diff --git a/config/lockout.php b/config/lockout.php
index 91447e9..3b15545 100644
--- a/config/lockout.php
+++ b/config/lockout.php
@@ -55,4 +55,94 @@
'whitelist' => [
// 'post' => 'password/confirm',
],
+
+ /*
+ |--------------------------------------------------------------------------
+ | IP Whitelist - Allow specific IP addresses to bypass lockout
+ |--------------------------------------------------------------------------
+ */
+ 'ip_whitelist' => env('LOCKOUT_IP_WHITELIST', ''),
+ 'ip_whitelist_array' => [
+ // '127.0.0.1',
+ // '192.168.1.0/24',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | IP Blacklist - Block specific IP addresses even if lockout is disabled
+ |--------------------------------------------------------------------------
+ */
+ 'ip_blacklist' => env('LOCKOUT_IP_BLACKLIST', ''),
+ 'ip_blacklist_array' => [
+ //
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Role-Based Exceptions - Allow specific user roles to bypass lockout
+ |--------------------------------------------------------------------------
+ */
+ 'allowed_roles' => [
+ // 'admin',
+ // 'super-admin',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Response - Customize the response when lockout is active
+ |--------------------------------------------------------------------------
+ */
+ 'response_type' => env('LOCKOUT_RESPONSE_TYPE', 'abort'), // 'abort', 'view', 'json'
+ 'response_view' => 'lockout::maintenance',
+ 'response_message' => 'Application is currently in read-only mode.',
+ 'response_code' => 401, // HTTP_UNAUTHORIZED (backward compatible), use 503 for maintenance mode
+
+ /*
+ |--------------------------------------------------------------------------
+ | Route Patterns - Whitelist routes by pattern or route name
+ |--------------------------------------------------------------------------
+ */
+ 'route_patterns' => [
+ // 'api/*',
+ // 'health',
+ ],
+ 'route_names' => [
+ // 'health.check',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | API-Specific Handling
+ |--------------------------------------------------------------------------
+ */
+ 'api_enabled' => env('LOCKOUT_API_ENABLED', true),
+ 'api_response_type' => 'json', // 'json', 'abort'
+ 'api_response_message' => [
+ 'message' => 'Application is currently in read-only mode.',
+ 'status' => 'maintenance',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Health Check Endpoint - Always accessible endpoint for monitoring
+ |--------------------------------------------------------------------------
+ */
+ 'health_check_enabled' => env('LOCKOUT_HEALTH_CHECK_ENABLED', true),
+ 'health_check_path' => env('LOCKOUT_HEALTH_CHECK_PATH', 'health'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Cache Configuration
+ |--------------------------------------------------------------------------
+ */
+ 'cache_enabled' => env('LOCKOUT_CACHE_ENABLED', true),
+ 'cache_key' => 'lockout.status',
+ 'cache_ttl' => 60, // seconds
+
+ /*
+ |--------------------------------------------------------------------------
+ | Event System
+ |--------------------------------------------------------------------------
+ */
+ 'fire_events' => env('LOCKOUT_FIRE_EVENTS', true),
];
diff --git a/docs/configuration.md b/docs/configuration.md
index 30d6430..2d49653 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -3,8 +3,373 @@ title: Configuration
weight: 6
---
-You can publish the configuration file to override the defaults:
+## Publishing Configuration
-``` bash
-php artisan vendor:publish --provider="Rappasoft\Lockout\LockoutServiceProvider"
+Publish the configuration file to customize all settings:
+
+```bash
+php artisan vendor:publish --provider="Rappasoft\Lockout\LockoutServiceProvider" --tag=config
+```
+
+This will create `config/lockout.php` in your application.
+
+## Configuration Options
+
+### Basic Settings
+
+#### `enabled`
+Enable or disable lockout mode.
+
+**Default:** `env('APP_READ_ONLY', false)`
+
+```php
+'enabled' => env('APP_READ_ONLY', false),
+```
+
+#### `allow_login`
+Allow users to log in during lockout (only GET requests allowed after login).
+
+**Default:** `env('APP_READ_ONLY_LOGIN', false)`
+
+```php
+'allow_login' => env('APP_READ_ONLY_LOGIN', false),
+```
+
+#### `login_path` / `logout_path`
+Customize login and logout paths.
+
+**Default:** `'login'` / `'logout'`
+
+```php
+'login_path' => 'login',
+'logout_path' => 'logout',
+```
+
+### Request Type Configuration
+
+#### `locked_types`
+HTTP methods to block when lockout is enabled.
+
+**Default:** `['post', 'put', 'patch', 'delete']`
+
+```php
+'locked_types' => [
+ 'post',
+ 'put',
+ 'patch',
+ 'delete',
+],
+```
+
+#### `pages`
+Specific GET request paths to block.
+
+**Default:** `[]`
+
+```php
+'pages' => [
+ 'register',
+ 'subscribe',
+],
+```
+
+#### `whitelist`
+Whitelist specific method/path combinations.
+
+**Default:** `[]`
+
+```php
+'whitelist' => [
+ 'post' => 'password/confirm',
+ 'put' => 'profile/update',
+],
+```
+
+### IP Management
+
+#### `ip_whitelist`
+Comma-separated list of IP addresses to whitelist (from .env).
+
+**Default:** `env('LOCKOUT_IP_WHITELIST', '')`
+
+```php
+'ip_whitelist' => env('LOCKOUT_IP_WHITELIST', ''),
+```
+
+#### `ip_whitelist_array`
+Array of IP addresses or CIDR ranges to whitelist.
+
+**Default:** `[]`
+
+```php
+'ip_whitelist_array' => [
+ '127.0.0.1',
+ '192.168.1.0/24',
+ '10.0.0.0/8',
+],
+```
+
+#### `ip_blacklist`
+Comma-separated list of IP addresses to blacklist (from .env).
+
+**Default:** `env('LOCKOUT_IP_BLACKLIST', '')`
+
+```php
+'ip_blacklist' => env('LOCKOUT_IP_BLACKLIST', ''),
+```
+
+#### `ip_blacklist_array`
+Array of IP addresses or CIDR ranges to blacklist.
+
+**Default:** `[]`
+
+```php
+'ip_blacklist_array' => [
+ '192.168.1.50',
+ '10.0.0.100/24',
+],
+```
+
+### Role-Based Access
+
+#### `allowed_roles`
+User roles that can bypass lockout.
+
+**Default:** `[]`
+
+```php
+'allowed_roles' => [
+ 'admin',
+ 'super-admin',
+],
+```
+
+### Response Configuration
+
+#### `response_type`
+Type of response when request is blocked.
+
+**Options:** `'abort'`, `'view'`, `'json'`
+
+**Default:** `env('LOCKOUT_RESPONSE_TYPE', 'abort')`
+
+```php
+'response_type' => env('LOCKOUT_RESPONSE_TYPE', 'abort'),
+```
+
+#### `response_view`
+Blade view to render when `response_type` is `'view'`.
+
+**Default:** `'lockout::maintenance'`
+
+```php
+'response_view' => 'lockout::maintenance',
+```
+
+#### `response_message`
+Message to display in responses.
+
+**Default:** `'Application is currently in read-only mode.'`
+
+```php
+'response_message' => 'Application is currently in read-only mode.',
+```
+
+#### `response_code`
+HTTP status code for blocked requests.
+
+**Default:** `401` (HTTP_UNAUTHORIZED)
+
+```php
+'response_code' => 401, // Use 503 for maintenance mode
+```
+
+### Route Configuration
+
+#### `route_patterns`
+Route patterns to whitelist (supports wildcards).
+
+**Default:** `[]`
+
+```php
+'route_patterns' => [
+ 'api/*',
+ 'health',
+],
+```
+
+#### `route_names`
+Route names to whitelist.
+
+**Default:** `[]`
+
+```php
+'route_names' => [
+ 'health.check',
+ 'api.status',
+],
+```
+
+### API Configuration
+
+#### `api_enabled`
+Enable API-specific handling.
+
+**Default:** `env('LOCKOUT_API_ENABLED', true)`
+
+```php
+'api_enabled' => env('LOCKOUT_API_ENABLED', true),
+```
+
+#### `api_response_type`
+Response type for API requests.
+
+**Options:** `'json'`, `'abort'`
+
+**Default:** `'json'`
+
+```php
+'api_response_type' => 'json',
+```
+
+#### `api_response_message`
+JSON response message for API requests.
+
+**Default:**
+```php
+'api_response_message' => [
+ 'message' => 'Application is currently in read-only mode.',
+ 'status' => 'maintenance',
+],
+```
+
+### Health Check
+
+#### `health_check_enabled`
+Enable the health check endpoint.
+
+**Default:** `env('LOCKOUT_HEALTH_CHECK_ENABLED', true)`
+
+```php
+'health_check_enabled' => env('LOCKOUT_HEALTH_CHECK_ENABLED', true),
+```
+
+#### `health_check_path`
+Path for the health check endpoint.
+
+**Default:** `env('LOCKOUT_HEALTH_CHECK_PATH', 'health')`
+
+```php
+'health_check_path' => env('LOCKOUT_HEALTH_CHECK_PATH', 'health'),
+```
+
+### Cache Configuration
+
+#### `cache_enabled`
+Enable caching of lockout status.
+
+**Default:** `env('LOCKOUT_CACHE_ENABLED', true)`
+
+```php
+'cache_enabled' => env('LOCKOUT_CACHE_ENABLED', true),
+```
+
+#### `cache_key`
+Cache key for lockout status.
+
+**Default:** `'lockout.status'`
+
+```php
+'cache_key' => 'lockout.status',
+```
+
+#### `cache_ttl`
+Cache time-to-live in seconds.
+
+**Default:** `60`
+
+```php
+'cache_ttl' => 60,
+```
+
+### Event Configuration
+
+#### `fire_events`
+Enable event firing for lockout actions.
+
+**Default:** `env('LOCKOUT_FIRE_EVENTS', true)`
+
+```php
+'fire_events' => env('LOCKOUT_FIRE_EVENTS', true),
+```
+
+## Environment Variables
+
+All configuration can be set via environment variables:
+
+```bash
+# Basic
+APP_READ_ONLY=true
+APP_READ_ONLY_LOGIN=false
+
+# IP Management
+LOCKOUT_IP_WHITELIST=127.0.0.1,192.168.1.0/24
+LOCKOUT_IP_BLACKLIST=192.168.1.50
+
+# Response
+LOCKOUT_RESPONSE_TYPE=view
+LOCKOUT_RESPONSE_CODE=503
+
+# API
+LOCKOUT_API_ENABLED=true
+
+# Health Check
+LOCKOUT_HEALTH_CHECK_ENABLED=true
+LOCKOUT_HEALTH_CHECK_PATH=health
+
+# Cache
+LOCKOUT_CACHE_ENABLED=true
+
+# Events
+LOCKOUT_FIRE_EVENTS=true
+```
+
+## Configuration Examples
+
+### Maintenance Mode Setup
+
+```php
+'enabled' => true,
+'response_type' => 'view',
+'response_code' => 503,
+'ip_whitelist_array' => [
+ '127.0.0.1', // Your IP
+ '10.0.0.0/8', // Internal network
+],
+'health_check_enabled' => true,
+'cache_enabled' => true,
+```
+
+### API-Only Lockout
+
+```php
+'enabled' => true,
+'api_enabled' => true,
+'api_response_type' => 'json',
+'route_patterns' => [
+ 'web/*', // Allow web routes
+],
+'locked_types' => ['post', 'put', 'patch', 'delete'],
+```
+
+### Role-Based Maintenance
+
+```php
+'enabled' => true,
+'allowed_roles' => [
+ 'admin',
+ 'maintenance',
+],
+'response_type' => 'view',
+'response_code' => 503,
```
diff --git a/docs/introduction.md b/docs/introduction.md
index 1fb802b..78f1292 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -10,4 +10,53 @@ weight: 1
[](https://scrutinizer-ci.com/g/rappasoft/lockout)
[](https://packagist.org/packages/rappasoft/lockout)
-A simple .env flag that can place your application into read-only mode. By default only get requests are allowed. You can optionally allow authentication.
+A powerful Laravel package that places your application into read-only mode with advanced features for maintenance, security, and access control.
+
+## Features
+
+- **Simple Configuration**: Enable/disable with a single `.env` flag
+- **IP Whitelist/Blacklist**: Control access by IP address with CIDR support
+- **Role-Based Exceptions**: Allow specific user roles to bypass lockout
+- **Custom Responses**: View, JSON, or custom response types
+- **API Support**: Automatic API detection with JSON responses
+- **Route Patterns**: Whitelist routes by pattern or name
+- **Health Check Endpoint**: Built-in monitoring endpoint
+- **Cache Integration**: Performance optimization with configurable caching
+- **Event System**: Listen to lockout events for logging and monitoring
+- **Artisan Commands**: Manage lockout via command line
+- **Blade Directives**: Conditional rendering based on lockout status
+
+## Quick Start
+
+1. Install the package:
+```bash
+composer require rappasoft/lockout
+```
+
+2. Enable lockout:
+```bash
+# In .env
+APP_READ_ONLY=true
+```
+
+3. That's it! Your application is now in read-only mode.
+
+## Use Cases
+
+- **Maintenance Mode**: Put your application in maintenance while keeping it accessible
+- **Emergency Lockdown**: Quickly disable write operations during security incidents
+- **Scheduled Maintenance**: Allow read access while performing updates
+- **Demo Environments**: Prevent data modification in demo/staging environments
+- **Access Control**: Combine with IP whitelisting for secure maintenance access
+
+## Requirements
+
+- PHP 8.2+
+- Laravel 11.x or 12.x
+
+## Documentation
+
+- [Installation](installation.md)
+- [Configuration](configuration.md)
+- [Usage](usage.md)
+- [Changelog](changelog.md)
diff --git a/docs/usage.md b/docs/usage.md
index b8c0115..90a868e 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -3,52 +3,355 @@ title: Usage
weight: 7
---
-## Enable
+## Quick Start
-Add to .env file
+### Enable Lockout
-``` bash
-APP_READ_ONLY = true
+Add to your `.env` file:
+
+```bash
+APP_READ_ONLY=true
+```
+
+By default, only pages accessed with GET requests will be allowed. All other request types (POST, PUT, PATCH, DELETE) will be blocked with a 401 Unauthorized response.
+
+### Disable Lockout
+
+```bash
+APP_READ_ONLY=false
+```
+
+Or use the Artisan command:
+
+```bash
+php artisan lockout:disable
```
-By default only pages accessed with GET requests will be allowed, everything else will throw a 401 Unauthorized response. You can modify the blocked request types in the lockout configuration file.
+## Basic Configuration
-## Configure
+### Allow Login During Lockout
-To optionally allow logging into the application to view secure areas, after login only GET requests will still be allowed.
+To allow users to log in and view secure areas (but still only allow GET requests after login):
-``` bash
-APP_READ_ONLY_LOGIN = true
+```bash
+APP_READ_ONLY_LOGIN=true
```
-You can set your login/logout paths from the configuration file if they differ from the Laravel default.
+You can customize your login/logout paths in the configuration file if they differ from Laravel defaults.
-### Locking GET Pages
+### Locking Specific GET Pages
-If you want to block access to regular pages in addition to the lockout types, or just by themselves you can edit the pages array of the config file:
+Block access to specific pages even for GET requests:
-``` php
+```php
'pages' => [
- 'register', // Blocks access to the register page
+ 'register',
+ 'subscribe',
+ 'admin/settings',
],
```
-### Conditionally Rendering Views
+### Whitelisting Specific Routes
-You may conditionally render views based on the status of the lockout with this blade helper:
+Allow specific routes to bypass lockout:
-``` php
-@readonly
- Sorry for the inconvenience, but our application is currently in read-only mode. Please check back soon.
-@endreadonly
+```php
+'whitelist' => [
+ 'post' => 'password/confirm',
+ 'put' => 'profile/update',
+],
```
-### Whitelisting Pages
+## Advanced Features
-You may whitelist certain paths for certain methods. I.e. part of your application is behind the password.confirm middleware and you want the demo user to be able to access it.
+### IP Whitelist/Blacklist
+#### IP Whitelist
+
+Allow specific IP addresses to bypass lockout completely:
+
+**Via .env:**
+```bash
+LOCKOUT_IP_WHITELIST=127.0.0.1,192.168.1.100
```
-'whitelist' => [
- 'post' => '/password/confirm',
+
+**Via Config:**
+```php
+'ip_whitelist_array' => [
+ '127.0.0.1',
+ '192.168.1.0/24', // CIDR notation supported
+ '10.0.0.0/8',
],
```
+
+#### IP Blacklist
+
+Block specific IP addresses even when lockout is disabled:
+
+**Via .env:**
+```bash
+LOCKOUT_IP_BLACKLIST=192.168.1.50
+```
+
+**Via Config:**
+```php
+'ip_blacklist_array' => [
+ '192.168.1.50',
+ '10.0.0.100/24',
+],
+```
+
+### Role-Based Exceptions
+
+Allow users with specific roles to bypass lockout:
+
+```php
+'allowed_roles' => [
+ 'admin',
+ 'super-admin',
+ 'maintenance',
+],
+```
+
+The package supports:
+- Laravel Bouncer
+- Spatie Permission
+- Simple role attributes (`$user->role`)
+- Roles relationships
+
+### Custom Response Types
+
+#### View Response
+
+Show a custom maintenance page:
+
+```php
+'response_type' => 'view',
+'response_view' => 'lockout::maintenance',
+'response_message' => 'We are currently performing maintenance.',
+'response_code' => 503,
+```
+
+Publish the view to customize:
+
+```bash
+php artisan vendor:publish --tag=lockout-views
+```
+
+#### JSON Response
+
+Return JSON for API requests:
+
+```php
+'response_type' => 'json',
+'response_code' => 503,
+```
+
+#### API-Specific Handling
+
+Automatically detect and handle API requests differently:
+
+```php
+'api_enabled' => true,
+'api_response_type' => 'json',
+'api_response_message' => [
+ 'message' => 'Application is in maintenance mode.',
+ 'status' => 'maintenance',
+],
+```
+
+### Route Patterns and Names
+
+#### Route Patterns
+
+Whitelist routes by pattern:
+
+```php
+'route_patterns' => [
+ 'api/*', // All API routes
+ 'health', // Health check
+ 'admin/*', // All admin routes
+],
+```
+
+#### Route Names
+
+Whitelist routes by name:
+
+```php
+'route_names' => [
+ 'health.check',
+ 'api.status',
+ 'admin.dashboard',
+],
+```
+
+### Health Check Endpoint
+
+A health check endpoint is automatically registered at `/health` (configurable):
+
+```bash
+GET /health
+```
+
+Response:
+```json
+{
+ "status": "ok",
+ "timestamp": "2025-01-15T10:30:00Z",
+ "lockout_enabled": true
+}
+```
+
+Disable or customize:
+
+```php
+'health_check_enabled' => true,
+'health_check_path' => 'health',
+```
+
+### Cache Integration
+
+Improve performance by caching lockout status:
+
+```php
+'cache_enabled' => true,
+'cache_key' => 'lockout.status',
+'cache_ttl' => 60, // seconds
+```
+
+Disable caching:
+
+```php
+'cache_enabled' => false,
+```
+
+Clear cache manually:
+
+```bash
+php artisan cache:clear
+```
+
+Or when enabling/disabling:
+
+```bash
+php artisan lockout:enable --clear-cache
+php artisan lockout:disable --clear-cache
+```
+
+### Event System
+
+The package fires events that you can listen to:
+
+#### Available Events
+
+- `Rappasoft\Lockout\Events\LockoutEnabled` - Fired when lockout is enabled
+- `Rappasoft\Lockout\Events\LockoutDisabled` - Fired when lockout is disabled
+- `Rappasoft\Lockout\Events\RequestBlocked` - Fired when a request is blocked
+
+#### Example Listener
+
+```php
+use Rappasoft\Lockout\Events\RequestBlocked;
+use Illuminate\Support\Facades\Log;
+
+class LogBlockedRequests
+{
+ public function handle(RequestBlocked $event)
+ {
+ Log::warning('Request blocked', [
+ 'path' => $event->request->path(),
+ 'method' => $event->request->method(),
+ 'ip' => $event->request->ip(),
+ 'reason' => $event->reason,
+ ]);
+ }
+}
+```
+
+Register in `EventServiceProvider`:
+
+```php
+protected $listen = [
+ RequestBlocked::class => [
+ LogBlockedRequests::class,
+ ],
+];
+```
+
+Disable events:
+
+```php
+'fire_events' => false,
+```
+
+## Artisan Commands
+
+### Enable Lockout
+
+```bash
+php artisan lockout:enable
+```
+
+Options:
+- `--clear-cache` - Clear the lockout cache after enabling
+
+### Disable Lockout
+
+```bash
+php artisan lockout:disable
+```
+
+Options:
+- `--clear-cache` - Clear the lockout cache after disabling
+
+### Check Status
+
+```bash
+php artisan lockout:status
+```
+
+Output:
+```
+Lockout Status:
+─────────────────
+Enabled: Yes
+Cached: Yes
+
+Configuration:
+ Allow Login: Yes
+ Locked Types: post, put, patch, delete
+ Response Type: abort
+ Health Check: Yes
+ Cache Enabled: Yes
+```
+
+## Blade Directive
+
+Conditionally render content based on lockout status:
+
+```blade
+@readonly
+
+ Application is currently in read-only mode.
+
+@else
+
+ Application is fully operational.
+
+@endreadonly
+```
+
+## Best Practices
+
+1. **Use IP Whitelist for Maintenance**: Whitelist your IP before enabling lockout to ensure you can still access the application.
+
+2. **Use Health Check for Monitoring**: Configure your monitoring tools to check the `/health` endpoint.
+
+3. **Enable Caching in Production**: Improve performance by enabling cache for lockout status.
+
+4. **Use Events for Logging**: Listen to `RequestBlocked` events to track blocked requests.
+
+5. **Customize Response for Better UX**: Use view responses for web requests and JSON for API requests.
+
+6. **Test Before Production**: Always test lockout in a staging environment first.
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 08911cd..0f2131f 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,12 +1,7 @@
@@ -14,15 +9,12 @@
tests
-
-
+
+ src/
-
-
+
+
-
-
-
-
+
diff --git a/resources/views/maintenance.blade.php b/resources/views/maintenance.blade.php
new file mode 100644
index 0000000..e85bf65
--- /dev/null
+++ b/resources/views/maintenance.blade.php
@@ -0,0 +1,79 @@
+
+
+
+
+
+ Maintenance Mode
+
+
+
+
+
🔒
+
Application in Read-Only Mode
+
We're currently performing maintenance. The application is in read-only mode to protect your data.
+ @if(isset($message))
+
+ Status:
+ {{ $message }}
+
+ @endif
+
+ Please check back soon. We apologize for any inconvenience.
+
+
+
+
+
diff --git a/src/Console/Commands/LockoutDisable.php b/src/Console/Commands/LockoutDisable.php
new file mode 100644
index 0000000..9c1eb8f
--- /dev/null
+++ b/src/Console/Commands/LockoutDisable.php
@@ -0,0 +1,64 @@
+option('clear-cache')) {
+ Cache::forget(config('lockout.cache_key', 'lockout.status'));
+ $this->info('Lockout cache cleared.');
+ }
+
+ // Fire event
+ if (config('lockout.fire_events', true)) {
+ event(new LockoutDisabled());
+ }
+
+ // Reload config
+ config(['lockout.enabled' => false]);
+
+ $this->info('Application lockout has been disabled.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Console/Commands/LockoutEnable.php b/src/Console/Commands/LockoutEnable.php
new file mode 100644
index 0000000..e677c7e
--- /dev/null
+++ b/src/Console/Commands/LockoutEnable.php
@@ -0,0 +1,64 @@
+option('clear-cache')) {
+ Cache::forget(config('lockout.cache_key', 'lockout.status'));
+ $this->info('Lockout cache cleared.');
+ }
+
+ // Fire event
+ if (config('lockout.fire_events', true)) {
+ event(new LockoutEnabled());
+ }
+
+ // Reload config
+ config(['lockout.enabled' => true]);
+
+ $this->info('Application lockout has been enabled.');
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Console/Commands/LockoutStatus.php b/src/Console/Commands/LockoutStatus.php
new file mode 100644
index 0000000..5db3881
--- /dev/null
+++ b/src/Console/Commands/LockoutStatus.php
@@ -0,0 +1,49 @@
+info('Lockout Status:');
+ $this->line('─────────────────');
+ $this->line('Enabled: '.($enabled ? 'Yes>' : 'No>'));
+ $this->line('Cached: '.($cached ? 'Yes>' : 'No>'));
+
+ if ($enabled) {
+ $this->newLine();
+ $this->info('Configuration:');
+ $this->line(' Allow Login: '.(config('lockout.allow_login') ? 'Yes' : 'No'));
+ $this->line(' Locked Types: '.implode(', ', config('lockout.locked_types', [])));
+ $this->line(' Response Type: '.config('lockout.response_type', 'abort'));
+ $this->line(' Health Check: '.(config('lockout.health_check_enabled') ? 'Yes' : 'No'));
+ $this->line(' Cache Enabled: '.(config('lockout.cache_enabled') ? 'Yes' : 'No'));
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Events/LockoutDisabled.php b/src/Events/LockoutDisabled.php
new file mode 100644
index 0000000..32cb68a
--- /dev/null
+++ b/src/Events/LockoutDisabled.php
@@ -0,0 +1,19 @@
+ $path) {
- if ($request->isMethod($method) && $request->path() === $path) {
- return $next($request);
- }
+ // Check if lockout is enabled (with caching)
+ $enabled = $this->isLockoutEnabled();
+
+ if (! $enabled) {
+ // Even if lockout is disabled, check IP blacklist
+ if ($this->isIpBlocked($request)) {
+ return $this->blockRequest($request, 'IP blacklisted');
}
- foreach (config('lockout.locked_types', []) as $type) {
- if ($request->isMethod('post') && config('lockout.allow_login')) {
- abort_if($request->path() !== config('lockout.login_path') && $request->path() !== config('lockout.logout_path'), Response::HTTP_UNAUTHORIZED);
- } elseif ($request->isMethod(strtolower($type))) {
- abort(Response::HTTP_UNAUTHORIZED);
- }
+ return $next($request);
+ }
+
+ $requestMethod = strtolower($request->method());
+ $requestPath = $request->path();
+ $clientIp = $request->ip();
+
+ // Health check endpoint is always accessible
+ if ($this->isHealthCheckPath($requestPath)) {
+ return $next($request);
+ }
+
+ // Check IP whitelist first
+ if ($this->isIpWhitelisted($clientIp)) {
+ return $next($request);
+ }
+
+ // Check IP blacklist
+ if ($this->isIpBlocked($clientIp)) {
+ return $this->blockRequest($request, 'IP blacklisted');
+ }
+
+ // Check role-based exceptions
+ if ($this->hasAllowedRole($request)) {
+ return $next($request);
+ }
+
+ // Check route patterns
+ if ($this->matchesRoutePattern($request)) {
+ return $next($request);
+ }
+
+ // Check route names
+ if ($this->matchesRouteName($request)) {
+ return $next($request);
+ }
+
+ // Check to see if this method and path is whitelisted
+ foreach (config('lockout.whitelist', []) as $method => $path) {
+ if ($request->isMethod($method) && $requestPath === $path) {
+ return $next($request);
+ }
+ }
+
+ // Handle POST requests with allow_login enabled
+ if ($requestMethod === 'post' && config('lockout.allow_login')) {
+ $loginPath = config('lockout.login_path', 'login');
+ $logoutPath = config('lockout.logout_path', 'logout');
+
+ if ($requestPath === $loginPath || $requestPath === $logoutPath) {
+ return $next($request);
}
+ }
+
+ // Check if the request method is in the locked types
+ $lockedTypes = array_map('strtolower', config('lockout.locked_types', []));
+ if (in_array($requestMethod, $lockedTypes, true)) {
+ return $this->blockRequest($request, 'Method locked: '.$requestMethod);
+ }
- // Block any other specific get requests that may alter data
- if ($request->isMethod('get')) {
- collect(config('lockout.pages', []))
- ->each(function ($item) use ($request) {
- if ($request->path() === $item) {
- abort(Response::HTTP_UNAUTHORIZED);
- }
- });
+ // Block any other specific get requests that may alter data
+ if ($requestMethod === 'get') {
+ $pages = config('lockout.pages', []);
+ if (is_array($pages) && in_array($requestPath, $pages, true)) {
+ return $this->blockRequest($request, 'Page blocked: '.$requestPath);
}
}
return $next($request);
}
+
+ /**
+ * Check if lockout is enabled (with caching).
+ */
+ protected function isLockoutEnabled(): bool
+ {
+ if (! config('lockout.cache_enabled', true)) {
+ return (bool) config('lockout.enabled');
+ }
+
+ $cacheKey = config('lockout.cache_key', 'lockout.status');
+ $cacheTtl = config('lockout.cache_ttl', 60);
+
+ return Cache::remember($cacheKey, $cacheTtl, function () {
+ return (bool) config('lockout.enabled');
+ });
+ }
+
+ /**
+ * Check if IP is whitelisted.
+ */
+ protected function isIpWhitelisted(string $ip): bool
+ {
+ $whitelistEnv = config('lockout.ip_whitelist', '');
+ $whitelistArray = config('lockout.ip_whitelist_array', []);
+
+ $whitelist = array_merge(
+ IpHelper::parseIpList($whitelistEnv),
+ $whitelistArray
+ );
+
+ return IpHelper::isIpAllowed($ip, $whitelist);
+ }
+
+ /**
+ * Check if IP is blacklisted.
+ */
+ protected function isIpBlocked(string|Request $ipOrRequest): bool
+ {
+ $ip = $ipOrRequest instanceof Request ? $ipOrRequest->ip() : $ipOrRequest;
+
+ $blacklistEnv = config('lockout.ip_blacklist', '');
+ $blacklistArray = config('lockout.ip_blacklist_array', []);
+
+ $blacklist = array_merge(
+ IpHelper::parseIpList($blacklistEnv),
+ $blacklistArray
+ );
+
+ return IpHelper::isIpBlocked($ip, $blacklist);
+ }
+
+ /**
+ * Check if user has an allowed role.
+ */
+ protected function hasAllowedRole(Request $request): bool
+ {
+ $allowedRoles = config('lockout.allowed_roles', []);
+
+ if (empty($allowedRoles)) {
+ return false;
+ }
+
+ $user = $request->user();
+
+ if (! $user) {
+ return false;
+ }
+
+ // Check if user has any of the allowed roles
+ // Supports both Laravel's built-in roles and common packages
+ foreach ($allowedRoles as $role) {
+ // Check for Laravel Bouncer, Spatie Permission, or similar packages
+ if (method_exists($user, 'hasRole') && $user->hasRole($role)) {
+ return true;
+ }
+
+ // Check for simple role attribute
+ if (isset($user->role) && $user->role === $role) {
+ return true;
+ }
+
+ // Check for roles relationship
+ if (method_exists($user, 'roles')) {
+ $roles = $user->roles;
+ if ($roles && $roles->contains('name', $role)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if request matches route patterns.
+ */
+ protected function matchesRoutePattern(Request $request): bool
+ {
+ $patterns = config('lockout.route_patterns', []);
+
+ if (empty($patterns)) {
+ return false;
+ }
+
+ $path = $request->path();
+
+ foreach ($patterns as $pattern) {
+ // Convert wildcard pattern to regex
+ $regex = str_replace(['*', '/'], ['.*', '\/'], $pattern);
+ $regex = '/^'.$regex.'$/';
+
+ if (preg_match($regex, $path)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if request matches route names.
+ */
+ protected function matchesRouteName(Request $request): bool
+ {
+ $routeNames = config('lockout.route_names', []);
+
+ if (empty($routeNames)) {
+ return false;
+ }
+
+ $route = Route::current();
+
+ if (! $route) {
+ return false;
+ }
+
+ $routeName = $route->getName();
+
+ return $routeName && in_array($routeName, $routeNames, true);
+ }
+
+ /**
+ * Check if path is the health check endpoint.
+ */
+ protected function isHealthCheckPath(string $path): bool
+ {
+ if (! config('lockout.health_check_enabled', true)) {
+ return false;
+ }
+
+ $healthCheckPath = config('lockout.health_check_path', 'health');
+
+ return $path === $healthCheckPath;
+ }
+
+ /**
+ * Block the request and return appropriate response.
+ */
+ protected function blockRequest(Request $request, string $reason): SymfonyResponse
+ {
+ // Fire event if enabled
+ if (config('lockout.fire_events', true)) {
+ event(new RequestBlocked($request, $reason));
+ }
+
+ // Check if this is an API request
+ if ($this->isApiRequest($request)) {
+ return $this->handleApiResponse($request);
+ }
+
+ // Handle custom response types
+ $responseType = config('lockout.response_type', 'abort');
+
+ return match ($responseType) {
+ 'view' => $this->handleViewResponse($request),
+ 'json' => $this->handleJsonResponse($request),
+ default => $this->handleAbortResponse($request),
+ };
+ }
+
+ /**
+ * Check if request is an API request.
+ */
+ protected function isApiRequest(Request $request): bool
+ {
+ // Check if API is enabled
+ if (! config('lockout.api_enabled', true)) {
+ return false;
+ }
+
+ // Check if request expects JSON
+ if ($request->expectsJson()) {
+ return true;
+ }
+
+ // Check if path starts with api/
+ if (str_starts_with($request->path(), 'api/')) {
+ return true;
+ }
+
+ // Check if route is in api middleware group
+ $route = Route::current();
+ if ($route && in_array('api', $route->middleware(), true)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Handle API response.
+ */
+ protected function handleApiResponse(Request $request): SymfonyResponse
+ {
+ $apiResponseType = config('lockout.api_response_type', 'json');
+ $message = config('lockout.api_response_message', [
+ 'message' => 'Application is currently in read-only mode.',
+ 'status' => 'maintenance',
+ ]);
+
+ if ($apiResponseType === 'json') {
+ return response()->json($message, config('lockout.response_code', Response::HTTP_UNAUTHORIZED));
+ }
+
+ return $this->handleAbortResponse($request);
+ }
+
+ /**
+ * Handle view response.
+ */
+ protected function handleViewResponse(Request $request): SymfonyResponse
+ {
+ $view = config('lockout.response_view', 'lockout::maintenance');
+ $message = config('lockout.response_message', 'Application is currently in read-only mode.');
+ $code = config('lockout.response_code', Response::HTTP_UNAUTHORIZED);
+
+ return response()->view($view, ['message' => $message], $code);
+ }
+
+ /**
+ * Handle JSON response.
+ */
+ protected function handleJsonResponse(Request $request): SymfonyResponse
+ {
+ $message = config('lockout.response_message', 'Application is currently in read-only mode.');
+ $code = config('lockout.response_code', Response::HTTP_UNAUTHORIZED);
+
+ return response()->json(['message' => $message], $code);
+ }
+
+ /**
+ * Handle abort response (default).
+ */
+ protected function handleAbortResponse(Request $request): SymfonyResponse
+ {
+ $code = config('lockout.response_code', Response::HTTP_UNAUTHORIZED);
+ $message = config('lockout.response_message', 'Application is currently in read-only mode.');
+
+ return response($message, $code);
+ }
}
diff --git a/src/LockoutServiceProvider.php b/src/LockoutServiceProvider.php
index ab05ead..d0b2b3c 100644
--- a/src/LockoutServiceProvider.php
+++ b/src/LockoutServiceProvider.php
@@ -4,7 +4,11 @@
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Support\Facades\Blade;
+use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
+use Rappasoft\Lockout\Console\Commands\LockoutDisable;
+use Rappasoft\Lockout\Console\Commands\LockoutEnable;
+use Rappasoft\Lockout\Console\Commands\LockoutStatus;
use Rappasoft\Lockout\Http\Middleware\CheckForReadOnlyMode;
/**
@@ -15,32 +19,52 @@ class LockoutServiceProvider extends ServiceProvider
/**
* Bootstrap the application services.
*/
- public function boot()
+ public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__.'/../config/lockout.php' => config_path('lockout.php'),
], 'config');
+
+ $this->publishes([
+ __DIR__.'/../../resources/views' => resource_path('views/vendor/lockout'),
+ ], 'lockout-views');
}
$this->registerBladeExtensions();
+ $this->registerHealthCheckRoute();
}
/**
* Register the application services.
*/
- public function register()
+ public function register(): void
{
// Register the config file
$this->mergeConfigFrom(__DIR__.'/../config/lockout.php', 'lockout');
+ // Register views
+ $this->loadViewsFrom(__DIR__.'/../../resources/views', 'lockout');
+
+ // Register commands
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ LockoutEnable::class,
+ LockoutDisable::class,
+ LockoutStatus::class,
+ ]);
+ }
+
// Publish the middleware globally
$this->app
->make(Kernel::class)
->pushMiddleware(CheckForReadOnlyMode::class);
}
- protected function registerBladeExtensions()
+ /**
+ * Register Blade extensions.
+ */
+ protected function registerBladeExtensions(): void
{
/*
* The block of code inside this directive indicates
@@ -50,4 +74,24 @@ protected function registerBladeExtensions()
return config('lockout.enabled');
});
}
+
+ /**
+ * Register health check route.
+ */
+ protected function registerHealthCheckRoute(): void
+ {
+ if (! config('lockout.health_check_enabled', true)) {
+ return;
+ }
+
+ $healthCheckPath = config('lockout.health_check_path', 'health');
+
+ Route::get($healthCheckPath, function () {
+ return response()->json([
+ 'status' => 'ok',
+ 'timestamp' => now()->toIso8601String(),
+ 'lockout_enabled' => config('lockout.enabled', false),
+ ]);
+ })->name('lockout.health');
+ }
}
diff --git a/src/resources/views/maintenance.blade.php b/src/resources/views/maintenance.blade.php
new file mode 100644
index 0000000..e85bf65
--- /dev/null
+++ b/src/resources/views/maintenance.blade.php
@@ -0,0 +1,79 @@
+
+
+
+
+
+ Maintenance Mode
+
+
+
+
+
🔒
+
Application in Read-Only Mode
+
We're currently performing maintenance. The application is in read-only mode to protect your data.
+ @if(isset($message))
+
+ Status:
+ {{ $message }}
+
+ @endif
+
+ Please check back soon. We apologize for any inconvenience.
+