feat(auth): harden authentication and add configurable two-factor support
This commit is contained in:
13
.env.example
13
.env.example
@@ -13,7 +13,11 @@ APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
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_STACK=single
|
||||
@@ -30,6 +34,9 @@ DB_PASSWORD=password
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_SECURE_COOKIE=false
|
||||
SESSION_HTTP_ONLY=true
|
||||
SESSION_SAME_SITE=lax
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
@@ -69,6 +76,10 @@ AUTH_ENABLE_REMEMBER_ME=true
|
||||
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
||||
AUTH_ENABLE_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_REDIRECT_LOGIN=/dashboard
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\CompleteProfileRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -33,8 +35,11 @@ class CompleteProfileController extends Controller
|
||||
/**
|
||||
* Handle the complete profile request.
|
||||
*/
|
||||
public function store(CompleteProfileRequest $request): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
CompleteProfileRequest $request,
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$socialiteUser = session('socialite_user');
|
||||
$validated = $request->validated();
|
||||
|
||||
@@ -58,6 +63,16 @@ class CompleteProfileController extends Controller
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.social_succeeded', $user, $request);
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.login', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ForgotPasswordRequest;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Inertia\Inertia;
|
||||
@@ -26,16 +27,20 @@ class ForgotPasswordController extends Controller
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*/
|
||||
public function store(ForgotPasswordRequest $request): RedirectResponse
|
||||
{
|
||||
$status = Password::sendResetLink(
|
||||
public function store(
|
||||
ForgotPasswordRequest $request,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
Password::sendResetLink(
|
||||
$request->validated()
|
||||
);
|
||||
$securityEvents->record('password_reset.requested', null, $request, [
|
||||
'email_hash' => hash('sha256', strtolower($request->validated('email'))),
|
||||
]);
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
return back()->with('status', __($status));
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => __($status)]);
|
||||
return back()->with(
|
||||
'status',
|
||||
'If an account exists for that email address, a password reset link has been sent.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
@@ -26,8 +30,11 @@ class LoginController extends Controller
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request, TwoFactorChallenge $twoFactor): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
LoginRequest $request,
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$login = $request->validated('login');
|
||||
$password = $request->validated('password');
|
||||
|
||||
@@ -46,7 +53,19 @@ class LoginController extends Controller
|
||||
? $provider->retrieveByCredentials($credentials)
|
||||
: null;
|
||||
|
||||
if (! $user || ! $provider->validateCredentials($user, $credentials)) {
|
||||
try {
|
||||
$credentialsAreValid = $user
|
||||
? $provider->validateCredentials($user, $credentials)
|
||||
: Hash::check($password, $this->dummyPasswordHash());
|
||||
} catch (RuntimeException) {
|
||||
$credentialsAreValid = false;
|
||||
}
|
||||
|
||||
if (! $user || ! $credentialsAreValid) {
|
||||
$securityEvents->record('login.password_failed', $user, $request, [
|
||||
'login_hash' => hash('sha256', strtolower($login)),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'login' => __('auth.failed'),
|
||||
]);
|
||||
@@ -58,11 +77,23 @@ class LoginController extends Controller
|
||||
}
|
||||
|
||||
if ($twoFactor->requiredFor($user)) {
|
||||
$securityEvents->record('login.password_succeeded_pending_two_factor', $user, $request);
|
||||
|
||||
return $twoFactor->begin($request, $user, $remember);
|
||||
}
|
||||
|
||||
Auth::login($user, $remember);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.succeeded', $user, $request);
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.login', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
@@ -72,6 +103,10 @@ class LoginController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
app(SecurityEventRecorder::class)->record('logout.succeeded', $user, $request);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
@@ -79,4 +114,11 @@ class LoginController extends Controller
|
||||
|
||||
return redirect(config('auth-ui.redirects.logout', '/'));
|
||||
}
|
||||
|
||||
private function dummyPasswordHash(): string
|
||||
{
|
||||
static $hash;
|
||||
|
||||
return $hash ??= Hash::make(Str::random(64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -28,8 +30,11 @@ class RegisterController extends Controller
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function store(RegisterRequest $request): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
RegisterRequest $request,
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$validated = $request->validated();
|
||||
|
||||
$user = User::create([
|
||||
@@ -43,11 +48,22 @@ class RegisterController extends Controller
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('registration.completed', $user, $request);
|
||||
|
||||
if (config('auth-ui.features.email_verification')) {
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.register', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect(config('auth-ui.redirects.register', '/'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ResetPasswordRequest;
|
||||
use App\Models\User;
|
||||
use App\Notifications\PasswordChangedNotification;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
@@ -32,21 +36,39 @@ class ResetPasswordController extends Controller
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*/
|
||||
public function store(ResetPasswordRequest $request): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
ResetPasswordRequest $request,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$resetUser = null;
|
||||
$status = Password::reset(
|
||||
$request->validated(),
|
||||
function ($user, string $password): void {
|
||||
function (User $user, string $password) use ($request, &$resetUser): void {
|
||||
$user->forceFill([
|
||||
'password' => $password,
|
||||
'remember_token' => Str::random(60),
|
||||
'auth_session_version' => $user->auth_session_version + 1,
|
||||
])->save();
|
||||
|
||||
if (config('session.driver') === 'database') {
|
||||
DB::table(config('session.table'))
|
||||
->where('user_id', $user->getKey())
|
||||
->delete();
|
||||
}
|
||||
|
||||
$user->notify(new PasswordChangedNotification(
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$resetUser = $user;
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
$securityEvents->record('password_reset.completed', $resetUser, $request);
|
||||
|
||||
return redirect()->route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -54,7 +55,8 @@ class SocialiteController extends Controller
|
||||
public function callback(
|
||||
Request $request,
|
||||
string $provider,
|
||||
TwoFactorChallenge $twoFactor
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
if (! in_array($provider, $this->getEnabledProviders())) {
|
||||
abort(404, 'Provider not enabled');
|
||||
@@ -74,7 +76,12 @@ class SocialiteController extends Controller
|
||||
->first();
|
||||
|
||||
if ($socialAccount) {
|
||||
return $this->finishAuthentication($request, $socialAccount->user, $twoFactor);
|
||||
return $this->finishAuthentication(
|
||||
$request,
|
||||
$socialAccount->user,
|
||||
$twoFactor,
|
||||
$securityEvents
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a user with this email already exists
|
||||
@@ -92,7 +99,12 @@ class SocialiteController extends Controller
|
||||
'provider_id' => $socialUser->getId(),
|
||||
]);
|
||||
|
||||
return $this->finishAuthentication($request, $existingUser, $twoFactor);
|
||||
return $this->finishAuthentication(
|
||||
$request,
|
||||
$existingUser,
|
||||
$twoFactor,
|
||||
$securityEvents
|
||||
);
|
||||
}
|
||||
|
||||
// New user — check if registration is enabled
|
||||
@@ -134,20 +146,33 @@ class SocialiteController extends Controller
|
||||
'provider_id' => $socialUser->getId(),
|
||||
]);
|
||||
|
||||
return $this->finishAuthentication($request, $user, $twoFactor);
|
||||
return $this->finishAuthentication($request, $user, $twoFactor, $securityEvents);
|
||||
}
|
||||
|
||||
private function finishAuthentication(
|
||||
Request $request,
|
||||
User $user,
|
||||
TwoFactorChallenge $twoFactor
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
if ($twoFactor->requiredFor($user)) {
|
||||
$securityEvents->record('login.social_succeeded_pending_two_factor', $user, $request);
|
||||
|
||||
return $twoFactor->begin($request, $user, remember: true);
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.social_succeeded', $user, $request);
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.login', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Notifications\TwoFactorFailedNotification;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -19,7 +21,9 @@ class TwoFactorChallengeController extends Controller
|
||||
*/
|
||||
public function create(TwoFactorLoginRequest $request): Response|RedirectResponse
|
||||
{
|
||||
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) {
|
||||
if (! config('auth-ui.features.two_factor')
|
||||
|| ! $request->hasChallengedUser()
|
||||
|| $this->challengeHasExpired($request)) {
|
||||
$this->clearChallenge($request);
|
||||
|
||||
return redirect()->route('login');
|
||||
@@ -31,9 +35,13 @@ class TwoFactorChallengeController extends Controller
|
||||
/**
|
||||
* Complete the pending login using a TOTP or recovery code.
|
||||
*/
|
||||
public function store(TwoFactorLoginRequest $request): RedirectResponse
|
||||
{
|
||||
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) {
|
||||
public function store(
|
||||
TwoFactorLoginRequest $request,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
if (! config('auth-ui.features.two_factor')
|
||||
|| ! $request->hasChallengedUser()
|
||||
|| $this->challengeHasExpired($request)) {
|
||||
$this->clearChallenge($request);
|
||||
|
||||
return redirect()->route('login');
|
||||
@@ -51,6 +59,12 @@ class TwoFactorChallengeController extends Controller
|
||||
$user->replaceRecoveryCode($recoveryCode);
|
||||
} elseif (! $request->hasValidCode()) {
|
||||
event(new TwoFactorAuthenticationFailed($user));
|
||||
$user->notify(new TwoFactorFailedNotification(
|
||||
(string) $request->ip(),
|
||||
(string) $request->userAgent(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('login.two_factor_failed', $user, $request);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$request->filled('recovery_code') ? 'recovery_code' : 'code' => 'The provided authentication code was invalid.',
|
||||
@@ -61,12 +75,24 @@ class TwoFactorChallengeController extends Controller
|
||||
|
||||
Auth::login($user, $request->remember());
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.two_factor_succeeded', $user, $request, [
|
||||
'used_recovery_code' => (bool) $recoveryCode,
|
||||
]);
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/dashboard'));
|
||||
}
|
||||
|
||||
private function clearChallenge(TwoFactorLoginRequest $request): void
|
||||
{
|
||||
$request->session()->forget(['login.id', 'login.remember']);
|
||||
$request->session()->forget(['login.id', 'login.remember', 'login.started_at']);
|
||||
}
|
||||
|
||||
private function challengeHasExpired(TwoFactorLoginRequest $request): bool
|
||||
{
|
||||
$startedAt = $request->session()->get('login.started_at');
|
||||
|
||||
return ! is_int($startedAt)
|
||||
|| now()->timestamp - $startedAt
|
||||
> config('auth-ui.security.two_factor_challenge_timeout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TwoFactorSecurityNotification;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -21,19 +23,19 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse
|
||||
{
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
return redirect()->route('profile.show');
|
||||
return $this->enrollmentRedirect($request);
|
||||
}
|
||||
|
||||
$this->validatePasswordForPasswordUser($request, $user);
|
||||
$enable($user, force: true);
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
return $this->enrollmentRedirect($request)
|
||||
->with('success', 'Scan the QR code, then enter a code to finish enabling two-factor authentication.');
|
||||
}
|
||||
|
||||
@@ -42,9 +44,10 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function confirm(
|
||||
Request $request,
|
||||
ConfirmTwoFactorAuthentication $confirm
|
||||
ConfirmTwoFactorAuthentication $confirm,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
@@ -53,8 +56,18 @@ class TwoFactorSettingsController extends Controller
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$confirm($user, $validated['code']);
|
||||
$user->notify(new TwoFactorSecurityNotification(
|
||||
'Two-factor authentication was enabled for your account.',
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('two_factor.enabled', $user, $request);
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
$redirect = $request->routeIs('two-factor.setup.confirm')
|
||||
? redirect()->route('two-factor.setup')
|
||||
: redirect()->route('profile.show');
|
||||
|
||||
return $redirect
|
||||
->with('success', 'Two-factor authentication is now enabled.')
|
||||
->with('recoveryCodes', $user->fresh()->recoveryCodes());
|
||||
}
|
||||
@@ -64,9 +77,15 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function disable(
|
||||
Request $request,
|
||||
DisableTwoFactorAuthentication $disable
|
||||
DisableTwoFactorAuthentication $disable,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
abort_if(
|
||||
config('auth-ui.features.two_factor_required'),
|
||||
403,
|
||||
'Two-factor authentication is required for this application.'
|
||||
);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
@@ -76,6 +95,12 @@ class TwoFactorSettingsController extends Controller
|
||||
}
|
||||
|
||||
$disable($user);
|
||||
$user->notify(new TwoFactorSecurityNotification(
|
||||
'Two-factor authentication was disabled for your account.',
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('two_factor.disabled', $user, $request);
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
->with('success', 'Two-factor authentication has been disabled.');
|
||||
@@ -86,9 +111,10 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function recoveryCodes(
|
||||
Request $request,
|
||||
GenerateNewRecoveryCodes $generate
|
||||
GenerateNewRecoveryCodes $generate,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
@@ -102,6 +128,14 @@ class TwoFactorSettingsController extends Controller
|
||||
if ($request->boolean('regenerate')) {
|
||||
$generate($user);
|
||||
$user->refresh();
|
||||
$user->notify(new TwoFactorSecurityNotification(
|
||||
'New two-factor authentication recovery codes were generated for your account.',
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('two_factor.recovery_codes_regenerated', $user, $request);
|
||||
} else {
|
||||
$securityEvents->record('two_factor.recovery_codes_revealed', $user, $request);
|
||||
}
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
@@ -111,9 +145,20 @@ class TwoFactorSettingsController extends Controller
|
||||
->with('recoveryCodes', $user->recoveryCodes());
|
||||
}
|
||||
|
||||
private function ensureFeatureIsEnabled(): void
|
||||
private function ensureFeatureIsEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(config('auth-ui.features.two_factor'), 404);
|
||||
|
||||
if ($request->routeIs('two-factor.setup.*')) {
|
||||
abort_unless(config('auth-ui.features.two_factor_required'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
private function enrollmentRedirect(Request $request): RedirectResponse
|
||||
{
|
||||
return $request->routeIs('two-factor.setup.*')
|
||||
? redirect()->route('two-factor.setup')
|
||||
: redirect()->route('profile.show');
|
||||
}
|
||||
|
||||
private function validatePasswordForPasswordUser(Request $request, User $user): void
|
||||
@@ -131,8 +176,6 @@ class TwoFactorSettingsController extends Controller
|
||||
{
|
||||
if ($user->hasPassword()) {
|
||||
$this->validatePasswordForPasswordUser($request, $user);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
||||
54
app/Http/Controllers/Auth/TwoFactorSetupController.php
Normal file
54
app/Http/Controllers/Auth/TwoFactorSetupController.php
Normal 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', '/'));
|
||||
}
|
||||
}
|
||||
@@ -3,38 +3,25 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TwoFactorEnrollmentState;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the authenticated user's profile and security settings.
|
||||
*/
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
TwoFactorEnrollmentState $enrollmentState
|
||||
): Response {
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$available = (bool) config('auth-ui.features.two_factor');
|
||||
$pending = $available
|
||||
&& filled($user->two_factor_secret)
|
||||
&& ! $user->hasEnabledTwoFactorAuthentication();
|
||||
|
||||
return Inertia::render('Profile/Show', [
|
||||
'twoFactor' => [
|
||||
'available' => $available,
|
||||
'enabled' => $available && $user->hasEnabledTwoFactorAuthentication(),
|
||||
'pending' => $pending,
|
||||
'requiresPassword' => $user->hasPassword(),
|
||||
'qrCodeDataUri' => $pending
|
||||
? 'data:image/svg+xml;base64,'.base64_encode($user->twoFactorQrCodeSvg())
|
||||
: null,
|
||||
'secretKey' => $pending
|
||||
? Fortify::currentEncrypter()->decrypt($user->two_factor_secret)
|
||||
: null,
|
||||
],
|
||||
'twoFactor' => $enrollmentState->for($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
58
app/Http/Middleware/AddSecurityHeaders.php
Normal file
58
app/Http/Middleware/AddSecurityHeaders.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
82
app/Http/Middleware/EnforceAbsoluteSessionTimeout.php
Normal file
82
app/Http/Middleware/EnforceAbsoluteSessionTimeout.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -24,18 +24,22 @@ class EnsureTwoFactorAuthenticationIsEnabled
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Two-factor authentication setup is required.',
|
||||
'setup_url' => route('profile.show'),
|
||||
'setup_url' => route('two-factor.setup'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
if ($request->isMethod('GET')) {
|
||||
$request->session()->put('url.intended', $request->fullUrl());
|
||||
}
|
||||
|
||||
return redirect()->route('two-factor.setup')
|
||||
->with('error', 'Two-factor authentication is required before you can continue.');
|
||||
}
|
||||
|
||||
private function isEnrollmentRoute(Request $request): bool
|
||||
{
|
||||
return $request->routeIs([
|
||||
'profile.*',
|
||||
'two-factor.setup*',
|
||||
'logout',
|
||||
'verification.*',
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Notifications\QueuedResetPasswordNotification;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -61,6 +62,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'auth_session_version' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,6 +84,15 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->password !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue password reset mail so the request does not reveal account
|
||||
* existence through mail-delivery timing.
|
||||
*/
|
||||
public function sendPasswordResetNotification($token): void
|
||||
{
|
||||
$this->notify(new QueuedResetPasswordNotification($token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's social accounts.
|
||||
*/
|
||||
|
||||
38
app/Notifications/PasswordChangedNotification.php
Normal file
38
app/Notifications/PasswordChangedNotification.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
12
app/Notifications/QueuedResetPasswordNotification.php
Normal file
12
app/Notifications/QueuedResetPasswordNotification.php
Normal 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;
|
||||
}
|
||||
40
app/Notifications/TwoFactorFailedNotification.php
Normal file
40
app/Notifications/TwoFactorFailedNotification.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
39
app/Notifications/TwoFactorSecurityNotification.php
Normal file
39
app/Notifications/TwoFactorSecurityNotification.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -22,6 +29,73 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Event::listen(Login::class, function (Login $event): void {
|
||||
if (request()->hasSession()) {
|
||||
request()->session()->put([
|
||||
'auth.session_version' => (int) $event->user->auth_session_version,
|
||||
'auth.absolute_expires_at' => now()->timestamp
|
||||
+ config('auth-ui.security.absolute_session_lifetime'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
Password::defaults(function (): Password {
|
||||
return Password::min(15)->max(128);
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.login', function (Request $request): array {
|
||||
$login = Str::lower((string) $request->input('login'));
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('login-ip:'.$request->ip()),
|
||||
Limit::perMinutes(10, 10)->by('login-account:'.hash('sha256', $login)),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.two-factor', function (Request $request): array {
|
||||
$userKey = $request->user()?->getAuthIdentifier()
|
||||
?? $request->session()->get('login.id')
|
||||
?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('two-factor-ip:'.$request->ip()),
|
||||
Limit::perMinutes(5, 5)->by('two-factor-account:'.$userKey),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.password-email', function (Request $request): array {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return [
|
||||
Limit::perMinutes(15, 20)->by('password-email-ip:'.$request->ip()),
|
||||
Limit::perMinutes(15, 3)->by('password-email-account:'.hash('sha256', $email)),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.password-reset', function (Request $request): array {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return [
|
||||
Limit::perMinutes(15, 20)->by('password-reset-ip:'.$request->ip()),
|
||||
Limit::perMinutes(15, 5)->by('password-reset-account:'.hash('sha256', $email)),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.register', fn (Request $request): array => [
|
||||
Limit::perHour(20)->by('register-ip:'.$request->ip()),
|
||||
]);
|
||||
|
||||
RateLimiter::for('auth.social', fn (Request $request): array => [
|
||||
Limit::perMinute(20)->by('social-ip:'.$request->ip()),
|
||||
]);
|
||||
|
||||
RateLimiter::for('auth.account', function (Request $request): array {
|
||||
$userKey = $request->user()?->getAuthIdentifier() ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('account-action-ip:'.$request->ip()),
|
||||
Limit::perMinutes(5, 6)->by('account-action-user:'.$userKey),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Services/Auth/SecurityEventRecorder.php
Normal file
34
app/Services/Auth/SecurityEventRecorder.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,16 @@ use Illuminate\Http\Request;
|
||||
|
||||
class TwoFactorChallenge
|
||||
{
|
||||
/**
|
||||
* Determine whether the user must enroll in two-factor authentication.
|
||||
*/
|
||||
public function enrollmentRequiredFor(User $user): bool
|
||||
{
|
||||
return (bool) config('auth-ui.features.two_factor')
|
||||
&& (bool) config('auth-ui.features.two_factor_required')
|
||||
&& ! $user->hasEnabledTwoFactorAuthentication();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user must complete a two-factor challenge.
|
||||
*/
|
||||
@@ -25,6 +35,7 @@ class TwoFactorChallenge
|
||||
$request->session()->put([
|
||||
'login.id' => $user->getKey(),
|
||||
'login.remember' => $remember,
|
||||
'login.started_at' => now()->timestamp,
|
||||
]);
|
||||
$request->session()->regenerate();
|
||||
|
||||
|
||||
43
app/Services/Auth/TwoFactorEnrollmentState.php
Normal file
43
app/Services/Auth/TwoFactorEnrollmentState.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\AddSecurityHeaders;
|
||||
use App\Http\Middleware\EnforceAbsoluteSessionTimeout;
|
||||
use App\Http\Middleware\EnsureTwoFactorAuthenticationIsEnabled;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Foundation\Application;
|
||||
@@ -19,7 +21,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
},
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->append(AddSecurityHeaders::class);
|
||||
|
||||
$middleware->web(append: [
|
||||
EnforceAbsoluteSessionTimeout::class,
|
||||
HandleInertiaRequests::class,
|
||||
EnsureTwoFactorAuthenticationIsEnabled::class,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"laravel/sail": "^1.53",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.2"
|
||||
"pestphp/pest": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
||||
1225
composer.lock
generated
1225
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,12 @@ return [
|
||||
'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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
@@ -62,7 +64,7 @@ return [
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
@@ -59,7 +60,7 @@ return [
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'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,
|
||||
'engine' => null,
|
||||
'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'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ return [
|
||||
? [
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'secret-length' => 32,
|
||||
'window' => 0,
|
||||
]),
|
||||
]
|
||||
: [],
|
||||
|
||||
30
config/hashing.php
Normal file
30
config/hashing.php
Normal 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,
|
||||
];
|
||||
@@ -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),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -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
1249
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -22,13 +22,19 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^10.7.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"eslint": "^10.8.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"laravel-vite-plugin": "^3.1.3",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.5",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"@vue/test-utils": {
|
||||
"js-beautify": "2.0.3"
|
||||
},
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,11 @@
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<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="CACHE_STORE" value="array"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
|
||||
@@ -26,7 +26,7 @@ function submit() {
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<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">
|
||||
Two-factor authentication
|
||||
</h1>
|
||||
|
||||
222
resources/js/Pages/Auth/TwoFactorSetup.vue
Normal file
222
resources/js/Pages/Auth/TwoFactorSetup.vue
Normal 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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useForm } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import RecoveryCodesPanel from '@/components/auth/RecoveryCodesPanel.vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
|
||||
@@ -235,19 +236,24 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</p>
|
||||
</div>
|
||||
<UFormField
|
||||
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
|
||||
:name="twoFactor.requiresPassword ? 'password' : 'code'"
|
||||
:error="recoveryForm.errors.password || recoveryForm.errors.code"
|
||||
v-if="twoFactor.requiresPassword"
|
||||
label="Current password"
|
||||
name="password"
|
||||
:error="recoveryForm.errors.password"
|
||||
>
|
||||
<UInput
|
||||
v-if="twoFactor.requiresPassword"
|
||||
v-model="recoveryForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Authentication or recovery code"
|
||||
name="code"
|
||||
:error="recoveryForm.errors.code"
|
||||
>
|
||||
<UInput
|
||||
v-else
|
||||
v-model="recoveryForm.code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full"
|
||||
@@ -272,7 +278,16 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</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>
|
||||
<h3 class="font-medium text-error">
|
||||
Disable two-factor authentication
|
||||
@@ -282,19 +297,24 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</p>
|
||||
</div>
|
||||
<UFormField
|
||||
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
|
||||
:name="twoFactor.requiresPassword ? 'password' : 'code'"
|
||||
:error="disableForm.errors.password || disableForm.errors.code"
|
||||
v-if="twoFactor.requiresPassword"
|
||||
label="Current password"
|
||||
name="password"
|
||||
:error="disableForm.errors.password"
|
||||
>
|
||||
<UInput
|
||||
v-if="twoFactor.requiresPassword"
|
||||
v-model="disableForm.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField
|
||||
label="Authentication or recovery code"
|
||||
name="code"
|
||||
:error="disableForm.errors.code"
|
||||
>
|
||||
<UInput
|
||||
v-else
|
||||
v-model="disableForm.code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full"
|
||||
@@ -318,13 +338,7 @@ function recoveryCodesAction(regenerate: boolean) {
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid gap-2 sm:grid-cols-2">
|
||||
<code
|
||||
v-for="code in recoveryCodes"
|
||||
:key="code"
|
||||
class="rounded bg-elevated px-3 py-2 text-sm"
|
||||
>{{ code }}</code>
|
||||
</div>
|
||||
<RecoveryCodesPanel :codes="recoveryCodes" />
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
78
resources/js/components/auth/RecoveryCodesPanel.vue
Normal file
78
resources/js/components/auth/RecoveryCodesPanel.vue
Normal 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>
|
||||
@@ -40,8 +40,8 @@ describe('registerSchema', () => {
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123',
|
||||
password: 'correct horse battery staple',
|
||||
password_confirmation: 'correct horse battery staple',
|
||||
}
|
||||
|
||||
it('accepts valid registration data', () => {
|
||||
@@ -69,8 +69,8 @@ describe('registerSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters', () => {
|
||||
const result = validate(registerSchema, { ...validData, password: 'short', password_confirmation: 'short' })
|
||||
it('rejects password shorter than 15 characters', () => {
|
||||
const result = validate(registerSchema, { ...validData, password: 'too-short', password_confirmation: 'too-short' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
@@ -110,8 +110,8 @@ describe('forgotPasswordSchema', () => {
|
||||
describe('resetPasswordSchema', () => {
|
||||
const validData = {
|
||||
email: 'test@example.com',
|
||||
password: 'newpassword123',
|
||||
password_confirmation: 'newpassword123',
|
||||
password: 'a secure new password',
|
||||
password_confirmation: 'a secure new password',
|
||||
}
|
||||
|
||||
it('accepts valid reset data', () => {
|
||||
@@ -124,8 +124,8 @@ describe('resetPasswordSchema', () => {
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects password shorter than 8 characters', () => {
|
||||
const result = validate(resetPasswordSchema, { ...validData, password: 'short', password_confirmation: 'short' })
|
||||
it('rejects password shorter than 15 characters', () => {
|
||||
const result = validate(resetPasswordSchema, { ...validData, password: 'too-short', password_confirmation: 'too-short' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')),
|
||||
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')),
|
||||
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')),
|
||||
}),
|
||||
v.forward(
|
||||
@@ -37,7 +37,7 @@ export const forgotPasswordSchema = v.object({
|
||||
export const resetPasswordSchema = v.pipe(
|
||||
v.object({
|
||||
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')),
|
||||
}),
|
||||
v.forward(
|
||||
|
||||
@@ -9,34 +9,38 @@ use App\Http\Controllers\Auth\ResetPasswordController;
|
||||
use App\Http\Controllers\Auth\SocialiteController;
|
||||
use App\Http\Controllers\Auth\TwoFactorChallengeController;
|
||||
use App\Http\Controllers\Auth\TwoFactorSettingsController;
|
||||
use App\Http\Controllers\Auth\TwoFactorSetupController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
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::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::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::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
|
||||
Route::get('auth/{provider}', [SocialiteController::class, 'redirect'])->name('socialite.redirect');
|
||||
Route::get('auth/{provider}/callback', [SocialiteController::class, 'callback'])->name('socialite.callback')->middleware('throttle:10,1');
|
||||
Route::get('auth/{provider}', [SocialiteController::class, 'redirect'])
|
||||
->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)
|
||||
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'])
|
||||
->name('two-factor.login');
|
||||
Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store'])
|
||||
->middleware('throttle:5,1')
|
||||
->middleware('throttle:auth.two-factor')
|
||||
->name('two-factor.login.store');
|
||||
});
|
||||
|
||||
@@ -46,20 +50,32 @@ Route::middleware('auth')->group(function () {
|
||||
// Email verification routes
|
||||
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::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::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'])
|
||||
->middleware('throttle:5,1')
|
||||
->middleware('throttle:auth.two-factor')
|
||||
->name('profile.two-factor.enable');
|
||||
Route::post('profile/two-factor/confirm', [TwoFactorSettingsController::class, 'confirm'])
|
||||
->middleware('throttle:5,1')
|
||||
->middleware('throttle:auth.two-factor')
|
||||
->name('profile.two-factor.confirm');
|
||||
Route::delete('profile/two-factor', [TwoFactorSettingsController::class, 'disable'])
|
||||
->middleware('throttle:5,1')
|
||||
->middleware('throttle:auth.two-factor')
|
||||
->name('profile.two-factor.disable');
|
||||
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');
|
||||
});
|
||||
|
||||
226
tests/Feature/Auth/AuthSecurityHardeningTest.php
Normal file
226
tests/Feature/Auth/AuthSecurityHardeningTest.php
Normal 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();
|
||||
});
|
||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\URL;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['auth-ui.features.two_factor_required' => false]);
|
||||
});
|
||||
|
||||
it('does not send verification email when feature is disabled', function () {
|
||||
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',
|
||||
'last_name' => 'User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
'password' => 'correct horse battery staple',
|
||||
'password_confirmation' => 'correct horse battery staple',
|
||||
]);
|
||||
|
||||
Notification::assertNothingSent();
|
||||
@@ -35,8 +39,8 @@ it('sends verification email when feature is enabled', function () {
|
||||
'first_name' => 'Test',
|
||||
'last_name' => 'User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
'password' => 'correct horse battery staple',
|
||||
'password_confirmation' => 'correct horse battery staple',
|
||||
]);
|
||||
|
||||
$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',
|
||||
'last_name' => 'User',
|
||||
'email' => 'new@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
'password' => 'correct horse battery staple',
|
||||
'password_confirmation' => 'correct horse battery staple',
|
||||
])->assertRedirect('/email/verify');
|
||||
});
|
||||
|
||||
@@ -148,7 +152,7 @@ it('redirects to home after registration when feature is disabled', function ()
|
||||
'first_name' => 'New',
|
||||
'last_name' => 'User',
|
||||
'email' => 'new@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
'password' => 'correct horse battery staple',
|
||||
'password_confirmation' => 'correct horse battery staple',
|
||||
])->assertRedirect('/dashboard');
|
||||
});
|
||||
|
||||
@@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['auth-ui.features.two_factor_required' => false]);
|
||||
});
|
||||
|
||||
it('allows login with email and password', function () {
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
|
||||
@@ -9,6 +9,7 @@ use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['auth-ui.features.two_factor_required' => false]);
|
||||
config(['auth-ui.providers.github' => [
|
||||
'label' => 'GitHub',
|
||||
'icon' => 'i-simple-icons-github',
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Notifications\TwoFactorFailedNotification;
|
||||
use App\Notifications\TwoFactorSecurityNotification;
|
||||
use Database\Seeders\DatabaseSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||
use Laravel\Fortify\Features;
|
||||
@@ -18,7 +21,11 @@ beforeEach(function () {
|
||||
'auth-ui.features.two_factor' => true,
|
||||
'auth-ui.features.two_factor_required' => false,
|
||||
'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' => [
|
||||
'label' => '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)
|
||||
->get('/dashboard')
|
||||
->assertRedirect('/profile')
|
||||
->assertRedirect('/two-factor-setup')
|
||||
->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)
|
||||
->get('/profile')
|
||||
->assertOk();
|
||||
->assertRedirect('/two-factor-setup');
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/logout')
|
||||
@@ -94,20 +108,63 @@ it('redirects authenticated users to enrollment when two-factor setup is mandato
|
||||
$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 () {
|
||||
config(['auth-ui.features.two_factor_required' => true]);
|
||||
$user = User::factory()->create(['password' => 'password123']);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post('/profile/two-factor', ['password' => 'password123'])
|
||||
->assertRedirect('/profile');
|
||||
->post('/two-factor-setup', ['password' => 'password123'])
|
||||
->assertRedirect('/two-factor-setup');
|
||||
|
||||
expect($user->fresh()->two_factor_secret)->not->toBeNull()
|
||||
->and($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||
|
||||
$this->actingAs($user)
|
||||
->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 () {
|
||||
@@ -120,6 +177,21 @@ it('allows normal access after mandatory enrollment is complete', function () {
|
||||
->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 () {
|
||||
config([
|
||||
'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();
|
||||
});
|
||||
|
||||
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 () {
|
||||
config(['auth-ui.features.two_factor_required' => true]);
|
||||
$user = User::factory()->create();
|
||||
@@ -140,7 +226,7 @@ it('blocks authenticated API requests until mandatory enrollment is complete', f
|
||||
->assertForbidden()
|
||||
->assertJson([
|
||||
'message' => 'Two-factor authentication setup is required.',
|
||||
'setup_url' => route('profile.show'),
|
||||
'setup_url' => route('two-factor.setup'),
|
||||
]);
|
||||
|
||||
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 () {
|
||||
Notification::fake();
|
||||
$user = User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
'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'])
|
||||
->assertSessionHasErrors('code');
|
||||
$this->assertGuest();
|
||||
Notification::assertSentTo($user, TwoFactorFailedNotification::class);
|
||||
|
||||
$challengeResponse = $this->post('/two-factor-challenge', ['code' => $twoFactor['code']]);
|
||||
$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();
|
||||
});
|
||||
|
||||
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 () {
|
||||
$user = User::factory()->social()->create();
|
||||
$twoFactor = enableTwoFactorForTest($user);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
@@ -11,7 +13,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
pest()->extend(Tests\TestCase::class)
|
||||
pest()->extend(TestCase::class)
|
||||
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
|
||||
->in('Feature');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './resources/js'),
|
||||
'@': resolve(import.meta.dirname, './resources/js'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user