Recognise the devices an account signs in from, and warn about a new one
Sessions lived in Redis, where they cannot be enumerated — one key per session, no index by user — so "where am I signed in" and "end that one" were not answerable at all. The database driver keeps a row per session and makes ending one a delete. It costs every currently signed-in person one forced sign-in, once, which is why it is happening now rather than later. The warning is only worth anything if it is rare, so the two obvious signals are deliberately not used. Not the IP: it changes on every train, every café, every mobile handover, and behind a VPN it is not theirs to begin with. Not the user-agent: Chrome ships every four weeks and the string changes each time, which would be twelve warnings a year per customer, describing nothing. A device is a long-lived signed cookie holding a random token, and nothing else is consulted. The honest cost — cleared cookies, a private window, a second browser — is written into the mail itself, because a reader who thinks the warning is wrong stops reading the next one. Three things the tests found or hold: The first device on an account is new and must not warn. Replying to somebody creating an account by telling them they signed in from a new device is noise on the one message that has to stay worth reading. token_hash was globally unique, and the shared-browser test failed on it. Two people on one machine share one cookie and must each own a device row against it; the obvious repair — issue a fresh token for the second — would orphan the first person's row and warn them about their own desk. Unique is now per identity. Recording a device may never block a sign-in. A safeguard that locks the owner out when it breaks has stopped being one, so the listener logs and swallows, and a test drops the tables to prove it. Sessions are linked by a uuid carried inside the session payload rather than by the session id, which Laravel rotates on every sign-in and every regenerate(). Laravel's own sessions.user_id cannot carry the link either: it is filled from the default guard, while operators and customers are two guards and two tables (R21) — operator 1 and customer 1 would each be offered the other's sessions. Also lands the shared mail layout the rest of the templates will use: tables and inline styles for Outlook, no image and no web font because half of all clients block remote content, and the button in --accent-active rather than the brand orange, which is 2.9:1 against white. Not tagged: the session list is not built yet, and without a tag no server can install this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
669d0471a8
commit
2609393e3a
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Mail\NewDeviceSignInMail;
|
||||
use App\Services\Devices\DeviceRecognition;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* One listener for both ways in.
|
||||
*
|
||||
* The console signs in through App\Livewire\Auth\OperatorLogin and the portal
|
||||
* through Fortify — two code paths that share nothing. Both raise Laravel's
|
||||
* Login event, and it carries the guard, which is the one thing that tells
|
||||
* operators and customers apart (R21). Hooking the event rather than the two
|
||||
* controllers means neither can be changed in a way that quietly stops
|
||||
* recording sign-ins.
|
||||
*
|
||||
* Nothing here may prevent a sign-in. Recognising a device is a safeguard, and
|
||||
* a safeguard that locks the owner out when it breaks has stopped being one —
|
||||
* so a failure is logged and swallowed. This is a deliberate exception to
|
||||
* failing loudly, and the reason is written here so nobody removes it as an
|
||||
* oversight.
|
||||
*/
|
||||
class RecordSignInDevice
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DeviceRecognition $devices,
|
||||
private readonly Request $request,
|
||||
) {}
|
||||
|
||||
public function handle(Login $event): void
|
||||
{
|
||||
try {
|
||||
$outcome = $this->devices->recognise($event->user, $event->guard, $this->request);
|
||||
$this->devices->attachSession($outcome->device, $this->request);
|
||||
|
||||
if (! $outcome->shouldWarn) {
|
||||
return;
|
||||
}
|
||||
|
||||
$address = $event->user->getAttribute('email');
|
||||
|
||||
if (! is_string($address) || $address === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Queued: a sign-in must not wait on a mail server, and this one
|
||||
// runs while somebody is looking at a spinner on the login button.
|
||||
Mail::to($address)->queue(new NewDeviceSignInMail(
|
||||
name: (string) ($event->user->getAttribute('name') ?? ''),
|
||||
device: $outcome->device,
|
||||
guard: $event->guard,
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Could not record the device for a sign-in', [
|
||||
'guard' => $event->guard,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\UserDevice;
|
||||
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;
|
||||
|
||||
/**
|
||||
* "Somebody signed in from a device this account has not used before."
|
||||
*
|
||||
* Sent from the SYSTEM mailbox rather than SUPPORT: it is not a conversation,
|
||||
* and a reply to it should not open a ticket.
|
||||
*
|
||||
* The link goes to the session list, because a warning whose only advice is
|
||||
* "if this was not you, take action" is a warning that names a problem and
|
||||
* hands back nothing to do about it.
|
||||
*/
|
||||
class NewDeviceSignInMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public UserDevice $device,
|
||||
public string $guard,
|
||||
) {
|
||||
$this->mailer('cp_'.MailPurpose::SYSTEM);
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return $this->mailboxEnvelope(
|
||||
MailPurpose::SYSTEM,
|
||||
__('devices.mail_subject'),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'mail.new-device', with: [
|
||||
'name' => $this->name,
|
||||
'device' => $this->device,
|
||||
// Operators and customers land in different places, and sending
|
||||
// somebody to the other product's settings page is sending them
|
||||
// somewhere they cannot sign in (R21).
|
||||
'sessionsUrl' => $this->guard === 'operator'
|
||||
? route('admin.settings')
|
||||
: route('settings'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One signed-in browser session, as something a person can see and end.
|
||||
*
|
||||
* Keyed by its own uuid, which is kept inside the session payload rather than
|
||||
* by the session id. Laravel rotates the session id on every sign-in and on
|
||||
* every regenerate() — a row keyed by that id loses sight of the session it
|
||||
* describes within a request or two, and the operator is then offered a list of
|
||||
* sessions that no longer exist next to the one they are sitting in.
|
||||
*
|
||||
* `session_id` is still stored, because ending a session means deleting the
|
||||
* framework's own row: the next request from that browser finds nothing and is
|
||||
* signed out. It is refreshed from the uuid on every request instead of being
|
||||
* trusted to stay put.
|
||||
*/
|
||||
class LoginSession extends Model
|
||||
{
|
||||
use HasUuid;
|
||||
|
||||
/** Where the uuid lives inside the session payload. */
|
||||
public const KEY = 'login_session_uuid';
|
||||
|
||||
protected $fillable = [
|
||||
'device_id', 'session_id', 'ip_address', 'last_seen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['last_seen_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserDevice::class, 'device_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RecordSignInDevice;
|
||||
use App\Provisioning\PipelineRegistry;
|
||||
use App\Services\Dns\HetznerDnsClient;
|
||||
use App\Services\Dns\HttpHetznerDnsClient;
|
||||
|
|
@ -26,6 +27,7 @@ use App\Models\MaintenanceNotification;
|
|||
use App\Services\Maintenance\MaintenanceNotifier;
|
||||
use Carbon\CarbonImmutable;
|
||||
use App\Mail\Transport\MailboxTransport;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Illuminate\Mail\Events\MessageSent;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
|
@ -65,6 +67,14 @@ class AppServiceProvider extends ServiceProvider
|
|||
// so nothing here reads the database while the application boots.
|
||||
Mail::extend('mailbox', fn (array $config) => new MailboxTransport($config['purpose']));
|
||||
|
||||
// One listener for both ways in. The console signs in through
|
||||
// App\Livewire\Auth\OperatorLogin and the portal through Fortify — two
|
||||
// paths that share no code but both raise this event, which carries the
|
||||
// guard that tells the two identities apart (R21). Registered here
|
||||
// rather than left to listener discovery, so that moving the class
|
||||
// cannot silently stop sign-ins being recorded.
|
||||
Event::listen(Login::class, RecordSignInDevice::class);
|
||||
|
||||
// Every timestamp that gets shown to somebody goes through ->local()
|
||||
// first. Storage is UTC and stays UTC; this is the last step before a
|
||||
// human reads it.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Devices;
|
||||
|
||||
/**
|
||||
* "Chrome auf macOS", from a user-agent string.
|
||||
*
|
||||
* For the person reading their list of sessions, and for nothing else. It never
|
||||
* decides whether a device is known — which is exactly why it is allowed to be
|
||||
* this rough, and why it is recomputed on every sign-in: a browser upgrade
|
||||
* quietly relabels the row instead of raising an alarm.
|
||||
*
|
||||
* Order matters throughout. Every Chromium browser also says "Chrome", Edge
|
||||
* also says "Chrome" and "Safari", and Safari says "Safari" while Chrome says
|
||||
* it too — so the specific names have to be tested before the generic ones.
|
||||
*/
|
||||
final class DeviceName
|
||||
{
|
||||
/** @var array<string, string> needle => label, most specific first */
|
||||
private const BROWSERS = [
|
||||
'Edg/' => 'Edge',
|
||||
'OPR/' => 'Opera',
|
||||
'Vivaldi' => 'Vivaldi',
|
||||
'Brave' => 'Brave',
|
||||
'SamsungBrowser' => 'Samsung Internet',
|
||||
'Firefox' => 'Firefox',
|
||||
'CriOS' => 'Chrome', // Chrome on iOS, which is Safari underneath
|
||||
'FxiOS' => 'Firefox',
|
||||
'Chrome' => 'Chrome',
|
||||
'Safari' => 'Safari',
|
||||
];
|
||||
|
||||
/** @var array<string, string> needle => label, most specific first */
|
||||
private const PLATFORMS = [
|
||||
'iPhone' => 'iPhone',
|
||||
'iPad' => 'iPad',
|
||||
'Android' => 'Android',
|
||||
'Macintosh' => 'macOS',
|
||||
'Mac OS X' => 'macOS',
|
||||
'Windows' => 'Windows',
|
||||
'CrOS' => 'ChromeOS',
|
||||
'Linux' => 'Linux',
|
||||
];
|
||||
|
||||
public static function from(string $userAgent): string
|
||||
{
|
||||
$browser = self::match($userAgent, self::BROWSERS);
|
||||
$platform = self::match($userAgent, self::PLATFORMS);
|
||||
|
||||
if ($browser !== null && $platform !== null) {
|
||||
return __('devices.name_on', ['browser' => $browser, 'platform' => $platform]);
|
||||
}
|
||||
|
||||
// One of them is better than the shrug, and the shrug is better than a
|
||||
// raw user-agent string in a list somebody is meant to recognise
|
||||
// themselves in.
|
||||
return $browser ?? $platform ?? __('devices.name_unknown');
|
||||
}
|
||||
|
||||
/** @param array<string, string> $map */
|
||||
private static function match(string $haystack, array $map): ?string
|
||||
{
|
||||
foreach ($map as $needle => $label) {
|
||||
if (str_contains($haystack, $needle)) {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Devices;
|
||||
|
||||
use App\Models\UserDevice;
|
||||
|
||||
/**
|
||||
* What a sign-in turned out to be.
|
||||
*
|
||||
* `isNew` and `shouldWarn` are deliberately two fields rather than one. The
|
||||
* very first device on an account is new and must still not produce a warning:
|
||||
* telling somebody that they have just signed in from a new device, in reply to
|
||||
* them creating the account, is noise on the one message that has to stay
|
||||
* worth reading.
|
||||
*/
|
||||
final readonly class DeviceOutcome
|
||||
{
|
||||
public function __construct(
|
||||
public UserDevice $device,
|
||||
public bool $isNew,
|
||||
public bool $shouldWarn,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Devices;
|
||||
|
||||
use App\Models\LoginSession;
|
||||
use App\Models\UserDevice;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Deciding whether a sign-in comes from somewhere this account has been before.
|
||||
*
|
||||
* The whole value of a new-device warning is that it is rare. A warning that
|
||||
* fires on ordinary behaviour is worse than none: it trains the recipient to
|
||||
* delete it, and the one that matters goes with it. So the two obvious signals
|
||||
* are deliberately not used.
|
||||
*
|
||||
* NOT the IP address. It changes on every train, every café, every mobile
|
||||
* handover, and for anyone behind a VPN it is not theirs to begin with.
|
||||
*
|
||||
* NOT the user-agent string. Chrome ships a new version roughly every four
|
||||
* weeks and the string changes each time — twelve warnings a year, per
|
||||
* customer, describing nothing.
|
||||
*
|
||||
* A device is a long-lived signed cookie holding a random token. Present and
|
||||
* known for this identity: known. Absent or unknown: new. Nothing else is
|
||||
* consulted, and the honest cost is that clearing cookies, a private window or
|
||||
* a second browser each read as a new device — which is correct, because as far
|
||||
* as anything here can tell, they are.
|
||||
*/
|
||||
final class DeviceRecognition
|
||||
{
|
||||
/** Not httpOnly by accident — nothing in the browser needs to read this. */
|
||||
public const COOKIE = 'clupilot_device';
|
||||
|
||||
/**
|
||||
* 400 days, because that is the ceiling browsers enforce.
|
||||
*
|
||||
* Chrome caps any cookie lifetime at 400 days and silently shortens
|
||||
* anything longer, so asking for more only makes the code disagree with
|
||||
* the browser about when the warning comes back.
|
||||
*/
|
||||
private const LIFETIME_DAYS = 400;
|
||||
|
||||
/**
|
||||
* Record this sign-in, and say whether it deserves a warning.
|
||||
*
|
||||
* @param string $guard 'web' or 'operator' — the guard IS the identity
|
||||
* type here, operators and customers being two
|
||||
* tables and two guards (R21).
|
||||
*/
|
||||
public function recognise(Authenticatable $user, string $guard, Request $request): DeviceOutcome
|
||||
{
|
||||
$id = (int) $user->getAuthIdentifier();
|
||||
|
||||
// Whether this account has ever signed in anywhere, asked BEFORE the
|
||||
// row below exists. Warning somebody about the very first device they
|
||||
// have ever used is telling them they just did the thing they just did.
|
||||
$firstEver = ! UserDevice::query()
|
||||
->where('guard', $guard)
|
||||
->where('authenticatable_id', $id)
|
||||
->exists();
|
||||
|
||||
// The cookie's token is reused when it is already there, even if it is
|
||||
// unknown for THIS identity: on a shared browser two people hold two
|
||||
// device rows against one token, and issuing a fresh token for the
|
||||
// second would orphan the first — whose owner then gets a new-device
|
||||
// warning on their own machine the next time they sign in.
|
||||
$token = $this->tokenFrom($request) ?? Str::random(48);
|
||||
$hash = hash('sha256', $token);
|
||||
|
||||
$device = UserDevice::query()
|
||||
->where('guard', $guard)
|
||||
->where('authenticatable_id', $id)
|
||||
->where('token_hash', $hash)
|
||||
->first();
|
||||
|
||||
$isNew = $device === null;
|
||||
|
||||
if ($isNew) {
|
||||
$device = new UserDevice([
|
||||
'guard' => $guard,
|
||||
'authenticatable_id' => $id,
|
||||
'token_hash' => $hash,
|
||||
]);
|
||||
}
|
||||
|
||||
// Recomputed every time, on a known device too: a browser upgrade
|
||||
// should relabel the row, not raise an alarm. It can only be safely
|
||||
// recomputed because the name identifies nothing.
|
||||
$device->name = DeviceName::from((string) $request->userAgent());
|
||||
$device->last_ip = $request->ip();
|
||||
$device->last_seen_at = now();
|
||||
$device->save();
|
||||
|
||||
// Re-issued on every sign-in, known device included, so that somebody
|
||||
// who signs in regularly never falls off the end of the 400 days and
|
||||
// gets warned about the machine on their own desk.
|
||||
Cookie::queue(Cookie::make(
|
||||
self::COOKIE,
|
||||
$token,
|
||||
self::LIFETIME_DAYS * 24 * 60,
|
||||
secure: $request->isSecure(),
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
));
|
||||
|
||||
return new DeviceOutcome(
|
||||
device: $device,
|
||||
isNew: $isNew,
|
||||
// A first device is new but is not news.
|
||||
shouldWarn: $isNew && ! $firstEver,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the current session to a device, and remember it by uuid.
|
||||
*
|
||||
* The uuid goes into the session payload rather than the row being keyed by
|
||||
* the session id, because Laravel rotates that id on sign-in and on every
|
||||
* regenerate(). A row keyed by it stops describing anything within a
|
||||
* request or two — and the list would then offer somebody sessions that no
|
||||
* longer exist alongside the one they are sitting in.
|
||||
*/
|
||||
public function attachSession(UserDevice $device, Request $request): LoginSession
|
||||
{
|
||||
$session = LoginSession::create([
|
||||
'device_id' => $device->id,
|
||||
'session_id' => $request->hasSession() ? $request->session()->getId() : null,
|
||||
'ip_address' => $request->ip(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
if ($request->hasSession()) {
|
||||
$request->session()->put(LoginSession::KEY, $session->uuid);
|
||||
}
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private function tokenFrom(Request $request): ?string
|
||||
{
|
||||
$value = $request->cookie(self::COOKIE);
|
||||
|
||||
// Cookies are encrypted by the framework, so anything that survives
|
||||
// decryption was issued here — but a truncated or replayed value is
|
||||
// still not a token, and a lookup on nonsense must not match a row.
|
||||
return is_string($value) && strlen($value) >= 32 ? $value : null;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,7 +62,13 @@ return new class extends Migration
|
|||
// The cookie's token, hashed. Stored the way a password is, for the
|
||||
// same reason: a leaked database must not hand out working device
|
||||
// cookies that skip the new-device warning.
|
||||
$table->string('token_hash', 64)->unique();
|
||||
//
|
||||
// Unique per IDENTITY, not globally. One browser shared by two
|
||||
// people holds one cookie, and both of them must be able to own a
|
||||
// device row against it — a global unique makes the second person's
|
||||
// sign-in fail, and the obvious repair (issue a fresh token) would
|
||||
// orphan the first person's row and warn them about their own desk.
|
||||
$table->string('token_hash', 64)->index();
|
||||
|
||||
// "Chrome auf macOS" — for the person reading the list, never for
|
||||
// deciding whether the device is known. Recomputed on each sign-in
|
||||
|
|
@ -74,6 +80,7 @@ return new class extends Migration
|
|||
$table->timestamps();
|
||||
|
||||
$table->index(['guard', 'authenticatable_id']);
|
||||
$table->unique(['guard', 'authenticatable_id', 'token_hash'], 'user_devices_identity_token_unique');
|
||||
});
|
||||
|
||||
Schema::create('login_sessions', function (Blueprint $table) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Gerätenamen. Nur zum Wiedererkennen durch den Menschen — sie entscheiden
|
||||
// nie, ob ein Gerät bekannt ist, und werden bei jeder Anmeldung neu
|
||||
// gebildet, damit ein Browser-Update das Etikett ändert und keinen Alarm.
|
||||
'name_on' => ':browser auf :platform',
|
||||
'name_unknown' => 'Unbekanntes Gerät',
|
||||
|
||||
'mail_subject' => 'Neue Anmeldung bei CluPilot',
|
||||
'mail_heading' => 'Anmeldung von einem neuen Gerät',
|
||||
'mail_preheader' => 'Erstmals angemeldet von: :device',
|
||||
'mail_greeting' => 'Guten Tag :name,',
|
||||
'mail_intro' => 'bei Ihrem Konto hat sich jemand von einem Gerät angemeldet, das bisher nicht verwendet wurde.',
|
||||
'field_device' => 'Gerät',
|
||||
'field_when' => 'Zeitpunkt',
|
||||
'field_ip' => 'IP-Adresse',
|
||||
'mail_if_you' => 'Waren Sie das, ist nichts zu tun. Waren Sie es nicht, beenden Sie die Sitzung und ändern Sie Ihr Passwort.',
|
||||
'mail_action' => 'Angemeldete Geräte 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 Sie Ihre Cookies gelöscht, ein privates Fenster benutzt oder einen anderen Browser verwendet haben.',
|
||||
];
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
// Die Fußzeile, die jede CluPilot-Mail trägt. Eigene Datei, weil sie in jeder
|
||||
// Vorlage identisch ist und ein Rechtstext an genau einer Stelle stehen muss.
|
||||
return [
|
||||
// Steht bewusst ganz oben in der Fußzeile, nicht als Kleingedrucktes: das
|
||||
// ist die Zeile, die ein Empfänger sucht, wenn er die Mail nicht einordnen
|
||||
// kann — und die ein Spamfilter für die Einordnung mitliest.
|
||||
'why_you_got_this' => 'Sie erhalten diese E-Mail, weil mit dieser Adresse ein Konto bei CluPilot besteht.',
|
||||
'imprint' => 'Impressum',
|
||||
'privacy' => 'Datenschutz',
|
||||
'terms' => 'AGB',
|
||||
'sender_line' => 'CluPilot Cloud · Musterstraße 1, 1010 Wien · office@clupilot.com',
|
||||
// Der Unterschied zwischen einer Werbemail und einer Kontomail, ausdrücklich
|
||||
// gesagt: für diese gibt es keinen Abmeldelink, und das ist zulässig.
|
||||
'transactional_note' => 'Diese Nachricht betrifft Ihr Konto und wird unabhängig von Werbeeinstellungen versendet.',
|
||||
];
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Device names. For a person to recognise themselves in a list, never for
|
||||
// deciding whether a device is known — and recomputed on every sign-in so a
|
||||
// browser update relabels the row instead of raising an alarm.
|
||||
'name_on' => ':browser on :platform',
|
||||
'name_unknown' => 'Unknown device',
|
||||
|
||||
'mail_subject' => 'New sign-in to CluPilot',
|
||||
'mail_heading' => 'Signed in from a new device',
|
||||
'mail_preheader' => 'First sign-in from: :device',
|
||||
'mail_greeting' => 'Hello :name,',
|
||||
'mail_intro' => 'somebody signed in to your account from a device it has not been used on before.',
|
||||
'field_device' => 'Device',
|
||||
'field_when' => 'Time',
|
||||
'field_ip' => 'IP address',
|
||||
'mail_if_you' => 'If this was you, there is nothing to do. If it was not, end the session and change your password.',
|
||||
'mail_action' => 'Review signed-in devices',
|
||||
// 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 you cleared your cookies, used a private window, or signed in with a different browser.',
|
||||
];
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
// The footer every CluPilot mail carries. Its own file because it is identical
|
||||
// in every template, and a legal line has to live in exactly one place.
|
||||
return [
|
||||
// Deliberately at the top of the footer rather than buried: this is the
|
||||
// line a recipient looks for when they cannot place the mail, and the one
|
||||
// a spam filter reads for the same purpose.
|
||||
'why_you_got_this' => 'You are receiving this because an account at CluPilot uses this address.',
|
||||
'imprint' => 'Imprint',
|
||||
'privacy' => 'Privacy',
|
||||
'terms' => 'Terms',
|
||||
'sender_line' => 'CluPilot Cloud · Musterstraße 1, 1010 Vienna · office@clupilot.com',
|
||||
// The difference between a marketing mail and an account mail, said out
|
||||
// loud: this one has no unsubscribe link, and that is allowed.
|
||||
'transactional_note' => 'This message concerns your account and is sent regardless of marketing preferences.',
|
||||
];
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
@props(['heading', 'preheader' => null, 'greeting' => null])
|
||||
|
||||
{{--
|
||||
The one layout every CluPilot mail uses.
|
||||
|
||||
Tables and inline styles throughout, and not out of nostalgia: Outlook
|
||||
renders with Word, which has no flexbox, no grid, and drops <style> blocks
|
||||
in several configurations. Anything that has to survive there has to be an
|
||||
attribute on the element it styles.
|
||||
|
||||
Colours come from resources/css/portal-tokens.css by hand, because a mail
|
||||
cannot load a stylesheet. The button is --accent-active (#b8500a), never the
|
||||
brand orange (#f97316): white on the brand orange is 2.9:1 and unreadable,
|
||||
which is the single most common way this palette gets broken and the reason
|
||||
the two tones have different names.
|
||||
|
||||
No image, no web font, no external asset of any kind. Half of all clients
|
||||
block remote content by default, so a design that needs an image to make
|
||||
sense is a design that is broken for half of its readers.
|
||||
--}}
|
||||
<div style="margin:0;padding:0;background-color:#f6f6f8;">
|
||||
@if ($preheader)
|
||||
{{-- The line shown next to the subject in the inbox list. Hidden in the body
|
||||
itself, or it reads as a duplicate first sentence. --}}
|
||||
<div style="display:none;font-size:1px;color:#f6f6f8;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">{{ $preheader }}</div>
|
||||
@endif
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f6f6f8;border-collapse:collapse;">
|
||||
<tr><td align="center" style="padding:32px 16px;">
|
||||
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
|
||||
|
||||
<tr><td style="padding:0 0 20px 4px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr>
|
||||
<td style="padding-right:10px;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td width="26" height="26" style="width:26px;height:26px;background-color:#f97316;border-radius:7px;font-size:0;line-height:0;"> </td></tr>
|
||||
</table>
|
||||
</td>
|
||||
<td style="font-size:17px;line-height:26px;color:#17171c;letter-spacing:-0.2px;">
|
||||
<span style="font-weight:700;">Clu</span><span style="font-weight:700;color:#b8500a;">Pilot</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="background-color:#ffffff;border:1px solid #e9e9ee;border-radius:12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
|
||||
<tr><td style="padding:40px 40px 0 40px;">
|
||||
<h1 style="margin:0 0 14px 0;font-size:25px;line-height:32px;font-weight:700;color:#17171c;letter-spacing:-0.5px;">{{ $heading }}</h1>
|
||||
@if ($greeting)
|
||||
<p style="margin:0 0 8px 0;font-size:15px;line-height:24px;color:#43434e;">{{ $greeting }}</p>
|
||||
@endif
|
||||
</td></tr>
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td align="center" style="padding:22px 16px 0 16px;text-align:center;">
|
||||
<p style="margin:0 0 10px 0;font-size:12px;line-height:18px;color:#6e6e7a;text-align:center;">{{ __('mail.why_you_got_this') }}</p>
|
||||
<p style="margin:0 0 6px 0;font-size:12px;line-height:18px;color:#6e6e7a;text-align:center;">
|
||||
<a href="{{ route('legal.impressum') }}" style="color:#6e6e7a;text-decoration:underline;">{{ __('mail.imprint') }}</a>
|
||||
·
|
||||
<a href="{{ route('legal.datenschutz') }}" style="color:#6e6e7a;text-decoration:underline;">{{ __('mail.privacy') }}</a>
|
||||
·
|
||||
<a href="{{ route('legal.agb') }}" style="color:#6e6e7a;text-decoration:underline;">{{ __('mail.terms') }}</a>
|
||||
</p>
|
||||
<p style="margin:0;font-size:12px;line-height:18px;color:#b4b4be;text-align:center;">{{ __('mail.sender_line') }}</p>
|
||||
<p style="margin:10px 0 0 0;font-size:12px;line-height:18px;color:#b4b4be;text-align:center;">{{ __('mail.transactional_note') }}</p>
|
||||
</td></tr>
|
||||
|
||||
</table>
|
||||
|
||||
</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<x-mail.layout
|
||||
:heading="__('devices.mail_heading')"
|
||||
:preheader="__('devices.mail_preheader', ['device' => $device->name])"
|
||||
:greeting="$name !== '' ? __('devices.mail_greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('devices.mail_intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- The facts, as a table rather than a sentence: the reader is checking
|
||||
whether this was them, and that is a comparison, not a story. --}}
|
||||
<tr><td style="padding:0 40px;">
|
||||
<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%;">{{ __('devices.field_device') }}</td>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $device->name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('devices.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;">{{ $device->last_seen_at?->local()->isoFormat('LLLL') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('devices.field_ip') }}</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;">{{ $device->last_ip ?? '—' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('devices.mail_if_you') }}</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="{{ $sessionsUrl }}" 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;">{{ __('devices.mail_action') }}</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<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;">{{ __('devices.mail_false_alarm') }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
</x-mail.layout>
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\NewDeviceSignInMail;
|
||||
use App\Models\Operator;
|
||||
use App\Models\User;
|
||||
use App\Models\UserDevice;
|
||||
use App\Services\Devices\DeviceRecognition;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* A new-device warning is only worth anything if it is rare.
|
||||
*
|
||||
* A warning that fires on ordinary behaviour trains its reader to delete it,
|
||||
* and the one that matters goes with it. Most of these tests are therefore
|
||||
* about the cases that must produce NO mail — they are the feature, far more
|
||||
* than the case that does.
|
||||
*/
|
||||
function signIn(string $guard, $user, array $cookies = [], string $agent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0 Safari/537.36', string $ip = '203.0.113.10')
|
||||
{
|
||||
$request = Request::create('/anmelden', 'POST', [], $cookies, [], [
|
||||
'HTTP_USER_AGENT' => $agent,
|
||||
'REMOTE_ADDR' => $ip,
|
||||
]);
|
||||
|
||||
return app(DeviceRecognition::class)->recognise($user, $guard, $request);
|
||||
}
|
||||
|
||||
it('does not warn about the very first device on an account', function () {
|
||||
// Telling somebody they signed in from a new device, in reply to them
|
||||
// creating the account, is noise on the one message that must stay worth
|
||||
// reading.
|
||||
$user = User::factory()->create();
|
||||
|
||||
$outcome = signIn('web', $user);
|
||||
|
||||
expect($outcome->isNew)->toBeTrue()
|
||||
->and($outcome->shouldWarn)->toBeFalse()
|
||||
->and(UserDevice::query()->where('authenticatable_id', $user->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('warns when a second, unknown device appears', function () {
|
||||
$user = User::factory()->create();
|
||||
signIn('web', $user); // the first, silent
|
||||
|
||||
$outcome = signIn('web', $user); // no cookie: another browser
|
||||
|
||||
expect($outcome->isNew)->toBeTrue()
|
||||
->and($outcome->shouldWarn)->toBeTrue();
|
||||
});
|
||||
|
||||
it('stays quiet when the same device comes back', function () {
|
||||
$user = User::factory()->create();
|
||||
$first = signIn('web', $user);
|
||||
$token = tokenFor($first->device);
|
||||
|
||||
$again = signIn('web', $user, [DeviceRecognition::COOKIE => $token]);
|
||||
|
||||
expect($again->isNew)->toBeFalse()
|
||||
->and($again->shouldWarn)->toBeFalse()
|
||||
->and($again->device->id)->toBe($first->device->id);
|
||||
});
|
||||
|
||||
it('stays quiet when the browser updates itself', function () {
|
||||
// The reason the user-agent string is not what identifies a device. Chrome
|
||||
// ships every four weeks; recognising by the string would mean twelve
|
||||
// warnings a year, per customer, describing nothing.
|
||||
$user = User::factory()->create();
|
||||
$first = signIn('web', $user, agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0 Safari/537.36');
|
||||
$token = tokenFor($first->device);
|
||||
|
||||
$again = signIn('web', $user,
|
||||
cookies: [DeviceRecognition::COOKIE => $token],
|
||||
agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/141.0 Safari/537.36',
|
||||
);
|
||||
|
||||
expect($again->shouldWarn)->toBeFalse()
|
||||
->and($again->device->id)->toBe($first->device->id);
|
||||
});
|
||||
|
||||
it('stays quiet when the address changes, and follows it', function () {
|
||||
// The reason the IP is not what identifies a device: it changes on every
|
||||
// train, every café and every mobile handover.
|
||||
$user = User::factory()->create();
|
||||
$first = signIn('web', $user, ip: '203.0.113.10');
|
||||
$token = tokenFor($first->device);
|
||||
|
||||
$again = signIn('web', $user, cookies: [DeviceRecognition::COOKIE => $token], ip: '198.51.100.77');
|
||||
|
||||
expect($again->shouldWarn)->toBeFalse()
|
||||
// Still recorded, because the list is where somebody checks it — it is
|
||||
// only barred from DECIDING anything.
|
||||
->and($again->device->refresh()->last_ip)->toBe('198.51.100.77');
|
||||
});
|
||||
|
||||
it('relabels a device when the browser changes, without treating it as new', function () {
|
||||
$user = User::factory()->create();
|
||||
$first = signIn('web', $user, agent: 'Mozilla/5.0 (Windows NT 10.0) Chrome/126.0 Safari/537.36');
|
||||
$token = tokenFor($first->device);
|
||||
|
||||
expect($first->device->name)->toBe(__('devices.name_on', ['browser' => 'Chrome', 'platform' => 'Windows']));
|
||||
|
||||
$again = signIn('web', $user,
|
||||
cookies: [DeviceRecognition::COOKIE => $token],
|
||||
agent: 'Mozilla/5.0 (Windows NT 10.0) Firefox/130.0',
|
||||
);
|
||||
|
||||
expect($again->shouldWarn)->toBeFalse()
|
||||
->and($again->device->refresh()->name)->toBe(__('devices.name_on', ['browser' => 'Firefox', 'platform' => 'Windows']));
|
||||
});
|
||||
|
||||
it('does not orphan the first person on a shared browser', function () {
|
||||
// Two people, one machine. Issuing a fresh token for the second would
|
||||
// replace the cookie, leaving the first with a device row nothing matches
|
||||
// — and a new-device warning on their own desk the next time they sign in.
|
||||
$alice = User::factory()->create();
|
||||
$bob = User::factory()->create();
|
||||
|
||||
$aliceFirst = signIn('web', $alice);
|
||||
$token = tokenFor($aliceFirst->device);
|
||||
|
||||
signIn('web', $bob, [DeviceRecognition::COOKIE => $token]);
|
||||
$aliceAgain = signIn('web', $alice, [DeviceRecognition::COOKIE => $token]);
|
||||
|
||||
expect($aliceAgain->shouldWarn)->toBeFalse()
|
||||
->and($aliceAgain->device->id)->toBe($aliceFirst->device->id)
|
||||
->and(UserDevice::query()->where('token_hash', hash('sha256', $token))->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('keeps operators and customers apart even at the same id', function () {
|
||||
// Operator 1 is not customer 1 (R21). A device shared across the two would
|
||||
// let one of them silence the other's warning.
|
||||
$user = User::factory()->create();
|
||||
$operator = Operator::factory()->create();
|
||||
|
||||
$customerDevice = signIn('web', $user);
|
||||
$token = tokenFor($customerDevice->device);
|
||||
|
||||
$operatorSignIn = signIn('operator', $operator, [DeviceRecognition::COOKIE => $token]);
|
||||
|
||||
expect($operatorSignIn->device->id)->not->toBe($customerDevice->device->id)
|
||||
->and($operatorSignIn->device->guard)->toBe('operator');
|
||||
});
|
||||
|
||||
it('sends the warning from the console sign-in path too', function () {
|
||||
// Both ways in raise the same event, which is the whole reason it is hooked
|
||||
// rather than the two controllers.
|
||||
Mail::fake();
|
||||
|
||||
$operator = operator('Owner');
|
||||
signIn('operator', $operator); // first, silent
|
||||
|
||||
event(new Illuminate\Auth\Events\Login('operator', $operator, false));
|
||||
|
||||
Mail::assertQueued(NewDeviceSignInMail::class);
|
||||
});
|
||||
|
||||
it('never blocks a sign-in when recording the device fails', function () {
|
||||
// A safeguard that locks the owner out when it breaks has stopped being
|
||||
// one. Deliberate, and tested so nobody removes it as an oversight.
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
// A real failure rather than a stubbed one: the class is final on purpose,
|
||||
// and a test that replaces it would prove only that the replacement throws.
|
||||
// This is the actual shape of the outage — the device store unreachable.
|
||||
Illuminate\Support\Facades\Schema::drop('login_sessions');
|
||||
Illuminate\Support\Facades\Schema::drop('user_devices');
|
||||
|
||||
event(new Illuminate\Auth\Events\Login('web', $user, false));
|
||||
|
||||
// No throwsNoExceptions() needed: event() propagates whatever a listener
|
||||
// throws, so an escaping exception fails this test on its own.
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
/** The plaintext token that was queued into the cookie for a device. */
|
||||
function tokenFor(UserDevice $device): string
|
||||
{
|
||||
$queued = collect(Illuminate\Support\Facades\Cookie::getQueuedCookies())
|
||||
->first(fn ($c) => $c->getName() === DeviceRecognition::COOKIE);
|
||||
|
||||
expect($queued)->not->toBeNull();
|
||||
|
||||
return $queued->getValue();
|
||||
}
|
||||
Loading…
Reference in New Issue