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
48
resources/js/passkeys.js
Normal file
48
resources/js/passkeys.js
Normal 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 })),
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
23
resources/views/emails/auth/security-change-notice.blade.php
Normal file
23
resources/views/emails/auth/security-change-notice.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
147
resources/views/livewire/auth/strong-confirmation.blade.php
Normal file
147
resources/views/livewire/auth/strong-confirmation.blade.php
Normal 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>
|
||||
|
|
@ -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. --}}
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
41
resources/views/partials/security-proof.blade.php
Normal file
41
resources/views/partials/security-proof.blade.php
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue