Moved /v1/me over.

This commit is contained in:
flash 2025-03-24 22:15:37 +00:00
parent 649968814a
commit e3707bf2b4
Signed by: flash
GPG key ID: 2C9C2C574D47FE3E
7 changed files with 345 additions and 45 deletions

View file

@ -1 +1 @@
20250324.1
20250324.2

158
src/Auth/AuthApiRoutes.php Normal file
View file

@ -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;
}
}
}

View file

@ -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

View file

@ -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));

View file

@ -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;
}

View file

@ -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();

View file

@ -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;
}
}