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
parent
ec1f13807e
commit
2d979052d0
|
|
@ -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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue