Skip to content

Commit

Permalink
Systemic improvements.
Browse files Browse the repository at this point in the history
  • Loading branch information
mralston committed Oct 28, 2021
1 parent 4d06e2e commit 96cb74b
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 54 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
5 changes: 4 additions & 1 deletion config/lockout.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?php

return [
'max_attempts' => 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),
];
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
Expand Down
20 changes: 20 additions & 0 deletions src/Console/Prune.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Mralston\Lockout\Console;

use Illuminate\Console\Command;
use Mralston\Lockout\Models\AuthFailure;

class Prune extends Command
{
protected $signature = 'lockout:prune';

protected $description = 'Prunes stale authentication failure records.';

public function handle()
{
AuthFailure::prune();

return 0;
}
}
38 changes: 30 additions & 8 deletions src/Console/Unlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,44 @@

class Unlock extends Command
{
protected $signature = 'lockout:unlock {user}';
protected $signature = 'lockout:unlock {--user=} {--email=} {--ip=}';

protected $description = 'Unlock a user account.';
protected $description = 'Unlock a user account by ID or email address, or unlock an IP address.';

public function handle()
{
$failure = AuthFailure::firstWhere('user_id', $this->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;
}
Expand Down
27 changes: 0 additions & 27 deletions src/Http/Middleware/Lockout.php

This file was deleted.

48 changes: 48 additions & 0 deletions src/Listeners/AuthAttempted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

namespace Mralston\Lockout\Listeners;

use Illuminate\Auth\Events\Attempting;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Mralston\Lockout\Models\AuthFailure;

class AuthAttempted
{
use AuthenticatesUsers;

public function handle(Attempting $event)
{
// Determine the username property on the user model
$username_field = $this->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');
}
}
}
18 changes: 10 additions & 8 deletions src/Listeners/AuthFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
]);
}
}
7 changes: 2 additions & 5 deletions src/Listeners/AuthSucceeded.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
52 changes: 51 additions & 1 deletion src/Models/AuthFailure.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
5 changes: 5 additions & 0 deletions src/Providers/EventServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
Expand Down
4 changes: 2 additions & 2 deletions src/Providers/LockoutServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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([
Expand Down

0 comments on commit 96cb74b

Please sign in to comment.