QA-Befunde A4: 2FA-Bestätigungsschritt + sicherer E-Mail-Wechsel

- 2FA: Einrichtung verlangt jetzt die Bestätigung mit einem App-Code
  (Fortify confirm-Semantik); vorher blieb two_factor_confirmed_at
  leer und der Login fragte nie nach der Challenge.
- Recovery-Codes nur noch einmalig sichtbar (nach Bestätigung bzw.
  Neu-Erzeugung), nicht mehr bei jedem Seitenaufruf.
- E-Mail-Änderung als Pending-Flow: Bestätigungslink geht an die NEUE
  Adresse, die alte bleibt bis zum Klick aktiv — kein Aussperren durch
  Tippfehler und keine Registrierungs-Notice mehr nach dem Ändern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 10:53:24 +00:00
parent b7bbf6df4e
commit 15387d0a3f
8 changed files with 505 additions and 45 deletions

View file

@ -1,13 +1,17 @@
<?php
use App\Mail\EmailChangeConfirmation;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
@ -26,13 +30,17 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
public string $email = '';
public bool $confirmedTwoFactor = false;
public string $twoFactorCode = '';
/**
* Recovery-Codes werden nur direkt nach Bestätigung bzw. Neu-Erzeugung
* angezeigt (einmalige Sichtbarkeit), nicht bei jedem Seitenaufruf.
*/
public bool $showRecoveryCodes = false;
public function mount(): void
{
$user = auth()->user();
$this->email = (string) $user->email;
$this->confirmedTwoFactor = ! is_null($user->two_factor_confirmed_at ?? null);
$this->email = (string) auth()->user()->email;
}
public function updatePassword(): void
@ -57,6 +65,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
session()->flash('security-status', __('Passwort aktualisiert.'));
}
/**
* Startet eine E-Mail-Änderung: Die neue Adresse wird nur als
* `pending_email` vorgemerkt und erhält einen signierten Bestätigungslink.
* Die alte Adresse bleibt bis zur Bestätigung aktiv — so sperrt sich
* niemand mit einer vertippten Adresse aus dem Konto aus.
*/
public function updateEmail(): void
{
$validated = $this->validate([
@ -71,29 +85,73 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
/** @var User $user */
$user = auth()->user();
$user->forceFill([
'email' => $validated['email'],
'email_verified_at' => null,
])->save();
if (Features::enabled(Features::emailVerification())) {
$user->sendEmailVerificationNotification();
if (strcasecmp($validated['email'], $user->email) === 0) {
throw ValidationException::withMessages([
'email' => __('Das ist bereits Ihre aktuelle E-Mail-Adresse.'),
]);
}
session()->flash('security-status', __('E-Mail-Adresse aktualisiert. Bitte erneut bestätigen, falls eine Verifizierung verschickt wurde.'));
$user->forceFill(['pending_email' => $validated['email']])->save();
$expiresAt = now()->addHour();
$confirmUrl = URL::temporarySignedRoute('email-change.confirm', $expiresAt, [
'user' => $user->id,
'hash' => sha1($validated['email']),
]);
Mail::to($validated['email'])->send(new EmailChangeConfirmation(
$user,
$validated['email'],
$confirmUrl,
$expiresAt->format('H:i')
));
session()->flash('security-status', __('Bestätigungslink an :email gesendet. Die Änderung wird erst nach Klick auf den Link wirksam — bis dahin bleibt Ihre bisherige Adresse aktiv.', ['email' => $validated['email']]));
}
public function cancelPendingEmailChange(): void
{
auth()->user()->forceFill(['pending_email' => null])->save();
session()->flash('security-status', __('Die ausstehende E-Mail-Änderung wurde verworfen.'));
}
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
{
$enable(auth()->user());
$this->showRecoveryCodes = false;
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung aktiviert. Scannen Sie den QR-Code mit Ihrer Authenticator-App.'));
session()->flash('security-status', __('Scannen Sie den QR-Code mit Ihrer Authenticator-App und bestätigen Sie die Einrichtung mit einem aktuellen Code.'));
}
/**
* Bestätigt die 2FA-Einrichtung mit einem TOTP-Code. Erst danach ist
* `two_factor_confirmed_at` gesetzt und der Login fordert die Challenge an.
*/
public function confirmTwoFactorAuthentication(ConfirmTwoFactorAuthentication $confirm): void
{
$this->validate(['twoFactorCode' => ['required', 'string']]);
try {
$confirm(auth()->user(), $this->twoFactorCode);
} catch (ValidationException) {
throw ValidationException::withMessages([
'twoFactorCode' => __('Der eingegebene Code ist ungültig. Bitte versuchen Sie es mit einem aktuellen Code erneut.'),
]);
}
$this->reset('twoFactorCode');
$this->showRecoveryCodes = true;
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung ist jetzt aktiv. Speichern Sie Ihre Wiederherstellungs-Codes — sie werden nur einmal angezeigt.'));
}
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void
{
$disable(auth()->user());
$this->confirmedTwoFactor = false;
$this->reset('twoFactorCode');
$this->showRecoveryCodes = false;
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
}
@ -101,8 +159,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
{
$generate(auth()->user());
$this->showRecoveryCodes = true;
session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt.'));
session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt. Die alten Codes sind ab sofort ungültig.'));
}
public function with(): array
@ -111,13 +170,20 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
$user = auth()->user();
$user->refresh();
$twoFactorPending = ! is_null($user->two_factor_secret ?? null) && is_null($user->two_factor_confirmed_at ?? null);
$twoFactorConfirmed = ! is_null($user->two_factor_confirmed_at ?? null);
$qrUrl = null;
$recoveryCodes = [];
if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) {
try {
$qrUrl = $user->twoFactorQrCodeSvg();
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?: [];
// QR-Code nur während der Einrichtung; Recovery-Codes nur
// direkt nach Bestätigung/Neu-Erzeugung (einmalige Anzeige).
$qrUrl = $twoFactorPending ? $user->twoFactorQrCodeSvg() : null;
$recoveryCodes = ($twoFactorConfirmed && $this->showRecoveryCodes)
? (json_decode(decrypt($user->two_factor_recovery_codes), true) ?: [])
: [];
} catch (\Throwable) {
$qrUrl = null;
$recoveryCodes = [];
@ -126,7 +192,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
return [
'user' => $user,
'twoFactorEnabled' => ! is_null($user->two_factor_secret ?? null),
'twoFactorPending' => $twoFactorPending,
'twoFactorConfirmed' => $twoFactorConfirmed,
'twoFactorQrSvg' => $qrUrl,
'recoveryCodes' => $recoveryCodes,
'sessions' => DB::table('sessions')
@ -163,6 +230,14 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</div>
@endif
@if (session('security-error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-warn-soft,#fef2f2)] border-[color:var(--color-warn,#dc2626)]/30 text-[color:var(--color-warn,#b91c1c)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0" />
{{ session('security-error') }}
</div>
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article class="panel p-5 space-y-2">
@ -184,11 +259,19 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
{{ __('Zwei-Faktor') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">
{{ $twoFactorEnabled ? __('Aktiv') : __('Nicht aktiv') }}
@if ($twoFactorConfirmed)
{{ __('Aktiv') }}
@elseif ($twoFactorPending)
{{ __('Einrichtung ausstehend') }}
@else
{{ __('Nicht aktiv') }}
@endif
</div>
<div>
@if ($twoFactorEnabled)
@if ($twoFactorConfirmed)
<span class="badge ok">{{ __('Zusatzschutz aktiv') }}</span>
@elseif ($twoFactorPending)
<span class="badge warn">{{ __('Bestätigung erforderlich') }}</span>
@else
<span class="badge warn">{{ __('Empfohlen') }}</span>
@endif
@ -238,12 +321,25 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
<span class="section-eyebrow">{{ __('E-Mail-Adresse ändern') }}</span>
</div>
<form wire:submit="updateEmail" class="p-5 space-y-4">
@if ($user->pending_email)
<div class="px-3 py-2.5 rounded-[5px] border text-[12px] space-y-1.5
bg-[color:var(--color-bg-elev)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<div class="flex items-center gap-2">
<flux:icon.clock class="size-[14px] flex-shrink-0" />
<span>{{ __('Ausstehende Änderung zu:') }} <strong class="text-[color:var(--color-ink)]">{{ $user->pending_email }}</strong></span>
</div>
<p class="m-0">{{ __('Der Bestätigungslink wurde an die neue Adresse gesendet. Bis zur Bestätigung bleibt die bisherige Adresse aktiv.') }}</p>
<flux:button size="sm" variant="subtle" wire:click="cancelPendingEmailChange">
{{ __('Änderung verwerfen') }}
</flux:button>
</div>
@endif
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Nach der Änderung kann eine erneute Bestätigung der E-Mail-Adresse erforderlich sein.') }}
{{ __('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>
<div class="flex justify-end pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:button type="submit" variant="primary">{{ __('E-Mail speichern') }}</flux:button>
<flux:button type="submit" variant="primary">{{ __('Bestätigungslink senden') }}</flux:button>
</div>
</form>
</article>
@ -252,41 +348,68 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
@if ($twoFactorEnabled)
@if ($twoFactorConfirmed)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@elseif ($twoFactorPending)
<span class="badge warn dot">{{ __('Bestätigung erforderlich') }}</span>
@endif
</div>
<div class="p-5">
@if (! $twoFactorEnabled)
@if (! $twoFactorPending && ! $twoFactorConfirmed)
{{-- Zustand 1: nicht eingerichtet --}}
<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>
@elseif ($twoFactorPending)
{{-- Zustand 2: Secret erzeugt, Bestätigung mit App-Code steht aus.
Erst die Bestätigung aktiviert die Login-Abfrage. --}}
<div class="flex flex-col gap-5 lg:flex-row lg:items-start">
{{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi —
QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}}
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-white p-4 flex-shrink-0">
{!! $twoFactorQrSvg !!}
</div>
<div class="space-y-4 min-w-0 flex-1">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator) und geben Sie anschließend den angezeigten 6-stelligen Code ein. Erst danach ist die Zwei-Faktor-Authentifizierung aktiv.') }}
</p>
<form wire:submit="confirmTwoFactorAuthentication" class="space-y-3 max-w-[280px]">
<flux:input wire:model="twoFactorCode" :label="__('Code aus der App')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
<div class="flex flex-wrap gap-2">
<flux:button type="submit" variant="primary">
{{ __('Einrichtung bestätigen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="subtle">
{{ __('Abbrechen') }}
</flux:button>
</div>
</form>
</div>
</div>
@else
@if ($twoFactorQrSvg)
<div class="flex flex-col gap-5 lg:flex-row lg:items-start">
{{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi —
QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}}
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-white p-4 flex-shrink-0">
{!! $twoFactorQrSvg !!}
</div>
<div class="space-y-3 min-w-0">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator).') }}
</p>
@if (! empty($recoveryCodes))
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Wiederherstellungs-Codes') }}
</div>
<ul class="grid grid-cols-2 gap-2 text-[11.5px] font-mono m-0 p-0 list-none">
@foreach ($recoveryCodes as $code)
<li class="rounded-[4px] bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)] px-2 py-1 text-[color:var(--color-ink)]">{{ $code }}</li>
@endforeach
</ul>
@endif
{{-- Zustand 3: aktiv. Recovery-Codes nur einmalig direkt nach
Bestätigung bzw. Neu-Erzeugung sichtbar. --}}
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Die Zwei-Faktor-Authentifizierung ist aktiv. Beim Login wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.') }}
</p>
@if (! empty($recoveryCodes))
<div class="mt-4 space-y-3">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Wiederherstellungs-Codes') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Speichern Sie diese Codes jetzt an einem sicheren Ort — sie werden nur dieses eine Mal angezeigt. Jeder Code ersetzt einmalig Ihre Authenticator-App, falls Sie den Zugriff darauf verlieren.') }}
</p>
<ul class="grid grid-cols-2 gap-2 text-[11.5px] font-mono m-0 p-0 list-none max-w-[420px]">
@foreach ($recoveryCodes as $code)
<li class="rounded-[4px] bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)] px-2 py-1 text-[color:var(--color-ink)]">{{ $code }}</li>
@endforeach
</ul>
</div>
@endif