diff --git a/resources/js/pages/auth/two-factor-challenge.tsx b/resources/js/pages/auth/two-factor-challenge.tsx
new file mode 100644
index 000000000..c08724b0c
--- /dev/null
+++ b/resources/js/pages/auth/two-factor-challenge.tsx
@@ -0,0 +1,131 @@
+import InputError from '@/components/input-error';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from '@/components/ui/input-otp';
+import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
+import AuthLayout from '@/layouts/auth-layout';
+import { store } from '@/routes/two-factor/login';
+import { Form, Head } from '@inertiajs/react';
+import { REGEXP_ONLY_DIGITS } from 'input-otp';
+import { useMemo, useState } from 'react';
+
+export default function TwoFactorChallenge() {
+ const [showRecoveryInput, setShowRecoveryInput] = useState
(false);
+ const [code, setCode] = useState('');
+
+ const authConfigContent = useMemo<{
+ title: string;
+ description: string;
+ toggleText: string;
+ }>(() => {
+ if (showRecoveryInput) {
+ return {
+ title: 'Recovery Code',
+ description:
+ 'Please confirm access to your account by entering one of your emergency recovery codes.',
+ toggleText: 'login using an authentication code',
+ };
+ }
+
+ return {
+ title: 'Authentication Code',
+ description:
+ 'Enter the authentication code provided by your authenticator application.',
+ toggleText: 'login using a recovery code',
+ };
+ }, [showRecoveryInput]);
+
+ const toggleRecoveryMode = (clearErrors: () => void): void => {
+ setShowRecoveryInput(!showRecoveryInput);
+ clearErrors();
+ setCode('');
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/resources/js/pages/settings/two-factor.tsx b/resources/js/pages/settings/two-factor.tsx
new file mode 100644
index 000000000..43f203e23
--- /dev/null
+++ b/resources/js/pages/settings/two-factor.tsx
@@ -0,0 +1,137 @@
+import HeadingSmall from '@/components/heading-small';
+import TwoFactorRecoveryCodes from '@/components/two-factor-recovery-codes';
+import TwoFactorSetupModal from '@/components/two-factor-setup-modal';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { useTwoFactorAuth } from '@/hooks/use-two-factor-auth';
+import AppLayout from '@/layouts/app-layout';
+import SettingsLayout from '@/layouts/settings/layout';
+import { disable, enable, show } from '@/routes/two-factor';
+import { type BreadcrumbItem } from '@/types';
+import { Form, Head } from '@inertiajs/react';
+import { ShieldBan, ShieldCheck } from 'lucide-react';
+import { useState } from 'react';
+
+interface TwoFactorProps {
+ requiresConfirmation?: boolean;
+ twoFactorEnabled?: boolean;
+}
+
+const breadcrumbs: BreadcrumbItem[] = [
+ {
+ title: 'Two-Factor Authentication',
+ href: show.url(),
+ },
+];
+
+export default function TwoFactor({
+ requiresConfirmation = false,
+ twoFactorEnabled = false,
+}: TwoFactorProps) {
+ const {
+ qrCodeSvg,
+ hasSetupData,
+ manualSetupKey,
+ clearSetupData,
+ fetchSetupData,
+ recoveryCodesList,
+ fetchRecoveryCodes,
+ errors,
+ } = useTwoFactorAuth();
+ const [showSetupModal, setShowSetupModal] = useState(false);
+
+ return (
+
+
+
+
+
+ {twoFactorEnabled ? (
+
+
Enabled
+
+ With two-factor authentication enabled, you will
+ be prompted for a secure, random pin during
+ login, which you can retrieve from the
+ TOTP-supported application on your phone.
+
+
+
+
+
+
+
+
+ ) : (
+
+
Disabled
+
+ When you enable two-factor authentication, you
+ will be prompted for a secure pin during login.
+ This pin can be retrieved from a TOTP-supported
+ application on your phone.
+
+
+
+ {hasSetupData ? (
+ setShowSetupModal(true)}
+ >
+
+ Continue Setup
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
setShowSetupModal(false)}
+ requiresConfirmation={requiresConfirmation}
+ twoFactorEnabled={twoFactorEnabled}
+ qrCodeSvg={qrCodeSvg}
+ manualSetupKey={manualSetupKey}
+ clearSetupData={clearSetupData}
+ fetchSetupData={fetchSetupData}
+ errors={errors}
+ />
+
+
+
+ );
+}
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts
index 42f88e8d9..2f10844c7 100644
--- a/resources/js/types/index.d.ts
+++ b/resources/js/types/index.d.ts
@@ -36,6 +36,7 @@ export interface User {
email: string;
avatar?: string;
email_verified_at: string | null;
+ two_factor_enabled?: boolean;
created_at: string;
updated_at: string;
[key: string]: unknown; // This allows for additional properties...
diff --git a/routes/auth.php b/routes/auth.php
index 168a9f4bc..191847eba 100644
--- a/routes/auth.php
+++ b/routes/auth.php
@@ -1,7 +1,6 @@
middleware('throttle:6,1')
->name('verification.send');
- Route::get('confirm-password', [ConfirmablePasswordController::class, 'show'])
- ->name('password.confirm');
-
- Route::post('confirm-password', [ConfirmablePasswordController::class, 'store'])
- ->middleware('throttle:6,1')
- ->name('password.confirm.store');
-
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});
diff --git a/routes/settings.php b/routes/settings.php
index 67bb6bbe8..98dd9d730 100644
--- a/routes/settings.php
+++ b/routes/settings.php
@@ -2,6 +2,7 @@
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
+use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -21,4 +22,7 @@
Route::get('settings/appearance', function () {
return Inertia::render('settings/appearance');
})->name('appearance.edit');
+
+ Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show'])
+ ->name('two-factor.show');
});
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
index 46578335d..7c2a12367 100644
--- a/tests/Feature/Auth/AuthenticationTest.php
+++ b/tests/Feature/Auth/AuthenticationTest.php
@@ -4,6 +4,8 @@
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\RateLimiter;
+use Laravel\Fortify\Features;
use Tests\TestCase;
class AuthenticationTest extends TestCase
@@ -30,6 +32,35 @@ public function test_users_can_authenticate_using_the_login_screen()
$response->assertRedirect(route('dashboard', absolute: false));
}
+ public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge()
+ {
+ if (! Features::canManageTwoFactorAuthentication()) {
+ $this->markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $user = User::factory()->create();
+
+ $user->forceFill([
+ 'two_factor_secret' => encrypt('test-secret'),
+ 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
+ 'two_factor_confirmed_at' => now(),
+ ])->save();
+
+ $response = $this->post(route('login'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ ]);
+
+ $response->assertRedirect(route('two-factor.login'));
+ $response->assertSessionHas('login.id', $user->id);
+ $this->assertGuest();
+ }
+
public function test_users_can_not_authenticate_with_invalid_password()
{
$user = User::factory()->create();
@@ -56,14 +87,7 @@ public function test_users_are_rate_limited()
{
$user = User::factory()->create();
- for ($i = 0; $i < 5; $i++) {
- $this->post(route('login.store'), [
- 'email' => $user->email,
- 'password' => 'wrong-password',
- ])->assertStatus(302)->assertSessionHasErrors([
- 'email' => 'These credentials do not match our records.',
- ]);
- }
+ RateLimiter::increment(implode('|', [$user->email, '127.0.0.1']), amount: 10);
$response = $this->post(route('login.store'), [
'email' => $user->email,
diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php
index 9ff9f0052..5191f9c20 100644
--- a/tests/Feature/Auth/PasswordConfirmationTest.php
+++ b/tests/Feature/Auth/PasswordConfirmationTest.php
@@ -4,6 +4,7 @@
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
@@ -17,28 +18,16 @@ public function test_confirm_password_screen_can_be_rendered()
$response = $this->actingAs($user)->get(route('password.confirm'));
$response->assertStatus(200);
- }
-
- public function test_password_can_be_confirmed()
- {
- $user = User::factory()->create();
- $response = $this->actingAs($user)->post(route('password.confirm.store'), [
- 'password' => 'password',
- ]);
-
- $response->assertRedirect();
- $response->assertSessionHasNoErrors();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('auth/confirm-password')
+ );
}
- public function test_password_is_not_confirmed_with_invalid_password()
+ public function test_password_confirmation_requires_authentication()
{
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)->post(route('password.confirm.store'), [
- 'password' => 'wrong-password',
- ]);
+ $response = $this->get(route('password.confirm'));
- $response->assertSessionHasErrors();
+ $response->assertRedirect(route('login'));
}
}
diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php
new file mode 100644
index 000000000..b8d6b803e
--- /dev/null
+++ b/tests/Feature/Auth/TwoFactorChallengeTest.php
@@ -0,0 +1,56 @@
+markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ $response = $this->get(route('two-factor.login'));
+
+ $response->assertRedirect(route('login'));
+ }
+
+ public function test_two_factor_challenge_can_be_rendered(): void
+ {
+ if (! Features::canManageTwoFactorAuthentication()) {
+ $this->markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $user = User::factory()->create();
+
+ $user->forceFill([
+ 'two_factor_secret' => encrypt('test-secret'),
+ 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
+ 'two_factor_confirmed_at' => now(),
+ ])->save();
+
+ $this->post(route('login'), [
+ 'email' => $user->email,
+ 'password' => 'password',
+ ]);
+
+ $this->get(route('two-factor.login'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('auth/two-factor-challenge')
+ );
+ }
+}
diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php
new file mode 100644
index 000000000..12dca797c
--- /dev/null
+++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php
@@ -0,0 +1,92 @@
+markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->withSession(['auth.password_confirmed_at' => time()])
+ ->get(route('two-factor.show'))
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('settings/two-factor')
+ ->where('twoFactorEnabled', false)
+ );
+ }
+
+ public function test_two_factor_settings_page_requires_password_confirmation_when_enabled()
+ {
+ if (! Features::canManageTwoFactorAuthentication()) {
+ $this->markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ $user = User::factory()->create();
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => true,
+ ]);
+
+ $response = $this->actingAs($user)
+ ->get(route('two-factor.show'));
+
+ $response->assertRedirect(route('password.confirm'));
+ }
+
+ public function test_two_factor_settings_page_does_not_requires_password_confirmation_when_disabled()
+ {
+ if (! Features::canManageTwoFactorAuthentication()) {
+ $this->markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ $user = User::factory()->create();
+
+ Features::twoFactorAuthentication([
+ 'confirm' => true,
+ 'confirmPassword' => false,
+ ]);
+
+ $this->actingAs($user)
+ ->get(route('two-factor.show'))
+ ->assertOk()
+ ->assertInertia(fn (Assert $page) => $page
+ ->component('settings/two-factor')
+ );
+ }
+
+ public function test_two_factor_settings_page_returns_forbidden_response_when_two_factor_is_disabled()
+ {
+ if (! Features::canManageTwoFactorAuthentication()) {
+ $this->markTestSkipped('Two-factor authentication is not enabled.');
+ }
+
+ config(['fortify.features' => []]);
+
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->withSession(['auth.password_confirmed_at' => time()])
+ ->get(route('two-factor.show'))
+ ->assertForbidden();
+ }
+}