Moved /v1/me over.
This commit is contained in:
parent
649968814a
commit
e3707bf2b4
7 changed files with 345 additions and 45 deletions
2
VERSION
2
VERSION
|
@ -1 +1 @@
|
|||
20250324.1
|
||||
20250324.2
|
||||
|
|
158
src/Auth/AuthApiRoutes.php
Normal file
158
src/Auth/AuthApiRoutes.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
109
src/Users/UsersApiRoutes.php
Normal file
109
src/Users/UsersApiRoutes.php
Normal 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;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue