From 9ae2a6a39b6173b64a83fe1f272c7bebef664614 Mon Sep 17 00:00:00 2001 From: flashwave Date: Thu, 3 Aug 2023 01:35:08 +0000 Subject: [PATCH] Changed the way msz_auth is handled. Going forward msz_auth is always assumed to be present, even while the user is not logged in. If the cookie is not present a default, empty value will be used. The msz_uid and msz_sid cookies are also still upconverted for some reason but are no longer removed even though there's no active sessions that can possibly have those anymore. As with the previous change, shit may be broken so report any Anomalies you come across, through flashii-issues@flash.moe if necessary. --- composer.lock | 126 ++++++++++++---- public-legacy/auth/login.php | 10 +- public-legacy/auth/logout.php | 31 ++-- public-legacy/auth/revert.php | 28 ++-- public-legacy/auth/twofactor.php | 10 +- public-legacy/manage/users/user.php | 9 +- public/index.php | 87 ++++++----- src/Auth/AuthInfo.php | 86 +++++++++++ src/Auth/AuthTokenBuilder.php | 72 ++++++++++ src/Auth/AuthTokenCookie.php | 25 ++++ src/Auth/AuthTokenInfo.php | 83 +++++++++++ src/Auth/AuthTokenPacker.php | 99 +++++++++++++ src/AuthToken.php | 215 ---------------------------- src/Http/Handlers/AssetsHandler.php | 4 +- src/Http/Handlers/ForumHandler.php | 2 +- src/MisuzuContext.php | 52 ++----- src/SharpChat/SharpChatRoutes.php | 51 ++++--- 17 files changed, 618 insertions(+), 372 deletions(-) create mode 100644 src/Auth/AuthInfo.php create mode 100644 src/Auth/AuthTokenBuilder.php create mode 100644 src/Auth/AuthTokenCookie.php create mode 100644 src/Auth/AuthTokenInfo.php create mode 100644 src/Auth/AuthTokenPacker.php delete mode 100644 src/AuthToken.php diff --git a/composer.lock b/composer.lock index a8a96101..59304169 100644 --- a/composer.lock +++ b/composer.lock @@ -348,7 +348,7 @@ "source": { "type": "git", "url": "https://git.flash.moe/flash/index.git", - "reference": "557f089ff79c3806f1973ee7bf82f81ab4faa5f4" + "reference": "553b7c4a14aa7f2403c87ce474933986ac17d040" }, "require": { "ext-mbstring": "*", @@ -386,20 +386,20 @@ ], "description": "Composer package for the common library for my projects.", "homepage": "https://railgun.sh/index", - "time": "2023-07-22T14:25:58+00:00" + "time": "2023-08-03T01:29:57+00:00" }, { "name": "matomo/device-detector", - "version": "6.1.3", + "version": "6.1.4", "source": { "type": "git", "url": "https://github.com/matomo-org/device-detector.git", - "reference": "3e0fac7e77f3faadc3858fea9f5fa7efeb9cf239" + "reference": "74f6c4f6732b3ad6cdf25560746841d522969112" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/3e0fac7e77f3faadc3858fea9f5fa7efeb9cf239", - "reference": "3e0fac7e77f3faadc3858fea9f5fa7efeb9cf239", + "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/74f6c4f6732b3ad6cdf25560746841d522969112", + "reference": "74f6c4f6732b3ad6cdf25560746841d522969112", "shasum": "" }, "require": { @@ -455,7 +455,7 @@ "source": "https://github.com/matomo-org/matomo", "wiki": "https://dev.matomo.org/" }, - "time": "2023-06-06T11:58:07+00:00" + "time": "2023-08-02T08:48:53+00:00" }, { "name": "mustangostang/spyc", @@ -661,17 +661,84 @@ "time": "2021-07-14T16:46:02+00:00" }, { - "name": "symfony/event-dispatcher", - "version": "v6.3.0", + "name": "symfony/deprecation-contracts", + "version": "v3.3.0", "source": { "type": "git", - "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "3af8ac1a3f98f6dbc55e10ae59c9e44bfc38dfaa" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/3af8ac1a3f98f6dbc55e10ae59c9e44bfc38dfaa", - "reference": "3af8ac1a3f98f6dbc55e10ae59c9e44bfc38dfaa", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "adb01fe097a4ee930db9258a3cc906b5beb5cf2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/adb01fe097a4ee930db9258a3cc906b5beb5cf2e", + "reference": "adb01fe097a4ee930db9258a3cc906b5beb5cf2e", "shasum": "" }, "require": { @@ -722,7 +789,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v6.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.3.2" }, "funding": [ { @@ -738,7 +805,7 @@ "type": "tidelift" } ], - "time": "2023-04-21T14:41:17+00:00" + "time": "2023-07-06T06:56:43+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -898,20 +965,21 @@ }, { "name": "symfony/mime", - "version": "v6.3.0", + "version": "v6.3.3", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "7b5d2121858cd6efbed778abce9cfdd7ab1f62ad" + "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/7b5d2121858cd6efbed778abce9cfdd7ab1f62ad", - "reference": "7b5d2121858cd6efbed778abce9cfdd7ab1f62ad", + "url": "https://api.github.com/repos/symfony/mime/zipball/9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", + "reference": "9a0cbd52baa5ba5a5b1f0cacc59466f194730f98", "shasum": "" }, "require": { "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -920,7 +988,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/mailer": "<5.4", - "symfony/serializer": "<6.2" + "symfony/serializer": "<6.2.13|>=6.3,<6.3.2" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1|^4", @@ -929,7 +997,7 @@ "symfony/dependency-injection": "^5.4|^6.0", "symfony/property-access": "^5.4|^6.0", "symfony/property-info": "^5.4|^6.0", - "symfony/serializer": "^6.2" + "symfony/serializer": "~6.2.13|^6.3.2" }, "type": "library", "autoload": { @@ -961,7 +1029,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v6.3.0" + "source": "https://github.com/symfony/mime/tree/v6.3.3" }, "funding": [ { @@ -977,7 +1045,7 @@ "type": "tidelift" } ], - "time": "2023-04-28T15:57:00+00:00" + "time": "2023-07-31T07:08:24+00:00" }, { "name": "symfony/polyfill-ctype", @@ -1475,16 +1543,16 @@ }, { "name": "twig/twig", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "7e7d5839d4bec168dfeef0ac66d5c5a2edbabffd" + "reference": "5cf942bbab3df42afa918caeba947f1b690af64b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/7e7d5839d4bec168dfeef0ac66d5c5a2edbabffd", - "reference": "7e7d5839d4bec168dfeef0ac66d5c5a2edbabffd", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/5cf942bbab3df42afa918caeba947f1b690af64b", + "reference": "5cf942bbab3df42afa918caeba947f1b690af64b", "shasum": "" }, "require": { @@ -1530,7 +1598,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.6.1" + "source": "https://github.com/twigphp/Twig/tree/v3.7.0" }, "funding": [ { @@ -1542,7 +1610,7 @@ "type": "tidelift" } ], - "time": "2023-06-08T12:52:13+00:00" + "time": "2023-07-26T07:16:09+00:00" } ], "packages-dev": [ diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php index 77a650de..dede8222 100644 --- a/public-legacy/auth/login.php +++ b/public-legacy/auth/login.php @@ -2,6 +2,7 @@ namespace Misuzu; use Exception; +use Misuzu\Auth\AuthTokenCookie; if($msz->isLoggedIn()) { url_redirect('index'); @@ -135,8 +136,13 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { break; } - $authToken = AuthToken::create($userInfo, $sessionInfo); - $authToken->applyCookie($sessionInfo->getExpiresTime()); + $tokenBuilder = $msz->getAuthInfo()->getTokenInfo()->toBuilder(); + $tokenBuilder->setUserId($userInfo); + $tokenBuilder->setSessionToken($sessionInfo); + $tokenBuilder->removeImpersonatedUserId(); + $tokenInfo = $tokenBuilder->toInfo(); + + AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); if(!is_local_url($loginRedirect)) $loginRedirect = url('index'); diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php index 2dfa5e11..dede271b 100644 --- a/public-legacy/auth/logout.php +++ b/public-legacy/auth/logout.php @@ -1,16 +1,25 @@ isLoggedIn()) { - url_redirect('index'); - return; +use Misuzu\Auth\AuthTokenCookie; + +if($msz->isLoggedIn()) { + if(!CSRF::validateRequest()) { + Template::render('auth.logout'); + return; + } + + $tokenInfo = $msz->getAuthInfo()->getTokenInfo(); + + $msz->getSessions()->deleteSessions(sessionTokens: $tokenInfo->getSessionToken()); + + $tokenBuilder = $tokenInfo->toBuilder(); + $tokenBuilder->removeUserId(); + $tokenBuilder->removeSessionToken(); + $tokenBuilder->removeImpersonatedUserId(); + $tokenInfo = $tokenBuilder->toInfo(); + + AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); } -if(CSRF::validateRequest()) { - $msz->getSessions()->deleteSessions(sessionTokens: $authToken->getSessionToken()); - AuthToken::nukeCookie(); - url_redirect('index'); - return; -} - -Template::render('auth.logout'); +url_redirect('index'); diff --git a/public-legacy/auth/revert.php b/public-legacy/auth/revert.php index b10f36b8..920a20a7 100644 --- a/public-legacy/auth/revert.php +++ b/public-legacy/auth/revert.php @@ -1,16 +1,22 @@ hasImpersonatedUserId() || !CSRF::validateRequest()) { - url_redirect('index'); - return; +use Misuzu\Auth\AuthTokenCookie; + +if(CSRF::validateRequest()) { + $tokenInfo = $msz->getAuthInfo()->getTokenInfo(); + + if($tokenInfo->hasImpersonatedUserId()) { + $impUserId = $tokenInfo->getImpersonatedUserId(); + + $tokenBuilder = $tokenInfo->toBuilder(); + $tokenBuilder->removeImpersonatedUserId(); + $tokenInfo = $tokenBuilder->toInfo(); + + AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); + url_redirect('manage-user', ['user' => $impUserId]); + return; + } } -$impUserId = $authToken->getImpersonatedUserId(); -$authToken->removeImpersonatedUserId(); -$authToken->applyCookie(); - -url_redirect( - $impUserId > 0 ? 'manage-user' : 'index', - ['user' => $impUserId] -); +url_redirect('index'); diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php index 7d1a57aa..297e013f 100644 --- a/public-legacy/auth/twofactor.php +++ b/public-legacy/auth/twofactor.php @@ -3,6 +3,7 @@ namespace Misuzu; use RuntimeException; use Misuzu\TOTPGenerator; +use Misuzu\Auth\AuthTokenCookie; if($msz->isLoggedIn()) { url_redirect('index'); @@ -83,8 +84,13 @@ while(!empty($twofactor)) { break; } - $authToken = AuthToken::create($userInfo, $sessionInfo); - $authToken->applyCookie($sessionInfo->getExpiresTime()); + $tokenBuilder = $msz->getAuthInfo()->getTokenInfo()->toBuilder(); + $tokenBuilder->setUserId($userInfo); + $tokenBuilder->setSessionToken($sessionInfo); + $tokenBuilder->removeImpersonatedUserId(); + $tokenInfo = $tokenBuilder->toInfo(); + + AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); if(!is_local_url($redirect)) $redirect = url('index'); diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php index 880aac44..1fcea86b 100644 --- a/public-legacy/manage/users/user.php +++ b/public-legacy/manage/users/user.php @@ -3,6 +3,7 @@ namespace Misuzu; use RuntimeException; use Index\Colour\Colour; +use Misuzu\Auth\AuthTokenCookie; use Misuzu\Users\User; if(!$msz->isLoggedIn()) { @@ -62,8 +63,12 @@ if(CSRF::validateRequest() && $canEdit) { if($allowToImpersonate) { $msz->createAuditLog('USER_IMPERSONATE', [$userInfo->getId(), $userInfo->getName()]); - $authToken->setImpersonatedUserId($userInfo->getId()); - $authToken->applyCookie(); + + $tokenBuilder = $msz->getAuthInfo()->getTokenInfo()->toBuilder(); + $tokenBuilder->setImpersonatedUserId($userInfo->getId()); + $tokenInfo = $tokenBuilder->toInfo(); + + AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); url_redirect('index'); return; } else $notices[] = 'You aren\'t allowed to impersonate this user.'; diff --git a/public/index.php b/public/index.php index 406ee213..2eed00bf 100644 --- a/public/index.php +++ b/public/index.php @@ -2,6 +2,9 @@ namespace Misuzu; use RuntimeException; +use Misuzu\Auth\AuthTokenBuilder; +use Misuzu\Auth\AuthTokenCookie; +use Misuzu\Auth\AuthTokenInfo; require_once __DIR__ . '/../misuzu.php'; @@ -51,7 +54,6 @@ $globals = $cfg->getValues([ 'site.url:s', 'eeprom.path:s', 'eeprom.app:s', - ['auth.secret:s', 'meow'], ['csrf.secret:s', 'soup'], ]); @@ -74,50 +76,55 @@ unset($mszAssetsInfo); Template::addPath(MSZ_TEMPLATES); -AuthToken::setSecretKey($globals['auth.secret']); +$tokenPacker = $msz->createAuthTokenPacker(); -if(isset($_COOKIE['msz_uid']) && isset($_COOKIE['msz_sid'])) { - $authToken = new AuthToken; - $authToken->setUserId(filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT) ?? '0'); - $authToken->setSessionToken(filter_input(INPUT_COOKIE, 'msz_sid') ?? ''); +if(filter_has_var(INPUT_COOKIE, 'msz_auth')) + $tokenInfo = $tokenPacker->unpack(filter_input(INPUT_COOKIE, 'msz_auth')); +elseif(filter_has_var(INPUT_COOKIE, 'msz_uid') && filter_has_var(INPUT_COOKIE, 'msz_sid')) { + $tokenBuilder = new AuthTokenBuilder; + $tokenBuilder->setUserId((string)filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT)); + $tokenBuilder->setSessionToken((string)filter_input(INPUT_COOKIE, 'msz_sid')); + $tokenInfo = $tokenBuilder->toInfo(); + $tokenBuilder = null; +} else + $tokenInfo = AuthTokenInfo::empty(); - if($authToken->isValid()) - $authToken->applyCookie(strtotime('1 year')); +$userInfo = null; +$sessionInfo = null; +$userInfoReal = null; - AuthToken::nukeCookieLegacy(); -} +if($tokenInfo->hasUserId() && $tokenInfo->hasSessionToken()) { + $users = $msz->getUsers(); + $sessions = $msz->getSessions(); + $tokenBuilder = new AuthTokenBuilder($tokenInfo); -if(!isset($authToken)) - $authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? ''); - -$users = $msz->getUsers(); -$sessions = $msz->getSessions(); - -if($authToken->isValid()) { try { - $sessionInfo = $sessions->getSession(sessionToken: $authToken->getSessionToken()); + $sessionInfo = $sessions->getSession(sessionToken: $tokenInfo->getSessionToken()); if($sessionInfo->hasExpired()) { - $sessions->deleteSessions(sessionInfos: $sessionInfo); - } elseif($sessionInfo->getUserId() === $authToken->getUserId()) { - $userInfo = $users->getUser($authToken->getUserId(), 'id'); + $tokenBuilder->removeUserId(); + $tokenBuilder->removeSessionToken(); + } elseif($sessionInfo->getUserId() === $tokenInfo->getUserId()) { + $userInfo = $users->getUser($tokenInfo->getUserId(), 'id'); - if(!$userInfo->isDeleted()) { + if($userInfo->isDeleted()) { + $tokenBuilder->removeUserId(); + $tokenBuilder->removeSessionToken(); + } else { $users->recordUserActivity($userInfo, remoteAddr: $_SERVER['REMOTE_ADDR']); $sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $_SERVER['REMOTE_ADDR']); if($sessionInfo->shouldBumpExpires()) - $authToken->applyCookie($sessionInfo->getExpiresTime()); + $tokenBuilder->setEdited(); - if($authToken->hasImpersonatedUserId()) { + if($tokenInfo->hasImpersonatedUserId()) { $allowToImpersonate = $userInfo->isSuperUser(); - $impersonatedUserId = $authToken->getImpersonatedUserId(); + $impersonatedUserId = $tokenInfo->getImpersonatedUserId(); if(!$allowToImpersonate) { $allowImpersonateUsers = $cfg->getArray(sprintf('impersonate.allow.u%s', $userInfo->getId())); $allowToImpersonate = in_array((string)$impersonatedUserId, $allowImpersonateUsers, true); } - $removeImpersonationData = !$allowToImpersonate; if($allowToImpersonate) { $userInfoReal = $userInfo; @@ -125,24 +132,30 @@ if($authToken->isValid()) { $userInfo = $users->getUser($impersonatedUserId, 'id'); } catch(RuntimeException $ex) { $userInfo = $userInfoReal; - $removeImpersonationData = true; + $userInfoReal = null; + $tokenBuilder->removeImpersonatedUserId(); } - } - - if($removeImpersonationData) { - $authToken->removeImpersonatedUserId(); - $authToken->applyCookie(); - } + } else $tokenBuilder->removeImpersonatedUserId(); } - - $msz->setAuthInfo($authToken, $userInfo, $userInfoReal ?? null); } } } catch(RuntimeException $ex) { - AuthToken::nukeCookie(); + $tokenBuilder->removeUserId(); + $tokenBuilder->removeSessionToken(); + $tokenBuilder->removeImpersonatedUserId(); + $userInfo = null; + $sessionInfo = null; + $userInfoReal = null; + } + + if($tokenBuilder->isEdited()) { + $tokenInfo = $tokenBuilder->toInfo(); + AuthTokenCookie::apply($tokenPacker->pack($tokenInfo)); } } +$msz->getAuthInfo()->setInfo($tokenInfo, $userInfo, $sessionInfo, $userInfoReal); + if(!empty($userInfo)) $userInfo = $users->getUser((string)$userInfo->getId(), 'id'); if(!empty($userInfoReal)) @@ -150,7 +163,7 @@ if(!empty($userInfoReal)) CSRF::init( $globals['csrf.secret'], - ($msz->isLoggedIn() ? $authToken->getSessionToken() : $_SERVER['REMOTE_ADDR']) + ($msz->isLoggedIn() ? $sessionInfo->getToken() : $_SERVER['REMOTE_ADDR']) ); if(!empty($userInfo)) { diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php new file mode 100644 index 00000000..6a550ef8 --- /dev/null +++ b/src/Auth/AuthInfo.php @@ -0,0 +1,86 @@ +setInfo( + $tokenInfo ?? AuthTokenInfo::empty(), + $userInfo, + $sessionInfo, + $realUserInfo + ); + } + + public function setInfo( + AuthTokenInfo $tokenInfo, + ?UserInfo $userInfo = null, + ?SessionInfo $sessionInfo = null, + ?UserInfo $realUserInfo = null + ): void { + $this->tokenInfo = $tokenInfo; + $this->userInfo = $userInfo; + $this->sessionInfo = $sessionInfo; + $this->realUserInfo = $realUserInfo; + } + + public function removeInfo(): void { + $this->setInfo(AuthTokenInfo::empty()); + } + + public function getTokenInfo(): AuthTokenInfo { + return $this->tokenInfo; + } + + public function isLoggedIn(): bool { + return $this->userInfo !== null; + } + + public function getUserId(): ?string { + return $this->userInfo?->getId(); + } + + public function getUserInfo(): ?UserInfo { + return $this->userInfo; + } + + public function getSessionInfo(): ?SessionInfo { + return $this->sessionInfo; + } + + public function isImpersonating(): bool { + return $this->realUserInfo !== null; + } + + public function getRealUserId(): ?string { + return $this->realUserInfo?->getId(); + } + + public function getRealUserInfo(): ?UserInfo { + return $this->realUserInfo; + } + + private static AuthInfo $empty; + + public static function init(): void { + self::$empty = new AuthInfo(AuthTokenInfo::empty()); + } + + public static function empty(): self { + return self::$empty; + } +} + +AuthInfo::init(); diff --git a/src/Auth/AuthTokenBuilder.php b/src/Auth/AuthTokenBuilder.php new file mode 100644 index 00000000..0624d80e --- /dev/null +++ b/src/Auth/AuthTokenBuilder.php @@ -0,0 +1,72 @@ +props = $baseTokenInfo === null ? [] : $baseTokenInfo->getProperties(); + } + + public function getProperties(): array { + return $this->props; + } + + public function setEdited(): void { + $this->edited = true; + } + + public function isEdited(): bool { + return $this->edited; + } + + public function setProperty(string $name, string $value): void { + $this->props[$name] = $value; + } + + public function removeProperty(string $name): void { + $this->edited = true; + unset($this->props[$name]); + } + + public function setUserId(UserInfo|string $userId): void { + if($userId instanceof UserInfo) + $userId = $userId->getId(); + + $this->setProperty(AuthTokenInfo::USER_ID, $userId); + } + + public function removeUserId(): void { + $this->removeProperty(AuthTokenInfo::USER_ID); + } + + public function setSessionToken(SessionInfo|string $sessionKey): void { + if($sessionKey instanceof SessionInfo) + $sessionKey = $sessionKey->getToken(); + + $this->setProperty(AuthTokenInfo::SESSION_TOKEN, $sessionKey); + } + + public function removeSessionToken(): void { + $this->removeProperty(AuthTokenInfo::SESSION_TOKEN); + } + + public function setImpersonatedUserId(UserInfo|string $userId): void { + if($userId instanceof UserInfo) + $userId = $userId->getId(); + + $this->setProperty(AuthTokenInfo::IMPERSONATED_USER_ID, $userId); + } + + public function removeImpersonatedUserId(): void { + $this->removeProperty(AuthTokenInfo::IMPERSONATED_USER_ID); + } + + public function toInfo(?int $timestamp = null): AuthTokenInfo { + return new AuthTokenInfo($timestamp ?? time(), $this->props); + } +} diff --git a/src/Auth/AuthTokenCookie.php b/src/Auth/AuthTokenCookie.php new file mode 100644 index 00000000..ec17b2aa --- /dev/null +++ b/src/Auth/AuthTokenCookie.php @@ -0,0 +1,25 @@ +timestamp === 0 && empty($this->props); + } + + public function getTimestamp(): int { + return $this->timestamp; + } + + public function getProperties(): array { + return $this->props; + } + + public function toBuilder(): AuthTokenBuilder { + return new AuthTokenBuilder($this); + } + + public function hasProperty(string $name): bool { + return array_key_exists($name, $this->props); + } + + public function getProperty(string $name): string { + return $this->props[$name] ?? ''; + } + + public function hasUserId(): bool { + return $this->hasProperty(self::USER_ID); + } + + public function getUserId(): string { + return $this->getProperty(self::USER_ID); + } + + public function hasSessionToken(): bool { + return $this->hasProperty(self::SESSION_TOKEN) + || $this->hasProperty(self::SESSION_TOKEN_HEX); + } + + public function getSessionToken(): string { + if($this->hasProperty(self::SESSION_TOKEN)) + return $this->getProperty(self::SESSION_TOKEN); + + if($this->hasProperty(self::SESSION_TOKEN_HEX)) + return bin2hex($this->getProperty(self::SESSION_TOKEN_HEX)); + + return ''; + } + + public function hasImpersonatedUserId(): bool { + return $this->hasProperty(self::IMPERSONATED_USER_ID); + } + + public function getImpersonatedUserId(): string { + return $this->getProperty(self::IMPERSONATED_USER_ID); + } + + private static AuthTokenInfo $empty; + + public static function init(): void { + self::$empty = new AuthTokenInfo(0); + } + + public static function empty(): self { + return self::$empty; + } +} + +AuthTokenInfo::init(); diff --git a/src/Auth/AuthTokenPacker.php b/src/Auth/AuthTokenPacker.php new file mode 100644 index 00000000..7835362d --- /dev/null +++ b/src/Auth/AuthTokenPacker.php @@ -0,0 +1,99 @@ +getProperties(); + $timestamp = $tokenInfo instanceof AuthTokenInfo ? $tokenInfo->getTimestamp() : time(); + + $data = ''; + + foreach($props as $name => $value) { + // very smart solution for this issue, you definitely won't be confused by this later + // down the line when a variable suddenly despawns from the token + $nameLength = strlen($name); + $valueLength = strlen($value); + if($nameLength > 255 || $valueLength > 255) + continue; + + $data .= chr($nameLength) . $name . chr($valueLength) . $value; + } + + $prefix = pack('CN', 2, $timestamp - self::EPOCH_V2); + $data = $prefix . hash_hmac('sha3-256', $prefix . $data, $this->secretKey, true) . $data; + + return UriBase64::encode($data); + } + + public function unpack(?string $token): AuthTokenInfo { + if($token === null || $token === '') + return AuthTokenInfo::empty(); + + $data = UriBase64::decode($token); + if($data === false || $data === '') + return AuthTokenInfo::empty(); + + $builder = new AuthTokenBuilder; + $version = ord($data[0]); + $data = str_pad(substr($data, 1), 36, "\x00"); + $timestamp = null; + + if($version === 1) { + $data = unpack('Nuser/H*token', $data); + if($data === false) + return AuthTokenInfo::empty(); + + $builder->setUserId((string)$data['user']); + $builder->setSessionToken($data['token']); + } elseif($version === 2) { + $timestamp = substr($data, 0, 4); + $userHash = substr($data, 4, 32); + $data = substr($data, 36); + $realHash = hash_hmac('sha3-256', chr($version) . $timestamp . $data, $this->secretKey, true); + + if(!hash_equals($realHash, $userHash)) + return AuthTokenInfo::empty(); + + $unpackTime = unpack('Nts', $timestamp); + if($unpackTime === false) + throw new RuntimeException('$token does not contain a valid timestamp.'); + + $timestamp = $unpackTime['ts'] + self::EPOCH_V2; + + $stream = MemoryStream::fromString($data); + $stream->seek(0); + + for(;;) { + $length = $stream->readChar(); + if($length === null) + break; + + $length = ord($length); + if($length < 1) + break; + + $name = $stream->read($length); + $value = null; + $length = $stream->readChar(); + if($length !== null) { + $length = ord($length); + if($length > 0) + $value = $stream->read($length); + } + + $builder->setProperty($name, $value); + } + } else + return AuthTokenInfo::empty(); + + return $builder->toInfo($timestamp); + } +} diff --git a/src/AuthToken.php b/src/AuthToken.php deleted file mode 100644 index ef92363c..00000000 --- a/src/AuthToken.php +++ /dev/null @@ -1,215 +0,0 @@ -timestamp; - } - public function updateTimestamp(): void { - $this->timestamp = self::timestamp(); - } - - public function hasProperty(string $name): bool { - return isset($this->props[$name]); - } - public function getProperty(string $name): string { - return $this->props[$name] ?? ''; - } - public function setProperty(string $name, string $value): void { - $this->props[$name] = $value; - $this->updateTimestamp(); - } - public function removeProperty(string $name): void { - unset($this->props[$name]); - $this->updateTimestamp(); - } - - public function isValid(): bool { - if($this->getUserId() < 1 || empty($this->getSessionToken())) - return false; - return true; - } - - public function getUserId(): string { - return $this->getProperty('u'); - } - public function setUserId(string $userId): self { - $this->setProperty('u', $userId); - return $this; - } - - public function getSessionToken(): string { - if($this->hasProperty('s')) - return $this->getProperty('s'); - - if($this->hasProperty('t')) - return bin2hex($this->getProperty('t')); - - return ''; - } - public function setSessionToken(string $token): self { - $this->setProperty('s', $token); - return $this; - } - - public function hasImpersonatedUserId(): bool { - return $this->hasProperty('i'); - } - public function getImpersonatedUserId(): int { - $value = (int)$this->getProperty('i'); - return $value < 1 ? -1 : $value; - } - public function setImpersonatedUserId(int $userId): void { - $this->setProperty('i', (string)$userId); - } - public function removeImpersonatedUserId(): void { - $this->removeProperty('i'); - } - - public function pack(bool $base64 = true): string { - $data = ''; - - foreach($this->props as $name => $value) { - // very smart solution for this issue, you definitely won't be confused by this later - // down the line when a variable suddenly despawns from the token - $nameLength = strlen($name); - $valueLength = strlen($value); - if($nameLength > 255 || $valueLength > 255) - continue; - - $data .= chr($nameLength) . $name . chr($valueLength) . $value; - } - - $prefix = pack('CN', 2, $this->getTimestamp()); - $data = $prefix . hash_hmac('sha3-256', $prefix . $data, self::$secretKey, true) . $data; - if($base64) - $data = UriBase64::encode($data); - - return $data; - } - - public static function unpack(string $data, bool $base64 = true): self { - $obj = new AuthToken; - - if(empty($data)) - return $obj; - if($base64) - $data = UriBase64::decode($data); - if(empty($data)) - return $obj; - - $version = ord($data[0]); - $data = substr($data, 1); - - if($version === 1) { - $data = str_pad($data, 36, "\x00"); - $data = unpack('Nuser/H*token', $data); - - $obj->props['u'] = (string)$data['user']; - $obj->props['s'] = $data['token']; - $obj->updateTimestamp(); - } elseif($version === 2) { - $timestamp = substr($data, 0, 4); - $userHash = substr($data, 4, 32); - $data = substr($data, 36); - $realHash = hash_hmac('sha3-256', chr($version) . $timestamp . $data, self::$secretKey, true); - - if(!hash_equals($realHash, $userHash)) - return $obj; - - $unpacked = unpack('Nts', $timestamp); - $obj->timestamp = (int)$unpacked['ts']; - - $stream = MemoryStream::fromString($data); - $stream->seek(0); - - for(;;) { - $length = $stream->readChar(); - if($length === null) - break; - - $length = ord($length); - if($length < 1) - break; - - $name = $stream->read($length); - $value = null; - $length = $stream->readChar(); - if($length !== null) { - $length = ord($length); - if($length > 0) - $value = $stream->read($length); - } - - $obj->props[$name] = $value; - } - } - - return $obj; - } - - public static function timestamp(): int { - return time() - self::EPOCH; - } - - public static function create(UserInfo $userInfo, SessionInfo $sessionInfo): self { - $token = new AuthToken; - $token->setUserId($userInfo->getId()); - $token->setSessionToken($sessionInfo->getToken()); - return $token; - } - - public static function cookieDomain(bool $compatible = true): string { - $url = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST); - if(empty($url)) - $url = $_SERVER['HTTP_HOST']; - - if(!filter_var($url, FILTER_VALIDATE_IP) && $compatible) - $url = '.' . $url; - - return $url; - } - - public function applyCookie(int $expires = 0): void { - if($expires > 0) - $this->cookieExpires = $expires; - else - $expires = $this->cookieExpires; - - setcookie('msz_auth', $this->pack(), $expires, '/', self::cookieDomain(), !empty($_SERVER['HTTPS']), true); - } - - public static function nukeCookie(): void { - setcookie('msz_auth', '', -9001, '/', self::cookieDomain(), !empty($_SERVER['HTTPS']), true); - setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true); - } - - public static function nukeCookieLegacy(): void { - setcookie('msz_uid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true); - setcookie('msz_sid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true); - } -} diff --git a/src/Http/Handlers/AssetsHandler.php b/src/Http/Handlers/AssetsHandler.php index 9329dabf..52d8e12d 100644 --- a/src/Http/Handlers/AssetsHandler.php +++ b/src/Http/Handlers/AssetsHandler.php @@ -41,7 +41,7 @@ final class AssetsHandler extends Handler { } public function serveAvatar($response, $request, string $fileName) { - $userId = (int)pathinfo($fileName, PATHINFO_FILENAME); + $userId = pathinfo($fileName, PATHINFO_FILENAME); $type = pathinfo($fileName, PATHINFO_EXTENSION); if($type !== '' && $type !== 'png') @@ -65,7 +65,7 @@ final class AssetsHandler extends Handler { } public function serveProfileBackground($response, $request, string $fileName) { - $userId = (int)pathinfo($fileName, PATHINFO_FILENAME); + $userId = pathinfo($fileName, PATHINFO_FILENAME); $type = pathinfo($fileName, PATHINFO_EXTENSION); if($type !== '' && $type !== 'png') diff --git a/src/Http/Handlers/ForumHandler.php b/src/Http/Handlers/ForumHandler.php index ebf38dce..65201f5d 100644 --- a/src/Http/Handlers/ForumHandler.php +++ b/src/Http/Handlers/ForumHandler.php @@ -32,7 +32,7 @@ final class ForumHandler extends Handler { return 400; $forumId = (int)$request->getContent()->getParam('forum', FILTER_SANITIZE_NUMBER_INT); - forum_mark_read($forumId, $this->context->getActiveUser()->getId()); + forum_mark_read($forumId, (int)$this->context->getActiveUser()->getId()); $redirect = url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]); $response->redirect($redirect, false); diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index ea624143..6e615330 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -2,6 +2,8 @@ namespace Misuzu; use Misuzu\Template; +use Misuzu\Auth\AuthInfo; +use Misuzu\Auth\AuthTokenPacker; use Misuzu\Auth\LoginAttempts; use Misuzu\Auth\RecoveryTokens; use Misuzu\Auth\Sessions; @@ -57,6 +59,7 @@ class MisuzuContext { private Sessions $sessions; private Counters $counters; private ProfileFields $profileFields; + private AuthInfo $authInfo; public function __construct(IDbConnection $dbConn, IConfig $config) { $this->dbConn = $dbConn; @@ -77,6 +80,7 @@ class MisuzuContext { $this->sessions = new Sessions($this->dbConn); $this->counters = new Counters($this->dbConn); $this->profileFields = new ProfileFields($this->dbConn); + $this->authInfo = new AuthInfo; } public function getDbConn(): IDbConnection { @@ -168,53 +172,21 @@ class MisuzuContext { return $this->profileFields; } - private ?AuthToken $authToken = null; - private ?UserInfo $activeUser = null; - private ?UserInfo $activeUserReal = null; - - public function setAuthInfo(AuthToken $authToken, ?UserInfo $userInfo, ?UserInfo $realUserInfo): void { - $this->authToken = $authToken; - $this->activeUser = $userInfo; - $this->activeUserReal = $realUserInfo; + public function createAuthTokenPacker(): AuthTokenPacker { + return new AuthTokenPacker($this->config->getString('auth.secret', 'meow')); } - public function removeAuthInfo(): void { - $this->authToken = null; - $this->activeUser = null; - $this->activeUserReal = null; - } - - public function hasAuthToken(): bool { - return $this->authToken !== null; - } - - public function getAuthToken(): ?AuthToken { - return $this->authToken; + public function getAuthInfo(): AuthInfo { + return $this->authInfo; } + // isLoggedIn and getActiveUser are proxied for convenience, supply authInfo to things in the future public function isLoggedIn(): bool { - return $this->authToken !== null && $this->activeUser !== null; - } - - public function isImpersonating(): bool { - return $this->activeUser !== null && $this->activeUserReal !== null - && $this->activeUser->getId() !== $this->activeUserReal->getId(); - } - - public function hasActiveUser(): bool { - return $this->activeUser !== null; + return $this->authInfo->isLoggedIn(); } public function getActiveUser(): ?UserInfo { - return $this->activeUser; - } - - public function hasRealActiveUser(): bool { - return $this->activeUserReal !== null; - } - - public function getRealActiveUser(): ?UserInfo { - return $this->activeUserReal; + return $this->authInfo->getUserInfo(); } private array $activeBansCache = []; @@ -430,7 +402,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, $this->bans, $this->emotes, $this->users, $this->sessions); + new SharpChatRoutes($this->router, $this->config->scopeTo('sockChat'), $this->bans, $this->emotes, $this->users, $this->sessions, $this->authInfo, $this->createAuthTokenPacker(...)); new SatoriRoutes($this->dbConn, $this->config->scopeTo('satori'), $this->router, $this->users, $this->profileFields); } diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index 2831f864..ab5bda77 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -1,12 +1,12 @@ config = $config; - $this->context = $context; $this->bans = $bans; $this->emotes = $emotes; $this->users = $users; $this->sessions = $sessions; + $this->authInfo = $authInfo; + $this->createAuthTokenPacker = $createAuthTokenPacker; $this->hashKey = $this->config->getString('hashKey', 'woomy'); // Simplify default error pages @@ -98,7 +101,7 @@ final class SharpChatRoutes { } public function getLogin($response, $request): void { - if(!$this->context->isLoggedIn()) { + if(!$this->authInfo->isLoggedIn()) { $response->redirect(url('auth-login')); return; } @@ -132,10 +135,10 @@ final class SharpChatRoutes { if($request->getMethod() === 'OPTIONS') return 204; - if(!$this->context->hasAuthToken()) - return ['ok' => false, 'err' => 'token']; + $tokenInfo = $this->authInfo->getTokenInfo(); - $tokenInfo = $this->context->getAuthToken(); + if(!$tokenInfo->hasSessionToken()) + return ['ok' => false, 'err' => 'token']; try { $sessionInfo = $this->sessions->getSession(sessionToken: $tokenInfo->getSessionToken()); @@ -145,16 +148,20 @@ final class SharpChatRoutes { if($sessionInfo->hasExpired()) return ['ok' => false, 'err' => 'expired']; + if($sessionInfo->getUserId() !== $tokenInfo->getUserId()) + return ['ok' => false, 'err' => 'user']; $userInfo = $this->users->getUser($sessionInfo->getUserId(), 'id'); $userId = $tokenInfo->hasImpersonatedUserId() && $userInfo->isSuperUser() ? $tokenInfo->getImpersonatedUserId() : $userInfo->getId(); + $tokenPacker = ($this->createAuthTokenPacker)(); + return [ 'ok' => true, 'usr' => (int)$userId, - 'tkn' => $tokenInfo->pack(), + 'tkn' => $tokenPacker->pack($tokenInfo), ]; } @@ -212,12 +219,19 @@ final class SharpChatRoutes { return ['success' => false, 'reason' => 'hash']; if($authMethod === 'SESS' || $authMethod === 'Misuzu') { - $authTokenInfo = AuthToken::unpack($authToken); - if($authTokenInfo->isValid()) - $authToken = $authTokenInfo->getSessionToken(); + $tokenPacker = ($this->createAuthTokenPacker)(); + $tokenInfo = $tokenPacker->unpack($authToken); + if($tokenInfo->isEmpty()) { + // don't support using the raw session key for Misuzu format + if($authMethod !== 'SESS') + return ['success' => false, 'reason' => 'format']; + + $sessionToken = $authToken; + } else + $sessionToken = $tokenInfo->getSessionToken(); try { - $sessionInfo = $this->sessions->getSession(sessionToken: $authToken); + $sessionInfo = $this->sessions->getSession(sessionToken: $sessionToken); } catch(RuntimeException $ex) { return ['success' => false, 'reason' => 'token']; } @@ -230,11 +244,11 @@ final class SharpChatRoutes { $this->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $ipAddress); $userInfo = $this->users->getUser($sessionInfo->getUserId(), 'id'); - if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuperUser()) { + if($tokenInfo->hasImpersonatedUserId() && $userInfo->isSuperUser()) { $userInfoReal = $userInfo; try { - $userInfo = $this->users->getUser($authTokenInfo->getImpersonatedUserId(), 'id'); + $userInfo = $this->users->getUser($tokenInfo->getImpersonatedUserId(), 'id'); } catch(RuntimeException $ex) { $userInfo = $userInfoReal; } @@ -243,9 +257,6 @@ final class SharpChatRoutes { return ['success' => false, 'reason' => 'unsupported']; } - if(empty($userInfo)) - return ['success' => false, 'reason' => 'user']; - $this->users->recordUserActivity($userInfo, remoteAddr: $ipAddress); $userColour = $this->users->getUserColour($userInfo); $userRank = $this->users->getUserRank($userInfo);