diff --git a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php index c7da01d..d4700d2 100644 --- a/Modules/Roles/Database/Seeders/RolePermissionSeeder.php +++ b/Modules/Roles/Database/Seeders/RolePermissionSeeder.php @@ -67,6 +67,9 @@ final class RolePermissionSeeder extends Seeder 'commerce.manage', 'commerce.orders.manage', 'commerce.products.manage', + 'security.view', + 'security.manage', + 'security.audit', ]; foreach ($permissions as $permission) { diff --git a/Modules/Security/Config/config.php b/Modules/Security/Config/config.php new file mode 100644 index 0000000..55cce9d --- /dev/null +++ b/Modules/Security/Config/config.php @@ -0,0 +1,8 @@ + (bool) env('SECURITY_RECORD_AUTH_EVENTS', true), + 'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30), + 'session_idle_minutes' => (int) env('SECURITY_SESSION_IDLE_MINUTES', 120), + 'max_failed_logins' => (int) env('SECURITY_MAX_FAILED_LOGINS', 10), + 'lockout_minutes' => (int) env('SECURITY_LOCKOUT_MINUTES', 15), +]; diff --git a/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php b/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php new file mode 100644 index 0000000..9b8dd41 --- /dev/null +++ b/Modules/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php @@ -0,0 +1,45 @@ +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(); + }); + 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(); + }); + 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']); + }); + 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']); + }); + } + public function down(): void + { + Schema::dropIfExists('security_ip_rules'); Schema::dropIfExists('security_devices'); + Schema::dropIfExists('security_login_events'); Schema::dropIfExists('security_policies'); + } +}; diff --git a/Modules/Security/Http/Controllers/SecurityController.php b/Modules/Security/Http/Controllers/SecurityController.php new file mode 100644 index 0000000..6277cbc --- /dev/null +++ b/Modules/Security/Http/Controllers/SecurityController.php @@ -0,0 +1,31 @@ +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.'); + } + 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.'); + } + 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.'); } +} diff --git a/Modules/Security/Http/Middleware/EnforceIpRestrictions.php b/Modules/Security/Http/Middleware/EnforceIpRestrictions.php new file mode 100644 index 0000000..d6129f1 --- /dev/null +++ b/Modules/Security/Http/Middleware/EnforceIpRestrictions.php @@ -0,0 +1,15 @@ +rules->isBlocked((string)$request->ip(),$scope),403,'Access is not permitted from this IP address.'); + return $next($request); + } +} diff --git a/Modules/Security/Listeners/RecordFailedLogin.php b/Modules/Security/Listeners/RecordFailedLogin.php new file mode 100644 index 0000000..d93b948 --- /dev/null +++ b/Modules/Security/Listeners/RecordFailedLogin.php @@ -0,0 +1,13 @@ +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()]); + } +} diff --git a/Modules/Security/Listeners/RecordLogin.php b/Modules/Security/Listeners/RecordLogin.php new file mode 100644 index 0000000..566499b --- /dev/null +++ b/Modules/Security/Listeners/RecordLogin.php @@ -0,0 +1,13 @@ +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()]); + } +} diff --git a/Modules/Security/Listeners/RecordLogout.php b/Modules/Security/Listeners/RecordLogout.php new file mode 100644 index 0000000..d5ed8ab --- /dev/null +++ b/Modules/Security/Listeners/RecordLogout.php @@ -0,0 +1,13 @@ +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()]); + } +} diff --git a/Modules/Security/Models/IpRule.php b/Modules/Security/Models/IpRule.php new file mode 100644 index 0000000..0136f27 --- /dev/null +++ b/Modules/Security/Models/IpRule.php @@ -0,0 +1,9 @@ +'boolean','applies_to_login'=>'boolean','is_active'=>'boolean']; +} diff --git a/Modules/Security/Models/LoginEvent.php b/Modules/Security/Models/LoginEvent.php new file mode 100644 index 0000000..616ddce --- /dev/null +++ b/Modules/Security/Models/LoginEvent.php @@ -0,0 +1,9 @@ +'array','occurred_at'=>'datetime']; +} diff --git a/Modules/Security/Models/SecurityPolicy.php b/Modules/Security/Models/SecurityPolicy.php new file mode 100644 index 0000000..5294683 --- /dev/null +++ b/Modules/Security/Models/SecurityPolicy.php @@ -0,0 +1,8 @@ +'boolean','require_numbers'=>'boolean','require_symbols'=>'boolean','require_mfa'=>'boolean','single_session'=>'boolean','is_active'=>'boolean']; +} diff --git a/Modules/Security/Models/TrustedDevice.php b/Modules/Security/Models/TrustedDevice.php new file mode 100644 index 0000000..5a4dad3 --- /dev/null +++ b/Modules/Security/Models/TrustedDevice.php @@ -0,0 +1,9 @@ +'datetime','last_seen_at'=>'datetime','revoked_at'=>'datetime']; +} diff --git a/Modules/Security/Providers/RouteServiceProvider.php b/Modules/Security/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..baca617 --- /dev/null +++ b/Modules/Security/Providers/RouteServiceProvider.php @@ -0,0 +1,11 @@ +group(module_path('Security', 'Routes/web.php')); + } +} diff --git a/Modules/Security/Providers/SecurityServiceProvider.php b/Modules/Security/Providers/SecurityServiceProvider.php new file mode 100644 index 0000000..cceea52 --- /dev/null +++ b/Modules/Security/Providers/SecurityServiceProvider.php @@ -0,0 +1,29 @@ +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')); + $this->loadViewsFrom(module_path('Security', 'Resources/views'), 'security'); + $router->aliasMiddleware('security.ip', EnforceIpRestrictions::class); + Event::listen(Login::class, RecordLogin::class); + Event::listen(Failed::class, RecordFailedLogin::class); + Event::listen(Logout::class, RecordLogout::class); + } +} diff --git a/Modules/Security/Resources/views/admin/index.blade.php b/Modules/Security/Resources/views/admin/index.blade.php new file mode 100644 index 0000000..b07de13 --- /dev/null +++ b/Modules/Security/Resources/views/admin/index.blade.php @@ -0,0 +1,5 @@ +

Security Centre

Identity policy, authentication history, trusted devices and network access controls.

@if(session('status'))
{{ session('status') }}
@endif +
Failed logins (24h)
{{ $failed24h }}
Active trusted devices
{{ $devices->count() }}
IP rules
{{ $rules->count() }}
MFA required
{{ $policy?->require_mfa ? 'Yes' : 'No' }}
+
@csrf @method('PUT')

Security policy

Save policy
+
@csrf

Add IP rule

Create rule
@foreach($rules as $rule)
{{ $rule->name }}{{ $rule->type }}
{{ $rule->address }}
@csrf @method('DELETE')Delete
@endforeach
+
Authentication history
@forelse($events as $event)@empty@endforelse
WhenEventUserIP addressClient
{{ $event->occurred_at?->format('d M Y H:i:s') }}{{ $event->event }}{{ $event->email ?: 'Unknown' }}{{ $event->ip_address ?: '—' }}{{ $event->user_agent ?: '—' }}
No authentication events recorded.
{{ $events->links() }}
diff --git a/Modules/Security/Routes/web.php b/Modules/Security/Routes/web.php new file mode 100644 index 0000000..ce84bd1 --- /dev/null +++ b/Modules/Security/Routes/web.php @@ -0,0 +1,10 @@ +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'); +}); diff --git a/Modules/Security/Services/IpRuleService.php b/Modules/Security/Services/IpRuleService.php new file mode 100644 index 0000000..ca2ea30 --- /dev/null +++ b/Modules/Security/Services/IpRuleService.php @@ -0,0 +1,24 @@ +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; + } +} diff --git a/Modules/Security/Tests/Feature/ModuleBootTest.php b/Modules/Security/Tests/Feature/ModuleBootTest.php new file mode 100644 index 0000000..dbea313 --- /dev/null +++ b/Modules/Security/Tests/Feature/ModuleBootTest.php @@ -0,0 +1,8 @@ +assertTrue(app('router')->has('administration.security.index')); } + public function test_security_middleware_alias_is_registered(): void { $this->assertArrayHasKey('security.ip',app('router')->getMiddleware()); } +} diff --git a/Modules/Security/module.json b/Modules/Security/module.json new file mode 100644 index 0000000..515918b --- /dev/null +++ b/Modules/Security/module.json @@ -0,0 +1 @@ +{"name":"Security","alias":"security","version":"1.0.0","description":"Identity policies, login history, trusted devices, IP restrictions and session security.","keywords":["identity","security","sessions","devices","authentication"],"priority":120,"providers":["Modules\\Security\\Providers\\SecurityServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit"]} diff --git a/modules_statuses.json b/modules_statuses.json index 0dafcf1..bbc0218 100644 --- a/modules_statuses.json +++ b/modules_statuses.json @@ -36,7 +36,7 @@ "Sites": true, "Developer": true, "Api": true, - "Enterprise": true -, - "Commerce": true + "Enterprise": true, + "Commerce": true, + "Security": true } diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php index c0a5e42..86a2a2f 100644 --- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php +++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php @@ -5,6 +5,9 @@ + +{{ __('Security Centre') }} + {{ __('Commerce') }} {{ __('CRM') }}