diff --git a/composer.json b/composer.json index d625176..a9b2dea 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "php": "^7.4|^8.0", "illuminate/auth": "^8.68", "illuminate/database": "^7.0|^8.0", + "illuminate/http": "^8.68", "illuminate/support": "^7.0|^8.0" }, "autoload": { diff --git a/config/lockout.php b/config/lockout.php index e804a5d..d97e09c 100644 --- a/config/lockout.php +++ b/config/lockout.php @@ -1,5 +1,8 @@ env('MAX_LOGIN_ATTEMPTS', 10), + 'max_attempts_user' => env('MAX_LOGIN_ATTEMPTS_USER', 10), + 'max_attempts_ip' => env('MAX_LOGIN_ATTEMPTS_IP', 20), + 'lockout_duration_user' => env('LOCKOUT_DURATION_USER', 15 * 60), + 'lockout_duration_ip' => env('LOCKOUT_DURATION_IP', 60 * 60 * 24 * 7), ]; diff --git a/database/migrations/create_auth_failures_table.php b/database/migrations/2021_10_28_000000_create_auth_failures_table.php similarity index 66% rename from database/migrations/create_auth_failures_table.php rename to database/migrations/2021_10_28_000000_create_auth_failures_table.php index dd7aec7..45bfa52 100644 --- a/database/migrations/create_auth_failures_table.php +++ b/database/migrations/2021_10_28_000000_create_auth_failures_table.php @@ -10,8 +10,10 @@ public function up() { Schema::create('auth_failures', function (Blueprint $table) { $table->id(); - $table->unsignedBigInteger('user_id')->unique(); - $table->integer('attempts')->default(0); + $table->unsignedBigInteger('user_id')->nullable()->index(); + $table->string('email')->nullable()->index(); + $table->ipAddress('ip_address')->index(); + $table->string('user_agent'); $table->timestamps(); }); } diff --git a/src/Console/Prune.php b/src/Console/Prune.php new file mode 100644 index 0000000..3b15bc6 --- /dev/null +++ b/src/Console/Prune.php @@ -0,0 +1,20 @@ +argument('user')); + if (!empty($this->option('ip'))) { + $records_affected = AuthFailure::where('ip_address', $this->option('ip')) + ->delete(); + + if ($records_affected > 0) { + $this->info('IP address unlocked.'); + } else { + $this->warn('IP address wasn\'t locked.'); + } + } + + if (!empty($this->option('email'))) { + $records_affected = AuthFailure::where('email', $this->option('email')) + ->delete(); - if (empty($failure)) { - $this->error('User wasn\'t locked.'); - return 1; + if ($records_affected > 0) { + $this->info('E-mail address unlocked.'); + } else { + $this->warn('E-mail address wasn\'t locked.'); + } } - $failure->delete(); + if (!empty($this->option('user'))) { + $records_affected = AuthFailure::where('user_id', $this->option('user')) + ->delete(); - $this->info('User unlocked.'); + if ($records_affected > 0) { + $this->info('User unlocked.'); + } else { + $this->warn('User wasn\'t locked.'); + } + } return 0; } diff --git a/src/Http/Middleware/Lockout.php b/src/Http/Middleware/Lockout.php deleted file mode 100644 index 16c6e6a..0000000 --- a/src/Http/Middleware/Lockout.php +++ /dev/null @@ -1,27 +0,0 @@ -attempts ?? 0 > intval(config('lockout.max_attempts'))) { - - Auth::logout(); - - abort(403, 'Your account has been locked'); - } - } - - return $response; - } -} diff --git a/src/Listeners/AuthAttempted.php b/src/Listeners/AuthAttempted.php new file mode 100644 index 0000000..bbd0f0c --- /dev/null +++ b/src/Listeners/AuthAttempted.php @@ -0,0 +1,48 @@ +username(); + + // Fetch the maximum number of login attempts per user and per IP address + $max_attempts_user = config('lockout.max_attempts_user'); + $max_attempts_ip = config('lockout.max_attempts_ip'); + + // Determine the timescale within which max login attempts shouldn't be exceeded + $lockout_duration_user = config('lockout.lockout_duration_user'); + $lockout_duration_ip = config('lockout.lockout_duration_ip'); + + if (!empty($lockout_duration_user)) { + $user_since = Carbon::now()->subSeconds($lockout_duration_user); + } + + if (!empty($lockout_duration_ip)) { + $ip_since = Carbon::now()->subSeconds($lockout_duration_ip); + } + + // Lockout if client IP has made too many attempts + if (AuthFailure::countIpFailures(request()->ip(), $ip_since ?? null) >= $max_attempts_ip) { + abort(403, 'Too many failed attempts'); + } + + // Lockout if username has had too many failures + if (AuthFailure::countEmailFailures($event->credentials[$username_field], $user_since ?? null) >= $max_attempts_user) { + Auth::logout(); + + abort(403, 'Too many failed attempts'); + } + } +} \ No newline at end of file diff --git a/src/Listeners/AuthFailed.php b/src/Listeners/AuthFailed.php index 17117c0..ddcdd9b 100644 --- a/src/Listeners/AuthFailed.php +++ b/src/Listeners/AuthFailed.php @@ -3,22 +3,24 @@ namespace Mralston\Lockout\Listeners; use Illuminate\Auth\Events\Failed; +use Illuminate\Foundation\Auth\AuthenticatesUsers; use Mralston\Lockout\Models\AuthFailure; class AuthFailed { + use AuthenticatesUsers; + public function handle(Failed $event) { - if (empty($event->user)) { - return; - } + $request = app('request'); - $failure = AuthFailure::firstOrCreate([ - 'user_id' => $event->user->id, - ]); + $username_field = $this->username(); - $failure->update([ - 'attempts' => ($failure->attempts ?? 0) + 1, + AuthFailure::create([ + 'user_id' => $event->user->id ?? null, + 'email' => $event->credentials[$username_field], + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), ]); } } \ No newline at end of file diff --git a/src/Listeners/AuthSucceeded.php b/src/Listeners/AuthSucceeded.php index f23fe4f..0522efc 100644 --- a/src/Listeners/AuthSucceeded.php +++ b/src/Listeners/AuthSucceeded.php @@ -13,10 +13,7 @@ public function handle(Login $event) return; } - $failure = AuthFailure::firstWhere('user_id', $event->user->id); - - if (!empty($failure) && $failure->attempts < intval(config('lockout.max_attempts'))) { - $failure->delete(); - } + AuthFailure::where('user_id', $event->user->id) + ->delete(); } } \ No newline at end of file diff --git a/src/Models/AuthFailure.php b/src/Models/AuthFailure.php index 546fd0f..a38714d 100644 --- a/src/Models/AuthFailure.php +++ b/src/Models/AuthFailure.php @@ -2,13 +2,63 @@ namespace Mralston\Lockout\Models; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Carbon; class AuthFailure extends Model { public $fillable = [ 'user_id', 'email', - 'attempts', + 'ip_address', + 'user_agent', ]; + + public static function countUserFailures(Authenticatable $user, ?Carbon $since = null): int + { + $query = static::where('user_id', $user->id); + + if (!empty($since)) { + $query->where('created_at', '>', $since); + } + + return $query->count(); + } + + public static function countEmailFailures(string $email, ?Carbon $since = null): int + { + $query = static::where('email', $email); + + if (!empty($since)) { + $query->where('created_at', '>', $since); + } + + return $query->count(); + } + + public static function countIpFailures(string $ip_address, ?Carbon $since = null): int + { + $query = static::where('ip_address', $ip_address); + + if (!empty($since)) { + $query->where('created_at', '>', $since); + } + + return $query->count(); + } + + public static function prune() + { + $duration = max( + config('lockout.lockout_duration_user'), + config('lockout.lockout_duration_ip'), + 0 + ); + + $since = Carbon::now()->subSeconds($duration); + + static::where('created_at', '<', $since) + ->delete(); + } } diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index f40a035..bcd0f8c 100644 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -2,15 +2,20 @@ namespace Mralston\Lockout\Providers; +use Illuminate\Auth\Events\Attempting; use Illuminate\Auth\Events\Failed; use Illuminate\Auth\Events\Login; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Mralston\Lockout\Listeners\AuthAttempted; use Mralston\Lockout\Listeners\AuthFailed; use Mralston\Lockout\Listeners\AuthSucceeded; class EventServiceProvider extends ServiceProvider { protected $listen = [ + Attempting::class => [ + AuthAttempted::class, + ], Login::class => [ AuthSucceeded::class, ], diff --git a/src/Providers/LockoutServiceProvider.php b/src/Providers/LockoutServiceProvider.php index 7f19289..73f6f7b 100644 --- a/src/Providers/LockoutServiceProvider.php +++ b/src/Providers/LockoutServiceProvider.php @@ -4,7 +4,7 @@ use Illuminate\Contracts\Http\Kernel; use Illuminate\Support\ServiceProvider; -use Mralston\Lockout\Http\Middleware\Lockout as LockoutMiddleware; +use Mralston\Lockout\Console\Prune; use Mralston\Lockout\Console\Unlock; class LockoutServiceProvider extends ServiceProvider @@ -17,11 +17,11 @@ class LockoutServiceProvider extends ServiceProvider public function boot(Kernel $kernel) { $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); - $kernel->pushMiddleware(LockoutMiddleware::class); if ($this->app->runningInConsole()) { $this->commands([ Unlock::class, + Prune::class, ]); $this->publishes([