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:
Kevin Adametz 2026-10-07 16:09:35 +00:00
parent fd8dd74d5f
commit bae8347f6c
66 changed files with 4035 additions and 144 deletions

View file

@ -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;
}

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

View 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'));
}
}

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

View file

@ -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);
}
/**

View 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;
}
}

View file

@ -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

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

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