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:
Pachira 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;
use Misuzu\Users\User;
use Misuzu\Users\UserNotFoundException;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserSessionNotFoundException;
require_once __DIR__ . '/../misuzu.php';

View file

@ -1,44 +1,45 @@
<?php
namespace Misuzu;
use InvalidArgumentException;
use stdClass;
use RuntimeException;
use Stringable;
use DeviceDetector\ClientHints;
use DeviceDetector\DeviceDetector;
class ClientInfo implements Stringable {
private DeviceDetector $dd;
private const SERIALIZE_VERSION = 1;
public function __construct(DeviceDetector $dd) {
$this->dd = $dd;
}
public function __construct(
private array|bool|null $botInfo,
private ?array $clientInfo,
private ?array $osInfo,
private string $brandName,
private string $modelName
) {}
public function __toString(): string {
if($this->dd->isBot()) {
$botInfo = $this->dd->getBot();
return $botInfo['name'] ?? 'an unknown bot';
if($this->botInfo === true || is_array($this->botInfo)) {
if($this->botInfo === true)
return 'a bot';
return $this->botInfo['name'] ?? 'an unknown bot';
}
$clientInfo = $this->dd->getClient();
if(empty($clientInfo['name']))
if(empty($this->clientInfo['name']))
return 'an unknown browser';
$string = $clientInfo['name'];
if(!empty($clientInfo['version']))
$string .= ' ' . $clientInfo['version'];
$string = $this->clientInfo['name'];
if(!empty($this->clientInfo['version']))
$string .= ' ' . $this->clientInfo['version'];
$osInfo = $this->dd->getOs();
$hasOsInfo = !empty($osInfo['name']);
$brandName = $this->dd->getBrandName();
$modelName = $this->dd->getModel();
$hasModelName = !empty($modelName);
$hasOsInfo = !empty($this->osInfo['name']);
$hasModelName = !empty($this->modelName);
if($hasOsInfo || $hasModelName)
$string .= ' on ';
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
$firstCharIsVowel = in_array(strtolower($deviceName[0]), ['a', 'i', 'u', 'e', 'o']);
$string .= ($firstCharIsVowel ? 'an' : 'a') . ' ' . $deviceName;
@ -48,29 +49,71 @@ class ClientInfo implements Stringable {
if($hasModelName)
$string .= ' running ';
$string .= $osInfo['name'];
if(!empty($osInfo['version']))
$string .= ' ' . $osInfo['version'];
if(!empty($osInfo['platform']))
$string .= ' (' . $osInfo['platform'] . ')';
$string .= $this->osInfo['name'];
if(!empty($this->osInfo['version']))
$string .= ' ' . $this->osInfo['version'];
if(!empty($this->osInfo['platform']))
$string .= ' (' . $this->osInfo['platform'] . ')';
}
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 {
static $dd = null;
$dd ??= new DeviceDetector();
if(is_string($serverVarsOrUserAgent)) {
$userAgent = $serverVarsOrUserAgent;
$clientHints = null;
$dd->setUserAgent($serverVarsOrUserAgent);
} else {
$userAgent = array_key_exists('HTTP_USER_AGENT', $serverVarsOrUserAgent)
? $serverVarsOrUserAgent['HTTP_USER_AGENT'] : '';
$clientHints = ClientHints::factory($serverVarsOrUserAgent);
$dd->setUserAgent(
array_key_exists('HTTP_USER_AGENT', $serverVarsOrUserAgent)
? $serverVarsOrUserAgent['HTTP_USER_AGENT'] : ''
);
$dd->setClientHints(ClientHints::factory($serverVarsOrUserAgent));
}
$dd = new DeviceDetector($userAgent, $clientHints);
$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_created = null;
private $attempt_user_agent = '';
private $attempt_client_info = '';
private $user = null;
private $userLookedUp = false;
public const TABLE = 'login_attempts';
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`'
. ', UNIX_TIMESTAMP(%1$s.`attempt_created`) AS `attempt_created`';
@ -58,8 +59,8 @@ class UserLoginAttempt {
public function getUserAgent(): string {
return $this->attempt_user_agent;
}
public function getClientString(): string {
return (string)ClientInfo::parse($this->attempt_user_agent);
public function getClientInfo(): ClientInfo {
return ClientInfo::decode($this->attempt_client_info);
}
public static function remaining(string $remoteAddr): int {
@ -78,18 +79,21 @@ class UserLoginAttempt {
string $countryCode,
bool $success,
?User $user = null,
string $userAgent = null
string $userAgent = null,
?ClientInfo $clientInfo = null
): void {
$userAgent = $userAgent ?? filter_input(INPUT_SERVER, 'HTTP_USER_AGENT') ?? '';
$clientInfo ??= ClientInfo::parse($_SERVER);
$createLog = DB::prepare(
'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `attempt_success`, `attempt_ip`, `attempt_country`, `attempt_user_agent`)'
. ' VALUES (:user, :success, INET6_ATON(:ip), :country, :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, :client_info)'
) ->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('ip', $remoteAddr)
->bind('country', $countryCode)
->bind('user_agent', $userAgent)
->bind('client_info', $clientInfo->encode())
->execute();
}

View file

@ -20,6 +20,7 @@ class UserSession {
private $session_ip = '::1';
private $session_ip_last = null;
private $session_user_agent = '';
private $session_client_info = '';
private $session_country = 'XX';
private $session_expires = null;
private $session_expires_bump = 1;
@ -32,7 +33,7 @@ class UserSession {
public const TABLE = 'sessions';
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_last`) AS `session_ip_last`'
. ', UNIX_TIMESTAMP(%1$s.`session_created`) AS `session_created`'
@ -74,8 +75,8 @@ class UserSession {
public function getUserAgent(): string {
return $this->session_user_agent;
}
public function getClientString(): string {
return (string)ClientInfo::parse($this->session_user_agent);
public function getClientInfo(): ClientInfo {
return ClientInfo::decode($this->session_client_info);
}
public function getCountry(): string {
@ -170,18 +171,26 @@ class UserSession {
->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') ?? '';
$token = $token ?? self::generateToken();
$clientInfo ??= ClientInfo::parse($_SERVER);
$token = self::generateToken();
$sessionId = DB::prepare(
'INSERT INTO `' . DB::PREFIX . self::TABLE . '`'
. ' (`user_id`, `session_ip`, `session_country`, `session_user_agent`, `session_key`, `session_created`, `session_expires`)'
. ' VALUES (:user, INET6_ATON(:remote_addr), :country, :user_agent, :token, NOW(), NOW() + INTERVAL :expires SECOND)'
. ' (`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, :client_info, :token, NOW(), NOW() + INTERVAL :expires SECOND)'
) ->bind('user', $user->getId())
->bind('remote_addr', $remoteAddr)
->bind('country', $countryCode)
->bind('user_agent', $userAgent)
->bind('client_info', $clientInfo->encode())
->bind('token', $token)
->bind('expires', self::LIFETIME)
->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="settings__session__description">
{{ session.clientString }}
{{ session.clientInfo }}
</div>
<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="settings__login-attempt__description">
{{ attempt.clientString }}
{{ attempt.clientInfo }}
</div>
</div>