feat(auth): harden authentication and add configurable two-factor support

This commit is contained in:
2026-07-31 03:12:46 +02:00
parent 235d5f646c
commit e47243f7dc
51 changed files with 2904 additions and 1359 deletions

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AddSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set(
'Permissions-Policy',
'camera=(), geolocation=(), microphone=(), payment=(), usb=()'
);
if (app()->isProduction()) {
$response->headers->set(
'Content-Security-Policy',
"default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'"
);
if ($request->isSecure()) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
}
}
if ($this->containsAuthenticationSecrets($request)) {
$response->headers->set(
'Cache-Control',
'no-store, no-cache, must-revalidate, private'
);
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', '0');
}
return $response;
}
private function containsAuthenticationSecrets(Request $request): bool
{
return $request->routeIs([
'login',
'password.*',
'two-factor.*',
'profile.show',
]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnforceAbsoluteSessionTimeout
{
public function __construct(
private readonly SecurityEventRecorder $securityEvents
) {}
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->hasSession()) {
return $next($request);
}
/** @var User $user */
$user = $request->user();
$sessionVersion = $request->session()->get('auth.session_version');
$userVersion = (int) $user->auth_session_version;
if (! is_int($sessionVersion) && $userVersion === 0) {
$request->session()->put('auth.session_version', 0);
} elseif ($sessionVersion !== $userVersion) {
return $this->terminateSession(
$request,
$user,
'session.revoked',
'Your session is no longer valid. Please sign in again.'
);
}
$expiresAt = $request->session()->get('auth.absolute_expires_at');
if (! is_int($expiresAt)) {
$request->session()->put(
'auth.absolute_expires_at',
now()->timestamp + config('auth-ui.security.absolute_session_lifetime')
);
return $next($request);
}
if (now()->timestamp < $expiresAt) {
return $next($request);
}
return $this->terminateSession(
$request,
$user,
'session.absolute_timeout',
'Your session has expired. Please sign in again.'
);
}
private function terminateSession(
Request $request,
User $user,
string $event,
string $message
): Response {
$this->securityEvents->record($event, $user, $request);
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
if ($request->expectsJson()) {
return response()->json(['message' => $message], 401);
}
return redirect()->route('login')
->with('error', $message);
}
}

View File

@@ -24,18 +24,22 @@ class EnsureTwoFactorAuthenticationIsEnabled
if ($request->expectsJson()) {
return response()->json([
'message' => 'Two-factor authentication setup is required.',
'setup_url' => route('profile.show'),
'setup_url' => route('two-factor.setup'),
], 403);
}
return redirect()->route('profile.show')
if ($request->isMethod('GET')) {
$request->session()->put('url.intended', $request->fullUrl());
}
return redirect()->route('two-factor.setup')
->with('error', 'Two-factor authentication is required before you can continue.');
}
private function isEnrollmentRoute(Request $request): bool
{
return $request->routeIs([
'profile.*',
'two-factor.setup*',
'logout',
'verification.*',
]);