Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
362 lines
13 KiB
PHP
362 lines
13 KiB
PHP
<?php
|
||
|
||
use App\Enums\SecurityChange;
|
||
use App\Mail\SecurityChangeNotice;
|
||
use App\Mail\SocialAccountLinkConfirmation;
|
||
use App\Models\User;
|
||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Hash;
|
||
use Illuminate\Support\Facades\Mail;
|
||
use Laravel\Socialite\Contracts\Provider;
|
||
use Laravel\Socialite\Facades\Socialite;
|
||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||
use Livewire\Livewire;
|
||
use PragmaRX\Google2FA\Google2FA;
|
||
use Tests\TestCase;
|
||
|
||
beforeEach(function () {
|
||
$this->seed(RolesAndPermissionsSeeder::class);
|
||
});
|
||
|
||
/**
|
||
* Simuliert die Google-Antwort. Die Rohdaten tragen wie der userinfo-Endpunkt
|
||
* `email_verified` und – bei Workspace-Konten – `hd` (Sicherheitsaudit
|
||
* 07.10.2026, S-05).
|
||
*/
|
||
function fakeGoogleUser(string $id, string $email, string $name, ?bool $emailVerified = true, ?string $hostedDomain = null): void
|
||
{
|
||
$raw = array_filter([
|
||
'sub' => $id,
|
||
'email' => $email,
|
||
'name' => $name,
|
||
'email_verified' => $emailVerified,
|
||
'hd' => $hostedDomain,
|
||
], fn ($value) => $value !== null);
|
||
|
||
$socialiteUser = (new SocialiteUser)->setRaw($raw)->map([
|
||
'id' => $id,
|
||
'email' => $email,
|
||
'name' => $name,
|
||
]);
|
||
|
||
$provider = Mockery::mock(Provider::class);
|
||
$provider->shouldReceive('user')->andReturn($socialiteUser);
|
||
|
||
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
||
}
|
||
|
||
test('a new google user is created active, verified and as customer', function () {
|
||
/** @var TestCase $this */
|
||
fakeGoogleUser('g-new-1', 'new-google@example.test', 'Neu Google');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('me.dashboard', absolute: false));
|
||
|
||
$user = User::query()->where('email', 'new-google@example.test')->firstOrFail();
|
||
|
||
expect($user->is_active)->toBeTrue();
|
||
expect($user->hasVerifiedEmail())->toBeTrue();
|
||
expect($user->hasRole('customer'))->toBeTrue();
|
||
expect($user->oauth_provider)->toBe('google');
|
||
expect($user->oauth_provider_id)->toBe('g-new-1');
|
||
expect($user->gdpr_consent_at)->not->toBeNull();
|
||
$this->assertAuthenticatedAs($user);
|
||
});
|
||
|
||
test('an existing gmail user is linked by email without creating a duplicate', function () {
|
||
/** @var TestCase $this */
|
||
Mail::fake();
|
||
$existing = User::factory()->create(['email' => 'existing-google@gmail.com', 'is_active' => true]);
|
||
$existing->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-existing-1', 'Existing-Google@gmail.com', 'Existing');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('me.dashboard', absolute: false));
|
||
|
||
expect(User::query()->where('email', 'existing-google@gmail.com')->count())->toBe(1);
|
||
$existing->refresh();
|
||
expect($existing->oauth_provider)->toBe('google');
|
||
expect($existing->oauth_provider_id)->toBe('g-existing-1');
|
||
$this->assertAuthenticatedAs($existing);
|
||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::GoogleLinked
|
||
&& $mail->hasTo('existing-google@gmail.com'));
|
||
});
|
||
|
||
test('a deactivated verified account is not reactivated by google login', function () {
|
||
/** @var TestCase $this */
|
||
$existing = User::factory()->create(['email' => 'deactivated@example.test', 'is_active' => false]);
|
||
$existing->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-deact-1', 'deactivated@example.test', 'Deactivated');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('login'));
|
||
|
||
expect($existing->fresh()->is_active)->toBeFalse();
|
||
$this->assertGuest();
|
||
});
|
||
|
||
test('a pending unverified registrant is onboarded via google', function () {
|
||
/** @var TestCase $this */
|
||
$pending = User::factory()->unverified()->create([
|
||
'email' => 'pending@example.test',
|
||
'is_active' => false,
|
||
]);
|
||
|
||
fakeGoogleUser('g-pending-1', 'pending@example.test', 'Pending');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('me.dashboard', absolute: false));
|
||
|
||
$pending->refresh();
|
||
expect($pending->is_active)->toBeTrue();
|
||
expect($pending->hasVerifiedEmail())->toBeTrue();
|
||
expect($pending->hasRole('customer'))->toBeTrue();
|
||
});
|
||
|
||
test('a failed google callback redirects back to login with an error', function () {
|
||
/** @var TestCase $this */
|
||
$provider = Mockery::mock(Provider::class);
|
||
$provider->shouldReceive('user')->andThrow(new RuntimeException('invalid state'));
|
||
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('login'));
|
||
|
||
$this->assertGuest();
|
||
});
|
||
|
||
test('google login hands an account with confirmed two-factor to the challenge (Audit F02)', function () {
|
||
/** @var TestCase $this */
|
||
$user = User::factory()->create(['email' => 'totp-google@example.test', 'is_active' => true]);
|
||
$user->assignRole('customer');
|
||
$user->forceFill([
|
||
'oauth_provider' => 'google',
|
||
'oauth_provider_id' => 'g-totp-1',
|
||
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
|
||
'two_factor_confirmed_at' => now(),
|
||
])->save();
|
||
|
||
fakeGoogleUser('g-totp-1', 'totp-google@example.test', 'Totp');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('two-factor.challenge'));
|
||
|
||
$this->assertGuest();
|
||
expect(session('login.id'))->toBe($user->id);
|
||
expect($user->fresh()->last_login_at)->toBeNull();
|
||
});
|
||
|
||
/*
|
||
* K-04 (Umsetzungsaudit 29.09.2026): Vorab-Übernahme. Ein Angreifer
|
||
* registriert die Adresse der echten Pressestelle mit eigenem Passwort; die
|
||
* Pressestelle meldet sich später mit Google an. Danach darf weder das
|
||
* Passwort noch der Remember-Cookie noch eine offene Session des Angreifers
|
||
* das Konto erreichen.
|
||
*/
|
||
test('a pre-registered account is no longer reachable with the old password, token or session after google verifies it', function () {
|
||
/** @var TestCase $this */
|
||
$pending = User::factory()->unverified()->create([
|
||
'email' => 'pressestelle@firma.test',
|
||
'password' => Hash::make('attacker-secret'),
|
||
'is_active' => false,
|
||
'remember_token' => 'attacker-remember-token',
|
||
]);
|
||
$pending->createToken('attacker', ['press-releases:read']);
|
||
|
||
// Offene Session des Angreifers (vor der Google-Anmeldung entstanden).
|
||
$this->actingAs($pending);
|
||
$this->flushSession();
|
||
|
||
fakeGoogleUser('g-takeover-1', 'pressestelle@firma.test', 'Pressestelle');
|
||
|
||
Auth::logout();
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('me.dashboard', absolute: false));
|
||
|
||
$pending->refresh();
|
||
expect($pending->password)->toBeNull()
|
||
->and($pending->remember_token)->not->toBe('attacker-remember-token')
|
||
->and($pending->tokens()->count())->toBe(0);
|
||
|
||
// Die neue Google-Session bleibt gültig.
|
||
$this->get(route('me.dashboard'))->assertOk();
|
||
$this->assertAuthenticatedAs($pending);
|
||
|
||
// Die Session des Angreifers (ohne Anmeldezeitpunkt nach der Übernahme) nicht.
|
||
Auth::logout();
|
||
$this->flushSession();
|
||
$this->actingAs($pending)->get(route('me.dashboard'))->assertRedirect(route('login'));
|
||
$this->assertGuest();
|
||
|
||
// Und das alte Passwort führt nicht mehr hinein.
|
||
Livewire::test('auth.login')
|
||
->set('email', 'pressestelle@firma.test')
|
||
->set('password', 'attacker-secret')
|
||
->call('login')
|
||
->assertHasErrors(['email']);
|
||
$this->assertGuest();
|
||
});
|
||
|
||
/*
|
||
* S-05 (Sicherheitsaudit 07.10.2026): Google-Identität fest binden und die
|
||
* Erstverknüpfung bestehender Konten nur bei Googles Zuständigkeit für das
|
||
* Postfach direkt zulassen.
|
||
*/
|
||
test('google login is rejected without a confirmed email address', function (?bool $emailVerified) {
|
||
/** @var TestCase $this */
|
||
fakeGoogleUser('g-unverified-1', 'unverified-claim@gmail.com', 'Unverified', $emailVerified);
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('login'))
|
||
->assertSessionHasErrors('email');
|
||
|
||
$this->assertGuest();
|
||
expect(User::query()->where('email', 'unverified-claim@gmail.com')->exists())->toBeFalse();
|
||
})->with([
|
||
'email_verified false' => false,
|
||
'claim missing' => null,
|
||
]);
|
||
|
||
test('an account linked to another google identity is not relinked', function () {
|
||
/** @var TestCase $this */
|
||
$user = User::factory()->create([
|
||
'email' => 'linked@gmail.com',
|
||
'is_active' => true,
|
||
'oauth_provider' => 'google',
|
||
'oauth_provider_id' => 'g-original',
|
||
]);
|
||
$user->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-other', 'linked@gmail.com', 'Other');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('login'))
|
||
->assertSessionHasErrors('email');
|
||
|
||
$this->assertGuest();
|
||
expect($user->fresh()->oauth_provider_id)->toBe('g-original');
|
||
});
|
||
|
||
test('a linked google identity signs in even after its google address changed', function () {
|
||
/** @var TestCase $this */
|
||
$user = User::factory()->create([
|
||
'email' => 'old-address@firma.test',
|
||
'is_active' => true,
|
||
'oauth_provider' => 'google',
|
||
'oauth_provider_id' => 'g-stable',
|
||
]);
|
||
$user->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-stable', 'new-address@gmail.com', 'Stable');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('me.dashboard', absolute: false));
|
||
|
||
$this->assertAuthenticatedAs($user);
|
||
expect($user->fresh()->email)->toBe('old-address@firma.test');
|
||
});
|
||
|
||
test('an existing account with an external address is only linked after mailbox confirmation in the same browser', function () {
|
||
/** @var TestCase $this */
|
||
Mail::fake();
|
||
$user = User::factory()->create(['email' => 'presse@firma.test', 'is_active' => true]);
|
||
$user->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-external', 'presse@firma.test', 'Presse');
|
||
|
||
$this->get(route('oauth.google.callback'))
|
||
->assertRedirect(route('login'))
|
||
->assertSessionHas('status');
|
||
|
||
$this->assertGuest();
|
||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||
|
||
$confirmUrl = null;
|
||
Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) {
|
||
$confirmUrl = $mail->confirmUrl;
|
||
|
||
return $mail->hasTo('presse@firma.test');
|
||
});
|
||
|
||
$this->get($confirmUrl)->assertRedirect(route('me.dashboard', absolute: false));
|
||
|
||
$this->assertAuthenticatedAs($user);
|
||
expect($user->fresh()->oauth_provider_id)->toBe('g-external');
|
||
Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::GoogleLinked);
|
||
});
|
||
|
||
test('the link confirmation does nothing in a browser that did not start the google login', function () {
|
||
/** @var TestCase $this */
|
||
Mail::fake();
|
||
$user = User::factory()->create(['email' => 'presse2@firma.test', 'is_active' => true]);
|
||
$user->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-attacker', 'presse2@firma.test', 'Angreifer');
|
||
$this->get(route('oauth.google.callback'));
|
||
|
||
$confirmUrl = null;
|
||
Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) {
|
||
$confirmUrl = $mail->confirmUrl;
|
||
|
||
return true;
|
||
});
|
||
|
||
// Der Postfachinhaber klickt die Mail in seinem eigenen Browser an.
|
||
$this->flushSession();
|
||
|
||
$this->get($confirmUrl)
|
||
->assertRedirect(route('login'))
|
||
->assertSessionHasErrors('email');
|
||
|
||
$this->assertGuest();
|
||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||
});
|
||
|
||
test('a tampered link confirmation is rejected', function () {
|
||
/** @var TestCase $this */
|
||
Mail::fake();
|
||
$user = User::factory()->create(['email' => 'presse3@firma.test', 'is_active' => true]);
|
||
$user->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-real', 'presse3@firma.test', 'Presse');
|
||
$this->get(route('oauth.google.callback'));
|
||
|
||
$confirmUrl = null;
|
||
Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) {
|
||
$confirmUrl = $mail->confirmUrl;
|
||
|
||
return true;
|
||
});
|
||
|
||
$this->get(str_replace('g-real', 'g-forged', $confirmUrl))->assertForbidden();
|
||
|
||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||
});
|
||
|
||
test('a workspace account is linked directly when hd matches the address domain', function (string $hostedDomain, bool $linkedDirectly) {
|
||
/** @var TestCase $this */
|
||
Mail::fake();
|
||
$user = User::factory()->create(['email' => 'team@workspace-firma.test', 'is_active' => true]);
|
||
$user->assignRole('customer');
|
||
|
||
fakeGoogleUser('g-workspace', 'team@workspace-firma.test', 'Team', hostedDomain: $hostedDomain);
|
||
|
||
$this->get(route('oauth.google.callback'));
|
||
|
||
if ($linkedDirectly) {
|
||
$this->assertAuthenticatedAs($user);
|
||
expect($user->fresh()->oauth_provider_id)->toBe('g-workspace');
|
||
Mail::assertNotQueued(SocialAccountLinkConfirmation::class);
|
||
} else {
|
||
$this->assertGuest();
|
||
expect($user->fresh()->oauth_provider_id)->toBeNull();
|
||
Mail::assertQueued(SocialAccountLinkConfirmation::class);
|
||
}
|
||
})->with([
|
||
'matching hd' => ['workspace-firma.test', true],
|
||
'foreign hd' => ['andere-firma.test', false],
|
||
]);
|