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

48
resources/js/passkeys.js Normal file
View file

@ -0,0 +1,48 @@
// Passkeys (Entscheidung 5, 07.10.2026): dünne Hülle um den offiziellen
// Laravel-Client für Login-Seite, Sicherheitsseite und Admin-Bestätigung.
// Kein eigenes Alpine — die Seiten rufen window.PkPasskeys aus ihren
// bestehenden Alpine-Komponenten auf. Ergebnis immer { ok, error, result }.
import {
Passkeys,
InvalidDomainError,
NotSupportedError,
PasskeyExistsError,
UserCancelledError,
} from "@laravel/passkeys";
const CONFIRM_ROUTES = {
options: "/passkeys/confirm/options",
submit: "/passkeys/confirm",
};
function messageFor(error) {
if (error instanceof UserCancelledError) {
return "Der Vorgang wurde abgebrochen.";
}
if (error instanceof NotSupportedError) {
return "Dieser Browser unterstützt keine Passkeys.";
}
if (error instanceof PasskeyExistsError) {
return "Für dieses Gerät ist bereits ein Passkey hinterlegt.";
}
if (error instanceof InvalidDomainError) {
return "Passkeys funktionieren nur auf der Anmeldeseite von pressekonto.";
}
return "Der Passkey konnte nicht geprüft werden. Bitte versuchen Sie es erneut.";
}
async function run(action) {
try {
return { ok: true, error: null, result: await action() };
} catch (error) {
return { ok: false, error: messageFor(error), result: null };
}
}
window.PkPasskeys = {
isSupported: () => Passkeys.isSupported(),
login: (remember = false) => run(() => Passkeys.verify({ remember })),
confirm: () => run(() => Passkeys.verify({ routes: CONFIRM_ROUTES })),
register: (name) => run(() => Passkeys.register({ name })),
};

View file

@ -450,7 +450,7 @@
<flux:toast position="top end" class="pt-24" />
@endpersist
@vite(['resources/js/portal-form-hooks.js'], 'build/portal')
@vite(['resources/js/portal-form-hooks.js', 'resources/js/passkeys.js'], 'build/portal')
@fluxScripts
</body>

View file

@ -58,6 +58,8 @@
laden, gäbe es zwei Alpine-Instanzen und wire:submit, x-data,
wire:model würden brechen (siehe Browser-Logs vor diesem Fix). --}}
@vite([$themeCssPath], $assetsDir)
{{-- Passkey-Login (Entscheidung 5): kleiner Client ohne eigenes Alpine. --}}
@vite(['resources/js/passkeys.js'], 'build/portal')
@livewireStyles
<x-monitoring.browser-errors :build="$assetsDir" area="auth" />
</head>

View file

@ -0,0 +1,23 @@
<x-emails.layout title="Sicherheitshinweis" preheader="An Ihrem pressekonto wurde eine sicherheitsrelevante Einstellung geändert.">
<p>Hallo {{ $user->name }},</p>
<p><strong>{{ $change->label() }}</strong></p>
@if ($newEmail)
<p>Neue Login-Adresse: <strong>{{ $newEmail }}</strong>. Diese Nachricht geht an Ihre bisherige Adresse; künftige Kontomails erhalten Sie an die neue.</p>
@endif
<p>Zeitpunkt: {{ $occurredAt }} Uhr.</p>
@if ($change === \App\Enums\SecurityChange::SecondFactorsReset)
<p>Alle angemeldeten Geräte wurden abgemeldet. Bitte melden Sie sich erneut an und richten Sie unter „Sicherheit“ einen neuen Passkey oder die Authenticator-App ein.</p>
@endif
@if ($change->endsOtherSessions())
<p>Alle anderen angemeldeten Geräte wurden dabei abgemeldet. Bestehende API-Tokens und Passkeys bleiben gültig — prüfen Sie diese bei Bedarf in Ihrem Konto unter „Sicherheit“ bzw. „API-Tokens“.</p>
@endif
<div class="note">
<p>Waren Sie das nicht? Setzen Sie Ihr Passwort über „Passwort vergessen“ auf der Login-Seite zurück und wenden Sie sich an {{ config('legal.contact_email') }}.</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,13 @@
<x-emails.layout title="Ihr Bestätigungscode" preheader="Bestätigungscode für eine Sicherheitsänderung an Ihrem pressekonto.">
<p>Hallo {{ $user->name }},</p>
<p>für eine Sicherheitsänderung an Ihrem Konto (z. B. neue Login-Adresse, Zwei-Faktor-Authentifizierung oder API-Token) benötigen wir eine Bestätigung. Ihr Code lautet:</p>
<p style="font-size: 26px; font-weight: 700; letter-spacing: 6px; text-align: center;">{{ $code }}</p>
<p>Der Code ist gültig bis {{ $expiresAt }} Uhr und kann nur einmal verwendet werden.</p>
<div class="note">
<p>Falls Sie keine Änderung vorgenommen haben, geben Sie den Code nicht weiter und setzen Sie Ihr Passwort über „Passwort vergessen“ zurück — jemand nutzt möglicherweise eine Anmeldung zu Ihrem Konto.</p>
</div>
</x-emails.layout>

View file

@ -0,0 +1,13 @@
<x-emails.layout title="Google-Anmeldung bestätigen" preheader="Bestätigen Sie die Verknüpfung Ihres Google-Kontos mit Ihrem pressekonto.">
<p>Hallo {{ $user->name }},</p>
<p>soeben wurde versucht, sich mit einem Google-Konto bei Ihrem pressekonto anzumelden. Damit diese Anmeldung künftig möglich ist, bestätigen Sie bitte die Verknüpfung.</p>
<x-emails.button :url="$confirmUrl">Google-Anmeldung bestätigen</x-emails.button>
<p>Der Link ist gültig bis {{ $expiresAt }} Uhr und funktioniert nur in dem Browser, in dem Sie die Anmeldung mit Google gestartet haben.</p>
<div class="note">
<p>Falls Sie das nicht waren, ignorieren Sie diese E-Mail bitte — ohne Bestätigung wird nichts verknüpft.</p>
</div>
</x-emails.layout>

View file

@ -3,6 +3,7 @@
use App\Models\Company;
use App\Models\Contact;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
@ -10,6 +11,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
{
use \App\Livewire\Concerns\HasFullTextSearch;
#[Locked]
public int $id;
public string $activeTab = 'overview';
@ -53,8 +55,16 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
$this->selectedExistingContactId = null;
}
/**
* Hängt einen bestehenden Kontakt an diese Firma um. Die Seite ist für
* Editoren lesbar, die Aktion schreibt aber — daher `companies:write`
* direkt an der Mutation (Sicherheitsaudit 07.10.2026, S-02). Greift auch
* für den Aufruf über updatedSelectedExistingContactId().
*/
public function attachExistingContact(): void
{
$this->authorize('companies:write');
if (! $this->selectedExistingContactId) {
return;
}
@ -146,7 +156,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
$contactLookupResults = collect();
$lookupTerm = trim($this->contactLookup);
if ($this->activeTab === 'contacts' && mb_strlen($lookupTerm) >= 1) {
if ($this->activeTab === 'contacts' && mb_strlen($lookupTerm) >= 1 && auth()->user()->can('companies:write')) {
$contactLookupResults = Contact::withoutGlobalScopes()
->with('company:id,name')
->where('company_id', '!=', $company->id)
@ -398,13 +408,14 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
icon="magnifying-glass"
class="flex-1"
/>
@if (\Illuminate\Support\Facades\Route::has('admin.companies.contacts.create'))
@can('companies:write')
<flux:button size="sm" icon="plus" href="{{ route('admin.companies.contacts.create', ['companyId' => $company->id]) }}" wire:navigate>
{{ __('Neu') }}
</flux:button>
@endif
@endcan
</div>
@can('companies:write')
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4">
<div class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-2">
{{ __('Bestehenden Kontakt zuordnen') }}
@ -444,6 +455,7 @@ new #[Layout('components.layouts.app'), Title('Firma anzeigen')] class extends C
</x-slot>
</flux:select>
</div>
@endcan
<div class="space-y-2">
@forelse ($filteredContacts as $contact)

View file

@ -2,10 +2,13 @@
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Enums\SecurityChange;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\SessionRevocation;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -622,6 +625,48 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
'countries' => config('countries.items', []),
'portalOptions' => Portal::cases(),
'registrationTypeOptions' => RegistrationType::cases(),
'secondFactors' => $this->secondFactorsOfTarget(),
];
}
/**
* Notfallweg bei verlorenem Gerät (Entscheidung 5, 07.10.2026): Ein
* Super-Admin setzt Passkeys und Authenticator-App eines anderen Kontos
* zurück. Alle Sessions des Kontos enden, der Inhaber wird informiert und
* richtet den zweiten Faktor nach der nächsten Anmeldung neu ein.
*/
public function resetSecondFactors(SessionRevocation $sessions, SecurityNotifier $notifier): void
{
$this->authorize('users:manage');
abort_unless(auth()->user()->is_super_admin, 403);
$user = User::query()->findOrFail($this->id);
abort_if($user->is(auth()->user()), 403, __('Das eigene Konto verwalten Sie unter „Sicherheit“.'));
$user->passkeys()->delete();
$user->forceFill([
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
])->save();
$sessions->revokeAllFor($user);
$notifier->notify($user, SecurityChange::SecondFactorsReset);
session()->flash('success', __('Passkeys und Zwei-Faktor-Authentifizierung wurden zurückgesetzt. Alle Sitzungen des Kontos sind beendet.'));
}
/**
* @return array{passkeys: int, totp: bool, canReset: bool}
*/
private function secondFactorsOfTarget(): array
{
$user = User::query()->find($this->id);
return [
'passkeys' => $user?->passkeys()->count() ?? 0,
'totp' => (bool) $user?->hasEnabledTwoFactorAuthentication(),
'canReset' => $user !== null && auth()->user()->is_super_admin && ! $user->is(auth()->user()),
];
}
@ -1160,6 +1205,24 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
@endforelse
</flux:card>
{{-- Zweiter Faktor (Entscheidung 5): Notfall-Reset nur durch Super-Admins. --}}
<flux:card id="second-factors">
<flux:heading size="lg" class="mb-2">{{ __('Zweiter Faktor') }}</flux:heading>
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ trans_choice('{0} Keine Passkeys|{1} :count Passkey|[2,*] :count Passkeys', $secondFactors['passkeys'], ['count' => $secondFactors['passkeys']]) }}
· {{ $secondFactors['totp'] ? __('Authenticator-App aktiv') : __('keine Authenticator-App') }}
</p>
@if ($secondFactors['canReset'] && ($secondFactors['passkeys'] > 0 || $secondFactors['totp']))
<div class="mt-4">
<flux:button type="button" variant="danger" icon="arrow-path"
wire:click="resetSecondFactors"
wire:confirm="{{ __('Passkeys und Authenticator-App dieses Kontos zurücksetzen und alle Sitzungen beenden? Nur bei verlorenem Gerät und nach Rücksprache mit dem Inhaber.') }}">
{{ __('Zweiten Faktor zurücksetzen') }}
</flux:button>
</div>
@endif
</flux:card>
<flux:card id="billing-address">
<flux:heading size="lg" class="mb-4">{{ __('Rechnungsadresse') }}</flux:heading>

View file

@ -175,6 +175,27 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
<span class="flex-1 h-px bg-bg-rule"></span>
</div>
{{-- Passkey-Login (Entscheidung 5, 07.10.2026): Fingerabdruck, Gesicht
oder Geräte-PIN statt Passwort und App-Code. Nur sichtbar, wenn der
Browser WebAuthn kann. --}}
<div x-data="{ busy: false, error: null, supported: typeof window.PublicKeyCredential !== 'undefined' }" x-show="supported" class="!mt-0 mb-3">
<button type="button" class="auth-btn-outline !mt-0" x-bind:disabled="busy"
x-on:click="busy = true; error = null;
const outcome = await window.PkPasskeys.login($wire.remember);
if (outcome.ok) { window.location.href = outcome.result?.redirect ?? '/'; return; }
error = outcome.error; busy = false;">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle cx="6" cy="5" r="3" stroke="currentColor" stroke-width="1.4" />
<path d="M1.5 14c0-2.5 2-4.5 4.5-4.5 1 0 1.9.3 2.6.8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
<circle cx="12" cy="10" r="2" stroke="currentColor" stroke-width="1.4" />
<path d="M12 12v3M12 14h1.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" />
</svg>
<span x-show="! busy">{{ __('Mit Passkey anmelden') }}</span>
<span x-show="busy">{{ __('Passkey wird geprüft …') }}</span>
</button>
<p class="field-error" x-show="error" x-text="error"></p>
</div>
<a href="{{ route('magic-link.request') }}" class="auth-btn-outline !mt-0" wire:navigate>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect x="2" y="3" width="12" height="10" stroke="currentColor" stroke-width="1.4" />

View file

@ -59,7 +59,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Neues Passwor
return;
}
Session::flash('status', __($status));
// Alle offenen Sessions sind beendet (RevokeAccessAfterPasswordReset);
// API-Tokens bleiben bewusst gültig (Sicherheitsaudit 07.10.2026, S-04).
Session::flash('status', __('Ihr Passwort wurde zurückgesetzt. Alle angemeldeten Geräte wurden abgemeldet. Bitte prüfen Sie nach der Anmeldung unter „Sicherheit“ auch Ihre API-Tokens.'));
$this->redirectRoute('login', navigate: true);
}

View file

@ -0,0 +1,147 @@
<?php
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use App\Services\Auth\StrongAuthentication;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Einmalige Bestätigung der Session für den Admin-Bereich (Entscheidung 5,
* 07.10.2026): per Passkey (Browser-Zeremonie gegen /passkeys/confirm, die
* Marke setzt TrackSecondFactorEvents) oder per Authenticator-Code. Ohne
* beides führt die Seite zur Einrichtung auf der Sicherheitsseite.
*/
new #[Layout('components.layouts.app'), Title('Anmeldung bestätigen')] class extends Component
{
use ConfirmsIdentity;
public function mount(): void
{
if (app(StrongAuthentication::class)->isSatisfiedBy($this->user())) {
$this->continueToIntended();
}
}
public function confirmWithCode(): void
{
$user = $this->user();
abort_unless($user->hasEnabledTwoFactorAuthentication(), 403);
$this->assertSecondFactor($user);
app(StrongAuthentication::class)->mark($user, 'totp');
app(SecurityConfirmation::class)->markConfirmed($user);
$this->continueToIntended();
}
/**
* Aufruf nach erfolgreicher Passkey-Zeremonie im Browser.
*/
public function passkeyConfirmed(): void
{
if (! app(StrongAuthentication::class)->isSatisfiedBy($this->user())) {
$this->addError('passkey', __('Der Passkey konnte nicht bestätigt werden. Bitte versuchen Sie es erneut.'));
return;
}
$this->continueToIntended();
}
public function with(): array
{
$user = $this->user();
return [
'hasPasskeys' => app(StrongAuthentication::class)->hasPasskeys($user),
'hasTotp' => $user->hasEnabledTwoFactorAuthentication(),
];
}
private function continueToIntended(): void
{
$intended = session()->pull(StrongAuthentication::INTENDED_KEY);
$host = request()->getHost();
// Nur Ziele auf dieser Domain (kein offener Redirect).
$target = is_string($intended) && parse_url($intended, PHP_URL_HOST) === $host
? $intended
: route('dashboard');
$this->redirect($target);
}
private function user(): User
{
/** @var User */
return auth()->user();
}
}; ?>
<div class="max-w-[560px] space-y-6">
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3">
<span class="eyebrow muted">{{ __('Admin-Bereich') }}</span>
</div>
<h1 class="text-[26px] font-bold tracking-[-0.5px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Anmeldung bestätigen') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 text-[color:var(--color-ink-2)]">
{{ __('Für den Admin-Bereich bestätigen Sie Ihre Anmeldung einmalig mit einem zweiten Faktor. Die Bestätigung gilt, bis Sie sich abmelden.') }}
</p>
</div>
</header>
@if ($hasPasskeys)
<article class="panel p-5 space-y-3"
x-data="{ busy: false, error: null }">
<div class="section-eyebrow">{{ __('Mit Passkey') }}</div>
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Fingerabdruck, Gesicht oder Geräte-PIN — ohne Code aus der App.') }}
</p>
<flux:button variant="primary" icon="finger-print" x-bind:disabled="busy"
x-on:click="busy = true; error = null;
const outcome = await window.PkPasskeys.confirm();
if (outcome.ok) { await $wire.passkeyConfirmed(); }
else { error = outcome.error; }
busy = false;">
{{ __('Mit Passkey bestätigen') }}
</flux:button>
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
@error('passkey')
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0">{{ $message }}</p>
@enderror
</article>
@endif
@if ($hasTotp)
<article class="panel p-5">
<form wire:submit="confirmWithCode" class="space-y-3 max-w-[320px]">
<div class="section-eyebrow">{{ __('Mit Authenticator-App') }}</div>
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
<flux:button type="submit" :variant="$hasPasskeys ? 'filled' : 'primary'">
{{ __('Bestätigen') }}
</flux:button>
</form>
</article>
@endif
@if (! $hasPasskeys && ! $hasTotp)
<article class="panel p-5 space-y-3">
<div class="section-eyebrow">{{ __('Zweiten Faktor einrichten') }}</div>
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Für Administratoren ist ein zweiter Faktor Pflicht. Richten Sie am einfachsten einen Passkey ein — alternativ die Authenticator-App. Danach geht es hier direkt weiter.') }}
</p>
<flux:button variant="primary" icon="shield-check" :href="route('me.security')" wire:navigate>
{{ __('Jetzt einrichten') }}
</flux:button>
</article>
@endif
</div>

View file

@ -1,12 +1,16 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\SecurityChange;
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Mail\EmailChangeConfirmation;
use App\Models\User;
use App\Services\Auth\PasswordChanger;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\StrongAuthentication;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
@ -17,7 +21,7 @@ use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Laravel\Fortify\Features;
use Laravel\Fortify\TwoFactorAuthenticationProvider;
use Laravel\Passkeys\Actions\DeletePasskey;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@ -25,6 +29,8 @@ use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends Component
{
use ConfirmsIdentity;
public string $current_password = '';
public string $password = '';
@ -35,15 +41,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
public string $twoFactorCode = '';
/**
* Erneuter Nachweis vor sicherheitskritischen Aktionen (Audit F03, E3):
* bei aktiver 2FA ein aktueller App-Code oder ein Wiederherstellungs-Code,
* sonst das Passwort. Eine bestehende Session allein reicht nicht, um die
* 2FA abzuschalten, Codes neu zu erzeugen oder die Adresse zu wechseln.
*/
public string $securityCode = '';
public string $securityPassword = '';
public string $passkeyName = '';
/**
* Recovery-Codes werden nur direkt nach Bestätigung bzw. Neu-Erzeugung
@ -72,13 +70,11 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
throw $e;
}
Auth::user()->forceFill([
'password' => Hash::make($validated['password']),
])->save();
app(PasswordChanger::class)->change(Auth::user(), $validated['password'], session()->driver());
$this->reset('current_password', 'password', 'password_confirmation');
session()->flash('security-status', __('Passwort aktualisiert.'));
session()->flash('security-status', __('Passwort aktualisiert. Alle anderen angemeldeten Geräte wurden abgemeldet.'));
}
/**
@ -135,9 +131,19 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
session()->flash('security-status', __('Die ausstehende E-Mail-Änderung wurde verworfen.'));
}
/**
* Erstmalige Einrichtung nur mit frischem Nachweis (Sicherheitsaudit
* 07.10.2026, S-06): Wer nur eine fremde Session hat, soll keinen eigenen
* Authenticator registrieren und den Inhaber damit aussperren können.
*/
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
{
$enable(auth()->user());
/** @var User $user */
$user = auth()->user();
$this->assertSecurityProof($user);
$enable($user);
$this->showRecoveryCodes = false;
session()->flash('security-status', __('Scannen Sie den QR-Code mit Ihrer Authenticator-App und bestätigen Sie die Einrichtung mit einem aktuellen Code.'));
@ -162,6 +168,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
$this->reset('twoFactorCode');
$this->showRecoveryCodes = true;
app(SecurityNotifier::class)->notify(auth()->user(), SecurityChange::TwoFactorEnabled);
// Der gerade bestätigte Code belegt den zweiten Faktor auch für den
// Admin-Bereich (Entscheidung 5).
app(StrongAuthentication::class)->mark(auth()->user(), 'totp');
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung ist jetzt aktiv. Speichern Sie Ihre Wiederherstellungs-Codes — sie werden nur einmal angezeigt.'));
}
@ -172,7 +184,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
// Eine noch unbestätigte Einrichtung abzubrechen braucht keinen
// Nachweis – aktiv ist die 2FA erst nach dem bestätigten App-Code.
if ($user->hasEnabledTwoFactorAuthentication()) {
$wasEnabled = $user->hasEnabledTwoFactorAuthentication();
if ($wasEnabled) {
$this->assertSecondFactor($user);
}
@ -180,6 +194,10 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
$this->reset('twoFactorCode');
$this->showRecoveryCodes = false;
if ($wasEnabled) {
app(SecurityNotifier::class)->notify($user, SecurityChange::TwoFactorDisabled);
}
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
}
@ -197,70 +215,49 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
}
/**
* Nachweis je nach Kontoart: 2FA aktiv → zweiter Faktor; Passwort gesetzt
* → Passwort; reines Magic-Link-Konto ohne beides → kein weiterer Nachweis
* (der Zugang selbst ist dann bereits der Postfach-Nachweis, und die neue
* Adresse muss ihren Bestätigungslink ohnehin einlösen).
* Startet das Hinzufügen eines Passkeys (Entscheidung 5, 07.10.2026):
* erst der frische Identitätsnachweis, dann die Browser-Zeremonie gegen
* Fortifys Registrierungsrouten (EnsurePasskeyManagementIsConfirmed prüft
* den Nachweis dort erneut).
*/
private function assertSecurityProof(User $user): void
public function startPasskeyRegistration(): void
{
if ($user->hasEnabledTwoFactorAuthentication()) {
$this->assertSecondFactor($user);
$this->ensureNotImpersonating();
return;
}
$validated = $this->validate(['passkeyName' => ['required', 'string', 'max:60']], [], [
'passkeyName' => __('Name des Passkeys'),
]);
if ((string) $user->password === '') {
return;
}
$this->assertSecurityProof(auth()->user());
try {
$this->validate(['securityPassword' => ['required', 'string', 'current_password']]);
} finally {
$this->reset('securityPassword');
}
$this->dispatch('passkey-register', name: $validated['passkeyName']);
}
/**
* 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.
*/
private function assertSecondFactor(User $user): void
public function passkeyRegistered(): void
{
$this->validate(['securityCode' => ['required', 'string']]);
$this->reset('passkeyName');
$code = trim($this->securityCode);
$this->reset('securityCode');
session()->flash('security-status', __('Passkey hinzugefügt. Sie können sich ab sofort damit anmelden.'));
}
$throttleKey = 'security-proof:'.$user->getKey();
public function deletePasskey(int $passkeyId, DeletePasskey $delete): void
{
$this->ensureNotImpersonating();
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
throw ValidationException::withMessages([
'securityCode' => __('Zu viele Fehlversuche. Bitte warten Sie eine Minute.'),
]);
}
/** @var User $user */
$user = auth()->user();
$secret = decrypt($user->two_factor_secret);
$this->assertSecurityProof($user);
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
RateLimiter::clear($throttleKey);
$passkey = $user->passkeys()->whereKey($passkeyId)->firstOrFail();
$delete($user, $passkey);
return;
}
session()->flash('security-status', __('Passkey entfernt.'));
}
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 ensureNotImpersonating(): void
{
abort_if(app(UserImpersonation::class)->isActive(), 403, __('Während der Benutzer-Impersonation können keine Passkeys verwaltet werden.'));
}
public function with(): array
@ -295,6 +292,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
'twoFactorConfirmed' => $twoFactorConfirmed,
'twoFactorQrSvg' => $qrUrl,
'recoveryCodes' => $recoveryCodes,
'securityProofKind' => $this->securityProofKind($user),
'passkeys' => $user->passkeys()->latest()->get(['id', 'name', 'credential', 'last_used_at', 'created_at']),
'sessions' => DB::table('sessions')
->where('user_id', $user->id)
->orderByDesc('last_activity')
@ -434,12 +433,7 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</div>
@endif
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
@if ($twoFactorConfirmed)
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
@elseif ((string) $user->password !== '')
<flux:input wire:model="securityPassword" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
@endif
@include('partials.security-proof')
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Sie erhalten einen Bestätigungslink an die neue Adresse. Erst nach dem Klick wird die Änderung wirksam — Ihre bisherige Adresse bleibt bis dahin aktiv.') }}
</p>
@ -450,6 +444,61 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</article>
</div>
{{-- ============== PASSKEYS (Entscheidung 5) ============== --}}
<article class="panel"
x-data="{ busy: false, error: null }"
x-on:passkey-register.window="busy = true; error = null;
const outcome = await window.PkPasskeys.register($event.detail.name);
if (outcome.ok) { await $wire.passkeyRegistered(); } else { error = outcome.error; }
busy = false;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Passkeys') }}</span>
@if ($passkeys->isNotEmpty())
<span class="badge ok dot">{{ trans_choice(':count Passkey|:count Passkeys', $passkeys->count(), ['count' => $passkeys->count()]) }}</span>
@endif
</div>
<div class="p-5 space-y-5">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Mit einem Passkey melden Sie sich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort und ohne Code aus der App. Passkeys gelten zugleich als zweiter Faktor und funktionieren nur auf der echten pressekonto-Seite.') }}
</p>
@if ($passkeys->isNotEmpty())
<ul class="divide-y divide-[color:var(--color-bg-rule)] border border-[color:var(--color-bg-rule)] rounded-[5px] m-0 p-0 list-none">
@foreach ($passkeys as $passkey)
<li class="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between" wire:key="passkey-{{ $passkey->id }}">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] truncate">
{{ $passkey->name }}
@if ($passkey->authenticator)
<span class="font-normal text-[color:var(--color-ink-3)]">· {{ $passkey->authenticator }}</span>
@endif
</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Hinzugefügt am :date', ['date' => $passkey->created_at?->format('d.m.Y')]) }}
· {{ $passkey->last_used_at ? __('zuletzt genutzt :date', ['date' => $passkey->last_used_at->format('d.m.Y H:i')]) : __('noch nicht genutzt') }}
</div>
</div>
<flux:button size="sm" variant="subtle" icon="trash"
wire:click="deletePasskey({{ $passkey->id }})"
wire:confirm="{{ __('Diesen Passkey wirklich entfernen?') }}">
{{ __('Entfernen') }}
</flux:button>
</li>
@endforeach
</ul>
@endif
<form wire:submit="startPasskeyRegistration" class="space-y-4 max-w-[420px]">
<flux:input wire:model="passkeyName" :label="__('Name für den neuen Passkey')" placeholder="{{ __('z. B. MacBook, iPhone') }}" maxlength="60" required />
@include('partials.security-proof')
<flux:button type="submit" variant="primary" icon="finger-print" x-bind:disabled="busy">
{{ __('Passkey hinzufügen') }}
</flux:button>
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
</form>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
@ -465,9 +514,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
</p>
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
</flux:button>
<form wire:submit="enableTwoFactorAuthentication" class="mt-4 space-y-4 max-w-[420px]">
@include('partials.security-proof')
<flux:button type="submit" variant="primary">
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
</flux:button>
</form>
@elseif ($twoFactorPending)
{{-- Zustand 2: Secret erzeugt, Bestätigung mit App-Code steht aus.
Erst die Bestätigung aktiviert die Login-Abfrage. --}}

View file

@ -1,6 +1,7 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Services\Api\ApiAccessEligibilityService;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -9,6 +10,8 @@ use Livewire\Component;
new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Component
{
use ConfirmsIdentity;
public string $tokenName = '';
/** @var list<string> */
@ -50,6 +53,10 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
'selectedAbilities.*' => ['required', 'string', Rule::in(array_keys(self::ABILITIES))],
]);
// Ein Token wirkt dauerhaft und ohne Session — daher wie beim
// E-Mail-Wechsel nur mit frischem Nachweis (Sicherheitsaudit 07.10.2026, S-06).
$this->assertSecurityProof(auth()->user());
$token = auth()->user()->createToken(
$validated['tokenName'],
$validated['selectedAbilities'],
@ -83,6 +90,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
return [
'abilityOptions' => self::ABILITIES,
'securityProofKind' => $this->securityProofKind(auth()->user()),
'canCreateApiToken' => $denialReason === null && ! $isImpersonating,
'canRevokeApiTokens' => ! $isImpersonating,
'apiTokenDenialReason' => $isImpersonating
@ -191,6 +199,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
<flux:error name="selectedAbilities" class="mt-3" />
</div>
@if ($canCreateApiToken && $securityProofKind !== 'fresh')
<div class="max-w-[420px]">
@include('partials.security-proof')
</div>
@endif
<div class="flex justify-end pt-3 border-t border-[color:var(--color-bg-rule)]">
<flux:button type="submit" variant="primary" icon="key" :disabled="! $canCreateApiToken">
{{ __('Token erstellen') }}

View file

@ -1,7 +1,7 @@
<?php
use App\Services\Auth\PasswordChanger;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
@ -31,9 +31,7 @@ new #[Layout('components.layouts.app')] class extends Component
throw $e;
}
Auth::user()->update([
'password' => Hash::make($validated['password']),
]);
app(PasswordChanger::class)->change(Auth::user(), $validated['password'], session()->driver());
$this->reset('current_password', 'password', 'password_confirmation');

View file

@ -0,0 +1,41 @@
{{--
Felder für den erneuten Identitätsnachweis (App\Livewire\Concerns\ConfirmsIdentity,
Sicherheitsaudit 07.10.2026, S-06). Erwartet $securityProofKind. Wer einen
Passkey hat, kann stattdessen damit bestätigen (Entscheidung 5): Die
Zeremonie gegen /passkeys/confirm markiert die Session als bestätigt
(TrackSecondFactorEvents), danach zeigt die Komponente keinen Nachweis mehr.
--}}
@if ($securityProofKind === 'two_factor')
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
@elseif ($securityProofKind === 'password')
<flux:input wire:model="securityPassword" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
@elseif ($securityProofKind === 'email_code')
<div class="space-y-2">
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Zur Bestätigung senden wir einen Code an Ihre aktuelle Adresse :email.', ['email' => auth()->user()->email]) }}
</p>
<div class="flex items-end gap-2">
<div class="flex-1 max-w-[200px]">
<flux:input wire:model="securityEmailCode" :label="__('Code aus der E-Mail')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" />
</div>
<flux:button type="button" wire:click="requestSecurityEmailCode" variant="filled">
{{ $securityEmailCodeSent ? __('Code erneut senden') : __('Code senden') }}
</flux:button>
</div>
</div>
@endif
@if ($securityProofKind !== 'fresh' && auth()->user()->passkeys()->exists())
<div x-data="{ busy: false, error: null }" class="space-y-1">
<flux:button type="button" size="sm" variant="subtle" icon="finger-print" x-bind:disabled="busy"
x-on:click="busy = true; error = null;
const outcome = await window.PkPasskeys.confirm();
if (outcome.ok) { await $wire.$refresh(); } else { error = outcome.error; }
busy = false;">
{{ __('Stattdessen mit Passkey bestätigen') }}
</flux:button>
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
</div>
@endif