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

@ -3,7 +3,7 @@
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use App\Services\Auth\PasswordChanger;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
@ -12,7 +12,10 @@ class UpdateUserPassword implements UpdatesUserPasswords
use PasswordValidationRules;
/**
* Validate and update the user's password.
* Validate and update the user's password. Die Fortify-Route dazu ist
* abgeschaltet (Sicherheitsaudit 07.10.2026, Entscheidung 4); die Action
* nutzt trotzdem den gemeinsamen PasswordChanger, falls sie wieder
* aktiviert wird.
*
* @param array<string, string> $input
*/
@ -25,8 +28,6 @@ class UpdateUserPassword implements UpdatesUserPasswords
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
app(PasswordChanger::class)->change($user, $input['password'], request()->hasSession() ? request()->session() : null);
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Bewertet automatisch vergebene Firmenrechte (`granted_via` = auto bzw.
* auto-upgrade) aller betroffenen User neu (Sicherheitsaudit 07.10.2026,
* S-03). Der CompanyMembershipObserver erledigt das bei jeder Änderung über
* Eloquent; dieses Kommando bereinigt den Altbestand und repariert nach
* Massen-Updates per Query-Builder.
*
* Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt.
*/
class ResyncAutomaticMemberships extends Command
{
protected $signature = 'memberships:resync-auto
{--user= : Nur diesen User (ID) abgleichen}';
protected $description = 'Gleicht automatisch vergebene Firmenrechte mit den aktuellen Kontakt- und Firmenadressen ab.';
public function handle(ContactAccessService $contactAccess): int
{
$before = $this->countAutomaticGrants();
$userIds = DB::table('company_user')
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->when($this->option('user'), fn ($query, $userId) => $query->where('user_id', (int) $userId))
->distinct()
->pluck('user_id');
if ($userIds->isEmpty()) {
$this->info('Keine automatisch vergebenen Firmenrechte gefunden — nichts zu tun.');
return self::SUCCESS;
}
$bar = $this->output->createProgressBar($userIds->count());
foreach ($userIds->chunk(200) as $chunk) {
User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void {
$contactAccess->syncMembershipsForEmail($user);
$bar->advance();
});
}
$bar->finish();
$this->newLine();
$after = $this->countAutomaticGrants();
$this->info("Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}.");
return self::SUCCESS;
}
private function countAutomaticGrants(): int
{
return DB::table('company_user')
->where('role', 'responsible')
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->count();
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Enums;
/**
* Sicherheitsrelevante Kontoänderung, über die der Kontoinhaber an seine
* bisherige Adresse informiert wird (Sicherheitsaudit 07.10.2026, S-06).
*/
enum SecurityChange: string
{
case PasswordChanged = 'password_changed';
case PasswordReset = 'password_reset';
case EmailChanged = 'email_changed';
case TwoFactorEnabled = 'two_factor_enabled';
case TwoFactorDisabled = 'two_factor_disabled';
case GoogleLinked = 'google_linked';
case PasskeyAdded = 'passkey_added';
case PasskeyRemoved = 'passkey_removed';
case SecondFactorsReset = 'second_factors_reset';
public function label(): string
{
return match ($this) {
self::PasswordChanged => 'Ihr Passwort wurde geändert.',
self::PasswordReset => 'Ihr Passwort wurde über „Passwort vergessen“ neu gesetzt.',
self::EmailChanged => 'Die Login-Adresse Ihres Kontos wurde geändert.',
self::TwoFactorEnabled => 'Die Zwei-Faktor-Authentifizierung wurde aktiviert.',
self::TwoFactorDisabled => 'Die Zwei-Faktor-Authentifizierung wurde deaktiviert.',
self::GoogleLinked => 'Ein Google-Konto wurde mit Ihrem Konto verknüpft.',
self::PasskeyAdded => 'Ein neuer Passkey wurde zu Ihrem Konto hinzugefügt.',
self::PasskeyRemoved => 'Ein Passkey wurde aus Ihrem Konto entfernt.',
self::SecondFactorsReset => 'Ein Administrator hat Ihre Passkeys und Ihre Zwei-Faktor-Authentifizierung zurückgesetzt.',
};
}
/**
* Ob andere angemeldete Geräte dabei abgemeldet wurden.
*/
public function endsOtherSessions(): bool
{
return in_array($this, [self::PasswordChanged, self::PasswordReset], true);
}
}

View file

@ -2,9 +2,11 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SecurityChange;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use App\Services\Auth\SecurityNotifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -15,7 +17,7 @@ class ConfirmEmailChangeController extends Controller
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
*/
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess, SecurityNotifier $notifier): RedirectResponse
{
abort_unless($request->user()->is($user), 403);
@ -34,6 +36,8 @@ class ConfirmEmailChangeController extends Controller
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
}
$previousEmail = (string) $user->email;
$user->forceFill([
'email' => $user->pending_email,
'email_verified_at' => now(),
@ -45,6 +49,11 @@ class ConfirmEmailChangeController extends Controller
// vergeben – nicht erst beim nächsten Login.
$contactAccess->syncMembershipsForEmail($user);
// Die bisherige Adresse erfährt von der Änderung (Sicherheitsaudit
// 07.10.2026, S-06) — sonst bliebe eine Übernahme per fremder Session
// für den Inhaber unsichtbar.
$notifier->notify($user, SecurityChange::EmailChanged, recipient: $previousEmail, newEmail: (string) $user->email);
return redirect()->route('me.security')
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
}

View file

@ -3,17 +3,24 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\SocialAccountLinker;
use App\Services\Auth\SocialAuthService;
use App\Services\Auth\SocialLoginResult;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\AbstractUser;
use Laravel\Socialite\Facades\Socialite;
/**
* Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen
* aktiven, verifizierten customer-Account an; bestehende Accounts werden nur
* verknüpft. Deaktivierte Accounts werden nicht reaktiviert.
* aktiven, verifizierten customer-Account an; bestehende Accounts werden
* verknüpft, bei Adressen außerhalb von Googles Zuständigkeit erst nach
* Bestätigung per E-Mail (Sicherheitsaudit 07.10.2026, S-05). Deaktivierte
* Accounts werden nicht reaktiviert.
*/
class GoogleController extends Controller
{
@ -22,29 +29,72 @@ class GoogleController extends Controller
return Socialite::driver('google')->redirect();
}
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
public function callback(Request $request, SocialAuthService $social, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
{
try {
$googleUser = Socialite::driver('google')->user();
} catch (\Throwable) {
return redirect()->route('login')->withErrors([
'email' => __('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'),
]);
return $this->failed(__('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'));
}
$user = $social->resolveUser(
$raw = $googleUser instanceof AbstractUser ? $googleUser->getRaw() : [];
$providerId = (string) $googleUser->getId();
$result = $social->resolve(
'google',
(string) $googleUser->getId(),
$providerId,
$googleUser->getEmail(),
$googleUser->getName(),
emailVerified: filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
hostedDomain: isset($raw['hd']) ? (string) $raw['hd'] : null,
);
if (! $user || ! $user->is_active) {
return redirect()->route('login')->withErrors([
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
]);
if ($result->outcome === SocialLoginResult::UNVERIFIED_EMAIL) {
return $this->failed(__('Google hat keine bestätigte E-Mail-Adresse übermittelt. Bitte melden Sie sich per E-Mail-Link an.'));
}
if ($result->outcome === SocialLoginResult::PROVIDER_MISMATCH) {
Log::warning('google-login.provider-mismatch', ['user_id' => $result->user?->getKey()]);
return $this->failed(__('Ihr Konto ist bereits mit einem anderen Google-Konto verknüpft. Bitte melden Sie sich per E-Mail-Link an.'));
}
$user = $result->user;
if (! $user || ! $user->is_active) {
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
}
if ($result->outcome === SocialLoginResult::CONFIRM_LINK) {
if (! $linker->requestConfirmation($user, 'google', $providerId)) {
return $this->failed(__('Zu viele Anfragen. Bitte warten Sie einige Minuten und versuchen Sie es erneut.'));
}
return redirect()->route('login')->with('status', __('Für Ihr bestehendes Konto ist eine einmalige Bestätigung nötig: Wir haben Ihnen einen Link an Ihre E-Mail-Adresse geschickt. Bitte öffnen Sie ihn in diesem Browser.'));
}
return $this->completeLogin($request, $user, $completion);
}
/**
* Bestätigungslink aus der Mail (SocialAccountLinker): verknüpft und
* meldet an — nur in der Browser-Session, die den Google-Login begonnen hat.
*/
public function confirmLink(Request $request, User $user, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
{
if (! $linker->confirm($user, (string) $request->query('provider_id', ''))) {
return $this->failed(__('Der Bestätigungslink ist ungültig oder abgelaufen. Bitte öffnen Sie ihn im selben Browser, in dem Sie die Anmeldung mit Google gestartet haben, oder starten Sie die Anmeldung erneut.'));
}
if (! $user->is_active) {
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
}
return $this->completeLogin($request, $user, $completion);
}
private function completeLogin(Request $request, User $user, LoginCompletion $completion): RedirectResponse
{
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
// den Besitz des Postfachs, nicht den zweiten Faktor.
@ -65,4 +115,9 @@ class GoogleController extends Controller
LoginRedirect::homeFor($user),
));
}
private function failed(string $message): RedirectResponse
{
return redirect()->route('login')->withErrors(['email' => $message]);
}
}

View file

@ -33,6 +33,9 @@ class BlockUnusedFortifyRoutes
'two-factor.secret-key',
'two-factor.recovery-codes',
'two-factor.regenerate-recovery-codes',
// Passkeys löscht die Sicherheitsseite mit Identitätsnachweis
// (Entscheidung 5, 07.10.2026).
'passkey.destroy',
];
public function handle(Request $request, Closure $next): Response

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Middleware;
use App\Actions\Admin\UserImpersonation;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Passkey hinzufügen nur mit frischem Identitätsnachweis (Entscheidung 5,
* Sicherheitsaudit 07.10.2026, S-06): Ein Passkey ist dauerhafter Zugang —
* eine fremde Session allein darf keinen hinterlegen. Den Nachweis erbringt
* die Sicherheitsseite (ConfirmsIdentity), bevor sie die Registrierung im
* Browser startet. Während einer Impersonation nie.
*
* Hängt über `fortify.middleware` an allen Fortify-Routen und greift nur bei
* den Registrierungsrouten. Gelöscht wird über die Sicherheitsseite; die
* Fortify-Löschroute sperrt BlockUnusedFortifyRoutes.
*/
class EnsurePasskeyManagementIsConfirmed
{
/**
* @var list<string>
*/
public const GUARDED_ROUTES = [
'passkey.registration-options',
'passkey.store',
];
public function __construct(private readonly SecurityConfirmation $confirmation) {}
public function handle(Request $request, Closure $next): Response
{
if (! in_array($request->route()?->getName(), self::GUARDED_ROUTES, true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User || app(UserImpersonation::class)->isActive() || ! $this->confirmation->isFresh($user)) {
abort(403, __('Bitte bestätigen Sie zuerst Ihre Identität.'));
}
return $next($request);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Auth\StrongAuthentication;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Admin-Bereich nur mit starker Anmeldung (Entscheidung 5, 07.10.2026):
* Super-Admins und Admins bestätigen ihre Session einmalig per Passkey oder
* Authenticator-Code. Seitenaufrufe führen zur Bestätigungsseite und danach
* zurück; Livewire- und JSON-Folgeanfragen werden abgewiesen. Editoren und
* Kunden sind nicht betroffen. Läuft auch als persistente Livewire-Middleware.
*/
class EnsureStrongAdminAuthentication
{
public function __construct(private readonly StrongAuthentication $strong) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User || ! $this->strong->isRequiredFor($user) || $this->strong->isSatisfiedBy($user)) {
return $next($request);
}
if (! $request->isMethod('GET') || $request->expectsJson()) {
abort(403, __('Bitte bestätigen Sie Ihre Anmeldung mit Passkey oder Authenticator-Code.'));
}
$request->session()->put(StrongAuthentication::INTENDED_KEY, $request->fullUrl());
return redirect()->route('strong-auth.confirm');
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Responses;
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Support\LoginRedirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
/**
* Abschluss eines Passkey-Logins (Entscheidung 5, 07.10.2026): wie
* RoleAwareLoginResponse den Login protokollieren und nur auf erlaubte Ziele
* weiterleiten. Aktiv- und Verifizierungsstatus prüft bereits
* Passkeys::authorizeLoginUsing() vor der Anmeldung (FortifyServiceProvider).
* Der Browser-Client erwartet JSON mit `redirect`.
*/
class PasskeyLoginResponse implements PasskeyLoginResponseContract
{
public function toResponse($request): JsonResponse|RedirectResponse
{
/** @var User $user */
$user = $request->user();
app(LoginCompletion::class)->recordLogin($user, $request);
$target = LoginRedirect::safeTarget(
$user,
$request->session()->pull('url.intended'),
LoginRedirect::homeFor($user),
);
return $request->wantsJson()
? new JsonResponse(['redirect' => $target])
: redirect($target);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Listeners;
use App\Enums\SecurityChange;
use App\Models\MagicLink;
use App\Models\User;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\SessionRevocation;
use Illuminate\Auth\Events\PasswordReset;
/**
* Passwort-Reset als Kontowiederherstellung (Sicherheitsaudit 07.10.2026,
* S-04): Alle offenen Browser-Sessions enden, noch nicht eingelöste
* Anmeldelinks verfallen. Den Remember-Token rotiert die Reset-Komponente
* selbst. API-Tokens bleiben bestehen (Entscheidung 1) — der Hinweis an die
* Kontoadresse weist darauf hin.
*/
class RevokeAccessAfterPasswordReset
{
public function __construct(
private readonly SessionRevocation $sessions,
private readonly SecurityNotifier $notifier,
) {}
public function handle(PasswordReset $event): void
{
if (! $event->user instanceof User) {
return;
}
$this->sessions->revokeAllFor($event->user);
MagicLink::query()
->where('user_id', $event->user->getKey())
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->whereNull('consumed_at')
->update(['consumed_at' => now()]);
$this->notifier->notify($event->user, SecurityChange::PasswordReset);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Listeners;
use App\Enums\SecurityChange;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\StrongAuthentication;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
use Laravel\Passkeys\Events\PasskeyDeleted;
use Laravel\Passkeys\Events\PasskeyRegistered;
use Laravel\Passkeys\Events\PasskeyVerified;
/**
* Verbindet Passkeys und Authenticator-Codes mit den eigenen Schutzschichten
* (Entscheidung 5, 07.10.2026):
* - Ein geprüfter Passkey (Login oder Bestätigung) oder ein gültiger Code
* in der 2FA-Challenge macht die Session „stark“ (Admin-Bereich).
* - Ein geprüfter Passkey gilt zugleich als frischer Identitätsnachweis
* für Sicherheitsänderungen (S-06).
* - Hinzugefügte und entfernte Passkeys werden an die Kontoadresse gemeldet.
*/
class TrackSecondFactorEvents
{
public function __construct(
private readonly StrongAuthentication $strong,
private readonly SecurityConfirmation $confirmation,
private readonly SecurityNotifier $notifier,
) {}
public function handlePasskeyVerified(PasskeyVerified $event): void
{
if (! $event->user instanceof User) {
return;
}
$this->strong->mark($event->user, 'passkey');
$this->confirmation->markConfirmed($event->user);
// Login per Passkey: Anmeldeweg für das Aktivitätsprotokoll.
if (! auth()->guard('web')->check()) {
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'passkey');
}
}
public function handleValidTwoFactorCode(ValidTwoFactorAuthenticationCodeProvided $event): void
{
$this->strong->mark($event->user, 'totp');
}
public function handlePasskeyRegistered(PasskeyRegistered $event): void
{
if (! $event->user instanceof User) {
return;
}
// Die Registrierung verlangt Benutzerverifizierung am Gerät — die
// Session ist damit ebenso stark bestätigt wie durch einen Login.
$this->strong->mark($event->user, 'passkey');
$this->notifier->notify($event->user, SecurityChange::PasskeyAdded);
}
public function handlePasskeyDeleted(PasskeyDeleted $event): void
{
if ($event->user instanceof User) {
$this->notifier->notify($event->user, SecurityChange::PasskeyRemoved);
}
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\TwoFactorAuthenticationProvider;
/**
* Erneuter Nachweis vor sicherheitskritischen Aktionen (Audit F03, E3;
* Sicherheitsaudit 07.10.2026, S-06). Je nach Kontoart:
* - 2FA aktiv → aktueller App-Code oder Wiederherstellungs-Code,
* - Passwort gesetzt → Passwort,
* - sonst → Einmal-Code an die aktuelle Kontoadresse.
*
* Nach erfolgreichem Nachweis gilt die Session eine Weile als bestätigt
* (SecurityConfirmation). Für das Abschalten der 2FA und neue
* Wiederherstellungs-Codes verlangt assertSecondFactor() dagegen immer einen
* aktuellen zweiten Faktor. Das Formular dazu: `partials.security-proof`.
*/
trait ConfirmsIdentity
{
public string $securityCode = '';
public string $securityPassword = '';
public string $securityEmailCode = '';
public bool $securityEmailCodeSent = false;
public function requestSecurityEmailCode(): void
{
/** @var User $user */
$user = auth()->user();
if (! app(SecurityConfirmation::class)->sendEmailCode($user)) {
throw ValidationException::withMessages([
'securityEmailCode' => __('Zu viele Codes angefordert. Bitte warten Sie einige Minuten.'),
]);
}
$this->securityEmailCodeSent = true;
}
/**
* Art des Nachweises für die Anzeige: fresh, two_factor, password oder email_code.
*/
public function securityProofKind(User $user): string
{
return match (true) {
app(SecurityConfirmation::class)->isFresh($user) => 'fresh',
$user->hasEnabledTwoFactorAuthentication() => 'two_factor',
(string) $user->password !== '' => 'password',
default => 'email_code',
};
}
protected function assertSecurityProof(User $user): void
{
$confirmation = app(SecurityConfirmation::class);
if ($confirmation->isFresh($user)) {
return;
}
if ($user->hasEnabledTwoFactorAuthentication()) {
$this->assertSecondFactor($user);
} elseif ((string) $user->password !== '') {
try {
$this->validate(['securityPassword' => ['required', 'string', 'current_password']]);
} finally {
$this->reset('securityPassword');
}
} else {
$this->assertEmailCode($user);
}
$confirmation->markConfirmed($user);
}
/**
* Prüft `securityCode` als TOTP (Fortify-Provider, replay-geschützt) oder
* als Wiederherstellungs-Code (wird dabei verbraucht). Fünf Fehlversuche
* pro Minute, damit eine Session die sechs Stellen nicht durchprobiert.
*/
protected function assertSecondFactor(User $user): void
{
$this->validate(['securityCode' => ['required', 'string']]);
$code = trim($this->securityCode);
$this->reset('securityCode');
$throttleKey = 'security-proof:'.$user->getKey();
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
throw ValidationException::withMessages([
'securityCode' => __('Zu viele Fehlversuche. Bitte warten Sie eine Minute.'),
]);
}
$secret = decrypt($user->two_factor_secret);
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
RateLimiter::clear($throttleKey);
return;
}
if (in_array($code, $user->recoveryCodes() ?? [], true)) {
$user->replaceRecoveryCode($code);
RateLimiter::clear($throttleKey);
return;
}
RateLimiter::hit($throttleKey, 60);
throw ValidationException::withMessages([
'securityCode' => __('Der Code ist ungültig. Geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.'),
]);
}
private function assertEmailCode(User $user): void
{
$this->validate(['securityEmailCode' => ['required', 'string']], [
'securityEmailCode.required' => __('Bitte fordern Sie einen Bestätigungscode per E-Mail an und geben Sie ihn ein.'),
]);
$code = $this->securityEmailCode;
$this->reset('securityEmailCode');
if (! app(SecurityConfirmation::class)->verifyEmailCode($user, $code)) {
throw ValidationException::withMessages([
'securityEmailCode' => __('Der Code ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Code an.'),
]);
}
$this->securityEmailCodeSent = false;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use App\Enums\SecurityChange;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
/**
* Hinweis an die (bisherige) Kontoadresse nach einer sicherheitsrelevanten
* Änderung (Sicherheitsaudit 07.10.2026, S-06). Enthält bewusst keinen Link
* mit Anmeldewirkung und keine Codes — nur Art und Zeitpunkt der Änderung.
*/
class SecurityChangeNotice extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public SecurityChange $change,
public string $occurredAt,
public ?string $newEmail = null,
) {}
public function headers(): Headers
{
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Sicherheitshinweis zu Ihrem pressekonto'
);
}
public function content(): Content
{
return new Content(
view: 'emails.auth.security-change-notice'
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
/**
* Einmal-Code für den Identitätsnachweis von Konten ohne Passwort und ohne
* 2FA (Sicherheitsaudit 07.10.2026, S-06).
*/
class SecurityConfirmationCode extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $code,
public string $expiresAt,
) {}
public function headers(): Headers
{
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Ihr Bestätigungscode für pressekonto'
);
}
public function content(): Content
{
return new Content(
view: 'emails.auth.security-confirmation-code'
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
/**
* Bestätigungslink für die Erstverknüpfung eines Google-Kontos mit einem
* bestehenden Konto (Sicherheitsaudit 07.10.2026, S-05).
*/
class SocialAccountLinkConfirmation extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $confirmUrl,
public string $expiresAt,
) {}
public function headers(): Headers
{
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Google-Anmeldung für Ihr pressekonto bestätigen'
);
}
public function content(): Content
{
return new Content(
view: 'emails.auth.social-account-link-confirmation'
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -19,14 +19,16 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Cashier\Billable;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements MustVerifyEmail
class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
{
/** @use HasFactory<UserFactory> */
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, SoftDeletes, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.

View file

@ -0,0 +1,105 @@
<?php
namespace App\Observers;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use App\Support\EmailQuery;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Bewertet automatisch vergebene Firmenrechte neu, sobald sich ihre
* Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03): Kontakt- oder
* Firmenadresse geändert, Kontakt in eine andere Firma verschoben, gelöscht
* oder wiederhergestellt, Login-Adresse eines Users geändert.
*
* Bisher geschah das nur beim Login — ein aktiver Nutzer behielt entzogene
* Rechte so lange, wie seine Session lief. Am Model statt in den
* Formularen: Kunden-Newsroom, Admin-CRM, API und Importer schreiben dieselben
* Felder. Läuft synchron in derselben Transaktion; ein Rollback nimmt die
* Rechteänderung mit zurück. Massen-Updates per Query-Builder lösen keine
* Events aus — dafür `php artisan memberships:resync-auto`.
*/
class CompanyMembershipObserver
{
public function __construct(private readonly ContactAccessService $contactAccess) {}
public function updated(Model $model): void
{
if ($model instanceof User) {
if ($model->wasChanged('email')) {
$this->contactAccess->syncMembershipsForEmail($model);
}
return;
}
$relevant = $model instanceof Contact ? ['email', 'company_id'] : ['email'];
if (! $model->wasChanged($relevant)) {
return;
}
$this->resync(
emails: [$model->getOriginal('email'), $model->getAttribute('email')],
companyIds: $this->companyIdsOf($model, withOriginal: true),
);
}
public function deleted(Model $model): void
{
if ($model instanceof User) {
return;
}
$this->resync([$model->getAttribute('email')], $this->companyIdsOf($model));
}
public function restored(Model $model): void
{
$this->deleted($model);
}
/**
* @return list<int>
*/
private function companyIdsOf(Company|Contact|Model $model, bool $withOriginal = false): array
{
$ids = $model instanceof Contact
? [$model->getAttribute('company_id'), $withOriginal ? $model->getOriginal('company_id') : null]
: [$model->getKey()];
return array_values(array_unique(array_map('intval', array_filter($ids))));
}
/**
* Alle User, deren automatische Rechte an den betroffenen Firmen hängen,
* sowie alle User mit einer der betroffenen Adressen.
*
* @param list<string|null> $emails
* @param list<int> $companyIds
*/
private function resync(array $emails, array $companyIds): void
{
$userIds = $companyIds === [] ? collect() : DB::table('company_user')
->whereIn('company_id', $companyIds)
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->pluck('user_id');
/** @var Collection<int, User> $users */
$users = User::query()->whereIn('id', $userIds->all())->get();
foreach (array_unique(array_filter(array_map(
fn (?string $email): string => mb_strtolower(trim((string) $email)),
$emails,
))) as $email) {
$users = $users->merge(EmailQuery::whereEmail(User::query(), $email)->get());
}
$users->unique('id')->each(fn (User $user) => $this->contactAccess->syncMembershipsForEmail($user));
}
}

View file

@ -2,9 +2,16 @@
namespace App\Policies;
use App\Authorization\Permission;
use App\Models\LegacyInvoice;
use App\Models\User;
/**
* Alt-Rechnungen aus dem Legacy-Import. Kunden sehen ihre eigenen Belege,
* fremde nur mit `billing:manage` — der Panel-Zugang (Editor) allein reicht
* nicht (Sicherheitsaudit 07.10.2026, S-01). Gilt für die Kunden- und die
* Admin-Downloadroute gleichermaßen.
*/
class LegacyInvoicePolicy
{
public function before(User $user): ?bool
@ -19,7 +26,7 @@ class LegacyInvoicePolicy
public function view(User $user, LegacyInvoice $legacyInvoice): bool
{
if ($user->canAccessAdmin()) {
if ($user->can(Permission::BillingManage)) {
return true;
}

View file

@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient;
use App\Exceptions\MailDeliveryPausedException;
use App\Helpers\ThemeHelper;
use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureStrongAdminAuthentication;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
@ -31,6 +32,7 @@ use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\CompanyMembershipObserver;
use App\Observers\CompanyPublicCacheObserver;
use App\Observers\EmailDomainObserver;
use App\Observers\ImageRightsConfirmationObserver;
@ -243,6 +245,12 @@ class AppServiceProvider extends ServiceProvider
Company::observe(EmailDomainObserver::class);
Contact::observe(EmailDomainObserver::class);
// Automatische Firmenrechte sofort neu bewerten, wenn sich ihre
// E-Mail-Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03).
Company::observe(CompanyMembershipObserver::class);
Contact::observe(CompanyMembershipObserver::class);
User::observe(CompanyMembershipObserver::class);
// Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
Company::observe(CompanyPublicCacheObserver::class);
@ -266,6 +274,7 @@ class AppServiceProvider extends ServiceProvider
Livewire::addPersistentMiddleware([
BlockAdminDuringImpersonation::class,
EnsureUserIsAdmin::class,
EnsureStrongAdminAuthentication::class,
EnsureUserIsCustomer::class,
LogSlowAdminRequests::class,
]);

View file

@ -6,7 +6,9 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Http\Responses\FailedTwoFactorChallengeResponse;
use App\Http\Responses\PasskeyLoginResponse;
use App\Http\Responses\RoleAwareLoginResponse;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@ -17,6 +19,10 @@ use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify;
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\Passkey;
use Laravel\Passkeys\Passkeys;
class FortifyServiceProvider extends ServiceProvider
{
@ -30,6 +36,7 @@ class FortifyServiceProvider extends ServiceProvider
// Fortifys Default leitet auf die nicht registrierte Route
// `two-factor.login` (views => false) – zurück zur Livewire-Challenge.
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
$this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class);
}
/**
@ -54,5 +61,15 @@ class FortifyServiceProvider extends ServiceProvider
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(10)->by($request->user()?->getAuthIdentifier() ?? $request->ip());
});
// Passkey-Login (Entscheidung 5, 07.10.2026): nur aktive, verifizierte
// Konten — wie die übrigen Anmeldewege.
Passkeys::authorizeLoginUsing(function (Request $request, PasskeyUser $user, Passkey $passkey): bool {
return $user instanceof User && $user->is_active && $user->hasVerifiedEmail();
});
}
}

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