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>
This commit is contained in:
parent
fd8dd74d5f
commit
bae8347f6c
66 changed files with 4035 additions and 144 deletions
|
|
@ -119,16 +119,22 @@ class ContactAccessService
|
|||
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
|
||||
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
|
||||
* die Freischaltung des Einlieferers, nicht die E-Mail.
|
||||
*
|
||||
* Neben Login und E-Mail-Bestätigung ruft auch der
|
||||
* CompanyMembershipObserver diese Methode auf, sobald sich Kontakt- oder
|
||||
* Firmenadressen bzw. Kontaktzuordnungen ändern — der Entzug greift damit
|
||||
* in der laufenden Session (Sicherheitsaudit 07.10.2026, S-03).
|
||||
*/
|
||||
public function syncMembershipsForEmail(User $user): void
|
||||
{
|
||||
if (! $user->is_active || ! $user->hasVerifiedEmail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$email = mb_strtolower(trim((string) $user->email));
|
||||
|
||||
if ($email === '') {
|
||||
// Ohne nachgewiesene Adresse gibt es keine Grundlage für automatische
|
||||
// Rechte — vorhandene fallen weg (Sicherheitsaudit 07.10.2026, S-03:
|
||||
// der Abgleich läuft jetzt auch außerhalb des Logins).
|
||||
if (! $user->is_active || ! $user->hasVerifiedEmail() || $email === '') {
|
||||
$this->revokeStaleAutomaticGrants($user, collect());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
41
app/Services/Auth/PasswordChanger.php
Normal file
41
app/Services/Auth/PasswordChanger.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Einziger Weg für den Passwortwechsel eines angemeldeten Kontos
|
||||
* (Sicherheitsaudit 07.10.2026, S-04). Neben dem neuen Hash wird der
|
||||
* Remember-Token rotiert und jede andere Browser-Session beendet — die
|
||||
* aktuelle bleibt angemeldet. API-Tokens bleiben bewusst bestehen
|
||||
* (Entscheidung 1: Integrationen sollen nicht unangekündigt brechen); der
|
||||
* Hinweis an die Kontoadresse nennt sie.
|
||||
*/
|
||||
class PasswordChanger
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SessionRevocation $sessions,
|
||||
private readonly SecurityNotifier $notifier,
|
||||
) {}
|
||||
|
||||
public function change(User $user, string $plainPassword, ?Session $currentSession = null): void
|
||||
{
|
||||
$user->forceFill([
|
||||
'password' => Hash::make($plainPassword),
|
||||
'remember_token' => Str::random(60),
|
||||
])->save();
|
||||
|
||||
if ($currentSession !== null) {
|
||||
$this->sessions->revokeOthersFor($user, $currentSession);
|
||||
} else {
|
||||
$this->sessions->revokeAllFor($user);
|
||||
}
|
||||
|
||||
$this->notifier->notify($user, SecurityChange::PasswordChanged);
|
||||
}
|
||||
}
|
||||
121
app/Services/Auth/SecurityConfirmation.php
Normal file
121
app/Services/Auth/SecurityConfirmation.php
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Mail\SecurityConfirmationCode;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
/**
|
||||
* Frischer Identitätsnachweis vor dauerhaften Sicherheitsänderungen
|
||||
* (Sicherheitsaudit 07.10.2026, S-06): Login-Adresse ändern, 2FA erstmals
|
||||
* einrichten, API-Token erzeugen. Eine bestehende Session allein reicht dafür
|
||||
* nicht.
|
||||
*
|
||||
* Konten ohne Passwort und ohne 2FA (Magic-Link-/Google-Konten) bestätigen
|
||||
* per Einmal-Code an die aktuelle Kontoadresse — wer nur die Session, nicht
|
||||
* aber das Postfach hat, kommt damit nicht weiter. Nach erfolgreichem Nachweis
|
||||
* gilt die Session für FRESH_FOR_SECONDS als bestätigt.
|
||||
*/
|
||||
class SecurityConfirmation
|
||||
{
|
||||
public const SESSION_KEY = 'auth.security_confirmed';
|
||||
|
||||
public const FRESH_FOR_SECONDS = 900;
|
||||
|
||||
public const CODE_VALID_MINUTES = 10;
|
||||
|
||||
public const MAX_CODE_ATTEMPTS = 5;
|
||||
|
||||
public function isFresh(User $user): bool
|
||||
{
|
||||
$confirmed = Session::get(self::SESSION_KEY);
|
||||
|
||||
return is_array($confirmed)
|
||||
&& (int) ($confirmed['user_id'] ?? 0) === (int) $user->getKey()
|
||||
&& (int) ($confirmed['at'] ?? 0) >= now()->getTimestamp() - self::FRESH_FOR_SECONDS;
|
||||
}
|
||||
|
||||
public function markConfirmed(User $user): void
|
||||
{
|
||||
Session::put(self::SESSION_KEY, [
|
||||
'user_id' => $user->getKey(),
|
||||
'at' => now()->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schickt einen 6-stelligen Code an die aktuelle Kontoadresse. Liefert
|
||||
* false, wenn zu viele Codes angefordert wurden (3 je 10 Minuten).
|
||||
*/
|
||||
public function sendEmailCode(User $user): bool
|
||||
{
|
||||
$throttleKey = 'security-email-code-send:'.$user->getKey();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 600);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$expiresAt = now()->addMinutes(self::CODE_VALID_MINUTES);
|
||||
|
||||
Cache::put($this->codeKey($user), [
|
||||
'hash' => $this->hash($code),
|
||||
'attempts' => 0,
|
||||
'expires_at' => $expiresAt->getTimestamp(),
|
||||
], $expiresAt);
|
||||
|
||||
Mail::to($user->email)->queue((new SecurityConfirmationCode(
|
||||
user: $user,
|
||||
code: $code,
|
||||
expiresAt: $expiresAt->format('H:i'),
|
||||
))->afterCommit());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft den Code; er ist nur einmal gültig und nach
|
||||
* MAX_CODE_ATTEMPTS Fehlversuchen verbraucht.
|
||||
*/
|
||||
public function verifyEmailCode(User $user, string $code): bool
|
||||
{
|
||||
$key = $this->codeKey($user);
|
||||
$entry = Cache::get($key);
|
||||
|
||||
if (! is_array($entry) || (int) ($entry['expires_at'] ?? 0) < now()->getTimestamp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hash_equals((string) $entry['hash'], $this->hash(trim($code)))) {
|
||||
Cache::forget($key);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$entry['attempts'] = (int) $entry['attempts'] + 1;
|
||||
|
||||
if ($entry['attempts'] >= self::MAX_CODE_ATTEMPTS) {
|
||||
Cache::forget($key);
|
||||
} else {
|
||||
Cache::put($key, $entry, now()->setTimestamp((int) $entry['expires_at']));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function codeKey(User $user): string
|
||||
{
|
||||
return 'security-email-code:'.$user->getKey();
|
||||
}
|
||||
|
||||
private function hash(string $code): string
|
||||
{
|
||||
return hash_hmac('sha256', $code, (string) config('app.key'));
|
||||
}
|
||||
}
|
||||
36
app/Services/Auth/SecurityNotifier.php
Normal file
36
app/Services/Auth/SecurityNotifier.php
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Enums\SecurityChange;
|
||||
use App\Mail\SecurityChangeNotice;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Informiert den Kontoinhaber über sicherheitsrelevante Änderungen
|
||||
* (Sicherheitsaudit 07.10.2026, S-06). Wer eine fremde Session nutzt, soll
|
||||
* nicht unbemerkt Passwort, Login-Adresse oder zweiten Faktor ändern können.
|
||||
*/
|
||||
class SecurityNotifier
|
||||
{
|
||||
/**
|
||||
* @param string|null $recipient Abweichender Empfänger, z. B. die
|
||||
* bisherige Adresse nach einem E-Mail-Wechsel.
|
||||
*/
|
||||
public function notify(User $user, SecurityChange $change, ?string $recipient = null, ?string $newEmail = null): void
|
||||
{
|
||||
$recipient = trim((string) ($recipient ?? $user->email));
|
||||
|
||||
if ($recipient === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($recipient)->queue((new SecurityChangeNotice(
|
||||
user: $user,
|
||||
change: $change,
|
||||
occurredAt: now()->format('d.m.Y H:i'),
|
||||
newEmail: $newEmail,
|
||||
))->afterCommit());
|
||||
}
|
||||
}
|
||||
|
|
@ -31,13 +31,30 @@ class SessionRevocation
|
|||
/**
|
||||
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
|
||||
*/
|
||||
public function revokeAllFor(User $user): void
|
||||
public function revokeAllFor(User $user): int
|
||||
{
|
||||
$revokedAt = now()->getTimestampMs();
|
||||
|
||||
Cache::put(
|
||||
$this->cacheKey((int) $user->getKey()),
|
||||
now()->getTimestampMs(),
|
||||
$revokedAt,
|
||||
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
|
||||
);
|
||||
|
||||
return $revokedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle anderen Sessions des Kontos werden ungültig, die übergebene
|
||||
* (aktuelle) bleibt angemeldet: Sie erhält einen Anmeldezeitpunkt nach
|
||||
* der Sperre und eine neue Session-ID (Sicherheitsaudit 07.10.2026, S-04).
|
||||
*/
|
||||
public function revokeOthersFor(User $user, Session $current): void
|
||||
{
|
||||
$revokedAt = $this->revokeAllFor($user);
|
||||
|
||||
$current->put(self::SESSION_KEY, $revokedAt + 1);
|
||||
$current->migrate(true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
app/Services/Auth/SocialAccountLinker.php
Normal file
90
app/Services/Auth/SocialAccountLinker.php
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Mail\SocialAccountLinkConfirmation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
/**
|
||||
* Bestätigte Erstverknüpfung eines Social-Logins mit einem bestehenden Konto
|
||||
* (Sicherheitsaudit 07.10.2026, S-05, Entscheidung 3).
|
||||
*
|
||||
* Die Provider-Identität wird zunächst nur in der Session vorgemerkt, die
|
||||
* den Login gestartet hat; an die Kontoadresse geht ein signierter Link.
|
||||
* Verknüpft wird erst, wenn dieser Link in DERSELBEN Browser-Session geöffnet
|
||||
* wird. So belegt der Klick den Postfachbesitz, und ein Postfachinhaber, der
|
||||
* eine fremd ausgelöste Mail anklickt, verknüpft nichts.
|
||||
*/
|
||||
class SocialAccountLinker
|
||||
{
|
||||
public const SESSION_KEY = 'oauth.pending_link';
|
||||
|
||||
public const EXPIRES_IN_MINUTES = 30;
|
||||
|
||||
public function __construct(private readonly SocialAuthService $social) {}
|
||||
|
||||
/**
|
||||
* Merkt die Verknüpfung vor und verschickt den Bestätigungslink.
|
||||
* Liefert false, wenn für das Konto zu viele Anfragen kamen.
|
||||
*/
|
||||
public function requestConfirmation(User $user, string $provider, string $providerId): bool
|
||||
{
|
||||
$throttleKey = 'social-link-mail:'.$user->getKey();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RateLimiter::hit($throttleKey, 600);
|
||||
|
||||
$expiresAt = now()->addMinutes(self::EXPIRES_IN_MINUTES);
|
||||
|
||||
Session::put(self::SESSION_KEY, [
|
||||
'user_id' => $user->getKey(),
|
||||
'provider' => $provider,
|
||||
'provider_id' => $providerId,
|
||||
'expires_at' => $expiresAt->getTimestamp(),
|
||||
]);
|
||||
|
||||
$confirmUrl = URL::temporarySignedRoute('oauth.google.confirm-link', $expiresAt, [
|
||||
'user' => $user->getKey(),
|
||||
'provider_id' => $providerId,
|
||||
]);
|
||||
|
||||
Mail::to($user->email)->queue((new SocialAccountLinkConfirmation(
|
||||
user: $user,
|
||||
confirmUrl: $confirmUrl,
|
||||
expiresAt: $expiresAt->format('H:i'),
|
||||
))->afterCommit());
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert die vorgemerkte Verknüpfung, wenn sie zu dieser Session,
|
||||
* diesem Konto und dieser Provider-Identität gehört.
|
||||
*/
|
||||
public function confirm(User $user, string $providerId): bool
|
||||
{
|
||||
$pending = Session::pull(self::SESSION_KEY);
|
||||
|
||||
if (! is_array($pending)
|
||||
|| (int) ($pending['user_id'] ?? 0) !== (int) $user->getKey()
|
||||
|| ! hash_equals((string) ($pending['provider_id'] ?? ''), $providerId)
|
||||
|| (int) ($pending['expires_at'] ?? 0) < now()->getTimestamp()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (filled($user->oauth_provider_id)) {
|
||||
return hash_equals((string) $user->oauth_provider_id, $providerId);
|
||||
}
|
||||
|
||||
$this->social->link($user, (string) $pending['provider'], $providerId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,47 +2,79 @@
|
|||
|
||||
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.
|
||||
*
|
||||
* Identität ist die E-Mail (De-Dup darüber, keine Dubletten). Der Provider
|
||||
* bestätigt die E-Mail, daher gilt die Verifizierung über den Kanal als erfüllt
|
||||
* (Entscheidung 15.06.) – kein zusätzlicher E-Mail-Verifizierungsschritt.
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein
|
||||
* deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer prüft
|
||||
* danach is_active und blockiert.
|
||||
* Ein deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer
|
||||
* prüft danach is_active und blockiert.
|
||||
*/
|
||||
public function resolveUser(string $provider, string $providerId, ?string $email, ?string $name): ?User
|
||||
{
|
||||
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 === '') {
|
||||
return 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 $this->createUser($provider, $providerId, $email, $name);
|
||||
return SocialLoginResult::login($this->createUser($provider, $providerId, $email, $name));
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'oauth_provider' => $provider,
|
||||
'oauth_provider_id' => $providerId,
|
||||
]);
|
||||
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).
|
||||
|
|
@ -54,6 +86,8 @@ class SocialAuthService
|
|||
// 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,
|
||||
|
|
@ -68,14 +102,47 @@ class SocialAuthService
|
|||
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
|
||||
}
|
||||
|
||||
return $user;
|
||||
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).
|
||||
$user->save();
|
||||
$this->link($user, $provider, $providerId);
|
||||
|
||||
return $user;
|
||||
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
|
||||
|
|
|
|||
47
app/Services/Auth/SocialLoginResult.php
Normal file
47
app/Services/Auth/SocialLoginResult.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Ergebnis der Auflösung eines Social-Logins (SocialAuthService::resolve()).
|
||||
*/
|
||||
final readonly class SocialLoginResult
|
||||
{
|
||||
public const LOGIN = 'login';
|
||||
|
||||
/** Bestehendes Konto, Verknüpfung erst nach Bestätigung per E-Mail (S-05). */
|
||||
public const CONFIRM_LINK = 'confirm_link';
|
||||
|
||||
/** Keine bzw. eine vom Provider nicht bestätigte E-Mail-Adresse. */
|
||||
public const UNVERIFIED_EMAIL = 'unverified_email';
|
||||
|
||||
/** Konto ist bereits mit einer anderen Identität desselben Providers verknüpft. */
|
||||
public const PROVIDER_MISMATCH = 'provider_mismatch';
|
||||
|
||||
private function __construct(
|
||||
public string $outcome,
|
||||
public ?User $user = null,
|
||||
) {}
|
||||
|
||||
public static function login(User $user): self
|
||||
{
|
||||
return new self(self::LOGIN, $user);
|
||||
}
|
||||
|
||||
public static function confirmLink(User $user): self
|
||||
{
|
||||
return new self(self::CONFIRM_LINK, $user);
|
||||
}
|
||||
|
||||
public static function unverifiedEmail(): self
|
||||
{
|
||||
return new self(self::UNVERIFIED_EMAIL);
|
||||
}
|
||||
|
||||
public static function providerMismatch(User $user): self
|
||||
{
|
||||
return new self(self::PROVIDER_MISMATCH, $user);
|
||||
}
|
||||
}
|
||||
56
app/Services/Auth/StrongAuthentication.php
Normal file
56
app/Services/Auth/StrongAuthentication.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
|
||||
/**
|
||||
* Starke Anmeldung für den Admin-Bereich (Entscheidung 5, 07.10.2026).
|
||||
*
|
||||
* Super-Admins und Admins brauchen eine Session, die mit einem zweiten Faktor
|
||||
* bestätigt wurde: Passkey (Login oder Bestätigung, Benutzerverifizierung per
|
||||
* Biometrie/PIN ist Pflicht) oder Authenticator-Code (2FA-Challenge bzw.
|
||||
* Bestätigung). Magic Link, Google und Passwort allein genügen nicht — sonst
|
||||
* ließe sich die Pflicht über den bequemsten Anmeldeweg umgehen. Die Marke
|
||||
* gilt für die Lebensdauer der Session.
|
||||
*/
|
||||
class StrongAuthentication
|
||||
{
|
||||
public const SESSION_KEY = 'auth.strong';
|
||||
|
||||
public const INTENDED_KEY = 'auth.strong_intended';
|
||||
|
||||
public function isRequiredFor(User $user): bool
|
||||
{
|
||||
if (! config('auth.require_strong_admin_authentication', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->is_super_admin || $user->hasRole('admin');
|
||||
}
|
||||
|
||||
public function isSatisfiedBy(User $user): bool
|
||||
{
|
||||
$strong = Session::get(self::SESSION_KEY);
|
||||
|
||||
return is_array($strong) && (int) ($strong['user_id'] ?? 0) === (int) $user->getKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $method passkey oder totp
|
||||
*/
|
||||
public function mark(User $user, string $method): void
|
||||
{
|
||||
Session::put(self::SESSION_KEY, [
|
||||
'user_id' => $user->getKey(),
|
||||
'method' => $method,
|
||||
'at' => now()->getTimestamp(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function hasPasskeys(User $user): bool
|
||||
{
|
||||
return $user->passkeys()->exists();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue