CluPilotCloud/app/Services/Devices/DeviceRecognition.php

153 lines
5.9 KiB
PHP

<?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;
}
}