presseportale/tests/Feature/Auth/LoginCompletionTest.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

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();
});