diff --git a/database/2023_07_21_121854_update_user_agent_storage.php b/database/2023_07_21_121854_update_user_agent_storage.php new file mode 100644 index 0000000..2d41ecb --- /dev/null +++ b/database/2023_07_21_121854_update_user_agent_storage.php @@ -0,0 +1,51 @@ +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 + '); + } +} diff --git a/public/index.php b/public/index.php index a9182f7..7070456 100644 --- a/public/index.php +++ b/public/index.php @@ -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'; diff --git a/src/ClientInfo.php b/src/ClientInfo.php index dca7fcc..3ba7688 100644 --- a/src/ClientInfo.php +++ b/src/ClientInfo.php @@ -1,44 +1,45 @@ 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() + ); } } diff --git a/src/Users/UserLoginAttempt.php b/src/Users/UserLoginAttempt.php index b67666d..ba55399 100644 --- a/src/Users/UserLoginAttempt.php +++ b/src/Users/UserLoginAttempt.php @@ -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(); } diff --git a/src/Users/UserSession.php b/src/Users/UserSession.php index f24c576..7a83063 100644 --- a/src/Users/UserSession.php +++ b/src/Users/UserSession.php @@ -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(); diff --git a/templates/user/macros.twig b/templates/user/macros.twig index a338a04..95d93c1 100644 --- a/templates/user/macros.twig +++ b/templates/user/macros.twig @@ -73,7 +73,7 @@
{{ session.country }}
- {{ session.clientString }} + {{ session.clientInfo }}
@@ -160,7 +160,7 @@
{{ attempt.country }}
- {{ attempt.clientString }} + {{ attempt.clientInfo }}