Rewrite login attempts log to use new database backend.

This commit is contained in:
flash 2023-07-22 16:37:57 +00:00
parent d0e3f6ce65
commit 6e3023a772
12 changed files with 289 additions and 162 deletions

View file

@ -23,7 +23,7 @@ final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
while($selectLoginAttempts->next()) {
$updateLoginAttempts->reset();
$userAgent = $selectLoginAttempts->getString(0);
$updateLoginAttempts->addParameter(1, ClientInfo::parse($userAgent)->encode());
$updateLoginAttempts->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
$updateLoginAttempts->addParameter(2, $userAgent);
$updateLoginAttempts->execute();
}
@ -33,7 +33,7 @@ final class UpdateUserAgentStorage_20230721_121854 implements IDbMigration {
while($selectSessions->next()) {
$updateSessions->reset();
$userAgent = $selectSessions->getString(0);
$updateSessions->addParameter(1, ClientInfo::parse($userAgent)->encode());
$updateSessions->addParameter(1, json_encode(ClientInfo::parse($userAgent)));
$updateSessions->addParameter(2, $userAgent);
$updateSessions->execute();
}

View file

@ -2,10 +2,8 @@
namespace Misuzu;
use RuntimeException;
use Misuzu\AuthToken;
use Misuzu\Users\User;
use Misuzu\Users\UserAuthSession;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserSession;
if(UserSession::hasCurrent()) {
@ -39,7 +37,10 @@ if(!empty($_GET['resolve'])) {
$notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
$siteIsPrivate = $cfg->getBoolean('private.enable');
if($siteIsPrivate) {
@ -90,7 +91,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
try {
$userInfo = User::byUsernameOrEMailAddress($_POST['login']['username']);
} catch(RuntimeException $ex) {
UserLoginAttempt::create($ipAddress, $countryCode, false);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest());
$notices[] = $loginFailedError;
break;
}
@ -101,7 +102,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
}
if($userInfo->isDeleted() || !$userInfo->checkPassword($_POST['login']['password'])) {
UserLoginAttempt::create($ipAddress, $countryCode, false, $userInfo);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$notices[] = $loginFailedError;
break;
}
@ -111,7 +112,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userInfo->getId(), $loginPermVal)) {
$notices[] = "Login succeeded, but you're not allowed to browse the site right now.";
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
break;
}
@ -122,7 +123,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) {
return;
}
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
try {
$sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode);

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserRecoveryToken;
use Misuzu\Users\UserSession;
@ -30,7 +29,9 @@ $notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$siteIsPrivate = $cfg->getBoolean('private.enable');
$canResetPassword = $siteIsPrivate ? $cfg->getBoolean('private.allow_password_reset', true) : true;
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
while($canResetPassword) {
if(!empty($reset) && $userId > 0) {

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserRole;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserWarning;
@ -17,9 +16,11 @@ $register = !empty($_POST['register']) && is_array($_POST['register']) ? $_POST[
$notices = [];
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$remainingAttempts = UserLoginAttempt::remaining($_SERVER['REMOTE_ADDR']);
$restricted = UserWarning::countByRemoteAddress($ipAddress) > 0 ? 'ban' : '';
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
while(!$restricted && !empty($register)) {
if(!CSRF::validateRequest()) {
$notices[] = 'Was unable to verify the request, please try again!';

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use RuntimeException;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
use Misuzu\Users\UserSession;
use Misuzu\Users\UserAuthSession;
@ -14,9 +13,12 @@ if(UserSession::hasCurrent()) {
$ipAddress = $_SERVER['REMOTE_ADDR'];
$countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : [];
$notices = [];
$remainingAttempts = UserLoginAttempt::remaining($ipAddress);
$loginAttempts = $msz->getLoginAttempts();
$remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress);
try {
$tokenInfo = UserAuthSession::byToken(
@ -65,11 +67,11 @@ while(!empty($twofactor)) {
$remainingAttempts - 1,
$remainingAttempts === 2 ? '' : 's'
);
UserLoginAttempt::create($ipAddress, $countryCode, false, $userInfo);
$loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
break;
}
UserLoginAttempt::create($ipAddress, $countryCode, true, $userInfo);
$loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo);
$tokenInfo->delete();
try {

View file

@ -3,7 +3,6 @@ namespace Misuzu;
use Misuzu\Pagination;
use Misuzu\Users\User;
use Misuzu\Users\UserLoginAttempt;
$currentUser = User::getCurrent();
@ -12,15 +11,17 @@ if($currentUser === null) {
return;
}
$loginAttempts = $msz->getLoginAttempts();
$auditLog = $msz->getAuditLog();
$loginHistoryPagination = new Pagination(UserLoginAttempt::countAll($currentUser), 15, 'hp');
$accountLogPagination = new Pagination($auditLog->countLogs(userInfo: $currentUser), 15, 'ap');
$loginHistoryPagination = new Pagination($loginAttempts->countAttempts(userInfo: $currentUser), 5, 'hp');
$accountLogPagination = new Pagination($auditLog->countLogs(userInfo: $currentUser), 10, 'ap');
$loginHistory = $loginAttempts->getAttempts(userInfo: $currentUser, pagination: $loginHistoryPagination);
$auditLogs = $auditLog->getLogs(userInfo: $currentUser, pagination: $accountLogPagination);
Template::render('settings.logs', [
'login_history_list' => UserLoginAttempt::all($loginHistoryPagination, $currentUser),
'login_history_list' => $loginHistory,
'login_history_pagination' => $loginHistoryPagination,
'account_log_list' => $auditLogs,
'account_log_pagination' => $accountLogPagination,

View file

@ -0,0 +1,71 @@
<?php
namespace Misuzu\Auth;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
class LoginAttemptInfo {
private ?string $userId;
private bool $success;
private string $remoteAddr;
private string $countryCode;
private int $created;
private string $userAgent;
private string $clientInfo;
public function __construct(IDbResult $result) {
$this->userId = $result->isNull(0) ? null : (string)$result->getInteger(0);
$this->success = $result->getInteger(1) !== 0;
$this->remoteAddr = $result->getString(2);
$this->countryCode = $result->getString(3);
$this->created = $result->getInteger(4);
$this->userAgent = $result->getString(5);
$this->clientInfo = $result->getString(6);
}
public function hasUserId(): bool {
return $this->userId !== null;
}
public function getUserId(): string {
return $this->userId;
}
public function isSuccess(): bool {
return $this->success;
}
public function getRemoteAddressRaw(): string {
return $this->remoteAddr;
}
public function getRemoteAddress(): IPAddress {
return IPAddress::parse($this->remoteAddr);
}
public function getCountryCode(): string {
return $this->countryCode;
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
public function getUserAgentString(): string {
return $this->userAgent;
}
public function getClientInfoRaw(): string {
return $this->clientInfo;
}
public function getClientInfo(): ClientInfo {
return ClientInfo::decode($this->clientInfo);
}
}

167
src/Auth/LoginAttempts.php Normal file
View file

@ -0,0 +1,167 @@
<?php
namespace Misuzu\Auth;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Misuzu\ClientInfo;
use Misuzu\Pagination;
use Misuzu\Users\User;
class LoginAttempts {
public const REMAINING_MAX = 5;
public const REMAINING_WINDOW = 60 * 60;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function countAttempts(
?bool $success = null,
User|string|null $userInfo = null,
IPAddress|string|null $remoteAddr = null,
TimeSpan|int|null $timeRange = null
): int {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($timeRange instanceof TimeSpan)
$timeRange = (int)$timeRange->totalSeconds();
$hasSuccess = $success !== null;
$hasUserInfo = $userInfo !== null;
$hasRemoteAddr = $remoteAddr !== null;
$hasTimeRange = $timeRange !== null;
$args = 0;
$query = 'SELECT COUNT(*) FROM msz_login_attempts';
if($hasSuccess) {
++$args;
$query .= sprintf(' WHERE attempt_success %s 0', $success ? '<>' : '=');
}
if($hasUserInfo)
$query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasRemoteAddr)
$query .= sprintf(' %s attempt_ip = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
if($hasTimeRange)
$query .= sprintf(' %s attempt_created > NOW() - INTERVAL ? SECOND', ++$args > 1 ? 'AND' : 'WHERE');
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
if($hasRemoteAddr)
$stmt->addParameter(++$args, $remoteAddr);
if($hasTimeRange)
$stmt->addParameter(++$args, $timeRange);
$stmt->execute();
$result = $stmt->getResult();
$count = 0;
if($result->next())
$count = $result->getInteger(0);
return $count;
}
public function countRemainingAttempts(IPAddress|string $remoteAddr): int {
return self::REMAINING_MAX - $this->countAttempts(
success: false,
timeRange: self::REMAINING_WINDOW,
remoteAddr: $remoteAddr
);
}
public function getAttempts(
?bool $success = null,
User|string|null $userInfo = null,
IPAddress|string|null $remoteAddr = null,
TimeSpan|int|null $timeRange = null,
?Pagination $pagination = null
): array {
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($timeRange instanceof TimeSpan)
$timeRange = (int)$timeRange->totalSeconds();
$hasSuccess = $success !== null;
$hasUserInfo = $userInfo !== null;
$hasRemoteAddr = $remoteAddr !== null;
$hasTimeRange = $timeRange !== null;
$hasPagination = $pagination !== null;
$args = 0;
$query = 'SELECT user_id, attempt_success, INET6_NTOA(attempt_ip), attempt_country, UNIX_TIMESTAMP(attempt_created), attempt_user_agent, attempt_client_info FROM msz_login_attempts';
if($hasSuccess) {
++$args;
$query .= sprintf(' WHERE attempt_success %s 0', $success ? '<>' : '=');
}
if($hasUserInfo)
$query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE');
if($hasRemoteAddr)
$query .= sprintf(' %s attempt_ip = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
if($hasTimeRange)
$query .= sprintf(' %s attempt_created > NOW() - INTERVAL ? SECOND', ++$args > 1 ? 'AND' : 'WHERE');
$query .= ' ORDER BY attempt_created DESC';
if($hasPagination)
$query .= ' LIMIT ? OFFSET ?';
$args = 0;
$stmt = $this->cache->get($query);
if($hasUserInfo)
$stmt->addParameter(++$args, $userInfo);
if($hasRemoteAddr)
$stmt->addParameter(++$args, $remoteAddr);
if($hasTimeRange)
$stmt->addParameter(++$args, $timeRange);
if($hasPagination) {
$stmt->addParameter(++$args, $pagination->getRange());
$stmt->addParameter(++$args, $pagination->getOffset());
}
$stmt->execute();
$result = $stmt->getResult();
$attempts = [];
while($result->next())
$attempts[] = new LoginAttemptInfo($result);
return $attempts;
}
public function recordAttempt(
bool $success,
IPAddress|string $remoteAddr,
string $countryCode,
string $userAgentString,
?ClientInfo $clientInfo = null,
User|string|null $userInfo = null
): void {
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
if($userInfo instanceof User)
$userInfo = (string)$userInfo->getId();
$hasUserInfo = $userInfo !== null;
$clientInfo = json_encode($clientInfo ?? ClientInfo::parse($userAgentString));
$stmt = $this->cache->get('INSERT INTO msz_login_attempts (user_id, attempt_success, attempt_ip, attempt_country, attempt_user_agent, attempt_client_info) VALUES (?, ?, INET6_ATON(?), ?, ?, ?)');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $success ? 1 : 0);
$stmt->addParameter(3, $remoteAddr);
$stmt->addParameter(4, $countryCode);
$stmt->addParameter(5, $userAgentString);
$stmt->addParameter(6, $clientInfo);
$stmt->execute();
}
}

View file

@ -2,12 +2,13 @@
namespace Misuzu;
use stdClass;
use JsonSerializable;
use RuntimeException;
use Stringable;
use DeviceDetector\ClientHints;
use DeviceDetector\DeviceDetector;
class ClientInfo implements Stringable {
class ClientInfo implements Stringable, JsonSerializable {
private const SERIALIZE_VERSION = 1;
public function __construct(
@ -60,6 +61,10 @@ class ClientInfo implements Stringable {
}
public function encode(): string {
return json_encode($this);
}
public function jsonSerialize(): mixed {
$data = new stdClass;
$data->version = self::SERIALIZE_VERSION;
@ -74,7 +79,7 @@ class ClientInfo implements Stringable {
if($this->modelName !== '')
$data->model = $this->modelName;
return json_encode($data);
return $data;
}
public static function decode(string $encoded): self {
@ -116,4 +121,8 @@ class ClientInfo implements Stringable {
$dd->getModel()
);
}
public static function fromRequest(): self {
return self::parse($_SERVER);
}
}

View file

@ -2,6 +2,7 @@
namespace Misuzu;
use Misuzu\Template;
use Misuzu\Auth\LoginAttempts;
use Misuzu\AuditLog\AuditLog;
use Misuzu\Changelog\Changelog;
use Misuzu\Comments\Comments;
@ -21,6 +22,9 @@ use Index\Routing\Router;
// this class should function as the root for everything going forward
// no more magical static classes that are just kind of assumed to exist
// it currently looks Pretty Messy, but most everything else will be holding instances of other classes
// instances of certain classes should only be made as needed,
// dunno if i want null checks some maybe some kind of init func should be called first like is the case
// with the http shit
class MisuzuContext {
private IDbConnection $dbConn;
private IConfig $config;
@ -30,6 +34,7 @@ class MisuzuContext {
private Changelog $changelog;
private News $news;
private Comments $comments;
private LoginAttempts $loginAttempts;
public function __construct(IDbConnection $dbConn, IConfig $config) {
$this->dbConn = $dbConn;
@ -39,6 +44,7 @@ class MisuzuContext {
$this->changelog = new Changelog($this->dbConn);
$this->news = new News($this->dbConn);
$this->comments = new Comments($this->dbConn);
$this->loginAttempts = new LoginAttempts($this->dbConn);
}
public function getDbConn(): IDbConnection {
@ -86,6 +92,10 @@ class MisuzuContext {
return $this->auditLog;
}
public function getLoginAttempts(): LoginAttempts {
return $this->loginAttempts;
}
public function createAuditLog(string $action, array $params = [], User|string|null $userInfo = null): void {
if($userInfo === null && User::hasCurrent())
$userInfo = User::getCurrent();

View file

@ -1,136 +0,0 @@
<?php
namespace Misuzu\Users;
use RuntimeException;
use Misuzu\ClientInfo;
use Misuzu\DB;
use Misuzu\Pagination;
class UserLoginAttempt {
// Database fields
private $user_id = null;
private $attempt_success = false;
private $attempt_ip = '::1';
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`, %1$s.`attempt_client_info`'
. ', INET6_NTOA(%1$s.`attempt_ip`) AS `attempt_ip`'
. ', UNIX_TIMESTAMP(%1$s.`attempt_created`) AS `attempt_created`';
public function getUserId(): int {
return $this->user_id < 1 ? -1 : $this->user_id;
}
public function getUser(): ?User {
if(!$this->userLookedUp && ($userId = $this->getUserId()) > 0) {
$this->userLookedUp = true;
try {
$this->user = User::byId($userId);
} catch(RuntimeException $ex) {}
}
return $this->user;
}
public function isSuccess(): bool {
return boolval($this->attempt_success);
}
public function getRemoteAddress(): string {
return $this->attempt_ip;
}
public function getCountry(): string {
return $this->attempt_country;
}
public function getCountryName(): string {
return get_country_name($this->getCountry());
}
public function getCreatedTime(): int {
return $this->attempt_created === null ? -1 : $this->attempt_created;
}
public function getUserAgent(): string {
return $this->attempt_user_agent;
}
public function getClientInfo(): ClientInfo {
return ClientInfo::decode($this->attempt_client_info);
}
public static function remaining(string $remoteAddr): int {
return (int)DB::prepare(
'SELECT 5 - COUNT(*)'
. ' FROM `' . DB::PREFIX . self::TABLE . '`'
. ' WHERE `attempt_success` = 0'
. ' AND `attempt_created` > NOW() - INTERVAL 1 HOUR'
. ' AND `attempt_ip` = INET6_ATON(:remote_ip)'
) ->bind('remote_ip', $remoteAddr)
->fetchColumn();
}
public static function create(
string $remoteAddr,
string $countryCode,
bool $success,
?User $user = 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`, `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();
}
private static function countQueryBase(): string {
return sprintf(self::QUERY_SELECT, 'COUNT(*)');
}
public static function countAll(?User $user = null): int {
$getCount = DB::prepare(
self::countQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')
);
if($user !== null)
$getCount->bind('user', $user->getId());
return (int)$getCount->fetchColumn();
}
private static function byQueryBase(): string {
return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
}
public static function all(?Pagination $pagination = null, ?User $user = null): array {
$attemptsQuery = self::byQueryBase()
. ($user === null ? '' : ' WHERE `user_id` = :user')
. ' ORDER BY `attempt_created` DESC';
if($pagination !== null)
$attemptsQuery .= ' LIMIT :range OFFSET :offset';
$getAttempts = DB::prepare($attemptsQuery);
if($user !== null)
$getAttempts->bind('user', $user->getId());
if($pagination !== null)
$getAttempts->bind('range', $pagination->getRange())
->bind('offset', $pagination->getOffset());
return $getAttempts->fetchObjects(self::class);
}
}

View file

@ -157,7 +157,7 @@
<div class="settings__login-attempt{% if not attempt.success %} settings__login-attempt--failed{% endif %}">
<div class="settings__login-attempt__container">
<div class="settings__login-attempt__important">
<div class="flag flag--{{ attempt.country|lower }} settings__login-attempt__flag" title="{{ attempt.countryName }}">{{ attempt.country }}</div>
<div class="flag flag--{{ attempt.countryCode|lower }} settings__login-attempt__flag" title="{{ attempt.countryCode|country_name }}">{{ attempt.countryCode }}</div>
<div class="settings__login-attempt__description">
{{ attempt.clientInfo }}
@ -170,7 +170,7 @@
IP Address
</div>
<div class="settings__login-attempt__detail__value">
{{ attempt.remoteAddress }}
{{ attempt.remoteAddressRaw }}
</div>
</div>
@ -197,7 +197,7 @@
User Agent
</div>
<div class="settings__login-attempt__detail__value">
{{ attempt.userAgent is empty ? 'None' : attempt.userAgent }}
{{ attempt.userAgentString }}
</div>
</div>
</div>