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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
68
app/Console/Commands/ResyncAutomaticMemberships.php
Normal file
68
app/Console/Commands/ResyncAutomaticMemberships.php
Normal 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();
|
||||
}
|
||||
}
|
||||
51
app/Enums/SecurityChange.php
Normal file
51
app/Enums/SecurityChange.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
app/Http/Middleware/EnsurePasskeyManagementIsConfirmed.php
Normal file
49
app/Http/Middleware/EnsurePasskeyManagementIsConfirmed.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
app/Http/Middleware/EnsureStrongAdminAuthentication.php
Normal file
38
app/Http/Middleware/EnsureStrongAdminAuthentication.php
Normal 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');
|
||||
}
|
||||
}
|
||||
38
app/Http/Responses/PasskeyLoginResponse.php
Normal file
38
app/Http/Responses/PasskeyLoginResponse.php
Normal 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);
|
||||
}
|
||||
}
|
||||
42
app/Listeners/RevokeAccessAfterPasswordReset.php
Normal file
42
app/Listeners/RevokeAccessAfterPasswordReset.php
Normal 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);
|
||||
}
|
||||
}
|
||||
71
app/Listeners/TrackSecondFactorEvents.php
Normal file
71
app/Listeners/TrackSecondFactorEvents.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
142
app/Livewire/Concerns/ConfirmsIdentity.php
Normal file
142
app/Livewire/Concerns/ConfirmsIdentity.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
app/Mail/SecurityChangeNotice.php
Normal file
54
app/Mail/SecurityChangeNotice.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
51
app/Mail/SecurityConfirmationCode.php
Normal file
51
app/Mail/SecurityConfirmationCode.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
51
app/Mail/SocialAccountLinkConfirmation.php
Normal file
51
app/Mail/SocialAccountLinkConfirmation.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
105
app/Observers/CompanyMembershipObserver.php
Normal file
105
app/Observers/CompanyMembershipObserver.php
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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