From 2609393e3af91e028c85c4d43eed49987d82883e Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 28 Jul 2026 23:28:34 +0200 Subject: [PATCH] Recognise the devices an account signs in from, and warn about a new one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/Listeners/RecordSignInDevice.php | 66 +++++++ app/Mail/NewDeviceSignInMail.php | 58 ++++++ app/Models/LoginSession.php | 43 ++++ app/Providers/AppServiceProvider.php | 10 + app/Services/Devices/DeviceName.php | 71 +++++++ app/Services/Devices/DeviceOutcome.php | 23 +++ app/Services/Devices/DeviceRecognition.php | 152 ++++++++++++++ ...0000_create_sessions_and_device_tables.php | 9 +- lang/de/devices.php | 23 +++ lang/de/mail.php | 17 ++ lang/en/devices.php | 23 +++ lang/en/mail.php | 17 ++ .../views/components/mail/layout.blade.php | 80 ++++++++ resources/views/mail/new-device.blade.php | 47 +++++ tests/Feature/NewDeviceWarningTest.php | 187 ++++++++++++++++++ 15 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 app/Listeners/RecordSignInDevice.php create mode 100644 app/Mail/NewDeviceSignInMail.php create mode 100644 app/Models/LoginSession.php create mode 100644 app/Services/Devices/DeviceName.php create mode 100644 app/Services/Devices/DeviceOutcome.php create mode 100644 app/Services/Devices/DeviceRecognition.php create mode 100644 lang/de/devices.php create mode 100644 lang/de/mail.php create mode 100644 lang/en/devices.php create mode 100644 lang/en/mail.php create mode 100644 resources/views/components/mail/layout.blade.php create mode 100644 resources/views/mail/new-device.blade.php create mode 100644 tests/Feature/NewDeviceWarningTest.php diff --git a/app/Listeners/RecordSignInDevice.php b/app/Listeners/RecordSignInDevice.php new file mode 100644 index 0000000..1449516 --- /dev/null +++ b/app/Listeners/RecordSignInDevice.php @@ -0,0 +1,66 @@ +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(), + ]); + } + } +} diff --git a/app/Mail/NewDeviceSignInMail.php b/app/Mail/NewDeviceSignInMail.php new file mode 100644 index 0000000..aa5f9ee --- /dev/null +++ b/app/Mail/NewDeviceSignInMail.php @@ -0,0 +1,58 @@ +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'), + ]); + } +} diff --git a/app/Models/LoginSession.php b/app/Models/LoginSession.php new file mode 100644 index 0000000..a52439c --- /dev/null +++ b/app/Models/LoginSession.php @@ -0,0 +1,43 @@ + 'datetime']; + } + + public function device(): BelongsTo + { + return $this->belongsTo(UserDevice::class, 'device_id'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 6929113..d293e8c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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. diff --git a/app/Services/Devices/DeviceName.php b/app/Services/Devices/DeviceName.php new file mode 100644 index 0000000..9de2949 --- /dev/null +++ b/app/Services/Devices/DeviceName.php @@ -0,0 +1,71 @@ + 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 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 $map */ + private static function match(string $haystack, array $map): ?string + { + foreach ($map as $needle => $label) { + if (str_contains($haystack, $needle)) { + return $label; + } + } + + return null; + } +} diff --git a/app/Services/Devices/DeviceOutcome.php b/app/Services/Devices/DeviceOutcome.php new file mode 100644 index 0000000..7a54b08 --- /dev/null +++ b/app/Services/Devices/DeviceOutcome.php @@ -0,0 +1,23 @@ +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; + } +} diff --git a/database/migrations/2026_07_29_120000_create_sessions_and_device_tables.php b/database/migrations/2026_07_29_120000_create_sessions_and_device_tables.php index 2501210..472819f 100644 --- a/database/migrations/2026_07_29_120000_create_sessions_and_device_tables.php +++ b/database/migrations/2026_07_29_120000_create_sessions_and_device_tables.php @@ -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) { diff --git a/lang/de/devices.php b/lang/de/devices.php new file mode 100644 index 0000000..eb9be38 --- /dev/null +++ b/lang/de/devices.php @@ -0,0 +1,23 @@ + ':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.', +]; diff --git a/lang/de/mail.php b/lang/de/mail.php new file mode 100644 index 0000000..9b7d14c --- /dev/null +++ b/lang/de/mail.php @@ -0,0 +1,17 @@ + '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.', +]; diff --git a/lang/en/devices.php b/lang/en/devices.php new file mode 100644 index 0000000..6ada86d --- /dev/null +++ b/lang/en/devices.php @@ -0,0 +1,23 @@ + ':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.', +]; diff --git a/lang/en/mail.php b/lang/en/mail.php new file mode 100644 index 0000000..e80712a --- /dev/null +++ b/lang/en/mail.php @@ -0,0 +1,17 @@ + '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.', +]; diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php new file mode 100644 index 0000000..4dc781e --- /dev/null +++ b/resources/views/components/mail/layout.blade.php @@ -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