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);