QA-Befunde A4: 2FA-Bestätigungsschritt + sicherer E-Mail-Wechsel
- 2FA: Einrichtung verlangt jetzt die Bestätigung mit einem App-Code (Fortify confirm-Semantik); vorher blieb two_factor_confirmed_at leer und der Login fragte nie nach der Challenge. - Recovery-Codes nur noch einmalig sichtbar (nach Bestätigung bzw. Neu-Erzeugung), nicht mehr bei jedem Seitenaufruf. - E-Mail-Änderung als Pending-Flow: Bestätigungslink geht an die NEUE Adresse, die alte bleibt bis zum Klick aktiv — kein Aussperren durch Tippfehler und keine Registrierungs-Notice mehr nach dem Ändern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b7bbf6df4e
commit
15387d0a3f
8 changed files with 505 additions and 45 deletions
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue