move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+9
View File
@@ -0,0 +1,9 @@
<?php
return [
'record_authentication_events' => (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),
];
@@ -0,0 +1,76 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
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->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');
}
};
@@ -0,0 +1,52 @@
<?php
namespace Modules\Security\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
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()]);
}
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.');
}
}
@@ -0,0 +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
{
abort_if($this->rules->isBlocked((string) $request->ip(), $scope), 403, 'Access is not permitted from this IP address.');
return $next($request);
}
}
+19
View File
@@ -0,0 +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()]);
}
}
+19
View File
@@ -0,0 +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()]);
}
}
+19
View File
@@ -0,0 +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()]);
}
}
+14
View File
@@ -0,0 +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'];
}
+14
View File
@@ -0,0 +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'];
}
+12
View File
@@ -0,0 +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'];
}
+14
View File
@@ -0,0 +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'];
}
@@ -0,0 +1,14 @@
<?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
{
Route::middleware('web')->group(module_path('Security', 'Routes/web.php'));
}
}
@@ -0,0 +1,33 @@
<?php
namespace Modules\Security\Providers;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Logout;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
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
{
$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'));
$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);
}
}
@@ -0,0 +1,5 @@
<x-layouts.app :title="__('Security Centre')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">Security Centre</h1><p class="text-sm text-zinc-500">Identity policy, authentication history, trusted devices and network access controls.</p></div>@if(session('status'))<div class="rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif
<div class="grid gap-4 md:grid-cols-4"><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Failed logins (24h)</div><div class="text-2xl font-semibold">{{ $failed24h }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Active trusted devices</div><div class="text-2xl font-semibold">{{ $devices->count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">IP rules</div><div class="text-2xl font-semibold">{{ $rules->count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">MFA required</div><div class="text-2xl font-semibold">{{ $policy?->require_mfa ? 'Yes' : 'No' }}</div></div></div>
<div class="grid gap-6 xl:grid-cols-2"><form method="POST" action="{{ route('administration.security.policy.update') }}" class="space-y-4 rounded-xl border p-5">@csrf @method('PUT')<h2 class="font-semibold">Security policy</h2><div class="grid gap-4 md:grid-cols-2"><flux:input name="minimum_password_length" type="number" label="Minimum password length" value="{{ old('minimum_password_length',$policy?->minimum_password_length ?? 12) }}"/><flux:input name="password_expiry_days" type="number" label="Password expiry days" value="{{ old('password_expiry_days',$policy?->password_expiry_days) }}"/><flux:input name="password_history" type="number" label="Password history" value="{{ old('password_history',$policy?->password_history ?? 5) }}"/><flux:input name="max_failed_logins" type="number" label="Maximum failed logins" value="{{ old('max_failed_logins',$policy?->max_failed_logins ?? 10) }}"/><flux:input name="lockout_minutes" type="number" label="Lockout minutes" value="{{ old('lockout_minutes',$policy?->lockout_minutes ?? 15) }}"/><flux:input name="session_idle_minutes" type="number" label="Session idle minutes" value="{{ old('session_idle_minutes',$policy?->session_idle_minutes ?? 120) }}"/></div><div class="grid gap-3 md:grid-cols-2"><flux:checkbox name="require_mixed_case" label="Require mixed case" :checked="$policy?->require_mixed_case ?? true"/><flux:checkbox name="require_numbers" label="Require numbers" :checked="$policy?->require_numbers ?? true"/><flux:checkbox name="require_symbols" label="Require symbols" :checked="$policy?->require_symbols ?? true"/><flux:checkbox name="require_mfa" label="Require multi-factor authentication" :checked="$policy?->require_mfa ?? false"/><flux:checkbox name="single_session" label="Allow one session per user" :checked="$policy?->single_session ?? false"/></div><flux:button type="submit" variant="primary">Save policy</flux:button></form>
<form method="POST" action="{{ route('administration.security.rules.store') }}" class="space-y-4 rounded-xl border p-5">@csrf<h2 class="font-semibold">Add IP rule</h2><flux:input name="name" label="Rule name" required/><div class="grid gap-4 md:grid-cols-2"><flux:select name="type" label="Action"><option value="deny">Deny</option><option value="allow">Allow</option></flux:select><flux:input name="address" label="IP address or IPv4 CIDR" placeholder="192.0.2.10 or 10.0.0.0/8" required/></div><flux:textarea name="description" label="Description"/><div class="flex gap-5"><flux:checkbox name="applies_to_admin" label="Administration" checked/><flux:checkbox name="applies_to_login" label="Login" checked/></div><flux:button type="submit" variant="primary">Create rule</flux:button><div class="space-y-2">@foreach($rules as $rule)<div class="flex items-center justify-between rounded border p-3"><div><span class="font-medium">{{ $rule->name }}</span><span class="ml-2 text-xs uppercase text-zinc-500">{{ $rule->type }}</span><div class="text-sm text-zinc-500">{{ $rule->address }}</div></div><form method="POST" action="{{ route('administration.security.rules.destroy',$rule) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></div>@endforeach</div></form></div>
<div class="rounded-xl border"><div class="border-b p-4 font-semibold">Authentication history</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">When</th><th class="p-3 text-left">Event</th><th class="p-3 text-left">User</th><th class="p-3 text-left">IP address</th><th class="p-3 text-left">Client</th></tr></thead><tbody>@forelse($events as $event)<tr class="border-t"><td class="p-3">{{ $event->occurred_at?->format('d M Y H:i:s') }}</td><td class="p-3 capitalize">{{ $event->event }}</td><td class="p-3">{{ $event->email ?: 'Unknown' }}</td><td class="p-3">{{ $event->ip_address ?: '—' }}</td><td class="max-w-md truncate p-3 text-zinc-500">{{ $event->user_agent ?: '—' }}</td></tr>@empty<tr><td colspan="5" class="p-6 text-center text-zinc-500">No authentication events recorded.</td></tr>@endforelse</tbody></table></div><div class="p-4">{{ $events->links() }}</div></div></div></x-layouts.app>
+12
View File
@@ -0,0 +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');
});
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Modules\Security\Services;
use Modules\Security\Models\IpRule;
final class IpRuleService
{
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);
}
return false;
}
}
+18
View File
@@ -0,0 +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());
}
}
+1
View File
@@ -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"]}