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>
160 lines
5.6 KiB
PHP
160 lines
5.6 KiB
PHP
<?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,
|
||
);
|
||
}
|
||
}
|