- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
6.5 KiB
PHP
180 lines
6.5 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Laravel\Socialite\Contracts\Provider;
|
|
use Laravel\Socialite\Facades\Socialite;
|
|
use Laravel\Socialite\Two\User as SocialiteUser;
|
|
use Livewire\Volt\Volt;
|
|
use PragmaRX\Google2FA\Google2FA;
|
|
use Tests\TestCase;
|
|
|
|
beforeEach(function () {
|
|
$this->seed(RolesAndPermissionsSeeder::class);
|
|
});
|
|
|
|
function fakeGoogleUser(string $id, string $email, string $name): void
|
|
{
|
|
$socialiteUser = (new SocialiteUser)->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 user is linked by email without creating a duplicate', function () {
|
|
/** @var TestCase $this */
|
|
$existing = User::factory()->create(['email' => 'existing-google@example.test', 'is_active' => true]);
|
|
$existing->assignRole('customer');
|
|
|
|
fakeGoogleUser('g-existing-1', 'Existing-Google@example.test', 'Existing');
|
|
|
|
$this->get(route('oauth.google.callback'))
|
|
->assertRedirect(route('me.dashboard', absolute: false));
|
|
|
|
expect(User::query()->where('email', 'existing-google@example.test')->count())->toBe(1);
|
|
$existing->refresh();
|
|
expect($existing->oauth_provider)->toBe('google');
|
|
expect($existing->oauth_provider_id)->toBe('g-existing-1');
|
|
$this->assertAuthenticatedAs($existing);
|
|
});
|
|
|
|
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([
|
|
'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.
|
|
Volt::test('auth.login')
|
|
->set('email', 'pressestelle@firma.test')
|
|
->set('password', 'attacker-secret')
|
|
->call('login')
|
|
->assertHasErrors(['email']);
|
|
$this->assertGuest();
|
|
});
|