diff --git a/composer.json b/composer.json index 2331f77..4e3f5eb 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "matomo/device-detector": "^6.1", "sentry/sdk": "^4.0", "phpseclib/phpseclib": "~3.0", - "nesbot/carbon": "^3.7" + "nesbot/carbon": "^3.7", + "flashwave/aiwass": "^1.0" }, "autoload": { "classmap": [ diff --git a/composer.lock b/composer.lock index f9d97aa..063c8d1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "16b27dc9d1837e8435dc95cecc4eeea2", + "content-hash": "8cfa8c2738ab51aba3efea42afb68771", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -366,6 +366,45 @@ ], "time": "2023-10-06T06:47:41+00:00" }, + { + "name": "flashwave/aiwass", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://patchii.net/flashii/aiwass.git", + "reference": "de27da54b603f0fdc06dab89341908e73ea7a880" + }, + "require": { + "ext-msgpack": ">=2.2", + "flashwave/index": "^0.2408.40014", + "php": ">=8.3" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "phpunit/phpunit": "^11.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Aiwass\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "bsd-3-clause-clear" + ], + "authors": [ + { + "name": "flashwave", + "email": "packagist@flash.moe", + "homepage": "https://flash.moe", + "role": "mom" + } + ], + "description": "Shared HTTP RPC client/server library.", + "homepage": "https://railgun.sh/aiwass", + "time": "2024-08-16T15:59:19+00:00" + }, { "name": "flashwave/index", "version": "v0.2408.40014", diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php index 31484af..9069c66 100644 --- a/src/HanyuuContext.php +++ b/src/HanyuuContext.php @@ -14,8 +14,8 @@ class HanyuuContext { private SasaeEnvironment $templating; private SiteInfo $siteInfo; - private MisuzuInterop $misuzuInterop; - private object $authInfo; + private MisuzuRpcClient $misuzuRpc; + private MisuzuAuthInfo $authInfo; private Apps\AppsContext $appsCtx; private OAuth2\OAuth2Context $oauth2Ctx; @@ -24,7 +24,7 @@ class HanyuuContext { $this->config = $config; $this->dbConn = $dbConn; $this->siteInfo = new SiteInfo($config->scopeTo('site')); - $this->misuzuInterop = new MisuzuInterop($config->scopeTo('misuzu')); + $this->misuzuRpc = new MisuzuRpcClient($config->scopeTo('misuzu')); $this->appsCtx = new Apps\AppsContext($dbConn); $this->oauth2Ctx = new OAuth2\OAuth2Context($dbConn); @@ -41,8 +41,8 @@ class HanyuuContext { ]); } - public function getMisuzu(): MisuzuInterop { - return $this->misuzuInterop; + public function getMisuzuRpc(): MisuzuRpcClient { + return $this->misuzuRpc; } public function getDatabase(): IDbConnection { @@ -62,7 +62,7 @@ class HanyuuContext { return new FsDbMigrationRepo(HAU_DIR_MIGRATIONS); } - public function getAuthInfo(): object { + public function getAuthInfo(): MisuzuAuthInfo { return $this->authInfo; } @@ -83,7 +83,7 @@ class HanyuuContext { $routingCtx = new RoutingContext($this->getTemplating()); $routingCtx->getRouter()->use('/', function($response, $request) { - $this->authInfo = $this->misuzuInterop->authCheck( + $this->authInfo = $this->misuzuRpc->authCheck( 'Misuzu', (string)$request->getCookie('msz_auth'), $_SERVER['REMOTE_ADDR'], diff --git a/src/MisuzuAuthBanInfo.php b/src/MisuzuAuthBanInfo.php new file mode 100644 index 0000000..2dc3ac9 --- /dev/null +++ b/src/MisuzuAuthBanInfo.php @@ -0,0 +1,44 @@ +info) && is_int($this->info['severity']) + ? $this->info['severity'] : 0; + } + + public function getReason(): string { + return array_key_exists('reason', $this->info) && is_string($this->info['reason']) + ? $this->info['reason'] : ''; + } + + public function getCreatedAt(): int { + return array_key_exists('created_at', $this->info) && is_int($this->info['created_at']) + ? $this->info['created_at'] : 0; + } + + public function isPermanent(): bool { + return array_key_exists('is_permanent', $this->info) + && is_bool($this->info['is_permanent']) + && $this->info['is_permanent']; + } + + public function getExpiresAt(): int { + return array_key_exists('expires_at', $this->info) && is_int($this->info['expires_at']) + ? $this->info['expires_at'] : 0; + } + + public function getDurationString(): string { + return array_key_exists('duration_str', $this->info) && is_string($this->info['duration_str']) + ? $this->info['duration_str'] : ''; + } + + public function getRemainingString(): string { + return array_key_exists('remaining_str', $this->info) && is_string($this->info['remaining_str']) + ? $this->info['remaining_str'] : ''; + } +} diff --git a/src/MisuzuAuthGuiseInfo.php b/src/MisuzuAuthGuiseInfo.php new file mode 100644 index 0000000..3904dc4 --- /dev/null +++ b/src/MisuzuAuthGuiseInfo.php @@ -0,0 +1,15 @@ +info) && is_string($this->info['revert_url']) + ? $this->info['revert_url'] : ''; + } +} diff --git a/src/MisuzuAuthInfo.php b/src/MisuzuAuthInfo.php new file mode 100644 index 0000000..b704aad --- /dev/null +++ b/src/MisuzuAuthInfo.php @@ -0,0 +1,51 @@ +info); + } + + public function getFailureReason(): string { + return array_key_exists('reason', $this->info) && is_string($this->info['reason']) + ? $this->info['reason'] : ''; + } + + public function getLoginUrl(): string { + return array_key_exists('login_url', $this->info) && is_string($this->info['login_url']) + ? $this->info['login_url'] : ''; + } + + public function getRegisterUrl(): string { + return array_key_exists('register_url', $this->info) && is_string($this->info['register_url']) + ? $this->info['register_url'] : ''; + } + + public function getSessionInfo(): MisuzuAuthSessionInfo { + return new MisuzuAuthSessionInfo($this->info['session'] ?? []); + } + + public function isBanned(): bool { + return array_key_exists('ban', $this->info); + } + + public function getBanInfo(): MisuzuAuthBanInfo { + return new MisuzuAuthBanInfo($this->info['ban'] ?? []); + } + + public function getUserInfo(): MisuzuAuthUserInfo { + return new MisuzuAuthUserInfo($this->info['user'] ?? []); + } + + public function isGuise(): bool { + return array_key_exists('guise', $this->info); + } + + public function getGuiseInfo(): MisuzuAuthGuiseInfo { + return new MisuzuAuthGuiseInfo($this->info['guise'] ?? []); + } +} diff --git a/src/MisuzuAuthSessionInfo.php b/src/MisuzuAuthSessionInfo.php new file mode 100644 index 0000000..a6bf343 --- /dev/null +++ b/src/MisuzuAuthSessionInfo.php @@ -0,0 +1,29 @@ +info) && is_string($this->info['token']) + ? $this->info['token'] : ''; + } + + public function getCreatedAt(): int { + return array_key_exists('created_at', $this->info) && is_int($this->info['created_at']) + ? $this->info['created_at'] : 0; + } + + public function getExpiresAt(): int { + return array_key_exists('expires_at', $this->info) && is_int($this->info['expires_at']) + ? $this->info['expires_at'] : 0; + } + + public function doesLifetimeExtend(): bool { + return array_key_exists('lifetime_extends', $this->info) + && is_bool($this->info['lifetime_extends']) + && $this->info['lifetime_extends']; + } +} diff --git a/src/MisuzuAuthUserInfo.php b/src/MisuzuAuthUserInfo.php new file mode 100644 index 0000000..b8903f6 --- /dev/null +++ b/src/MisuzuAuthUserInfo.php @@ -0,0 +1,70 @@ +info) && is_string($this->info['id']) + ? $this->info['id'] : ''; + } + + public function getName(): string { + return array_key_exists('name', $this->info) && is_string($this->info['name']) + ? $this->info['name'] : ''; + } + + public function getColour(): string { + return array_key_exists('colour', $this->info) && is_string($this->info['colour']) + ? $this->info['colour'] : 'inherit'; + } + + public function getRank(): int { + return array_key_exists('rank', $this->info) && is_int($this->info['rank']) + ? $this->info['rank'] : 0; + } + + public function isSuper(): bool { + return array_key_exists('is_super', $this->info) + && is_bool($this->info['is_super']) + && $this->info['is_super']; + } + + public function getCountryCode(): string { + return array_key_exists('country_code', $this->info) && is_string($this->info['country_code']) + ? $this->info['country_code'] : ''; + } + + public function isDeleted(): bool { + return array_key_exists('is_deleted', $this->info) + && is_bool($this->info['is_deleted']) + && $this->info['is_deleted']; + } + + public function has2fa(): bool { + return array_key_exists('has_totp', $this->info) + && is_bool($this->info['has_totp']) + && $this->info['has_totp']; + } + + public function getProfileUrl(): string { + return array_key_exists('profile_url', $this->info) && is_string($this->info['profile_url']) + ? $this->info['profile_url'] : ''; + } + + public function getAvatars(): array { + return array_key_exists('avatars', $this->info) && is_array($this->info['avatars']) + ? $this->info['avatars'] : []; + } + + public function getAvatar(string $variant): string { + $avatars = $this->getAvatars(); + if(array_key_exists($variant, $avatars)) + return $avatars[$variant]; + if(array_key_exists('original', $avatars)) + return $avatars['original']; + return ''; + } +} diff --git a/src/MisuzuInterop.php b/src/MisuzuInterop.php deleted file mode 100644 index 4b8c73b..0000000 --- a/src/MisuzuInterop.php +++ /dev/null @@ -1,111 +0,0 @@ -config->getString('endpoint'); - } - - private function getSecret(): string { - return $this->config->getString('secret'); - } - - public function authCheck(string $method, string $token, string $remoteAddr, array $avatars = []): object { - $result = $this->callRpc('auth-check', body: [ - 'method' => $method, - 'token' => $token, - 'remote_addr' => $remoteAddr, - 'avatars' => implode(',', $avatars), - ]); - - if(!str_starts_with($result->name, 'auth:check:')) - return new stdClass; - - return $result->attrs; - } - - private function callRpc(string $action, array $params = [], array $body = []): object { - $time = time(); - - $url = sprintf('%s/_hanyuu/%s', $this->getEndpoint(), $action); - if(empty($params)) - $params = ''; - else { - $params = http_build_query($params, '', '&', PHP_QUERY_RFC3986); - $url .= sprintf('?%s', $params); - } - - $hasBody = !empty($body); - $body = $hasBody ? http_build_query($body, '', '&', PHP_QUERY_RFC3986) : ''; - - $signature = UriBase64::encode(hash_hmac( - 'sha256', - sprintf('[%s|%s|%s]', $time, $url, $body), - $this->getSecret(), - true - )); - $headers = [ - sprintf('X-Hanyuu-Timestamp: %s', $time), - sprintf('X-Hanyuu-Signature: %s', $signature), - ]; - - if($hasBody) - $headers[] = 'Content-Type: application/x-www-form-urlencoded'; - - $req = curl_init($url); - try { - curl_setopt_array($req, [ - CURLOPT_AUTOREFERER => false, - CURLOPT_FAILONERROR => false, - CURLOPT_FOLLOWLOCATION => false, - CURLOPT_HEADER => false, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TCP_FASTOPEN => true, - CURLOPT_CONNECTTIMEOUT => 2, - CURLOPT_MAXREDIRS => 2, - CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, - CURLOPT_TIMEOUT => 5, - CURLOPT_USERAGENT => 'Hanyuu', - CURLOPT_HTTPHEADER => $headers, - ]); - - if($hasBody) - curl_setopt_array($req, [ - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $body, - ]); - - $response = curl_exec($req); - if($response === false) - throw new RuntimeException(curl_error($req)); - } finally { - curl_close($req); - } - - $decoded = json_decode($response); - if($decoded === null) - throw new RuntimeException($response); - - if(empty($decoded->name) || !isset($decoded->attrs)) - throw new RuntimeException('Missing name or attrs attribute in response body.'); - - if($decoded->name === 'error') { - $line = $decoded->attrs->code ?? 'unknown'; - if(!empty($decoded->attrs->text)) - $line = sprintf('[%s] %s', $line, $decoded->attrs->text); - - throw new RuntimeException($line); - } - - return $decoded; - } -} diff --git a/src/MisuzuRpcClient.php b/src/MisuzuRpcClient.php new file mode 100644 index 0000000..95a3d68 --- /dev/null +++ b/src/MisuzuRpcClient.php @@ -0,0 +1,39 @@ +client = RpcClient::createHmac( + sprintf('%s/_hanyuu', $config->getString('endpoint')), + fn() => $config->getString('secret') + ); + } + + public function getRpcClient(): RpcClient { + return $this->client; + } + + public function authCheck(string $method, string $token, string $remoteAddr, array $avatars = []): MisuzuAuthInfo { + $result = $this->client->procedure('mszhau:authCheck', [ + 'method' => $method, + 'token' => $token, + 'remoteAddr' => $remoteAddr, + 'avatars' => implode(',', $avatars), + ]); + + if(empty($result['name'])) + throw new RuntimeException('Unexpected response from Misuzu.'); + if(!str_starts_with($result['name'], 'auth:check:')) + throw new RuntimeException($result['name']); + + return new MisuzuAuthInfo($result['attrs']); + } +} diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php index 43a4edd..293b4ac 100644 --- a/src/OAuth2/OAuth2Routes.php +++ b/src/OAuth2/OAuth2Routes.php @@ -66,10 +66,13 @@ final class OAuth2Routes extends RouteHandler { #[HttpGet('/oauth2/authorise')] public function getAuthorise($response, $request) { $authInfo = ($this->getAuthInfo)(); - if(!isset($authInfo->user)) - return $this->templating->render('oauth2/login', ['auth' => $authInfo]); + if($authInfo->isFailure()) + return $this->templating->render('oauth2/login', [ + 'login_url' => $authInfo->getLoginUrl(), + 'register_url' => $authInfo->getRegisterUrl(), + ]); - $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token); + $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken()); return $this->templating->render('oauth2/authorise', [ 'csrfp_token' => $csrfp->createToken(), @@ -84,12 +87,12 @@ final class OAuth2Routes extends RouteHandler { // TODO: RATE LIMITING $authInfo = ($this->getAuthInfo)(); - if(!isset($authInfo->user)) + if($authInfo->isFailure()) return ['error' => 'auth']; $content = $request->getContent(); - $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token); + $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken()); if(!$csrfp->verifyToken((string)$content->getParam('_csrfp'))) return ['error' => 'csrf']; @@ -145,7 +148,7 @@ final class OAuth2Routes extends RouteHandler { try { $authsInfo = $authsData->createAuthorisation( $appInfo, - $authInfo->user->id, + $authInfo->getUserInfo()->getId(), $redirectUriId, $codeChallenge, $codeChallengeMethod, @@ -166,10 +169,11 @@ final class OAuth2Routes extends RouteHandler { // TODO: RATE LIMITING $authInfo = ($this->getAuthInfo)(); - if(!isset($authInfo->user)) + if($authInfo->isFailure()) return ['error' => 'auth']; - $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token); + $sessionInfo = $authInfo->getSessionInfo(); + $csrfp = new CSRFP(($this->getCSRFPSecret)(), $sessionInfo->getToken()); if(!$csrfp->verifyToken((string)$request->getParam('csrfp'))) return ['error' => 'csrf']; @@ -196,6 +200,7 @@ final class OAuth2Routes extends RouteHandler { return ['error' => 'scope']; } + $userInfo = $authInfo->getUserInfo(); $result = [ 'app' => [ 'name' => $appInfo->getName(), @@ -206,21 +211,23 @@ final class OAuth2Routes extends RouteHandler { ], ], 'user' => [ - 'name' => $authInfo->user->name, - 'colour' => $authInfo->user->colour, - 'profile_uri' => $authInfo->user->profile_url, - 'avatar_uri' => $authInfo->user->avatars->x120, + 'name' => $userInfo->getName(), + 'colour' => $userInfo->getColour(), + 'profile_uri' => $userInfo->getProfileUrl(), + 'avatar_uri' => $userInfo->getAvatar('x120'), ], ]; - if(isset($authInfo->guise)) + if($authInfo->isGuise()) { + $guiseInfo = $authInfo->getGuiseInfo(); $result['user']['guise'] = [ - 'name' => $authInfo->guise->name, - 'colour' => $authInfo->guise->colour, - 'profile_uri' => $authInfo->guise->profile_url, - 'revert_uri' => $authInfo->guise->revert_url, - 'avatar_uri' => $authInfo->guise->avatars->x60, + 'name' => $guiseInfo->getName(), + 'colour' => $guiseInfo->getColour(), + 'profile_uri' => $guiseInfo->getProfileUrl(), + 'revert_uri' => $guiseInfo->getRevertUrl(), + 'avatar_uri' => $guiseInfo->getAvatar('x60'), ]; + } return $result; } @@ -228,10 +235,13 @@ final class OAuth2Routes extends RouteHandler { #[HttpGet('/oauth2/verify')] public function getVerify($response, $request) { $authInfo = ($this->getAuthInfo)(); - if(!isset($authInfo->user)) - return $this->templating->render('oauth2/login', ['auth' => $authInfo]); + if($authInfo->isFailure()) + return $this->templating->render('oauth2/login', [ + 'login_url' => $authInfo->getLoginUrl(), + 'register_url' => $authInfo->getRegisterUrl(), + ]); - $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token); + $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken()); return $this->templating->render('oauth2/verify', [ 'csrfp_token' => $csrfp->createToken(), @@ -246,12 +256,12 @@ final class OAuth2Routes extends RouteHandler { // TODO: RATE LIMITING $authInfo = ($this->getAuthInfo)(); - if(!isset($authInfo->user)) + if($authInfo->isFailure()) return ['error' => 'auth']; $content = $request->getContent(); - $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token); + $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken()); if(!$csrfp->verifyToken((string)$content->getParam('_csrfp'))) return ['error' => 'csrf']; @@ -270,7 +280,7 @@ final class OAuth2Routes extends RouteHandler { return ['error' => 'invalid']; $approved = $approve === 'yes'; - $devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->user->id); + $devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->getUserInfo()->getId()); return [ 'approval' => $approved ? 'approved' : 'denied', @@ -282,10 +292,10 @@ final class OAuth2Routes extends RouteHandler { // TODO: RATE LIMITING $authInfo = ($this->getAuthInfo)(); - if(!isset($authInfo->user)) + if($authInfo->isFailure()) return ['error' => 'auth']; - $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token); + $csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->getSessionInfo()->getToken()); if(!$csrfp->verifyToken((string)$request->getParam('csrfp'))) return ['error' => 'csrf']; @@ -306,6 +316,7 @@ final class OAuth2Routes extends RouteHandler { return ['error' => 'code']; } + $userInfo = $authInfo->getUserInfo(); $result = [ 'req' => [ 'code' => $deviceInfo->getUserCode(), @@ -319,21 +330,23 @@ final class OAuth2Routes extends RouteHandler { ], ], 'user' => [ - 'name' => $authInfo->user->name, - 'colour' => $authInfo->user->colour, - 'profile_uri' => $authInfo->user->profile_url, - 'avatar_uri' => $authInfo->user->avatars->x120, + 'name' => $userInfo->getName(), + 'colour' => $userInfo->getColour(), + 'profile_uri' => $userInfo->getProfileUrl(), + 'avatar_uri' => $userInfo->getAvatar('x120'), ], ]; - if(isset($authInfo->guise)) + if($authInfo->isGuise()) { + $guiseInfo = $authInfo->getGuiseInfo(); $result['user']['guise'] = [ - 'name' => $authInfo->guise->name, - 'colour' => $authInfo->guise->colour, - 'profile_uri' => $authInfo->guise->profile_url, - 'revert_uri' => $authInfo->guise->revert_url, - 'avatar_uri' => $authInfo->guise->avatars->x60, + 'name' => $guiseInfo->getName(), + 'colour' => $guiseInfo->getColour(), + 'profile_uri' => $guiseInfo->getProfileUrl(), + 'revert_uri' => $guiseInfo->getRevertUrl(), + 'avatar_uri' => $guiseInfo->getAvatar('x60'), ]; + } return $result; } diff --git a/templates/oauth2/login.twig b/templates/oauth2/login.twig index 04da046..2faa8f1 100644 --- a/templates/oauth2/login.twig +++ b/templates/oauth2/login.twig @@ -10,7 +10,7 @@
A third-party application is requesting permission to access your account.
{% endif %} -You must be logged in to authorise applications. Log in or create an account and reload this page to try again.
+You must be logged in to authorise applications. Log in or create an account and reload this page to try again.
{% if app is defined and app.isTrusted %}You will be redirected to the following application after logging in.