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

@ -43,10 +43,17 @@ class MagicLinkConsumeController extends Controller
Auth::guard('web')->login($magicLink->user);
$request->session()->regenerate();
$home = $magicLink->user->canAccessAdmin()
? route('dashboard', absolute: false)
: route('me.dashboard', absolute: false);
// Rollensicherer Redirect ohne intended(): eine als Gast besuchte
// Admin-URL (z. B. /dashboard) darf einen Customer nach dem
// Magic-Link-Login nicht in den 403-Admin-Bereich schicken.
$user = $magicLink->user;
return redirect()->intended($home);
$home = $user->canAccessAdmin()
? route('dashboard', absolute: false)
: ($user->canAccessCustomer()
? route('me.dashboard', absolute: false)
: '/');
return redirect($home);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Listeners;
use App\Models\User;
use App\Services\Auth\UserRolePermissionSyncService;
use Illuminate\Auth\Events\Verified;
/**
* Aktiviert einen frisch verifizierten Selbst-Registrierer.
*
* Entscheidung 15.06.: Erst nach der E-Mail-Verifizierung erhält ein Konto die
* customer-Rolle und wird aktiv geschaltet. Magic-Link- und Google-Logins
* verifizieren über ihren eigenen Kanal und laufen nicht über dieses Event.
*/
class ActivateUserAfterVerification
{
public function __construct(private UserRolePermissionSyncService $roleSync) {}
public function handle(Verified $event): void
{
$user = $event->user;
if (! $user instanceof User) {
return;
}
if (! $user->is_active) {
$user->forceFill(['is_active' => true])->save();
}
// Bestehende Rollen (z. B. Admin, der seine Mail erneut bestätigt)
// bleiben unangetastet; nur rollenlose Selbst-Registrierer werden
// zum customer.
if ($user->roles()->doesntExist()) {
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
}
}
}

View file

@ -2,11 +2,11 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\Portal;
use App\Enums\RegistrationType;
use App\Enums\UserPaymentOptionStatus;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -20,7 +20,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<UserFactory> */
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable;

View file

@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient;
use App\Helpers\ThemeHelper;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\LogSlowAdminRequests;
use App\Listeners\ActivateUserAfterVerification;
use App\Models\AdminPreset;
use App\Models\Category;
use App\Models\CategoryTranslation;
@ -18,8 +19,12 @@ use App\Observers\AdminPerformanceCacheObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use App\Services\Newsletter\NullNewsletterSyncClient;
use App\Services\PressRelease\PressReleaseService;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
@ -52,6 +57,10 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme('https');
}
// Registrierung → Verifizierungsmail; bestätigter Klick → Aktivierung.
Event::listen(Registered::class, SendEmailVerificationNotification::class);
Event::listen(Verified::class, ActivateUserAfterVerification::class);
// Stripe Tax berechnet die USt im Checkout automatisch nach den
// gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer,
// EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich

View file

@ -13,10 +13,19 @@ use Illuminate\Support\Facades\DB;
class UserImporter
{
/** Legacy-Gruppen → Spatie-Rollen */
/**
* Legacy-Gruppen Spatie-Rollen.
*
* Achtung: Die Alt-Gruppe 2 hieß dort „editor", meinte aber die normale
* Self-Publisher-Masse (Kunden, die ihre eigenen PMs einstellen). Im neuen
* Rollenmodell ist `editor` ein Redaktions-/Staff-Recht MIT Admin-Panel-
* Zugriff (canAccessAdmin). Legacy-Publisher gehören daher auf `customer`
* (nur „Mein Bereich"), nicht auf `editor`. `editor` wird ausschließlich
* manuell an echtes Personal vergeben.
*/
private const GROUP_ROLE_MAP = [
1 => 'admin',
2 => 'editor',
2 => 'customer',
3 => 'api-only',
4 => 'customer',
];

View file

@ -23,6 +23,23 @@ return Application::configure(basePath: dirname(__DIR__))
// Portal-Kontext nach dem Theme-Provider setzen (liest config('app.theme'))
$middleware->append(SetCurrentPortal::class);
// Wohin eingeloggte User von Gast-Routen (/login, /register) gelenkt
// werden: rollen- und verifizierungsbewusst statt fix auf /dashboard,
// sonst landet ein Customer dort im 403 und sitzt fest.
$middleware->redirectUsersTo(function (Request $request) {
$user = $request->user();
if ($user && ! $user->hasVerifiedEmail()) {
return route('verification.notice');
}
if ($user?->canAccessAdmin()) {
return route('dashboard');
}
return $user?->canAccessCustomer() ? route('me.dashboard') : '/';
});
$middleware->api(prepend: [
LogApiUsage::class,
RejectLegacyApiKeys::class,

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Mark every pre-existing user as e-mail-verified.
*
* The User model now implements MustVerifyEmail and the panel routes carry
* the `verified` middleware. Without this backfill, all already-imported
* legacy users (verified on their original platform) would be locked out
* the moment verification is enforced. New registrations created after this
* migration keep `email_verified_at = null` until they confirm their e-mail.
*/
public function up(): void
{
DB::table('users')
->whereNull('email_verified_at')
->update([
'email_verified_at' => DB::raw('COALESCE(created_at, CURRENT_TIMESTAMP)'),
]);
}
/**
* Intentionally irreversible: we cannot tell which timestamps were
* backfilled versus genuinely set, and re-nulling them would lock users out.
*/
public function down(): void
{
// no-op
}
};

View file

@ -0,0 +1,86 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\PermissionRegistrar;
return new class extends Migration
{
/**
* Stuft fälschlich als `editor` importierte Legacy-Publisher auf `customer` herab.
*
* Der Legacy-Import mappte die Alt-Gruppe 2 ("editor" = Self-Publisher-Masse)
* auf die neue Rolle `editor`, die jedoch Admin-Panel-Zugriff gewährt. Dadurch
* konnte sich praktisch jeder Legacy-Kunde (z. B. per Magic-Link) ins Admin-
* Panel einloggen. Diese Migration korrigiert die bestehenden Datensätze; das
* Mapping selbst ist in UserImporter bereits auf `customer` umgestellt.
*
* Betroffen werden nur Legacy-User (legacy_portal gesetzt). Echtes Personal
* mit `editor`/`admin` (manuell vergeben, ohne Legacy-Bezug) bleibt unberührt.
*/
public function up(): void
{
$editorId = DB::table('roles')->where('name', 'editor')->value('id');
$customerId = DB::table('roles')->where('name', 'customer')->value('id');
if (! $editorId || ! $customerId) {
return;
}
$morphType = (new User)->getMorphClass();
$legacyEditorIds = DB::table('model_has_roles as mhr')
->join('users', 'users.id', '=', 'mhr.model_id')
->where('mhr.role_id', $editorId)
->where('mhr.model_type', $morphType)
->whereNotNull('users.legacy_portal')
->pluck('users.id');
if ($legacyEditorIds->isEmpty()) {
return;
}
// In Blöcken arbeiten, damit kein whereIn das Placeholder-Limit sprengt.
$legacyEditorIds->chunk(1000)->each(function ($chunk) use ($editorId, $customerId, $morphType) {
$alreadyCustomer = DB::table('model_has_roles')
->where('role_id', $customerId)
->where('model_type', $morphType)
->whereIn('model_id', $chunk)
->pluck('model_id')
->flip();
// editor-Zuweisung entfernen …
DB::table('model_has_roles')
->where('role_id', $editorId)
->where('model_type', $morphType)
->whereIn('model_id', $chunk)
->delete();
// … und customer setzen, wo noch nicht vorhanden.
$rows = $chunk
->reject(fn ($id) => $alreadyCustomer->has($id))
->map(fn ($id) => [
'role_id' => $customerId,
'model_type' => $morphType,
'model_id' => $id,
])
->all();
if ($rows !== []) {
DB::table('model_has_roles')->insert($rows);
}
});
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
/**
* Nicht umkehrbar: Welche customer zuvor fälschlich editor waren, lässt sich
* nach der Korrektur nicht mehr von echten customer unterscheiden.
*/
public function down(): void
{
// no-op
}
};

View file

@ -32,6 +32,7 @@ class DatabaseSeeder extends Seeder
'registration_type' => RegistrationType::ExistingLegacy->value,
'language' => 'de',
'is_active' => true,
'email_verified_at' => now(),
]);
$rolePermissionSync->assignRoleAndSyncPermissions($adminUser, 'admin');
@ -45,6 +46,7 @@ class DatabaseSeeder extends Seeder
'registration_type' => RegistrationType::ExistingLegacy->value,
'language' => 'de',
'is_active' => true,
'email_verified_at' => now(),
]);
$rolePermissionSync->assignRoleAndSyncPermissions($testUser, 'customer');

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>

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Auth\MagicLinkConsumeController;
use App\Http\Controllers\Auth\VerifyEmailController;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Http\Controllers\AuthenticatedSessionController;
use Livewire\Volt\Volt;
@ -31,11 +32,24 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
->middleware(['guest:'.config('fortify.guard')])
->name('password.reset');
// E-Mail-Verifizierung mit Livewire
// E-Mail-Verifizierung: Notice-/Danke-Seite (Volt)
Volt::route('/verify-email', 'auth.verify-email')
->middleware(['auth:'.config('fortify.guard')])
->name('verification.notice');
// Signierter Bestätigungslink aus der Verifizierungsmail
Route::get('/verify-email/{id}/{hash}', VerifyEmailController::class)
->middleware(['auth:'.config('fortify.guard'), 'signed', 'throttle:6,1'])
->name('verification.verify');
// Bestätigungsmail erneut anfordern
Route::post('/email/verification-notification', function () {
request()->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
})->middleware(['auth:'.config('fortify.guard'), 'throttle:6,1'])
->name('verification.send');
// Passwort bestätigen mit Livewire
Volt::route('/confirm-password', 'auth.confirm-password')
->middleware(['auth:'.config('fortify.guard')])

View file

@ -1,6 +1,7 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -34,6 +35,20 @@ test('users can authenticate using the login screen', function () {
expect($user->last_login_ip)->toBe('127.0.0.1');
});
test('unverified users are redirected to the verification notice on login', function () {
/** @var TestCase $this */
$user = User::factory()->unverified()->create(['is_active' => false]);
LivewireVolt::test('auth.login')
->set('email', $user->email)
->set('password', 'password')
->call('login')
->assertHasNoErrors()
->assertRedirect(route('verification.notice', absolute: false));
$this->assertAuthenticated();
});
test('users can not authenticate with invalid password', function () {
/** @var TestCase $this */
$user = User::factory()->create();
@ -48,6 +63,30 @@ test('users can not authenticate with invalid password', function () {
$this->assertGuest();
});
test('an authenticated customer visiting guest routes is not trapped on the admin dashboard', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$this->actingAs($customer)->get('/login')->assertRedirect(route('me.dashboard'));
$this->actingAs($customer)->get('/register')->assertRedirect(route('me.dashboard'));
});
test('an authenticated admin visiting guest routes lands on the admin dashboard', function () {
/** @var TestCase $this */
$admin = User::factory()->superAdmin()->create();
$this->actingAs($admin)->get('/login')->assertRedirect(route('dashboard'));
});
test('an unverified authenticated user visiting guest routes lands on the notice', function () {
/** @var TestCase $this */
$user = User::factory()->unverified()->create(['is_active' => false]);
$this->actingAs($user)->get('/login')->assertRedirect(route('verification.notice'));
});
test('users can logout', function () {
/** @var TestCase $this */
$user = User::factory()->create();

View file

@ -1,35 +1,39 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Auth\Events\Verified;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Laravel\Fortify\Features;
use Tests\TestCase;
/**
* Fortify's emailVerification feature is intentionally disabled in
* config/fortify.php (Volt handles the verification notice instead).
* The tests below cover the Fortify-issued signed URL flow and become
* relevant again once the feature is re-enabled.
*/
beforeEach(function () {
if (! Features::enabled(Features::emailVerification())) {
$this->markTestSkipped('Fortify emailVerification feature is disabled.');
}
test('the verification notice can be rendered for an unverified user', function () {
/** @var TestCase $this */
$user = User::factory()->unverified()->create(['is_active' => false]);
$this->actingAs($user)
->get('/verify-email')
->assertStatus(200)
// Abmelden läuft über ein echtes POST-Formular auf die Logout-Route,
// nicht über eine Livewire-Action (verhindert den 419 beim Session-Invalidate).
->assertSee('action="'.route('logout').'"', false)
->assertSee('method="POST"', false);
});
test('email verification screen can be rendered', function () {
$user = User::factory()->unverified()->create();
test('an unverified user is redirected away from the panel to the notice', function () {
/** @var TestCase $this */
$user = User::factory()->unverified()->create(['is_active' => false]);
$response = $this->actingAs($user)->get('/verify-email');
$response->assertStatus(200);
$this->actingAs($user)
->get(route('me.dashboard'))
->assertRedirect(route('verification.notice'));
});
test('email can be verified', function () {
$user = User::factory()->unverified()->create();
test('confirming the signed link verifies, activates and grants the customer role', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Event::fake();
$user = User::factory()->unverified()->create(['is_active' => false]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
@ -37,16 +41,37 @@ test('email can be verified', function () {
['id' => $user->id, 'hash' => sha1($user->email)]
);
$response = $this->actingAs($user)->get($verificationUrl);
$this->actingAs($user)
->get($verificationUrl)
->assertRedirect(route('me.dashboard', absolute: false).'?verified=1');
Event::assertDispatched(Verified::class);
$user->refresh();
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
expect($user->hasVerifiedEmail())->toBeTrue();
expect($user->is_active)->toBeTrue();
expect($user->hasRole('customer'))->toBeTrue();
});
test('email is not verified with invalid hash', function () {
$user = User::factory()->unverified()->create();
test('verification dispatches the Verified event', function () {
/** @var TestCase $this */
Event::fake();
$user = User::factory()->unverified()->create(['is_active' => false]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(60),
['id' => $user->id, 'hash' => sha1($user->email)]
);
$this->actingAs($user)->get($verificationUrl);
Event::assertDispatched(Verified::class);
});
test('the email is not verified with an invalid hash', function () {
/** @var TestCase $this */
$user = User::factory()->unverified()->create(['is_active' => false]);
$verificationUrl = URL::temporarySignedRoute(
'verification.verify',
@ -54,7 +79,7 @@ test('email is not verified with invalid hash', function () {
['id' => $user->id, 'hash' => sha1('wrong-email')]
);
$this->actingAs($user)->get($verificationUrl);
$this->actingAs($user)->get($verificationUrl)->assertForbidden();
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
});

View file

@ -13,7 +13,7 @@ test('user can request a magic login link from login page', function () {
$user = User::factory()->create(['is_active' => true]);
LivewireVolt::test('auth.login')
->set('email', $user->email)
->set('magicEmail', $user->email)
->call('sendMagicLink')
->assertHasNoErrors();
@ -27,6 +27,27 @@ test('user can request a magic login link from login page', function () {
expect($magicLink->token_hash)->toHaveLength(64);
});
test('the magic link modal validates its own email field and closes on success', function () {
Mail::fake();
$user = User::factory()->create(['is_active' => true]);
// Leere Eingabe → Validierungsfehler, keine Mail.
LivewireVolt::test('auth.login')
->set('magicEmail', '')
->call('sendMagicLink')
->assertHasErrors(['magicEmail']);
Mail::assertNothingSent();
// Gültige Eingabe → Feld wird geleert, Schließen-Event wird ausgelöst.
LivewireVolt::test('auth.login')
->set('magicEmail', $user->email)
->call('sendMagicLink')
->assertHasNoErrors()
->assertSet('magicEmail', '')
->assertDispatched('magic-link-sent');
});
test('admin can login with a valid magic link and lands on admin dashboard', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
@ -35,7 +56,7 @@ test('admin can login with a valid magic link and lands on admin dashboard', fun
$user->assignRole('admin');
LivewireVolt::test('auth.login')
->set('email', $user->email)
->set('magicEmail', $user->email)
->call('sendMagicLink');
$sentMail = null;
@ -67,7 +88,7 @@ test('customer is redirected to me dashboard after magic link login', function (
$customer->assignRole('customer');
LivewireVolt::test('auth.login')
->set('email', $customer->email)
->set('magicEmail', $customer->email)
->call('sendMagicLink');
$sentMail = null;
@ -84,6 +105,29 @@ test('customer is redirected to me dashboard after magic link login', function (
$this->assertAuthenticatedAs($customer);
});
test('a stale intended admin url does not send a customer into the 403 admin area', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$plainToken = 'intended-token';
MagicLink::query()->create([
'user_id' => $customer->id,
'token_hash' => hash('sha256', $plainToken),
'purpose' => 'login',
'expires_at' => now()->addMinutes(5),
]);
// Als Gast zuvor /dashboard besucht → intended-URL liegt in der Session.
$this->withSession(['url.intended' => route('dashboard')]);
$this->get(route('magic-links.consume', ['token' => $plainToken]))
->assertRedirect(route('me.dashboard', absolute: false));
$this->assertAuthenticatedAs($customer);
});
test('expired magic link can not be used', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);

View file

@ -1,28 +1,54 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Support\Facades\Notification;
use Livewire\Volt\Volt;
use Tests\TestCase;
test('registration screen can be rendered', function () {
/** @var TestCase $this */
$response = $this->get('/register');
$response->assertStatus(200);
});
test('new users can register', function () {
// terms_accepted ist seit dem Hub-Auth-Refresh Pflicht (AGB-Checkbox).
// Frisch registrierte User haben keine Rolle → Login-Code fällt auf '/'
// zurück (siehe Phase 1 rollen-basierter Redirect in login/register).
$response = Volt::test('auth.register')
test('new users register inactive and are sent to the verification notice', function () {
/** @var TestCase $this */
Notification::fake();
Volt::test('auth.register')
->set('name', 'Test User')
->set('email', 'test@example.com')
->set('password', 'password')
->set('password_confirmation', 'password')
->set('terms_accepted', true)
->call('register');
$response
->call('register')
->assertHasNoErrors()
->assertRedirect('/');
->assertRedirect(route('verification.notice', absolute: false));
$this->assertAuthenticated();
$user = User::where('email', 'test@example.com')->firstOrFail();
// Konto bleibt bis zur Verifizierung inaktiv, rollenlos und unverifiziert.
expect($user->is_active)->toBeFalse();
expect($user->hasVerifiedEmail())->toBeFalse();
expect($user->roles()->count())->toBe(0);
Notification::assertSentTo($user, VerifyEmail::class);
});
test('registration requires accepting the terms', function () {
/** @var TestCase $this */
Volt::test('auth.register')
->set('name', 'Test User')
->set('email', 'terms@example.com')
->set('password', 'password')
->set('password_confirmation', 'password')
->set('terms_accepted', false)
->call('register')
->assertHasErrors(['terms_accepted']);
expect(User::where('email', 'terms@example.com')->exists())->toBeFalse();
});

View file

@ -128,3 +128,111 @@ test('legacy user import creates linked profile data from sf guard user profile'
expect($user->profile?->tax_exempt_reason)->toBe('Reverse Charge');
expect($user->profile?->disable_footer_code)->toBeTrue();
});
/**
* Baut das minimale Legacy-Schema und importiert einen User in der gegebenen
* Alt-Gruppe. Dient der Rollen-Mapping-Absicherung.
*/
function importLegacyUserInGroup(int $groupId, string $email): void
{
Config::set('database.connections.mysql_presseecho', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
'foreign_key_constraints' => false,
]);
DB::purge('mysql_presseecho');
Schema::connection('mysql_presseecho')->create('sf_guard_user', function (Blueprint $table): void {
$table->integer('id')->primary();
$table->string('username')->nullable();
$table->boolean('is_active')->default(true);
$table->boolean('is_super_admin')->default(false);
$table->timestamp('last_login')->nullable();
$table->string('ip_address')->nullable();
$table->timestamp('created_at')->nullable();
$table->timestamp('updated_at')->nullable();
});
Schema::connection('mysql_presseecho')->create('sf_guard_user_profile', function (Blueprint $table): void {
$table->integer('user_id')->primary();
$table->string('email');
$table->integer('salutation_id')->nullable();
$table->string('title')->nullable();
$table->string('first_name')->nullable();
$table->string('last_name')->nullable();
$table->string('address', 1000)->nullable();
$table->integer('country_id')->nullable();
$table->string('phone')->nullable();
$table->date('birthdate')->nullable();
$table->string('language')->nullable();
$table->string('backlink_url')->nullable();
$table->boolean('show_stats')->default(false);
$table->timestamp('validation_date')->nullable();
$table->timestamp('contract_date')->nullable();
$table->string('registration_type')->nullable();
$table->string('validate')->nullable();
$table->string('tax_id_number')->nullable();
$table->boolean('tax_exempt')->default(false);
$table->string('tax_exempt_reason', 1000)->nullable();
$table->boolean('disable_footer_code')->default(false);
});
Schema::connection('mysql_presseecho')->create('sf_guard_user_group', function (Blueprint $table): void {
$table->integer('user_id');
$table->integer('group_id');
});
DB::connection('mysql_presseecho')->table('sf_guard_user')->insert([
'id' => 77,
'username' => 'grp-user',
'is_active' => true,
'is_super_admin' => false,
'created_at' => '2020-01-01 00:00:00',
'updated_at' => '2020-01-02 00:00:00',
]);
DB::connection('mysql_presseecho')->table('sf_guard_user_profile')->insert([
'user_id' => 77,
'email' => $email,
'first_name' => 'Gruppen',
'last_name' => 'Nutzer',
'language' => 'de',
]);
DB::connection('mysql_presseecho')->table('sf_guard_user_group')->insert([
'user_id' => 77,
'group_id' => $groupId,
]);
app(UserImporter::class)->run(new ImportContext('presseecho', false, true));
}
test('legacy group 2 (self-publisher) imports as customer without admin access', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
importLegacyUserInGroup(2, 'legacy-group2@example.com');
$user = User::query()->where('email', 'legacy-group2@example.com')->firstOrFail();
// Alt-Gruppe 2 ("editor") darf NICHT die neue editor-Rolle (Admin-Zugriff)
// erhalten, sondern customer ("Mein Bereich").
expect($user->hasRole('customer'))->toBeTrue();
expect($user->hasRole('editor'))->toBeFalse();
expect($user->canAccessAdmin())->toBeFalse();
expect($user->canAccessCustomer())->toBeTrue();
});
test('legacy group 1 still imports as admin', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
importLegacyUserInGroup(1, 'legacy-group1@example.com');
$user = User::query()->where('email', 'legacy-group1@example.com')->firstOrFail();
expect($user->hasRole('admin'))->toBeTrue();
expect($user->canAccessAdmin())->toBeTrue();
});