From 00d1d2922d7fe187daddf90337010f651d29f2af 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 a8a9610..5930416 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 77a650d..dede822 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 2dfa5e1..dede271 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 b10f36b..920a20a 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 7d1a57a..297e013 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 880aac4..1fcea86 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 406ee21..2eed00b 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 0000000..6a550ef --- /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 0000000..0624d80 --- /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 0000000..ec17b2a --- /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 0000000..7835362 --- /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 ef92363..0000000 --- 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 9329dab..52d8e12 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 ebf38dc..65201f5 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 ea62414..6e61533 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 2831f86..ab5bda7 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);