From 6e3023a77241d61da03574eb7a98a50379250af9 Mon Sep 17 00:00:00 2001 From: flashwave Date: Sat, 22 Jul 2023 16:37:57 +0000 Subject: [PATCH] Rewrite login attempts log to use new database backend. --- ...07_21_121854_update_user_agent_storage.php | 4 +- public-legacy/auth/login.php | 15 +- public-legacy/auth/password.php | 5 +- public-legacy/auth/register.php | 5 +- public-legacy/auth/twofactor.php | 10 +- public-legacy/settings/logs.php | 9 +- src/Auth/LoginAttemptInfo.php | 71 ++++++++ src/Auth/LoginAttempts.php | 167 ++++++++++++++++++ src/ClientInfo.php | 13 +- src/MisuzuContext.php | 10 ++ src/Users/UserLoginAttempt.php | 136 -------------- templates/user/macros.twig | 6 +- 12 files changed, 289 insertions(+), 162 deletions(-) create mode 100644 src/Auth/LoginAttemptInfo.php create mode 100644 src/Auth/LoginAttempts.php delete mode 100644 src/Users/UserLoginAttempt.php diff --git a/database/2023_07_21_121854_update_user_agent_storage.php b/database/2023_07_21_121854_update_user_agent_storage.php index 2d41ecb..2f8e299 100644 --- a/database/2023_07_21_121854_update_user_agent_storage.php +++ b/database/2023_07_21_121854_update_user_agent_storage.php @@ -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(); } diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php index e12e24b..6617694 100644 --- a/public-legacy/auth/login.php +++ b/public-legacy/auth/login.php @@ -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); diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php index 3adb645..1bad0a7 100644 --- a/public-legacy/auth/password.php +++ b/public-legacy/auth/password.php @@ -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) { diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php index e832450..e25b69d 100644 --- a/public-legacy/auth/register.php +++ b/public-legacy/auth/register.php @@ -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!'; diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php index 0c9a0b5..80e11c5 100644 --- a/public-legacy/auth/twofactor.php +++ b/public-legacy/auth/twofactor.php @@ -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 { diff --git a/public-legacy/settings/logs.php b/public-legacy/settings/logs.php index 7ddda79..c9f4bd5 100644 --- a/public-legacy/settings/logs.php +++ b/public-legacy/settings/logs.php @@ -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, diff --git a/src/Auth/LoginAttemptInfo.php b/src/Auth/LoginAttemptInfo.php new file mode 100644 index 0000000..025c11c --- /dev/null +++ b/src/Auth/LoginAttemptInfo.php @@ -0,0 +1,71 @@ +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); + } +} diff --git a/src/Auth/LoginAttempts.php b/src/Auth/LoginAttempts.php new file mode 100644 index 0000000..b6ad00f --- /dev/null +++ b/src/Auth/LoginAttempts.php @@ -0,0 +1,167 @@ +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(); + } +} diff --git a/src/ClientInfo.php b/src/ClientInfo.php index fc0b132..d0cbdd3 100644 --- a/src/ClientInfo.php +++ b/src/ClientInfo.php @@ -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); + } } diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 52d22ce..34423a1 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -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(); diff --git a/src/Users/UserLoginAttempt.php b/src/Users/UserLoginAttempt.php deleted file mode 100644 index 893a4c6..0000000 --- a/src/Users/UserLoginAttempt.php +++ /dev/null @@ -1,136 +0,0 @@ -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); - } -} diff --git a/templates/user/macros.twig b/templates/user/macros.twig index 95d93c1..0340bbf 100644 --- a/templates/user/macros.twig +++ b/templates/user/macros.twig @@ -157,7 +157,7 @@