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>
169 lines
7.1 KiB
PHP
169 lines
7.1 KiB
PHP
<?php
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Models\ActivityEvent;
|
|
use App\Models\Company;
|
|
use App\Models\MagicLink;
|
|
use App\Models\PressRelease;
|
|
use App\Models\User;
|
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
|
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;
|
|
|
|
/**
|
|
* Einheitliche Login-Abschlüsse (Plan 1.8, Umsetzungsaudit 29.09.2026 K-08):
|
|
* Passwort, Magic Link und Google setzen denselben Stand — auch nach der
|
|
* 2FA-Challenge —, und ein eingeloggter Kunde kann seinen eigenen Link öffnen.
|
|
*/
|
|
beforeEach(function (): void {
|
|
/** @var TestCase $this */
|
|
$this->seed(RolesAndPermissionsSeeder::class);
|
|
});
|
|
|
|
/**
|
|
* @return array{user: User, secret: string|null, company: Company, pr: PressRelease}
|
|
*/
|
|
function loginCompletionCustomer(bool $twoFactor = false): array
|
|
{
|
|
$user = User::factory()->create(['is_active' => true]);
|
|
$user->assignRole('customer');
|
|
$secret = null;
|
|
|
|
if ($twoFactor) {
|
|
$secret = app(Google2FA::class)->generateSecretKey();
|
|
$user->forceFill(['two_factor_secret' => encrypt($secret), 'two_factor_confirmed_at' => now()])->save();
|
|
}
|
|
|
|
$company = Company::factory()->presseecho()->create();
|
|
$user->companies()->attach($company->id, ['role' => 'owner']);
|
|
$pr = PressRelease::factory()->create([
|
|
'user_id' => $user->id,
|
|
'company_id' => $company->id,
|
|
'portal' => $company->portal->value,
|
|
'status' => 'draft',
|
|
]);
|
|
|
|
return compact('user', 'secret', 'company', 'pr');
|
|
}
|
|
|
|
function loginCompletionLink(User $user, string $token, array $payload = []): MagicLink
|
|
{
|
|
return MagicLink::query()->create([
|
|
'user_id' => $user->id,
|
|
'token_hash' => hash('sha256', $token),
|
|
'purpose' => 'login',
|
|
'payload' => $payload ?: null,
|
|
'expires_at' => now()->addMinutes(30),
|
|
]);
|
|
}
|
|
|
|
function loginCompletionMethod(User $user): ?string
|
|
{
|
|
return ActivityEvent::query()
|
|
->where('type', ActivityType::UserLoggedIn->value)
|
|
->where('user_id', $user->id)
|
|
->latest('id')
|
|
->first()?->meta['method'] ?? null;
|
|
}
|
|
|
|
test('the password login records the last login also after the two-factor challenge', function () {
|
|
/** @var TestCase $this */
|
|
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
|
|
|
|
Livewire::test('auth.login')
|
|
->set('email', $user->email)
|
|
->set('password', 'password')
|
|
->call('login')
|
|
->assertRedirect(route('two-factor.challenge'));
|
|
|
|
$this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)])
|
|
->assertRedirect(route('me.dashboard', absolute: false));
|
|
|
|
$this->assertAuthenticatedAs($user);
|
|
expect($user->fresh()->last_login_at)->not->toBeNull()
|
|
->and(loginCompletionMethod($user))->toBe('password');
|
|
});
|
|
|
|
test('a magic link keeps its landing target and its method through the two-factor challenge', function () {
|
|
/** @var TestCase $this */
|
|
['user' => $user, 'secret' => $secret, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer(twoFactor: true);
|
|
loginCompletionLink($user, 'landing-2fa', ['company_id' => $company->id, 'press_release_id' => $pr->id]);
|
|
|
|
$this->get(route('magic-links.consume', ['token' => 'landing-2fa']))
|
|
->assertRedirect(route('two-factor.challenge'));
|
|
|
|
$this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)])
|
|
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
|
|
|
|
expect($user->fresh()->last_login_at)->not->toBeNull()
|
|
->and(loginCompletionMethod($user))->toBe('magic-link');
|
|
});
|
|
|
|
test('google no longer forces a remember cookie, also not through the two-factor challenge', function () {
|
|
/** @var TestCase $this */
|
|
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
|
|
|
|
$provider = Mockery::mock(Provider::class);
|
|
// Bereits verknüpftes Konto; die Erstverknüpfung prüft GoogleLoginTest (S-05).
|
|
$user->forceFill(['oauth_provider' => 'google', 'oauth_provider_id' => 'g-lc-1'])->save();
|
|
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->setRaw(['email_verified' => true])->map(['id' => 'g-lc-1', 'email' => $user->email, 'name' => 'Kunde']));
|
|
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
|
|
|
$this->get(route('oauth.google.callback'))->assertRedirect(route('two-factor.challenge'));
|
|
expect(session('login.remember'))->toBeFalse();
|
|
|
|
$response = $this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)]);
|
|
|
|
$response->assertRedirect(route('me.dashboard', absolute: false));
|
|
expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse()
|
|
->and(loginCompletionMethod($user))->toBe('google')
|
|
->and($user->fresh()->last_login_at)->not->toBeNull();
|
|
});
|
|
|
|
test('google without two-factor logs in without a remember cookie', function () {
|
|
/** @var TestCase $this */
|
|
['user' => $user] = loginCompletionCustomer();
|
|
|
|
$provider = Mockery::mock(Provider::class);
|
|
// Bereits verknüpftes Konto; die Erstverknüpfung prüft GoogleLoginTest (S-05).
|
|
$user->forceFill(['oauth_provider' => 'google', 'oauth_provider_id' => 'g-lc-2'])->save();
|
|
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->setRaw(['email_verified' => true])->map(['id' => 'g-lc-2', 'email' => $user->email, 'name' => 'Kunde']));
|
|
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
|
|
|
$response = $this->get(route('oauth.google.callback'));
|
|
|
|
$response->assertRedirect(route('me.dashboard', absolute: false));
|
|
$this->assertAuthenticatedAs($user);
|
|
expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse();
|
|
});
|
|
|
|
test('a logged-in customer opening his own magic link lands on its target', function () {
|
|
/** @var TestCase $this */
|
|
['user' => $user, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer();
|
|
$link = loginCompletionLink($user, 'own-link', ['company_id' => $company->id, 'press_release_id' => $pr->id]);
|
|
|
|
$this->actingAs($user)
|
|
->get(route('magic-links.consume', ['token' => 'own-link']))
|
|
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
|
|
|
|
$this->assertAuthenticatedAs($user);
|
|
expect($link->fresh()->consumed_at)->not->toBeNull();
|
|
});
|
|
|
|
test('a logged-in user opening a link of another account keeps the previous behaviour', function () {
|
|
/** @var TestCase $this */
|
|
['user' => $user] = loginCompletionCustomer();
|
|
['user' => $other] = loginCompletionCustomer();
|
|
$link = loginCompletionLink($other, 'foreign-link');
|
|
|
|
$this->actingAs($user)
|
|
->get(route('magic-links.consume', ['token' => 'foreign-link']))
|
|
->assertRedirect(route('me.dashboard'));
|
|
|
|
$this->assertAuthenticatedAs($user);
|
|
expect($link->fresh()->consumed_at)->toBeNull();
|
|
});
|