diff --git a/lib/index b/lib/index index 1a8344c..bce5ba7 160000 --- a/lib/index +++ b/lib/index @@ -1 +1 @@ -Subproject commit 1a8344c1c31cb62726305d079384045582315f64 +Subproject commit bce5ba77a268ecd6338d0e3520e41ff4c40cbeda diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php index b5a2dd0..6a6cbe4 100644 --- a/src/SharpChat/SharpChatRoutes.php +++ b/src/SharpChat/SharpChatRoutes.php @@ -13,6 +13,7 @@ use Misuzu\Users\UserSession; use Misuzu\Users\UserWarning; use Misuzu\Users\UserNotFoundException; use Misuzu\Users\UserWarningCreationFailedException; +use Misuzu\Users\UserSessionNotFoundException; final class SharpChatRoutes { private IConfig $config; @@ -31,23 +32,43 @@ final class SharpChatRoutes { $this->hashKey = $hashKey; } + // Simplify default error pages + $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, 'emotes']); - $router->get('/_sockchat/login', [$this, 'login']); - $router->options('/_sockchat/token', [$this, 'token']); - $router->get('/_sockchat/token', [$this, 'token']); + $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->get('/_sockchat/resolve', [$this, 'resolve']); - $router->post('/_sockchat/bump', [$this, 'bump']); - $router->post('/_sockchat/verify', [$this, 'verify']); - $router->get('/_sockchat/bans', [$this, 'bans']); - $router->get('/_sockchat/bans/check', [$this, 'checkBan']); - $router->post('/_sockchat/bans/create', [$this, 'createBan']); - $router->delete('/_sockchat/bans/remove', [$this, 'removeBan']); + $router->get('/_sockchat/resolve', [$this, 'getResolve']); + $router->post('/_sockchat/bump', [$this, 'postBump']); + $router->post('/_sockchat/verify', [$this, 'postVerify']); + $router->get('/_sockchat/bans', [$this, 'getBans']); + $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 static function emotes($response, $request): array { + public static function getEmotes($response, $request): array { $response->setHeader('Access-Control-Allow-Origin', '*'); $response->setHeader('Access-Control-Allow-Methods', 'GET'); @@ -70,7 +91,7 @@ final class SharpChatRoutes { return $out; } - public function login($response, $request): void { + public function getLogin($response, $request): void { $currentUser = User::getCurrent(); $configKey = $request->hasParam('legacy') ? 'chatPath.legacy' : 'chatPath.normal'; $chatPath = $this->config->getValue($configKey, IConfig::T_STR, '/'); @@ -82,7 +103,7 @@ final class SharpChatRoutes { ); } - public function token($response, $request) { + 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) ?? ''); @@ -119,7 +140,7 @@ final class SharpChatRoutes { ]; } - public function resolve($response, $request): array { + public function getResolve($response, $request): array { $userHash = $request->hasHeader('X-SharpChat-Signature') ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; $method = (string)$request->getParam('m'); @@ -153,86 +174,121 @@ final class SharpChatRoutes { ]; } - public function bump($response, $request) { - if(!$request->isStringContent()) + public function postBump($response, $request) { + if(!$request->hasHeader('X-SharpChat-Signature')) return 400; - $userHash = $request->hasHeader('X-SharpChat-Signature') - ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; - $bumpString = (string)$request->getContent(); - $realHash = hash_hmac('sha256', $bumpString, $this->hashKey); + 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 { + $bumpString = (string)$request->getContent(); + $signature = $bumpString; + } + + $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature'); + $realHash = hash_hmac('sha256', $signature, $this->hashKey); if(!hash_equals($realHash, $userHash)) - return; + return 403; - $bumpInfo = json_decode($bumpString); + if(empty($bumpString)) { + if($userTime < time() - 60) + return 403; + } else { + $bumpList = []; + $bumpInfo = json_decode($bumpString); + if(empty($bumpInfo)) + return; - if(empty($bumpInfo)) - return; + foreach($bumpInfo as $bumpUser) + if(!empty($bumpUser->id) && !empty($bumpUser->ip)) + $bumpList[$bumpUser->id] = $bumpUser->ip; + } - foreach($bumpInfo as $bumpUser) - try { - User::byId($bumpUser->id)->bumpActivity($bumpUser->ip); - } catch(UserNotFoundException $ex) {} + foreach($bumpList as $userId => $ipAddr) + User::byId($userId)->bumpActivity($ipAddr); } - public function verify($response, $request): array { + public function postVerify($response, $request) { + if(!$request->hasHeader('X-SharpChat-Signature')) + return 400; + if($request->isStreamContent()) $authInfo = json_decode((string)$request->getContent()); elseif($request->isJsonContent()) $authInfo = $request->getContent()->getContent(); // maybe change this api lol, this looks silly - else + elseif($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 = $request->hasHeader('X-SharpChat-Signature') - ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; - + $userHash = (string)$request->getHeaderFirstLine('X-SharpChat-Signature'); if(strlen($userHash) !== 64) return ['success' => false, 'reason' => 'length']; - if(!isset($authInfo->user_id, $authInfo->token, $authInfo->ip)) + if(isset($authInfo) && !empty($authInfo->token) && !empty($authInfo->ip)) { + // user_id is discarded now + // tokens should be entirely unique anyway + + $tokenParts = explode(':', $authInfo->token, 2); + if(count($tokenParts) < 2) { + $authMethod = ''; + $authToken = $tokenParts[0]; + } else [$authMethod, $authToken] = $tokenParts; + + $ipAddress = $authInfo->ip; + $signature = "{$authInfo->user_id}#{$authInfo->token}#{$authInfo->ip}"; + } + + if(empty($authMethod) || empty($authToken)) return ['success' => false, 'reason' => 'data']; - $realHash = hash_hmac('sha256', implode('#', [$authInfo->user_id, $authInfo->token, $authInfo->ip]), $this->hashKey); + if(!isset($signature)) + $signature = "verify#{$authMethod}#{$authToken}#{$ipAddress}"; + $realHash = hash_hmac('sha256', $signature, $this->hashKey); if(!hash_equals($realHash, $userHash)) return ['success' => false, 'reason' => 'hash']; - try { - $userInfo = User::byId($authInfo->user_id); - } catch(UserNotFoundException $ex) { - return ['success' => false, 'reason' => 'user']; - } - - $authMethod = mb_substr($authInfo->token, 0, 5); - - if($authMethod === 'SESS:') { - $sessionToken = mb_substr($authInfo->token, 5); - - $authToken = AuthToken::unpack($sessionToken); - if($authToken->isValid()) - $sessionToken = $authToken->getSessionToken(); + if($authMethod === 'SESS' || $authMethod === 'Misuzu') { + $authTokenInfo = AuthToken::unpack($authToken); + if($authTokenInfo->isValid()) + $authToken = $authTokenInfo->getSessionToken(); try { - $sessionInfo = UserSession::byToken($sessionToken); + $sessionInfo = UserSession::byToken($authToken); } catch(UserSessionNotFoundException $ex) { return ['success' => false, 'reason' => 'token']; } - if($sessionInfo->getUserId() !== $userInfo->getId()) - return ['success' => false, 'reason' => 'user']; - if($sessionInfo->hasExpired()) { $sessionInfo->delete(); return ['success' => false, 'reason' => 'expired']; } - $sessionInfo->bump($authInfo->ip); + $sessionInfo->bump($ipAddress); + + $userInfo = $sessionInfo->getUser(); } else { return ['success' => false, 'reason' => 'unsupported']; } - $userInfo->bumpActivity($authInfo->ip); + if(empty($userInfo)) + return ['success' => false, 'reason' => 'user']; + + $userInfo->bumpActivity($ipAddress); return [ 'success' => true, @@ -246,7 +302,7 @@ final class SharpChatRoutes { ]; } - public function bans($response, $request): array { + public function getBans($response, $request): array { $userHash = $request->hasHeader('X-SharpChat-Signature') ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; $realHash = hash_hmac('sha256', 'givemethebeans', $this->hashKey); @@ -279,50 +335,105 @@ final class SharpChatRoutes { return $bans; } - public function checkBan($response, $request): array { - $userHash = $request->hasHeader('X-SharpChat-Signature') - ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; - $ipAddress = (string)$request->getParam('a'); - $userId = (int)$request->getParam('u', FILTER_SANITIZE_NUMBER_INT); - - $realHash = hash_hmac('sha256', "check#{$ipAddress}#{$userId}", $this->hashKey); - if(!hash_equals($realHash, $userHash)) - return []; - - $response = []; - $warning = UserWarning::byRemoteAddressActive($ipAddress) - ?? UserWarning::byUserIdActive($userId); - - if($warning !== null) { - $response['warning'] = $warning->getId(); - $response['id'] = $warning->getUserId(); - $response['user_id'] = $warning->getUserId(); - $response['ip'] = $warning->getUserRemoteAddress(); - $response['is_permanent'] = $warning->isPermanent(); - $response['expires'] = date('c', $response['is_permanent'] ? 0x7FFFFFFF : $warning->getExpirationTime()); - } else { - $response['expires'] = date('c', 0); - $response['is_permanent'] = false; - } - - return $response; - } - - public function createBan($response, $request): int { - if(!$request->isFormContent()) + public function getBanList($response, $request) { + if(!$request->hasHeader('X-SharpChat-Signature')) return 400; - $userHash = $request->hasHeader('X-SharpChat-Signature') - ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; + $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; + + $warnings = UserWarning::byActive(); + $bans = []; + + foreach($warnings as $warning) { + if(!$warning->isBan() || $warning->hasExpired()) + continue; + + $isPerma = $warning->isPermanent(); + $userInfo = $warning->getUser(); + $bans[] = [ + 'is_ban' => true, + 'user_id' => (string)$userInfo->getId(), + 'user_name' => $userInfo->getUsername(), + 'user_colour' => Colour::toMisuzu($userInfo->getColour()), + 'ip_addr' => $warning->getUserRemoteAddress(), + 'is_perma' => $isPerma, + 'expires' => date('c', $isPerma ? 0xFFFFFFFF : $warning->getExpirationTime()), + ]; + } + + return $bans; + } + + 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(!empty($ipAddress)) + $warning = UserWarning::byRemoteAddressActive($ipAddress); + + if(empty($warning) && !empty($userId)) { + if($userIdIsName) + try { + $userInfo = User::byUsername($userId); + $userId = $userInfo->getId(); + } catch(UserNotFoundException $ex) { + $userId = 0; + } + + $warning = UserWarning::byUserIdActive((int)$userId); + } + + if($warning === null) + return ['is_ban' => false]; + + $isPerma = $warning->isPermanent(); + + return [ + 'is_ban' => true, + 'user_id' => (string)$warning->getUserId(), + 'ip_addr' => $warning->getUserRemoteAddress(), + 'is_perma' => $isPerma, + 'expires' => date('c', $isPerma ? 0xFFFFFFFF : $warning->getExpirationTime()), + ]; + } + + 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(); - $userId = (int)$content->getParam('u', FILTER_SANITIZE_NUMBER_INT); - $modId = (int)$content->getParam('m', FILTER_SANITIZE_NUMBER_INT); + $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'); - $realHash = hash_hmac('sha256', "create#{$userId}#{$modId}#{$duration}#{$isPermanent}#{$reason}", $this->hashKey); - if(!hash_equals($realHash, $userHash)) + $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)) @@ -333,14 +444,21 @@ final class SharpChatRoutes { elseif($duration < 1) return 400; + // IPs cannot be banned on their own + // substituting with the unused Railgun account for now. + if(empty($targetId)) + $targetId = 69; + if(empty($modId)) + $modId = 69; + try { - $userInfo = User::byId($userId); + $modInfo = User::byId((int)$modId); } catch(UserNotFoundException $ex) { return 404; } try { - $modInfo = User::byId($modId); + $userInfo = User::byId((int)$userId); } catch(UserNotFoundException $ex) { return 404; } @@ -351,7 +469,10 @@ final class SharpChatRoutes { $modInfo, UserWarning::TYPE_BAHN, $duration, - $reason + $reason, + null, + $userAddr, + $modAddr ); } catch(UserWarningCreationFailedException $ex) { return 500; @@ -360,19 +481,22 @@ final class SharpChatRoutes { return 201; } - public function removeBan($response, $request): int { - $userHash = $request->hasHeader('X-SharpChat-Signature') - ? $request->getHeaderFirstLine('X-SharpChat-Signature') : ''; + 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', "remove#{$type}#{$subject}", $this->hashKey); - if(!hash_equals($realHash, $userHash)) + $realHash = hash_hmac('sha256', "revoke#{$userTime}#{$type}#{$subject}", $this->hashKey); + if(!hash_equals($realHash, $userHash) || $userTime < time() - 60) return 403; $warning = null; switch($type) { - case 'ip': + case 'addr': $warning = UserWarning::byRemoteAddressActive($subject); break; diff --git a/src/Users/UserWarning.php b/src/Users/UserWarning.php index ad8d35b..a78c76b 100644 --- a/src/Users/UserWarning.php +++ b/src/Users/UserWarning.php @@ -162,7 +162,16 @@ class UserWarning { ->execute(); } - public static function create(User $user, User $issuer, int $type, int $duration, string $publicNote, ?string $privateNote = null): self { + public static function create( + User $user, + User $issuer, + int $type, + int $duration, + string $publicNote, + ?string $privateNote = null, + ?string $targetAddr = null, + ?string $issuerAddr = null + ): self { if(!in_array($type, self::TYPES)) throw new InvalidArgumentException('Type was invalid.'); @@ -175,13 +184,16 @@ class UserWarning { $duration = -1; } + $targetAddr ??= $user->getLastRemoteAddress(); + $issuerAddr ??= $issuer->getLastRemoteAddress(); + $warningId = DB::prepare( 'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (`user_id`, `user_ip`, `issuer_id`, `issuer_ip`, `warning_created`, `warning_duration`, `warning_type`, `warning_note`, `warning_note_private`)' . ' VALUES (:user, INET6_ATON(:user_addr), :issuer, INET6_ATON(:issuer_addr), NOW(), IF(:set_duration, NOW() + INTERVAL :duration SECOND, NULL), :type, :public_note, :private_note)' ) ->bind('user', $user->getId()) - ->bind('user_addr', $user->getLastRemoteAddress()) + ->bind('user_addr', $targetAddr) ->bind('issuer', $issuer->getId()) - ->bind('issuer_addr', $issuer->getLastRemoteAddress()) + ->bind('issuer_addr', $issuerAddr) ->bind('set_duration', $duration > 0 ? 1 : 0) ->bind('duration', $duration) ->bind('type', $type)