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:
parent
ebac064c59
commit
14c5635b4f
6 changed files with 156 additions and 47 deletions
51
database/2023_07_21_121854_update_user_agent_storage.php
Normal file
51
database/2023_07_21_121854_update_user_agent_storage.php
Normal 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
|
||||
');
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in a new issue