presseportale/app/Services/Auth/SocialAuthService.php
Kevin Adametz 1c07fcb84b 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

160 lines
5.6 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
namespace App\Services\Auth;
use App\Enums\SecurityChange;
use App\Models\User;
use Illuminate\Support\Str;
/**
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
*
* Reihenfolge (Sicherheitsaudit 07.10.2026, S-05):
* 1. Ohne vom Provider bestätigte E-Mail (`email_verified`) kein Login.
* 2. Eine bereits verknüpfte Provider-Identität (`oauth_provider_id`) ist
* die stabile Bindung und gewinnt vor der E-Mail.
* 3. Sonst De-Dup über die E-Mail. Ein Konto, das schon mit einer ANDEREN
* Identität desselben Providers verknüpft ist, wird nicht still
* umgehängt.
* 4. Die Erstverknüpfung eines bestehenden, verifizierten Kontos erfolgt
* direkt nur, wenn Google für das Postfach zuständig ist (gmail.com bzw.
* Workspace-Domain `hd`). Bei sonstigen Adressen belegt Google den
* aktuellen Postfachbesitz nicht — dann erst nach Bestätigungslink an die
* Adresse (SocialAccountLinker).
*
* Neue Konten und noch unverifizierte Selbst-Registrierer werden wie bisher
* direkt angelegt bzw. übernommen (Entscheidung 15.06., Audit K-04).
*/
class SocialAuthService
{
/**
* Domains, deren Postfächer Google selbst betreibt.
*/
private const GOOGLE_MAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
private readonly CustomerAccountProvisioner $accountProvisioner,
private readonly SessionRevocation $sessions,
private readonly SecurityNotifier $notifier,
) {}
/**
* Ein deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer
* prüft danach is_active und blockiert.
*/
public function resolve(
string $provider,
string $providerId,
?string $email,
?string $name,
bool $emailVerified,
?string $hostedDomain = null,
): SocialLoginResult {
$email = $email ? mb_strtolower(trim($email)) : null;
if ($email === null || $email === '' || ! $emailVerified || $providerId === '') {
return SocialLoginResult::unverifiedEmail();
}
$linked = User::query()
->where('oauth_provider', $provider)
->where('oauth_provider_id', $providerId)
->first();
if ($linked) {
return SocialLoginResult::login($linked);
}
$user = User::findByEmail($email);
if (! $user) {
return SocialLoginResult::login($this->createUser($provider, $providerId, $email, $name));
}
if (filled($user->oauth_provider_id)) {
return SocialLoginResult::providerMismatch($user);
}
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
//
// Vorab-Übernahme (Audit K-04): Wer die Adresse unverifiziert
// registriert hat, war nicht zwingend der Inhaber des Postfachs. Alles,
// was er dabei gesetzt hat, verfällt: Passwort, Remember-Cookie,
// API-Tokens und offene Sessions. Der Inhaber meldet sich per Google
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
if ($user->email_verified_at === null) {
$user->forceFill([
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
'email_verified_at' => now(),
'is_active' => true,
'password' => null,
]);
$user->setRememberToken(Str::random(60));
$user->save();
$user->tokens()->delete();
$this->sessions->revokeAllFor($user);
if ($user->roles()->doesntExist()) {
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
}
return SocialLoginResult::login($user);
}
if (! $this->providerOwnsMailbox($email, $hostedDomain)) {
return SocialLoginResult::confirmLink($user);
}
// Bereits verifiziert: nur Provider verknüpfen. is_active bleibt
// unangetastet (deaktivierte Accounts werden nicht reaktiviert).
$this->link($user, $provider, $providerId);
return SocialLoginResult::login($user);
}
/**
* Verknüpft die Provider-Identität mit einem bestehenden Konto und
* informiert den Kontoinhaber darüber.
*/
public function link(User $user, string $provider, string $providerId): void
{
$user->forceFill([
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
])->save();
$this->notifier->notify($user, SecurityChange::GoogleLinked);
}
/**
* Google ist für das Postfach zuständig: eigene Mail-Domain oder eine
* Workspace-Domain (`hd`), zu der die Adresse gehört.
*/
private function providerOwnsMailbox(string $email, ?string $hostedDomain): bool
{
$domain = Str::afterLast($email, '@');
if (in_array($domain, self::GOOGLE_MAIL_DOMAINS, true)) {
return true;
}
return filled($hostedDomain) && strcasecmp(trim($hostedDomain), $domain) === 0;
}
private function createUser(string $provider, string $providerId, string $email, ?string $name): User
{
return $this->accountProvisioner->provisionVerifiedCustomer(
$email,
$name !== null ? trim($name) : '',
[
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
],
channel: $provider,
);
}
}