153 lines
5.9 KiB
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;
|
|
}
|
|
}
|