WS-6: E-Mail-Verifizierung, Auth-Flow-Fixes & Legacy-Rollen-Sicherheitsfix

E-Mail-Verifizierung (Entscheidung 15.06.):
- User implementiert MustVerifyEmail; Registrierung legt inaktives, rollenloses
  Konto an und leitet auf die Danke-/Notice-Seite; Registered-Event versendet die
  Verifizierungsmail. Bestätigter Link aktiviert das Konto + vergibt customer-Rolle
  (ActivateUserAfterVerification). Backfill-Migration setzt email_verified_at für
  alle Bestands-User (sonst würde die verified-Middleware ~59k aktive Legacy-User
  aussperren). Seeder-User verifiziert.

Auth-Flow-Korrekturen:
- Magic-Link-Consume: rollensicherer Redirect ohne intended() (Customer landete
  sonst per stale intended=/dashboard im 403-Admin-Bereich).
- Guest-Redirect (bootstrap/app.php) rollen-/verifizierungsbewusst statt fix
  /dashboard – schließt die 403-Sackgasse auf /login und /register.
- Logout auf der Notice-Seite via echtes POST-Formular statt Livewire-Action
  (behebt 419 beim Session-Invalidate).
- Magic-Link-Anforderung über eigenes Modal mit separater E-Mail-Eingabe.
- Unverifizierte Login-Versuche landen auf der Notice-Seite.

Sicherheitsfix Legacy-Rollen:
- UserImporter mappte Alt-Gruppe 2 (Self-Publisher) auf editor (= Admin-Zugriff).
  Mapping auf customer korrigiert; Daten-Migration stuft die 65.950 fälschlichen
  Legacy-Editoren auf customer herab. Echte admin/api-only bleiben unberührt.

Tests: Registration, EmailVerification, Authentication (Guest-Redirect),
MagicLinkLogin (Modal/Redirect/Regression), Legacy-Import (Gruppen-Mapping).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-16 08:16:41 +00:00
parent c804f3bfc3
commit 94cb209a9f
18 changed files with 608 additions and 86 deletions

View file

@ -24,6 +24,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
public bool $remember = false;
// Eigene Eingabe für das Magic-Link-Modal, getrennt vom Login-Formular.
public string $magicEmail = '';
/**
* Handle an incoming authentication request.
*/
@ -52,6 +55,14 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
RateLimiter::clear($this->throttleKey());
Session::regenerate();
// Unverifizierte Selbst-Registrierer (Konto angelegt, Mail noch nicht
// bestätigt) gehören auf die Notice-Seite, nicht in die Panel-Logik.
if ($authenticatedUser && ! $authenticatedUser->hasVerifiedEmail()) {
$this->redirect(route('verification.notice', absolute: false));
return;
}
// Rollen-basierter Default-Redirect:
// Admin/Editor → /dashboard, Customer → /admin/me.
// Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt
@ -68,9 +79,12 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
public function sendMagicLink(): void
{
$this->validateOnly('email');
$this->validate(
['magicEmail' => 'required|string|email'],
attributes: ['magicEmail' => __('E-Mail-Adresse')],
);
$user = User::query()->where('email', $this->email)->first();
$user = User::query()->where('email', $this->magicEmail)->first();
if ($user && $user->is_active) {
$generated = app(MagicLinkGenerator::class)->createLoginLink($user, request()->ip());
@ -85,6 +99,9 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
);
}
$this->reset('magicEmail');
$this->dispatch('magic-link-sent');
session()->flash('status', __('If an active account exists for this email, we sent a magic login link.'));
}
@ -118,7 +135,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
}
}; ?>
<div>
<div x-data="{ magicModal: false }" x-on:magic-link-sent.window="magicModal = false">
@if (session('status'))
<div class="field-status mb-4" role="status">
{{ session('status') }}
@ -195,17 +212,65 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
<button
type="button"
wire:click="sendMagicLink"
wire:loading.attr="disabled"
wire:target="sendMagicLink"
@click="magicModal = true; $nextTick(() => $refs.magicEmail?.focus())"
class="auth-btn-outline !mt-0"
>
<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" />
<path d="M2.5 4l5.5 5 5.5-5" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" />
</svg>
<span wire:loading.remove wire:target="sendMagicLink">Magic-Link senden</span>
<span wire:loading wire:target="sendMagicLink">Magic-Link wird gesendet …</span>
<span>Magic-Link senden</span>
</button>
</form>
{{-- Magic-Link-Modal: eigene E-Mail-Eingabe, unabhängig vom Login-Formular --}}
<div
x-show="magicModal"
x-cloak
style="display: none"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
x-on:keydown.escape.window="magicModal = false"
role="dialog"
aria-modal="true"
>
<div class="absolute inset-0 bg-black/50" @click="magicModal = false"></div>
<div
class="relative w-full max-w-sm rounded-lg border border-bg-rule bg-bg-card p-6 shadow-xl"
x-transition
>
<h3 class="text-[15px] font-semibold text-ink mb-1.5">Magic-Link anfordern</h3>
<p class="text-[12.5px] text-ink-2 leading-[1.55] mb-4">
Geben Sie Ihre E-Mail-Adresse ein. Wir senden Ihnen einen Anmeldelink,
mit dem Sie sich ohne Passwort anmelden können.
</p>
<form wire:submit="sendMagicLink">
<label class="field-label" for="magic-email">E-Mail-Adresse</label>
<input
id="magic-email"
x-ref="magicEmail"
type="email"
wire:model="magicEmail"
autocomplete="email"
class="field-input"
placeholder="redaktion@ihr-unternehmen.de"
@error('magicEmail') aria-invalid="true" @enderror
/>
@error('magicEmail')
<p class="field-error">{{ $message }}</p>
@enderror
<div class="flex gap-2.5 !mt-4">
<button type="button" class="auth-btn-outline !mt-0 flex-1" @click="magicModal = false">
Abbrechen
</button>
<button type="submit" class="auth-btn-primary !mt-0 flex-1" wire:loading.attr="disabled" wire:target="sendMagicLink">
<span wire:loading.remove wire:target="sendMagicLink">Link senden</span>
<span wire:loading wire:target="sendMagicLink">Senden …</span>
</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -36,21 +36,20 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
unset($validated['terms_accepted']);
$validated['password'] = Hash::make($validated['password']);
// Konto bleibt bis zur E-Mail-Verifizierung inaktiv und rollenlos
// (Entscheidung 15.06.). Rolle + Aktivierung erfolgen erst nach dem
// bestätigten Verifizierungslink (ActivateUserAfterVerification).
$validated['is_active'] = false;
// Das Registered-Event versendet über den verdrahteten
// SendEmailVerificationNotification-Listener die Bestätigungsmail.
event(new Registered($user = User::create($validated)));
Auth::login($user);
// Frisch registrierte User sind in der Regel Customer ohne Admin-
// Rollen → /admin/me. Ohne navigate:true, weil das Panel ein
// anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
$defaultRoute = $user->canAccessAdmin()
? route('dashboard', absolute: false)
: ($user->canAccessCustomer()
? route('me.dashboard', absolute: false)
: '/');
$this->redirectIntended($defaultRoute);
// Direkt zur Bestätigungs-/Notice-Seite. Ohne navigate:true, weil das
// Panel ein anderes Vite-Bundle nutzt als das Hub-Auth-Layout.
$this->redirect(route('verification.notice', absolute: false));
}
}; ?>
@ -171,5 +170,15 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Konto erstell
<span wire:loading.remove wire:target="register">Konto erstellen</span>
<span wire:loading wire:target="register">Konto wird angelegt …</span>
</button>
<div class="flex items-center gap-3 !mt-[22px] !mb-[14px]">
<span class="flex-1 h-px bg-bg-rule"></span>
<span class="text-[11px] font-semibold tracking-[0.18em] uppercase text-ink-3">Bereits Konto?</span>
<span class="flex-1 h-px bg-bg-rule"></span>
</div>
<a href="{{ route('login') }}" class="auth-btn-outline !mt-0" wire:navigate>
Stattdessen anmelden
</a>
</form>
</div>

View file

@ -1,12 +1,11 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'E-Mail-Adresse bestätigen', 'eyebrow' => 'Konto-Verifizierung', 'showFromBanner' => false])] class extends Component {
new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Danke für Ihre Registrierung', 'eyebrow' => 'Nur noch ein Schritt', 'showFromBanner' => false])] class extends Component {
/**
* Send an email verification notification to the user.
*/
@ -22,23 +21,14 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'E-Mail-Adress
Session::flash('status', 'verification-link-sent');
}
/**
* Log the current user out of the application.
*/
public function logout(Logout $logout): void
{
$logout();
$this->redirect('/', navigate: true);
}
}; ?>
<div>
<p class="text-[13.5px] text-ink-2 leading-[1.65] !-mt-4 mb-6">
Wir haben Ihnen einen Bestätigungslink an
Ihr Konto wurde angelegt. Wir haben Ihnen einen Bestätigungslink an
<strong class="text-ink font-semibold">{{ Auth::user()?->email }}</strong>
gesendet. Bitte öffnen Sie die Mail und klicken Sie auf den Link, um Ihre E-Mail-Adresse zu bestätigen.
gesendet. Bitte öffnen Sie die Mail und klicken Sie auf den Link – danach
wird Ihr Konto freigeschaltet und Sie landen direkt in Ihrem Dashboard.
</p>
@if (session('status') === 'verification-link-sent')
@ -59,12 +49,11 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'E-Mail-Adress
<span wire:loading wire:target="sendVerification">Mail wird gesendet …</span>
</button>
<button
type="button"
wire:click="logout"
class="w-full text-center text-[12.5px] text-ink-3 hover:text-hub transition-colors py-2"
>
Abmelden
</button>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="auth-btn-outline">
Abmelden
</button>
</form>
</div>
</div>