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.
This commit is contained in:
flash 2023-08-03 01:35:08 +00:00
parent 383e2ed0e0
commit 00d1d2922d
17 changed files with 618 additions and 372 deletions

126
composer.lock generated
View File

@ -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": [

View File

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

View File

@ -1,16 +1,25 @@
<?php
namespace Misuzu;
if(!$msz->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');

View File

@ -1,16 +1,22 @@
<?php
namespace Misuzu;
if(!isset($userInfoReal) || !$authToken->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');

View File

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

View File

@ -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.';

View File

@ -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)) {

86
src/Auth/AuthInfo.php Normal file
View File

@ -0,0 +1,86 @@
<?php
namespace Misuzu\Auth;
use Misuzu\Auth\SessionInfo;
use Misuzu\Users\UserInfo;
class AuthInfo {
private AuthTokenInfo $tokenInfo;
private ?UserInfo $userInfo;
private ?SessionInfo $sessionInfo;
private ?UserInfo $realUserInfo;
public function __construct(
?AuthTokenInfo $tokenInfo = null,
?UserInfo $userInfo = null,
?SessionInfo $sessionInfo = null,
?UserInfo $realUserInfo = null
) {
$this->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();

View File

@ -0,0 +1,72 @@
<?php
namespace Misuzu\Auth;
use Misuzu\Auth\SessionInfo;
use Misuzu\Users\UserInfo;
class AuthTokenBuilder {
private array $props;
private bool $edited = false;
public function __construct(?AuthTokenInfo $baseTokenInfo = null) {
$this->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);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace Misuzu\Auth;
// is this the right way to do this?
final class AuthTokenCookie {
public static function domain(): string {
$url = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);
if(empty($url))
$url = $_SERVER['HTTP_HOST'];
if(!filter_var($url, FILTER_VALIDATE_IP))
$url = '.' . $url;
return $url;
}
public static function apply(string $packed): void {
setcookie('msz_auth', $packed, strtotime('+3 months'), '/', self::domain(), !empty($_SERVER['HTTPS']), true);
}
public static function nuke(): void {
setcookie('msz_auth', '', -9001, '/', self::domain(), !empty($_SERVER['HTTPS']), true);
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace Misuzu\Auth;
class AuthTokenInfo {
// Standard auth token properties
// Props may be used for general purpose stuff not defined here but don't use single char names for them
public const USER_ID = 'u'; // User ID
public const SESSION_TOKEN = 's'; // Session token
public const SESSION_TOKEN_HEX = 't'; // Session token that should be hex encoded
public const IMPERSONATED_USER_ID = 'i'; // Impersonated user ID
public function __construct(
private int $timestamp = 0,
private array $props = []
) {}
public function isEmpty(): bool {
return $this->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();

View File

@ -0,0 +1,99 @@
<?php
namespace Misuzu\Auth;
use RuntimeException;
use Index\IO\MemoryStream;
use Index\Serialisation\UriBase64;
class AuthTokenPacker {
private const EPOCH_V2 = 1682985600;
public function __construct(private string $secretKey) {}
public function pack(AuthTokenBuilder|AuthTokenInfo $tokenInfo): string {
$props = $tokenInfo->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);
}
}

View File

@ -1,215 +0,0 @@
<?php
namespace Misuzu;
use Index\IO\MemoryStream;
use Index\Serialisation\UriBase64;
use Misuzu\Auth\SessionInfo;
use Misuzu\Users\UserInfo;
/* Map of props
* u - User ID
* s - Plaintext token string
* t - Old hex token string, fallback for s
* i - Impersonation User ID
*/
class AuthToken {
private const EPOCH = 1682985600;
private int $timestamp = 0;
private int $cookieExpires = 0;
private array $props = [];
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 {
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);
}
}

View File

@ -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')

View File

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

View File

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

View File

@ -1,12 +1,12 @@
<?php
namespace Misuzu\SharpChat;
use Closure;
use RuntimeException;
use Index\Colour\Colour;
use Index\Routing\IRouter;
use Index\Http\HttpFx;
use Misuzu\AuthToken;
use Misuzu\MisuzuContext;
use Misuzu\Auth\AuthInfo;
use Misuzu\Auth\Sessions;
use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes;
@ -15,28 +15,31 @@ use Misuzu\Users\Users;
final class SharpChatRoutes {
private IConfig $config;
private MisuzuContext $context;
private Bans $bans;
private Emotes $emotes;
private Users $users;
private Sessions $sessions;
private AuthInfo $authInfo;
private Closure $createAuthTokenPacker;
private string $hashKey;
public function __construct(
IRouter $router,
IConfig $config,
MisuzuContext $context,
Bans $bans,
Emotes $emotes,
Users $users,
Sessions $sessions
Sessions $sessions,
AuthInfo $authInfo,
Closure $createAuthTokenPacker // this sucks lol
) {
$this->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);