From 3148da44033b4c355ddc677cc16642c5dfffa1f8 Mon Sep 17 00:00:00 2001 From: flashwave Date: Fri, 28 Jul 2023 20:06:12 +0000 Subject: [PATCH] Rewrote Sessions backend. --- public-legacy/auth/login.php | 18 +- public-legacy/auth/logout.php | 7 +- public-legacy/auth/password.php | 3 +- public-legacy/auth/register.php | 3 +- public-legacy/auth/twofactor.php | 17 +- public-legacy/forum/post.php | 3 +- public-legacy/forum/topic.php | 3 +- public-legacy/profile.php | 1 - public-legacy/settings/account.php | 3 +- public-legacy/settings/data.php | 3 +- public-legacy/settings/index.php | 4 +- public-legacy/settings/sessions.php | 66 +++---- public/index.php | 28 +-- src/Auth/SessionInfo.php | 121 ++++++++++++ src/Auth/Sessions.php | 285 ++++++++++++++++++++++++++++ src/AuthToken.php | 12 +- src/Http/Handlers/ForumHandler.php | 5 +- src/Http/Handlers/HomeHandler.php | 3 +- src/MisuzuContext.php | 9 +- src/SharpChat/SharpChatRoutes.php | 44 +++-- src/Users/UserSession.php | 255 ------------------------- templates/settings/sessions.twig | 2 +- templates/user/macros.twig | 18 +- 23 files changed, 539 insertions(+), 374 deletions(-) create mode 100644 src/Auth/SessionInfo.php create mode 100644 src/Auth/Sessions.php delete mode 100644 src/Users/UserSession.php diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php index 2215358..b801533 100644 --- a/public-legacy/auth/login.php +++ b/public-legacy/auth/login.php @@ -3,9 +3,8 @@ namespace Misuzu; use RuntimeException; use Misuzu\Users\User; -use Misuzu\Users\UserSession; -if(UserSession::hasCurrent()) { +if(User::hasCurrent()) { url_redirect('index'); return; } @@ -38,7 +37,9 @@ $ipAddress = $_SERVER['REMOTE_ADDR']; $countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX'; $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; +$sessions = $msz->getSessions(); $loginAttempts = $msz->getLoginAttempts(); + $remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress); $siteIsPrivate = $cfg->getBoolean('private.enable'); @@ -80,6 +81,8 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { break; } + $clientInfo = ClientInfo::fromRequest(); + $attemptsRemainingError = sprintf( "%d attempt%s remaining", $remainingAttempts - 1, @@ -90,7 +93,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { try { $userInfo = User::byUsernameOrEMailAddress($_POST['login']['username']); } catch(RuntimeException $ex) { - $loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest()); + $loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo); $notices[] = $loginFailedError; break; } @@ -101,7 +104,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { } if($userInfo->isDeleted() || !$userInfo->checkPassword($_POST['login']['password'])) { - $loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo); + $loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); $notices[] = $loginFailedError; break; } @@ -111,7 +114,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."; - $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo); + $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); break; } @@ -123,11 +126,10 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { return; } - $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo); + $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); try { - $sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode); - $sessionInfo->setCurrent(); + $sessionInfo = $sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo); } catch(RuntimeException $ex) { $notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!"; break; diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php index 57065f2..41d8d63 100644 --- a/public-legacy/auth/logout.php +++ b/public-legacy/auth/logout.php @@ -2,18 +2,15 @@ namespace Misuzu; use Misuzu\Users\User; -use Misuzu\Users\UserSession; -if(!UserSession::hasCurrent()) { +if(!User::hasCurrent()) { url_redirect('index'); return; } if(CSRF::validateRequest()) { + $msz->getSessions()->deleteSessions(sessionTokens: $authToken->getSessionToken()); AuthToken::nukeCookie(); - UserSession::getCurrent()->delete(); - UserSession::unsetCurrent(); - User::unsetCurrent(); url_redirect('index'); return; } diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php index 92d1a8d..2c746fa 100644 --- a/public-legacy/auth/password.php +++ b/public-legacy/auth/password.php @@ -3,9 +3,8 @@ namespace Misuzu; use RuntimeException; use Misuzu\Users\User; -use Misuzu\Users\UserSession; -if(UserSession::hasCurrent()) { +if(User::hasCurrent()) { url_redirect('settings-account'); return; } diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php index 9015c3b..525c2e4 100644 --- a/public-legacy/auth/register.php +++ b/public-legacy/auth/register.php @@ -3,9 +3,8 @@ namespace Misuzu; use RuntimeException; use Misuzu\Users\User; -use Misuzu\Users\UserSession; -if(UserSession::hasCurrent()) { +if(User::hasCurrent()) { url_redirect('index'); return; } diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php index c930b44..f45e72c 100644 --- a/public-legacy/auth/twofactor.php +++ b/public-legacy/auth/twofactor.php @@ -3,9 +3,8 @@ namespace Misuzu; use RuntimeException; use Misuzu\Users\User; -use Misuzu\Users\UserSession; -if(UserSession::hasCurrent()) { +if(User::hasCurrent()) { url_redirect('index'); return; } @@ -16,8 +15,10 @@ $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; $twofactor = !empty($_POST['twofactor']) && is_array($_POST['twofactor']) ? $_POST['twofactor'] : []; $notices = []; +$sessions = $msz->getSessions(); $tfaSessions = $msz->getTFASessions(); $loginAttempts = $msz->getLoginAttempts(); + $remainingAttempts = $loginAttempts->countRemainingAttempts($ipAddress); $tokenString = !empty($_GET['token']) && is_string($_GET['token']) ? $_GET['token'] : ( @@ -58,22 +59,23 @@ while(!empty($twofactor)) { break; } + $clientInfo = ClientInfo::fromRequest(); + if(!in_array($twofactor['code'], $userInfo->getValidTOTPTokens())) { $notices[] = sprintf( "Invalid two factor code, %d attempt%s remaining", $remainingAttempts - 1, $remainingAttempts === 2 ? '' : 's' ); - $loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo); + $loginAttempts->recordAttempt(false, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); break; } - $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, ClientInfo::fromRequest(), $userInfo); + $loginAttempts->recordAttempt(true, $ipAddress, $countryCode, $userAgent, $clientInfo, $userInfo); $tfaSessions->deleteToken($tokenString); try { - $sessionInfo = UserSession::create($userInfo, $ipAddress, $countryCode); - $sessionInfo->setCurrent(); + $sessionInfo = $sessions->createSession($userInfo, $ipAddress, $countryCode, $userAgent, $clientInfo); } catch(RuntimeException $ex) { $notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!"; break; @@ -82,9 +84,8 @@ while(!empty($twofactor)) { $authToken = AuthToken::create($userInfo, $sessionInfo); $authToken->applyCookie($sessionInfo->getExpiresTime()); - if(!is_local_url($redirect)) { + if(!is_local_url($redirect)) $redirect = url('index'); - } redirect($redirect); return; diff --git a/public-legacy/forum/post.php b/public-legacy/forum/post.php index 32e983f..a69f3b1 100644 --- a/public-legacy/forum/post.php +++ b/public-legacy/forum/post.php @@ -2,7 +2,6 @@ namespace Misuzu; use Misuzu\Users\User; -use Misuzu\Users\UserSession; $postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0; $postMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : ''; @@ -10,7 +9,7 @@ $submissionConfirmed = !empty($_GET['confirm']) && is_string($_GET['confirm']) & $postRequestVerified = CSRF::validateRequest(); -if(!empty($postMode) && !UserSession::hasCurrent()) { +if(!empty($postMode) && !User::hasCurrent()) { echo render_info('You must be logged in to manage posts.', 401); return; } diff --git a/public-legacy/forum/topic.php b/public-legacy/forum/topic.php index 6d343cd..eede247 100644 --- a/public-legacy/forum/topic.php +++ b/public-legacy/forum/topic.php @@ -2,7 +2,6 @@ namespace Misuzu; use Misuzu\Users\User; -use Misuzu\Users\UserSession; $postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0; $topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0; @@ -78,7 +77,7 @@ if(in_array($moderationMode, $validModerationModes, true)) { return; } - if(!UserSession::hasCurrent()) { + if(!User::hasCurrent()) { echo render_info('You must be logged in to manage posts.', 401); return; } diff --git a/public-legacy/profile.php b/public-legacy/profile.php index 873c3ba..92ea2e9 100644 --- a/public-legacy/profile.php +++ b/public-legacy/profile.php @@ -7,7 +7,6 @@ use Index\ByteFormat; use Misuzu\Parsers\Parser; use Misuzu\Profile\ProfileFields; use Misuzu\Users\User; -use Misuzu\Users\UserSession; use Misuzu\Users\Assets\UserBackgroundAsset; $userId = !empty($_GET['u']) && is_string($_GET['u']) ? trim($_GET['u']) : 0; diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php index 199a943..c54f6f8 100644 --- a/public-legacy/settings/account.php +++ b/public-legacy/settings/account.php @@ -3,11 +3,10 @@ namespace Misuzu; use RuntimeException; use Misuzu\Users\User; -use Misuzu\Users\UserSession; use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; -if(!UserSession::hasCurrent()) { +if(!User::hasCurrent()) { echo render_error(401); return; } diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php index 7aa5d29..f0fce52 100644 --- a/public-legacy/settings/data.php +++ b/public-legacy/settings/data.php @@ -5,9 +5,8 @@ use ZipArchive; use Index\XString; use Index\IO\FileStream; use Misuzu\Users\User; -use Misuzu\Users\UserSession; -if(!UserSession::hasCurrent()) { +if(!User::hasCurrent()) { echo render_error(401); return; } diff --git a/public-legacy/settings/index.php b/public-legacy/settings/index.php index 266811e..0e73dc0 100644 --- a/public-legacy/settings/index.php +++ b/public-legacy/settings/index.php @@ -1,9 +1,9 @@ getSessions(); $currentUser = User::getCurrent(); -$currentSession = UserSession::getCurrent(); -$currentUserId = $currentUser->getId(); -$sessionActive = $currentSession->getId();; +$activeSessionToken = $authToken->getSessionToken(); -if(!empty($_POST['session']) && CSRF::validateRequest()) { - $currentSessionKilled = false; +while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) { + $sessionId = (string)filter_input(INPUT_POST, 'session'); + $activeSessionKilled = false; - if(is_array($_POST['session'])) { - foreach($_POST['session'] as $sessionId) { - $sessionId = (int)$sessionId; - - try { - $sessionInfo = UserSession::byId($sessionId); - } catch(RuntimeException $ex) {} - - if(empty($sessionInfo) || $sessionInfo->getUserId() !== $currentUser->getId()) { - $errors[] = "Session #{$sessionId} does not exist."; - continue; - } elseif($sessionInfo->getId() === $sessionActive) { - $currentSessionKilled = true; - } - - $sessionInfo->delete(); - $msz->createAuditLog('PERSONAL_SESSION_DESTROY', [$sessionInfo->getId()]); - } - } elseif($_POST['session'] === 'all') { - $currentSessionKilled = true; - UserSession::purgeUser($currentUser); + if($sessionId === 'all') { + $activeSessionKilled = true; + $sessions->deleteSessions(userInfos: $currentUser); $msz->createAuditLog('PERSONAL_SESSION_DESTROY_ALL'); + } else { + try { + $sessionInfo = $sessions->getSession(sessionId: $sessionId); + } catch(RuntimeException $ex) {} + + if(empty($sessionInfo) || $sessionInfo->getUserId() !== (string)$currentUser->getId()) { + $errors[] = "That session doesn't exist."; + break; + } + + $activeSessionKilled = $sessionInfo->getToken() === $activeSessionToken; + $sessions->deleteSessions(sessionInfos: $sessionInfo); + $msz->createAuditLog('PERSONAL_SESSION_DESTROY', [$sessionInfo->getId()]); } - if($currentSessionKilled) { + if($activeSessionKilled) { url_redirect('index'); return; - } + } else break; } -$pagination = new Pagination(UserSession::countAll($currentUser), 15); +$pagination = new Pagination($sessions->countSessions(userInfo: $currentUser), 10); + +$sessionList = []; +$sessionInfos = $sessions->getSessions(userInfo: $currentUser, pagination: $pagination); + +foreach($sessionInfos as $sessionInfo) + $sessionList[] = [ + 'info' => $sessionInfo, + 'active' => $sessionInfo->getToken() === $activeSessionToken, + ]; Template::render('settings.sessions', [ 'errors' => $errors, - 'session_list' => UserSession::all($pagination, $currentUser), - 'session_current' => $currentSession, + 'session_list' => $sessionList, 'session_pagination' => $pagination, ]); diff --git a/public/index.php b/public/index.php index 4a8f80f..eb0c58b 100644 --- a/public/index.php +++ b/public/index.php @@ -3,7 +3,6 @@ namespace Misuzu; use RuntimeException; use Misuzu\Users\User; -use Misuzu\Users\UserSession; require_once __DIR__ . '/../misuzu.php'; @@ -95,20 +94,22 @@ if(!isset($authToken)) $authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? ''); if($authToken->isValid()) { + $sessions = $msz->getSessions(); $authToken->setCurrent(); try { - $sessionInfo = UserSession::byToken($authToken->getSessionToken()); - if($sessionInfo->hasExpired()) { - $sessionInfo->delete(); - } elseif($sessionInfo->getUserId() === $authToken->getUserId()) { - $userInfo = $sessionInfo->getUser(); - if(!$userInfo->isDeleted()) { - $sessionInfo->setCurrent(); - $userInfo->setCurrent(); - $sessionInfo->bump($_SERVER['REMOTE_ADDR']); + $sessionInfo = $sessions->getSession(sessionToken: $authToken->getSessionToken()); - if($sessionInfo->shouldBumpExpire()) + if($sessionInfo->hasExpired()) { + $sessions->deleteSessions(sessionInfos: $sessionInfo); + } elseif($sessionInfo->getUserId() === (string)$authToken->getUserId()) { + $userInfo = User::byId((int)$sessionInfo->getUserId()); + + if(!$userInfo->isDeleted()) { + $userInfo->setCurrent(); + + $sessions->updateSession(sessionInfo: $sessionInfo, remoteAddr: $_SERVER['REMOTE_ADDR']); + if($sessionInfo->shouldBumpExpires()) $authToken->applyCookie($sessionInfo->getExpiresTime()); // only allow impersonation when super user @@ -128,11 +129,10 @@ if($authToken->isValid()) { } } } catch(RuntimeException $ex) { - UserSession::unsetCurrent(); User::unsetCurrent(); } - if(UserSession::hasCurrent()) { + if(User::hasCurrent()) { $userInfo->bumpActivity($_SERVER['REMOTE_ADDR']); } else AuthToken::nukeCookie(); @@ -140,7 +140,7 @@ if($authToken->isValid()) { CSRF::init( $globals['csrf.secret'], - (UserSession::hasCurrent() ? UserSession::getCurrent()->getToken() : ($_SERVER['REMOTE_ADDR'] ?? '::1')) + (User::hasCurrent() ? $authToken->getSessionToken() : $_SERVER['REMOTE_ADDR']) ); if(!empty($userInfo)) { diff --git a/src/Auth/SessionInfo.php b/src/Auth/SessionInfo.php new file mode 100644 index 0000000..2a5bafe --- /dev/null +++ b/src/Auth/SessionInfo.php @@ -0,0 +1,121 @@ +id = (string)$result->getInteger(0); + $this->userId = (string)$result->getInteger(1); + $this->token = $result->getString(2); + $this->firstRemoteAddr = $result->getString(3); + $this->lastRemoteAddr = $result->isNull(4) ? null : $result->getString(4); + $this->userAgent = $result->getString(5); + $this->clientInfo = $result->getString(6); + $this->countryCode = $result->getString(7); + $this->expires = $result->getInteger(8); + $this->bumpExpires = $result->getInteger(9) !== 0; + $this->created = $result->getInteger(10); + $this->lastActive = $result->isNull(11) ? null : $result->getInteger(11); + } + + public function getId(): string { + return $this->id; + } + + public function getUserId(): string { + return $this->userId; + } + + public function getToken(): string { + return $this->token; + } + + public function getFirstRemoteAddressRaw(): string { + return $this->firstRemoteAddr; + } + + public function getFirstRemoteAddress(): IPAddress { + return IPAddress::parse($this->firstRemoteAddr); + } + + public function hasLastRemoteAddress(): bool { + return $this->lastRemoteAddr !== null; + } + + public function getLastRemoteAddressRaw(): string { + return $this->lastRemoteAddr; + } + + public function getLastRemoteAddress(): ?IPAddress { + return $this->lastRemoteAddr === null ? null : IPAddress::parse($this->lastRemoteAddr); + } + + public function getUserAgentString(): string { + return $this->userAgent; + } + + public function getClientInfoRaw(): string { + return $this->clientInfo; + } + + public function getClientInfo(): ClientInfo { + return ClientInfo::decode($this->clientInfo); + } + + public function getCountryCode(): string { + return $this->countryCode; + } + + public function getExpiresTime(): int { + return $this->expires; + } + + public function getExpiresAt(): DateTime { + return DateTime::fromUnixTimeSeconds($this->expires); + } + + public function shouldBumpExpires(): bool { + return $this->bumpExpires; + } + + public function hasExpired(): bool { + return $this->expires < time(); + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getCreatedAt(): DateTime { + return DateTime::fromUnixTimeSeconds($this->created); + } + + public function hasLastActive(): bool { + return $this->lastActive !== null; + } + + public function getLastActiveTime(): ?int { + return $this->lastActive; + } + + public function getLastActiveAt(): ?DateTime { + return $this->lastActive === null ? null : DateTime::fromUnixTimeSeconds($this->lastActive); + } +} diff --git a/src/Auth/Sessions.php b/src/Auth/Sessions.php new file mode 100644 index 0000000..645ff89 --- /dev/null +++ b/src/Auth/Sessions.php @@ -0,0 +1,285 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + // would like to un-hex this but need to make sure AuthToken doesn't have an aneurysm over it + public static function generateToken(): string { + return bin2hex(random_bytes(32)); + } + + public function countSessions( + User|string|null $userInfo = null + ): int { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + + $hasUserInfo = $userInfo !== null; + + //$args = 0; + $query = 'SELECT COUNT(*) FROM msz_sessions'; + if($hasUserInfo) { + //++$args; + $query .= ' WHERE user_id = ?'; + } + + $args = 0; + $stmt = $this->cache->get($query); + if($hasUserInfo) + $stmt->addParameter(++$args, $userInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + $count = 0; + + if($result->next()) + $count = $result->getInteger(0); + + return $count; + } + + public function getSessions( + User|string|null $userInfo = null, + ?Pagination $pagination = null + ): array { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + + $hasUserInfo = $userInfo !== null; + $hasPagination = $pagination !== null; + + //$args = 0; + $query = 'SELECT session_id, user_id, session_key, INET6_NTOA(session_ip), INET6_NTOA(session_ip_last), session_user_agent, session_client_info, session_country, UNIX_TIMESTAMP(session_expires), session_expires_bump, UNIX_TIMESTAMP(session_created), UNIX_TIMESTAMP(session_active) FROM msz_sessions'; + if($hasUserInfo) { + //++$args; + $query .= ' WHERE user_id = ?'; + } + $query .= ' ORDER BY session_active DESC'; + if($hasPagination) + $query .= ' LIMIT ? OFFSET ?'; + + $args = 0; + $stmt = $this->cache->get($query); + if($hasUserInfo) + $stmt->addParameter(++$args, $userInfo); + if($hasPagination) { + $stmt->addParameter(++$args, $pagination->getRange()); + $stmt->addParameter(++$args, $pagination->getOffset()); + } + $stmt->execute(); + + $result = $stmt->getResult(); + $sessions = []; + + while($result->next()) + $sessions[] = new SessionInfo($result); + + return $sessions; + } + + public function getSession( + ?string $sessionId = null, + ?string $sessionToken = null + ): SessionInfo { + if($sessionId === null && $sessionToken === null) + throw new InvalidArgumentException('At least one argument must be specified.'); + if($sessionId !== null && $sessionToken !== null) + throw new InvalidArgumentException('Only one argument may be specified.'); + + $hasSessionId = $sessionId !== null; + $hasSessionToken = $sessionToken !== null; + $value = null; + + $query = 'SELECT session_id, user_id, session_key, INET6_NTOA(session_ip), INET6_NTOA(session_ip_last), session_user_agent, session_client_info, session_country, UNIX_TIMESTAMP(session_expires), session_expires_bump, UNIX_TIMESTAMP(session_created), UNIX_TIMESTAMP(session_active) FROM msz_sessions'; + if($hasSessionId) { + $query .= ' WHERE session_id = ?'; + $value = $sessionId; + } elseif($hasSessionToken) { + $query .= ' WHERE session_key = ?'; + $value = $sessionToken; + } + + $stmt = $this->cache->get($query); + $stmt->addParameter(1, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Session not found.'); + + return new SessionInfo($result); + } + + public function createSession( + User|string $userInfo, + IPAddress|string $remoteAddr, + string $countryCode, + string $userAgentString, + ?ClientInfo $clientInfo = null + ): SessionInfo { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + if($remoteAddr instanceof IPAddress) + $remoteAddr = (string)$remoteAddr; + + $sessionToken = self::generateToken(); + $clientInfo = json_encode($clientInfo ?? ClientInfo::parse($userAgentString)); + + $stmt = $this->cache->get('INSERT INTO msz_sessions (user_id, session_key, session_ip, session_user_agent, session_client_info, session_country, session_expires) VALUES (?, ?, INET6_ATON(?), ?, ?, ?, NOW() + INTERVAL 1 MONTH)'); + $stmt->addParameter(1, $userInfo); + $stmt->addParameter(2, $sessionToken); + $stmt->addParameter(3, $remoteAddr); + $stmt->addParameter(4, $userAgentString); + $stmt->addParameter(5, $clientInfo); + $stmt->addParameter(6, $countryCode); + $stmt->execute(); + + return $this->getSession(sessionId: (string)$this->dbConn->getLastInsertId()); + } + + public function deleteSessions( + SessionInfo|string|array|null $sessionInfos = null, + string|array|null $sessionTokens = null, + User|string|array|null $userInfos = null + ): void { + $hasSessionInfos = $sessionInfos !== null; + $hasSessionTokens = $sessionTokens !== null; + $hasUserInfos = $userInfos !== null; + + $args = 0; + $query = 'DELETE FROM msz_sessions'; + + if($hasSessionInfos) { + if(!is_array($sessionInfos)) + $sessionInfos = [$sessionInfos]; + + if(empty($sessionInfos)) + $hasSessionInfos = false; + else { + ++$args; + $query .= sprintf( + ' WHERE session_id IN (%s)', + DbTools::prepareListString($sessionInfos) + ); + } + } + + if($hasSessionTokens) { + if(!is_array($sessionTokens)) + $sessionTokens = [$sessionTokens]; + + if(empty($sessionTokens)) + $hasSessionTokens = false; + else + $query .= sprintf( + ' %s session_key IN (%s)', + ++$args > 1 ? 'OR' : 'WHERE', + DbTools::prepareListString($sessionTokens) + ); + } + + if($hasUserInfos) { + if(!is_array($userInfos)) + $userInfos = [$userInfos]; + + if(empty($userInfos)) + $hasUserInfos = false; + else + $query .= sprintf( + ' %s user_id IN (%s)', + ++$args > 1 ? 'OR' : 'WHERE', + DbTools::prepareListString($userInfos) + ); + } + + if(!$hasSessionInfos && !$hasSessionTokens && !$hasUserInfos) + throw new InvalidArgumentException('At least one argument must be specified.'); + + $args = 0; + $stmt = $this->cache->get($query); + + if($hasSessionInfos) + foreach($sessionInfos as $sessionInfo) { + if($sessionInfo instanceof SessionInfo) + $sessionInfo = $sessionInfo->getId(); + elseif(!is_string($sessionInfo)) + throw new InvalidArgumentException('$sessionInfos must be strings or instances of SessionInfo.'); + + $stmt->addParameter(++$args, $sessionInfo); + } + + if($hasSessionTokens) + foreach($sessionTokens as $sessionToken) { + if(!is_string($sessionToken)) + throw new InvalidArgumentException('$sessionTokens must be strings.'); + + $stmt->addParameter(++$args, $sessionToken); + } + + if($hasUserInfos) + foreach($userInfos as $userInfo) { + if($userInfo instanceof User) + $userInfo = (string)$userInfo->getId(); + elseif(!is_string($userInfo)) + throw new InvalidArgumentException('$userInfos must be strings or instances of User.'); + + $stmt->addParameter(++$args, $userInfo); + } + + $stmt->execute(); + } + + public function updateSession( + SessionInfo|string|null $sessionInfo = null, + ?string $sessionToken = null, + IPAddress|string|null $remoteAddr = null + ): void { + if($sessionInfo === null && $sessionToken === null) + throw new InvalidArgumentException('Either $sessionInfo or $sessionToken needs to be set.'); + if($sessionInfo !== null && $sessionToken !== null) + throw new InvalidArgumentException('Only one of $sessionInfo and $sessionToken may be set at once.'); + if($sessionInfo instanceof SessionInfo) + $sessionInfo = $sessionInfo->getId(); + if($remoteAddr instanceof IPAddress) + $remoteAddr = (string)$remoteAddr; + + $hasSessionInfo = $sessionInfo !== null; + $hasSessionToken = $sessionToken !== null; + $value = null; + + $query = 'UPDATE msz_sessions SET session_ip_last = COALESCE(INET6_ATON(?), session_ip_last), session_active = NOW(), session_expires = IF(session_expires_bump, NOW() + INTERVAL 1 MONTH, session_expires)'; + if($hasSessionInfo) { + $query .= ' WHERE session_id = ?'; + $value = $sessionInfo; + } elseif($hasSessionToken) { + $query .= ' WHERE session_key = ?'; + $value = $sessionToken; + } else throw new RuntimeException('Failsafe to prevent all sessions from being updated at once somehow.'); + + $stmt = $this->cache->get($query); + $stmt->addParameter(1, $remoteAddr); + $stmt->addParameter(2, $value); + $stmt->execute(); + } + + public function purgeExpiredSessions(): void { + $this->dbConn->execute('DELETE FROM msz_sessions WHERE session_expires <= NOW()'); + } +} diff --git a/src/AuthToken.php b/src/AuthToken.php index b4d2722..8b3d6f6 100644 --- a/src/AuthToken.php +++ b/src/AuthToken.php @@ -1,10 +1,10 @@ setUserId($user->getId()); - $token->setSessionToken($session->getToken()); + $token->setUserId($userInfo->getId()); + $token->setSessionToken($sessionInfo->getToken()); return $token; } @@ -205,7 +205,7 @@ class AuthToken { // please never use the below functions beyond the scope of the sharpchat auth stuff // a better mechanism for keeping a global instance of this available - // that isn't a $GLOBAL variable or static instance needs to be established, for User and UserSession as well + // that isn't a $GLOBAL variable or static instance needs to be established, for User as well private static $localToken = null; diff --git a/src/Http/Handlers/ForumHandler.php b/src/Http/Handlers/ForumHandler.php index 725c206..9ba7709 100644 --- a/src/Http/Handlers/ForumHandler.php +++ b/src/Http/Handlers/ForumHandler.php @@ -4,11 +4,10 @@ namespace Misuzu\Http\Handlers; use Misuzu\CSRF; use Misuzu\Template; use Misuzu\Users\User; -use Misuzu\Users\UserSession; final class ForumHandler extends Handler { public function markAsReadGET($response, $request) { - if(!UserSession::hasCurrent() || !User::hasCurrent()) + if(!User::hasCurrent()) return 403; $forumId = (int)$request->getParam('forum', FILTER_SANITIZE_NUMBER_INT); @@ -23,7 +22,7 @@ final class ForumHandler extends Handler { } public function markAsReadPOST($response, $request) { - if(!UserSession::hasCurrent() || !User::hasCurrent()) + if(!User::hasCurrent()) return 403; if(!$request->isFormContent()) diff --git a/src/Http/Handlers/HomeHandler.php b/src/Http/Handlers/HomeHandler.php index 787f12a..753641f 100644 --- a/src/Http/Handlers/HomeHandler.php +++ b/src/Http/Handlers/HomeHandler.php @@ -7,11 +7,10 @@ use Misuzu\Pagination; use Misuzu\Template; use Misuzu\Comments\CommentsCategory; use Misuzu\Users\User; -use Misuzu\Users\UserSession; final class HomeHandler extends Handler { public function index($response, $request): void { - if(UserSession::hasCurrent()) + if(User::hasCurrent()) $this->home($response, $request); else $this->landing($response, $request); diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index f22c416..404779e 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -4,6 +4,7 @@ namespace Misuzu; use Misuzu\Template; use Misuzu\Auth\LoginAttempts; use Misuzu\Auth\RecoveryTokens; +use Misuzu\Auth\Sessions; use Misuzu\Auth\TwoFactorAuthSessions; use Misuzu\AuditLog\AuditLog; use Misuzu\Changelog\Changelog; @@ -50,6 +51,7 @@ class MisuzuContext { private TwoFactorAuthSessions $tfaSessions; private Roles $roles; private Users $users; + private Sessions $sessions; public function __construct(IDbConnection $dbConn, IConfig $config) { $this->dbConn = $dbConn; @@ -67,6 +69,7 @@ class MisuzuContext { $this->tfaSessions = new TwoFactorAuthSessions($this->dbConn); $this->roles = new Roles($this->dbConn); $this->users = new Users($this->dbConn); + $this->sessions = new Sessions($this->dbConn); } public function getDbConn(): IDbConnection { @@ -146,6 +149,10 @@ class MisuzuContext { return $this->users; } + public function getSessions(): Sessions { + return $this->sessions; + } + private array $activeBansCache = []; public function tryGetActiveBan(User|string|null $userInfo = null): ?BanInfo { @@ -241,7 +248,7 @@ class MisuzuContext { $this->router->get('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadGET')); $this->router->post('/forum/mark-as-read', $mszCompatHandler('Forum', 'markAsReadPOST')); - new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes); + new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes, $this->sessions); } private function registerLegacyRedirects(): void { diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index 78a4ffd..bf58eee 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -6,24 +6,32 @@ use Index\Colour\Colour; use Index\Routing\IRouter; use Index\Http\HttpFx; use Misuzu\AuthToken; +use Misuzu\Auth\Sessions; use Misuzu\Config\IConfig; use Misuzu\Emoticons\Emotes; use Misuzu\Users\Bans; // Replace use Misuzu\Users\User; -use Misuzu\Users\UserSession; final class SharpChatRoutes { private IConfig $config; private Bans $bans; private Emotes $emotes; + private Sessions $sessions; private string $hashKey; - public function __construct(IRouter $router, IConfig $config, Bans $bans, Emotes $emotes) { + public function __construct( + IRouter $router, + IConfig $config, + Bans $bans, + Emotes $emotes, + Sessions $sessions + ) { $this->config = $config; $this->bans = $bans; $this->emotes = $emotes; + $this->sessions = $sessions; $this->hashKey = $this->config->getString('hashKey', 'woomy'); // Simplify default error pages @@ -119,22 +127,28 @@ final class SharpChatRoutes { if($request->getMethod() === 'OPTIONS') return 204; - if(!UserSession::hasCurrent() || !AuthToken::hasCurrent()) - return ['ok' => false]; + if(!AuthToken::hasCurrent()) + return ['ok' => false, 'err' => 'token']; $token = AuthToken::getCurrent(); - $session = UserSession::getCurrent(); - if($session->getToken() !== $token->getSessionToken()) - return ['ok' => false]; - $user = $session->getUser(); - $userId = $token->hasImpersonatedUserId() && $user->isSuper() + try { + $sessionInfo = $this->sessions->getSession(sessionToken: $token->getSessionToken()); + } catch(RuntimeException $ex) { + return ['ok' => false, 'err' => 'session']; + } + + if($sessionInfo->hasExpired()) + return ['ok' => false, 'err' => 'expired']; + + $userInfo = User::byId((int)$sessionInfo->getUserId()); + $userId = $token->hasImpersonatedUserId() && $userInfo->isSuper() ? $token->getImpersonatedUserId() - : $user->getId(); + : $userInfo->getId(); return [ 'ok' => true, - 'usr' => $userId, + 'usr' => (int)$userId, 'tkn' => $token->pack(), ]; } @@ -198,19 +212,19 @@ final class SharpChatRoutes { $authToken = $authTokenInfo->getSessionToken(); try { - $sessionInfo = UserSession::byToken($authToken); + $sessionInfo = $this->sessions->getSession(sessionToken: $authToken); } catch(RuntimeException $ex) { return ['success' => false, 'reason' => 'token']; } if($sessionInfo->hasExpired()) { - $sessionInfo->delete(); + $this->sessions->deleteSessions(sessionInfos: $sessionInfo); return ['success' => false, 'reason' => 'expired']; } - $sessionInfo->bump($ipAddress); + $this->sessions->updateSession(sessionInfo: $sessionInfo, remoteAddr: $ipAddress); - $userInfo = $sessionInfo->getUser(); + $userInfo = User::byId((int)$sessionInfo->getUserId()); if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuper()) { $userInfoReal = $userInfo; diff --git a/src/Users/UserSession.php b/src/Users/UserSession.php deleted file mode 100644 index 51ea99a..0000000 --- a/src/Users/UserSession.php +++ /dev/null @@ -1,255 +0,0 @@ -session_id < 1 ? -1 : $this->session_id; - } - - public function getUserId(): int { - return $this->user_id < 1 ? -1 : $this->user_id; - } - public function getUser(): User { - if($this->user === null) - $this->user = User::byId($this->getUserId()); - return $this->user; - } - - public function getToken(): string { - return $this->session_key; - } - - public function getInitialRemoteAddress(): string { - return $this->session_ip; - } - - public function getLastRemoteAddress(): string { - return $this->session_ip_last ?? ''; - } - public function hasLastRemoteAddress(): bool { - return !empty($this->session_ip_last); - } - public function setLastRemoteAddress(string $remoteAddr): self { - $this->session_ip_last = $remoteAddr; - return $this; - } - - public function getUserAgent(): string { - return $this->session_user_agent; - } - public function getClientInfo(): ClientInfo { - return ClientInfo::decode($this->session_client_info); - } - - public function getCountry(): string { - return $this->session_country; - } - public function getCountryName(): string { - return get_country_name($this->getCountry()); - } - - public function getCreatedTime(): int { - return $this->session_created === null ? -1 : $this->session_created; - } - - public function getActiveTime(): int { - return $this->session_active === null ? -1 : $this->session_active; - } - public function hasActiveTime(): bool { - return $this->session_active !== null; - } - public function setActiveTime(int $timestamp): self { - if($timestamp > $this->session_active) - $this->session_active = $timestamp; - return $this; - } - - public function getExpiresTime(): int { - return $this->session_expires === null ? -1 : $this->session_expires; - } - public function setExpiresTime(int $timestamp): self { - $this->session_expires = $timestamp; - return $this; - } - public function hasExpired(): bool { - return $this->getExpiresTime() <= time(); - } - - public function shouldBumpExpire(): bool { - return boolval($this->session_expires_bump); - } - - public function bump(string $remoteAddr, int $timestamp = -1): void { - if($timestamp < 0) - $timestamp = time(); - - $this->setActiveTime($timestamp) - ->setLastRemoteAddress($remoteAddr); - - if($this->shouldBumpExpire()) - $this->setExpiresTime($timestamp + self::LIFETIME); - - $this->update(); - } - - public function delete(): void { - DB::prepare('DELETE FROM `msz_sessions` WHERE `session_id` = :session') - ->bind('session', $this->getId()) - ->execute(); - } - - public static function purgeUser(User $user): void { - DB::prepare('DELETE FROM `msz_sessions` WHERE `user_id` = :user') - ->bind('user', $user->getId()) - ->execute(); - } - - public function setCurrent(): void { - self::$localSession = $this; - } - public static function unsetCurrent(): void { - self::$localSession = null; - } - public static function getCurrent(): ?self { - return self::$localSession; - } - public static function hasCurrent(): bool { - return self::$localSession !== null; - } - - public static function generateToken(): string { - return bin2hex(random_bytes(self::TOKEN_SIZE / 2)); - } - - public function update(): void { - DB::prepare( - 'UPDATE `msz_sessions`' - . ' SET `session_active` = FROM_UNIXTIME(:active), `session_ip_last` = INET6_ATON(:remote_addr), `session_expires` = FROM_UNIXTIME(:expires)' - . ' WHERE `session_id` = :session' - ) ->bind('active', $this->session_active) - ->bind('remote_addr', $this->session_ip_last) - ->bind('expires', $this->session_expires) - ->bind('session', $this->session_id) - ->execute(); - } - - 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') ?? ''; - $clientInfo ??= ClientInfo::parse($_SERVER); - $token = self::generateToken(); - - $sessionId = DB::prepare( - 'INSERT INTO `msz_sessions`' - . ' (`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(); - - if($sessionId < 1) - throw new RuntimeException('Failed to create new session.'); - - return self::byId($sessionId); - } - - 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, self::SELECT); - } - public static function byId(int $sessionId): self { - $session = DB::prepare(self::byQueryBase() . ' WHERE `session_id` = :session_id') - ->bind('session_id', $sessionId) - ->fetchObject(self::class); - - if(!$session) - throw new RuntimeException('Could not find a session with that ID.'); - - return $session; - } - public static function byToken(string $token): self { - $session = DB::prepare(self::byQueryBase() . ' WHERE `session_key` = :token') - ->bind('token', $token) - ->fetchObject(self::class); - - if(!$session) - throw new RuntimeException('Could not find a session with that token.'); - - return $session; - } - public static function all(?Pagination $pagination = null, ?User $user = null): array { - $sessionsQuery = self::byQueryBase() - . ($user === null ? '' : ' WHERE `user_id` = :user') - . ' ORDER BY `session_created` DESC'; - - if($pagination !== null) - $sessionsQuery .= ' LIMIT :range OFFSET :offset'; - - $getSessions = DB::prepare($sessionsQuery); - - if($user !== null) - $getSessions->bind('user', $user->getId()); - - if($pagination !== null) - $getSessions->bind('range', $pagination->getRange()) - ->bind('offset', $pagination->getOffset()); - - return $getSessions->fetchObjects(self::class); - } -} diff --git a/templates/settings/sessions.twig b/templates/settings/sessions.twig index 5c13bbb..ee5a23b 100644 --- a/templates/settings/sessions.twig +++ b/templates/settings/sessions.twig @@ -31,7 +31,7 @@
{% for session in session_list %} - {{ user_session(session, session_current.id == session.id) }} + {{ user_session(session.info, session.active) }} {% endfor %}
diff --git a/templates/user/macros.twig b/templates/user/macros.twig index b7e0284..50f159e 100644 --- a/templates/user/macros.twig +++ b/templates/user/macros.twig @@ -70,7 +70,7 @@
-
{{ session.country }}
+
{{ session.countryCode }}
{{ session.clientInfo }} @@ -78,7 +78,7 @@
{{ input_csrf() }} - {{ input_hidden('session[]', session.id) }} + {{ input_hidden('session', session.id) }}
- {{ session.initialRemoteAddress }} + {{ session.firstRemoteAddress }}
@@ -122,20 +122,20 @@
- Expires{% if not session.shouldBumpExpire %} (static){% endif %} + Expires{% if not session.shouldBumpExpires %} (static){% endif %}
- {% if session.hasActiveTime %} -
+ {% if session.hasLastActive %} +
Last Active
-
{% endif %} @@ -145,7 +145,7 @@ User Agent
- {{ session.userAgent is empty ? 'None' : session.userAgent }} + {{ session.userAgentString }}