feat: add two-factor authentication support and related configurations

This commit is contained in:
2026-07-24 12:59:10 +02:00
parent 54345e621c
commit 235d5f646c
27 changed files with 2608 additions and 374 deletions

View File

@@ -67,6 +67,8 @@ AUTH_ENABLE_REGISTRATION=true
AUTH_ENABLE_PASSWORD_RESET=true AUTH_ENABLE_PASSWORD_RESET=true
AUTH_ENABLE_REMEMBER_ME=true AUTH_ENABLE_REMEMBER_ME=true
AUTH_ENABLE_EMAIL_VERIFICATION=false AUTH_ENABLE_EMAIL_VERIFICATION=false
AUTH_ENABLE_TWO_FACTOR=false
AUTH_REQUIRE_TWO_FACTOR=false
# Auth Redirects # Auth Redirects
# AUTH_REDIRECT_LOGIN=/dashboard # AUTH_REDIRECT_LOGIN=/dashboard

View File

@@ -17,6 +17,7 @@ A starter template built with Laravel 13, Inertia.js v3, Vue 3, Nuxt UI, and Tai
- **Authentication** — Login (email or username), register, forgot/reset password - **Authentication** — Login (email or username), register, forgot/reset password
- **Social Login** — OAuth via Laravel Socialite with provider tracking (`social_accounts` table) - **Social Login** — OAuth via Laravel Socialite with provider tracking (`social_accounts` table)
- **Email Verification** — Optional, toggle via `AUTH_ENABLE_EMAIL_VERIFICATION` - **Email Verification** — Optional, toggle via `AUTH_ENABLE_EMAIL_VERIFICATION`
- **Two-Factor Authentication** — Optional TOTP and recovery-code protection, toggle via `AUTH_ENABLE_TWO_FACTOR`
- **Dashboard** — Protected page using Nuxt UI dashboard components with `auth` + `verified` middleware - **Dashboard** — Protected page using Nuxt UI dashboard components with `auth` + `verified` middleware
- **Security** — Nullable passwords for social-only users, verified-email requirement for provider linking, rate limiting on all auth endpoints - **Security** — Nullable passwords for social-only users, verified-email requirement for provider linking, rate limiting on all auth endpoints
- **Code Quality** — ESLint, Laravel Pint, shared Valibot validation schemas - **Code Quality** — ESLint, Laravel Pint, shared Valibot validation schemas
@@ -73,6 +74,8 @@ AUTH_ENABLE_REGISTRATION=true
AUTH_ENABLE_PASSWORD_RESET=true AUTH_ENABLE_PASSWORD_RESET=true
AUTH_ENABLE_REMEMBER_ME=true AUTH_ENABLE_REMEMBER_ME=true
AUTH_ENABLE_EMAIL_VERIFICATION=false AUTH_ENABLE_EMAIL_VERIFICATION=false
AUTH_ENABLE_TWO_FACTOR=false
AUTH_REQUIRE_TWO_FACTOR=false
AUTH_REDIRECT_LOGIN=/dashboard AUTH_REDIRECT_LOGIN=/dashboard
AUTH_REDIRECT_LOGOUT=/ AUTH_REDIRECT_LOGOUT=/
@@ -85,6 +88,16 @@ When `AUTH_ENABLE_EMAIL_VERIFICATION=true`:
- Social login users are auto-verified (trusted from provider) - Social login users are auto-verified (trusted from provider)
- Social accounts can only be linked to users with verified emails - Social accounts can only be linked to users with verified emails
When `AUTH_ENABLE_TWO_FACTOR=true`, authenticated users can enroll an authenticator
and manage recovery codes from `/profile`. Existing password and social logins are
both held at the two-factor challenge until a valid TOTP or one-time recovery code is
provided.
Set both `AUTH_ENABLE_TWO_FACTOR=true` and `AUTH_REQUIRE_TWO_FACTOR=true` to
require every authenticated user to complete enrollment before accessing
protected web pages or authenticated API endpoints. Enrollment, email
verification, and logout routes remain available while setup is pending.
See `config/auth-ui.php` for all available options including page titles, icons, legal links, and social provider configuration. See `config/auth-ui.php` for all available options including page titles, icons, legal links, and social provider configuration.
## Social Login Setup ## Social Login Setup

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest; use App\Http\Requests\Auth\LoginRequest;
use App\Models\User; use App\Models\User;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -25,26 +26,42 @@ class LoginController extends Controller
/** /**
* Handle an incoming authentication request. * Handle an incoming authentication request.
*/ */
public function store(LoginRequest $request): RedirectResponse public function store(LoginRequest $request, TwoFactorChallenge $twoFactor): RedirectResponse
{ {
$login = $request->validated('login'); $login = $request->validated('login');
$password = $request->validated('password'); $password = $request->validated('password');
$isEmail = filter_var($login, FILTER_VALIDATE_EMAIL); $isEmail = filter_var($login, FILTER_VALIDATE_EMAIL);
$credentials = $isEmail $email = $isEmail
? ['email' => $login, 'password' => $password] ? $login
: ['email' => User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email'), 'password' => $password]; : User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email');
$credentials = ['email' => $email, 'password' => $password];
$remember = config('auth-ui.features.remember_me') $remember = config('auth-ui.features.remember_me')
? $request->boolean('remember') ? $request->boolean('remember')
: false; : false;
if (! $credentials['email'] || ! Auth::attempt($credentials, $remember)) { $provider = Auth::getProvider();
$user = $credentials['email']
? $provider->retrieveByCredentials($credentials)
: null;
if (! $user || ! $provider->validateCredentials($user, $credentials)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'login' => __('auth.failed'), 'login' => __('auth.failed'),
]); ]);
} }
if (config('hashing.rehash_on_login', true)
&& method_exists($provider, 'rehashPasswordIfRequired')) {
$provider->rehashPasswordIfRequired($user, $credentials);
}
if ($twoFactor->requiredFor($user)) {
return $twoFactor->begin($request, $user, $remember);
}
Auth::login($user, $remember);
$request->session()->regenerate(); $request->session()->regenerate();
return redirect()->intended(config('auth-ui.redirects.login', '/')); return redirect()->intended(config('auth-ui.redirects.login', '/'));

View File

@@ -5,7 +5,9 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\SocialAccount; use App\Models\SocialAccount;
use App\Models\User; use App\Models\User;
use App\Services\Auth\TwoFactorChallenge;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
@@ -49,8 +51,11 @@ class SocialiteController extends Controller
/** /**
* Obtain the user information from provider. * Obtain the user information from provider.
*/ */
public function callback(string $provider): RedirectResponse public function callback(
{ Request $request,
string $provider,
TwoFactorChallenge $twoFactor
): RedirectResponse {
if (! in_array($provider, $this->getEnabledProviders())) { if (! in_array($provider, $this->getEnabledProviders())) {
abort(404, 'Provider not enabled'); abort(404, 'Provider not enabled');
} }
@@ -69,10 +74,7 @@ class SocialiteController extends Controller
->first(); ->first();
if ($socialAccount) { if ($socialAccount) {
Auth::login($socialAccount->user, remember: true); return $this->finishAuthentication($request, $socialAccount->user, $twoFactor);
request()->session()->regenerate();
return redirect()->intended(config('auth-ui.redirects.login', '/'));
} }
// Check if a user with this email already exists // Check if a user with this email already exists
@@ -90,10 +92,7 @@ class SocialiteController extends Controller
'provider_id' => $socialUser->getId(), 'provider_id' => $socialUser->getId(),
]); ]);
Auth::login($existingUser, remember: true); return $this->finishAuthentication($request, $existingUser, $twoFactor);
request()->session()->regenerate();
return redirect()->intended(config('auth-ui.redirects.login', '/'));
} }
// New user — check if registration is enabled // New user — check if registration is enabled
@@ -135,8 +134,20 @@ class SocialiteController extends Controller
'provider_id' => $socialUser->getId(), 'provider_id' => $socialUser->getId(),
]); ]);
return $this->finishAuthentication($request, $user, $twoFactor);
}
private function finishAuthentication(
Request $request,
User $user,
TwoFactorChallenge $twoFactor
): RedirectResponse {
if ($twoFactor->requiredFor($user)) {
return $twoFactor->begin($request, $user, remember: true);
}
Auth::login($user, remember: true); Auth::login($user, remember: true);
request()->session()->regenerate(); $request->session()->regenerate();
return redirect()->intended(config('auth-ui.redirects.login', '/')); return redirect()->intended(config('auth-ui.redirects.login', '/'));
} }

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
use Laravel\Fortify\Http\Requests\TwoFactorLoginRequest;
class TwoFactorChallengeController extends Controller
{
/**
* Display the two-factor challenge.
*/
public function create(TwoFactorLoginRequest $request): Response|RedirectResponse
{
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) {
$this->clearChallenge($request);
return redirect()->route('login');
}
return Inertia::render('Auth/TwoFactorChallenge');
}
/**
* 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()) {
$this->clearChallenge($request);
return redirect()->route('login');
}
$user = $request->challengedUser();
if (! $user->hasEnabledTwoFactorAuthentication()) {
$this->clearChallenge($request);
return redirect()->route('login');
}
if ($recoveryCode = $request->validRecoveryCode()) {
$user->replaceRecoveryCode($recoveryCode);
} elseif (! $request->hasValidCode()) {
event(new TwoFactorAuthenticationFailed($user));
throw ValidationException::withMessages([
$request->filled('recovery_code') ? 'recovery_code' : 'code' => 'The provided authentication code was invalid.',
]);
}
event(new ValidTwoFactorAuthenticationCodeProvided($user));
Auth::login($user, $request->remember());
$request->session()->regenerate();
return redirect()->intended(config('auth-ui.redirects.login', '/dashboard'));
}
private function clearChallenge(TwoFactorLoginRequest $request): void
{
$request->session()->forget(['login.id', 'login.remember']);
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
use Laravel\Fortify\Fortify;
class TwoFactorSettingsController extends Controller
{
/**
* Begin two-factor enrollment.
*/
public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse
{
$this->ensureFeatureIsEnabled();
/** @var User $user */
$user = $request->user();
if ($user->hasEnabledTwoFactorAuthentication()) {
return redirect()->route('profile.show');
}
$this->validatePasswordForPasswordUser($request, $user);
$enable($user, force: true);
return redirect()->route('profile.show')
->with('success', 'Scan the QR code, then enter a code to finish enabling two-factor authentication.');
}
/**
* Confirm a newly configured authenticator.
*/
public function confirm(
Request $request,
ConfirmTwoFactorAuthentication $confirm
): RedirectResponse {
$this->ensureFeatureIsEnabled();
$validated = $request->validate([
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
]);
/** @var User $user */
$user = $request->user();
$confirm($user, $validated['code']);
return redirect()->route('profile.show')
->with('success', 'Two-factor authentication is now enabled.')
->with('recoveryCodes', $user->fresh()->recoveryCodes());
}
/**
* Disable two-factor authentication.
*/
public function disable(
Request $request,
DisableTwoFactorAuthentication $disable
): RedirectResponse {
$this->ensureFeatureIsEnabled();
/** @var User $user */
$user = $request->user();
if ($user->hasEnabledTwoFactorAuthentication()) {
$this->validateSensitiveAction($request, $user);
}
$disable($user);
return redirect()->route('profile.show')
->with('success', 'Two-factor authentication has been disabled.');
}
/**
* Reveal or regenerate recovery codes after re-authentication.
*/
public function recoveryCodes(
Request $request,
GenerateNewRecoveryCodes $generate
): RedirectResponse {
$this->ensureFeatureIsEnabled();
/** @var User $user */
$user = $request->user();
if (! $user->hasEnabledTwoFactorAuthentication()) {
abort(409, 'Two-factor authentication is not enabled.');
}
$this->validateSensitiveAction($request, $user);
if ($request->boolean('regenerate')) {
$generate($user);
$user->refresh();
}
return redirect()->route('profile.show')
->with('success', $request->boolean('regenerate')
? 'New recovery codes have been generated. Previous codes no longer work.'
: 'Recovery codes revealed.')
->with('recoveryCodes', $user->recoveryCodes());
}
private function ensureFeatureIsEnabled(): void
{
abort_unless(config('auth-ui.features.two_factor'), 404);
}
private function validatePasswordForPasswordUser(Request $request, User $user): void
{
if (! $user->hasPassword()) {
return;
}
$request->validate([
'password' => ['required', 'string', 'current_password:web'],
]);
}
private function validateSensitiveAction(Request $request, User $user): void
{
if ($user->hasPassword()) {
$this->validatePasswordForPasswordUser($request, $user);
return;
}
$validated = $request->validate([
'code' => ['required', 'string'],
]);
if (! $this->hasValidTwoFactorCredential($user, $validated['code'])) {
throw ValidationException::withMessages([
'code' => 'The provided authentication or recovery code was invalid.',
]);
}
}
private function hasValidTwoFactorCredential(User $user, string $code): bool
{
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
return true;
}
$recoveryCode = collect($user->recoveryCodes())
->first(fn (string $recoveryCode) => hash_equals($recoveryCode, $code));
if (! $recoveryCode) {
return false;
}
$user->replaceRecoveryCode($recoveryCode);
return true;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
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
{
/** @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,
],
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTwoFactorAuthenticationIsEnabled
{
/**
* Require authenticated users to finish two-factor enrollment when configured.
*/
public function handle(Request $request, Closure $next): Response
{
if (! config('auth-ui.features.two_factor')
|| ! config('auth-ui.features.two_factor_required')
|| ! $request->user()
|| $request->user()->hasEnabledTwoFactorAuthentication()
|| $this->isEnrollmentRoute($request)) {
return $next($request);
}
if ($request->expectsJson()) {
return response()->json([
'message' => 'Two-factor authentication setup is required.',
'setup_url' => route('profile.show'),
], 403);
}
return redirect()->route('profile.show')
->with('error', 'Two-factor authentication is required before you can continue.');
}
private function isEnrollmentRoute(Request $request): bool
{
return $request->routeIs([
'profile.*',
'logout',
'verification.*',
]);
}
}

View File

@@ -45,6 +45,7 @@ class HandleInertiaRequests extends Middleware
'error' => fn () => $request->session()->get('error'), 'error' => fn () => $request->session()->get('error'),
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),
'status' => fn () => $request->session()->get('status'), 'status' => fn () => $request->session()->get('status'),
'recoveryCodes' => fn () => $request->session()->get('recoveryCodes'),
], ],
'authConfig' => fn () => $this->getAuthConfig(), 'authConfig' => fn () => $this->getAuthConfig(),
]; ];

View File

@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable; use HasFactory, Notifiable, TwoFactorAuthenticatable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -36,6 +37,8 @@ class User extends Authenticatable implements MustVerifyEmail
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
]; ];
/** /**
@@ -57,6 +60,7 @@ class User extends Authenticatable implements MustVerifyEmail
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
]; ];
} }

View File

@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -11,7 +12,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// // This application owns its authentication routes and UI. We use
// Fortify's two-factor primitives without its full route scaffold.
Fortify::ignoreRoutes();
} }
/** /**

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TwoFactorChallenge
{
/**
* Determine whether the user must complete a two-factor challenge.
*/
public function requiredFor(User $user): bool
{
return (bool) config('auth-ui.features.two_factor')
&& $user->hasEnabledTwoFactorAuthentication();
}
/**
* Begin a two-factor challenge without authenticating the user.
*/
public function begin(Request $request, User $user, bool $remember = false): RedirectResponse
{
$request->session()->put([
'login.id' => $user->getKey(),
'login.remember' => $remember,
]);
$request->session()->regenerate();
return redirect()->route('two-factor.login');
}
}

View File

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

View File

@@ -11,6 +11,7 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"inertiajs/inertia-laravel": "^3.0.0-beta", "inertiajs/inertia-laravel": "^3.0.0-beta",
"laravel/fortify": "^1.37",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.24", "laravel/socialite": "^5.24",

1650
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,8 @@ return [
'password_reset' => env('AUTH_ENABLE_PASSWORD_RESET', true), 'password_reset' => env('AUTH_ENABLE_PASSWORD_RESET', true),
'remember_me' => env('AUTH_ENABLE_REMEMBER_ME', true), 'remember_me' => env('AUTH_ENABLE_REMEMBER_ME', true),
'email_verification' => env('AUTH_ENABLE_EMAIL_VERIFICATION', false), 'email_verification' => env('AUTH_ENABLE_EMAIL_VERIFICATION', false),
'two_factor' => env('AUTH_ENABLE_TWO_FACTOR', false),
'two_factor_required' => env('AUTH_REQUIRE_TWO_FACTOR', false),
], ],
/* /*

17
config/fortify.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
use Laravel\Fortify\Features;
return [
'guard' => 'web',
'username' => 'email',
'views' => false,
'home' => config('auth-ui.redirects.login', '/dashboard'),
'features' => config('auth-ui.features.two_factor')
? [
Features::twoFactorAuthentication([
'confirm' => true,
]),
]
: [],
];

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')->nullable()->after('password');
$table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret');
$table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@@ -18,7 +18,9 @@ class DatabaseSeeder extends Seeder
// User::factory(10)->create(); // User::factory(10)->create();
User::factory()->create([ User::factory()->create([
'name' => 'Test User', 'username' => 'testuser',
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'test@example.com', 'email' => 'test@example.com',
]); ]);
} }

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AuthLayout from '@/layouts/AuthLayout.vue'
const usingRecoveryCode = ref(false)
const form = useForm({
code: '',
recovery_code: '',
})
const error = computed(() => form.errors.code || form.errors.recovery_code)
function submit() {
form
.transform(data => usingRecoveryCode.value
? { code: '', recovery_code: data.recovery_code }
: { code: data.code, recovery_code: '' })
.post('/two-factor-challenge', {
onFinish: () => form.reset(),
})
}
</script>
<template>
<AuthLayout>
<div class="space-y-6">
<div class="text-center space-y-2">
<UIcon name="i-lucide-shield-check" class="text-primary size-10" />
<h1 class="text-xl font-semibold">
Two-factor authentication
</h1>
<p class="text-muted text-sm">
{{ usingRecoveryCode
? 'Enter one of your recovery codes to finish signing in.'
: 'Enter the six-digit code from your authenticator app.' }}
</p>
</div>
<form class="space-y-4" @submit.prevent="submit">
<UFormField
v-if="!usingRecoveryCode"
label="Authentication code"
name="code"
:error="form.errors.code"
>
<UInput
v-model="form.code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
placeholder="123456"
autofocus
class="w-full"
/>
</UFormField>
<UFormField
v-else
label="Recovery code"
name="recovery_code"
:error="form.errors.recovery_code"
>
<UInput
v-model="form.recovery_code"
autocomplete="one-time-code"
placeholder="xxxxx-xxxxx"
autofocus
class="w-full"
/>
</UFormField>
<UAlert
v-if="error"
color="error"
icon="i-lucide-alert-circle"
:title="error"
/>
<UButton type="submit" block :loading="form.processing">
Verify and sign in
</UButton>
</form>
<div class="text-center">
<UButton
variant="link"
size="sm"
@click="usingRecoveryCode = !usingRecoveryCode; form.clearErrors(); form.reset()"
>
{{ usingRecoveryCode ? 'Use an authentication code' : 'Use a recovery code' }}
</UButton>
</div>
</div>
</AuthLayout>
</template>

View File

@@ -0,0 +1,332 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3'
import { computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
interface TwoFactorState {
available: boolean
enabled: boolean
pending: boolean
requiresPassword: boolean
qrCodeDataUri: string | null
secretKey: string | null
}
defineOptions({
layout: DashboardLayout,
})
defineProps<{
twoFactor: TwoFactorState
}>()
const { config, flash, user } = useAuth()
const enableForm = useForm({ password: '' })
const confirmForm = useForm({ code: '' })
const disableForm = useForm({ password: '', code: '' })
const recoveryForm = useForm({ password: '', code: '', regenerate: false })
const recoveryCodes = computed(() => flash.value.recoveryCodes ?? [])
const twoFactorRequired = computed(() =>
config.value.features.two_factor && config.value.features.two_factor_required,
)
function enable() {
enableForm.post('/profile/two-factor', {
preserveScroll: true,
onFinish: () => enableForm.reset(),
})
}
function confirm() {
confirmForm.post('/profile/two-factor/confirm', {
preserveScroll: true,
onFinish: () => confirmForm.reset(),
})
}
function disable() {
disableForm.delete('/profile/two-factor', {
preserveScroll: true,
onFinish: () => disableForm.reset(),
})
}
function recoveryCodesAction(regenerate: boolean) {
recoveryForm.regenerate = regenerate
recoveryForm.post('/profile/two-factor/recovery-codes', {
preserveScroll: true,
onFinish: () => recoveryForm.reset(),
})
}
</script>
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar title="Profile" />
</template>
<template #body>
<div class="mx-auto w-full max-w-3xl space-y-6">
<UAlert
v-if="flash.success"
color="success"
icon="i-lucide-check-circle"
:title="flash.success"
/>
<UAlert
v-if="twoFactorRequired && !twoFactor.enabled"
color="warning"
icon="i-lucide-shield-alert"
title="Two-factor authentication is required"
description="Finish setup before accessing the rest of the application."
/>
<UCard>
<div class="flex flex-col gap-5 sm:flex-row sm:items-center">
<UAvatar :alt="user?.full_name" size="3xl" />
<div class="min-w-0 flex-1">
<h1 class="truncate text-xl font-semibold">
{{ user?.full_name }}
</h1>
<p class="text-muted truncate text-sm">
@{{ user?.username }}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<UBadge color="neutral" variant="subtle" icon="i-lucide-mail">
{{ user?.email }}
</UBadge>
<UBadge
:color="user?.email_verified_at ? 'success' : 'warning'"
variant="subtle"
:icon="user?.email_verified_at ? 'i-lucide-badge-check' : 'i-lucide-circle-alert'"
>
{{ user?.email_verified_at ? 'Email verified' : 'Email not verified' }}
</UBadge>
</div>
</div>
</div>
</UCard>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold">
Two-factor authentication
</h2>
<p class="text-muted mt-1 text-sm">
Protect your account with a time-based code from an authenticator app.
</p>
</div>
<UBadge :color="twoFactor.enabled ? 'success' : twoFactor.pending ? 'warning' : 'neutral'">
{{ twoFactor.enabled ? 'Enabled' : twoFactor.pending ? 'Setup pending' : 'Disabled' }}
</UBadge>
</div>
</template>
<UAlert
v-if="!twoFactor.available"
color="neutral"
icon="i-lucide-shield-off"
title="Two-factor authentication is not available"
description="An administrator can enable this feature through the authentication configuration."
/>
<form v-else-if="!twoFactor.enabled && !twoFactor.pending" class="space-y-4" @submit.prevent="enable">
<UAlert
v-if="!twoFactor.requiresPassword"
color="info"
icon="i-lucide-info"
title="Your social login session will authorize enrollment."
/>
<UFormField
v-if="twoFactor.requiresPassword"
label="Current password"
name="password"
:error="enableForm.errors.password"
>
<UInput
v-model="enableForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
</UFormField>
<UButton type="submit" :loading="enableForm.processing">
Enable two-factor authentication
</UButton>
</form>
<div v-else-if="twoFactor.pending" class="space-y-6">
<div class="grid gap-6 md:grid-cols-[auto_1fr]">
<img
v-if="twoFactor.qrCodeDataUri"
:src="twoFactor.qrCodeDataUri"
alt="Two-factor authenticator QR code"
class="size-48 rounded-lg border bg-white p-2"
>
<div class="space-y-3">
<h3 class="font-medium">
Scan this QR code
</h3>
<p class="text-muted text-sm">
Scan it with any TOTP-compatible authenticator app, then enter the generated code below.
</p>
<div>
<p class="text-muted mb-1 text-xs">
Manual setup key
</p>
<code class="block break-all rounded bg-elevated p-2 text-sm">{{ twoFactor.secretKey }}</code>
</div>
</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"
class="w-full"
/>
</UFormField>
<div class="flex flex-wrap gap-3">
<UButton type="submit" :loading="confirmForm.processing">
Confirm setup
</UButton>
<UButton
type="button"
color="neutral"
variant="outline"
:loading="disableForm.processing"
@click="disable"
>
Cancel setup
</UButton>
</div>
</form>
</div>
<div v-else class="space-y-6">
<UAlert
color="success"
variant="subtle"
icon="i-lucide-shield-check"
title="Your account requires a second factor when signing in."
/>
<div class="space-y-4 border-t pt-6">
<div>
<h3 class="font-medium">
Recovery codes
</h3>
<p class="text-muted text-sm">
Re-authenticate to reveal your codes or replace them with a new set.
</p>
</div>
<UFormField
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
:name="twoFactor.requiresPassword ? 'password' : 'code'"
:error="recoveryForm.errors.password || recoveryForm.errors.code"
>
<UInput
v-if="twoFactor.requiresPassword"
v-model="recoveryForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
<UInput
v-else
v-model="recoveryForm.code"
autocomplete="one-time-code"
class="w-full"
/>
</UFormField>
<div class="flex flex-wrap gap-3">
<UButton
variant="outline"
:loading="recoveryForm.processing && !recoveryForm.regenerate"
@click="recoveryCodesAction(false)"
>
Reveal recovery codes
</UButton>
<UButton
color="warning"
variant="outline"
:loading="recoveryForm.processing && recoveryForm.regenerate"
@click="recoveryCodesAction(true)"
>
Generate new codes
</UButton>
</div>
</div>
<form class="space-y-4 border-t pt-6" @submit.prevent="disable">
<div>
<h3 class="font-medium text-error">
Disable two-factor authentication
</h3>
<p class="text-muted text-sm">
This removes the additional protection from your account.
</p>
</div>
<UFormField
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
:name="twoFactor.requiresPassword ? 'password' : 'code'"
:error="disableForm.errors.password || disableForm.errors.code"
>
<UInput
v-if="twoFactor.requiresPassword"
v-model="disableForm.password"
type="password"
autocomplete="current-password"
class="w-full"
/>
<UInput
v-else
v-model="disableForm.code"
autocomplete="one-time-code"
class="w-full"
/>
</UFormField>
<UButton type="submit" color="error" :loading="disableForm.processing">
Disable two-factor authentication
</UButton>
</form>
</div>
</UCard>
<UCard v-if="recoveryCodes.length">
<template #header>
<div>
<h2 class="font-semibold">
Save your recovery codes
</h2>
<p class="text-muted mt-1 text-sm">
Store these codes somewhere safe. Each code can be used once.
</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>
</UCard>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
import { useForm } from '@inertiajs/vue3' import { useForm } from '@inertiajs/vue3'
import Logo from '@/components/common/Logo.vue' import Logo from '@/components/common/Logo.vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
@@ -18,6 +19,24 @@ const sidebarLinks = [
to: '/dashboard', to: '/dashboard',
}, },
] ]
const userMenuItems: DropdownMenuItem[][] = [
[
{
label: 'Profile',
icon: 'i-lucide-user-round',
to: '/profile',
},
],
[
{
label: 'Sign out',
icon: 'i-lucide-log-out',
color: 'error',
onSelect: logout,
},
],
]
</script> </script>
<template> <template>
@@ -28,27 +47,36 @@ const sidebarLinks = [
<Logo :link-to-home="false" size="sm" /> <Logo :link-to-home="false" size="sm" />
</template> </template>
<UNavigationMenu :items="sidebarLinks" /> <UNavigationMenu :items="sidebarLinks" orientation="vertical" />
<template #footer> <template #footer>
<div class="flex items-center gap-2 px-2"> <UDropdownMenu
<UAvatar :alt="user?.full_name" size="sm" /> :items="userMenuItems"
<div class="flex-1 truncate text-sm"> :content="{ align: 'start', side: 'top', sideOffset: 8 }"
<p class="font-medium truncate"> :ui="{ content: 'w-64' }"
{{ user?.full_name }} >
</p>
<p class="text-muted truncate text-xs">
{{ user?.email }}
</p>
</div>
<UButton <UButton
icon="i-lucide-log-out" color="neutral"
variant="ghost" variant="ghost"
size="sm" class="w-full justify-start px-2 py-2"
aria-label="Open user menu"
:loading="logoutForm.processing" :loading="logoutForm.processing"
@click="logout" >
<UAvatar :alt="user?.full_name" size="sm" />
<span class="min-w-0 flex-1 text-left">
<span class="block truncate text-sm font-medium text-highlighted">
{{ user?.full_name }}
</span>
<span class="block truncate text-xs font-normal text-muted">
{{ user?.email }}
</span>
</span>
<UIcon
name="i-lucide-chevrons-up-down"
class="size-4 shrink-0 text-muted"
/> />
</div> </UButton>
</UDropdownMenu>
</template> </template>
</UDashboardSidebar> </UDashboardSidebar>

View File

@@ -3,6 +3,8 @@ export interface AuthFeatures {
password_reset: boolean password_reset: boolean
remember_me: boolean remember_me: boolean
email_verification: boolean email_verification: boolean
two_factor: boolean
two_factor_required: boolean
} }
export interface AuthPageConfig { export interface AuthPageConfig {

View File

@@ -3,4 +3,5 @@ export interface Flash {
error: string | null error: string | null
message: string | null message: string | null
status: string | null status: string | null
recoveryCodes: string[] | null
} }

View File

@@ -1,8 +1,9 @@
<?php <?php
use App\Http\Middleware\EnsureTwoFactorAuthenticationIsEnabled;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) { Route::get('/user', function (Request $request) {
return $request->user(); return $request->user();
})->middleware('auth:sanctum'); })->middleware(['auth:sanctum', EnsureTwoFactorAuthenticationIsEnabled::class]);

View File

@@ -7,6 +7,9 @@ use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\RegisterController; use App\Http\Controllers\Auth\RegisterController;
use App\Http\Controllers\Auth\ResetPasswordController; use App\Http\Controllers\Auth\ResetPasswordController;
use App\Http\Controllers\Auth\SocialiteController; use App\Http\Controllers\Auth\SocialiteController;
use App\Http\Controllers\Auth\TwoFactorChallengeController;
use App\Http\Controllers\Auth\TwoFactorSettingsController;
use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
@@ -29,6 +32,12 @@ Route::middleware('guest')->group(function () {
// Complete profile after social login (when username is taken) // Complete profile after social login (when username is taken)
Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile'); Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile');
Route::post('complete-profile', [CompleteProfileController::class, 'store']); Route::post('complete-profile', [CompleteProfileController::class, 'store']);
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'create'])
->name('two-factor.login');
Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store'])
->middleware('throttle:5,1')
->name('two-factor.login.store');
}); });
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
@@ -38,4 +47,19 @@ Route::middleware('auth')->group(function () {
Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice'); Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice');
Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify'); Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify');
Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send');
Route::get('profile', ProfileController::class)->name('profile.show');
Route::redirect('security', '/profile')->name('profile.security-redirect');
Route::post('profile/two-factor', [TwoFactorSettingsController::class, 'enable'])
->middleware('throttle:5,1')
->name('profile.two-factor.enable');
Route::post('profile/two-factor/confirm', [TwoFactorSettingsController::class, 'confirm'])
->middleware('throttle:5,1')
->name('profile.two-factor.confirm');
Route::delete('profile/two-factor', [TwoFactorSettingsController::class, 'disable'])
->middleware('throttle:5,1')
->name('profile.two-factor.disable');
Route::post('profile/two-factor/recovery-codes', [TwoFactorSettingsController::class, 'recoveryCodes'])
->middleware('throttle:5,1')
->name('profile.two-factor.recovery-codes');
}); });

View File

@@ -0,0 +1,311 @@
<?php
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\User as SocialiteUser;
use PragmaRX\Google2FA\Google2FA;
uses(RefreshDatabase::class);
beforeEach(function () {
config([
'auth-ui.features.two_factor' => true,
'auth-ui.features.two_factor_required' => false,
'fortify.features' => [Features::twoFactorAuthentication()],
'fortify-options.two-factor-authentication' => ['confirm' => true],
'auth-ui.providers.github' => [
'label' => 'GitHub',
'icon' => 'i-simple-icons-github',
'enabled' => true,
],
]);
});
function enableTwoFactorForTest(User $user): array
{
app(EnableTwoFactorAuthentication::class)($user);
$user->forceFill(['two_factor_confirmed_at' => now()])->save();
$user->refresh();
$secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret);
return [
'code' => app(Google2FA::class)->getCurrentOtp($secret),
'recovery_codes' => $user->recoveryCodes(),
];
}
it('keeps the profile available while two-factor settings are disabled', function () {
config(['auth-ui.features.two_factor' => false]);
$this->actingAs(User::factory()->create())
->get('/profile')
->assertInertia(fn (Assert $page) => $page
->component('Profile/Show')
->where('twoFactor.available', false)
->where('twoFactor.enabled', false)
->where('twoFactor.pending', false));
});
it('redirects the former security page to the profile', function () {
$this->actingAs(User::factory()->create())
->get('/security')
->assertRedirect('/profile');
});
it('bypasses stored two-factor configuration when the feature is disabled', function () {
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => 'password123',
]);
enableTwoFactorForTest($user);
config(['auth-ui.features.two_factor' => false]);
$this->post('/login', [
'login' => 'test@example.com',
'password' => 'password123',
])->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
});
it('redirects authenticated users to enrollment when two-factor setup is mandatory', function () {
config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create();
$this->actingAs($user)
->get('/dashboard')
->assertRedirect('/profile')
->assertSessionHas('error', 'Two-factor authentication is required before you can continue.');
$this->actingAs($user)
->get('/profile')
->assertOk();
$this->actingAs($user)
->post('/logout')
->assertRedirect('/');
$this->assertGuest();
});
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');
expect($user->fresh()->two_factor_secret)->not->toBeNull()
->and($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
$this->actingAs($user)
->get('/dashboard')
->assertRedirect('/profile');
});
it('allows normal access after mandatory enrollment is complete', function () {
config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create();
enableTwoFactorForTest($user);
$this->actingAs($user)
->get('/dashboard')
->assertOk();
});
it('does not enforce the mandatory flag while the main two-factor feature is disabled', function () {
config([
'auth-ui.features.two_factor' => false,
'auth-ui.features.two_factor_required' => true,
]);
$this->actingAs(User::factory()->create())
->get('/dashboard')
->assertOk();
});
it('blocks authenticated API requests until mandatory enrollment is complete', function () {
config(['auth-ui.features.two_factor_required' => true]);
$user = User::factory()->create();
$this->actingAs($user)
->getJson('/api/user')
->assertForbidden()
->assertJson([
'message' => 'Two-factor authentication setup is required.',
'setup_url' => route('profile.show'),
]);
enableTwoFactorForTest($user);
$this->actingAs($user->fresh())
->getJson('/api/user')
->assertOk()
->assertJsonPath('id', $user->id);
});
it('requires the current password before a password user can enroll', function () {
$user = User::factory()->create(['password' => 'password123']);
$this->actingAs($user)
->post('/profile/two-factor', ['password' => 'wrong-password'])
->assertSessionHasErrors('password');
expect($user->fresh()->two_factor_secret)->toBeNull();
$this->actingAs($user)
->post('/profile/two-factor', ['password' => 'password123'])
->assertRedirect('/profile');
expect($user->fresh()->two_factor_secret)->not->toBeNull()
->and($user->fresh()->two_factor_confirmed_at)->toBeNull();
$this->actingAs($user)
->get('/profile')
->assertInertia(fn (Assert $page) => $page
->component('Profile/Show')
->where('twoFactor.pending', true)
->where('twoFactor.enabled', false)
->where('twoFactor.requiresPassword', true)
->where('twoFactor.qrCodeDataUri', fn ($value) => str_starts_with($value, 'data:image/svg+xml;base64,'))
->where('twoFactor.secretKey', fn ($value) => filled($value)));
});
it('confirms enrollment and returns recovery codes once', function () {
$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)
->post('/profile/two-factor/confirm', ['code' => $code])
->assertRedirect('/profile')
->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8);
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
});
it('holds password login until a valid authenticator code is supplied', function () {
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => 'password123',
]);
$twoFactor = enableTwoFactorForTest($user);
$loginResponse = $this->post('/login', [
'login' => 'test@example.com',
'password' => 'password123',
'remember' => true,
]);
$loginResponse->assertRedirect('/two-factor-challenge');
$this->assertGuest();
$loginResponse->assertSessionHas('login.id', $user->id);
$this->get('/two-factor-challenge')
->assertInertia(fn (Assert $page) => $page->component('Auth/TwoFactorChallenge'));
$this->post('/two-factor-challenge', ['code' => '000000'])
->assertSessionHasErrors('code');
$this->assertGuest();
$challengeResponse = $this->post('/two-factor-challenge', ['code' => $twoFactor['code']]);
$challengeResponse->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
$challengeResponse->assertSessionMissing('login.id');
});
it('accepts a recovery code once and replaces it after login', function () {
$user = User::factory()->create([
'email' => 'test@example.com',
'password' => 'password123',
]);
$twoFactor = enableTwoFactorForTest($user);
$recoveryCode = $twoFactor['recovery_codes'][0];
$this->post('/login', [
'login' => 'test@example.com',
'password' => 'password123',
])->assertRedirect('/two-factor-challenge');
$this->post('/two-factor-challenge', ['recovery_code' => $recoveryCode])
->assertRedirect('/dashboard');
$this->assertAuthenticatedAs($user);
expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode);
});
it('holds an existing social login at the same two-factor challenge', function () {
$user = User::factory()->social()->create();
$user->socialAccounts()->create([
'provider' => 'github',
'provider_id' => 'github-2fa',
]);
enableTwoFactorForTest($user);
Socialite::fake('github', (new SocialiteUser)->map([
'id' => 'github-2fa',
'name' => 'Social User',
'email' => $user->email,
'nickname' => $user->username,
]));
$response = $this->get('/auth/github/callback');
$response->assertRedirect('/two-factor-challenge');
$this->assertGuest();
$response->assertSessionHas('login.id', $user->id);
});
it('requires a valid second factor for social-only users to disable protection', function () {
$user = User::factory()->social()->create();
$twoFactor = enableTwoFactorForTest($user);
$this->actingAs($user)
->delete('/profile/two-factor', ['code' => 'invalid'])
->assertSessionHasErrors('code');
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue();
$this->actingAs($user)
->delete('/profile/two-factor', ['code' => $twoFactor['code']])
->assertRedirect('/profile');
expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse();
});
it('consumes recovery codes used to authorize a sensitive settings action', function () {
$user = User::factory()->social()->create();
$twoFactor = enableTwoFactorForTest($user);
$recoveryCode = $twoFactor['recovery_codes'][0];
$this->actingAs($user)
->post('/profile/two-factor/recovery-codes', [
'code' => $recoveryCode,
'regenerate' => false,
])
->assertRedirect('/profile')
->assertSessionHas('recoveryCodes');
expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode);
});
it('seeds a user matching the current user table structure', function () {
$this->seed(DatabaseSeeder::class);
$this->assertDatabaseHas('users', [
'username' => 'testuser',
'first_name' => 'Test',
'last_name' => 'User',
'email' => 'test@example.com',
]);
});