Benachrichtigung ueber eine gesperrte Adresse, hoechstens eine je Stunde

SecurityBlockMail geht bei einer Instanz-Sperre an die Kontoadresse des
Inhabers, aus dem SYSTEM-Postfach wie NewDeviceSignInMail. Die Drossel sitzt
in BlockAddress::notifyInstanceOwner() ueber Settings (kein neues Feld fuer
etwas, das nach einer Stunde niemanden mehr interessiert) und wird VOR dem
Versandversuch gesetzt. Ein Throwable beim Verschicken wird gemeldet und
verschluckt: die Sperre steht schon, bevor ueberhaupt versucht wird zu
verschicken, und ein kaputtes Postfach darf sie nicht rueckgaengig machen.

Host-Sperren verschicken bewusst noch keine Mail: kein Muster im Repo, wie
eine Betreiber-Meldung ihren Empfaenger findet (siehe Bericht).

Route 'portal.security' minimal angelegt (Aufgabe 6 baut die echte Seite) -
auf einem eigenen Pfad, weil sie sich mit der oeffentlichen /security-Seite
sonst lautlos gegenseitig ueberschreiben, sobald Portal und Website ohne
eigene Domain laufen (RouteCollection indiziert ueber Methode+Domain+URI,
nicht ueber den Namen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
claude/nice-moser-521659
nexxo 2026-08-03 14:39:35 +02:00
parent ec1f13807e
commit 2d979052d0
9 changed files with 315 additions and 1 deletions

View File

@ -0,0 +1,66 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Models\SecurityBlock;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* "Eine Adresse wurde wegen Anmeldeversuchen an Ihrer Instanz gesperrt."
*
* Aus dem SYSTEM-Postfach, genau wie NewDeviceSignInMail: das ist kein
* Gespräch, und eine Antwort darauf soll kein Ticket öffnen.
*
* Die Drossel höchstens eine Mail je Instanz und Stunde sitzt in
* BlockAddress, nicht hier: diese Klasse weiß nichts davon, ob sie gerade die
* erste oder die zehnte Sperre dieser Stunde beschreibt, und muss es auch
* nicht wissen.
*
* Der Link führt auf die Sicherheitsseite des Portals (Aufgabe 6, `portal.
* security`), wo die Sperre eingesehen und vorzeitig aufgehoben werden kann.
* Eine Warnung, deren einziger Rat "handeln Sie" ist, ist eine Warnung ohne
* Handgriff.
*/
class SecurityBlockMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
public function __construct(public SecurityBlock $block)
{
$this->mailer('cp_'.MailPurpose::SYSTEM);
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(
MailPurpose::SYSTEM,
__('security.mail_subject'),
'security-block',
);
}
public function content(): Content
{
// instance() statt eines eigenen Customer-Parameters: der Aufrufer
// (BlockAddress) hat ohnehin nur den Datensatz, und ein zweiter Weg,
// an dieselbe Instanz zu kommen, wäre eine zweite Stelle, an der der
// Name veralten könnte.
$customer = $this->block->instance?->customer;
return new Content(view: 'mail.security-block', with: [
'name' => (string) ($customer?->contact_name ?: $customer?->name ?: ''),
'ip' => $this->block->ip,
'attempts' => $this->block->attempts,
'blockedAt' => $this->block->blocked_at,
'expiresAt' => $this->block->expires_at,
'securityUrl' => route('portal.security'),
]);
}
}

View File

@ -43,6 +43,9 @@ final class MailCatalogue
'dunning-3' => ['label' => '3. Mahnung (letzte vor Abschaltung)', 'purpose' => MailPurpose::BILLING], 'dunning-3' => ['label' => '3. Mahnung (letzte vor Abschaltung)', 'purpose' => MailPurpose::BILLING],
'cloud-suspended' => ['label' => 'Cloud abgeschaltet', 'purpose' => MailPurpose::BILLING], 'cloud-suspended' => ['label' => 'Cloud abgeschaltet', 'purpose' => MailPurpose::BILLING],
'cloud-resumed' => ['label' => 'Cloud läuft wieder', 'purpose' => MailPurpose::BILLING], 'cloud-resumed' => ['label' => 'Cloud läuft wieder', 'purpose' => MailPurpose::BILLING],
// Sicherheit: die eine Meldung des Frühwarnsystems (BlockAddress),
// wenn eine Adresse an der Instanz eines Kunden gesperrt wird.
'security-block' => ['label' => 'Adresse wegen Anmeldeversuchen gesperrt', 'purpose' => MailPurpose::SYSTEM],
]; ];
} }

View File

@ -15,11 +15,14 @@ use App\Mail\NewDeviceSignInMail;
use App\Mail\OperatorMessageMail; use App\Mail\OperatorMessageMail;
use App\Mail\OrderConfirmationMail; use App\Mail\OrderConfirmationMail;
use App\Mail\ResetPasswordMail; use App\Mail\ResetPasswordMail;
use App\Mail\SecurityBlockMail;
use App\Mail\VerifyEmailMail; use App\Mail\VerifyEmailMail;
use App\Models\Customer; use App\Models\Customer;
use App\Models\Instance;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\MaintenanceWindow; use App\Models\MaintenanceWindow;
use App\Models\Order; use App\Models\Order;
use App\Models\SecurityBlock;
use App\Models\User; use App\Models\User;
use App\Models\UserDevice; use App\Models\UserDevice;
use App\Services\Billing\DunningSchedule; use App\Services\Billing\DunningSchedule;
@ -113,6 +116,7 @@ class MailPreviews
name: $customer->name, amountCents: 0, feeTotalCents: 0, name: $customer->name, amountCents: 0, feeTotalCents: 0,
currency: 'EUR', billingUrl: route('cloud'), currency: 'EUR', billingUrl: route('cloud'),
), ),
'security-block' => new SecurityBlockMail($this->securityBlock($customer)),
'operator-message' => new OperatorMessageMail( 'operator-message' => new OperatorMessageMail(
$customer, $customer,
'Zur Datenübernahme', 'Zur Datenübernahme',
@ -191,6 +195,28 @@ class MailPreviews
])->forceFill(['id' => 0]); ])->forceFill(['id' => 0]);
} }
/**
* Eine Sperre samt der Instanz, an der sie hängt beide nur im Speicher
* (setRelation statt eines echten Fremdschlüssels), damit SecurityBlockMail
* bis zum Kunden durchgreifen kann, ohne dass eine dieser drei Zeilen in
* der Datenbank landet.
*/
private function securityBlock(Customer $customer): SecurityBlock
{
$instance = Instance::make(['subdomain' => 'nc-beispiel', 'status' => 'active'])
->forceFill(['id' => 0])
->setRelation('customer', $customer);
return SecurityBlock::make([
'ip' => '203.0.113.7',
'reason' => 'instance_login',
'attempts' => 12,
'strikes' => 1,
'blocked_at' => Carbon::now(),
'expires_at' => Carbon::now()->addHour(),
])->forceFill(['id' => 0])->setRelation('instance', $instance);
}
/** /**
* The credentials mail is a notification rather than a Mailable. * The credentials mail is a notification rather than a Mailable.
* *

View File

@ -2,12 +2,16 @@
namespace App\Services\Security; namespace App\Services\Security;
use App\Mail\SecurityBlockMail;
use App\Models\Host; use App\Models\Host;
use App\Models\Instance; use App\Models\Instance;
use App\Models\SecurityBlock; use App\Models\SecurityBlock;
use App\Support\ProvisioningSettings; use App\Support\ProvisioningSettings;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\IpUtils; use Symfony\Component\HttpFoundation\IpUtils;
use Throwable;
/** /**
* Die Sperr-Entscheidung: erst die Ausnahmeliste, dann ob schon eine Sperre * Die Sperr-Entscheidung: erst die Ausnahmeliste, dann ob schon eine Sperre
@ -39,7 +43,7 @@ class BlockAddress
return null; return null;
} }
return $this->createBlock( $block = $this->createBlock(
ip: $ip, ip: $ip,
attempts: $attempts, attempts: $attempts,
reason: 'instance_login', reason: 'instance_login',
@ -47,6 +51,10 @@ class BlockAddress
hostId: null, hostId: null,
firewallHost: $instance->host, firewallHost: $instance->host,
); );
$this->notifyInstanceOwner($instance, $block);
return $block;
} }
public function forHost(Host $host, string $ip, int $attempts): ?SecurityBlock public function forHost(Host $host, string $ip, int $attempts): ?SecurityBlock
@ -65,6 +73,45 @@ class BlockAddress
); );
} }
/**
* Benachrichtigt den Inhaber der Instanz per Mail höchstens einmal je
* Instanz und Stunde. Der Zeitpunkt der letzten Meldung steht in
* `Settings` unter `security.notified.instance.<id>`, nicht in einer
* eigenen Spalte: nach einer Stunde interessiert er niemanden mehr, und
* eine Spalte dafür wäre außerhalb dieses einen Fensters immer bedeutungslos.
*
* Für Host-Sperren gibt es hier absichtlich noch kein Gegenstück: im Repo
* findet sich kein Muster, wie eine Betreiber-Meldung ihren Empfänger
* sucht (keine Konsolen-Rolle "bekommt Systemmails", keine Adresse dafür
* in den Einstellungen) das wäre eine eigene Entwurfsentscheidung.
*
* Zustellung ist nicht die Bedingung für Schutz: die Sperre steht bereits,
* bevor hier auch nur versucht wird zu verschicken, und die Drossel wird
* VOR dem Versandversuch gesetzt, nicht danach sonst würde ein
* dauerhaft kaputtes Postfach bei jeder weiteren Sperre an derselben
* Instanz erneut versuchen und denselben Fehler immer wieder melden. Ein
* Throwable beim Verschicken wird über `report()` gemeldet und
* verschluckt: ein kaputtes Postfach darf die Sperre nicht rückgängig
* machen, die es eigentlich ankündigen sollte.
*/
private function notifyInstanceOwner(Instance $instance, SecurityBlock $block): void
{
$key = 'security.notified.instance.'.$instance->id;
$last = Settings::get($key);
if ($last !== null && now()->subHour()->lt($last)) {
return;
}
Settings::set($key, now()->toIso8601String());
try {
Mail::to($instance->customer->email)->queue(new SecurityBlockMail($block));
} catch (Throwable $e) {
report($e);
}
}
/** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */ /** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */
private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool
{ {

View File

@ -40,4 +40,23 @@ return [
'step_tell_body' => 'Wir sehen dann nach, ob mit Ihrem Zugang etwas passiert ist, und können die Anmeldungen für Sie prüfen. Es ist uns lieber, Sie melden sich einmal zu oft.', 'step_tell_body' => 'Wir sehen dann nach, ob mit Ihrem Zugang etwas passiert ist, und können die Anmeldungen für Sie prüfen. Es ist uns lieber, Sie melden sich einmal zu oft.',
'tell_us' => 'Unsicher, ob eine Nachricht echt ist? Schicken Sie sie uns weiter, bevor Sie klicken. Das kostet Sie zwei Minuten und uns nichts.', 'tell_us' => 'Unsicher, ob eine Nachricht echt ist? Schicken Sie sie uns weiter, bevor Sie klicken. Das kostet Sie zwei Minuten und uns nichts.',
// Die Mail aus BlockAddress, wenn eine Adresse an der Instanz eines Kunden
// gesperrt wird: Adresse, Zeitpunkt, Anzahl der Versuche und wann die
// Sperre von selbst abläuft — eine Warnung, deren einziger Rat "handeln
// Sie" ist, ist eine Warnung ohne Handgriff.
'mail_subject' => 'Adresse wegen Anmeldeversuchen gesperrt',
'mail_heading' => 'Eine Adresse wurde gesperrt',
'mail_preheader' => ':ip wurde wegen wiederholter Anmeldeversuche gesperrt.',
'mail_greeting' => 'Guten Tag :name,',
'mail_intro' => 'von der Adresse :ip gab es innerhalb kurzer Zeit :attempts fehlgeschlagene Anmeldeversuche bei Ihrer Cloud. Wir haben diese Adresse deshalb vorübergehend gesperrt.',
'field_ip' => 'Adresse',
'field_when' => 'Gesperrt seit',
'field_attempts' => 'Fehlversuche',
'field_expires' => 'Sperre endet',
'mail_note' => 'Die Sperre endet von selbst zum genannten Zeitpunkt — Sie müssen nichts tun. Waren Sie das selbst, etwa nach einem falsch gespeicherten Passwort in einer Sync-App, können Sie die Sperre auf der Sicherheitsübersicht auch vorzeitig aufheben.',
'mail_action' => 'Sicherheitsübersicht ansehen',
// Die Fehlalarme, die bleiben, ausdrücklich benannt — sonst hält der
// Empfänger die Meldung für falsch und nimmt die nächste nicht mehr ernst.
'mail_false_alarm' => 'Diese Meldung kommt auch, wenn ein eigenes Gerät oder eine eigene Anwendung mit einem falschen Passwort auf Ihre Cloud zugreift.',
]; ];

View File

@ -40,4 +40,23 @@ return [
'step_tell_body' => 'We will check whether anything happened to your account and can go through the sign-ins with you. We would much rather hear from you once too often.', 'step_tell_body' => 'We will check whether anything happened to your account and can go through the sign-ins with you. We would much rather hear from you once too often.',
'tell_us' => 'Not sure whether a message is genuine? Forward it to us before you click. It costs you two minutes and us nothing.', 'tell_us' => 'Not sure whether a message is genuine? Forward it to us before you click. It costs you two minutes and us nothing.',
// The mail from BlockAddress when an address is blocked at a customer's
// instance: the address, the time, the number of attempts and when the
// block lifts on its own — a warning whose only advice is "take action"
// is a warning that names a problem and hands back nothing to do about it.
'mail_subject' => 'Address blocked after sign-in attempts',
'mail_heading' => 'An address was blocked',
'mail_preheader' => ':ip was blocked after repeated sign-in attempts.',
'mail_greeting' => 'Hello :name,',
'mail_intro' => 'address :ip made :attempts failed sign-in attempts against your cloud in a short time. We have blocked that address for the time being.',
'field_ip' => 'Address',
'field_when' => 'Blocked since',
'field_attempts' => 'Failed attempts',
'field_expires' => 'Block ends',
'mail_note' => 'The block lifts on its own at the time above — there is nothing you need to do. If this was you, for example a saved password in a sync app that has since changed, you can also lift the block early from the security overview.',
'mail_action' => 'View security overview',
// The false alarms that remain, said out loud — otherwise the recipient
// decides the warning is wrong and stops reading the next one.
'mail_false_alarm' => 'You will also get this message if a device or application of your own is using an outdated password against your cloud.',
]; ];

View File

@ -0,0 +1,51 @@
<x-mail.layout
:heading="__('security.mail_heading')"
:preheader="__('security.mail_preheader', ['ip' => $ip])"
:greeting="$name !== '' ? __('security.mail_greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('security.mail_intro', ['ip' => $ip, 'attempts' => $attempts]) }}</p>
</td></tr>
{{-- Die Fakten, als Tabelle statt als Satz: eine Sperre wird nachgeschlagen,
nicht gelesen wie eine Geschichte. --}}
<tr><td style="padding:0 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
<tr>
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('security.field_ip') }}</td>
<td style="padding:14px 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $ip }}</td>
</tr>
<tr>
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('security.field_when') }}</td>
<td style="padding:0 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $blockedAt?->local()->isoFormat('LLLL') }}</td>
</tr>
<tr>
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('security.field_attempts') }}</td>
<td style="padding:0 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $attempts }}</td>
</tr>
<tr>
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('security.field_expires') }}</td>
<td style="padding:0 16px 14px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $expiresAt?->local()->isoFormat('LLLL') }}</td>
</tr>
</table>
</td></tr>
<tr><td style="padding:24px 24px 0 24px;">
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('security.mail_note') }}</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
<a href="{{ $securityUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('security.mail_action') }}</a>
</td></tr>
</table>
</td></tr>
<tr><td style="padding:24px 24px 32px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('security.mail_false_alarm') }}</p>
</td></tr>
</table>
</td></tr>
</x-mail.layout>

View File

@ -354,6 +354,20 @@ $portal = function () {
Route::get('/backups', Backups::class)->name('backups'); Route::get('/backups', Backups::class)->name('backups');
Route::get('/invoices', Invoices::class)->name('invoices'); Route::get('/invoices', Invoices::class)->name('invoices');
// Platzhalter fuer Aufgabe 6, die hier die eigentliche Sicherheitsseite
// (Sperrliste der eigenen Instanzen) anlegt. Ohne einen benannten
// 'portal.security' bricht schon heute MailPreviewTest, weil
// SecurityBlockMail::content() dorthin verlinkt (route('portal.security')).
//
// NICHT '/security': die öffentliche Seite (oben, Zeile ~254) meldet
// denselben Pfad an, und $appHost/$siteHost sind hier beide leer (jede
// Entwicklungs- und Testumgebung) — beide Gruppen laufen dann
// host-unabhängig, und Illuminate\Routing\RouteCollection indiziert
// Routen über Methode+Domain+URI, NICHT über den Namen. Zwei GET-Routen
// auf demselben Pfad ohne Domain überschreiben sich also gegenseitig,
// unabhängig vom Namen — die zweite gewinnt lautlos, ganz ohne Fehler.
Route::get('/security-blocks', fn () => redirect()->route('dashboard'))->name('portal.security');
// The customer's own invoice as a PDF, rendered on demand from the frozen // The customer's own invoice as a PDF, rendered on demand from the frozen
// document — the same renderer the console uses, because there is only one // document — the same renderer the console uses, because there is only one
// version of a document that has been issued. // version of a document that has been issued.

View File

@ -0,0 +1,69 @@
<?php
// tests/Feature/Security/SecurityBlockMailTest.php
use App\Mail\SecurityBlockMail;
use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\SecurityBlock;
use App\Services\Mail\MailPurpose;
use App\Services\Mail\MailRoute;
use App\Services\Security\BlockAddress;
use App\Support\Settings;
use Illuminate\Support\Facades\Mail;
// Blank slate statt der migrationseigenen fuenf Postfaecher (no-reply,
// support, billing, office, info): der letzte Test unten legt selbst eines
// mit dem Schluessel 'no-reply' an, und das kollidiert sonst mit
// mailboxes.key's Unique-Constraint gegen die von RefreshDatabase stehen
// gelassene Saat (siehe MailRoutingTest, derselbe Helfer aus tests/Pest.php).
beforeEach(fn () => clearMailboxSeed());
it('schickt dem Inhaber eine Mail, wenn seine Instanz gesperrt wird', function () {
Mail::fake();
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
Mail::assertQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($instance->customer->email));
});
it('schickt hoechstens eine Mail je Instanz und Stunde', function () {
// Ein Angreifer, der Adressen durchwechselt, erzeugt sonst zwanzig Mails —
// und die zwanzigste liest niemand mehr.
Mail::fake();
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
app(BlockAddress::class)->forInstance($instance, '203.0.113.8', 12);
app(BlockAddress::class)->forInstance($instance, '203.0.113.9', 12);
Mail::assertQueuedCount(1);
});
it('laesst die Sperre stehen, wenn die Mail scheitert', function () {
// Zustellung ist nicht die Bedingung fuer Schutz. Eine Sperre, die von einem
// kaputten Postfach abhinge, waere genau dann weg, wenn ohnehin schon etwas
// im Argen liegt.
Mail::shouldReceive('to')->andThrow(new RuntimeException('Postfach kaputt'));
$instance = Instance::factory()->create(['status' => 'active', 'vmid' => 101]);
$block = app(BlockAddress::class)->forInstance($instance, '203.0.113.7', 12);
expect($block)->not->toBeNull()
->and(SecurityBlock::count())->toBe(1);
});
it('geht standardmaessig aus dem System-Postfach und folgt der Wegwahl', function () {
Mailbox::factory()->create(['key' => 'no-reply', 'address' => 'no-reply@clupilot.com', 'active' => true]);
$info = Mailbox::factory()->create(['key' => 'info', 'active' => true]);
Settings::set(MailPurpose::settingKey(MailPurpose::SYSTEM), 'no-reply');
$block = SecurityBlock::factory()->create();
expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe('no-reply@clupilot.com');
Settings::set(MailRoute::settingKey('security-block'), 'info');
expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe($info->address);
});