style updates

This commit is contained in:
Mark Gregory
2026-07-24 14:59:44 +01:00
parent 24ab771cd3
commit dd23205d0b
482 changed files with 7173 additions and 1435 deletions
+1
View File
@@ -1,4 +1,5 @@
<?php
return [
'record_authentication_events' => (bool) env('SECURITY_RECORD_AUTH_EVENTS', true),
'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30),
@@ -1,45 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
return new class extends Migration
{
public function up(): void
{
Schema::create('security_policies', function (Blueprint $table) {
$table->id(); $table->string('name')->default('Default security policy');
$table->id();
$table->string('name')->default('Default security policy');
$table->unsignedSmallInteger('minimum_password_length')->default(12);
$table->boolean('require_mixed_case')->default(true); $table->boolean('require_numbers')->default(true);
$table->boolean('require_symbols')->default(true); $table->boolean('require_mfa')->default(false);
$table->unsignedInteger('password_expiry_days')->nullable(); $table->unsignedSmallInteger('password_history')->default(5);
$table->unsignedSmallInteger('max_failed_logins')->default(10); $table->unsignedSmallInteger('lockout_minutes')->default(15);
$table->unsignedSmallInteger('session_idle_minutes')->default(120); $table->boolean('single_session')->default(false);
$table->boolean('is_active')->default(true); $table->timestamps();
$table->boolean('require_mixed_case')->default(true);
$table->boolean('require_numbers')->default(true);
$table->boolean('require_symbols')->default(true);
$table->boolean('require_mfa')->default(false);
$table->unsignedInteger('password_expiry_days')->nullable();
$table->unsignedSmallInteger('password_history')->default(5);
$table->unsignedSmallInteger('max_failed_logins')->default(10);
$table->unsignedSmallInteger('lockout_minutes')->default(15);
$table->unsignedSmallInteger('session_idle_minutes')->default(120);
$table->boolean('single_session')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
Schema::create('security_login_events', function (Blueprint $table) {
$table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('email')->nullable()->index(); $table->string('event', 30)->index();
$table->string('ip_address', 45)->nullable()->index(); $table->text('user_agent')->nullable();
$table->string('session_id')->nullable()->index(); $table->string('reason')->nullable();
$table->json('metadata')->nullable(); $table->timestamp('occurred_at')->useCurrent(); $table->timestamps();
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('email')->nullable()->index();
$table->string('event', 30)->index();
$table->string('ip_address', 45)->nullable()->index();
$table->text('user_agent')->nullable();
$table->string('session_id')->nullable()->index();
$table->string('reason')->nullable();
$table->json('metadata')->nullable();
$table->timestamp('occurred_at')->useCurrent();
$table->timestamps();
});
Schema::create('security_devices', function (Blueprint $table) {
$table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name'); $table->string('fingerprint', 64)->index(); $table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable(); $table->timestamp('trusted_until')->nullable();
$table->timestamp('last_seen_at')->nullable(); $table->timestamp('revoked_at')->nullable(); $table->timestamps();
$table->unique(['user_id','fingerprint']);
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('fingerprint', 64)->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->timestamp('trusted_until')->nullable();
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'fingerprint']);
});
Schema::create('security_ip_rules', function (Blueprint $table) {
$table->id(); $table->string('name'); $table->string('type', 10)->default('deny');
$table->string('address', 64); $table->text('description')->nullable();
$table->boolean('applies_to_admin')->default(true); $table->boolean('applies_to_login')->default(true);
$table->boolean('is_active')->default(true); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps(); $table->index(['type','is_active']);
$table->id();
$table->string('name');
$table->string('type', 10)->default('deny');
$table->string('address', 64);
$table->text('description')->nullable();
$table->boolean('applies_to_admin')->default(true);
$table->boolean('applies_to_login')->default(true);
$table->boolean('is_active')->default(true);
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['type', 'is_active']);
});
}
public function down(): void
{
Schema::dropIfExists('security_ip_rules'); Schema::dropIfExists('security_devices');
Schema::dropIfExists('security_login_events'); Schema::dropIfExists('security_policies');
Schema::dropIfExists('security_ip_rules');
Schema::dropIfExists('security_devices');
Schema::dropIfExists('security_login_events');
Schema::dropIfExists('security_policies');
}
};
@@ -1,5 +1,7 @@
<?php
namespace Modules\Security\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -8,24 +10,43 @@ use Modules\Security\Models\IpRule;
use Modules\Security\Models\LoginEvent;
use Modules\Security\Models\SecurityPolicy;
use Modules\Security\Models\TrustedDevice;
final class SecurityController extends Controller
{
public function index(): View
{
return view('security::admin.index',['policy'=>SecurityPolicy::first(),'events'=>LoginEvent::latest('occurred_at')->paginate(25),'devices'=>TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(),'rules'=>IpRule::latest()->get(),'failed24h'=>LoginEvent::where('event','failed')->where('occurred_at','>=',now()->subDay())->count()]);
return view('security::admin.index', ['policy' => SecurityPolicy::first(), 'events' => LoginEvent::latest('occurred_at')->paginate(25), 'devices' => TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(), 'rules' => IpRule::latest()->get(), 'failed24h' => LoginEvent::where('event', 'failed')->where('occurred_at', '>=', now()->subDay())->count()]);
}
public function savePolicy(Request $request): RedirectResponse
{
$data=$request->validate(['minimum_password_length'=>['required','integer','min:8','max:128'],'password_expiry_days'=>['nullable','integer','min:1'],'password_history'=>['required','integer','min:0','max:24'],'max_failed_logins'=>['required','integer','min:1','max:100'],'lockout_minutes'=>['required','integer','min:1','max:1440'],'session_idle_minutes'=>['required','integer','min:5','max:10080']]);
foreach(['require_mixed_case','require_numbers','require_symbols','require_mfa','single_session'] as $key) $data[$key]=$request->boolean($key);
SecurityPolicy::updateOrCreate(['id'=>SecurityPolicy::value('id')],$data+['name'=>'Default security policy','is_active'=>true]);
return back()->with('status','Security policy updated.');
$data = $request->validate(['minimum_password_length' => ['required', 'integer', 'min:8', 'max:128'], 'password_expiry_days' => ['nullable', 'integer', 'min:1'], 'password_history' => ['required', 'integer', 'min:0', 'max:24'], 'max_failed_logins' => ['required', 'integer', 'min:1', 'max:100'], 'lockout_minutes' => ['required', 'integer', 'min:1', 'max:1440'], 'session_idle_minutes' => ['required', 'integer', 'min:5', 'max:10080']]);
foreach (['require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'single_session'] as $key) {
$data[$key] = $request->boolean($key);
}
SecurityPolicy::updateOrCreate(['id' => SecurityPolicy::value('id')], $data + ['name' => 'Default security policy', 'is_active' => true]);
return back()->with('status', 'Security policy updated.');
}
public function storeRule(Request $request): RedirectResponse
{
IpRule::create($request->validate(['name'=>['required','string','max:255'],'type'=>['required','in:allow,deny'],'address'=>['required','string','max:64'],'description'=>['nullable','string']])+['applies_to_admin'=>$request->boolean('applies_to_admin'),'applies_to_login'=>$request->boolean('applies_to_login'),'is_active'=>true,'created_by'=>$request->user()->id]);
return back()->with('status','IP rule created.');
IpRule::create($request->validate(['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:allow,deny'], 'address' => ['required', 'string', 'max:64'], 'description' => ['nullable', 'string']]) + ['applies_to_admin' => $request->boolean('applies_to_admin'), 'applies_to_login' => $request->boolean('applies_to_login'), 'is_active' => true, 'created_by' => $request->user()->id]);
return back()->with('status', 'IP rule created.');
}
public function destroyRule(IpRule $rule): RedirectResponse
{
$rule->delete();
return back()->with('status', 'IP rule deleted.');
}
public function revokeDevice(TrustedDevice $device): RedirectResponse
{
$device->update(['revoked_at' => now()]);
return back()->with('status', 'Device trust revoked.');
}
public function destroyRule(IpRule $rule): RedirectResponse { $rule->delete(); return back()->with('status','IP rule deleted.'); }
public function revokeDevice(TrustedDevice $device): RedirectResponse { $device->update(['revoked_at'=>now()]); return back()->with('status','Device trust revoked.'); }
}
@@ -1,15 +1,20 @@
<?php
namespace Modules\Security\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Modules\Security\Services\IpRuleService;
use Symfony\Component\HttpFoundation\Response;
final class EnforceIpRestrictions
{
public function __construct(private readonly IpRuleService $rules) {}
public function handle(Request $request, Closure $next, string $scope='admin'): Response
public function handle(Request $request, Closure $next, string $scope = 'admin'): Response
{
abort_if($this->rules->isBlocked((string)$request->ip(),$scope),403,'Access is not permitted from this IP address.');
abort_if($this->rules->isBlocked((string) $request->ip(), $scope), 403, 'Access is not permitted from this IP address.');
return $next($request);
}
}
@@ -1,13 +1,19 @@
<?php
namespace Modules\Security\Listeners;
use Illuminate\Auth\Events\Failed;
use Modules\Security\Models\LoginEvent;
final class RecordFailedLogin
{
public function handle(Failed $event): void
{
if (!config('security.record_authentication_events',true)) return;
$request=request(); $user=property_exists($event,'user')?$event->user:null;
LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'failed','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
if (! config('security.record_authentication_events', true)) {
return;
}
$request = request();
$user = property_exists($event, 'user') ? $event->user : null;
LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'failed', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
}
}
+9 -3
View File
@@ -1,13 +1,19 @@
<?php
namespace Modules\Security\Listeners;
use Illuminate\Auth\Events\Login;
use Modules\Security\Models\LoginEvent;
final class RecordLogin
{
public function handle(Login $event): void
{
if (!config('security.record_authentication_events',true)) return;
$request=request(); $user=property_exists($event,'user')?$event->user:null;
LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'login','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
if (! config('security.record_authentication_events', true)) {
return;
}
$request = request();
$user = property_exists($event, 'user') ? $event->user : null;
LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'login', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
}
}
+9 -3
View File
@@ -1,13 +1,19 @@
<?php
namespace Modules\Security\Listeners;
use Illuminate\Auth\Events\Logout;
use Modules\Security\Models\LoginEvent;
final class RecordLogout
{
public function handle(Logout $event): void
{
if (!config('security.record_authentication_events',true)) return;
$request=request(); $user=property_exists($event,'user')?$event->user:null;
LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'logout','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
if (! config('security.record_authentication_events', true)) {
return;
}
$request = request();
$user = property_exists($event, 'user') ? $event->user : null;
LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'logout', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
}
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?php
namespace Modules\Security\Models;
use Illuminate\Database\Eloquent\Model;
final class IpRule extends Model
{
protected $table='security_ip_rules';
protected $fillable=['name','type','address','description','applies_to_admin','applies_to_login','is_active','created_by'];
protected $casts=['applies_to_admin'=>'boolean','applies_to_login'=>'boolean','is_active'=>'boolean'];
protected $table = 'security_ip_rules';
protected $fillable = ['name', 'type', 'address', 'description', 'applies_to_admin', 'applies_to_login', 'is_active', 'created_by'];
protected $casts = ['applies_to_admin' => 'boolean', 'applies_to_login' => 'boolean', 'is_active' => 'boolean'];
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?php
namespace Modules\Security\Models;
use Illuminate\Database\Eloquent\Model;
final class LoginEvent extends Model
{
protected $table='security_login_events';
protected $fillable=['user_id','email','event','ip_address','user_agent','session_id','reason','metadata','occurred_at'];
protected $casts=['metadata'=>'array','occurred_at'=>'datetime'];
protected $table = 'security_login_events';
protected $fillable = ['user_id', 'email', 'event', 'ip_address', 'user_agent', 'session_id', 'reason', 'metadata', 'occurred_at'];
protected $casts = ['metadata' => 'array', 'occurred_at' => 'datetime'];
}
+6 -2
View File
@@ -1,8 +1,12 @@
<?php
namespace Modules\Security\Models;
use Illuminate\Database\Eloquent\Model;
final class SecurityPolicy extends Model
{
protected $fillable=['name','minimum_password_length','require_mixed_case','require_numbers','require_symbols','require_mfa','password_expiry_days','password_history','max_failed_logins','lockout_minutes','session_idle_minutes','single_session','is_active'];
protected $casts=['require_mixed_case'=>'boolean','require_numbers'=>'boolean','require_symbols'=>'boolean','require_mfa'=>'boolean','single_session'=>'boolean','is_active'=>'boolean'];
protected $fillable = ['name', 'minimum_password_length', 'require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'password_expiry_days', 'password_history', 'max_failed_logins', 'lockout_minutes', 'session_idle_minutes', 'single_session', 'is_active'];
protected $casts = ['require_mixed_case' => 'boolean', 'require_numbers' => 'boolean', 'require_symbols' => 'boolean', 'require_mfa' => 'boolean', 'single_session' => 'boolean', 'is_active' => 'boolean'];
}
+8 -3
View File
@@ -1,9 +1,14 @@
<?php
namespace Modules\Security\Models;
use Illuminate\Database\Eloquent\Model;
final class TrustedDevice extends Model
{
protected $table='security_devices';
protected $fillable=['user_id','name','fingerprint','ip_address','user_agent','trusted_until','last_seen_at','revoked_at'];
protected $casts=['trusted_until'=>'datetime','last_seen_at'=>'datetime','revoked_at'=>'datetime'];
protected $table = 'security_devices';
protected $fillable = ['user_id', 'name', 'fingerprint', 'ip_address', 'user_agent', 'trusted_until', 'last_seen_at', 'revoked_at'];
protected $casts = ['trusted_until' => 'datetime', 'last_seen_at' => 'datetime', 'revoked_at' => 'datetime'];
}
@@ -1,7 +1,10 @@
<?php
namespace Modules\Security\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
@@ -1,5 +1,7 @@
<?php
namespace Modules\Security\Providers;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
@@ -10,6 +12,7 @@ use Modules\Security\Http\Middleware\EnforceIpRestrictions;
use Modules\Security\Listeners\RecordFailedLogin;
use Modules\Security\Listeners\RecordLogin;
use Modules\Security\Listeners\RecordLogout;
final class SecurityServiceProvider extends ServiceProvider
{
public function register(): void
@@ -17,6 +20,7 @@ final class SecurityServiceProvider extends ServiceProvider
$this->app->register(RouteServiceProvider::class);
$this->mergeConfigFrom(module_path('Security', 'Config/config.php'), 'security');
}
public function boot(Router $router): void
{
$this->loadMigrationsFrom(module_path('Security', 'Database/Migrations'));
+8 -6
View File
@@ -1,10 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Security\Http\Controllers\SecurityController;
Route::prefix('administration/security')->name('administration.security.')->middleware(['auth','permission:security.view','security.ip:admin'])->group(function(){
Route::get('/',[SecurityController::class,'index'])->name('index');
Route::put('/policy',[SecurityController::class,'savePolicy'])->middleware('permission:security.manage')->name('policy.update');
Route::post('/ip-rules',[SecurityController::class,'storeRule'])->middleware('permission:security.manage')->name('rules.store');
Route::delete('/ip-rules/{rule}',[SecurityController::class,'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy');
Route::delete('/devices/{device}',[SecurityController::class,'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke');
Route::prefix('administration/security')->name('administration.security.')->middleware(['auth', 'permission:security.view', 'security.ip:admin'])->group(function () {
Route::get('/', [SecurityController::class, 'index'])->name('index');
Route::put('/policy', [SecurityController::class, 'savePolicy'])->middleware('permission:security.manage')->name('policy.update');
Route::post('/ip-rules', [SecurityController::class, 'storeRule'])->middleware('permission:security.manage')->name('rules.store');
Route::delete('/ip-rules/{rule}', [SecurityController::class, 'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy');
Route::delete('/devices/{device}', [SecurityController::class, 'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke');
});
+29 -14
View File
@@ -1,24 +1,39 @@
<?php
namespace Modules\Security\Services;
use Modules\Security\Models\IpRule;
final class IpRuleService
{
public function isBlocked(string $ip, string $scope='admin'): bool
public function isBlocked(string $ip, string $scope = 'admin'): bool
{
$column=$scope==='login'?'applies_to_login':'applies_to_admin';
$rules=IpRule::query()->where('is_active',true)->where($column,true)->get();
$allow=$rules->where('type','allow');
if ($allow->isNotEmpty() && !$allow->contains(fn(IpRule $rule)=>$this->matches($ip,$rule->address))) return true;
return $rules->where('type','deny')->contains(fn(IpRule $rule)=>$this->matches($ip,$rule->address));
}
private function matches(string $ip,string $rule): bool
{
if ($ip===$rule) return true;
if (!str_contains($rule,'/')) return false;
[$subnet,$bits]=explode('/',$rule,2); $bits=(int)$bits;
if (filter_var($ip,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4) && filter_var($subnet,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)) {
$mask=-1 << (32-$bits); return (ip2long($ip)&$mask)===(ip2long($subnet)&$mask);
$column = $scope === 'login' ? 'applies_to_login' : 'applies_to_admin';
$rules = IpRule::query()->where('is_active', true)->where($column, true)->get();
$allow = $rules->where('type', 'allow');
if ($allow->isNotEmpty() && ! $allow->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address))) {
return true;
}
return $rules->where('type', 'deny')->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address));
}
private function matches(string $ip, string $rule): bool
{
if ($ip === $rule) {
return true;
}
if (! str_contains($rule, '/')) {
return false;
}
[$subnet,$bits] = explode('/', $rule, 2);
$bits = (int) $bits;
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$mask = -1 << (32 - $bits);
return (ip2long($ip) & $mask) === (ip2long($subnet) & $mask);
}
return false;
}
}
@@ -1,8 +1,18 @@
<?php
namespace Modules\Security\Tests\Feature;
use Tests\TestCase;
final class ModuleBootTest extends TestCase
{
public function test_security_route_is_registered(): void { $this->assertTrue(app('router')->has('administration.security.index')); }
public function test_security_middleware_alias_is_registered(): void { $this->assertArrayHasKey('security.ip',app('router')->getMiddleware()); }
public function test_security_route_is_registered(): void
{
$this->assertTrue(app('router')->has('administration.security.index'));
}
public function test_security_middleware_alias_is_registered(): void
{
$this->assertArrayHasKey('security.ip', app('router')->getMiddleware());
}
}