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