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

@@ -13,7 +13,11 @@ APP_MAINTENANCE_DRIVER=file
PHP_CLI_SERVER_WORKERS=12 PHP_CLI_SERVER_WORKERS=12
BCRYPT_ROUNDS=12 HASH_DRIVER=argon2id
HASH_VERIFY=true
ARGON_MEMORY=65536
ARGON_THREADS=1
ARGON_TIME=4
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
@@ -30,6 +34,9 @@ DB_PASSWORD=password
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_SECURE_COOKIE=false
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
@@ -69,6 +76,10 @@ AUTH_ENABLE_REMEMBER_ME=true
AUTH_ENABLE_EMAIL_VERIFICATION=false AUTH_ENABLE_EMAIL_VERIFICATION=false
AUTH_ENABLE_TWO_FACTOR=false AUTH_ENABLE_TWO_FACTOR=false
AUTH_REQUIRE_TWO_FACTOR=false AUTH_REQUIRE_TWO_FACTOR=false
AUTH_TWO_FACTOR_CHALLENGE_TIMEOUT=300
AUTH_ABSOLUTE_SESSION_LIFETIME=28800
AUTH_SECURITY_LOG_CHANNEL=stack
# In production, set SESSION_ENCRYPT=true and SESSION_SECURE_COOKIE=true.
# Auth Redirects # Auth Redirects
# AUTH_REDIRECT_LOGIN=/dashboard # AUTH_REDIRECT_LOGIN=/dashboard

View File

@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\CompleteProfileRequest; use App\Http\Requests\Auth\CompleteProfileRequest;
use App\Models\User; use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -33,8 +35,11 @@ class CompleteProfileController extends Controller
/** /**
* Handle the complete profile request. * 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'); $socialiteUser = session('socialite_user');
$validated = $request->validated(); $validated = $request->validated();
@@ -58,6 +63,16 @@ class CompleteProfileController extends Controller
Auth::login($user, remember: true); Auth::login($user, remember: true);
$request->session()->regenerate(); $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', '/')); 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\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest; use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Inertia\Inertia; use Inertia\Inertia;
@@ -26,16 +27,20 @@ class ForgotPasswordController extends Controller
/** /**
* Handle an incoming password reset link request. * Handle an incoming password reset link request.
*/ */
public function store(ForgotPasswordRequest $request): RedirectResponse public function store(
{ ForgotPasswordRequest $request,
$status = Password::sendResetLink( SecurityEventRecorder $securityEvents
): RedirectResponse {
Password::sendResetLink(
$request->validated() $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(
return back()->with('status', __($status)); 'status',
} 'If an account exists for that email address, a password reset link has been sent.'
);
return back()->withErrors(['email' => __($status)]);
} }
} }

View File

@@ -5,13 +5,17 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Models\User; use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge; use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use RuntimeException;
class LoginController extends Controller class LoginController extends Controller
{ {
@@ -26,8 +30,11 @@ class LoginController extends Controller
/** /**
* Handle an incoming authentication request. * 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'); $login = $request->validated('login');
$password = $request->validated('password'); $password = $request->validated('password');
@@ -46,7 +53,19 @@ class LoginController extends Controller
? $provider->retrieveByCredentials($credentials) ? $provider->retrieveByCredentials($credentials)
: null; : 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([ throw ValidationException::withMessages([
'login' => __('auth.failed'), 'login' => __('auth.failed'),
]); ]);
@@ -58,11 +77,23 @@ class LoginController extends Controller
} }
if ($twoFactor->requiredFor($user)) { if ($twoFactor->requiredFor($user)) {
$securityEvents->record('login.password_succeeded_pending_two_factor', $user, $request);
return $twoFactor->begin($request, $user, $remember); return $twoFactor->begin($request, $user, $remember);
} }
Auth::login($user, $remember); Auth::login($user, $remember);
$request->session()->regenerate(); $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', '/')); return redirect()->intended(config('auth-ui.redirects.login', '/'));
} }
@@ -72,6 +103,10 @@ class LoginController extends Controller
*/ */
public function destroy(Request $request): RedirectResponse public function destroy(Request $request): RedirectResponse
{ {
/** @var User|null $user */
$user = $request->user();
app(SecurityEventRecorder::class)->record('logout.succeeded', $user, $request);
Auth::logout(); Auth::logout();
$request->session()->invalidate(); $request->session()->invalidate();
@@ -79,4 +114,11 @@ class LoginController extends Controller
return redirect(config('auth-ui.redirects.logout', '/')); 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\Controllers\Controller;
use App\Http\Requests\Auth\RegisterRequest; use App\Http\Requests\Auth\RegisterRequest;
use App\Models\User; use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -28,8 +30,11 @@ class RegisterController extends Controller
/** /**
* Handle an incoming registration request. * Handle an incoming registration request.
*/ */
public function store(RegisterRequest $request): RedirectResponse public function store(
{ RegisterRequest $request,
TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse {
$validated = $request->validated(); $validated = $request->validated();
$user = User::create([ $user = User::create([
@@ -43,11 +48,22 @@ class RegisterController extends Controller
event(new Registered($user)); event(new Registered($user));
Auth::login($user); Auth::login($user);
$request->session()->regenerate();
$securityEvents->record('registration.completed', $user, $request);
if (config('auth-ui.features.email_verification')) { if (config('auth-ui.features.email_verification')) {
return redirect()->route('verification.notice'); 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', '/')); 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\Controllers\Controller;
use App\Http\Requests\Auth\ResetPasswordRequest; 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\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
@@ -32,21 +36,39 @@ class ResetPasswordController extends Controller
/** /**
* Handle an incoming new password request. * 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( $status = Password::reset(
$request->validated(), $request->validated(),
function ($user, string $password): void { function (User $user, string $password) use ($request, &$resetUser): void {
$user->forceFill([ $user->forceFill([
'password' => $password, 'password' => $password,
'remember_token' => Str::random(60), 'remember_token' => Str::random(60),
'auth_session_version' => $user->auth_session_version + 1,
])->save(); ])->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)); event(new PasswordReset($user));
} }
); );
if ($status === Password::PASSWORD_RESET) { if ($status === Password::PASSWORD_RESET) {
$securityEvents->record('password_reset.completed', $resetUser, $request);
return redirect()->route('login')->with('status', __($status)); 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\Http\Controllers\Controller;
use App\Models\SocialAccount; use App\Models\SocialAccount;
use App\Models\User; use App\Models\User;
use App\Services\Auth\SecurityEventRecorder;
use App\Services\Auth\TwoFactorChallenge; use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -54,7 +55,8 @@ class SocialiteController extends Controller
public function callback( public function callback(
Request $request, Request $request,
string $provider, string $provider,
TwoFactorChallenge $twoFactor TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse { ): RedirectResponse {
if (! in_array($provider, $this->getEnabledProviders())) { if (! in_array($provider, $this->getEnabledProviders())) {
abort(404, 'Provider not enabled'); abort(404, 'Provider not enabled');
@@ -74,7 +76,12 @@ class SocialiteController extends Controller
->first(); ->first();
if ($socialAccount) { 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 // Check if a user with this email already exists
@@ -92,7 +99,12 @@ class SocialiteController extends Controller
'provider_id' => $socialUser->getId(), 'provider_id' => $socialUser->getId(),
]); ]);
return $this->finishAuthentication($request, $existingUser, $twoFactor); return $this->finishAuthentication(
$request,
$existingUser,
$twoFactor,
$securityEvents
);
} }
// New user — check if registration is enabled // New user — check if registration is enabled
@@ -134,20 +146,33 @@ class SocialiteController extends Controller
'provider_id' => $socialUser->getId(), 'provider_id' => $socialUser->getId(),
]); ]);
return $this->finishAuthentication($request, $user, $twoFactor); return $this->finishAuthentication($request, $user, $twoFactor, $securityEvents);
} }
private function finishAuthentication( private function finishAuthentication(
Request $request, Request $request,
User $user, User $user,
TwoFactorChallenge $twoFactor TwoFactorChallenge $twoFactor,
SecurityEventRecorder $securityEvents
): RedirectResponse { ): RedirectResponse {
if ($twoFactor->requiredFor($user)) { if ($twoFactor->requiredFor($user)) {
$securityEvents->record('login.social_succeeded_pending_two_factor', $user, $request);
return $twoFactor->begin($request, $user, remember: true); return $twoFactor->begin($request, $user, remember: true);
} }
Auth::login($user, remember: true); Auth::login($user, remember: true);
$request->session()->regenerate(); $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', '/')); return redirect()->intended(config('auth-ui.redirects.login', '/'));
} }

View File

@@ -3,6 +3,8 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Notifications\TwoFactorFailedNotification;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -19,7 +21,9 @@ class TwoFactorChallengeController extends Controller
*/ */
public function create(TwoFactorLoginRequest $request): Response|RedirectResponse 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); $this->clearChallenge($request);
return redirect()->route('login'); return redirect()->route('login');
@@ -31,9 +35,13 @@ class TwoFactorChallengeController extends Controller
/** /**
* Complete the pending login using a TOTP or recovery code. * Complete the pending login using a TOTP or recovery code.
*/ */
public function store(TwoFactorLoginRequest $request): RedirectResponse public function store(
{ TwoFactorLoginRequest $request,
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) { SecurityEventRecorder $securityEvents
): RedirectResponse {
if (! config('auth-ui.features.two_factor')
|| ! $request->hasChallengedUser()
|| $this->challengeHasExpired($request)) {
$this->clearChallenge($request); $this->clearChallenge($request);
return redirect()->route('login'); return redirect()->route('login');
@@ -51,6 +59,12 @@ class TwoFactorChallengeController extends Controller
$user->replaceRecoveryCode($recoveryCode); $user->replaceRecoveryCode($recoveryCode);
} elseif (! $request->hasValidCode()) { } elseif (! $request->hasValidCode()) {
event(new TwoFactorAuthenticationFailed($user)); 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([ throw ValidationException::withMessages([
$request->filled('recovery_code') ? 'recovery_code' : 'code' => 'The provided authentication code was invalid.', $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()); Auth::login($user, $request->remember());
$request->session()->regenerate(); $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')); return redirect()->intended(config('auth-ui.redirects.login', '/dashboard'));
} }
private function clearChallenge(TwoFactorLoginRequest $request): void 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\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Notifications\TwoFactorSecurityNotification;
use App\Services\Auth\SecurityEventRecorder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -21,19 +23,19 @@ class TwoFactorSettingsController extends Controller
*/ */
public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse
{ {
$this->ensureFeatureIsEnabled(); $this->ensureFeatureIsEnabled($request);
/** @var User $user */ /** @var User $user */
$user = $request->user(); $user = $request->user();
if ($user->hasEnabledTwoFactorAuthentication()) { if ($user->hasEnabledTwoFactorAuthentication()) {
return redirect()->route('profile.show'); return $this->enrollmentRedirect($request);
} }
$this->validatePasswordForPasswordUser($request, $user); $this->validatePasswordForPasswordUser($request, $user);
$enable($user, force: true); $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.'); ->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( public function confirm(
Request $request, Request $request,
ConfirmTwoFactorAuthentication $confirm ConfirmTwoFactorAuthentication $confirm,
SecurityEventRecorder $securityEvents
): RedirectResponse { ): RedirectResponse {
$this->ensureFeatureIsEnabled(); $this->ensureFeatureIsEnabled($request);
$validated = $request->validate([ $validated = $request->validate([
'code' => ['required', 'string', 'regex:/^\d{6}$/'], 'code' => ['required', 'string', 'regex:/^\d{6}$/'],
@@ -53,8 +56,18 @@ class TwoFactorSettingsController extends Controller
/** @var User $user */ /** @var User $user */
$user = $request->user(); $user = $request->user();
$confirm($user, $validated['code']); $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('success', 'Two-factor authentication is now enabled.')
->with('recoveryCodes', $user->fresh()->recoveryCodes()); ->with('recoveryCodes', $user->fresh()->recoveryCodes());
} }
@@ -64,9 +77,15 @@ class TwoFactorSettingsController extends Controller
*/ */
public function disable( public function disable(
Request $request, Request $request,
DisableTwoFactorAuthentication $disable DisableTwoFactorAuthentication $disable,
SecurityEventRecorder $securityEvents
): RedirectResponse { ): 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 */ /** @var User $user */
$user = $request->user(); $user = $request->user();
@@ -76,6 +95,12 @@ class TwoFactorSettingsController extends Controller
} }
$disable($user); $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') return redirect()->route('profile.show')
->with('success', 'Two-factor authentication has been disabled.'); ->with('success', 'Two-factor authentication has been disabled.');
@@ -86,9 +111,10 @@ class TwoFactorSettingsController extends Controller
*/ */
public function recoveryCodes( public function recoveryCodes(
Request $request, Request $request,
GenerateNewRecoveryCodes $generate GenerateNewRecoveryCodes $generate,
SecurityEventRecorder $securityEvents
): RedirectResponse { ): RedirectResponse {
$this->ensureFeatureIsEnabled(); $this->ensureFeatureIsEnabled($request);
/** @var User $user */ /** @var User $user */
$user = $request->user(); $user = $request->user();
@@ -102,6 +128,14 @@ class TwoFactorSettingsController extends Controller
if ($request->boolean('regenerate')) { if ($request->boolean('regenerate')) {
$generate($user); $generate($user);
$user->refresh(); $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') return redirect()->route('profile.show')
@@ -111,9 +145,20 @@ class TwoFactorSettingsController extends Controller
->with('recoveryCodes', $user->recoveryCodes()); ->with('recoveryCodes', $user->recoveryCodes());
} }
private function ensureFeatureIsEnabled(): void private function ensureFeatureIsEnabled(Request $request): void
{ {
abort_unless(config('auth-ui.features.two_factor'), 404); 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 private function validatePasswordForPasswordUser(Request $request, User $user): void
@@ -131,8 +176,6 @@ class TwoFactorSettingsController extends Controller
{ {
if ($user->hasPassword()) { if ($user->hasPassword()) {
$this->validatePasswordForPasswordUser($request, $user); $this->validatePasswordForPasswordUser($request, $user);
return;
} }
$validated = $request->validate([ $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; namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use App\Services\Auth\TwoFactorEnrollmentState;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
use Laravel\Fortify\Fortify;
class ProfileController extends Controller class ProfileController extends Controller
{ {
/** /**
* Display the authenticated user's profile and security settings. * 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 */ /** @var User $user */
$user = $request->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', [ return Inertia::render('Profile/Show', [
'twoFactor' => [ 'twoFactor' => $enrollmentState->for($user),
'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,
],
]); ]);
} }
} }

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()) { if ($request->expectsJson()) {
return response()->json([ return response()->json([
'message' => 'Two-factor authentication setup is required.', 'message' => 'Two-factor authentication setup is required.',
'setup_url' => route('profile.show'), 'setup_url' => route('two-factor.setup'),
], 403); ], 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.'); ->with('error', 'Two-factor authentication is required before you can continue.');
} }
private function isEnrollmentRoute(Request $request): bool private function isEnrollmentRoute(Request $request): bool
{ {
return $request->routeIs([ return $request->routeIs([
'profile.*', 'two-factor.setup*',
'logout', 'logout',
'verification.*', 'verification.*',
]); ]);

View File

@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Notifications\QueuedResetPasswordNotification;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -61,6 +62,7 @@ class User extends Authenticatable implements MustVerifyEmail
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime',
'auth_session_version' => 'integer',
]; ];
} }
@@ -82,6 +84,15 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->password !== null; 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. * 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; 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\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -22,6 +29,73 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void 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 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. * Determine whether the user must complete a two-factor challenge.
*/ */
@@ -25,6 +35,7 @@ class TwoFactorChallenge
$request->session()->put([ $request->session()->put([
'login.id' => $user->getKey(), 'login.id' => $user->getKey(),
'login.remember' => $remember, 'login.remember' => $remember,
'login.started_at' => now()->timestamp,
]); ]);
$request->session()->regenerate(); $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,
];
}
}

View File

@@ -1,5 +1,7 @@
<?php <?php
use App\Http\Middleware\AddSecurityHeaders;
use App\Http\Middleware\EnforceAbsoluteSessionTimeout;
use App\Http\Middleware\EnsureTwoFactorAuthenticationIsEnabled; use App\Http\Middleware\EnsureTwoFactorAuthenticationIsEnabled;
use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
@@ -19,7 +21,10 @@ return Application::configure(basePath: dirname(__DIR__))
}, },
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->append(AddSecurityHeaders::class);
$middleware->web(append: [ $middleware->web(append: [
EnforceAbsoluteSessionTimeout::class,
HandleInertiaRequests::class, HandleInertiaRequests::class,
EnsureTwoFactorAuthenticationIsEnabled::class, EnsureTwoFactorAuthenticationIsEnabled::class,
]); ]);

View File

@@ -1,5 +1,7 @@
<?php <?php
use App\Providers\AppServiceProvider;
return [ return [
App\Providers\AppServiceProvider::class, AppServiceProvider::class,
]; ];

View File

@@ -25,7 +25,7 @@
"laravel/sail": "^1.53", "laravel/sail": "^1.53",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.2" "pestphp/pest": "^5.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

1225
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,12 @@ return [
'two_factor_required' => env('AUTH_REQUIRE_TWO_FACTOR', false), 'two_factor_required' => env('AUTH_REQUIRE_TWO_FACTOR', false),
], ],
'security' => [
'log_channel' => env('AUTH_SECURITY_LOG_CHANNEL', env('LOG_CHANNEL', 'stack')),
'two_factor_challenge_timeout' => (int) env('AUTH_TWO_FACTOR_CHALLENGE_TIMEOUT', 300),
'absolute_session_lifetime' => (int) env('AUTH_ABSOLUTE_SESSION_LIFETIME', 28800),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Login Page Configuration | Login Page Configuration

View File

@@ -1,5 +1,7 @@
<?php <?php
use App\Models\User;
return [ return [
/* /*
@@ -62,7 +64,7 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', User::class),
], ],
// 'users' => [ // 'users' => [

View File

@@ -1,6 +1,7 @@
<?php <?php
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Pdo\Mysql;
return [ return [
@@ -59,7 +60,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@@ -79,7 +80,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],

View File

@@ -11,6 +11,8 @@ return [
? [ ? [
Features::twoFactorAuthentication([ Features::twoFactorAuthentication([
'confirm' => true, 'confirm' => true,
'secret-length' => 32,
'window' => 0,
]), ]),
] ]
: [], : [],

30
config/hashing.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password hashing
|--------------------------------------------------------------------------
|
| Argon2id is the only accepted password algorithm. Strict algorithm
| verification prevents hashes created with an unexpected algorithm from
| being accepted by this fresh application template.
|
*/
'driver' => env('HASH_DRIVER', 'argon2id'),
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => env('HASH_VERIFY', true),
'limit' => env('BCRYPT_LIMIT', null),
],
'argon' => [
'memory' => env('ARGON_MEMORY', 65536),
'threads' => env('ARGON_THREADS', 1),
'time' => env('ARGON_TIME', 4),
'verify' => env('HASH_VERIFY', true),
],
'rehash_on_login' => true,
];

View File

@@ -47,7 +47,9 @@ return [
| |
*/ */
'encrypt' => env('SESSION_ENCRYPT', false), 'encrypt' => env('APP_ENV') === 'production'
? true
: env('SESSION_ENCRYPT', false),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -169,7 +171,9 @@ return [
| |
*/ */
'secure' => env('SESSION_SECURE_COOKIE'), 'secure' => env('APP_ENV') === 'production'
? true
: env('SESSION_SECURE_COOKIE', false),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -0,0 +1,22 @@
<?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::table('users', function (Blueprint $table): void {
$table->unsignedInteger('auth_session_version')->default(0);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('auth_session_version');
});
}
};

1249
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,13 +22,19 @@
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^6.0.8", "@vitejs/plugin-vue": "^6.0.8",
"@vue/test-utils": "^2.4.11", "@vue/test-utils": "^2.4.11",
"concurrently": "^10.0.3", "concurrently": "^10.0.4",
"eslint": "^10.7.0", "eslint": "^10.8.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
"laravel-vite-plugin": "^3.1.3", "laravel-vite-plugin": "^3.1.3",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.1.5", "vite": "^8.2.0",
"vitest": "^4.1.10" "vitest": "^4.1.10"
},
"overrides": {
"@vue/test-utils": {
"js-beautify": "2.0.3"
},
"esbuild": "0.28.1"
} }
} }

View File

@@ -20,7 +20,11 @@
<php> <php>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="HASH_DRIVER" value="argon2id"/>
<env name="HASH_VERIFY" value="true"/>
<env name="ARGON_MEMORY" value="8192"/>
<env name="ARGON_THREADS" value="1"/>
<env name="ARGON_TIME" value="1"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_DATABASE" value="testing"/> <env name="DB_DATABASE" value="testing"/>

View File

@@ -26,7 +26,7 @@ function submit() {
<AuthLayout> <AuthLayout>
<div class="space-y-6"> <div class="space-y-6">
<div class="text-center space-y-2"> <div class="text-center space-y-2">
<UIcon name="i-lucide-shield-check" class="text-primary size-10" /> <UIcon name="i-lucide-shield-check" class="mx-auto block size-10 text-primary" />
<h1 class="text-xl font-semibold"> <h1 class="text-xl font-semibold">
Two-factor authentication Two-factor authentication
</h1> </h1>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
import { useAuth } from '@/composables/useAuth'
import AuthLayout from '@/layouts/AuthLayout.vue'
interface TwoFactorState {
available: boolean
enabled: boolean
pending: boolean
requiresPassword: boolean
qrCodeDataUri: string | null
secretKey: string | null
}
defineProps<{
twoFactor: TwoFactorState
}>()
const { flash } = useAuth()
const enableForm = useForm({ password: '' })
const confirmForm = useForm({ code: '' })
const logoutForm = useForm({})
const recoveryCodes = computed(() => flash.value.recoveryCodes ?? [])
const manualKeyCopied = ref(false)
function enable() {
enableForm.post('/two-factor-setup', {
onFinish: () => enableForm.reset(),
})
}
function confirm() {
confirmForm.post('/two-factor-setup/confirm', {
onFinish: () => confirmForm.reset(),
})
}
function logout() {
logoutForm.post('/logout')
}
async function copyManualKey(secretKey: string | null) {
if (!secretKey)
return
await navigator.clipboard.writeText(secretKey)
manualKeyCopied.value = true
window.setTimeout(() => {
manualKeyCopied.value = false
}, 2000)
}
</script>
<template>
<AuthLayout>
<div class="space-y-6">
<div class="space-y-2 text-center">
<span class="mx-auto flex size-12 items-center justify-center rounded-full bg-primary/10">
<UIcon
:name="twoFactor.enabled ? 'i-lucide-shield-check' : 'i-lucide-shield-keyhole'"
class="size-6 text-primary"
/>
</span>
<h1 class="text-xl font-semibold">
{{ twoFactor.enabled ? 'Setup complete' : 'Secure your account' }}
</h1>
<p class="text-muted text-sm">
{{ twoFactor.enabled
? 'Two-factor authentication is ready. Save your recovery codes before continuing.'
: 'Two-factor authentication is required before you can continue.' }}
</p>
</div>
<UAlert
v-if="flash.error"
color="error"
icon="i-lucide-alert-circle"
:title="flash.error"
/>
<div v-if="twoFactor.enabled" class="space-y-5">
<UAlert
color="success"
variant="subtle"
icon="i-lucide-check-circle"
title="Your authenticator has been verified."
/>
<div>
<p class="mb-3 text-sm font-medium">
Recovery codes
</p>
<RecoveryCodesPanel :codes="recoveryCodes" />
<p class="mt-3 text-xs text-muted">
Store these somewhere safe. Each recovery code can only be used once.
</p>
</div>
<UButton to="/two-factor-setup/complete" block trailing-icon="i-lucide-arrow-right">
Continue
</UButton>
</div>
<form
v-else-if="!twoFactor.pending"
class="space-y-4"
@submit.prevent="enable"
>
<p class="text-sm text-muted">
Start by confirming your identity. You will then connect an authenticator app.
</p>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="enableForm.errors.password"
>
<UInput
v-model="enableForm.password"
type="password"
autocomplete="current-password"
autofocus
class="w-full"
/>
</UFormField>
<UAlert
v-else
color="info"
variant="subtle"
icon="i-lucide-info"
title="Your social login has confirmed your identity."
/>
<UButton type="submit" block :loading="enableForm.processing">
Set up authenticator
</UButton>
</form>
<div v-else class="space-y-5">
<div class="space-y-3 text-center">
<img
v-if="twoFactor.qrCodeDataUri"
:src="twoFactor.qrCodeDataUri"
alt="Two-factor authenticator QR code"
class="mx-auto size-48 rounded-lg border bg-white p-2"
>
<div>
<h2 class="font-medium">
Scan the QR code
</h2>
<p class="mt-1 text-sm text-muted">
Use any TOTP-compatible authenticator app.
</p>
</div>
</div>
<div>
<p class="mb-1 text-xs text-muted">
Manual setup key
</p>
<div class="flex items-center gap-2 rounded-lg bg-elevated p-2">
<code class="min-w-0 flex-1 break-all px-1 text-sm">
{{ twoFactor.secretKey }}
</code>
<UButton
type="button"
variant="ghost"
size="sm"
:icon="manualKeyCopied ? 'i-lucide-check' : 'i-lucide-copy'"
:color="manualKeyCopied ? 'success' : 'neutral'"
:aria-label="manualKeyCopied ? 'Manual setup key copied' : 'Copy manual setup key'"
@click="copyManualKey(twoFactor.secretKey)"
>
{{ manualKeyCopied ? 'Copied' : 'Copy' }}
</UButton>
</div>
</div>
<form class="space-y-4" @submit.prevent="confirm">
<UFormField
label="Six-digit authentication code"
name="code"
:error="confirmForm.errors.code"
>
<UInput
v-model="confirmForm.code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="123456"
autofocus
class="w-full"
:ui="{ base: 'text-center tracking-[0.35em]' }"
/>
</UFormField>
<UButton type="submit" block :loading="confirmForm.processing">
Verify authenticator
</UButton>
</form>
</div>
<div v-if="!twoFactor.enabled" class="border-t pt-4">
<UButton
type="button"
color="neutral"
variant="outline"
icon="i-lucide-log-out"
block
:loading="logoutForm.processing"
@click="logout"
>
Sign out and finish later
</UButton>
</div>
</div>
</AuthLayout>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3' import { useForm } from '@inertiajs/vue3'
import { computed } from 'vue' import { computed } from 'vue'
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import DashboardLayout from '@/layouts/DashboardLayout.vue' import DashboardLayout from '@/layouts/DashboardLayout.vue'
@@ -235,19 +236,24 @@ function recoveryCodesAction(regenerate: boolean) {
</p> </p>
</div> </div>
<UFormField <UFormField
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'" v-if="twoFactor.requiresPassword"
:name="twoFactor.requiresPassword ? 'password' : 'code'" label="Current password"
:error="recoveryForm.errors.password || recoveryForm.errors.code" name="password"
:error="recoveryForm.errors.password"
> >
<UInput <UInput
v-if="twoFactor.requiresPassword"
v-model="recoveryForm.password" v-model="recoveryForm.password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="w-full" class="w-full"
/> />
</UFormField>
<UFormField
label="Authentication or recovery code"
name="code"
:error="recoveryForm.errors.code"
>
<UInput <UInput
v-else
v-model="recoveryForm.code" v-model="recoveryForm.code"
autocomplete="one-time-code" autocomplete="one-time-code"
class="w-full" class="w-full"
@@ -272,7 +278,16 @@ function recoveryCodesAction(regenerate: boolean) {
</div> </div>
</div> </div>
<form class="space-y-4 border-t pt-6" @submit.prevent="disable"> <UAlert
v-if="twoFactorRequired"
color="neutral"
variant="subtle"
icon="i-lucide-lock-keyhole"
title="Two-factor authentication is required by your administrator."
description="It cannot be disabled while the mandatory security policy is active."
/>
<form v-else class="space-y-4 border-t pt-6" @submit.prevent="disable">
<div> <div>
<h3 class="font-medium text-error"> <h3 class="font-medium text-error">
Disable two-factor authentication Disable two-factor authentication
@@ -282,19 +297,24 @@ function recoveryCodesAction(regenerate: boolean) {
</p> </p>
</div> </div>
<UFormField <UFormField
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'" v-if="twoFactor.requiresPassword"
:name="twoFactor.requiresPassword ? 'password' : 'code'" label="Current password"
:error="disableForm.errors.password || disableForm.errors.code" name="password"
:error="disableForm.errors.password"
> >
<UInput <UInput
v-if="twoFactor.requiresPassword"
v-model="disableForm.password" v-model="disableForm.password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
class="w-full" class="w-full"
/> />
</UFormField>
<UFormField
label="Authentication or recovery code"
name="code"
:error="disableForm.errors.code"
>
<UInput <UInput
v-else
v-model="disableForm.code" v-model="disableForm.code"
autocomplete="one-time-code" autocomplete="one-time-code"
class="w-full" class="w-full"
@@ -318,13 +338,7 @@ function recoveryCodesAction(regenerate: boolean) {
</p> </p>
</div> </div>
</template> </template>
<div class="grid gap-2 sm:grid-cols-2"> <RecoveryCodesPanel :codes="recoveryCodes" />
<code
v-for="code in recoveryCodes"
:key="code"
class="rounded bg-elevated px-3 py-2 text-sm"
>{{ code }}</code>
</div>
</UCard> </UCard>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
codes: string[]
}>()
const copied = ref(false)
function recoveryCodesText() {
return [
'Two-factor authentication recovery codes',
'',
...props.codes,
'',
'Each code can only be used once. Store these codes securely.',
].join('\n')
}
async function copyAll() {
await navigator.clipboard.writeText(props.codes.join('\n'))
copied.value = true
window.setTimeout(() => {
copied.value = false
}, 2000)
}
function download() {
const blob = new Blob([recoveryCodesText()], {
type: 'text/plain;charset=utf-8',
})
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'two-factor-recovery-codes.txt'
link.click()
URL.revokeObjectURL(url)
}
</script>
<template>
<div class="space-y-3">
<div class="flex flex-wrap gap-2">
<UButton
type="button"
variant="outline"
size="sm"
:color="copied ? 'success' : 'neutral'"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:aria-label="copied ? 'Recovery codes copied' : 'Copy all recovery codes'"
@click="copyAll"
>
{{ copied ? 'Copied' : 'Copy all' }}
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-download"
aria-label="Download recovery codes"
@click="download"
>
Download
</UButton>
</div>
<div class="grid gap-2 sm:grid-cols-2">
<code
v-for="code in codes"
:key="code"
class="rounded bg-elevated px-3 py-2 text-center text-sm"
>{{ code }}</code>
</div>
</div>
</template>

View File

@@ -40,8 +40,8 @@ describe('registerSchema', () => {
first_name: 'Test', first_name: 'Test',
last_name: 'User', last_name: 'User',
email: 'test@example.com', email: 'test@example.com',
password: 'password123', password: 'correct horse battery staple',
password_confirmation: 'password123', password_confirmation: 'correct horse battery staple',
} }
it('accepts valid registration data', () => { it('accepts valid registration data', () => {
@@ -69,8 +69,8 @@ describe('registerSchema', () => {
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
it('rejects password shorter than 8 characters', () => { it('rejects password shorter than 15 characters', () => {
const result = validate(registerSchema, { ...validData, password: 'short', password_confirmation: 'short' }) const result = validate(registerSchema, { ...validData, password: 'too-short', password_confirmation: 'too-short' })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
@@ -110,8 +110,8 @@ describe('forgotPasswordSchema', () => {
describe('resetPasswordSchema', () => { describe('resetPasswordSchema', () => {
const validData = { const validData = {
email: 'test@example.com', email: 'test@example.com',
password: 'newpassword123', password: 'a secure new password',
password_confirmation: 'newpassword123', password_confirmation: 'a secure new password',
} }
it('accepts valid reset data', () => { it('accepts valid reset data', () => {
@@ -124,8 +124,8 @@ describe('resetPasswordSchema', () => {
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })
it('rejects password shorter than 8 characters', () => { it('rejects password shorter than 15 characters', () => {
const result = validate(resetPasswordSchema, { ...validData, password: 'short', password_confirmation: 'short' }) const result = validate(resetPasswordSchema, { ...validData, password: 'too-short', password_confirmation: 'too-short' })
expect(result.success).toBe(false) expect(result.success).toBe(false)
}) })

View File

@@ -17,7 +17,7 @@ export const registerSchema = v.pipe(
first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')), first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')),
last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')), last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')),
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')), email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')), password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(15, 'Password must be at least 15 characters'), v.maxLength(128, 'Password must be 128 characters or fewer')),
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')), password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
}), }),
v.forward( v.forward(
@@ -37,7 +37,7 @@ export const forgotPasswordSchema = v.object({
export const resetPasswordSchema = v.pipe( export const resetPasswordSchema = v.pipe(
v.object({ v.object({
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')), email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')), password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(15, 'Password must be at least 15 characters'), v.maxLength(128, 'Password must be 128 characters or fewer')),
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')), password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
}), }),
v.forward( v.forward(

View File

@@ -9,34 +9,38 @@ use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\Auth\SocialiteController; use App\Http\Controllers\Auth\SocialiteController;
use App\Http\Controllers\Auth\TwoFactorChallengeController; use App\Http\Controllers\Auth\TwoFactorChallengeController;
use App\Http\Controllers\Auth\TwoFactorSettingsController; use App\Http\Controllers\Auth\TwoFactorSettingsController;
use App\Http\Controllers\Auth\TwoFactorSetupController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('login', [LoginController::class, 'create'])->name('login'); Route::get('login', [LoginController::class, 'create'])->name('login');
Route::post('login', [LoginController::class, 'store'])->middleware('throttle:5,1'); Route::post('login', [LoginController::class, 'store'])->middleware('throttle:auth.login');
Route::get('register', [RegisterController::class, 'create'])->name('register'); Route::get('register', [RegisterController::class, 'create'])->name('register');
Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:3,60'); Route::post('register', [RegisterController::class, 'store'])->middleware('throttle:auth.register');
Route::get('forgot-password', [ForgotPasswordController::class, 'create'])->name('password.request'); Route::get('forgot-password', [ForgotPasswordController::class, 'create'])->name('password.request');
Route::post('forgot-password', [ForgotPasswordController::class, 'store'])->name('password.email')->middleware('throttle:3,15'); Route::post('forgot-password', [ForgotPasswordController::class, 'store'])->name('password.email')->middleware('throttle:auth.password-email');
Route::get('reset-password/{token}', [ResetPasswordController::class, 'create'])->name('password.reset'); Route::get('reset-password/{token}', [ResetPasswordController::class, 'create'])->name('password.reset');
Route::post('reset-password', [ResetPasswordController::class, 'store'])->name('password.store')->middleware('throttle:5,15'); Route::post('reset-password', [ResetPasswordController::class, 'store'])->name('password.store')->middleware('throttle:auth.password-reset');
// Socialite routes // Socialite routes
Route::get('auth/{provider}', [SocialiteController::class, 'redirect'])->name('socialite.redirect'); Route::get('auth/{provider}', [SocialiteController::class, 'redirect'])
Route::get('auth/{provider}/callback', [SocialiteController::class, 'callback'])->name('socialite.callback')->middleware('throttle:10,1'); ->middleware('throttle:auth.social')
->name('socialite.redirect');
Route::get('auth/{provider}/callback', [SocialiteController::class, 'callback'])->name('socialite.callback')->middleware('throttle:auth.social');
// Complete profile after social login (when username is taken) // Complete profile after social login (when username is taken)
Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile'); Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile');
Route::post('complete-profile', [CompleteProfileController::class, 'store']); Route::post('complete-profile', [CompleteProfileController::class, 'store'])
->middleware('throttle:auth.social');
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'create']) Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'create'])
->name('two-factor.login'); ->name('two-factor.login');
Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']) Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store'])
->middleware('throttle:5,1') ->middleware('throttle:auth.two-factor')
->name('two-factor.login.store'); ->name('two-factor.login.store');
}); });
@@ -46,20 +50,32 @@ Route::middleware('auth')->group(function () {
// Email verification routes // Email verification routes
Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice'); Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice');
Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify'); Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])
->middleware('throttle:auth.account')
->name('verification.send');
Route::get('profile', ProfileController::class)->name('profile.show'); Route::get('profile', ProfileController::class)->name('profile.show');
Route::redirect('security', '/profile')->name('profile.security-redirect'); Route::redirect('security', '/profile')->name('profile.security-redirect');
Route::get('two-factor-setup', TwoFactorSetupController::class)
->name('two-factor.setup');
Route::get('two-factor-setup/complete', [TwoFactorSetupController::class, 'complete'])
->name('two-factor.setup.complete');
Route::post('two-factor-setup', [TwoFactorSettingsController::class, 'enable'])
->middleware('throttle:auth.two-factor')
->name('two-factor.setup.enable');
Route::post('two-factor-setup/confirm', [TwoFactorSettingsController::class, 'confirm'])
->middleware('throttle:auth.two-factor')
->name('two-factor.setup.confirm');
Route::post('profile/two-factor', [TwoFactorSettingsController::class, 'enable']) Route::post('profile/two-factor', [TwoFactorSettingsController::class, 'enable'])
->middleware('throttle:5,1') ->middleware('throttle:auth.two-factor')
->name('profile.two-factor.enable'); ->name('profile.two-factor.enable');
Route::post('profile/two-factor/confirm', [TwoFactorSettingsController::class, 'confirm']) Route::post('profile/two-factor/confirm', [TwoFactorSettingsController::class, 'confirm'])
->middleware('throttle:5,1') ->middleware('throttle:auth.two-factor')
->name('profile.two-factor.confirm'); ->name('profile.two-factor.confirm');
Route::delete('profile/two-factor', [TwoFactorSettingsController::class, 'disable']) Route::delete('profile/two-factor', [TwoFactorSettingsController::class, 'disable'])
->middleware('throttle:5,1') ->middleware('throttle:auth.two-factor')
->name('profile.two-factor.disable'); ->name('profile.two-factor.disable');
Route::post('profile/two-factor/recovery-codes', [TwoFactorSettingsController::class, 'recoveryCodes']) Route::post('profile/two-factor/recovery-codes', [TwoFactorSettingsController::class, 'recoveryCodes'])
->middleware('throttle:5,1') ->middleware('throttle:auth.two-factor')
->name('profile.two-factor.recovery-codes'); ->name('profile.two-factor.recovery-codes');
}); });

View File

@@ -0,0 +1,226 @@
<?php
use App\Models\User;
use App\Notifications\PasswordChangedNotification;
use App\Notifications\QueuedResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'auth-ui.features.email_verification' => false,
'auth-ui.features.two_factor_required' => false,
]);
});
it('returns the same password-reset response for existing and unknown accounts', function () {
Notification::fake();
$user = User::factory()->create(['email' => 'known@example.com']);
$expectedMessage = 'If an account exists for that email address, a password reset link has been sent.';
$this->post('/forgot-password', ['email' => 'known@example.com'])
->assertSessionHas('status', $expectedMessage)
->assertSessionDoesntHaveErrors();
$this->withServerVariables(['REMOTE_ADDR' => '192.0.2.2'])
->post('/forgot-password', ['email' => 'unknown@example.com'])
->assertSessionHas('status', $expectedMessage)
->assertSessionDoesntHaveErrors();
Notification::assertSentTo($user, QueuedResetPasswordNotification::class);
});
it('invalidates existing sessions and notifies the user after password reset', function () {
Notification::fake();
config(['session.driver' => 'database']);
$user = User::factory()->create(['email' => 'reset@example.com']);
DB::table('sessions')->insert([
[
'id' => 'existing-session-one',
'user_id' => $user->id,
'ip_address' => '192.0.2.10',
'user_agent' => 'Test',
'payload' => 'payload',
'last_activity' => now()->timestamp,
],
[
'id' => 'existing-session-two',
'user_id' => $user->id,
'ip_address' => '192.0.2.11',
'user_agent' => 'Test',
'payload' => 'payload',
'last_activity' => now()->timestamp,
],
]);
$token = Password::createToken($user);
$this->post('/reset-password', [
'token' => $token,
'email' => $user->email,
'password' => 'a completely new secure password',
'password_confirmation' => 'a completely new secure password',
])->assertRedirect('/login');
$user->refresh();
expect(DB::table('sessions')->where('user_id', $user->id)->count())->toBe(0)
->and(Hash::check('a completely new secure password', $user->password))->toBeTrue()
->and($user->auth_session_version)->toBe(1);
Notification::assertSentTo($user, PasswordChangedNotification::class);
});
it('stores new passwords with Argon2id', function () {
$this->post('/register', [
'username' => 'argon-user',
'first_name' => 'Argon',
'last_name' => 'User',
'email' => 'argon@example.com',
'password' => 'correct horse battery staple',
'password_confirmation' => 'correct horse battery staple',
])->assertRedirect('/dashboard');
$user = User::where('email', 'argon@example.com')->firstOrFail();
expect(password_get_info($user->password)['algoName'])->toBe('argon2id');
});
it('rejects new passwords shorter than fifteen characters', function () {
$this->post('/register', [
'username' => 'short-password',
'first_name' => 'Short',
'last_name' => 'Password',
'email' => 'short@example.com',
'password' => 'too-short',
'password_confirmation' => 'too-short',
])->assertSessionHasErrors('password');
$this->assertDatabaseMissing('users', ['email' => 'short@example.com']);
});
it('rejects password hashes created with an unexpected algorithm', function () {
$user = User::factory()->create(['email' => 'unexpected-hash@example.com']);
DB::table('users')->where('id', $user->id)->update([
'password' => password_hash('correct-password', PASSWORD_BCRYPT, ['cost' => 4]),
]);
$this->post('/login', [
'login' => 'unexpected-hash@example.com',
'password' => 'correct-password',
])->assertSessionHasErrors('login');
$this->assertGuest();
expect(password_get_info($user->fresh()->password)['algoName'])->toBe('bcrypt');
});
it('enforces account-aware login throttling across different IP addresses', function () {
User::factory()->create([
'email' => 'limited@example.com',
'password' => 'correct-password',
]);
foreach (range(1, 10) as $attempt) {
$this->withServerVariables(['REMOTE_ADDR' => "192.0.2.{$attempt}"])
->post('/login', [
'login' => 'limited@example.com',
'password' => 'wrong-password',
])
->assertSessionHasErrors('login');
}
$this->withServerVariables(['REMOTE_ADDR' => '192.0.2.11'])
->post('/login', [
'login' => 'limited@example.com',
'password' => 'wrong-password',
])
->assertTooManyRequests();
});
it('adds browser security headers and prevents caching secret-bearing pages', function () {
$this->get('/')
->assertHeader('X-Content-Type-Options', 'nosniff')
->assertHeader('X-Frame-Options', 'DENY')
->assertHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
->assertHeader('Permissions-Policy');
$response = $this->get('/login')
->assertHeader('Cache-Control')
->assertHeader('Pragma', 'no-cache');
expect($response->headers->get('Cache-Control'))->toContain('no-store');
});
it('adds CSP and HSTS to secure production responses', function () {
$previousEnvironment = $this->app->environment();
$this->app->instance('env', 'production');
try {
$this->withServerVariables([
'HTTPS' => 'on',
'REQUEST_SCHEME' => 'https',
'SERVER_PORT' => 443,
])
->get('https://localhost/')
->assertHeader('Content-Security-Policy')
->assertHeader(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
);
} finally {
$this->app->instance('env', $previousEnvironment);
}
});
it('ends an authenticated session at its absolute expiry', function () {
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.absolute_expires_at' => now()->subSecond()->timestamp])
->get('/dashboard')
->assertRedirect('/login')
->assertSessionHas('error', 'Your session has expired. Please sign in again.');
$this->assertGuest();
});
it('records the session version and absolute expiry after login', function () {
$user = User::factory()->create([
'email' => 'session-version@example.com',
'password' => 'correct horse battery staple',
'auth_session_version' => 3,
]);
$response = $this->post('/login', [
'login' => $user->email,
'password' => 'correct horse battery staple',
])->assertRedirect('/dashboard')
->assertSessionHas('auth.session_version', 3);
expect($response->getSession()->get('auth.absolute_expires_at'))
->toBeGreaterThan(now()->timestamp);
});
it('revokes an authenticated session when its user session version is stale', function () {
$user = User::factory()->create(['auth_session_version' => 2]);
$this->actingAs($user)
->withSession([
'auth.session_version' => 1,
'auth.absolute_expires_at' => now()->addHour()->timestamp,
])
->get('/dashboard')
->assertRedirect('/login')
->assertSessionHas(
'error',
'Your session is no longer valid. Please sign in again.'
);
$this->assertGuest();
});

View File

@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\URL;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () {
config(['auth-ui.features.two_factor_required' => false]);
});
it('does not send verification email when feature is disabled', function () { it('does not send verification email when feature is disabled', function () {
config(['auth-ui.features.email_verification' => false]); config(['auth-ui.features.email_verification' => false]);
@@ -18,8 +22,8 @@ it('does not send verification email when feature is disabled', function () {
'first_name' => 'Test', 'first_name' => 'Test',
'last_name' => 'User', 'last_name' => 'User',
'email' => 'test@example.com', 'email' => 'test@example.com',
'password' => 'password123', 'password' => 'correct horse battery staple',
'password_confirmation' => 'password123', 'password_confirmation' => 'correct horse battery staple',
]); ]);
Notification::assertNothingSent(); Notification::assertNothingSent();
@@ -35,8 +39,8 @@ it('sends verification email when feature is enabled', function () {
'first_name' => 'Test', 'first_name' => 'Test',
'last_name' => 'User', 'last_name' => 'User',
'email' => 'test@example.com', 'email' => 'test@example.com',
'password' => 'password123', 'password' => 'correct horse battery staple',
'password_confirmation' => 'password123', 'password_confirmation' => 'correct horse battery staple',
]); ]);
$user = User::where('email', 'test@example.com')->first(); $user = User::where('email', 'test@example.com')->first();
@@ -135,8 +139,8 @@ it('redirects to verification notice after registration when feature is enabled'
'first_name' => 'New', 'first_name' => 'New',
'last_name' => 'User', 'last_name' => 'User',
'email' => 'new@example.com', 'email' => 'new@example.com',
'password' => 'password123', 'password' => 'correct horse battery staple',
'password_confirmation' => 'password123', 'password_confirmation' => 'correct horse battery staple',
])->assertRedirect('/email/verify'); ])->assertRedirect('/email/verify');
}); });
@@ -148,7 +152,7 @@ it('redirects to home after registration when feature is disabled', function ()
'first_name' => 'New', 'first_name' => 'New',
'last_name' => 'User', 'last_name' => 'User',
'email' => 'new@example.com', 'email' => 'new@example.com',
'password' => 'password123', 'password' => 'correct horse battery staple',
'password_confirmation' => 'password123', 'password_confirmation' => 'correct horse battery staple',
])->assertRedirect('/dashboard'); ])->assertRedirect('/dashboard');
}); });

View File

@@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () {
config(['auth-ui.features.two_factor_required' => false]);
});
it('allows login with email and password', function () { it('allows login with email and password', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'email' => 'test@example.com', 'email' => 'test@example.com',

View File

@@ -9,6 +9,7 @@ use Laravel\Socialite\Two\User as SocialiteUser;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
config(['auth-ui.features.two_factor_required' => false]);
config(['auth-ui.providers.github' => [ config(['auth-ui.providers.github' => [
'label' => 'GitHub', 'label' => 'GitHub',
'icon' => 'i-simple-icons-github', 'icon' => 'i-simple-icons-github',

View File

@@ -1,8 +1,11 @@
<?php <?php
use App\Models\User; use App\Models\User;
use App\Notifications\TwoFactorFailedNotification;
use App\Notifications\TwoFactorSecurityNotification;
use Database\Seeders\DatabaseSeeder; use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Inertia\Testing\AssertableInertia as Assert; use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication; use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features; use Laravel\Fortify\Features;
@@ -18,7 +21,11 @@ beforeEach(function () {
'auth-ui.features.two_factor' => true, 'auth-ui.features.two_factor' => true,
'auth-ui.features.two_factor_required' => false, 'auth-ui.features.two_factor_required' => false,
'fortify.features' => [Features::twoFactorAuthentication()], 'fortify.features' => [Features::twoFactorAuthentication()],
'fortify-options.two-factor-authentication' => ['confirm' => true], 'fortify-options.two-factor-authentication' => [
'confirm' => true,
'secret-length' => 32,
'window' => 0,
],
'auth-ui.providers.github' => [ 'auth-ui.providers.github' => [
'label' => 'GitHub', 'label' => 'GitHub',
'icon' => 'i-simple-icons-github', 'icon' => 'i-simple-icons-github',
@@ -81,12 +88,19 @@ it('redirects authenticated users to enrollment when two-factor setup is mandato
$this->actingAs($user) $this->actingAs($user)
->get('/dashboard') ->get('/dashboard')
->assertRedirect('/profile') ->assertRedirect('/two-factor-setup')
->assertSessionHas('error', 'Two-factor authentication is required before you can continue.'); ->assertSessionHas('error', 'Two-factor authentication is required before you can continue.');
$this->actingAs($user)
->get('/two-factor-setup')
->assertInertia(fn (Assert $page) => $page
->component('Auth/TwoFactorSetup')
->where('twoFactor.enabled', false)
->where('twoFactor.pending', false));
$this->actingAs($user) $this->actingAs($user)
->get('/profile') ->get('/profile')
->assertOk(); ->assertRedirect('/two-factor-setup');
$this->actingAs($user) $this->actingAs($user)
->post('/logout') ->post('/logout')
@@ -94,20 +108,63 @@ it('redirects authenticated users to enrollment when two-factor setup is mandato
$this->assertGuest(); $this->assertGuest();
}); });
it('sends a password user directly to setup after login when enrollment is mandatory', function () {
config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create([
'email' => 'setup@example.com',
'password' => 'password123',
]);
$this->post('/login', [
'login' => 'setup@example.com',
'password' => 'password123',
])->assertRedirect('/two-factor-setup');
$this->assertAuthenticatedAs($user);
expect(session('url.intended'))->toEndWith('/dashboard');
});
it('keeps application access restricted while mandatory enrollment is pending', function () { it('keeps application access restricted while mandatory enrollment is pending', function () {
config(['auth-ui.features.two_factor_required' => true]); config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create(['password' => 'password123']); $user = User::factory()->create(['password' => 'password123']);
$this->actingAs($user) $this->actingAs($user)
->post('/profile/two-factor', ['password' => 'password123']) ->post('/two-factor-setup', ['password' => 'password123'])
->assertRedirect('/profile'); ->assertRedirect('/two-factor-setup');
expect($user->fresh()->two_factor_secret)->not->toBeNull() expect($user->fresh()->two_factor_secret)->not->toBeNull()
->and($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); ->and($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
$this->actingAs($user) $this->actingAs($user)
->get('/dashboard') ->get('/dashboard')
->assertRedirect('/profile'); ->assertRedirect('/two-factor-setup');
});
it('shows recovery codes before continuing after mandatory setup', function () {
config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create();
app(EnableTwoFactorAuthentication::class)($user);
$user->refresh();
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
$code = app(Google2FA::class)->getCurrentOtp($secret);
$this->actingAs($user)
->withSession(['url.intended' => '/dashboard'])
->post('/two-factor-setup/confirm', ['code' => $code])
->assertRedirect('/two-factor-setup')
->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8);
$this->actingAs($user->fresh())
->get('/two-factor-setup')
->assertInertia(fn (Assert $page) => $page
->component('Auth/TwoFactorSetup')
->where('twoFactor.enabled', true));
$this->actingAs($user->fresh())
->withSession(['url.intended' => '/dashboard'])
->get('/two-factor-setup/complete')
->assertRedirect('/dashboard');
}); });
it('allows normal access after mandatory enrollment is complete', function () { it('allows normal access after mandatory enrollment is complete', function () {
@@ -120,6 +177,21 @@ it('allows normal access after mandatory enrollment is complete', function () {
->assertOk(); ->assertOk();
}); });
it('does not allow mandatory two-factor authentication to be disabled', function () {
config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create(['password' => 'password123']);
$twoFactor = enableTwoFactorForTest($user);
$this->actingAs($user)
->delete('/profile/two-factor', [
'password' => 'password123',
'code' => $twoFactor['code'],
])
->assertForbidden();
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
});
it('does not enforce the mandatory flag while the main two-factor feature is disabled', function () { it('does not enforce the mandatory flag while the main two-factor feature is disabled', function () {
config([ config([
'auth-ui.features.two_factor' => false, 'auth-ui.features.two_factor' => false,
@@ -131,6 +203,20 @@ it('does not enforce the mandatory flag while the main two-factor feature is dis
->assertOk(); ->assertOk();
}); });
it('does not expose mandatory setup endpoints when enrollment is optional', function () {
$user = User::factory()->create(['password' => 'password123']);
$this->actingAs($user)
->get('/two-factor-setup')
->assertNotFound();
$this->actingAs($user)
->post('/two-factor-setup', ['password' => 'password123'])
->assertNotFound();
expect($user->fresh()->two_factor_secret)->toBeNull();
});
it('blocks authenticated API requests until mandatory enrollment is complete', function () { it('blocks authenticated API requests until mandatory enrollment is complete', function () {
config(['auth-ui.features.two_factor_required' => true]); config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create(); $user = User::factory()->create();
@@ -140,7 +226,7 @@ it('blocks authenticated API requests until mandatory enrollment is complete', f
->assertForbidden() ->assertForbidden()
->assertJson([ ->assertJson([
'message' => 'Two-factor authentication setup is required.', 'message' => 'Two-factor authentication setup is required.',
'setup_url' => route('profile.show'), 'setup_url' => route('two-factor.setup'),
]); ]);
enableTwoFactorForTest($user); enableTwoFactorForTest($user);
@@ -195,6 +281,7 @@ it('confirms enrollment and returns recovery codes once', function () {
}); });
it('holds password login until a valid authenticator code is supplied', function () { it('holds password login until a valid authenticator code is supplied', function () {
Notification::fake();
$user = User::factory()->create([ $user = User::factory()->create([
'email' => 'test@example.com', 'email' => 'test@example.com',
'password' => 'password123', 'password' => 'password123',
@@ -216,6 +303,7 @@ it('holds password login until a valid authenticator code is supplied', function
$this->post('/two-factor-challenge', ['code' => '000000']) $this->post('/two-factor-challenge', ['code' => '000000'])
->assertSessionHasErrors('code'); ->assertSessionHasErrors('code');
$this->assertGuest(); $this->assertGuest();
Notification::assertSentTo($user, TwoFactorFailedNotification::class);
$challengeResponse = $this->post('/two-factor-challenge', ['code' => $twoFactor['code']]); $challengeResponse = $this->post('/two-factor-challenge', ['code' => $twoFactor['code']]);
$challengeResponse->assertRedirect('/dashboard'); $challengeResponse->assertRedirect('/dashboard');
@@ -283,6 +371,94 @@ it('requires a valid second factor for social-only users to disable protection',
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
}); });
it('requires both password and second factor for password users to disable protection', function () {
Notification::fake();
$user = User::factory()->create(['password' => 'password123']);
$twoFactor = enableTwoFactorForTest($user);
$this->actingAs($user)
->delete('/profile/two-factor', ['password' => 'password123'])
->assertSessionHasErrors('code');
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
$this->actingAs($user)
->delete('/profile/two-factor', [
'password' => 'password123',
'code' => $twoFactor['code'],
])
->assertRedirect('/profile');
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
Notification::assertSentTo($user, TwoFactorSecurityNotification::class);
});
it('requires both password and second factor to reveal recovery codes', function () {
$user = User::factory()->create(['password' => 'password123']);
$twoFactor = enableTwoFactorForTest($user);
$this->actingAs($user)
->post('/profile/two-factor/recovery-codes', [
'password' => 'password123',
'regenerate' => false,
])
->assertSessionHasErrors('code');
$this->actingAs($user)
->post('/profile/two-factor/recovery-codes', [
'password' => 'password123',
'code' => $twoFactor['code'],
'regenerate' => false,
])
->assertRedirect('/profile')
->assertSessionHas('recoveryCodes');
});
it('expires an unfinished second-factor login after five minutes', function () {
$user = User::factory()->create([
'email' => 'expires@example.com',
'password' => 'password123',
]);
enableTwoFactorForTest($user);
$this->post('/login', [
'login' => 'expires@example.com',
'password' => 'password123',
])->assertRedirect('/two-factor-challenge');
$this->travel(301)->seconds();
$this->get('/two-factor-challenge')
->assertRedirect('/login')
->assertSessionMissing('login.id')
->assertSessionMissing('login.started_at');
});
it('does not accept the same TOTP twice', function () {
$user = User::factory()->create([
'email' => 'replay@example.com',
'password' => 'password123',
]);
$twoFactor = enableTwoFactorForTest($user);
$this->post('/login', [
'login' => 'replay@example.com',
'password' => 'password123',
])->assertRedirect('/two-factor-challenge');
$this->post('/two-factor-challenge', ['code' => $twoFactor['code']])
->assertRedirect('/dashboard');
$this->post('/logout');
$this->post('/login', [
'login' => 'replay@example.com',
'password' => 'password123',
])->assertRedirect('/two-factor-challenge');
$this->post('/two-factor-challenge', ['code' => $twoFactor['code']])
->assertSessionHasErrors('code');
$this->assertGuest();
});
it('consumes recovery codes used to authorize a sensitive settings action', function () { it('consumes recovery codes used to authorize a sensitive settings action', function () {
$user = User::factory()->social()->create(); $user = User::factory()->social()->create();
$twoFactor = enableTwoFactorForTest($user); $twoFactor = enableTwoFactorForTest($user);

View File

@@ -1,5 +1,7 @@
<?php <?php
use Tests\TestCase;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Test Case | Test Case
@@ -11,7 +13,7 @@
| |
*/ */
pest()->extend(Tests\TestCase::class) pest()->extend(TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature'); ->in('Feature');

View File

@@ -15,7 +15,7 @@ export default defineConfig({
}, },
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './resources/js'), '@': resolve(import.meta.dirname, './resources/js'),
}, },
}, },
}) })