diff --git a/app/Livewire/Auth/ConfirmPassword.php b/app/Livewire/Auth/ConfirmPassword.php deleted file mode 100644 index 9a89db0c..00000000 --- a/app/Livewire/Auth/ConfirmPassword.php +++ /dev/null @@ -1,37 +0,0 @@ -validate([ - 'password' => ['required', 'string'], - ]); - - if (! Auth::guard('web')->validate([ - 'email' => Auth::user()->email, - 'password' => $this->password, - ])) { - throw ValidationException::withMessages([ - 'password' => __('auth.password'), - ]); - } - - session(['auth.password_confirmed_at' => time()]); - - $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); - } -} diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php index 9925f639..88622619 100644 --- a/app/Livewire/Auth/Login.php +++ b/app/Livewire/Auth/Login.php @@ -2,12 +2,14 @@ namespace App\Livewire\Auth; +use App\Models\User; use Illuminate\Auth\Events\Lockout; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Session; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; +use Laravel\Fortify\Features; use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; use Livewire\Component; @@ -32,20 +34,45 @@ public function login(): void $this->ensureIsNotRateLimited(); - if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) { - RateLimiter::hit($this->throttleKey()); + $user = $this->validateCredentials(); - throw ValidationException::withMessages([ - 'email' => __('auth.failed'), + if (Features::canManageTwoFactorAuthentication() && $user->hasEnabledTwoFactorAuthentication()) { + Session::put([ + 'login.id' => $user->getKey(), + 'login.remember' => $this->remember, ]); + + $this->redirect(route('two-factor.login'), navigate: true); + + return; } + Auth::login($user, $this->remember); + RateLimiter::clear($this->throttleKey()); Session::regenerate(); $this->redirectIntended(default: route('dashboard', absolute: false), navigate: true); } + /** + * Validate the user's credentials. + */ + protected function validateCredentials(): User + { + $user = Auth::getProvider()->retrieveByCredentials(['email' => $this->email, 'password' => $this->password]); + + if (! $user || ! Auth::getProvider()->validateCredentials($user, ['password' => $this->password])) { + RateLimiter::hit($this->throttleKey()); + + throw ValidationException::withMessages([ + 'email' => __('auth.failed'), + ]); + } + + return $user; + } + /** * Ensure the authentication request is not rate limited. */ diff --git a/app/Livewire/Settings/TwoFactor.php b/app/Livewire/Settings/TwoFactor.php new file mode 100644 index 00000000..a1641b56 --- /dev/null +++ b/app/Livewire/Settings/TwoFactor.php @@ -0,0 +1,182 @@ +user()->two_factor_confirmed_at)) { + $disableTwoFactorAuthentication(auth()->user()); + } + + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); + } + + /** + * Enable two-factor authentication for the user. + */ + public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void + { + $enableTwoFactorAuthentication(auth()->user()); + + if (! $this->requiresConfirmation) { + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + } + + $this->loadSetupData(); + + $this->showModal = true; + } + + /** + * Load the two-factor authentication setup data for the user. + */ + private function loadSetupData(): void + { + $user = auth()->user(); + + try { + $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); + $this->manualSetupKey = decrypt($user->two_factor_secret); + } catch (Exception) { + $this->addError('setupData', 'Failed to fetch setup data.'); + + $this->reset('qrCodeSvg', 'manualSetupKey'); + } + } + + /** + * Show the two-factor verification step if necessary. + */ + public function showVerificationIfNecessary(): void + { + if ($this->requiresConfirmation) { + $this->showVerificationStep = true; + + $this->resetErrorBag(); + + return; + } + + $this->closeModal(); + } + + /** + * Confirm two-factor authentication for the user. + */ + public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void + { + $this->validate(); + + $confirmTwoFactorAuthentication(auth()->user(), $this->code); + + $this->closeModal(); + + $this->twoFactorEnabled = true; + } + + /** + * Reset two-factor verification state. + */ + public function resetVerification(): void + { + $this->reset('code', 'showVerificationStep'); + + $this->resetErrorBag(); + } + + /** + * Disable two-factor authentication for the user. + */ + public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void + { + $disableTwoFactorAuthentication(auth()->user()); + + $this->twoFactorEnabled = false; + } + + /** + * Close the two-factor authentication modal. + */ + public function closeModal(): void + { + $this->reset( + 'code', + 'manualSetupKey', + 'qrCodeSvg', + 'showModal', + 'showVerificationStep', + ); + + $this->resetErrorBag(); + + if (! $this->requiresConfirmation) { + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + } + } + + /** + * Get the current modal configuration state. + */ + public function getModalConfigProperty(): array + { + if ($this->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 ($this->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'), + ]; + } +} diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php new file mode 100644 index 00000000..7352d80f --- /dev/null +++ b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php @@ -0,0 +1,50 @@ +loadRecoveryCodes(); + } + + /** + * Generate new recovery codes for the user. + */ + public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void + { + $generateNewRecoveryCodes(auth()->user()); + + $this->loadRecoveryCodes(); + } + + /** + * Load the recovery codes for the user. + */ + private function loadRecoveryCodes(): void + { + $user = auth()->user(); + + if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) { + try { + $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true); + } catch (Exception) { + $this->addError('recoveryCodes', 'Failed to load recovery codes'); + + $this->recoveryCodes = []; + } + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3cb5ccb1..a56996b9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,11 +7,12 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; +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 00000000..b99dcc74 --- /dev/null +++ b/app/Providers/FortifyServiceProvider.php @@ -0,0 +1,33 @@ + view('livewire.auth.two-factor-challenge')); + Fortify::confirmPasswordView(fn () => view('livewire.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 38b258d1..0ad9c573 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 032cdfac..0e686d58 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "license": "MIT", "require": { "php": "^8.2", + "laravel/fortify": "^1.30", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", "livewire/flux": "^2.1.1", diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 00000000..4143bd35 --- /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_09_22_145432_add_two_factor_columns_to_users_table.php b/database/migrations/2025_09_22_145432_add_two_factor_columns_to_users_table.php new file mode 100644 index 00000000..187d974d --- /dev/null +++ b/database/migrations/2025_09_22_145432_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/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php new file mode 100644 index 00000000..96af6e2c --- /dev/null +++ b/resources/views/components/input-otp.blade.php @@ -0,0 +1,138 @@ +@props([ + 'digits' => 6, + 'name' => 'code', +]) + +
+
+ @for ($x = 1; $x <= $digits; $x++) + $x === 1, + 'rounded-r-md' => $x === $digits, + '-ml-px' => $x > 1, + ]) + /> + @endfor +
+ + except(['digits']) }} + type="hidden" + x-ref="code" + name="{{ $name }}" + minlength="{{ $digits }}" + maxlength="{{ $digits }}" + /> +
diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 05c0637d..315756a1 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -3,6 +3,9 @@ {{ __('Profile') }} {{ __('Password') }} + @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) + {{ __('Two-Factor Auth') }} + @endif {{ __('Appearance') }} diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 1e942055..a6a45b31 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -1,24 +1,28 @@ -
- + +
+ - - + -
- - + + @csrf + + - {{ __('Confirm') }} - -
+ + {{ __('Confirm') }} + + +
+ diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index 50be1ba2..c72b509f 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -29,7 +29,7 @@ /> @if (Route::has('password.request')) - + {{ __('Forgot your password?') }} @endif @@ -39,12 +39,14 @@
- {{ __('Log in') }} + + {{ __('Log in') }} +
@if (Route::has('register')) -
+
{{ __('Don\'t have an account?') }} {{ __('Sign up') }}
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php new file mode 100644 index 00000000..00409651 --- /dev/null +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -0,0 +1,99 @@ + +
+
+
+ +
+ +
+ +
+ +
+ @csrf + +
+
+
+ +
+ + @error('code') + + {{ $message }} + + @enderror +
+ +
+
+ +
+ + @error('recovery_code') + + {{ $message }} + + @enderror +
+ + + {{ __('Continue') }} + +
+ +
+ {{ __('or you can') }} +
+ {{ __('login using a recovery code') }} + {{ __('login using an authentication code') }} +
+
+
+
+
+
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php new file mode 100644 index 00000000..8981ec09 --- /dev/null +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -0,0 +1,206 @@ +
+ @include('partials.settings-heading') + + +
+ @if ($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.') }} + + + + +
+ + {{ __('Disable 2FA') }} + +
+
+ @else +
+
+ {{ __('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.') }} + + + + {{ __('Enable 2FA') }} + +
+ @endif +
+
+ + +
+
+
+
+
+ @for ($i = 1; $i <= 5; $i++) +
+ @endfor +
+ +
+ @for ($i = 1; $i <= 5; $i++) +
+ @endfor +
+ + +
+
+ +
+ {{ $this->modalConfig['title'] }} + {{ $this->modalConfig['description'] }} +
+
+ + @if ($showVerificationStep) +
+
+ + @error('code') + + {{ $message }} + + @enderror +
+ +
+ + {{ __('Back') }} + + + + {{ __('Confirm') }} + +
+
+ @else + @error('setupData') + + @enderror + +
+
+ @empty($qrCodeSvg) +
+ +
+ @else +
+ {!! $qrCodeSvg !!} +
+ @endempty +
+
+ +
+ + {{ $this->modalConfig['buttonText'] }} + +
+ +
+
+
+ + {{ __('or, enter the code manually') }} + +
+ +
+
+ @empty($manualSetupKey) +
+ +
+ @else + + + + @endempty +
+
+
+ @endif +
+
+
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php new file mode 100644 index 00000000..0bce00df --- /dev/null +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -0,0 +1,89 @@ +
+
+
+ + {{ __('2FA Recovery Codes') }} +
+ + {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} + +
+ +
+
+ + + + {{ __('Hide Recovery Codes') }} + + + @if (filled($recoveryCodes)) + + {{ __('Regenerate Codes') }} + + @endif +
+ +
+
+ @error('recoveryCodes') + + @enderror + + @if (filled($recoveryCodes)) +
+ @foreach($recoveryCodes as $code) +
+ {{ $code }} +
+ @endforeach +
+ + {{ __('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.') }} + + @endif +
+
+
+
diff --git a/routes/auth.php b/routes/auth.php index 031f43cb..3d7863af 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -1,7 +1,7 @@ middleware(['signed', 'throttle:6,1']) ->name('verification.verify'); - - Route::get('confirm-password', ConfirmPassword::class) - ->name('password.confirm'); }); -Route::post('logout', App\Livewire\Actions\Logout::class) +Route::post('logout', Logout::class) ->name('logout'); diff --git a/routes/web.php b/routes/web.php index 910db328..6c0f8cf5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,7 +3,9 @@ use App\Livewire\Settings\Appearance; use App\Livewire\Settings\Password; use App\Livewire\Settings\Profile; +use App\Livewire\Settings\TwoFactor; use Illuminate\Support\Facades\Route; +use Laravel\Fortify\Features; Route::get('/', function () { return view('welcome'); @@ -19,6 +21,17 @@ Route::get('settings/profile', Profile::class)->name('settings.profile'); Route::get('settings/password', Password::class)->name('settings.password'); Route::get('settings/appearance', Appearance::class)->name('settings.appearance'); + + Route::get('settings/two-factor', TwoFactor::class) + ->middleware( + when( + Features::canManageTwoFactorAuthentication() + && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'), + ['password.confirm'], + [], + ), + ) + ->name('two-factor.show'); }); require __DIR__.'/auth.php'; diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 07088f73..02ba4f2f 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -5,6 +5,7 @@ use App\Livewire\Auth\Login; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Laravel\Fortify\Features; use Livewire\Livewire; use Tests\TestCase; @@ -49,6 +50,35 @@ public function test_users_can_not_authenticate_with_invalid_password(): void $this->assertGuest(); } + public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge(): 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(); + + $response = Livewire::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login'); + + $response->assertRedirect(route('two-factor.login')); + $response->assertSessionHas('login.id', $user->id); + $this->assertGuest(); + } + public function test_users_can_logout(): void { $user = User::factory()->create(); diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php index bab6cb48..1b53b110 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -2,10 +2,8 @@ namespace Tests\Feature\Auth; -use App\Livewire\Auth\ConfirmPassword; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Livewire\Livewire; use Tests\TestCase; class PasswordConfirmationTest extends TestCase @@ -16,36 +14,8 @@ public function test_confirm_password_screen_can_be_rendered(): void { $user = User::factory()->create(); - $response = $this->actingAs($user)->get('/confirm-password'); + $response = $this->actingAs($user)->get(route('password.confirm')); $response->assertStatus(200); } - - public function test_password_can_be_confirmed(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(ConfirmPassword::class) - ->set('password', 'password') - ->call('confirmPassword'); - - $response - ->assertHasNoErrors() - ->assertRedirect(route('dashboard', absolute: false)); - } - - public function test_password_is_not_confirmed_with_invalid_password(): void - { - $user = User::factory()->create(); - - $this->actingAs($user); - - $response = Livewire::test(ConfirmPassword::class) - ->set('password', 'wrong-password') - ->call('confirmPassword'); - - $response->assertHasErrors(['password']); - } } diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php new file mode 100644 index 00000000..5e16870f --- /dev/null +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -0,0 +1,52 @@ +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(); + + Livewire::test('auth.login') + ->set('email', $user->email) + ->set('password', 'password') + ->call('login') + ->assertRedirect(route('two-factor.login')) + ->assertOk(); + } +} diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php new file mode 100644 index 00000000..6e9b8fc3 --- /dev/null +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -0,0 +1,86 @@ +markTestSkipped('Two-factor authentication is not enabled.'); + } + + Features::twoFactorAuthentication([ + 'confirm' => true, + 'confirmPassword' => true, + ]); + } + + public function test_two_factor_settings_page_can_be_rendered(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('two-factor.show')) + ->assertOk() + ->assertSee('Two Factor Authentication') + ->assertSee('Disabled'); + } + + public function test_two_factor_settings_page_requires_password_confirmation_when_enabled(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->get(route('two-factor.show')); + + $response->assertRedirect(route('password.confirm')); + } + + public function test_two_factor_settings_page_returns_forbidden_response_when_two_factor_is_disabled(): void + { + config(['fortify.features' => []]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user) + ->withSession(['auth.password_confirmed_at' => time()]) + ->get(route('two-factor.show')); + + $response->assertForbidden(); + } + + public function test_two_factor_authentication_disabled_when_confirmation_abandoned_between_requests(): void + { + $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' => null, + ])->save(); + + $this->actingAs($user); + + $component = Livewire::test('settings.two-factor'); + + $component->assertSet('twoFactorEnabled', false); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'two_factor_secret' => null, + 'two_factor_recovery_codes' => null, + ]); + } +}