- {{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi —
- QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}}
-
-
- {{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator).') }}
-
- @if (! empty($recoveryCodes))
-
- {{ __('Wiederherstellungs-Codes') }}
-
-
- @foreach ($recoveryCodes as $code)
- - {{ $code }}
- @endforeach
-
- @endif
+ {{-- Zustand 3: aktiv. Recovery-Codes nur einmalig direkt nach
+ Bestätigung bzw. Neu-Erzeugung sichtbar. --}}
+
+ {{ __('Die Zwei-Faktor-Authentifizierung ist aktiv. Beim Login wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.') }}
+
+
+ @if (! empty($recoveryCodes))
+
+
+ {{ __('Wiederherstellungs-Codes') }}
+
+ {{ __('Speichern Sie diese Codes jetzt an einem sicheren Ort — sie werden nur dieses eine Mal angezeigt. Jeder Code ersetzt einmalig Ihre Authenticator-App, falls Sie den Zugriff darauf verlieren.') }}
+
+
+ @foreach ($recoveryCodes as $code)
+ - {{ $code }}
+ @endforeach
+
@endif
diff --git a/routes/auth.php b/routes/auth.php
index c3132f90..1df55cc3 100644
--- a/routes/auth.php
+++ b/routes/auth.php
@@ -1,5 +1,6 @@
config('fortify.middleware', ['web'])], function (
})->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1'])
->name('verification.send');
+ // Signierter Bestätigungslink für eine E-Mail-Änderung (geht an die NEUE
+ // Adresse; erst der Klick übernimmt sie ins Konto).
+ Route::get('/email-aendern/{user}/{hash}', ConfirmEmailChangeController::class)
+ ->middleware(['auth:'.config('fortify.guard'), 'signed', 'throttle:6,1'])
+ ->name('email-change.confirm');
+
// 2FA-Challenge-Seite (Volt-Frontend für Fortifys two-factor-challenge);
// der eigentliche POST geht an Fortifys Controller.
Volt::route('/two-factor-challenge', 'auth.two-factor-challenge')
diff --git a/tests/Feature/Auth/TwoFactorLoginTest.php b/tests/Feature/Auth/TwoFactorLoginTest.php
index 0f043e4e..9beb3036 100644
--- a/tests/Feature/Auth/TwoFactorLoginTest.php
+++ b/tests/Feature/Auth/TwoFactorLoginTest.php
@@ -34,6 +34,29 @@ test('a user with two-factor enabled is handed to the challenge, not logged in',
expect(session('login.id'))->toBe($user->id);
});
+test('an unconfirmed two-factor setup does not trigger the challenge', function () {
+ /** @var TestCase $this */
+ $this->seed(RolesAndPermissionsSeeder::class);
+ $user = User::factory()->create(['is_active' => true]);
+ $user->assignRole('customer');
+
+ // Secret erzeugt, aber Einrichtung nie mit App-Code bestätigt
+ // (two_factor_confirmed_at bleibt null → Fortify-'confirm'-Semantik).
+ $user->forceFill([
+ 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
+ 'two_factor_confirmed_at' => null,
+ ])->save();
+
+ Volt::test('auth.login')
+ ->set('email', $user->email)
+ ->set('password', 'password')
+ ->call('login')
+ ->assertHasNoErrors()
+ ->assertRedirect(route('me.dashboard', absolute: false));
+
+ $this->assertAuthenticatedAs($user);
+});
+
test('the challenge page redirects to login without a pending challenge', function () {
/** @var TestCase $this */
$this->get(route('two-factor.challenge'))->assertRedirect(route('login'));
diff --git a/tests/Feature/CustomerProfileSecurityTest.php b/tests/Feature/CustomerProfileSecurityTest.php
index a60e4035..ac6b906f 100644
--- a/tests/Feature/CustomerProfileSecurityTest.php
+++ b/tests/Feature/CustomerProfileSecurityTest.php
@@ -2,6 +2,7 @@
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
+use App\Mail\EmailChangeConfirmation;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
@@ -11,7 +12,10 @@ use App\Models\PressReleaseStatusLog;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\URL;
use Livewire\Volt\Volt as LivewireVolt;
+use PragmaRX\Google2FA\Google2FA;
use Tests\TestCase;
test('customer can update own profile fields', function () {
@@ -185,6 +189,180 @@ test('customer can change own password through security page', function () {
expect(Hash::check('new-strong-password', $customer->password))->toBeTrue();
});
+test('two factor setup only becomes active after confirming an app code', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create(['is_active' => true]);
+
+ $this->actingAs($customer);
+
+ $component = LivewireVolt::test('customer.security')
+ ->call('enableTwoFactorAuthentication');
+
+ $customer->refresh();
+
+ expect($customer->two_factor_secret)->not->toBeNull();
+ expect($customer->two_factor_confirmed_at)->toBeNull();
+ expect($customer->hasEnabledTwoFactorAuthentication())->toBeFalse();
+
+ $otp = app(Google2FA::class)->getCurrentOtp(decrypt($customer->two_factor_secret));
+
+ $component
+ ->set('twoFactorCode', $otp)
+ ->call('confirmTwoFactorAuthentication')
+ ->assertHasNoErrors()
+ ->assertSee('Wiederherstellungs-Codes');
+
+ $customer->refresh();
+
+ expect($customer->two_factor_confirmed_at)->not->toBeNull();
+ expect($customer->hasEnabledTwoFactorAuthentication())->toBeTrue();
+});
+
+test('an invalid two factor confirmation code is rejected', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create(['is_active' => true]);
+
+ $this->actingAs($customer);
+
+ LivewireVolt::test('customer.security')
+ ->call('enableTwoFactorAuthentication')
+ ->set('twoFactorCode', '000000')
+ ->call('confirmTwoFactorAuthentication')
+ ->assertHasErrors(['twoFactorCode']);
+
+ expect($customer->refresh()->two_factor_confirmed_at)->toBeNull();
+});
+
+test('recovery codes stay hidden on a later visit to the security page', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create(['is_active' => true]);
+ $customer->forceFill([
+ 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
+ 'two_factor_recovery_codes' => encrypt(json_encode(['geheim-code-eins', 'geheim-code-zwei'])),
+ 'two_factor_confirmed_at' => now(),
+ ])->save();
+
+ $this->actingAs($customer);
+
+ LivewireVolt::test('customer.security')
+ ->assertDontSee('geheim-code-eins')
+ ->call('regenerateRecoveryCodes')
+ ->assertSee('Wiederherstellungs-Codes');
+});
+
+test('changing the email keeps the old address active and mails a confirm link to the new one', function () {
+ /** @var TestCase $this */
+ Mail::fake();
+
+ $customer = User::factory()->create([
+ 'is_active' => true,
+ 'email' => 'alt@example.com',
+ 'email_verified_at' => now(),
+ ]);
+
+ $this->actingAs($customer);
+
+ LivewireVolt::test('customer.security')
+ ->set('email', 'neu@example.com')
+ ->call('updateEmail')
+ ->assertHasNoErrors();
+
+ $customer->refresh();
+
+ expect($customer->email)->toBe('alt@example.com');
+ expect($customer->pending_email)->toBe('neu@example.com');
+ expect($customer->email_verified_at)->not->toBeNull();
+
+ Mail::assertSent(EmailChangeConfirmation::class, function ($mail) {
+ return $mail->hasTo('neu@example.com') && str_contains($mail->confirmUrl, 'signature=');
+ });
+});
+
+test('the signed link applies the pending email change', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create([
+ 'is_active' => true,
+ 'email' => 'alt@example.com',
+ 'pending_email' => 'neu@example.com',
+ ]);
+
+ $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
+ 'user' => $customer->id,
+ 'hash' => sha1('neu@example.com'),
+ ]);
+
+ $this->actingAs($customer)
+ ->get($url)
+ ->assertRedirect(route('me.security'));
+
+ $customer->refresh();
+
+ expect($customer->email)->toBe('neu@example.com');
+ expect($customer->pending_email)->toBeNull();
+ expect($customer->email_verified_at)->not->toBeNull();
+});
+
+test('the email change link of another user is rejected', function () {
+ /** @var TestCase $this */
+ $owner = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']);
+ $intruder = User::factory()->create(['is_active' => true]);
+
+ $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
+ 'user' => $owner->id,
+ 'hash' => sha1('neu@example.com'),
+ ]);
+
+ $this->actingAs($intruder)->get($url)->assertForbidden();
+
+ expect($owner->refresh()->email)->not->toBe('neu@example.com');
+});
+
+test('an unsigned email change link is rejected', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']);
+
+ $this->actingAs($customer)
+ ->get(route('email-change.confirm', ['user' => $customer->id, 'hash' => sha1('neu@example.com')]))
+ ->assertForbidden();
+});
+
+test('a pending email change can be discarded', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']);
+
+ $this->actingAs($customer);
+
+ LivewireVolt::test('customer.security')
+ ->call('cancelPendingEmailChange');
+
+ expect($customer->refresh()->pending_email)->toBeNull();
+});
+
+test('a pending email that got taken by another account meanwhile is discarded on confirm', function () {
+ /** @var TestCase $this */
+ $customer = User::factory()->create([
+ 'is_active' => true,
+ 'email' => 'alt@example.com',
+ 'pending_email' => 'neu@example.com',
+ ]);
+ User::factory()->create(['email' => 'neu@example.com']);
+
+ $url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
+ 'user' => $customer->id,
+ 'hash' => sha1('neu@example.com'),
+ ]);
+
+ $this->actingAs($customer)
+ ->get($url)
+ ->assertRedirect(route('me.security'))
+ ->assertSessionHas('security-error');
+
+ $customer->refresh();
+
+ expect($customer->email)->toBe('alt@example.com');
+ expect($customer->pending_email)->toBeNull();
+});
+
test('press release policy denies access to other users releases', function () {
/** @var TestCase $this */
$owner = User::factory()->create(['is_active' => true]);