diff --git a/.gitignore b/.gitignore index c625a11f7..afac65771 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /storage/*.key /storage/pail /vendor +.DS_Store .env .env.backup .env.production diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index b4a48d946..80da6826b 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Inertia\Response; +use Laravel\Fortify\Features; class AuthenticatedSessionController extends Controller { @@ -29,7 +30,18 @@ public function create(Request $request): Response */ public function store(LoginRequest $request): RedirectResponse { - $request->authenticate(); + $user = $request->validateCredentials(); + + if (Features::enabled(Features::twoFactorAuthentication()) && $user->hasEnabledTwoFactorAuthentication()) { + $request->session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => $request->boolean('remember'), + ]); + + return to_route('two-factor.login'); + } + + Auth::login($user, $request->boolean('remember')); $request->session()->regenerate(); diff --git a/app/Http/Controllers/Auth/ConfirmablePasswordController.php b/app/Http/Controllers/Auth/ConfirmablePasswordController.php deleted file mode 100644 index c729706d6..000000000 --- a/app/Http/Controllers/Auth/ConfirmablePasswordController.php +++ /dev/null @@ -1,41 +0,0 @@ -validate([ - 'email' => $request->user()->email, - 'password' => $request->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - $request->session()->put('auth.password_confirmed_at', time()); - - return redirect()->intended(route('dashboard', absolute: false)); - } -} diff --git a/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php new file mode 100644 index 000000000..5203fcf49 --- /dev/null +++ b/app/Http/Controllers/Settings/TwoFactorAuthenticationController.php @@ -0,0 +1,37 @@ +ensureStateIsValid(); + + return Inertia::render('settings/two-factor', [ + 'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(), + 'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'), + ]); + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index feef49848..d426f112c 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\Auth; +use App\Models\User; use Illuminate\Auth\Events\Lockout; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; @@ -32,15 +33,18 @@ public function rules(): array } /** - * Attempt to authenticate the request's credentials. + * Validate the request's credentials and return the user without logging them in. * * @throws \Illuminate\Validation\ValidationException */ - public function authenticate(): void + public function validateCredentials(): User { $this->ensureIsNotRateLimited(); - if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + /** @var User|null $user */ + $user = Auth::getProvider()->retrieveByCredentials($this->only('email', 'password')); + + if (! $user || ! Auth::getProvider()->validateCredentials($user, $this->only('password'))) { RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ @@ -49,6 +53,8 @@ public function authenticate(): void } RateLimiter::clear($this->throttleKey()); + + return $user; } /** @@ -75,7 +81,7 @@ public function ensureIsNotRateLimited(): void } /** - * Get the rate limiting throttle key for the request. + * Get the rate-limiting throttle key for the request. */ public function throttleKey(): string { diff --git a/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php new file mode 100644 index 000000000..9db81d217 --- /dev/null +++ b/app/Http/Requests/Settings/TwoFactorAuthenticationRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b77d..dea2e926e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php new file mode 100644 index 000000000..c13f6ee17 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,34 @@ + Inertia::render('auth/two-factor-challenge')); + Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password')); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d18..0ad9c5732 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\FortifyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index a53557207..8f0e9dc1f 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", + "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9" diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 000000000..df49e4f8c --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,159 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Fortify Password Broker + |-------------------------------------------------------------------------- + | + | Here you may specify which password broker Fortify can use when a user + | is resetting their password. This configured value should match one + | of your password brokers setup in your "auth" configuration file. + | + */ + + 'passwords' => 'users', + + /* + |-------------------------------------------------------------------------- + | Username / Email + |-------------------------------------------------------------------------- + | + | This value defines which model attribute should be considered as your + | application's "username" field. Typically, this might be the email + | address of the users but you are free to change this value here. + | + | Out of the box, Fortify expects forgot password and reset password + | requests to have a field named 'email'. If the application uses + | another name for the field you may define it below as needed. + | + */ + + 'username' => 'email', + + 'email' => 'email', + + /* + |-------------------------------------------------------------------------- + | Lowercase Usernames + |-------------------------------------------------------------------------- + | + | This value defines whether usernames should be lowercased before saving + | them in the database, as some database system string fields are case + | sensitive. You may disable this for your application if necessary. + | + */ + + 'lowercase_usernames' => true, + + /* + |-------------------------------------------------------------------------- + | Home Path + |-------------------------------------------------------------------------- + | + | Here you may configure the path where users will get redirected during + | authentication or password reset when the operations are successful + | and the user is authenticated. You are free to change this value. + | + */ + + 'home' => '/dashboard', + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Prefix / Subdomain + |-------------------------------------------------------------------------- + | + | Here you may specify which prefix Fortify will assign to all the routes + | that it registers with the application. If necessary, you may change + | subdomain under which all of the Fortify routes will be available. + | + */ + + 'prefix' => '', + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | Fortify Routes Middleware + |-------------------------------------------------------------------------- + | + | Here you may specify which middleware Fortify will assign to the routes + | that it registers with the application. If necessary, you may change + | these middleware but typically this provided default is preferred. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Rate Limiting + |-------------------------------------------------------------------------- + | + | By default, Fortify will throttle logins to five requests per minute for + | every email and IP address combination. However, if you would like to + | specify a custom rate limiter to call then you may specify it here. + | + */ + + 'limiters' => [ + 'login' => 'login', + 'two-factor' => 'two-factor', + ], + + /* + |-------------------------------------------------------------------------- + | Register View Routes + |-------------------------------------------------------------------------- + | + | Here you may specify if the routes returning views should be disabled as + | you may not need them when building your own application. This may be + | especially true if you're writing a custom single-page application. + | + */ + + 'views' => true, + + /* + |-------------------------------------------------------------------------- + | Features + |-------------------------------------------------------------------------- + | + | Some of the Fortify features are optional. You may disable the features + | by removing them from this array. You're free to only remove some of + | these features, or you can even remove all of these if you need to. + | + */ + + 'features' => [ + // Features::registration(), + // Features::resetPasswords(), + // Features::emailVerification(), + // Features::updateProfileInformation(), + // Features::updatePasswords(), + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + // 'window' => 0 + ]), + ], + +]; diff --git a/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php new file mode 100644 index 000000000..187d974d6 --- /dev/null +++ b/database/migrations/2025_08_26_100418_add_two_factor_columns_to_users_table.php @@ -0,0 +1,34 @@ +text('two_factor_secret')->after('password')->nullable(); + $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable(); + $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable(); + }); + } + + /** + * 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', + ]); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index f437a2a74..b772934dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "": { "dependencies": { "@headlessui/react": "^2.2.0", - "@inertiajs/react": "^2.1.0", + "@inertiajs/react": "^2.1.4", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -16,7 +16,7 @@ "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", @@ -28,6 +28,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", + "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", "react": "^19.0.0", @@ -975,22 +976,24 @@ } }, "node_modules/@inertiajs/core": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.1.2.tgz", - "integrity": "sha512-fS3bDanwIZMEhtndhs1NvDvFN7y9Nx+FPkuBLSjIvYXFVmwieZmj+q2SYLXVl/jKt0qg69GwfLVrNm+gFiFbMg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.1.4.tgz", + "integrity": "sha512-Mvq9itSwlAatS2zc6I+Pyu2bLnmgEm/fHwDd1AQ8xZUCLBKW5IaE2Y8hKKcuKcACU2Nu68jASq+hP5Inq7WXAQ==", "dependencies": { - "axios": "^1.8.2", - "es-toolkit": "^1.34.1", + "@types/lodash-es": "^4.17.12", + "axios": "^1.11.0", + "lodash-es": "^4.17.21", "qs": "^6.9.0" } }, "node_modules/@inertiajs/react": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.1.2.tgz", - "integrity": "sha512-hh3dQxoEumdjSRyMajYkEnG3fb3xkyexBD8tTSjo5OeulE/VteEjS7ZM8tNseM7ya/jb3G6ccoc5MSlYEh6atg==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-2.1.4.tgz", + "integrity": "sha512-vgvlMY/rh3sWsZRnJoeE4Sdu5basBu1z4CHk4KWzlmFWYzV+gGxIVRTgsdWn6rbfaUqD03FSG5WYrpYe94WOow==", "dependencies": { - "@inertiajs/core": "2.1.2", - "es-toolkit": "^1.33.0" + "@inertiajs/core": "2.1.4", + "@types/lodash-es": "^4.17.12", + "lodash-es": "^4.17.21" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2525,6 +2528,60 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.4.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.0.4", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.4.5", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.0", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz", @@ -2641,6 +2698,19 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "22.17.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.2.tgz", @@ -3807,15 +3877,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.39.9", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.9.tgz", - "integrity": "sha512-9OtbkZmTA2Qc9groyA1PUNeb6knVTkvB2RSdr/LcJXDL8IdEakaxwXLHXa7VX/Wj0GmdMJPR3WhnPGhiP3E+qg==", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -4549,6 +4610,15 @@ "node": ">=0.8.19" } }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -5323,6 +5393,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index 04e6b6527..e186f6922 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "dependencies": { "@headlessui/react": "^2.2.0", - "@inertiajs/react": "^2.1.0", + "@inertiajs/react": "^2.1.4", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-collapsible": "^1.1.3", @@ -35,7 +35,7 @@ "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", - "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", @@ -47,6 +47,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "globals": "^15.14.0", + "input-otp": "^1.4.2", "laravel-vite-plugin": "^2.0", "lucide-react": "^0.475.0", "react": "^19.0.0", diff --git a/resources/js/components/alert-error.tsx b/resources/js/components/alert-error.tsx new file mode 100644 index 000000000..8cc228bd4 --- /dev/null +++ b/resources/js/components/alert-error.tsx @@ -0,0 +1,24 @@ +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { AlertCircleIcon } from 'lucide-react'; + +export default function AlertError({ + errors, + title, +}: { + errors: string[]; + title?: string; +}) { + return ( + + + {title || 'Something went wrong.'} + +
    + {Array.from(new Set(errors)).map((error, index) => ( +
  • {error}
  • + ))} +
+
+
+ ); +} diff --git a/resources/js/components/two-factor-recovery-codes.tsx b/resources/js/components/two-factor-recovery-codes.tsx new file mode 100644 index 000000000..918208113 --- /dev/null +++ b/resources/js/components/two-factor-recovery-codes.tsx @@ -0,0 +1,164 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { regenerateRecoveryCodes } from '@/routes/two-factor'; +import { Form } from '@inertiajs/react'; +import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import AlertError from './alert-error'; + +interface TwoFactorRecoveryCodesProps { + recoveryCodesList: string[]; + fetchRecoveryCodes: () => Promise; + errors: string[]; +} + +export default function TwoFactorRecoveryCodes({ + recoveryCodesList, + fetchRecoveryCodes, + errors, +}: TwoFactorRecoveryCodesProps) { + const [codesAreVisible, setCodesAreVisible] = useState(false); + const codesSectionRef = useRef(null); + const canRegenerateCodes = recoveryCodesList.length > 0 && codesAreVisible; + + const toggleCodesVisibility = useCallback(async () => { + if (!codesAreVisible && !recoveryCodesList.length) { + await fetchRecoveryCodes(); + } + + setCodesAreVisible(!codesAreVisible); + + if (!codesAreVisible) { + setTimeout(() => { + codesSectionRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + }); + }); + } + }, [codesAreVisible, recoveryCodesList.length, fetchRecoveryCodes]); + + useEffect(() => { + if (!recoveryCodesList.length) { + fetchRecoveryCodes(); + } + }, [recoveryCodesList.length, fetchRecoveryCodes]); + + const RecoveryCodeIconComponent = codesAreVisible ? EyeOff : Eye; + + return ( + + + + + + Recovery codes let you regain access if you lose your 2FA + device. Store them in a secure password manager. + + + +
+ + + {canRegenerateCodes && ( +
+ {({ processing }) => ( + + )} +
+ )} +
+
+
+ {errors?.length ? ( + + ) : ( + <> +
+ {recoveryCodesList.length ? ( + recoveryCodesList.map((code, index) => ( +
+ {code} +
+ )) + ) : ( +
+ {Array.from( + { length: 8 }, + (_, index) => ( + + )} +
+ +
+

+ Each recovery code can be used once to + access your account and will be removed + after use. If you need more, click{' '} + + Regenerate Codes + {' '} + above. +

+
+ + )} +
+
+ + + ); +} diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx new file mode 100644 index 000000000..317149ea1 --- /dev/null +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -0,0 +1,338 @@ +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; +import { useClipboard } from '@/hooks/use-clipboard'; +import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; +import { confirm } from '@/routes/two-factor'; +import { Form } from '@inertiajs/react'; +import { REGEXP_ONLY_DIGITS } from 'input-otp'; +import { Check, Copy, Loader2, ScanLine } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import AlertError from './alert-error'; + +function GridScanIcon() { + return ( +
+
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+
+ {Array.from({ length: 5 }, (_, i) => ( +
+ ))} +
+ +
+
+ ); +} + +function TwoFactorSetupStep({ + qrCodeSvg, + manualSetupKey, + buttonText, + onNextStep, + errors, +}: { + qrCodeSvg: string | null; + manualSetupKey: string | null; + buttonText: string; + onNextStep: () => void; + errors: string[]; +}) { + const [copiedText, copy] = useClipboard(); + const IconComponent = copiedText === manualSetupKey ? Check : Copy; + + return ( + <> + {errors?.length ? ( + + ) : ( + <> +
+
+
+ {qrCodeSvg ? ( +
+ ) : ( + + )} +
+
+
+ +
+ +
+ +
+
+ + or, enter the code manually + +
+ +
+
+ {!manualSetupKey ? ( +
+ +
+ ) : ( + <> + + + + )} +
+
+ + )} + + ); +} + +function TwoFactorVerificationStep({ + onClose, + onBack, +}: { + onClose: () => void; + onBack: () => void; +}) { + const [code, setCode] = useState(''); + const pinInputContainerRef = useRef(null); + + useEffect(() => { + setTimeout(() => { + pinInputContainerRef.current?.querySelector('input')?.focus(); + }, 0); + }, []); + + return ( +
onClose()} + resetOnError + resetOnSuccess + > + {({ + processing, + errors, + }: { + processing: boolean; + errors?: { confirmTwoFactorAuthentication?: { code?: string } }; + }) => ( + <> +
+
+ + + {Array.from( + { length: OTP_MAX_LENGTH }, + (_, index) => ( + + ), + )} + + + +
+ +
+ + +
+
+ + )} +
+ ); +} + +interface TwoFactorSetupModalProps { + isOpen: boolean; + onClose: () => void; + requiresConfirmation: boolean; + twoFactorEnabled: boolean; + qrCodeSvg: string | null; + manualSetupKey: string | null; + clearSetupData: () => void; + fetchSetupData: () => Promise; + errors: string[]; +} + +export default function TwoFactorSetupModal({ + isOpen, + onClose, + requiresConfirmation, + twoFactorEnabled, + qrCodeSvg, + manualSetupKey, + clearSetupData, + fetchSetupData, + errors, +}: TwoFactorSetupModalProps) { + const [showVerificationStep, setShowVerificationStep] = + useState(false); + + const modalConfig = useMemo<{ + title: string; + description: string; + buttonText: string; + }>(() => { + if (twoFactorEnabled) { + return { + title: 'Two-Factor Authentication Enabled', + description: + 'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.', + buttonText: 'Close', + }; + } + + if (showVerificationStep) { + return { + title: 'Verify Authentication Code', + description: + 'Enter the 6-digit code from your authenticator app', + buttonText: 'Continue', + }; + } + + return { + title: 'Enable Two-Factor Authentication', + description: + 'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app', + buttonText: 'Continue', + }; + }, [twoFactorEnabled, showVerificationStep]); + + const handleModalNextStep = useCallback(() => { + if (requiresConfirmation) { + setShowVerificationStep(true); + + return; + } + + clearSetupData(); + onClose(); + }, [requiresConfirmation, clearSetupData, onClose]); + + const resetModalState = useCallback(() => { + setShowVerificationStep(false); + if (twoFactorEnabled) { + clearSetupData(); + } + }, [twoFactorEnabled, clearSetupData]); + + useEffect(() => { + if (!isOpen) { + resetModalState(); + + return; + } + + if (!qrCodeSvg) { + fetchSetupData(); + } + }, [isOpen, qrCodeSvg, fetchSetupData, resetModalState]); + + return ( + !open && onClose()}> + + + + {modalConfig.title} + + {modalConfig.description} + + + +
+ {showVerificationStep ? ( + setShowVerificationStep(false)} + /> + ) : ( + + )} +
+
+
+ ); +} diff --git a/resources/js/components/ui/badge.tsx b/resources/js/components/ui/badge.tsx index 268ea771c..02054139a 100644 --- a/resources/js/components/ui/badge.tsx +++ b/resources/js/components/ui/badge.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-auto", + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", { variants: { variant: { @@ -14,7 +14,7 @@ const badgeVariants = cva( secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", destructive: - "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", }, diff --git a/resources/js/components/ui/input-otp.tsx b/resources/js/components/ui/input-otp.tsx new file mode 100644 index 000000000..f7891c9cc --- /dev/null +++ b/resources/js/components/ui/input-otp.tsx @@ -0,0 +1,69 @@ +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Minus } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, containerClassName, ...props }, ref) => ( + +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ className, ...props }, ref) => ( +
+)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> & { index: number } +>(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ) +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef< + React.ElementRef<"div">, + React.ComponentPropsWithoutRef<"div"> +>(({ ...props }, ref) => ( +
+ +
+)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } diff --git a/resources/js/hooks/use-clipboard.ts b/resources/js/hooks/use-clipboard.ts new file mode 100644 index 000000000..eef514b0e --- /dev/null +++ b/resources/js/hooks/use-clipboard.ts @@ -0,0 +1,32 @@ +// Credit: https://usehooks-ts.com/ +import { useCallback, useState } from 'react'; + +type CopiedValue = string | null; + +type CopyFn = (text: string) => Promise; + +export function useClipboard(): [CopiedValue, CopyFn] { + const [copiedText, setCopiedText] = useState(null); + + const copy: CopyFn = useCallback(async (text) => { + if (!navigator?.clipboard) { + console.warn('Clipboard not supported'); + + return false; + } + + try { + await navigator.clipboard.writeText(text); + setCopiedText(text); + + return true; + } catch (error) { + console.warn('Copy failed', error); + setCopiedText(null); + + return false; + } + }, []); + + return [copiedText, copy]; +} diff --git a/resources/js/hooks/use-two-factor-auth.ts b/resources/js/hooks/use-two-factor-auth.ts new file mode 100644 index 000000000..384e20625 --- /dev/null +++ b/resources/js/hooks/use-two-factor-auth.ts @@ -0,0 +1,104 @@ +import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor'; +import { useCallback, useMemo, useState } from 'react'; + +interface TwoFactorSetupData { + svg: string; + url: string; +} + +interface TwoFactorSecretKey { + secretKey: string; +} + +export const OTP_MAX_LENGTH = 6; + +const fetchJson = async (url: string): Promise => { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + return response.json(); +}; + +export const useTwoFactorAuth = () => { + const [qrCodeSvg, setQrCodeSvg] = useState(null); + const [manualSetupKey, setManualSetupKey] = useState(null); + const [recoveryCodesList, setRecoveryCodesList] = useState([]); + const [errors, setErrors] = useState([]); + + const hasSetupData = useMemo( + () => qrCodeSvg !== null && manualSetupKey !== null, + [qrCodeSvg, manualSetupKey], + ); + + const fetchQrCode = useCallback(async (): Promise => { + try { + const { svg } = await fetchJson(qrCode.url()); + setQrCodeSvg(svg); + } catch { + setErrors((prev) => [...prev, 'Failed to fetch QR code']); + setQrCodeSvg(null); + } + }, []); + + const fetchSetupKey = useCallback(async (): Promise => { + try { + const { secretKey: key } = await fetchJson( + secretKey.url(), + ); + setManualSetupKey(key); + } catch { + setErrors((prev) => [...prev, 'Failed to fetch a setup key']); + setManualSetupKey(null); + } + }, []); + + const clearErrors = useCallback((): void => { + setErrors([]); + }, []); + + const clearSetupData = useCallback((): void => { + setManualSetupKey(null); + setQrCodeSvg(null); + clearErrors(); + }, [clearErrors]); + + const fetchRecoveryCodes = useCallback(async (): Promise => { + try { + clearErrors(); + const codes = await fetchJson(recoveryCodes.url()); + setRecoveryCodesList(codes); + } catch { + setErrors((prev) => [...prev, 'Failed to fetch recovery codes']); + setRecoveryCodesList([]); + } + }, [clearErrors]); + + const fetchSetupData = useCallback(async (): Promise => { + try { + clearErrors(); + await Promise.all([fetchQrCode(), fetchSetupKey()]); + } catch { + setQrCodeSvg(null); + setManualSetupKey(null); + } + }, [clearErrors, fetchQrCode, fetchSetupKey]); + + return { + qrCodeSvg, + manualSetupKey, + recoveryCodesList, + hasSetupData, + errors, + clearErrors, + clearSetupData, + fetchQrCode, + fetchSetupKey, + fetchSetupData, + fetchRecoveryCodes, + }; +}; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 0e7990662..c128d82c5 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'; import { edit as editAppearance } from '@/routes/appearance'; import { edit as editPassword } from '@/routes/password'; import { edit } from '@/routes/profile'; +import { show } from '@/routes/two-factor'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; import { type PropsWithChildren } from 'react'; @@ -20,6 +21,11 @@ const sidebarNavItems: NavItem[] = [ href: editPassword(), icon: null, }, + { + title: 'Two-Factor Auth', + href: show(), + icon: null, + }, { title: 'Appearance', href: editAppearance(), @@ -59,7 +65,7 @@ export default function SettingsLayout({ children }: PropsWithChildren) { : item.href.url), })} > - + {item.icon && ( )} diff --git a/resources/js/pages/auth/confirm-password.tsx b/resources/js/pages/auth/confirm-password.tsx index 2ab143d72..fe0ebc5aa 100644 --- a/resources/js/pages/auth/confirm-password.tsx +++ b/resources/js/pages/auth/confirm-password.tsx @@ -1,9 +1,9 @@ -import ConfirmablePasswordController from '@/actions/App/Http/Controllers/Auth/ConfirmablePasswordController'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import AuthLayout from '@/layouts/auth-layout'; +import { store } from '@/routes/password/confirm'; import { Form, Head } from '@inertiajs/react'; import { LoaderCircle } from 'lucide-react'; @@ -15,10 +15,7 @@ export default function ConfirmPassword() { > -
+ {({ processing, errors }) => (
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 ( + + + +
+ + {({ errors, processing, clearErrors }) => ( + <> + {showRecoveryInput ? ( + <> + + + + ) : ( +
+
+ setCode(value)} + disabled={processing} + pattern={REGEXP_ONLY_DIGITS} + > + + {Array.from( + { length: OTP_MAX_LENGTH }, + (_, index) => ( + + ), + )} + + +
+ +
+ )} + + + +
+ or you can + +
+ + )} + +
+
+ ); +} 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. +

+ + + +
+
+ {({ processing }) => ( + + )} +
+
+
+ ) : ( +
+ 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) + } + > + {({ processing }) => ( + + )} +
+ )} +
+
+ )} + + 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(); + } +}