diff --git a/assets/css/misuzu/impersonate.css b/assets/css/misuzu/impersonate.css new file mode 100644 index 0000000..4b870eb --- /dev/null +++ b/assets/css/misuzu/impersonate.css @@ -0,0 +1,53 @@ +.impersonate { + --start-colour: var(--accent-colour); + --end-colour: var(--background-colour); + background-image: repeating-linear-gradient(-45deg, var(--start-colour), var(--start-colour) 10px, var(--end-colour) 10px, var(--end-colour) 20px); + height: 30px; +} + +.impersonate-content { + max-width: var(--site-max-width); + margin: 0 auto; + display: flex; + justify-content: space-between; + height: 100%; +} + +.impersonate-user { + padding: 4px 10px; + background-color: #222d; +} +.impersonate-user-link { + color: var(--user-colour); + text-decoration: none; +} +.impersonate-user-link:hover, +.impersonate-user-link:focus { + text-decoration: underline; +} +.impersonate-user-avatar { + display: inline-block; +} + +.impersonate-options { + display: flex; +} +.impersonate-options-link { + width: 30px; + height: 30px; + line-height: 29px; + text-align: center; + font-size: 1.5em; + background-color: #222d; + display: block; + color: var(--text-colour); + text-decoration: none; + transition: background-color .1s; +} +.impersonate-options-link:focus, +.impersonate-options-link:hover { + background-color: #555d; +} +.impersonate-options-link:active { + background-color: #333d; +} diff --git a/misuzu.php b/misuzu.php index 3d53979..de97f2b 100644 --- a/misuzu.php +++ b/misuzu.php @@ -161,24 +161,27 @@ Template::set('globals', [ Template::addPath(MSZ_TEMPLATES); +AuthToken::setSecretKey($cfg->getValue('auth.secret', IConfig::T_STR, 'meow')); + if(isset($_COOKIE['msz_uid']) && isset($_COOKIE['msz_sid'])) { - $authToken = (new AuthToken) - ->setUserId(filter_input(INPUT_COOKIE, 'msz_uid', FILTER_SANITIZE_NUMBER_INT) ?? 0) - ->setSessionToken(filter_input(INPUT_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($authToken->isValid()) - setcookie('msz_auth', $authToken->pack(), strtotime('1 year'), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + $authToken->applyCookie(strtotime('1 year')); - setcookie('msz_uid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true); - setcookie('msz_sid', '', -3600, '/', '', !empty($_SERVER['HTTPS']), true); + AuthToken::nukeCookieLegacy(); } if(!isset($authToken)) $authToken = AuthToken::unpack(filter_input(INPUT_COOKIE, 'msz_auth') ?? ''); if($authToken->isValid()) { + $authToken->setCurrent(); + try { - $sessionInfo = $authToken->getSession(); + $sessionInfo = UserSession::byToken($authToken->getSessionToken()); if($sessionInfo->hasExpired()) { $sessionInfo->delete(); } elseif($sessionInfo->getUserId() === $authToken->getUserId()) { @@ -189,7 +192,22 @@ if($authToken->isValid()) { $sessionInfo->bump($_SERVER['REMOTE_ADDR']); if($sessionInfo->shouldBumpExpire()) - setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + $authToken->applyCookie($sessionInfo->getExpiresTime()); + + // only allow impersonation when super user + if($authToken->hasImpersonatedUserId() && $userInfo->isSuper()) { + $userInfoReal = $userInfo; + + try { + $userInfo = User::byId($authToken->getImpersonatedUserId()); + } catch(UserNotFoundException $ex) { + $userInfo = $userInfoReal; + $authToken->removeImpersonatedUserId(); + $authToken->applyCookie(); + } + + $userInfo->setCurrent(); + } } } } catch(UserNotFoundException $ex) { @@ -202,10 +220,8 @@ if($authToken->isValid()) { if(UserSession::hasCurrent()) { $userInfo->bumpActivity($_SERVER['REMOTE_ADDR']); - } else { - setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); - setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true); - } + } else + AuthToken::nukeCookie(); } CSRF::setGlobalSecretKey($cfg->getValue('csrf.secret', IConfig::T_STR, 'soup')); @@ -248,6 +264,8 @@ if(parse_url($_SERVER['PHP_SELF'], PHP_URL_PATH) !== '/index.php') if(!empty($userInfo)) Template::set('current_user', $userInfo); +if(!empty($userInfoReal)) + Template::set('current_user_real', $userInfoReal); $inManageMode = str_starts_with($_SERVER['REQUEST_URI'], '/manage'); $hasManageAccess = User::hasCurrent() diff --git a/public/auth/login.php b/public/auth/login.php index e7e84c1..cc5e33f 100644 --- a/public/auth/login.php +++ b/public/auth/login.php @@ -119,7 +119,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { } $authToken = AuthToken::create($userInfo, $sessionInfo); - setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + $authToken->applyCookie($sessionInfo->getExpiresTime()); if(!is_local_url($loginRedirect)) $loginRedirect = url('index'); diff --git a/public/auth/logout.php b/public/auth/logout.php index c75e722..346d760 100644 --- a/public/auth/logout.php +++ b/public/auth/logout.php @@ -12,8 +12,7 @@ if(!UserSession::hasCurrent()) { } if(CSRF::validateRequest()) { - setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); - setcookie('msz_auth', '', -9001, '/', '', !empty($_SERVER['HTTPS']), true); + AuthToken::nukeCookie(); UserSession::getCurrent()->delete(); UserSession::unsetCurrent(); User::unsetCurrent(); diff --git a/public/auth/revert.php b/public/auth/revert.php new file mode 100644 index 0000000..4aacfc9 --- /dev/null +++ b/public/auth/revert.php @@ -0,0 +1,21 @@ +hasImpersonatedUserId() || !CSRF::validateRequest()) { + url_redirect('index'); + return; +} + +$authToken->removeImpersonatedUserId(); +$authToken->applyCookie(); + +$impUserId = User::hasCurrent() ? User::getCurrent()->getId() : 0; + +url_redirect( + $impUserId > 0 ? 'manage-user' : 'index', + ['user' => $impUserId] +); diff --git a/public/auth/twofactor.php b/public/auth/twofactor.php index 3198a66..b80c83c 100644 --- a/public/auth/twofactor.php +++ b/public/auth/twofactor.php @@ -83,7 +83,7 @@ while(!empty($twofactor)) { } $authToken = AuthToken::create($userInfo, $sessionInfo); - setcookie('msz_auth', $authToken->pack(), $sessionInfo->getExpiresTime(), '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + $authToken->applyCookie($sessionInfo->getExpiresTime()); if(!is_local_url($redirect)) { $redirect = url('index'); diff --git a/public/manage/users/user.php b/public/manage/users/user.php index 0d82a4b..7f7ab2d 100644 --- a/public/manage/users/user.php +++ b/public/manage/users/user.php @@ -31,6 +31,19 @@ $canEditPerms = $canEdit && perms_check_user(MSZ_PERMS_USER, $currentUserId, MSZ $permissions = manage_perms_list(perms_get_user_raw($userId)); if(CSRF::validateRequest() && $canEdit) { + if(!empty($_POST['impersonate_user'])) { + if(!$currentUser->isSuper()) { + $notices[] = 'You must be a super user to do this.'; + } elseif(!is_string($_POST['impersonate_user']) || $_POST['impersonate_user'] !== 'meow') { + $notices[] = 'You didn\'t say the magic word.'; + } else { + $authToken->setImpersonatedUserId($userInfo->getId()); + $authToken->applyCookie(); + url_redirect('index'); + return; + } + } + if(!empty($_POST['send_test_email'])) { if(!$currentUser->isSuper()) { $notices[] = 'You must be a super user to do this.'; diff --git a/src/AuthToken.php b/src/AuthToken.php index 785b9d2..56cf571 100644 --- a/src/AuthToken.php +++ b/src/AuthToken.php @@ -3,66 +3,103 @@ namespace Misuzu; use Misuzu\Users\User; use Misuzu\Users\UserSession; +use Index\IO\MemoryStream; use Index\Serialisation\Serialiser; class AuthToken { - public const VERSION = 1; - public const WIDTH = 37; + private const EPOCH = 1682985600; - private $userId = -1; - private $sessionToken = ''; + private int $timestamp = 0; + private int $cookieExpires = 0; + private array $props = []; - private $user = null; - private $session = null; + private static string $secretKey = ''; + + public static function setSecretKey(string $secretKey): void { + self::$secretKey = $secretKey; + } + + public function getTimestamp(): int { + return $this->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 { - return $this->getUserId() > 0 - && !empty($this->getSessionToken()); + if($this->getUserId() < 1 || empty($this->getSessionToken())) + return false; + return true; } public function getUserId(): int { - return $this->userId < 1 ? -1 : $this->userId; + $value = (int)$this->getProperty('u'); + return $value < 1 ? -1 : $value; } public function setUserId(int $userId): self { - $this->user = null; - $this->userId = $userId; - return $this; - } - public function getUser(): User { - if($this->user === null) - $this->user = User::byId($this->getUserId()); - return $this->user; - } - public function setUser(User $user): self { - $this->user = $user; - $this->userId = $user->getId(); + $this->setProperty('u', (string)$userId); return $this; } public function getSessionToken(): string { - return $this->sessionToken ?? ''; + if(!$this->hasProperty('t')) + return ''; + return bin2hex($this->getProperty('t')); } public function setSessionToken(string $token): self { - $this->session = null; - $this->sessionToken = $token; - return $this; - } - public function getSession(): UserSession { - if($this->session === null) - $this->session = UserSession::byToken($this->getSessionToken()); - return $this->session; - } - public function setSession(UserSession $session): self { - $this->session = $session; - $this->sessionToken = $session->getToken(); + $this->setProperty('t', hex2bin($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 { - $packed = pack('CNH*', self::VERSION, $this->getUserId(), $this->getSessionToken()); + $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) - $packed = Serialiser::uriBase64()->serialise($packed); - return $packed; + $data = Serialiser::uriBase64()->serialise($data); + + return $data; } public static function unpack(string $data, bool $base64 = true): self { @@ -72,20 +109,105 @@ class AuthToken { return $obj; if($base64) $data = Serialiser::uriBase64()->deserialise($data); + if(empty($data)) + return $obj; - $data = str_pad($data, self::WIDTH, "\x00"); - $data = unpack('Cversion/Nuser/H*token', $data); + $version = ord($data[0]); + $data = substr($data, 1); - if($data['version'] >= 1) - $obj->setUserId($data['user']) - ->setSessionToken($data['token']); + if($version === 1) { + $data = str_pad($data, 36, "\x00"); + $data = unpack('Nuser/H*token', $data); + + $obj->props['u'] = (string)$data['user']; + $obj->props['t'] = hex2bin($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; + + extract(unpack('Ntimestamp', $timestamp)); + $obj->timestamp = $timestamp; + + $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(User $user, UserSession $session): self { - return (new AuthToken) - ->setUser($user) - ->setSession($session); + $token = new AuthToken; + $token->setUserId($user->getId()); + $token->setSessionToken($session->getToken()); + return $token; + } + + public function applyCookie(int $expires = 0): void { + if($expires > 0) + $this->cookieExpires = $expires; + else + $expires = $this->cookieExpires; + + setcookie('msz_auth', $this->pack(), $expires, '/', msz_cookie_domain(), !empty($_SERVER['HTTPS']), true); + } + + public static function nukeCookie(): void { + setcookie('msz_auth', '', -9001, '/', msz_cookie_domain(), !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); + } + + // please never use the below functions beyond the scope of the sharpchat auth stuff + // a better mechanism for keeping a global instance of this available + // that isn't a $GLOBAL variable or static instance needs to be established, for User and UserSession as well + + private static $localToken = null; + + public function setCurrent(): void { + self::$localToken = $this; + } + public static function unsetCurrent(): void { + self::$localToken = null; + } + public static function getCurrent(): ?self { + return self::$localToken; + } + public static function hasCurrent(): bool { + return self::$localToken !== null; } } diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index 6a6cbe4..15574d9 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -126,16 +126,22 @@ final class SharpChatRoutes { if($request->getMethod() === 'OPTIONS') return 204; - if(!UserSession::hasCurrent()) + if(!UserSession::hasCurrent() || !AuthToken::hasCurrent()) return ['ok' => false]; + $token = AuthToken::getCurrent(); $session = UserSession::getCurrent(); + if($session->getToken() !== $token->getSessionToken()) + return ['ok' => false]; + $user = $session->getUser(); - $token = AuthToken::create($user, $session); + $userId = $token->hasImpersonatedUserId() && $user->isSuper() + ? $token->getImpersonatedUserId() + : $user->getId(); return [ 'ok' => true, - 'usr' => $user->getId(), + 'usr' => $userId, 'tkn' => $token->pack(), ]; } @@ -281,6 +287,15 @@ final class SharpChatRoutes { $sessionInfo->bump($ipAddress); $userInfo = $sessionInfo->getUser(); + if($authTokenInfo->hasImpersonatedUserId() && $userInfo->isSuper()) { + $userInfoReal = $userInfo; + + try { + $userInfo = User::byId($authTokenInfo->getImpersonatedUserId()); + } catch(UserNotFoundException $ex) { + $userInfo = $userInfoReal; + } + } } else { return ['success' => false, 'reason' => 'unsupported']; } diff --git a/src/url.php b/src/url.php index e117ffc..16ecf9e 100644 --- a/src/url.php +++ b/src/url.php @@ -22,6 +22,7 @@ define('MSZ_URLS', [ 'auth-logout' => ['/auth/logout.php', ['csrf' => '{csrf}']], 'auth-resolve-user' => ['/auth/login.php', ['resolve' => '1', 'name' => '']], 'auth-two-factor' => ['/auth/twofactor.php', ['token' => '']], + 'auth-revert' => ['/auth/revert.php', ['csrf' => '{csrf}']], 'changelog-index' => ['/changelog', ['date' => '', 'user' => '', 'tags' => '', 'p' => '']], 'changelog-feed-rss' => ['/changelog.rss'], diff --git a/templates/_layout/header.twig b/templates/_layout/header.twig index b88520f..53b516a 100644 --- a/templates/_layout/header.twig +++ b/templates/_layout/header.twig @@ -1,6 +1,22 @@ {% from 'macros.twig' import avatar %} {% from '_layout/input.twig' import input_checkbox_raw %} +{% if current_user_real is defined %} + +{% endif %} +