Security-Härtung Login & Magic-Link (Review 16.06.)
- Magic-Link-Versand im Login rate-limited (E-Mail+IP 3/h und IP-only 15/h); verhindert Mail-Fluten und das Entwerten aktiver Links. - Inaktive (aber verifizierte) User werden beim Passwort-Login zentral blockiert (Auth::logout + Fehler) – sichert nur-auth/verified-Routen ab. - Rollensicherer Login-Redirect: gemerkte intended-Admin-URLs schicken einen Customer nicht mehr in den 403, sondern auf das rollengerechte Ziel. - ContactAccess prüft is_active vor jeder Mutation: deaktivierte Bestands- Accounts werden durch eine Anfrage weder verändert noch angemailt. - Magic-Link-Verbrauch atomar (UPDATE … whereNull(consumed_at)) – Single-Use auch bei parallelen Requests. - Sicherheits-Doku um diese Härtungen + Captcha-Empfehlung ergänzt. Tests: Rate-Limit, intended-Admin-URL für Customer, inaktiver Login, ContactAccess ohne Mutation inaktiver Accounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
84f7eb3aab
commit
d98d297524
7 changed files with 201 additions and 16 deletions
|
|
@ -30,10 +30,20 @@ class MagicLinkConsumeController extends Controller
|
|||
return redirect()->route('login')->with('status', __('Your account is not active.'));
|
||||
}
|
||||
|
||||
$magicLink->update([
|
||||
'consumed_at' => now(),
|
||||
'ip_consumed' => $request->ip(),
|
||||
]);
|
||||
// Atomar beanspruchen: nur EIN paralleler Request darf den Link
|
||||
// verbrauchen. Das konditionale UPDATE greift dank Zeilen-Atomarität
|
||||
// genau einmal; verliert ein Race, ist consumed_at bereits gesetzt.
|
||||
$claimed = MagicLink::query()
|
||||
->whereKey($magicLink->id)
|
||||
->whereNull('consumed_at')
|
||||
->update([
|
||||
'consumed_at' => now(),
|
||||
'ip_consumed' => $request->ip(),
|
||||
]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
|
||||
}
|
||||
|
||||
$magicLink->user->update([
|
||||
'last_login_at' => now(),
|
||||
|
|
|
|||
|
|
@ -45,12 +45,16 @@ class ContactAccessService
|
|||
}
|
||||
|
||||
$user = $this->resolveUser($email, $contacts);
|
||||
$this->linkCompanies($user, $contacts);
|
||||
|
||||
// Deaktivierte Bestands-Accounts nicht verändern: eine unauthentifizierte
|
||||
// Anfrage darf weder Firmenzuordnungen vorbereiten noch einen Link
|
||||
// auslösen. Frisch lazy angelegte Accounts sind aktiv und laufen weiter.
|
||||
if (! $user->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->linkCompanies($user, $contacts);
|
||||
|
||||
$generated = $this->magicLinks->createLoginLink($user, $ip);
|
||||
|
||||
Mail::to($user->email)->send(new MagicLoginLink(
|
||||
|
|
|
|||
|
|
@ -67,7 +67,21 @@ Ergänzend: Magic-Link-Consume nutzt einen rollensicheren Redirect **ohne** `int
|
|||
|
||||
---
|
||||
|
||||
## 6. Deployment-Reihenfolge (Migrationen dieser Phase)
|
||||
## 6. Login-/Magic-Link-Härtung (Security-Review 16.06.)
|
||||
|
||||
Aus einer gezielten Auth-Prüfung umgesetzt:
|
||||
|
||||
- **Magic-Link-Versand rate-limited** (vorher ungedrosselt): pro E-Mail+IP (3/h) und zusätzlich pro IP (15/h) – verhindert Mail-Fluten aktiver Accounts und das laufende Entwerten alter Links. Der Pressekontakt-Zugang war bereits limitiert.
|
||||
- **Inaktive (aber verifizierte) User werden beim Passwort-Login zentral blockiert** (`Auth::logout()` + Fehler), analog zum Magic-Link-Consume. Schließt nur-`auth`+`verified`-Routen ohne zusätzlichen `is_active`-Check ab.
|
||||
- **Rollensicherer Login-Redirect**: eine gemerkte `intended`-Admin-URL (z. B. `/admin/users`) schickt einen Customer nicht mehr in den 403 – inaktzessible Admin-Pfade fallen auf das rollengerechte Ziel zurück.
|
||||
- **ContactAccess mutiert keine deaktivierten Bestands-Accounts** mehr: der `is_active`-Check läuft vor jeder Firmenzuordnung/jedem Linkversand.
|
||||
- **Magic-Link-Verbrauch ist atomar** (konditionales `UPDATE … whereNull(consumed_at)`): Single-Use auch bei parallelen Requests garantiert.
|
||||
|
||||
**Offen (Empfehlung):** Statt nur Honeypot + Rate-Limit beim Pressekontakt-Zugang ein echtes Captcha (Cloudflare Turnstile / hCaptcha) – benötigt Provider-Entscheidung, Keys und ein Paket. Bewusst noch nicht umgesetzt.
|
||||
|
||||
---
|
||||
|
||||
## 7. Deployment-Reihenfolge (Migrationen dieser Phase)
|
||||
|
||||
1. `2026_06_15_101337_backfill_email_verified_at_for_existing_users` — verhindert Lockout.
|
||||
2. `2026_06_16_080913_downgrade_legacy_editor_users_to_customer` — schließt die Admin-Überberechtigung.
|
||||
|
|
|
|||
|
|
@ -45,24 +45,35 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
}
|
||||
|
||||
$authenticatedUser = Auth::user();
|
||||
if ($authenticatedUser) {
|
||||
$authenticatedUser->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => request()->ip(),
|
||||
]);
|
||||
}
|
||||
|
||||
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()) {
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
Session::regenerate();
|
||||
$this->redirect(route('verification.notice', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verifiziert, aber deaktiviert: Login zentral blockieren (analog zum
|
||||
// Magic-Link-Consume), damit auch nur-auth/verified-Routen sicher sind.
|
||||
if ($authenticatedUser && ! $authenticatedUser->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$authenticatedUser?->update([
|
||||
'last_login_at' => now(),
|
||||
'last_login_ip' => request()->ip(),
|
||||
]);
|
||||
|
||||
RateLimiter::clear($this->throttleKey());
|
||||
Session::regenerate();
|
||||
|
||||
// Rollen-basierter Default-Redirect:
|
||||
// Admin/Editor → /dashboard, Customer → /admin/me.
|
||||
// Ohne navigate:true, weil das Portal ein anderes Vite-Bundle nutzt
|
||||
|
|
@ -74,7 +85,7 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
? route('me.dashboard', absolute: false)
|
||||
: '/');
|
||||
|
||||
$this->redirectIntended(default: $defaultRoute);
|
||||
$this->redirect($this->safeRedirectTarget($authenticatedUser, $defaultRoute));
|
||||
}
|
||||
|
||||
public function sendMagicLink(): void
|
||||
|
|
@ -84,6 +95,10 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
attributes: ['magicEmail' => __('E-Mail-Adresse')],
|
||||
);
|
||||
|
||||
$this->ensureMagicLinkNotRateLimited();
|
||||
RateLimiter::hit($this->magicLinkThrottleKey(), 3600);
|
||||
RateLimiter::hit($this->magicLinkIpThrottleKey(), 3600);
|
||||
|
||||
$user = User::query()->where('email', $this->magicEmail)->first();
|
||||
|
||||
if ($user && $user->is_active) {
|
||||
|
|
@ -126,6 +141,65 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-Link-Versand drosseln: pro E-Mail+IP (gegen Mail-Fluten eines
|
||||
* Accounts und das laufende Entwerten alter Links) und zusätzlich pro IP
|
||||
* (gegen das Durchprobieren vieler Accounts von einer Quelle).
|
||||
*/
|
||||
protected function ensureMagicLinkNotRateLimited(): void
|
||||
{
|
||||
$withinEmail = ! RateLimiter::tooManyAttempts($this->magicLinkThrottleKey(), 3);
|
||||
$withinIp = ! RateLimiter::tooManyAttempts($this->magicLinkIpThrottleKey(), 15);
|
||||
|
||||
if ($withinEmail && $withinIp) {
|
||||
return;
|
||||
}
|
||||
|
||||
$seconds = max(
|
||||
RateLimiter::availableIn($this->magicLinkThrottleKey()),
|
||||
RateLimiter::availableIn($this->magicLinkIpThrottleKey()),
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'magicEmail' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [
|
||||
'minutes' => max(1, ceil($seconds / 60)),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die intended-URL nur, wenn der User sie auch erreichen darf –
|
||||
* sonst Default. Verhindert, dass ein Customer mit intended=/admin/users
|
||||
* nach dem Login in der 403-Sackgasse landet.
|
||||
*/
|
||||
protected function safeRedirectTarget(?User $user, string $default): string
|
||||
{
|
||||
// Wie Laravels intended(): die gemerkte URL aus der Session ziehen und
|
||||
// entfernen. (In Livewire ist redirect() der Livewire-Redirector ohne
|
||||
// getTargetUrl(), daher direkt über die Session.)
|
||||
$intended = (string) session()->pull('url.intended', $default);
|
||||
$path = parse_url($intended, PHP_URL_PATH) ?: '/';
|
||||
|
||||
if ($user && ! $user->canAccessAdmin() && $this->isAdminOnlyPath($path)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $intended;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Admin-Pfade: alles unter /admin außer dem Kundenbereich /admin/me
|
||||
* sowie das Admin-Dashboard /dashboard.
|
||||
*/
|
||||
protected function isAdminOnlyPath(string $path): bool
|
||||
{
|
||||
if ($path === '/dashboard' || str_starts_with($path, '/dashboard/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_starts_with($path, '/admin') && ! str_starts_with($path, '/admin/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication rate limiting throttle key.
|
||||
*/
|
||||
|
|
@ -133,6 +207,16 @@ new #[Layout('components.layouts.auth.pressekonto', ['heading' => 'Willkommen zu
|
|||
{
|
||||
return Str::transliterate(Str::lower($this->email).'|'.request()->ip());
|
||||
}
|
||||
|
||||
protected function magicLinkThrottleKey(): string
|
||||
{
|
||||
return 'magic-link|'.Str::transliterate(Str::lower($this->magicEmail).'|'.request()->ip());
|
||||
}
|
||||
|
||||
protected function magicLinkIpThrottleKey(): string
|
||||
{
|
||||
return 'magic-link-ip|'.request()->ip();
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div x-data="{ magicModal: false }" x-on:magic-link-sent.window="magicModal = false">
|
||||
|
|
|
|||
|
|
@ -87,6 +87,38 @@ test('an unverified authenticated user visiting guest routes lands on the notice
|
|||
$this->actingAs($user)->get('/login')->assertRedirect(route('verification.notice'));
|
||||
});
|
||||
|
||||
test('a customer login with a stale intended admin url is redirected to the customer area', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$customer = User::factory()->create(['is_active' => true]);
|
||||
$customer->assignRole('customer');
|
||||
|
||||
session()->put('url.intended', url('/admin/users'));
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('email', $customer->email)
|
||||
->set('password', 'password')
|
||||
->call('login')
|
||||
->assertHasNoErrors()
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($customer);
|
||||
});
|
||||
|
||||
test('an inactive but verified user cannot log in with a password', function () {
|
||||
/** @var TestCase $this */
|
||||
// Factory ist standardmäßig verifiziert; nur deaktiviert.
|
||||
$user = User::factory()->create(['is_active' => false]);
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('email', $user->email)
|
||||
->set('password', 'password')
|
||||
->call('login')
|
||||
->assertHasErrors(['email']);
|
||||
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('users can logout', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = User::factory()->create();
|
||||
|
|
|
|||
|
|
@ -95,6 +95,27 @@ test('the contact access form responds neutrally and triggers the service', func
|
|||
expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('requesting access does not mutate an existing inactive account', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
Contact::factory()->for($company)->create([
|
||||
'email' => 'inactive@example.test',
|
||||
'portal' => $company->portal->value,
|
||||
]);
|
||||
|
||||
$existing = User::factory()->create(['email' => 'inactive@example.test', 'is_active' => false]);
|
||||
$existing->assignRole('customer');
|
||||
|
||||
$sent = app(ContactAccessService::class)->requestAccess('inactive@example.test');
|
||||
|
||||
expect($sent)->toBeFalse();
|
||||
// Keine Firmenzuordnung an einem deaktivierten Bestands-Account.
|
||||
expect($existing->fresh()->accessibleCompanyIds())->not->toContain($company->id);
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('the honeypot blocks bots without creating an account', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
|
|
|
|||
|
|
@ -48,6 +48,26 @@ test('the magic link modal validates its own email field and closes on success',
|
|||
->assertDispatched('magic-link-sent');
|
||||
});
|
||||
|
||||
test('magic link requests are rate limited per email', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
|
||||
foreach (range(1, 3) as $ignored) {
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink')
|
||||
->assertHasNoErrors();
|
||||
}
|
||||
|
||||
LivewireVolt::test('auth.login')
|
||||
->set('magicEmail', $user->email)
|
||||
->call('sendMagicLink')
|
||||
->assertHasErrors(['magicEmail']);
|
||||
|
||||
Mail::assertSent(MagicLoginLink::class, 3);
|
||||
});
|
||||
|
||||
test('admin can login with a valid magic link and lands on admin dashboard', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue