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')
+
+
+
+
+
 }})
+
Confirm your password
+
+ This is a secure area. Please re-enter your password to continue.
+
+
+
+
+
+
+
+
+ © {{ 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
-
+
@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) }}
-
-
+
@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
-
-
+ @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);