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

@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\CompleteProfileRequest;
use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -33,8 +35,11 @@ class CompleteProfileController extends Controller
/**
* Handle the complete profile request.
*/
public function store(CompleteProfileRequest $request): RedirectResponse
{
public function store(
CompleteProfileRequest $request,
TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$socialiteUser = session('socialite_user');
$validated = $request->validated();
@@ -58,6 +63,16 @@ class CompleteProfileController extends Controller
Auth::login($user, remember: true);
$request->session()->regenerate();
$securityEvents->record('login.social_succeeded', $user, $request);
if ($twoFactor->enrollmentRequiredFor($user)) {
$request->session()->put(
'url.intended',
config('auth-ui.redirects.login', '/')
);
return redirect()->route('two-factor.setup');
}
return redirect()->intended(config('auth-ui.redirects.login', '/'));
}

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Password;
use Inertia\Inertia;
@@ -26,16 +27,20 @@ class ForgotPasswordController extends Controller
/**
* Handle an incoming password reset link request.
*/
public function store(ForgotPasswordRequest $request): RedirectResponse
{
$status = Password::sendResetLink(
public function store(
ForgotPasswordRequest $request,
SecurityEventRecorder $securityEvents
): RedirectResponse {
Password::sendResetLink(
$request->validated()
);
$securityEvents->record('password_reset.requested', null, $request, [
'email_hash' => hash('sha256', strtolower($request->validated('email'))),
]);
if ($status === Password::RESET_LINK_SENT) {
return back()->with('status', __($status));
}
return back()->withErrors(['email' => __($status)]);
return back()->with(
'status',
'If an account exists for that email address, a password reset link has been sent.'
);
}
}

View File

@@ -5,13 +5,17 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use RuntimeException;
class LoginController extends Controller
{
@@ -26,8 +30,11 @@ class LoginController extends Controller
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request, TwoFactorChallenge $twoFactor): RedirectResponse
{
public function store(
LoginRequest $request,
TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$login = $request->validated('login');
$password = $request->validated('password');
@@ -46,7 +53,19 @@ class LoginController extends Controller
? $provider->retrieveByCredentials($credentials)
: null;
if (! $user || ! $provider->validateCredentials($user, $credentials)) {
try {
$credentialsAreValid = $user
? $provider->validateCredentials($user, $credentials)
: Hash::check($password, $this->dummyPasswordHash());
} catch (RuntimeException) {
$credentialsAreValid = false;
}
if (! $user || ! $credentialsAreValid) {
$securityEvents->record('login.password_failed', $user, $request, [
'login_hash' => hash('sha256', strtolower($login)),
]);
throw ValidationException::withMessages([
'login' => __('auth.failed'),
]);
@@ -58,11 +77,23 @@ class LoginController extends Controller
}
if ($twoFactor->requiredFor($user)) {
$securityEvents->record('login.password_succeeded_pending_two_factor', $user, $request);
return $twoFactor->begin($request, $user, $remember);
}
Auth::login($user, $remember);
$request->session()->regenerate();
$securityEvents->record('login.succeeded', $user, $request);
if ($twoFactor->enrollmentRequiredFor($user)) {
$request->session()->put(
'url.intended',
config('auth-ui.redirects.login', '/')
);
return redirect()->route('two-factor.setup');
}
return redirect()->intended(config('auth-ui.redirects.login', '/'));
}
@@ -72,6 +103,10 @@ class LoginController extends Controller
*/
public function destroy(Request $request): RedirectResponse
{
/** @var User|null $user */
$user = $request->user();
app(SecurityEventRecorder::class)->record('logout.succeeded', $user, $request);
Auth::logout();
$request->session()->invalidate();
@@ -79,4 +114,11 @@ class LoginController extends Controller
return redirect(config('auth-ui.redirects.logout', '/'));
}
private function dummyPasswordHash(): string
{
static $hash;
return $hash ??= Hash::make(Str::random(64));
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\RegisterRequest;
use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
@@ -28,8 +30,11 @@ class RegisterController extends Controller
/**
* Handle an incoming registration request.
*/
public function store(RegisterRequest $request): RedirectResponse
{
public function store(
RegisterRequest $request,
TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$validated = $request->validated();
$user = User::create([
@@ -43,11 +48,22 @@ class RegisterController extends Controller
event(new Registered($user));
Auth::login($user);
$request->session()->regenerate();
$securityEvents->record('registration.completed', $user, $request);
if (config('auth-ui.features.email_verification')) {
return redirect()->route('verification.notice');
}
if ($twoFactor->enrollmentRequiredFor($user)) {
$request->session()->put(
'url.intended',
config('auth-ui.redirects.register', '/')
);
return redirect()->route('two-factor.setup');
}
return redirect(config('auth-ui.redirects.register', '/'));
}
}

View File

@@ -4,9 +4,13 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Models\User;
use App\Notifications\PasswordChangedNotification;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Inertia\Inertia;
@@ -32,21 +36,39 @@ class ResetPasswordController extends Controller
/**
* Handle an incoming new password request.
*/
public function store(ResetPasswordRequest $request): RedirectResponse
{
public function store(
ResetPasswordRequest $request,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$resetUser = null;
$status = Password::reset(
$request->validated(),
function ($user, string $password): void {
function (User $user, string $password) use ($request, &$resetUser): void {
$user->forceFill([
'password' => $password,
'remember_token' => Str::random(60),
'auth_session_version' => $user->auth_session_version + 1,
])->save();
if (config('session.driver') === 'database') {
DB::table(config('session.table'))
->where('user_id', $user->getKey())
->delete();
}
$user->notify(new PasswordChangedNotification(
(string) $request->ip(),
now()->toIso8601String()
));
$resetUser = $user;
event(new PasswordReset($user));
}
);
if ($status === Password::PASSWORD_RESET) {
$securityEvents->record('password_reset.completed', $resetUser, $request);
return redirect()->route('login')->with('status', __($status));
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -54,7 +55,8 @@ class SocialiteController extends Controller
public function callback(
Request $request,
string $provider,
TwoFactorChallenge $twoFactor
TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse {
if (! in_array($provider, $this->getEnabledProviders())) {
abort(404, 'Provider not enabled');
@@ -74,7 +76,12 @@ class SocialiteController extends Controller
->first();
if ($socialAccount) {
return $this->finishAuthentication($request, $socialAccount->user, $twoFactor);
return $this->finishAuthentication(
$request,
$socialAccount->user,
$twoFactor,
$securityEvents
);
}
// Check if a user with this email already exists
@@ -92,7 +99,12 @@ class SocialiteController extends Controller
'provider_id' => $socialUser->getId(),
]);
return $this->finishAuthentication($request, $existingUser, $twoFactor);
return $this->finishAuthentication(
$request,
$existingUser,
$twoFactor,
$securityEvents
);
}
// New user — check if registration is enabled
@@ -134,20 +146,33 @@ class SocialiteController extends Controller
'provider_id' => $socialUser->getId(),
]);
return $this->finishAuthentication($request, $user, $twoFactor);
return $this->finishAuthentication($request, $user, $twoFactor, $securityEvents);
}
private function finishAuthentication(
Request $request,
User $user,
TwoFactorChallenge $twoFactor
TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse {
if ($twoFactor->requiredFor($user)) {
$securityEvents->record('login.social_succeeded_pending_two_factor', $user, $request);
return $twoFactor->begin($request, $user, remember: true);
}
Auth::login($user, remember: true);
$request->session()->regenerate();
$securityEvents->record('login.social_succeeded', $user, $request);
if ($twoFactor->enrollmentRequiredFor($user)) {
$request->session()->put(
'url.intended',
config('auth-ui.redirects.login', '/')
);
return redirect()->route('two-factor.setup');
}
return redirect()->intended(config('auth-ui.redirects.login', '/'));
}

View File

@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Notifications\TwoFactorFailedNotification;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
@@ -19,7 +21,9 @@ class TwoFactorChallengeController extends Controller
*/
public function create(TwoFactorLoginRequest $request): Response|RedirectResponse
{
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) {
if (! config('auth-ui.features.two_factor')
|| ! $request->hasChallengedUser()
|| $this->challengeHasExpired($request)) {
$this->clearChallenge($request);
return redirect()->route('login');
@@ -31,9 +35,13 @@ class TwoFactorChallengeController extends Controller
/**
* Complete the pending login using a TOTP or recovery code.
*/
public function store(TwoFactorLoginRequest $request): RedirectResponse
{
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) {
public function store(
TwoFactorLoginRequest $request,
SecurityEventRecorder $securityEvents
): RedirectResponse {
if (! config('auth-ui.features.two_factor')
|| ! $request->hasChallengedUser()
|| $this->challengeHasExpired($request)) {
$this->clearChallenge($request);
return redirect()->route('login');
@@ -51,6 +59,12 @@ class TwoFactorChallengeController extends Controller
$user->replaceRecoveryCode($recoveryCode);
} elseif (! $request->hasValidCode()) {
event(new TwoFactorAuthenticationFailed($user));
$user->notify(new TwoFactorFailedNotification(
(string) $request->ip(),
(string) $request->userAgent(),
now()->toIso8601String()
));
$securityEvents->record('login.two_factor_failed', $user, $request);
throw ValidationException::withMessages([
$request->filled('recovery_code') ? 'recovery_code' : 'code' => 'The provided authentication code was invalid.',
@@ -61,12 +75,24 @@ class TwoFactorChallengeController extends Controller
Auth::login($user, $request->remember());
$request->session()->regenerate();
$securityEvents->record('login.two_factor_succeeded', $user, $request, [
'used_recovery_code' => (bool) $recoveryCode,
]);
return redirect()->intended(config('auth-ui.redirects.login', '/dashboard'));
}
private function clearChallenge(TwoFactorLoginRequest $request): void
{
$request->session()->forget(['login.id', 'login.remember']);
$request->session()->forget(['login.id', 'login.remember', 'login.started_at']);
}
private function challengeHasExpired(TwoFactorLoginRequest $request): bool
{
$startedAt = $request->session()->get('login.started_at');
return ! is_int($startedAt)
|| now()->timestamp - $startedAt
> config('auth-ui.security.two_factor_challenge_timeout');
}
}

View File

@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Notifications\TwoFactorSecurityNotification;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -21,19 +23,19 @@ class TwoFactorSettingsController extends Controller
*/
public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse
{
$this->ensureFeatureIsEnabled();
$this->ensureFeatureIsEnabled($request);
/** @var User $user */
$user = $request->user();
if ($user->hasEnabledTwoFactorAuthentication()) {
return redirect()->route('profile.show');
return $this->enrollmentRedirect($request);
}
$this->validatePasswordForPasswordUser($request, $user);
$enable($user, force: true);
return redirect()->route('profile.show')
return $this->enrollmentRedirect($request)
->with('success', 'Scan the QR code, then enter a code to finish enabling two-factor authentication.');
}
@@ -42,9 +44,10 @@ class TwoFactorSettingsController extends Controller
*/
public function confirm(
Request $request,
ConfirmTwoFactorAuthentication $confirm
ConfirmTwoFactorAuthentication $confirm,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$this->ensureFeatureIsEnabled();
$this->ensureFeatureIsEnabled($request);
$validated = $request->validate([
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
@@ -53,8 +56,18 @@ class TwoFactorSettingsController extends Controller
/** @var User $user */
$user = $request->user();
$confirm($user, $validated['code']);
$user->notify(new TwoFactorSecurityNotification(
'Two-factor authentication was enabled for your account.',
(string) $request->ip(),
now()->toIso8601String()
));
$securityEvents->record('two_factor.enabled', $user, $request);
return redirect()->route('profile.show')
$redirect = $request->routeIs('two-factor.setup.confirm')
? redirect()->route('two-factor.setup')
: redirect()->route('profile.show');
return $redirect
->with('success', 'Two-factor authentication is now enabled.')
->with('recoveryCodes', $user->fresh()->recoveryCodes());
}
@@ -64,9 +77,15 @@ class TwoFactorSettingsController extends Controller
*/
public function disable(
Request $request,
DisableTwoFactorAuthentication $disable
DisableTwoFactorAuthentication $disable,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$this->ensureFeatureIsEnabled();
$this->ensureFeatureIsEnabled($request);
abort_if(
config('auth-ui.features.two_factor_required'),
403,
'Two-factor authentication is required for this application.'
);
/** @var User $user */
$user = $request->user();
@@ -76,6 +95,12 @@ class TwoFactorSettingsController extends Controller
}
$disable($user);
$user->notify(new TwoFactorSecurityNotification(
'Two-factor authentication was disabled for your account.',
(string) $request->ip(),
now()->toIso8601String()
));
$securityEvents->record('two_factor.disabled', $user, $request);
return redirect()->route('profile.show')
->with('success', 'Two-factor authentication has been disabled.');
@@ -86,9 +111,10 @@ class TwoFactorSettingsController extends Controller
*/
public function recoveryCodes(
Request $request,
GenerateNewRecoveryCodes $generate
GenerateNewRecoveryCodes $generate,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$this->ensureFeatureIsEnabled();
$this->ensureFeatureIsEnabled($request);
/** @var User $user */
$user = $request->user();
@@ -102,6 +128,14 @@ class TwoFactorSettingsController extends Controller
if ($request->boolean('regenerate')) {
$generate($user);
$user->refresh();
$user->notify(new TwoFactorSecurityNotification(
'New two-factor authentication recovery codes were generated for your account.',
(string) $request->ip(),
now()->toIso8601String()
));
$securityEvents->record('two_factor.recovery_codes_regenerated', $user, $request);
} else {
$securityEvents->record('two_factor.recovery_codes_revealed', $user, $request);
}
return redirect()->route('profile.show')
@@ -111,9 +145,20 @@ class TwoFactorSettingsController extends Controller
->with('recoveryCodes', $user->recoveryCodes());
}
private function ensureFeatureIsEnabled(): void
private function ensureFeatureIsEnabled(Request $request): void
{
abort_unless(config('auth-ui.features.two_factor'), 404);
if ($request->routeIs('two-factor.setup.*')) {
abort_unless(config('auth-ui.features.two_factor_required'), 404);
}
}
private function enrollmentRedirect(Request $request): RedirectResponse
{
return $request->routeIs('two-factor.setup.*')
? redirect()->route('two-factor.setup')
: redirect()->route('profile.show');
}
private function validatePasswordForPasswordUser(Request $request, User $user): void
@@ -131,8 +176,6 @@ class TwoFactorSettingsController extends Controller
{
if ($user->hasPassword()) {
$this->validatePasswordForPasswordUser($request, $user);
return;
}
$validated = $request->validate([

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\TwoFactorEnrollmentState;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class TwoFactorSetupController extends Controller
{
/**
* Display the mandatory two-factor enrollment flow.
*/
public function __invoke(
Request $request,
TwoFactorEnrollmentState $enrollmentState
): Response|RedirectResponse {
abort_unless(
config('auth-ui.features.two_factor')
&& config('auth-ui.features.two_factor_required'),
404
);
/** @var User $user */
$user = $request->user();
if ($user->hasEnabledTwoFactorAuthentication()
&& ! $request->session()->has('recoveryCodes')) {
return redirect()->intended(config('auth-ui.redirects.login', '/'));
}
return Inertia::render('Auth/TwoFactorSetup', [
'twoFactor' => $enrollmentState->for($user),
]);
}
/**
* Continue to the page that was requested before enrollment.
*/
public function complete(Request $request): RedirectResponse
{
abort_unless(
config('auth-ui.features.two_factor')
&& $request->user()?->hasEnabledTwoFactorAuthentication(),
404
);
return redirect()->intended(config('auth-ui.redirects.login', '/'));
}
}

View File

@@ -3,38 +3,25 @@
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\Auth\TwoFactorEnrollmentState;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Fortify;
class ProfileController extends Controller
{
/**
* Display the authenticated user's profile and security settings.
*/
public function __invoke(Request $request): Response
{
public function __invoke(
Request $request,
TwoFactorEnrollmentState $enrollmentState
): Response {
/** @var User $user */
$user = $request->user();
$available = (bool) config('auth-ui.features.two_factor');
$pending = $available
&& filled($user->two_factor_secret)
&& ! $user->hasEnabledTwoFactorAuthentication();
return Inertia::render('Profile/Show', [
'twoFactor' => [
'available' => $available,
'enabled' => $available && $user->hasEnabledTwoFactorAuthentication(),
'pending' => $pending,
'requiresPassword' => $user->hasPassword(),
'qrCodeDataUri' => $pending
? 'data:image/svg+xml;base64,'.base64_encode($user->twoFactorQrCodeSvg())
: null,
'secretKey' => $pending
? Fortify::currentEncrypter()->decrypt($user->two_factor_secret)
: null,
],
'twoFactor' => $enrollmentState->for($user),
]);
}
}

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.*',
]);

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Notifications\QueuedResetPasswordNotification;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -61,6 +62,7 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'auth_session_version' => 'integer',
];
}
@@ -82,6 +84,15 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->password !== null;
}
/**
* Queue password reset mail so the request does not reveal account
* existence through mail-delivery timing.
*/
public function sendPasswordResetNotification($token): void
{
$this->notify(new QueuedResetPasswordNotification($token));
}
/**
* Get the user's social accounts.
*/

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class PasswordChangedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly string $ipAddress,
public readonly string $occurredAt
) {}
/**
* @return list<string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Your password was changed')
->greeting('Hello '.$notifiable->first_name.',')
->line('The password for your account was changed.')
->line("Time: {$this->occurredAt}")
->line("IP address: {$this->ipAddress}")
->line('All other signed-in sessions have been ended.')
->line('If you did not make this change, contact support immediately.');
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class QueuedResetPasswordNotification extends ResetPassword implements ShouldQueue
{
use Queueable;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TwoFactorFailedNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly string $ipAddress,
public readonly string $userAgent,
public readonly string $occurredAt
) {}
/**
* @return list<string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Failed two-factor authentication attempt')
->greeting('Hello '.$notifiable->first_name.',')
->line('A correct password was followed by a failed two-factor authentication attempt.')
->line("Time: {$this->occurredAt}")
->line("IP address: {$this->ipAddress}")
->line("Browser: {$this->userAgent}")
->line('If this was not you, change your password immediately.')
->action('Review account security', url('/profile'));
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TwoFactorSecurityNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly string $activity,
public readonly string $ipAddress,
public readonly string $occurredAt
) {}
/**
* @return list<string>
*/
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject('Two-factor authentication security notice')
->greeting('Hello '.$notifiable->first_name.',')
->line($this->activity)
->line("Time: {$this->occurredAt}")
->line("IP address: {$this->ipAddress}")
->line('If you did not perform this action, change your password and contact support immediately.')
->action('Review account security', url('/profile'));
}
}

View File

@@ -2,7 +2,14 @@
namespace App\Providers;
use Illuminate\Auth\Events\Login;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Fortify;
class AppServiceProvider extends ServiceProvider
@@ -22,6 +29,73 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
Event::listen(Login::class, function (Login $event): void {
if (request()->hasSession()) {
request()->session()->put([
'auth.session_version' => (int) $event->user->auth_session_version,
'auth.absolute_expires_at' => now()->timestamp
+ config('auth-ui.security.absolute_session_lifetime'),
]);
}
});
Password::defaults(function (): Password {
return Password::min(15)->max(128);
});
RateLimiter::for('auth.login', function (Request $request): array {
$login = Str::lower((string) $request->input('login'));
return [
Limit::perMinute(20)->by('login-ip:'.$request->ip()),
Limit::perMinutes(10, 10)->by('login-account:'.hash('sha256', $login)),
];
});
RateLimiter::for('auth.two-factor', function (Request $request): array {
$userKey = $request->user()?->getAuthIdentifier()
?? $request->session()->get('login.id')
?? 'guest';
return [
Limit::perMinute(20)->by('two-factor-ip:'.$request->ip()),
Limit::perMinutes(5, 5)->by('two-factor-account:'.$userKey),
];
});
RateLimiter::for('auth.password-email', function (Request $request): array {
$email = Str::lower((string) $request->input('email'));
return [
Limit::perMinutes(15, 20)->by('password-email-ip:'.$request->ip()),
Limit::perMinutes(15, 3)->by('password-email-account:'.hash('sha256', $email)),
];
});
RateLimiter::for('auth.password-reset', function (Request $request): array {
$email = Str::lower((string) $request->input('email'));
return [
Limit::perMinutes(15, 20)->by('password-reset-ip:'.$request->ip()),
Limit::perMinutes(15, 5)->by('password-reset-account:'.hash('sha256', $email)),
];
});
RateLimiter::for('auth.register', fn (Request $request): array => [
Limit::perHour(20)->by('register-ip:'.$request->ip()),
]);
RateLimiter::for('auth.social', fn (Request $request): array => [
Limit::perMinute(20)->by('social-ip:'.$request->ip()),
]);
RateLimiter::for('auth.account', function (Request $request): array {
$userKey = $request->user()?->getAuthIdentifier() ?? 'guest';
return [
Limit::perMinute(20)->by('account-action-ip:'.$request->ip()),
Limit::perMinutes(5, 6)->by('account-action-user:'.$userKey),
];
});
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class SecurityEventRecorder
{
/**
* Record a security event without credentials, tokens, or session IDs.
*
* @param array<string, bool|int|string|null> $context
*/
public function record(
string $event,
?User $user,
Request $request,
array $context = []
): void {
Log::channel(config('auth-ui.security.log_channel'))->notice(
'Authentication security event',
[
'event' => $event,
'user_id' => $user?->getKey(),
'ip_address' => $request->ip(),
'user_agent' => Str::limit((string) $request->userAgent(), 255, ''),
...$context,
]
);
}
}

View File

@@ -8,6 +8,16 @@ use Illuminate\Http\Request;
class TwoFactorChallenge
{
/**
* Determine whether the user must enroll in two-factor authentication.
*/
public function enrollmentRequiredFor(User $user): bool
{
return (bool) config('auth-ui.features.two_factor')
&& (bool) config('auth-ui.features.two_factor_required')
&& ! $user->hasEnabledTwoFactorAuthentication();
}
/**
* Determine whether the user must complete a two-factor challenge.
*/
@@ -25,6 +35,7 @@ class TwoFactorChallenge
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => $remember,
'login.started_at' => now()->timestamp,
]);
$request->session()->regenerate();

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Laravel\Fortify\Fortify;
class TwoFactorEnrollmentState
{
/**
* Build the two-factor state shared by setup and profile screens.
*
* @return array{
* available: bool,
* enabled: bool,
* pending: bool,
* requiresPassword: bool,
* qrCodeDataUri: string|null,
* secretKey: string|null
* }
*/
public function for(User $user): array
{
$available = (bool) config('auth-ui.features.two_factor');
$enabled = $available && $user->hasEnabledTwoFactorAuthentication();
$pending = $available
&& filled($user->two_factor_secret)
&& ! $enabled;
return [
'available' => $available,
'enabled' => $enabled,
'pending' => $pending,
'requiresPassword' => $user->hasPassword(),
'qrCodeDataUri' => $pending
? 'data:image/svg+xml;base64,'.base64_encode($user->twoFactorQrCodeSvg())
: null,
'secretKey' => $pending
? Fortify::currentEncrypter()->decrypt($user->two_factor_secret)
: null,
];
}
}