diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 54b16af..0f4d974 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -8,6 +8,10 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Validation\ValidationException; +use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication; +use Laravel\Fortify\Actions\DisableTwoFactorAuthentication; +use Laravel\Fortify\Actions\EnableTwoFactorAuthentication; +use Laravel\Fortify\Actions\GenerateNewRecoveryCodes; /** * User-facing account settings (profile, security, notification preferences). @@ -32,9 +36,82 @@ public function security(Request $request) { return view('settings.security', [ 'tokens' => $request->user()->tokens()->latest()->get(), + // Recovery codes are shown once (right after confirming/regenerating, + // or after an explicit password-confirmed reveal) and hidden otherwise. + 'revealRecoveryCodes' => (bool) $request->session()->get('reveal_recovery_codes'), ]); } + /** + * Begin two-factor enrolment. Requires the account password, then generates + * an unconfirmed secret; the user confirms it with a TOTP code afterwards. + */ + public function enableTwoFactor(Request $request, EnableTwoFactorAuthentication $enable) + { + $request->validate(['current_password' => ['required', 'current_password']]); + + $enable($request->user()); + + return redirect()->route('settings.security'); + } + + /** + * Finish enrolment by verifying a TOTP code. On success the fresh recovery + * codes are revealed once. + */ + public function confirmTwoFactor(Request $request, ConfirmTwoFactorAuthentication $confirm) + { + $request->validate(['code' => ['required', 'string']]); + + $confirm($request->user(), $request->input('code')); + + return redirect() + ->route('settings.security') + ->with('status', 'two-factor-authentication-confirmed') + ->with('reveal_recovery_codes', true); + } + + /** + * Regenerate the recovery codes (password-gated) and reveal the new set once. + */ + public function regenerateRecoveryCodes(Request $request, GenerateNewRecoveryCodes $generate) + { + $request->validate(['current_password' => ['required', 'current_password']]); + + $generate($request->user()); + + return redirect() + ->route('settings.security') + ->with('status', 'recovery-codes-generated') + ->with('reveal_recovery_codes', true); + } + + /** + * Re-display the existing recovery codes once, behind a password check. + */ + public function revealRecoveryCodes(Request $request) + { + $request->validate(['current_password' => ['required', 'current_password']]); + + return redirect() + ->route('settings.security') + ->with('reveal_recovery_codes', true); + } + + /** + * Disable two-factor authentication (password-gated). + */ + public function disableTwoFactor(Request $request, DisableTwoFactorAuthentication $disable) + { + $request->validate(['current_password' => ['required', 'current_password']]); + + $disable($request->user()); + + return redirect() + ->route('settings.security') + ->with('status', 'two-factor-authentication-disabled'); + } + public function storeToken(Request $request) { $request->validate([ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1ebdca5..d6b3856 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -128,6 +128,11 @@ public function boot(): void // second-factor code (mirrors the login page styling). Fortify::twoFactorChallengeView(fn () => view('auth.two-factor-challenge')); + // Password confirmation screen. The 2FA UI confirms the password inline + // via SettingsController, but Fortify's built-in 2FA routes are gated by + // confirmPassword => true and redirect here if ever hit directly. + Fortify::confirmPasswordView(fn () => view('auth.confirm-password')); + // Die Login-Pipeline von Fortify anpassen: // RedirectIfTwoFactorAuthenticatable diverts users with 2FA enabled to // the challenge screen before the password is accepted. The active diff --git a/config/fortify.php b/config/fortify.php index 9037096..cf51d40 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -149,8 +149,12 @@ Features::emailVerification(), Features::updateProfileInformation(), Features::updatePasswords(), + // 2FA management in the UI is handled by our own password-gated + // SettingsController routes. confirmPassword => true additionally guards + // Fortify's built-in 2FA routes (which we no longer link to) so a live + // session cannot hit them ungated. Features::twoFactorAuthentication([ - 'confirmPassword' => false, + 'confirmPassword' => true, ]), ], ]; diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php new file mode 100644 index 0000000..6ab2208 --- /dev/null +++ b/resources/views/auth/confirm-password.blade.php @@ -0,0 +1,46 @@ +@extends('layouts.loginlayout') +@section('title', 'Confirm Password') +@section('content') + +
+
+
+ YAAMS Logo +

Confirm your password

+

+ This is a secure area. Please re-enter your password to continue. +

+
+ +
+ @csrf + +
+ +
+ + +
+ @error('password') +
{{ $message }}
+ @enderror +
+ + +
+
+
+ +
+ © {{ date('Y') }} YAAMS Virtual Airline Management +
+ +@endsection diff --git a/resources/views/settings/_password_confirm_modal.blade.php b/resources/views/settings/_password_confirm_modal.blade.php new file mode 100644 index 0000000..6fbeb52 --- /dev/null +++ b/resources/views/settings/_password_confirm_modal.blade.php @@ -0,0 +1,37 @@ +{{-- + Reusable password-confirmation modal for a sensitive action. + Expects: $id, $action, $method (POST|DELETE), $title, $body, $submitLabel, $submitClass +--}} + diff --git a/resources/views/settings/security.blade.php b/resources/views/settings/security.blade.php index 744c4c7..3e96206 100644 --- a/resources/views/settings/security.blade.php +++ b/resources/views/settings/security.blade.php @@ -110,6 +110,9 @@ class="form-control" @endif + @error('current_password') +
{{ $message }}
+ @enderror @error('code')
{{ $message }}
@enderror @@ -119,12 +122,10 @@ class="form-control"
Disabled
-
- @csrf - -
+ @elseif (is_null($tfaUser->two_factor_confirmed_at)) {{-- State: pending confirmation - show QR + confirm form --}}
@@ -138,7 +139,7 @@ class="form-control" Can't scan? Enter this setup key manually: {{ decrypt($tfaUser->two_factor_secret) }}

-
+ @csrf
@@ -156,12 +157,8 @@ class="form-control @error('code') is-invalid @enderror"
-
- @csrf - @method('DELETE') - -
+ @else {{-- State: enabled --}}
@@ -169,36 +166,129 @@ class="form-control @error('code') is-invalid @enderror"
Recovery codes
-

- Store these in a safe place. Each code can be used once to sign in - if you lose access to your authenticator app. -

-
- @foreach ($tfaUser->recoveryCodes() as $recoveryCode) -
{{ $recoveryCode }}
- @endforeach -
-
-
- @csrf - + +
+ + I have saved my recovery codes + + + @else + {{-- Steady state: codes are hidden --}} +

+ Recovery codes let you sign in if you lose access to your authenticator app. + They're kept hidden for security - confirm your password to view or regenerate them. +

+
+ + - -
- @csrf - @method('DELETE') - -
-
+
+ @endif @endif + {{-- Password-confirmation modals for sensitive 2FA actions --}} + @if (! $tfaUser->two_factor_secret) + @include('settings._password_confirm_modal', [ + 'id' => 'tfaEnableModal', + 'action' => route('settings.2fa.enable'), + 'method' => 'POST', + 'title' => 'Enable two-factor authentication', + 'body' => 'Confirm your password to begin setting up two-factor authentication.', + 'submitLabel' => 'Continue', + 'submitClass' => 'btn-primary', + ]) + @elseif (is_null($tfaUser->two_factor_confirmed_at)) + @include('settings._password_confirm_modal', [ + 'id' => 'tfaCancelModal', + 'action' => route('settings.2fa.disable'), + 'method' => 'DELETE', + 'title' => 'Cancel two-factor setup', + 'body' => 'Confirm your password to cancel two-factor setup.', + 'submitLabel' => 'Cancel setup', + 'submitClass' => 'btn-danger', + ]) + @elseif (! $revealRecoveryCodes) + @include('settings._password_confirm_modal', [ + 'id' => 'tfaViewCodesModal', + 'action' => route('settings.2fa.recovery.show'), + 'method' => 'POST', + 'title' => 'View recovery codes', + 'body' => 'Confirm your password to reveal your recovery codes.', + 'submitLabel' => 'View codes', + 'submitClass' => 'btn-primary', + ]) + @include('settings._password_confirm_modal', [ + 'id' => 'tfaRegenModal', + 'action' => route('settings.2fa.recovery.regenerate'), + 'method' => 'POST', + 'title' => 'Regenerate recovery codes', + 'body' => 'Confirm your password to generate a new set of recovery codes. Your existing codes will stop working.', + 'submitLabel' => 'Regenerate', + 'submitClass' => 'btn-primary', + ]) + @include('settings._password_confirm_modal', [ + 'id' => 'tfaDisableModal', + 'action' => route('settings.2fa.disable'), + 'method' => 'DELETE', + 'title' => 'Disable two-factor authentication', + 'body' => 'Confirm your password to disable two-factor authentication. Your account will only be protected by your password.', + 'submitLabel' => 'Disable', + 'submitClass' => 'btn-danger', + ]) + @endif + {{-- API tokens --}}
diff --git a/routes/web.php b/routes/web.php index 0e2445b..8f291c7 100644 --- a/routes/web.php +++ b/routes/web.php @@ -78,6 +78,16 @@ Route::get('/settings', fn () => redirect()->route('settings.profile'))->name('settings'); Route::get('/settings/profile', [SettingsController::class, 'profile'])->name('settings.profile'); Route::get('/settings/security', [SettingsController::class, 'security'])->name('settings.security'); + + // Two-factor management — each mutation re-checks the account password in the + // controller (see SettingsController), so these do not rely on Fortify's own + // POST/DELETE 2FA routes. + Route::post('/settings/security/2fa/enable', [SettingsController::class, 'enableTwoFactor'])->name('settings.2fa.enable'); + Route::post('/settings/security/2fa/confirm', [SettingsController::class, 'confirmTwoFactor'])->name('settings.2fa.confirm'); + Route::post('/settings/security/2fa/recovery-codes', [SettingsController::class, 'regenerateRecoveryCodes'])->name('settings.2fa.recovery.regenerate'); + Route::post('/settings/security/2fa/recovery-codes/show', [SettingsController::class, 'revealRecoveryCodes'])->name('settings.2fa.recovery.show'); + Route::delete('/settings/security/2fa', [SettingsController::class, 'disableTwoFactor'])->name('settings.2fa.disable'); + Route::post('/settings/tokens', [SettingsController::class, 'storeToken'])->name('settings.tokens.store'); Route::delete('/settings/tokens/{tokenId}', [SettingsController::class, 'destroyToken'])->name('settings.tokens.destroy'); Route::get('/settings/notifications', [SettingsController::class, 'notifications'])->name('settings.notifications'); diff --git a/tests/Feature/TwoFactorAuthenticationTest.php b/tests/Feature/TwoFactorAuthenticationTest.php index 823286d..a5d88bf 100644 --- a/tests/Feature/TwoFactorAuthenticationTest.php +++ b/tests/Feature/TwoFactorAuthenticationTest.php @@ -47,22 +47,33 @@ public function test_enabling_generates_an_unconfirmed_secret(): void { $user = User::factory()->create(); - $this->actingAs($user)->post(route('two-factor.enable')); + $this->actingAs($user)->post(route('settings.2fa.enable'), ['current_password' => 'password']); $user->refresh(); $this->assertNotNull($user->two_factor_secret); $this->assertNull($user->two_factor_confirmed_at, '2FA must stay pending until confirmed'); } + public function test_enabling_requires_the_current_password(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->post(route('settings.2fa.enable'), ['current_password' => 'wrong-password']) + ->assertSessionHasErrors('current_password'); + + $this->assertNull($user->fresh()->two_factor_secret); + } + public function test_confirming_with_a_valid_code_enables_two_factor(): void { $user = User::factory()->create(); - $this->actingAs($user)->post(route('two-factor.enable')); + $this->actingAs($user)->post(route('settings.2fa.enable'), ['current_password' => 'password']); $user->refresh(); $code = $this->google2fa()->getCurrentOtp(decrypt($user->two_factor_secret)); - $this->actingAs($user)->post(route('two-factor.confirm'), ['code' => $code]); + $this->actingAs($user)->post(route('settings.2fa.confirm'), ['code' => $code]); $this->assertNotNull($user->fresh()->two_factor_confirmed_at); } @@ -149,7 +160,7 @@ public function test_security_page_shows_the_two_factor_section(): void public function test_security_page_renders_the_qr_code_during_setup(): void { $user = User::factory()->create(); - $this->actingAs($user)->post(route('two-factor.enable')); + $this->actingAs($user)->post(route('settings.2fa.enable'), ['current_password' => 'password']); $response = $this->actingAs($user)->get(route('settings.security')); @@ -158,7 +169,7 @@ public function test_security_page_renders_the_qr_code_during_setup(): void $response->assertSee('Confirm'); } - public function test_security_page_renders_recovery_codes_when_enabled(): void + public function test_recovery_codes_are_hidden_in_the_enabled_steady_state(): void { $user = User::factory()->create(); $this->confirmTwoFactorFor($user); @@ -167,8 +178,70 @@ public function test_security_page_renders_recovery_codes_when_enabled(): void $response->assertOk(); $response->assertSee('Recovery codes'); - $response->assertSee('ABCDE-12345'); - $response->assertSee('Regenerate recovery codes'); + $response->assertSee('View recovery codes'); + // The actual codes must NOT be rendered until re-authentication. + $response->assertDontSee('ABCDE-12345'); + } + + public function test_viewing_recovery_codes_requires_the_current_password(): void + { + $user = User::factory()->create(); + $this->confirmTwoFactorFor($user); + + // Wrong password: rejected, codes stay hidden. + $this->actingAs($user) + ->post(route('settings.2fa.recovery.show'), ['current_password' => 'wrong-password']) + ->assertSessionHasErrors('current_password'); + $this->actingAs($user)->get(route('settings.security'))->assertDontSee('ABCDE-12345'); + + // Correct password: the codes are revealed once. + $this->actingAs($user) + ->followingRedirects() + ->post(route('settings.2fa.recovery.show'), ['current_password' => 'password']) + ->assertSee('ABCDE-12345'); + } + + public function test_recovery_codes_are_revealed_after_confirming(): void + { + $user = User::factory()->create(); + $this->actingAs($user)->post(route('settings.2fa.enable'), ['current_password' => 'password']); + $user->refresh(); + + $code = $this->google2fa()->getCurrentOtp(decrypt($user->two_factor_secret)); + + $this->actingAs($user) + ->followingRedirects() + ->post(route('settings.2fa.confirm'), ['code' => $code]) + ->assertSee('I have saved my recovery codes'); + } + + public function test_regenerating_recovery_codes_requires_the_current_password(): void + { + $user = User::factory()->create(); + $this->confirmTwoFactorFor($user); + + // Wrong password: rejected, existing codes unchanged. + $this->actingAs($user) + ->post(route('settings.2fa.recovery.regenerate'), ['current_password' => 'wrong-password']) + ->assertSessionHasErrors('current_password'); + $this->assertContains('ABCDE-12345', $user->fresh()->recoveryCodes()); + + // Correct password: a new set is generated. + $this->actingAs($user) + ->post(route('settings.2fa.recovery.regenerate'), ['current_password' => 'password']); + $this->assertNotContains('ABCDE-12345', $user->fresh()->recoveryCodes()); + } + + public function test_disabling_requires_the_current_password(): void + { + $user = User::factory()->create(); + $this->confirmTwoFactorFor($user); + + $this->actingAs($user) + ->delete(route('settings.2fa.disable'), ['current_password' => 'wrong-password']) + ->assertSessionHasErrors('current_password'); + + $this->assertNotNull($user->fresh()->two_factor_secret, '2FA must remain enabled without the password'); } public function test_disabling_clears_the_two_factor_columns(): void @@ -176,7 +249,7 @@ public function test_disabling_clears_the_two_factor_columns(): void $user = User::factory()->create(); $this->confirmTwoFactorFor($user); - $this->actingAs($user)->delete(route('two-factor.disable')); + $this->actingAs($user)->delete(route('settings.2fa.disable'), ['current_password' => 'password']); $user->refresh(); $this->assertNull($user->two_factor_secret);