diff --git a/src/CSRF.php b/src/CSRF.php index bfd8c38..37b0b45 100644 --- a/src/CSRF.php +++ b/src/CSRF.php @@ -5,9 +5,19 @@ use Index\Security\CSRFP; final class CSRF { private static CSRFP $instance; + private static string $secretKey = ''; + + public static function create(string $identity, ?string $secretKey = null): CSRFP { + if($secretKey === null) + $secretKey = self::$secretKey; + else + self::$secretKey = $secretKey; + + return new CSRFP($secretKey, $identity); + } public static function init(string $secretKey, string $identity): void { - self::$instance = new CSRFP($secretKey, $identity); + self::$instance = self::create($identity, $secretKey); } public static function validate(string $token, int $tolerance = -1): bool { diff --git a/src/Hanyuu/HanyuuRoutes.php b/src/Hanyuu/HanyuuRoutes.php new file mode 100644 index 0000000..e4c198e --- /dev/null +++ b/src/Hanyuu/HanyuuRoutes.php @@ -0,0 +1,212 @@ +config->getString('endpoint'); + } + + private function getSecret(): string { + return $this->config->getString('secret'); + } + + private static function createPayload(string $name, array $attrs = []): object { + $payload = new stdClass; + $payload->name = $name; + $payload->attrs = []; + foreach($attrs as $name => $value) { + if($value === null) + continue; + $payload->attrs[(string)$name] = $value; + } + + return $payload; + } + + private static function createErrorPayload(string $code, ?string $text = null): object { + $attrs = ['code' => $code]; + if($text !== null && $text !== '') + $attrs['text'] = $text; + + return self::createPayload('error', $attrs); + } + + #[HttpMiddleware('/_hanyuu/(.*)')] + public function verifyRequest($response, $request, string $action) { + $userTime = (int)$request->getHeaderLine('X-Hanyuu-Timestamp'); + $userHash = UriBase64::decode((string)$request->getHeaderLine('X-Hanyuu-Signature')); + + $currentTime = time(); + if(empty($userHash) || $userTime < $currentTime - 60 || $userTime > $currentTime + 60) + return self::createErrorPayload('verification', 'Request verification failed.'); + + $url = sprintf('%s/_hanyuu/%s', $this->getEndpoint(), $action); + $params = $request->getParamString(); + if($params !== '') + $url .= sprintf('?%s', $params); + + if($request->getMethod() === 'POST') { + if(!$request->isFormContent()) + return self::createErrorPayload('request', 'Request body is not in expect format.'); + + $body = $request->getContent()->getParamString(); + } elseif($request->getMethod() !== 'GET') { + return self::createErrorPayload('request', 'Only GET and POST methods are allowed.'); + } else { + $body = ''; + } + + $verifyHash = hash_hmac( + 'sha256', + sprintf('[%s|%s|%s]', $userTime, $url, $body), + $this->getSecret(), + true + ); + + if(!hash_equals($verifyHash, $userHash)) + return self::createErrorPayload('verification', 'Request verification failed.'); + } + + private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool { + if($impersonator->isSuperUser()) + return true; + + return in_array( + $targetId, + $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->getId())), + true + ); + } + + #[HttpPost('/_hanyuu/auth-check')] + public function postCheckAuth($response, $request) { + $content = $request->getContent(); + $method = (string)$content->getParam('method'); + if($method !== 'Misuzu') + return self::createErrorPayload('auth:check:method', 'Requested auth method is not supported.'); + + $remoteAddr = (string)$content->getParam('remote_addr'); + if(filter_var($remoteAddr, FILTER_VALIDATE_IP) === false) + return self::createErrorPayload('auth:check:remote_addr', 'Provided remote address is not in a valid format.'); + + $avatarResolutions = trim((string)$content->getParam('avatars')); + if($avatarResolutions === '') { + $avatarResolutions = []; + } else { + $avatarResolutions = explode(',', $avatarResolutions); + foreach($avatarResolutions as $key => $avatarRes) { + if(!ctype_digit($avatarRes)) + return self::createErrorPayload('auth:check:avatars', 'Avatar resolution set must be a comma separated set of numbers or empty.'); + $avatarResolutions[$key] = (int)$avatarRes; + } + $avatarResolutions = array_unique($avatarResolutions); + } + + $loginUrl = $this->getEndpoint() . $this->urls->format('auth-login'); + $registerUrl = $this->getEndpoint() . $this->urls->format('auth-register'); + + $tokenPacker = $this->authCtx->createAuthTokenPacker(); + $tokenInfo = $tokenPacker->unpack(trim((string)$content->getParam('token'))); + if($tokenInfo->isEmpty()) + return self::createPayload('auth:check:fail', ['reason' => 'empty', 'login_url' => $loginUrl, 'register_url' => $registerUrl]); + + $sessions = $this->authCtx->getSessions(); + try { + $sessionInfo = $sessions->getSession(sessionToken: $tokenInfo->getSessionToken()); + } catch(RuntimeException $ex) { + return self::createPayload('auth:check:fail', ['reason' => 'session', 'login_url' => $loginUrl, 'register_url' => $registerUrl]); + } + + if($sessionInfo->hasExpired()) { + $sessions->deleteSessions(sessionInfos: $sessionInfo); + return self::createPayload('auth:check:fail', ['reason' => 'expired', 'login_url' => $loginUrl, 'register_url' => $registerUrl]); + } + + $sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $remoteAddr); + + $users = $this->usersCtx->getUsers(); + $userInfo = $userInfoReal = $users->getUser($sessionInfo->getUserId(), 'id'); + if($tokenInfo->hasImpersonatedUserId() && $this->canImpersonateUserId($userInfo, $tokenInfo->getImpersonatedUserId())) { + try { + $userInfo = $users->getUser($tokenInfo->getImpersonatedUserId(), 'id'); + } catch(RuntimeException $ex) { + $userInfo = $userInfoReal; + } + } + + $response = []; + $response['session'] = [ + 'created_at' => $sessionInfo->getCreatedTime(), + 'expires_at' => $sessionInfo->getExpiresTime(), + 'lifetime_extends' => $sessionInfo->shouldBumpExpires(), + ]; + + $banInfo = $this->usersCtx->tryGetActiveBan($userInfo); + if($banInfo !== null) + $response['ban'] = [ + 'severity' => $banInfo->getSeverity(), + 'reason' => $banInfo->getPublicReason(), + 'created_at' => $banInfo->getCreatedTime(), + 'is_permanent' => $banInfo->isPermanent(), + 'expires_at' => $banInfo->getExpiresTime(), + 'duration_str' => $banInfo->getDurationString(), + 'remaining_str' => $banInfo->getRemainingString(), + ]; + + $gatherRequestedAvatars = function($userInfo) use ($avatarResolutions) { + $formatAvatarUrl = fn($res = 0) => ( + $this->getEndpoint() . $this->urls->format('user-avatar', ['user' => $userInfo->getId(), 'res' => $res]) + ); + + $avatars = ['original' => $formatAvatarUrl()]; + + foreach($avatarResolutions as $avatarRes) + $avatars[sprintf('x%d', $avatarRes)] = $formatAvatarUrl($avatarRes); + + return $avatars; + }; + + $extractUserInfo = fn($userInfo) => [ + 'id' => $userInfo->getId(), + 'name' => $userInfo->getName(), + 'colour' => (string)$users->getUserColour($userInfo), + 'rank' => $users->getUserRank($userInfo), + 'is_super' => $userInfo->isSuperUser(), + 'country_code' => $userInfo->getCountryCode(), + 'is_deleted' => $userInfo->isDeleted(), + 'has_totp' => $userInfo->hasTOTPKey(), + 'profile_url' => $this->getEndpoint() . $this->urls->format('user-profile', ['user' => $userInfo->getId()]), + 'avatars' => $gatherRequestedAvatars($userInfo), + ]; + + $response['user'] = $extractUserInfo($userInfo); + if($userInfo !== $userInfoReal) { + $response['guise'] = $extractUserInfo($userInfoReal); + + $csrfp = CSRF::create($sessionInfo->getToken()); + $response['guise']['revert_url'] = $this->getEndpoint() . $this->urls->format('auth-revert', ['csrf' => $csrfp->createToken()]); + } + + return self::createPayload('auth:check:success', $response); + } +} diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index 38ca4c6..a598070 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -278,6 +278,15 @@ class MisuzuContext { $this->profileFields )); + $routingCtx->register(new \Misuzu\Hanyuu\HanyuuRoutes( + $this->config->scopeTo('hanyuu'), + $this->config->scopeTo('impersonate'), + $this->urls, + $this->usersCtx, + $this->authCtx, + $this->authInfo + )); + $routingCtx->register(new LegacyRoutes($this->urls)); return $routingCtx;