diff --git a/app/Mail/SecurityBlockMail.php b/app/Mail/SecurityBlockMail.php new file mode 100644 index 0000000..6a2b3d7 --- /dev/null +++ b/app/Mail/SecurityBlockMail.php @@ -0,0 +1,66 @@ +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'), + ]); + } +} diff --git a/app/Services/Mail/MailCatalogue.php b/app/Services/Mail/MailCatalogue.php index 5b7ebe1..a89ac00 100644 --- a/app/Services/Mail/MailCatalogue.php +++ b/app/Services/Mail/MailCatalogue.php @@ -43,6 +43,9 @@ final class MailCatalogue 'dunning-3' => ['label' => '3. Mahnung (letzte vor Abschaltung)', 'purpose' => MailPurpose::BILLING], 'cloud-suspended' => ['label' => 'Cloud abgeschaltet', '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], ]; } diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 4dbf439..013014d 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -15,11 +15,14 @@ use App\Mail\NewDeviceSignInMail; use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; +use App\Mail\SecurityBlockMail; use App\Mail\VerifyEmailMail; use App\Models\Customer; +use App\Models\Instance; use App\Models\Invoice; use App\Models\MaintenanceWindow; use App\Models\Order; +use App\Models\SecurityBlock; use App\Models\User; use App\Models\UserDevice; use App\Services\Billing\DunningSchedule; @@ -113,6 +116,7 @@ class MailPreviews name: $customer->name, amountCents: 0, feeTotalCents: 0, currency: 'EUR', billingUrl: route('cloud'), ), + 'security-block' => new SecurityBlockMail($this->securityBlock($customer)), 'operator-message' => new OperatorMessageMail( $customer, 'Zur Datenübernahme', @@ -191,6 +195,28 @@ class MailPreviews ])->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. * diff --git a/app/Services/Security/BlockAddress.php b/app/Services/Security/BlockAddress.php index 3cacf8c..13483a3 100644 --- a/app/Services/Security/BlockAddress.php +++ b/app/Services/Security/BlockAddress.php @@ -2,12 +2,16 @@ namespace App\Services\Security; +use App\Mail\SecurityBlockMail; use App\Models\Host; use App\Models\Instance; use App\Models\SecurityBlock; use App\Support\ProvisioningSettings; +use App\Support\Settings; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\IpUtils; +use Throwable; /** * Die Sperr-Entscheidung: erst die Ausnahmeliste, dann ob schon eine Sperre @@ -39,7 +43,7 @@ class BlockAddress return null; } - return $this->createBlock( + $block = $this->createBlock( ip: $ip, attempts: $attempts, reason: 'instance_login', @@ -47,6 +51,10 @@ class BlockAddress hostId: null, firewallHost: $instance->host, ); + + $this->notifyInstanceOwner($instance, $block); + + return $block; } 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.`, 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? */ private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool { diff --git a/lang/de/security.php b/lang/de/security.php index fa8eded..c8ed2f9 100644 --- a/lang/de/security.php +++ b/lang/de/security.php @@ -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.', '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.', ]; diff --git a/lang/en/security.php b/lang/en/security.php index b2cb96d..e6fd425 100644 --- a/lang/en/security.php +++ b/lang/en/security.php @@ -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.', '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.', ]; diff --git a/resources/views/mail/security-block.blade.php b/resources/views/mail/security-block.blade.php new file mode 100644 index 0000000..b35d9bc --- /dev/null +++ b/resources/views/mail/security-block.blade.php @@ -0,0 +1,51 @@ + + + +

{{ __('security.mail_intro', ['ip' => $ip, 'attempts' => $attempts]) }}

+ + +{{-- Die Fakten, als Tabelle statt als Satz: eine Sperre wird nachgeschlagen, + nicht gelesen wie eine Geschichte. --}} + + + + + + + + + + + + + + + + + + +
{{ __('security.field_ip') }}{{ $ip }}
{{ __('security.field_when') }}{{ $blockedAt?->local()->isoFormat('LLLL') }}
{{ __('security.field_attempts') }}{{ $attempts }}
{{ __('security.field_expires') }}{{ $expiresAt?->local()->isoFormat('LLLL') }}
+ + + +

{{ __('security.mail_note') }}

+ + +
+ {{ __('security.mail_action') }} +
+ + + + + +
+

{{ __('security.mail_false_alarm') }}

+
+ + +
diff --git a/routes/web.php b/routes/web.php index f6a587d..b9e8de6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -354,6 +354,20 @@ $portal = function () { Route::get('/backups', Backups::class)->name('backups'); 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 // document — the same renderer the console uses, because there is only one // version of a document that has been issued. diff --git a/tests/Feature/Security/SecurityBlockMailTest.php b/tests/Feature/Security/SecurityBlockMailTest.php new file mode 100644 index 0000000..86518f6 --- /dev/null +++ b/tests/Feature/Security/SecurityBlockMailTest.php @@ -0,0 +1,69 @@ + 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); +});