From 762d9618004cec33e5aa9f8a6d97f6f660b9ad5f Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 22 Sep 2025 20:54:44 +0530 Subject: [PATCH 1/4] Add Two-Factor Auth For Livewire Starter Kit (Livewire Components) --- app/Livewire/Auth/ConfirmPassword.php | 37 ---- app/Livewire/Auth/Login.php | 41 ++-- app/Livewire/Settings/TwoFactor.php | 146 +++++++++++++ .../Settings/TwoFactor/RecoveryCodes.php | 39 ++++ app/Models/User.php | 3 +- app/Providers/FortifyServiceProvider.php | 33 +++ bootstrap/providers.php | 1 + composer.json | 1 + config/fortify.php | 159 ++++++++++++++ ..._add_two_factor_columns_to_users_table.php | 34 +++ .../views/components/input-otp.blade.php | 134 ++++++++++++ .../components/settings/layout.blade.php | 3 + .../livewire/auth/confirm-password.blade.php | 46 +++-- .../auth/two-factor-challenge.blade.php | 95 +++++++++ .../livewire/settings/two-factor.blade.php | 194 ++++++++++++++++++ .../two-factor/recovery-codes.blade.php | 87 ++++++++ routes/auth.php | 7 +- routes/web.php | 13 ++ tests/Feature/Auth/AuthenticationTest.php | 30 +++ .../Feature/Auth/PasswordConfirmationTest.php | 32 +-- tests/Feature/Auth/TwoFactorChallengeTest.php | 52 +++++ .../Settings/TwoFactorAuthenticationTest.php | 87 ++++++++ 22 files changed, 1166 insertions(+), 108 deletions(-) delete mode 100644 app/Livewire/Auth/ConfirmPassword.php create mode 100644 app/Livewire/Settings/TwoFactor.php create mode 100644 app/Livewire/Settings/TwoFactor/RecoveryCodes.php create mode 100644 app/Providers/FortifyServiceProvider.php create mode 100644 config/fortify.php create mode 100644 database/migrations/2025_09_22_145432_add_two_factor_columns_to_users_table.php create mode 100644 resources/views/components/input-otp.blade.php create mode 100644 resources/views/livewire/auth/two-factor-challenge.blade.php create mode 100644 resources/views/livewire/settings/two-factor.blade.php create mode 100644 resources/views/livewire/settings/two-factor/recovery-codes.blade.php create mode 100644 tests/Feature/Auth/TwoFactorChallengeTest.php create mode 100644 tests/Feature/Settings/TwoFactorAuthenticationTest.php 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..aa0ac957 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; @@ -23,32 +25,48 @@ class Login extends Component public bool $remember = false; - /** - * Handle an incoming authentication request. - */ public function login(): void { $this->validate(); $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); } - /** - * Ensure the authentication request is not rate limited. - */ + 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; + } + protected function ensureIsNotRateLimited(): void { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { @@ -67,9 +85,6 @@ protected function ensureIsNotRateLimited(): void ]); } - /** - * Get the authentication rate limiting throttle key. - */ protected function throttleKey(): string { return Str::transliterate(Str::lower($this->email).'|'.request()->ip()); diff --git a/app/Livewire/Settings/TwoFactor.php b/app/Livewire/Settings/TwoFactor.php new file mode 100644 index 00000000..9e9e1593 --- /dev/null +++ b/app/Livewire/Settings/TwoFactor.php @@ -0,0 +1,146 @@ +user()->two_factor_confirmed_at)) { + $disableTwoFactorAuthentication(auth()->user()); + } + + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); + } + + public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void + { + $enableTwoFactorAuthentication(auth()->user()); + + if (! $this->requiresConfirmation) { + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + } + + $this->loadTwoFactorData(); + $this->showModal = true; + } + + public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void + { + $disableTwoFactorAuthentication(auth()->user()); + $this->twoFactorEnabled = false; + } + + public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void + { + $this->validate(); + $confirmTwoFactorAuthentication(auth()->user(), $this->code); + $this->closeModal(); + $this->twoFactorEnabled = true; + } + + 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'), + ]; + } + + public function handleNextAction(): void + { + if ($this->requiresConfirmation) { + $this->showVerificationStep = true; + $this->resetErrorBag(); + + return; + } + + $this->closeModal(); + } + + public function resetVerification(): void + { + $this->reset('code', 'showVerificationStep'); + $this->resetErrorBag(); + } + + public function closeModal(): void + { + $this->reset( + 'code', + 'showVerificationStep', + 'manualSetupKey', + 'qrCodeSvg', + 'showModal', + ); + $this->resetErrorBag(); + + if (! $this->requiresConfirmation) { + $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); + } + } + + private function loadTwoFactorData(): 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'); + } + } +} diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php new file mode 100644 index 00000000..15ccc3b3 --- /dev/null +++ b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php @@ -0,0 +1,39 @@ +loadRecoveryCodes(); + } + + public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void + { + $generateNewRecoveryCodes(auth()->user()); + $this->loadRecoveryCodes(); + } + + 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..9432cfec --- /dev/null +++ b/resources/views/components/input-otp.blade.php @@ -0,0 +1,134 @@ +@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..fad00e3e 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/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php new file mode 100644 index 00000000..7e944b34 --- /dev/null +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -0,0 +1,95 @@ + +
+
+
+ +
+ +
+ +
+ +
+ @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..f0d0a25a --- /dev/null +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -0,0 +1,194 @@ +
+ @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..157d9bce --- /dev/null +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -0,0 +1,87 @@ +
+
+
+ + {{ __('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..e1159e27 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; @@ -59,4 +60,33 @@ public function test_users_can_logout(): 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(); + } } 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..3e336c0f --- /dev/null +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -0,0 +1,87 @@ +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, + ]); + } +} From 027dac4124ce3e86e02a34263730d23f43ddb2f4 Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Mon, 22 Sep 2025 22:43:39 +0530 Subject: [PATCH 2/4] Formatting --- resources/views/livewire/settings/two-factor.blade.php | 1 - tests/Feature/Settings/TwoFactorAuthenticationTest.php | 1 - 2 files changed, 2 deletions(-) diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php index f0d0a25a..aad217a2 100644 --- a/resources/views/livewire/settings/two-factor.blade.php +++ b/resources/views/livewire/settings/two-factor.blade.php @@ -191,4 +191,3 @@ class="text-green-500" - diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php index 3e336c0f..6e9b8fc3 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -6,7 +6,6 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Fortify\Features; use Livewire\Livewire; -use Livewire\Volt\Volt; use Tests\TestCase; class TwoFactorAuthenticationTest extends TestCase From 265d14fc407eb2fe17fd721aedd9dfcfe23235bf Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 23 Sep 2025 14:44:11 +0530 Subject: [PATCH 3/4] Formatting --- .../views/components/input-otp.blade.php | 6 ++++- .../components/settings/layout.blade.php | 2 +- .../auth/two-factor-challenge.blade.php | 4 ++++ tests/Feature/Auth/AuthenticationTest.php | 22 +++++++++---------- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/resources/views/components/input-otp.blade.php b/resources/views/components/input-otp.blade.php index 9432cfec..96af6e2c 100644 --- a/resources/views/components/input-otp.blade.php +++ b/resources/views/components/input-otp.blade.php @@ -56,8 +56,10 @@ class="relative" } const previousInput = this.getInput(index - 1); + previousInput.value = ''; previousInput.focus(); + this.updateHiddenField(); }, handleKeyDown(index, event) { @@ -77,6 +79,7 @@ class="relative" }, handlePaste(event) { event.preventDefault(); + const pastedText = (event.clipboardData || window.clipboardData).getData('text'); const numericOnly = pastedText.replace(/[^0-9]/g, ''); const digitsToFill = Math.min(numericOnly.length, this.totalDigits); @@ -88,13 +91,14 @@ class="relative" }); if (numericOnly.length >= this.totalDigits) { - this.updateHiddenField(); + this.updateHiddenField(); } }, clearAll() { this.digitIndices.forEach(index => { this.setValue(index, ''); }); + this.$refs.code.value = ''; this.$refs.input1?.focus(); } diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index fad00e3e..315756a1 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -4,7 +4,7 @@ {{ __('Profile') }} {{ __('Password') }} @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) - {{ __('Two-factor Auth') }} + {{ __('Two-Factor Auth') }} @endif {{ __('Appearance') }} diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php index 7e944b34..33bcda85 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -5,13 +5,17 @@ class="relative w-full h-auto" x-cloak x-data="{ showRecoveryInput: @js($errors->has('recovery_code')), + code: '', recovery_code: '', + toggleInput() { this.showRecoveryInput = !this.showRecoveryInput; this.code = ''; this.recovery_code = ''; + $dispatch('clear-2fa-auth-code'); + $nextTick(() => { this.showRecoveryInput ? this.$refs.recovery_code?.focus() diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index e1159e27..02ba4f2f 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -50,17 +50,6 @@ public function test_users_can_not_authenticate_with_invalid_password(): void $this->assertGuest(); } - public function test_users_can_logout(): void - { - $user = User::factory()->create(); - - $response = $this->actingAs($user)->post('/logout'); - - $response->assertRedirect('/'); - - $this->assertGuest(); - } - public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_challenge(): void { if (! Features::canManageTwoFactorAuthentication()) { @@ -89,4 +78,15 @@ public function test_users_with_two_factor_enabled_are_redirected_to_two_factor_ $response->assertSessionHas('login.id', $user->id); $this->assertGuest(); } + + public function test_users_can_logout(): void + { + $user = User::factory()->create(); + + $response = $this->actingAs($user)->post('/logout'); + + $response->assertRedirect('/'); + + $this->assertGuest(); + } } From 2a846f79638b206c20d676d25fc1664a75bea7dc Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Tue, 23 Sep 2025 21:08:45 +0530 Subject: [PATCH 4/4] Formatting --- app/Livewire/Auth/Login.php | 12 ++ app/Livewire/Settings/TwoFactor.php | 124 +++++++++++------- .../Settings/TwoFactor/RecoveryCodes.php | 11 ++ resources/views/livewire/auth/login.blade.php | 8 +- .../auth/two-factor-challenge.blade.php | 4 +- .../livewire/settings/two-factor.blade.php | 15 ++- .../two-factor/recovery-codes.blade.php | 2 + 7 files changed, 126 insertions(+), 50 deletions(-) diff --git a/app/Livewire/Auth/Login.php b/app/Livewire/Auth/Login.php index aa0ac957..88622619 100644 --- a/app/Livewire/Auth/Login.php +++ b/app/Livewire/Auth/Login.php @@ -25,6 +25,9 @@ class Login extends Component public bool $remember = false; + /** + * Handle an incoming authentication request. + */ public function login(): void { $this->validate(); @@ -52,6 +55,9 @@ public function login(): void $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]); @@ -67,6 +73,9 @@ protected function validateCredentials(): User return $user; } + /** + * Ensure the authentication request is not rate limited. + */ protected function ensureIsNotRateLimited(): void { if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { @@ -85,6 +94,9 @@ protected function ensureIsNotRateLimited(): void ]); } + /** + * Get the authentication rate limiting throttle key. + */ protected function throttleKey(): string { return Str::transliterate(Str::lower($this->email).'|'.request()->ip()); diff --git a/app/Livewire/Settings/TwoFactor.php b/app/Livewire/Settings/TwoFactor.php index 9e9e1593..a1641b56 100644 --- a/app/Livewire/Settings/TwoFactor.php +++ b/app/Livewire/Settings/TwoFactor.php @@ -34,6 +34,9 @@ class TwoFactor extends Component #[Validate('required|string|size:6', onUpdate: false)] public string $code = ''; + /** + * Mount the component. + */ public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void { abort_unless(Features::enabled(Features::twoFactorAuthentication()), Response::HTTP_FORBIDDEN); @@ -46,6 +49,9 @@ public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentica $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'); } + /** + * Enable two-factor authentication for the user. + */ public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void { $enableTwoFactorAuthentication(auth()->user()); @@ -54,53 +60,36 @@ public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthenticat $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); } - $this->loadTwoFactorData(); - $this->showModal = true; - } + $this->loadSetupData(); - public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void - { - $disableTwoFactorAuthentication(auth()->user()); - $this->twoFactorEnabled = false; + $this->showModal = true; } - public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void + /** + * Load the two-factor authentication setup data for the user. + */ + private function loadSetupData(): void { - $this->validate(); - $confirmTwoFactorAuthentication(auth()->user(), $this->code); - $this->closeModal(); - $this->twoFactorEnabled = true; - } + $user = auth()->user(); - 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'), - ]; - } + try { + $this->qrCodeSvg = $user?->twoFactorQrCodeSvg(); + $this->manualSetupKey = decrypt($user->two_factor_secret); + } catch (Exception) { + $this->addError('setupData', 'Failed to fetch setup data.'); - if ($this->showVerificationStep) { - return [ - 'title' => __('Verify Authentication Code'), - 'description' => __('Enter the 6-digit code from your authenticator app'), - 'buttonText' => __('Continue'), - ]; + $this->reset('qrCodeSvg', 'manualSetupKey'); } - - 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'), - ]; } - public function handleNextAction(): void + /** + * Show the two-factor verification step if necessary. + */ + public function showVerificationIfNecessary(): void { if ($this->requiresConfirmation) { $this->showVerificationStep = true; + $this->resetErrorBag(); return; @@ -109,21 +98,53 @@ public function handleNextAction(): void $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', - 'showVerificationStep', 'manualSetupKey', 'qrCodeSvg', 'showModal', + 'showVerificationStep', ); + $this->resetErrorBag(); if (! $this->requiresConfirmation) { @@ -131,16 +152,31 @@ public function closeModal(): void } } - private function loadTwoFactorData(): void + /** + * Get the current modal configuration state. + */ + public function getModalConfigProperty(): array { - $user = auth()->user(); + 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'), + ]; + } - 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'); + 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 index 15ccc3b3..7352d80f 100644 --- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php +++ b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php @@ -12,17 +12,27 @@ class RecoveryCodes extends Component #[Locked] public array $recoveryCodes = []; + /** + * Mount the component. + */ public function mount(): void { $this->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(); @@ -32,6 +42,7 @@ private function loadRecoveryCodes(): void $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/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 index 33bcda85..00409651 100644 --- a/resources/views/livewire/auth/two-factor-challenge.blade.php +++ b/resources/views/livewire/auth/two-factor-challenge.blade.php @@ -5,12 +5,11 @@ class="relative w-full h-auto" x-cloak x-data="{ showRecoveryInput: @js($errors->has('recovery_code')), - code: '', recovery_code: '', - toggleInput() { this.showRecoveryInput = !this.showRecoveryInput; + this.code = ''; this.recovery_code = ''; @@ -58,6 +57,7 @@ class="relative w-full h-auto" @enderror
+
{{ __('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.') }} +
@endfor +
@for ($i = 1; $i <= 5; $i++)
@endfor
+ +
{{ $this->modalConfig['title'] }} {{ $this->modalConfig['description'] }} @@ -100,6 +108,7 @@ class="flex-1" > {{ __('Back') }} +
+
{{ $this->modalConfig['buttonText'] }}
+
@@ -145,6 +156,7 @@ class="w-full" {{ __('or, enter the code manually') }}
+
+