Determine client info on insert rather than on retrieve for speed improvements.

i suppose device detect only ever expects to analyse a single string at once given its made for matomo so it on the slower side for multiple dingusses
This commit is contained in:
flash 2023-07-21 12:47:56 +00:00
parent ebac064c59
commit 14c5635b4f
6 changed files with 156 additions and 47 deletions

View file

@ -0,0 +1,51 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
use Misuzu\ClientInfo;
final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
// convert user agent fields to BLOB and add field for client info storage
$conn->execute('
ALTER TABLE msz_login_attempts
CHANGE COLUMN attempt_user_agent attempt_user_agent TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER attempt_created,
ADD COLUMN attempt_client_info TEXT NULL DEFAULT NULL AFTER attempt_user_agent
');
$conn->execute('
ALTER TABLE msz_sessions
CHANGE column session_user_agent session_user_agent TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER session_ip_last,
ADD COLUMN session_client_info TEXT NULL DEFAULT NULL AFTER session_user_agent
');
// make sure all existing fields have client info fields filled
$updateLoginAttempts = $conn->prepare('UPDATE msz_login_attempts SET attempt_client_info = ? WHERE attempt_user_agent = ?');
$selectLoginAttempts = $conn->query('SELECT DISTINCT attempt_user_agent FROM msz_login_attempts');
while($selectLoginAttempts->next()) {
$updateLoginAttempts->reset();
$userAgent = $selectLoginAttempts->getString(0);
$updateLoginAttempts->addParameter(1, ClientInfo::parse($userAgent)->encode());
$updateLoginAttempts->addParameter(2, $userAgent);
$updateLoginAttempts->execute();
}
$updateSessions = $conn->prepare('UPDATE msz_sessions SET session_client_info = ? WHERE session_user_agent = ?');
$selectSessions = $conn->query('SELECT DISTINCT session_user_agent FROM msz_sessions');
while($selectSessions->next()) {
$updateSessions->reset();
$userAgent = $selectSessions->getString(0);
$updateSessions->addParameter(1, ClientInfo::parse($userAgent)->encode());
$updateSessions->addParameter(2, $userAgent);
$updateSessions->execute();
}
// make client info fields NOT NULL
$conn->execute('
ALTER TABLE msz_login_attempts
CHANGE COLUMN attempt_client_info attempt_client_info TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER attempt_user_agent
');
$conn->execute('
ALTER TABLE msz_sessions
CHANGE COLUMN session_client_info session_client_info TEXT NOT NULL COLLATE "utf8mb4_bin" AFTER session_user_agent
');
}
}

View file

@ -2,7 +2,9 @@
namespace Misuzu; namespace Misuzu;
use Misuzu\Users\User; use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
use Misuzu\Users\UserSession; use Misuzu\Users\UserSession;
use Misuzu\Users\UserSessionNotFoundException;
require_once __DIR__ . '/../misuzu.php'; require_once __DIR__ . '/../misuzu.php';

View file

@ -1,44 +1,45 @@
<?php <?php
namespace Misuzu; namespace Misuzu;
use InvalidArgumentException; use stdClass;
use RuntimeException;
use Stringable; use Stringable;
use DeviceDetector\ClientHints; use DeviceDetector\ClientHints;
use DeviceDetector\DeviceDetector; use DeviceDetector\DeviceDetector;
class ClientInfo implements Stringable { class ClientInfo implements Stringable {
private DeviceDetector $dd; private const SERIALIZE_VERSION = 1;
public function __construct(DeviceDetector $dd) { public function __construct(
$this->dd = $dd; private array|bool|null $botInfo,
} private ?array $clientInfo,
private ?array $osInfo,
private string $brandName,
private string $modelName
) {}
public function __toString(): string { public function __toString(): string {
if($this->dd->isBot()) { if($this->botInfo === true || is_array($this->botInfo)) {
$botInfo = $this->dd->getBot(); if($this->botInfo === true)
return $botInfo['name'] ?? 'an unknown bot'; return 'a bot';
return $this->botInfo['name'] ?? 'an unknown bot';
} }
$clientInfo = $this->dd->getClient(); if(empty($this->clientInfo['name']))
if(empty($clientInfo['name']))
return 'an unknown browser'; return 'an unknown browser';
$string = $clientInfo['name']; $string = $this->clientInfo['name'];
if(!empty($clientInfo['version'])) if(!empty($this->clientInfo['version']))
$string .= ' ' . $clientInfo['version']; $string .= ' ' . $this->clientInfo['version'];
$osInfo = $this->dd->getOs(); $hasOsInfo = !empty($this->osInfo['name']);
$hasOsInfo = !empty($osInfo['name']); $hasModelName = !empty($this->modelName);
$brandName = $this->dd->getBrandName();
$modelName = $this->dd->getModel();
$hasModelName = !empty($modelName);
if($hasOsInfo || $hasModelName) if($hasOsInfo || $hasModelName)
$string .= ' on '; $string .= ' on ';
if($hasModelName) { if($hasModelName) {
$deviceName = trim($brandName . ' ' . $modelName); $deviceName = trim($this->brandName . ' ' . $this->modelName);
// most naive check in the world but it works well enough for this lol // most naive check in the world but it works well enough for this lol
$firstCharIsVowel = in_array(strtolower($deviceName[0]), ['a', 'i', 'u', 'e', 'o']); $firstCharIsVowel = in_array(strtolower($deviceName[0]), ['a', 'i', 'u', 'e', 'o']);
$string .= ($firstCharIsVowel ? 'an' : 'a') . ' ' . $deviceName; $string .= ($firstCharIsVowel ? 'an' : 'a') . ' ' . $deviceName;
@ -48,29 +49,71 @@ class ClientInfo implements Stringable {
if($hasModelName) if($hasModelName)
$string .= ' running '; $string .= ' running ';
$string .= $osInfo['name']; $string .= $this->osInfo['name'];
if(!empty($osInfo['version'])) if(!empty($this->osInfo['version']))
$string .= ' ' . $osInfo['version']; $string .= ' ' . $this->osInfo['version'];
if(!empty($osInfo['platform'])) if(!empty($this->osInfo['platform']))
$string .= ' (' . $osInfo['platform'] . ')'; $string .= ' (' . $this->osInfo['platform'] . ')';
} }
return $string; return $string;
} }
public function encode(): string {
$data = new stdClass;
$data->version = self::SERIALIZE_VERSION;
if($this->botInfo === true || is_array($this->botInfo))
$data->bot = $this->botInfo;
if($this->clientInfo !== null)
$data->client = $this->clientInfo;
if($this->osInfo !== null)
$data->os = $this->osInfo;
if($this->brandName !== '')
$data->vendor = $this->brandName;
if($this->modelName !== '')
$data->model = $this->modelName;
return json_encode($data);
}
public static function decode(string $encoded): self {
$data = json_decode($encoded, true);
$version = $data['version'] ?? 0;
if($version < 0 || $version > self::SERIALIZE_VERSION)
throw new RuntimeException('$data does not contain a valid version argument');
return new static(
$data['bot'] ?? null,
$data['client'] ?? null,
$data['os'] ?? null,
$data['vendor'] ?? '',
$data['model'] ?? ''
);
}
public static function parse(array|string $serverVarsOrUserAgent): self { public static function parse(array|string $serverVarsOrUserAgent): self {
static $dd = null;
$dd ??= new DeviceDetector();
if(is_string($serverVarsOrUserAgent)) { if(is_string($serverVarsOrUserAgent)) {
$userAgent = $serverVarsOrUserAgent; $dd->setUserAgent($serverVarsOrUserAgent);
$clientHints = null;
} else { } else {
$userAgent = array_key_exists('HTTP_USER_AGENT', $serverVarsOrUserAgent) $dd->setUserAgent(
? $serverVarsOrUserAgent['HTTP_USER_AGENT'] : ''; array_key_exists('HTTP_USER_AGENT', $serverVarsOrUserAgent)
$clientHints = ClientHints::factory($serverVarsOrUserAgent); ? $serverVarsOrUserAgent['HTTP_USER_AGENT'] : ''
);
$dd->setClientHints(ClientHints::factory($serverVarsOrUserAgent));
} }
$dd = new DeviceDetector($userAgent, $clientHints);
$dd->parse(); $dd->parse();
return new static($dd); return new static(
$dd->getBot(),
$dd->getClient(),
$dd->getOs(),
$dd->getBrandName(),
$dd->getModel()
);
} }
} }

View file

@ -13,13 +13,14 @@ class UserLoginAttempt {
private $attempt_country = 'XX'; private $attempt_country = 'XX';
private $attempt_created = null; private $attempt_created = null;
private $attempt_user_agent = ''; private $attempt_user_agent = '';
private $attempt_client_info = '';
private $user = null; private $user = null;
private $userLookedUp = false; private $userLookedUp = false;
public const TABLE = 'login_attempts'; public const TABLE = 'login_attempts';
private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
private const SELECT = '%1$s.`user_id`, %1$s.`attempt_success`, %1$s.`attempt_country`, %1$s.`attempt_user_agent`' private const SELECT = '%1$s.`user_id`, %1$s.`attempt_success`, %1$s.`attempt_country`, %1$s.`attempt_user_agent`, %1$s.`attempt_client_info`'
. ', INET6_NTOA(%1$s.`attempt_ip`) AS `attempt_ip`' . ', INET6_NTOA(%1$s.`attempt_ip`) AS `attempt_ip`'
. ', UNIX_TIMESTAMP(%1$s.`attempt_created`) AS `attempt_created`'; . ', UNIX_TIMESTAMP(%1$s.`attempt_created`) AS `attempt_created`';
@ -58,8 +59,8 @@ class UserLoginAttempt {
public function getUserAgent(): string { public function getUserAgent(): string {
return $this->attempt_user_agent; return $this->attempt_user_agent;
} }
public function getClientString(): string { public function getClientInfo(): ClientInfo {
return (string)ClientInfo::parse($this->attempt_user_agent); return ClientInfo::decode($this->attempt_client_info);
} }
public static function remaining(string $remoteAddr): int { public static function remaining(string $remoteAddr): int {
@ -78,18 +79,21 @@ class UserLoginAttempt {
string $countryCode, string $countryCode,
bool $success, bool $success,
?User $user = null, ?User $user = null,
string $userAgent = null string $userAgent = null,
?ClientInfo $clientInfo = null
): void { ): void {
$userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? ''; $userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? '';
$clientInfo ??= ClientInfo::parse($_SERVER);
$createLog = DB::prepare( $createLog = DB::prepare(
'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `attempt_success`, `attempt_ip`, `attempt_country`, `attempt_user_agent`)' 'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `attempt_success`, `attempt_ip`, `attempt_country`, `attempt_user_agent`, `attempt_client_info`)'
. ' VALUES (:user, :success, INET6_ATON(:ip), :country, :user_agent)' . ' VALUES (:user, :success, INET6_ATON(:ip), :country, :user_agent, :client_info)'
) ->bind('user', $user === null ? null : $user->getId()) // this null situation should never ever happen but better safe than sorry ! ) ->bind('user', $user === null ? null : $user->getId()) // this null situation should never ever happen but better safe than sorry !
->bind('success', $success ? 1 : 0) ->bind('success', $success ? 1 : 0)
->bind('ip', $remoteAddr) ->bind('ip', $remoteAddr)
->bind('country', $countryCode) ->bind('country', $countryCode)
->bind('user_agent', $userAgent) ->bind('user_agent', $userAgent)
->bind('client_info', $clientInfo->encode())
->execute(); ->execute();
} }

View file

@ -20,6 +20,7 @@ class UserSession {
private $session_ip = '::1'; private $session_ip = '::1';
private $session_ip_last = null; private $session_ip_last = null;
private $session_user_agent = ''; private $session_user_agent = '';
private $session_client_info = '';
private $session_country = 'XX'; private $session_country = 'XX';
private $session_expires = null; private $session_expires = null;
private $session_expires_bump = 1; private $session_expires_bump = 1;
@ -32,7 +33,7 @@ class UserSession {
public const TABLE = 'sessions'; public const TABLE = 'sessions';
private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE;
private const SELECT = '%1$s.`session_id`, %1$s.`user_id`, %1$s.`session_key`, %1$s.`session_user_agent`, %1$s.`session_country`, %1$s.`session_expires_bump`' private const SELECT = '%1$s.`session_id`, %1$s.`user_id`, %1$s.`session_key`, %1$s.`session_user_agent`, %1$s.`session_client_info`, %1$s.`session_country`, %1$s.`session_expires_bump`'
. ', INET6_NTOA(%1$s.`session_ip`) AS `session_ip`' . ', INET6_NTOA(%1$s.`session_ip`) AS `session_ip`'
. ', INET6_NTOA(%1$s.`session_ip_last`) AS `session_ip_last`' . ', INET6_NTOA(%1$s.`session_ip_last`) AS `session_ip_last`'
. ', UNIX_TIMESTAMP(%1$s.`session_created`) AS `session_created`' . ', UNIX_TIMESTAMP(%1$s.`session_created`) AS `session_created`'
@ -74,8 +75,8 @@ class UserSession {
public function getUserAgent(): string { public function getUserAgent(): string {
return $this->session_user_agent; return $this->session_user_agent;
} }
public function getClientString(): string { public function getClientInfo(): ClientInfo {
return (string)ClientInfo::parse($this->session_user_agent); return ClientInfo::decode($this->session_client_info);
} }
public function getCountry(): string { public function getCountry(): string {
@ -170,18 +171,26 @@ class UserSession {
->execute(); ->execute();
} }
public static function create(User $user, string $remoteAddr, string $countryCode, ?string $userAgent = null, ?string $token = null): self { public static function create(
User $user,
string $remoteAddr,
string $countryCode,
?string $userAgent = null,
?ClientInfo $clientInfo = null
): self {
$userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? ''; $userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? '';
$token = $token ?? self::generateToken(); $clientInfo ??= ClientInfo::parse($_SERVER);
$token = self::generateToken();
$sessionId = DB::prepare( $sessionId = DB::prepare(
'INSERT INTO `' . DB::PREFIX . self::TABLE . '`' 'INSERT INTO `' . DB::PREFIX . self::TABLE . '`'
. ' (`user_id`, `session_ip`, `session_country`, `session_user_agent`, `session_key`, `session_created`, `session_expires`)' . ' (`user_id`, `session_ip`, `session_country`, `session_user_agent`, `session_client_info`, `session_key`, `session_created`, `session_expires`)'
. ' VALUES (:user, INET6_ATON(:remote_addr), :country, :user_agent, :token, NOW(), NOW() + INTERVAL :expires SECOND)' . ' VALUES (:user, INET6_ATON(:remote_addr), :country, :user_agent, :client_info, :token, NOW(), NOW() + INTERVAL :expires SECOND)'
) ->bind('user', $user->getId()) ) ->bind('user', $user->getId())
->bind('remote_addr', $remoteAddr) ->bind('remote_addr', $remoteAddr)
->bind('country', $countryCode) ->bind('country', $countryCode)
->bind('user_agent', $userAgent) ->bind('user_agent', $userAgent)
->bind('client_info', $clientInfo->encode())
->bind('token', $token) ->bind('token', $token)
->bind('expires', self::LIFETIME) ->bind('expires', self::LIFETIME)
->executeGetId(); ->executeGetId();

View file

@ -73,7 +73,7 @@
<div class="flag flag--{{ session.country|lower }} settings__session__flag" title="{{ session.countryName }}">{{ session.country }}</div> <div class="flag flag--{{ session.country|lower }} settings__session__flag" title="{{ session.countryName }}">{{ session.country }}</div>
<div class="settings__session__description"> <div class="settings__session__description">
{{ session.clientString }} {{ session.clientInfo }}
</div> </div>
<form class="settings__session__actions" method="post" action="{{ url('settings-sessions') }}"> <form class="settings__session__actions" method="post" action="{{ url('settings-sessions') }}">
@ -160,7 +160,7 @@
<div class="flag flag--{{ attempt.country|lower }} settings__login-attempt__flag" title="{{ attempt.countryName }}">{{ attempt.country }}</div> <div class="flag flag--{{ attempt.country|lower }} settings__login-attempt__flag" title="{{ attempt.countryName }}">{{ attempt.country }}</div>
<div class="settings__login-attempt__description"> <div class="settings__login-attempt__description">
{{ attempt.clientString }} {{ attempt.clientInfo }}
</div> </div>
</div> </div>