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:
parent
b7bbf6df4e
commit
15387d0a3f
8 changed files with 505 additions and 45 deletions
45
app/Http/Controllers/Auth/ConfirmEmailChangeController.php
Normal file
45
app/Http/Controllers/Auth/ConfirmEmailChangeController.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ConfirmEmailChangeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Übernimmt eine ausstehende E-Mail-Änderung, nachdem der Kunde den
|
||||||
|
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
|
||||||
|
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request, User $user, string $hash): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->is($user), 403);
|
||||||
|
|
||||||
|
if (! $user->pending_email) {
|
||||||
|
return redirect()->route('me.security')
|
||||||
|
->with('security-status', __('Es liegt keine ausstehende E-Mail-Änderung vor. Möglicherweise wurde der Link bereits verwendet.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
abort_unless(hash_equals(sha1($user->pending_email), $hash), 403);
|
||||||
|
|
||||||
|
// Zwischenzeitlich vergeben? Dann Änderung verwerfen statt Konflikt anlegen.
|
||||||
|
if (User::query()->where('email', $user->pending_email)->whereKeyNot($user->id)->exists()) {
|
||||||
|
$user->forceFill(['pending_email' => null])->save();
|
||||||
|
|
||||||
|
return redirect()->route('me.security')
|
||||||
|
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'email' => $user->pending_email,
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'pending_email' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return redirect()->route('me.security')
|
||||||
|
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Mail/EmailChangeConfirmation.php
Normal file
41
app/Mail/EmailChangeConfirmation.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class EmailChangeConfirmation extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public string $newEmail,
|
||||||
|
public string $confirmUrl,
|
||||||
|
public string $expiresAt
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Neue E-Mail-Adresse für Ihr pressekonto bestätigen'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.auth.email-change-confirmation'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
// Ausstehende E-Mail-Änderung: wird erst nach Klick auf den
|
||||||
|
// Bestätigungslink (an die NEUE Adresse) in `email` übernommen.
|
||||||
|
$table->string('pending_email', 190)->nullable()->after('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('pending_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<x-emails.layout title="Neue E-Mail-Adresse bestätigen" preheader="Bestätigen Sie die neue E-Mail-Adresse für Ihr pressekonto.">
|
||||||
|
<p>Hallo {{ $user->name }},</p>
|
||||||
|
|
||||||
|
<p>für Ihr Kundenkonto wurde eine Änderung der E-Mail-Adresse auf <strong>{{ $newEmail }}</strong> angefordert. Die Änderung wird erst wirksam, wenn Sie sie über den folgenden Button bestätigen.</p>
|
||||||
|
|
||||||
|
<x-emails.button :url="$confirmUrl">Neue E-Mail-Adresse bestätigen</x-emails.button>
|
||||||
|
|
||||||
|
<p>Der Link ist gültig bis {{ $expiresAt }} Uhr. Bis zur Bestätigung bleibt Ihre bisherige Adresse aktiv.</p>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<p>Falls Sie diese Änderung nicht angefordert haben, ignorieren Sie diese E-Mail bitte – Ihr Konto bleibt unverändert.</p>
|
||||||
|
</div>
|
||||||
|
</x-emails.layout>
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Mail\EmailChangeConfirmation;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||||
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||||
|
|
@ -26,13 +30,17 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
|
|
||||||
public string $email = '';
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$this->email = (string) auth()->user()->email;
|
||||||
$this->email = (string) $user->email;
|
|
||||||
$this->confirmedTwoFactor = ! is_null($user->two_factor_confirmed_at ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePassword(): void
|
public function updatePassword(): void
|
||||||
|
|
@ -57,6 +65,12 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
session()->flash('security-status', __('Passwort aktualisiert.'));
|
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
|
public function updateEmail(): void
|
||||||
{
|
{
|
||||||
$validated = $this->validate([
|
$validated = $this->validate([
|
||||||
|
|
@ -71,29 +85,73 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$user->forceFill([
|
if (strcasecmp($validated['email'], $user->email) === 0) {
|
||||||
'email' => $validated['email'],
|
throw ValidationException::withMessages([
|
||||||
'email_verified_at' => null,
|
'email' => __('Das ist bereits Ihre aktuelle E-Mail-Adresse.'),
|
||||||
])->save();
|
]);
|
||||||
|
|
||||||
if (Features::enabled(Features::emailVerification())) {
|
|
||||||
$user->sendEmailVerificationNotification();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
|
||||||
{
|
{
|
||||||
$enable(auth()->user());
|
$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
|
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void
|
||||||
{
|
{
|
||||||
$disable(auth()->user());
|
$disable(auth()->user());
|
||||||
$this->confirmedTwoFactor = false;
|
$this->reset('twoFactorCode');
|
||||||
|
$this->showRecoveryCodes = false;
|
||||||
|
|
||||||
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
|
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
|
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
|
||||||
{
|
{
|
||||||
$generate(auth()->user());
|
$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
|
public function with(): array
|
||||||
|
|
@ -111,13 +170,20 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$user->refresh();
|
$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;
|
$qrUrl = null;
|
||||||
$recoveryCodes = [];
|
$recoveryCodes = [];
|
||||||
|
|
||||||
if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) {
|
if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) {
|
||||||
try {
|
try {
|
||||||
$qrUrl = $user->twoFactorQrCodeSvg();
|
// QR-Code nur während der Einrichtung; Recovery-Codes nur
|
||||||
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?: [];
|
// 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) {
|
} catch (\Throwable) {
|
||||||
$qrUrl = null;
|
$qrUrl = null;
|
||||||
$recoveryCodes = [];
|
$recoveryCodes = [];
|
||||||
|
|
@ -126,7 +192,8 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'user' => $user,
|
'user' => $user,
|
||||||
'twoFactorEnabled' => ! is_null($user->two_factor_secret ?? null),
|
'twoFactorPending' => $twoFactorPending,
|
||||||
|
'twoFactorConfirmed' => $twoFactorConfirmed,
|
||||||
'twoFactorQrSvg' => $qrUrl,
|
'twoFactorQrSvg' => $qrUrl,
|
||||||
'recoveryCodes' => $recoveryCodes,
|
'recoveryCodes' => $recoveryCodes,
|
||||||
'sessions' => DB::table('sessions')
|
'sessions' => DB::table('sessions')
|
||||||
|
|
@ -163,6 +230,14 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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 ============== --}}
|
{{-- ============== KPI-Reihe ============== --}}
|
||||||
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<article class="panel p-5 space-y-2">
|
<article class="panel p-5 space-y-2">
|
||||||
|
|
@ -184,11 +259,19 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
{{ __('Zwei-Faktor') }}
|
{{ __('Zwei-Faktor') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
@if ($twoFactorEnabled)
|
@if ($twoFactorConfirmed)
|
||||||
<span class="badge ok">{{ __('Zusatzschutz aktiv') }}</span>
|
<span class="badge ok">{{ __('Zusatzschutz aktiv') }}</span>
|
||||||
|
@elseif ($twoFactorPending)
|
||||||
|
<span class="badge warn">{{ __('Bestätigung erforderlich') }}</span>
|
||||||
@else
|
@else
|
||||||
<span class="badge warn">{{ __('Empfohlen') }}</span>
|
<span class="badge warn">{{ __('Empfohlen') }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -238,12 +321,25 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
<span class="section-eyebrow">{{ __('E-Mail-Adresse ändern') }}</span>
|
<span class="section-eyebrow">{{ __('E-Mail-Adresse ändern') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<form wire:submit="updateEmail" class="p-5 space-y-4">
|
<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 />
|
<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">
|
<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>
|
</p>
|
||||||
<div class="flex justify-end pt-2 border-t border-[color:var(--color-bg-rule)]">
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
@ -252,41 +348,68 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
|
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
|
||||||
@if ($twoFactorEnabled)
|
@if ($twoFactorConfirmed)
|
||||||
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
||||||
|
@elseif ($twoFactorPending)
|
||||||
|
<span class="badge warn dot">{{ __('Bestätigung erforderlich') }}</span>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<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">
|
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||||
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
|
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
|
||||||
</p>
|
</p>
|
||||||
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
|
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
|
||||||
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
|
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
|
||||||
</flux:button>
|
</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
|
@else
|
||||||
@if ($twoFactorQrSvg)
|
{{-- Zustand 3: aktiv. Recovery-Codes nur einmalig direkt nach
|
||||||
<div class="flex flex-col gap-5 lg:flex-row lg:items-start">
|
Bestätigung bzw. Neu-Erzeugung sichtbar. --}}
|
||||||
{{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi —
|
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
||||||
QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}}
|
{{ __('Die Zwei-Faktor-Authentifizierung ist aktiv. Beim Login wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.') }}
|
||||||
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-white p-4 flex-shrink-0">
|
</p>
|
||||||
{!! $twoFactorQrSvg !!}
|
|
||||||
</div>
|
@if (! empty($recoveryCodes))
|
||||||
<div class="space-y-3 min-w-0">
|
<div class="mt-4 space-y-3">
|
||||||
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
|
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
|
||||||
{{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator).') }}
|
{{ __('Wiederherstellungs-Codes') }}
|
||||||
</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
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Auth\ConfirmEmailChangeController;
|
||||||
use App\Http\Controllers\Auth\GoogleController;
|
use App\Http\Controllers\Auth\GoogleController;
|
||||||
use App\Http\Controllers\Auth\MagicLinkConsumeController;
|
use App\Http\Controllers\Auth\MagicLinkConsumeController;
|
||||||
use App\Http\Controllers\Auth\VerifyEmailController;
|
use App\Http\Controllers\Auth\VerifyEmailController;
|
||||||
|
|
@ -69,6 +70,12 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
|
||||||
})->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1'])
|
})->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1'])
|
||||||
->name('verification.send');
|
->name('verification.send');
|
||||||
|
|
||||||
|
// Signierter Bestätigungslink für eine E-Mail-Änderung (geht an die NEUE
|
||||||
|
// Adresse; erst der Klick übernimmt sie ins Konto).
|
||||||
|
Route::get('/email-aendern/{user}/{hash}', ConfirmEmailChangeController::class)
|
||||||
|
->middleware(['auth:'.config('fortify.guard'), 'signed', 'throttle:6,1'])
|
||||||
|
->name('email-change.confirm');
|
||||||
|
|
||||||
// 2FA-Challenge-Seite (Volt-Frontend für Fortifys two-factor-challenge);
|
// 2FA-Challenge-Seite (Volt-Frontend für Fortifys two-factor-challenge);
|
||||||
// der eigentliche POST geht an Fortifys Controller.
|
// der eigentliche POST geht an Fortifys Controller.
|
||||||
Volt::route('/two-factor-challenge', 'auth.two-factor-challenge')
|
Volt::route('/two-factor-challenge', 'auth.two-factor-challenge')
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,29 @@ test('a user with two-factor enabled is handed to the challenge, not logged in',
|
||||||
expect(session('login.id'))->toBe($user->id);
|
expect(session('login.id'))->toBe($user->id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an unconfirmed two-factor setup does not trigger the challenge', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$this->seed(RolesAndPermissionsSeeder::class);
|
||||||
|
$user = User::factory()->create(['is_active' => true]);
|
||||||
|
$user->assignRole('customer');
|
||||||
|
|
||||||
|
// Secret erzeugt, aber Einrichtung nie mit App-Code bestätigt
|
||||||
|
// (two_factor_confirmed_at bleibt null → Fortify-'confirm'-Semantik).
|
||||||
|
$user->forceFill([
|
||||||
|
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
Volt::test('auth.login')
|
||||||
|
->set('email', $user->email)
|
||||||
|
->set('password', 'password')
|
||||||
|
->call('login')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(route('me.dashboard', absolute: false));
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
});
|
||||||
|
|
||||||
test('the challenge page redirects to login without a pending challenge', function () {
|
test('the challenge page redirects to login without a pending challenge', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
$this->get(route('two-factor.challenge'))->assertRedirect(route('login'));
|
$this->get(route('two-factor.challenge'))->assertRedirect(route('login'));
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
use App\Enums\Portal;
|
use App\Enums\Portal;
|
||||||
use App\Enums\PressReleaseStatus;
|
use App\Enums\PressReleaseStatus;
|
||||||
|
use App\Mail\EmailChangeConfirmation;
|
||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Company;
|
use App\Models\Company;
|
||||||
use App\Models\Contact;
|
use App\Models\Contact;
|
||||||
|
|
@ -11,7 +12,10 @@ use App\Models\PressReleaseStatusLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
use Livewire\Volt\Volt as LivewireVolt;
|
use Livewire\Volt\Volt as LivewireVolt;
|
||||||
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
test('customer can update own profile fields', function () {
|
test('customer can update own profile fields', function () {
|
||||||
|
|
@ -185,6 +189,180 @@ test('customer can change own password through security page', function () {
|
||||||
expect(Hash::check('new-strong-password', $customer->password))->toBeTrue();
|
expect(Hash::check('new-strong-password', $customer->password))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('two factor setup only becomes active after confirming an app code', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
$component = LivewireVolt::test('customer.security')
|
||||||
|
->call('enableTwoFactorAuthentication');
|
||||||
|
|
||||||
|
$customer->refresh();
|
||||||
|
|
||||||
|
expect($customer->two_factor_secret)->not->toBeNull();
|
||||||
|
expect($customer->two_factor_confirmed_at)->toBeNull();
|
||||||
|
expect($customer->hasEnabledTwoFactorAuthentication())->toBeFalse();
|
||||||
|
|
||||||
|
$otp = app(Google2FA::class)->getCurrentOtp(decrypt($customer->two_factor_secret));
|
||||||
|
|
||||||
|
$component
|
||||||
|
->set('twoFactorCode', $otp)
|
||||||
|
->call('confirmTwoFactorAuthentication')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSee('Wiederherstellungs-Codes');
|
||||||
|
|
||||||
|
$customer->refresh();
|
||||||
|
|
||||||
|
expect($customer->two_factor_confirmed_at)->not->toBeNull();
|
||||||
|
expect($customer->hasEnabledTwoFactorAuthentication())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an invalid two factor confirmation code is rejected', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.security')
|
||||||
|
->call('enableTwoFactorAuthentication')
|
||||||
|
->set('twoFactorCode', '000000')
|
||||||
|
->call('confirmTwoFactorAuthentication')
|
||||||
|
->assertHasErrors(['twoFactorCode']);
|
||||||
|
|
||||||
|
expect($customer->refresh()->two_factor_confirmed_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovery codes stay hidden on a later visit to the security page', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true]);
|
||||||
|
$customer->forceFill([
|
||||||
|
'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()),
|
||||||
|
'two_factor_recovery_codes' => encrypt(json_encode(['geheim-code-eins', 'geheim-code-zwei'])),
|
||||||
|
'two_factor_confirmed_at' => now(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.security')
|
||||||
|
->assertDontSee('geheim-code-eins')
|
||||||
|
->call('regenerateRecoveryCodes')
|
||||||
|
->assertSee('Wiederherstellungs-Codes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the email keeps the old address active and mails a confirm link to the new one', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$customer = User::factory()->create([
|
||||||
|
'is_active' => true,
|
||||||
|
'email' => 'alt@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.security')
|
||||||
|
->set('email', 'neu@example.com')
|
||||||
|
->call('updateEmail')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$customer->refresh();
|
||||||
|
|
||||||
|
expect($customer->email)->toBe('alt@example.com');
|
||||||
|
expect($customer->pending_email)->toBe('neu@example.com');
|
||||||
|
expect($customer->email_verified_at)->not->toBeNull();
|
||||||
|
|
||||||
|
Mail::assertSent(EmailChangeConfirmation::class, function ($mail) {
|
||||||
|
return $mail->hasTo('neu@example.com') && str_contains($mail->confirmUrl, 'signature=');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the signed link applies the pending email change', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create([
|
||||||
|
'is_active' => true,
|
||||||
|
'email' => 'alt@example.com',
|
||||||
|
'pending_email' => 'neu@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
|
||||||
|
'user' => $customer->id,
|
||||||
|
'hash' => sha1('neu@example.com'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($url)
|
||||||
|
->assertRedirect(route('me.security'));
|
||||||
|
|
||||||
|
$customer->refresh();
|
||||||
|
|
||||||
|
expect($customer->email)->toBe('neu@example.com');
|
||||||
|
expect($customer->pending_email)->toBeNull();
|
||||||
|
expect($customer->email_verified_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the email change link of another user is rejected', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$owner = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']);
|
||||||
|
$intruder = User::factory()->create(['is_active' => true]);
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
|
||||||
|
'user' => $owner->id,
|
||||||
|
'hash' => sha1('neu@example.com'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($intruder)->get($url)->assertForbidden();
|
||||||
|
|
||||||
|
expect($owner->refresh()->email)->not->toBe('neu@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unsigned email change link is rejected', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get(route('email-change.confirm', ['user' => $customer->id, 'hash' => sha1('neu@example.com')]))
|
||||||
|
->assertForbidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pending email change can be discarded', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create(['is_active' => true, 'pending_email' => 'neu@example.com']);
|
||||||
|
|
||||||
|
$this->actingAs($customer);
|
||||||
|
|
||||||
|
LivewireVolt::test('customer.security')
|
||||||
|
->call('cancelPendingEmailChange');
|
||||||
|
|
||||||
|
expect($customer->refresh()->pending_email)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pending email that got taken by another account meanwhile is discarded on confirm', function () {
|
||||||
|
/** @var TestCase $this */
|
||||||
|
$customer = User::factory()->create([
|
||||||
|
'is_active' => true,
|
||||||
|
'email' => 'alt@example.com',
|
||||||
|
'pending_email' => 'neu@example.com',
|
||||||
|
]);
|
||||||
|
User::factory()->create(['email' => 'neu@example.com']);
|
||||||
|
|
||||||
|
$url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), [
|
||||||
|
'user' => $customer->id,
|
||||||
|
'hash' => sha1('neu@example.com'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($customer)
|
||||||
|
->get($url)
|
||||||
|
->assertRedirect(route('me.security'))
|
||||||
|
->assertSessionHas('security-error');
|
||||||
|
|
||||||
|
$customer->refresh();
|
||||||
|
|
||||||
|
expect($customer->email)->toBe('alt@example.com');
|
||||||
|
expect($customer->pending_email)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
test('press release policy denies access to other users releases', function () {
|
test('press release policy denies access to other users releases', function () {
|
||||||
/** @var TestCase $this */
|
/** @var TestCase $this */
|
||||||
$owner = User::factory()->create(['is_active' => true]);
|
$owner = User::factory()->create(['is_active' => true]);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue