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', '/'));
}
}