misuzu/src/SharpChat/SharpChatRoutes.php
flash 00d1d2922d 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.
2023-08-03 01:35:08 +00:00

448 lines
16 KiB
PHP

<?php
namespace Misuzu\SharpChat;
use Closure;
use RuntimeException;
use Index\Colour\Colour;
use Index\Routing\IRouter;
use Index\Http\HttpFx;
use Misuzu\Auth\AuthInfo;
use Misuzu\Auth\Sessions;
use Misuzu\Config\IConfig;
use Misuzu\Emoticons\Emotes;
use Misuzu\Users\Bans;
use Misuzu\Users\Users;
final class SharpChatRoutes {
private IConfig $config;
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,
Bans $bans,
Emotes $emotes,
Users $users,
Sessions $sessions,
AuthInfo $authInfo,
Closure $createAuthTokenPacker // this sucks lol
) {
$this->config = $config;
$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
if($router instanceof HttpFx)
$router->use('/_sockchat', function() use($router) {
$router->addErrorHandler(400, function($response) {
$response->setContent('HTTP 400');
});
$router->addErrorHandler(403, function($response) {
$response->setContent('HTTP 403');
});
$router->addErrorHandler(404, function($response) {
$response->setContent('HTTP 404');
});
$router->addErrorHandler(500, function($response) {
$response->setContent('HTTP 500');
});
$router->addErrorHandler(503, function($response) {
$response->setContent('HTTP 503');
});
});
// Public endpoints
$router->get('/_sockchat/emotes', [$this, 'getEmotes']);
$router->get('/_sockchat/login', [$this, 'getLogin']);
$router->options('/_sockchat/token', [$this, 'getToken']);
$router->get('/_sockchat/token', [$this, 'getToken']);
// Private endpoints
$router->post('/_sockchat/bump', [$this, 'postBump']);
$router->post('/_sockchat/verify', [$this, 'postVerify']);
$router->get('/_sockchat/bans/list', [$this, 'getBanList']);
$router->get('/_sockchat/bans/check', [$this, 'getBanCheck']);
$router->post('/_sockchat/bans/create', [$this, 'postBanCreate']);
$router->delete('/_sockchat/bans/revoke', [$this, 'deleteBanRevoke']);
}
public function getEmotes($response, $request): array {
$response->setHeader('Access-Control-Allow-Origin', '*');
$response->setHeader('Access-Control-Allow-Methods', 'GET');
$emotes = $this->emotes->getAllEmotes(withStrings: true);
$out = [];
foreach($emotes as $emoteInfo) {
$strings = [];
foreach($emoteInfo->getStrings() as $stringInfo)
$strings[] = sprintf(':%s:', $stringInfo->getString());
$out[] = [
'Text' => $strings,
'Image' => $emoteInfo->getUrl(),
'Hierarchy' => $emoteInfo->getMinRank(),
];
}
return $out;
}
public function getLogin($response, $request): void {
if(!$this->authInfo->isLoggedIn()) {
$response->redirect(url('auth-login'));
return;
}
$response->redirect($this->config->getString(
($request->hasParam('legacy') ? 'chatPath.legacy' : 'chatPath.normal'),
'/'
));
}
public function getToken($response, $request) {
$host = $request->hasHeader('Host') ? $request->getHeaderFirstLine('Host') : '';
$origin = $request->hasHeader('Origin') ? $request->getHeaderFirstLine('Origin') : '';
$originHost = strtolower(parse_url($origin, PHP_URL_HOST) ?? '');
if(!empty($originHost) && $originHost !== $host) {
$whitelist = $this->config->getArray('origins', []);
if(!in_array($originHost, $whitelist))
return 403;
$originProto = strtolower(parse_url($origin, PHP_URL_SCHEME));
$origin = $originProto . '://' . $originHost;
$response->setHeader('Access-Control-Allow-Origin', $origin);
$response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET');
$response->setHeader('Access-Control-Allow-Credentials', 'true');
$response->setHeader('Vary', 'Origin');
}
if($request->getMethod() === 'OPTIONS')
return 204;
$tokenInfo = $this->authInfo->getTokenInfo();
if(!$tokenInfo->hasSessionToken())
return ['ok' => false, 'err' => 'token'];
try {
$sessionInfo = $this->sessions->getSession(sessionToken: $tokenInfo->getSessionToken());
} catch(RuntimeException $ex) {
return ['ok' => false, 'err' => 'session'];
}
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' => $tokenPacker->pack($tokenInfo),
];
}
public function postBump($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
if($request->isFormContent()) {
$content = $request->getContent();
$bumpList = $content->getParam('u', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY);
if(!is_array($bumpList))
return 400;
$userTime = (int)$content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
$signature = "bump#{$userTime}";
foreach($bumpList as $userId => $ipAddr)
$signature .= "#{$userId}:{$ipAddr}";
} else return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$realHash = hash_hmac('sha256', $signature, $this->hashKey);
if(!hash_equals($realHash, $userHash))
return 403;
if($userTime < time() - 60)
return 403;
foreach($bumpList as $userId => $ipAddr)
$this->users->recordUserActivity($userId, remoteAddr: $ipAddr);
}
public function postVerify($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
if($request->isFormContent()) {
$content = $request->getContent();
$authMethod = (string)$content->getParam('method');
$authToken = (string)$content->getParam('token');
$ipAddress = (string)$content->getParam('ipaddr');
} else
return ['success' => false, 'reason' => 'request'];
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
if(strlen($userHash) !== 64)
return ['success' => false, 'reason' => 'length'];
if(empty($authMethod) || empty($authToken) || empty($ipAddress))
return ['success' => false, 'reason' => 'data'];
$signature = "verify#{$authMethod}#{$authToken}#{$ipAddress}";
$realHash = hash_hmac('sha256', $signature, $this->hashKey);
if(!hash_equals($realHash, $userHash))
return ['success' => false, 'reason' => 'hash'];
if($authMethod === 'SESS' || $authMethod === 'Misuzu') {
$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: $sessionToken);
} catch(RuntimeException $ex) {
return ['success' => false, 'reason' => 'token'];
}
if($sessionInfo->hasExpired()) {
$this->sessions->deleteSessions(sessionInfos: $sessionInfo);
return ['success' => false, 'reason' => 'expired'];
}
$this->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $ipAddress);
$userInfo = $this->users->getUser($sessionInfo->getUserId(), 'id');
if($tokenInfo->hasImpersonatedUserId() && $userInfo->isSuperUser()) {
$userInfoReal = $userInfo;
try {
$userInfo = $this->users->getUser($tokenInfo->getImpersonatedUserId(), 'id');
} catch(RuntimeException $ex) {
$userInfo = $userInfoReal;
}
}
} else {
return ['success' => false, 'reason' => 'unsupported'];
}
$this->users->recordUserActivity($userInfo, remoteAddr: $ipAddress);
$userColour = $this->users->getUserColour($userInfo);
$userRank = $this->users->getUserRank($userInfo);
return [
'success' => true,
'user_id' => (int)$userInfo->getId(),
'username' => $userInfo->getName(),
'colour_raw' => Colour::toMisuzu($userColour),
'rank' => $userRank,
'hierarchy' => $userRank,
'perms' => SharpChatPerms::convert($userInfo),
];
}
public function getBanList($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
$realHash = hash_hmac('sha256', "list#{$userTime}", $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
$list = [];
$bans = $this->bans->getBans(activeOnly: true);
$userInfos = [];
foreach($bans as $banInfo) {
$userId = $banInfo->getUserId();
if(array_key_exists($userId, $userInfos))
$userInfo = $userInfos[$userId];
else
$userInfos[$userId] = $userInfo = $this->users->getUser($userId, 'id');
$userColour = $this->users->getUserColour($userInfo);
$isPerma = $banInfo->isPermanent();
$list[] = [
'is_ban' => true,
'user_id' => $userId,
'user_name' => $userInfo->getName(),
'user_colour' => Colour::toMisuzu($userColour),
'ip_addr' => '::',
'is_perma' => $isPerma,
'expires' => date('c', $isPerma ? 0x7FFFFFFF : $banInfo->getExpiresTime())
];
}
return $list;
}
public function getBanCheck($response, $request) {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
$ipAddress = (string)$request->getParam('a');
$userId = (string)$request->getParam('u');
$userIdIsName = (int)$request->getParam('n', FILTER_SANITIZE_NUMBER_INT);
$realHash = hash_hmac('sha256', "check#{$userTime}#{$userId}#{$ipAddress}#{$userIdIsName}", $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if($userIdIsName)
try {
$userInfo = $this->users->getUser($userId, 'name');
$userId = (string)$userInfo->getId();
} catch(RuntimeException $ex) {
$userId = '';
}
$banInfo = $this->bans->tryGetActiveBan($userId);
if($banInfo === null)
return ['is_ban' => false];
$isPerma = $banInfo->isPermanent();
return [
'is_ban' => true,
'user_id' => $banInfo->getUserId(),
'ip_addr' => '::',
'is_perma' => $isPerma,
'expires' => date('c', $isPerma ? 0x7FFFFFFF : $banInfo->getExpiresTime()),
];
}
public function postBanCreate($response, $request): int {
if(!$request->hasHeader('X-SharpChat-Signature') || !$request->isFormContent())
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$content = $request->getContent();
$userTime = (int)$content->getParam('t', FILTER_SANITIZE_NUMBER_INT);
$userId = (string)$content->getParam('ui', FILTER_SANITIZE_NUMBER_INT);
$userAddr = (string)$content->getParam('ua');
$modId = (string)$content->getParam('mi', FILTER_SANITIZE_NUMBER_INT);
$modAddr = (string)$content->getParam('ma');
$duration = (int)$content->getParam('d', FILTER_SANITIZE_NUMBER_INT);
$isPermanent = (int)$content->getParam('p', FILTER_SANITIZE_NUMBER_INT);
$reason = (string)$content->getParam('r');
$signature = implode('#', [
'create', $userTime, $userId, $userAddr,
$modId, $modAddr, $duration, $isPermanent, $reason,
]);
$realHash = hash_hmac('sha256', $signature, $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if(empty($reason))
$reason = 'Banned through chat.';
// maybe also adds the last couple lines of the chat log to the private reason
$comment = sprintf('User IP address: %s, Moderator IP address: %s', $userAddr, $modAddr);
if($isPermanent)
$expires = null;
else {
$now = time();
$expires = $now + $duration;
if($expires < $now)
return 400;
}
// IPs cannot be banned on their own
// substituting with the unused Railgun account for now.
if(empty($userId))
$userId = 69;
if(empty($modId))
$modId = 69;
try {
$modInfo = $this->users->getUser($modId, 'id');
} catch(RuntimeException $ex) {
return 404;
}
try {
$userInfo = $this->users->getUser($userId, 'id');
} catch(RuntimeException $ex) {
return 404;
}
try {
$this->bans->createBan(
$userInfo,
$expires,
$reason,
$comment,
modInfo: $modInfo
);
} catch(RuntimeException $ex) {
return 500;
}
return 201;
}
public function deleteBanRevoke($response, $request): int {
if(!$request->hasHeader('X-SharpChat-Signature'))
return 400;
$userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature');
$userTime = (int)$request->getParam('x', FILTER_SANITIZE_NUMBER_INT);
$type = (string)$request->getParam('t');
$subject = (string)$request->getParam('s');
$realHash = hash_hmac('sha256', "revoke#{$userTime}#{$type}#{$subject}", $this->hashKey);
if(!hash_equals($realHash, $userHash) || $userTime < time() - 60)
return 403;
if($type !== 'user')
return 404;
$banInfo = $this->bans->tryGetActiveBan($subject);
if($banInfo === null)
return 404;
$this->bans->deleteBans($banInfo);
return 204;
}
}