diff --git a/VERSION b/VERSION index ab374766..b584ac26 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -20250324.1 +20250324.2 diff --git a/src/Auth/AuthApiRoutes.php b/src/Auth/AuthApiRoutes.php new file mode 100644 index 00000000..b5d9a11a --- /dev/null +++ b/src/Auth/AuthApiRoutes.php @@ -0,0 +1,158 @@ +<?php +namespace Misuzu\Auth; + +use RuntimeException; +use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context}; +use Misuzu\Users\{UsersContext,UserInfo}; +use Index\Config\Config; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpMiddleware,RouteHandler,RouteHandlerCommon}; + +final class AuthApiRoutes implements RouteHandler { + use RouteHandlerCommon; + + public function __construct( + private Config $impersonateConfig, + private UsersContext $usersCtx, + private OAuth2Context $oauth2Ctx, + private AuthContext $authCtx, + private AuthInfo $authInfo, + ) {} + + private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool { + if($impersonator->super) + return true; + + $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id)); + return in_array($targetId, $whitelist, true); + } + + /** @return void|int */ + #[HttpMiddleware('/api/v1')] + #[HttpMiddleware('/oauth2')] + public function handleAuthorization(HttpResponseBuilder $response, HttpRequest $request) { + if($this->authInfo->loggedIn) + return; + + $authz = explode(' ', $request->getHeaderLine('Authorization'), 2); + if(count($authz) < 2) + return; + + [$method, $token] = $authz; + + if(strcasecmp('misuzu', $method) === 0) { + $tokenInfo = $this->authCtx->createAuthTokenPacker()->unpack($token); + if(!$tokenInfo->isEmpty) + $token = $tokenInfo->sessionToken; + + try { + $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $token); + } catch(RuntimeException $ex) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Misuzu error="invalid_token", error_description="Misuzu token has expired."'); + return; + } + + if($sessionInfo->expired) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Misuzu error="invalid_token", error_description="Misuzu token has expired."'); + $this->authCtx->sessions->deleteSessions(sessionInfos: $sessionInfo); + return; + } + + $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress); + + $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id'); + $userInfoReal = null; + if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) { + $userInfoReal = $userInfo; + + try { + $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id'); + } catch(RuntimeException $ex) { + $userInfo = $userInfoReal; + } + } + + $this->authInfo->setInfo( + tokenInfo: $tokenInfo, + userInfo: $userInfo, + sessionInfo: $sessionInfo, + realUserInfo: $userInfoReal, + ); + return; + } + + if(strcasecmp('basic', $method) === 0) { + $token = base64_decode($token); + if(empty($token)) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); + return; + } + + $authz = explode(':', $token, 2); + if(count($authz) < 2) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); + return; + } + + [$clientId, $clientSecret] = $authz; + + try { + $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false); + } catch(RuntimeException $ex) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); + return; + } + + if($appInfo->confidential) { + // TODO: rate limiting + + if(!$appInfo->verifyClientSecret($clientSecret)) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); + return; + } + } elseif($clientSecret !== '') { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."'); + return; + } + + $this->authInfo->setInfo( + appInfo: $appInfo, + ); + return; + } + + if(strcasecmp('bearer', $method) === 0) { + try { + $accessInfo = $this->oauth2Ctx->tokens->getAccessInfo($token, OAuth2AccessInfoGetField::Token); + } catch(RuntimeException $ex) { + $accessInfo = null; + } + + if($accessInfo?->expired !== false) { + $response->statusCode = 401; + $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."'); + return [ + 'error' => 'invalid_token', + 'error_description' => 'Access token has expired.', + ]; + } + + $userInfo = null; + if($accessInfo->userId !== null) + $userInfo = $this->usersCtx->users->getUser($accessInfo->userId, 'id'); + + $this->authInfo->setInfo( + userInfo: $userInfo, + accessInfo: $accessInfo, + ); + return; + } + } +} diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php index f97f240b..3d0602fa 100644 --- a/src/Auth/AuthInfo.php +++ b/src/Auth/AuthInfo.php @@ -1,10 +1,11 @@ <?php namespace Misuzu\Auth; +use Misuzu\Apps\AppInfo; use Misuzu\Auth\SessionInfo; use Misuzu\Forum\ForumCategoryInfo; -use Misuzu\Perms\IPermissionResult; -use Misuzu\Perms\{PermissionsData,PermissionResult}; +use Misuzu\OAuth2\OAuth2AccessInfo; +use Misuzu\Perms\{IPermissionResult,PermissionsData,PermissionResult}; use Misuzu\Users\UserInfo; class AuthInfo { @@ -12,6 +13,8 @@ class AuthInfo { public private(set) ?UserInfo $userInfo; public private(set) ?SessionInfo $sessionInfo; public private(set) ?UserInfo $realUserInfo; + public private(set) ?AppInfo $appInfo; + public private(set) ?OAuth2AccessInfo $accessInfo; /** @var array<string, PermissionResult> */ public private(set) array $perms; @@ -23,26 +26,42 @@ class AuthInfo { } public function setInfo( - AuthTokenInfo $tokenInfo, + ?AuthTokenInfo $tokenInfo = null, ?UserInfo $userInfo = null, ?SessionInfo $sessionInfo = null, - ?UserInfo $realUserInfo = null + ?UserInfo $realUserInfo = null, + ?AppInfo $appInfo = null, + ?OAuth2AccessInfo $accessInfo = null, ): void { - $this->tokenInfo = $tokenInfo; + $this->tokenInfo = $tokenInfo ?? AuthTokenInfo::empty(); $this->userInfo = $userInfo; $this->sessionInfo = $sessionInfo; $this->realUserInfo = $realUserInfo; + $this->appInfo = $appInfo; + $this->accessInfo = $accessInfo; $this->perms = []; } public function removeInfo(): void { - $this->setInfo(AuthTokenInfo::empty()); + $this->setInfo(); } public bool $loggedIn { get => $this->userInfo !== null; } + public bool $loggedInBasic { + get => $this->appInfo !== null; + } + + public bool $loggedInBearer { + get => $this->accessInfo !== null && $this->userInfo !== null; + } + + public bool $loggedInBearerClient { + get => $this->accessInfo !== null && $this->userInfo === null; + } + public ?string $userId { get => $this->userInfo?->id; } @@ -59,6 +78,38 @@ class AuthInfo { get => $this->realUserInfo?->id; } + public ?string $accessId { + get => $this->accessInfo?->id; + } + + public ?string $appId { + get => $this->appInfo?->id; + } + + public ?array $scopes { + get { + if($this->appInfo !== null) + return ['oauth']; + if($this->accessInfo !== null) + return $this->accessInfo->scopes; + if($this->sessionInfo !== null) + return null; + + return []; + } + } + + public function hasScope(string $scope): bool { + if($this->appInfo !== null) + return $scope === 'oauth'; + if($this->accessInfo !== null) + return in_array($scope, $this->accessInfo->scopes); + if($this->sessionInfo !== null) + return true; + + return false; + } + public function getPerms( string $category, ForumCategoryInfo|string|null $forumCategoryInfo = null diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php index cb6a3d5f..3d5e6a79 100644 --- a/src/MisuzuContext.php +++ b/src/MisuzuContext.php @@ -176,7 +176,12 @@ class MisuzuContext { )); $routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class)); + $routingCtx->register($this->deps->constructLazy( + Auth\AuthApiRoutes::class, + impersonateConfig: $this->config->scopeTo('impersonate') + )); $routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class)); + $routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class)); $routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class)); $routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class)); diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php index cbe9a442..b47628fb 100644 --- a/src/OAuth2/OAuth2ApiRoutes.php +++ b/src/OAuth2/OAuth2ApiRoutes.php @@ -9,6 +9,7 @@ use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCo use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon}; use Misuzu\SiteInfo; use Misuzu\Apps\AppsContext; +use Misuzu\Auth\AuthInfo; use Misuzu\Profile\ProfileContext; use Misuzu\Users\UsersContext; @@ -22,6 +23,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { private ProfileContext $profileCtx, private UrlRegistry $urls, private SiteInfo $siteInfo, + private AuthInfo $authInfo, ) {} /** @@ -181,9 +183,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { /** @return int|array{keys?: array<string, string>} */ #[HttpOptions('/oauth2/jwks.json')] - #[HttpOptions('/openid/jwks.json')] #[HttpGet('/oauth2/jwks.json')] - #[HttpGet('/openid/jwks.json')] #[UrlFormat('oauth2-jwks', '/oauth2/jwks.json')] public function getJwks(HttpResponseBuilder $response, HttpRequest $request): int|array { $response->setHeader('Access-Control-Allow-Origin', '*'); @@ -395,9 +395,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { * }|array{ error: string, error_description: string } */ #[HttpOptions('/oauth2/userinfo')] - #[HttpOptions('/openid/userinfo')] #[HttpGet('/oauth2/userinfo')] - #[HttpGet('/openid/userinfo')] #[UrlFormat('oauth2-openid-userinfo', '/oauth2/userinfo')] public function getUserInfo(HttpResponseBuilder $response, HttpRequest $request): int|array { $response->setHeader('Access-Control-Allow-Origin', '*'); @@ -405,8 +403,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { if($request->method === 'OPTIONS') return 204; - $header = explode(' ', (string)$request->getHeaderLine('Authorization'), 2); - if(strcasecmp($header[0], 'bearer') !== 0 || empty($header[1])) { + if(!$this->authInfo->loggedInBearer) { $response->statusCode = 401; $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Bearer authentication must be used."'); return [ @@ -415,22 +412,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { ]; } - try { - $tokenInfo = $this->oauth2Ctx->tokens->getAccessInfo(trim($header[1]), OAuth2AccessInfoGetField::Token); - } catch(RuntimeException $ex) { - $tokenInfo = null; - } - if($tokenInfo === null || $tokenInfo->userId === null || $tokenInfo->expired) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."'); - return [ - 'error' => 'invalid_token', - 'error_description' => 'Access token has expired.', - ]; - } - - $scopes = $tokenInfo->scopes; - if(!in_array('openid', $scopes)) { + if(!$this->authInfo->hasScope('openid')) { $response->statusCode = 403; $response->setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope", error_description="openid scope is required for this endpoint."'); return [ @@ -439,23 +421,9 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { ]; } - try { - $userInfo = $this->usersCtx->getUserInfo($tokenInfo->userId); - } catch(RuntimeException $ex) { - $userInfo = null; - } - if($userInfo === null || $userInfo->deleted) { - $response->statusCode = 401; - $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."'); - return [ - 'error' => 'invalid_token', - 'error_description' => 'Access token has expired.', - ]; - } - $result = ['sub' => $userInfo->id]; - if(in_array('profile', $scopes)) { + if($this->authInfo->hasScope('profile')) { $result['name'] = $userInfo->name; $result['nickname'] = $userInfo->name; $result['preferred_username'] = $userInfo->name; @@ -502,7 +470,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource { } } - if(in_array('email', $scopes)) { + if($this->authInfo->hasScope('email')) { $result['email'] = $userInfo->emailAddress; $result['email_verified'] = true; } diff --git a/src/Routing/RoutingErrorHandler.php b/src/Routing/RoutingErrorHandler.php index 167d5c22..ecd45513 100644 --- a/src/Routing/RoutingErrorHandler.php +++ b/src/Routing/RoutingErrorHandler.php @@ -12,6 +12,15 @@ class RoutingErrorHandler extends HtmlHttpErrorHandler { return; } + if(str_starts_with($request->path, '/api')) { + $response->setTypeJson(); + $response->content = json_encode([ + 'error' => sprintf('http:%s', $code), + 'message' => $message, + ], JSON_UNESCAPED_SLASHES); + return; + } + $path = sprintf('%s/error-%03d.html', Misuzu::PATH_PUBLIC, $code); if(is_file($path)) { $response->setTypeHTML(); diff --git a/src/Users/UsersApiRoutes.php b/src/Users/UsersApiRoutes.php new file mode 100644 index 00000000..ab2e2cc5 --- /dev/null +++ b/src/Users/UsersApiRoutes.php @@ -0,0 +1,109 @@ +<?php +namespace Misuzu\Users; + +use RuntimeException; +use Misuzu\SiteInfo; +use Misuzu\Auth\AuthInfo; +use Misuzu\Users\Assets\UserAvatarAsset; +use Index\XArray; +use Index\Colour\{Colour,ColourRgb}; +use Index\Http\{HttpRequest,HttpResponseBuilder}; +use Index\Http\Routing\{HttpGet,HttpOptions,RouteHandler,RouteHandlerCommon}; +use Index\Urls\UrlRegistry; + +final class UsersApiRoutes implements RouteHandler { + use RouteHandlerCommon; + + public function __construct( + private SiteInfo $siteInfo, + private UrlRegistry $urls, + private UsersContext $usersCtx, + private AuthInfo $authInfo, + ) {} + + /** @return int|mixed[] */ + #[HttpOptions('/api/v1/me')] + #[HttpGet('/api/v1/me')] + public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): int|array { + $response->setHeader('Access-Control-Allow-Origin', '*'); + $response->setHeader('Access-Control-Allow-Methods', 'GET'); + $response->addHeader('Access-Control-Allow-Headers', 'Cache-Control'); + $response->setHeader('Cache-Control', 'public, max-age=3600'); + + if($request->method === 'OPTIONS') + return 204; + + $openid = $this->authInfo->hasScope('openid'); + if(!$openid + && !$this->authInfo->hasScope('identify') + && !$this->authInfo->hasScope('beans')) + return 403; + + $includeProfile = !$openid || $this->authInfo->hasScope('profile'); + $includeEMail = $openid + ? $this->authInfo->hasScope('email') + : $this->authInfo->hasScope('identify:email'); + + try { + $userInfo = $this->usersCtx->getUserInfo($this->authInfo->userId, UsersData::GET_USER_ID); + } catch(RuntimeException) { + return 404; + } + + // TODO: there should be some kinda privacy controls for users + + $output = ['id' => $userInfo->id]; + + if($includeProfile) { + $output['name'] = $userInfo->name; + + $colour = $this->usersCtx->getUserColour($userInfo); + if($colour->inherits) { + $colourRaw = null; + $colourCSS = (string)$colour; + } else { + $colourRaw = Colour::toRawRgb($colour); + $colourCSS = (string)ColourRgb::convert($colour); + } + + $output['colour_raw'] = $colourRaw; + $output['colour_css'] = $colourCSS; + $output['country_code'] = $userInfo->countryCode; + + if($this->usersCtx->hasActiveBan($userInfo)) { + $output['rank'] = 0; + $output['roles'] = ['x-banned']; + } else { + $roles = XArray::select( + $this->usersCtx->roles->getRoles(userInfo: $userInfo, hasString: true, orderByRank: true), + fn($roleInfo) => $roleInfo->string, + ); + + $output['rank'] = $this->usersCtx->getUserRank($userInfo); + if(!empty($roles)) + $output['roles'] = $roles; + if($userInfo->super) + $output['is_super'] = true; + } + + if(!empty($userInfo->title)) + $output['title'] = $userInfo->title; + + $output['created_at'] = $userInfo->createdAt->toIso8601ZuluString(); + if($userInfo->lastActiveTime !== null) + $output['last_active_at'] = $userInfo->lastActiveAt->toIso8601ZuluString(); + + $baseUrl = $this->siteInfo->url; + $output['profile_url'] = $baseUrl . $this->urls->format('user-profile', ['user' => $userInfo->id]); + $output['avatar_url'] = $baseUrl . $this->urls->format('user-avatar', ['user' => $userInfo->id]); + + if($userInfo->deleted) + $output['is_deleted'] = true; + } + + if($includeEMail) + $output['email'] = $userInfo->emailAddress; + + return $output; + } +}