presseportale/tests/Feature/Auth/GoogleLoginTest.php
Kevin Adametz bae8347f6c security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins
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>
2026-10-07 16:09:35 +00:00

362 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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],
]);