misuzu/src/OAuth2/OAuth2WebRoutes.php

418 lines
14 KiB
PHP
Raw Normal View History

2025-02-02 02:09:56 +00:00
<?php
namespace Misuzu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
use Index\Urls\UrlRegistry;
use Misuzu\{CSRF,SiteInfo,Template};
use Misuzu\Auth\AuthInfo;
use Misuzu\Users\UsersContext;
final class OAuth2WebRoutes implements RouteHandler {
use RouteHandlerCommon;
public function __construct(
private OAuth2Context $oauth2Ctx,
private UsersContext $usersCtx,
private UrlRegistry $urls,
private AuthInfo $authInfo
) {
}
#[HttpGet('/oauth2/authorise')]
#[HttpGet('/oauth2/authorize')]
public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
return Template::renderRaw('oauth2.authorise');
}
/**
* @return int|array{
* error: 'auth'|'csrf'|'method'|'length'|'client'|'scope'|'format'|'required'|'authorise',
* scope?: string,
* reason?: string,
* }|array{
* code: string,
* redirect: string,
* }
*/
#[HttpPost('/oauth2/authorise')]
public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request): int|array {
if(!($request->content instanceof FormHttpContent))
return 400;
// TODO: RATE LIMITING
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
2025-02-02 02:09:56 +00:00
$codeChallengeMethod = 'plain';
if($request->content->hasParam('ccm')) {
$codeChallengeMethod = $request->content->getParam('ccm');
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
return ['error' => 'method'];
}
$codeChallenge = $request->content->getParam('cc');
$codeChallengeLength = strlen($codeChallenge);
if($codeChallengeMethod === 'S256') {
if($codeChallengeLength !== 43)
return ['error' => 'length'];
} else {
if($codeChallengeLength < 43 || $codeChallengeLength > 128)
return ['error' => 'length'];
}
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
clientId: (string)$request->content->getParam('client'),
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
if($request->content->hasParam('scope')) {
$scope = [];
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->content->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->string;
}
$scope = implode(' ', $scope);
} else $scope = '';
if($request->content->hasParam('redirect')) {
$redirectUri = (string)$request->content->getParam('redirect');
$redirectUriId = $this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri);
if($redirectUriId === null)
return ['error' => 'format'];
} else {
$uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1)
return ['error' => 'required'];
$uriInfo = array_pop($uriInfos);
$redirectUri = $uriInfo->string;
$redirectUriId = $uriInfo->id;
}
try {
$authsInfo = $this->oauth2Ctx->authorisations->createAuthorisation(
$appInfo,
$this->authInfo->userInfo,
$redirectUriId,
$codeChallenge,
$codeChallengeMethod,
$scope
);
} catch(RuntimeException $ex) {
return ['error' => 'authorise', 'detail' => $ex->getMessage()];
}
return [
'code' => $authsInfo->code,
'redirect' => $redirectUri,
];
}
/**
* @return array{
* error: 'auth'|'csrf'|'client'|'scope'|'format'|'required',
* scope?: string,
* reason?: string,
* }|array{
* app: array{
* name: string,
* summary: string,
* trusted: bool,
* links: array{ title: string, display: string, uri: string }[]
* },
* user: array{
* name: string,
* colour: string,
* profile_uri: string,
* avatar_uri: string,
* guise?: array{
* name: string,
* colour: string,
* profile_uri: string,
* revert_uri: string,
* avatar_uri: string,
* },
* },
* scope: string[],
* }
*/
#[HttpGet('/oauth2/resolve-authorise-app')]
public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
// TODO: RATE LIMITING
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
2025-02-02 02:09:56 +00:00
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
clientId: (string)$request->getParam('client'),
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
if($request->hasParam('redirect')) {
$redirectUri = (string)$request->getParam('redirect');
if($this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri) === null)
return ['error' => 'format'];
} else {
$uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1)
return ['error' => 'required'];
}
$scope = [];
if($request->hasParam('scope')) {
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->summary;
}
}
$result = [
'app' => [
'name' => $appInfo->name,
'summary' => $appInfo->summary,
'trusted' => $appInfo->trusted,
'links' => [
['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
],
],
'user' => [
'name' => $this->authInfo->userInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
2025-02-02 02:09:56 +00:00
],
'scope' => $scope,
];
2025-02-06 21:12:13 +00:00
if($this->authInfo->impersonating)
2025-02-02 02:09:56 +00:00
$result['user']['guise'] = [
'name' => $this->authInfo->realUserInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
'revert_uri' => $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
2025-02-02 02:09:56 +00:00
];
return $result;
}
#[HttpGet('/oauth2/verify')]
public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
return Template::renderRaw('oauth2.verify');
}
/**
* @return int|array{
* error: 'auth'|'csrf'|'invalid'|'code'|'expired'|'approval'|'code'|'scope',
* scope?: string,
* reason?: string,
* }|array{
* approval: 'approved'|'denied',
* }
*/
#[HttpPost('/oauth2/verify')]
public function postVerify(HttpResponseBuilder $response, HttpRequest $request): int|array {
if(!($request->content instanceof FormHttpContent))
return 400;
// TODO: RATE LIMITING
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
2025-02-02 02:09:56 +00:00
$approve = (string)$request->content->getParam('approve');
if(!in_array($approve, ['yes', 'no']))
return ['error' => 'invalid'];
try {
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(
userCode: (string)$request->content->getParam('code')
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
if($deviceInfo->expired)
return ['error' => 'expired'];
if(!$deviceInfo->pending)
return ['error' => 'approval'];
$approved = $approve === 'yes';
$error = null;
if($approved) {
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
appId: $deviceInfo->appId,
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
$approved = false;
$error = ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
break;
}
2025-02-02 02:34:51 +00:00
$scope[] = $scopeInfo->summary;
2025-02-02 02:09:56 +00:00
}
}
$this->oauth2Ctx->devices->setDeviceApproval($deviceInfo, $approved, $this->authInfo->userInfo);
if($error !== null)
return $error;
return [
'approval' => $approved ? 'approved' : 'denied',
];
}
/**
* @return array{
* error: 'auth'|'csrf'|'code'|'expired'|'approval'|'scope',
* scope?: string,
* reason?: string,
* }|array{
* req: array{
* code: string,
* },
* app: array{
* name: string,
* summary: string,
* trusted: bool,
* links: array{ title: string, display: string, uri: string }[]
* },
* user: array{
* name: string,
* colour: string,
* profile_uri: string,
* avatar_uri: string,
* guise?: array{
* name: string,
* colour: string,
* profile_uri: string,
* revert_uri: string,
* avatar_uri: string,
* },
* },
* scope: string[],
* }
*/
#[HttpGet('/oauth2/resolve-verify')]
public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
// TODO: RATE LIMITING
if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
return ['error' => 'csrf'];
$response->setHeader('X-CSRF-Token', CSRF::token());
if(!$this->authInfo->loggedIn)
return ['error' => 'auth'];
2025-02-02 02:09:56 +00:00
try {
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(userCode: (string)$request->getParam('code'));
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
if($deviceInfo->expired)
return ['error' => 'expired'];
if(!$deviceInfo->pending)
return ['error' => 'approval'];
try {
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
appId: $deviceInfo->appId,
deleted: false
);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scope = [];
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
2025-02-02 02:34:51 +00:00
$scope[] = $scopeInfo->summary;
2025-02-02 02:09:56 +00:00
}
$result = [
'req' => [
'code' => $deviceInfo->userCode,
],
'app' => [
'name' => $appInfo->name,
'summary' => $appInfo->summary,
'trusted' => $appInfo->trusted,
'links' => [
['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
],
],
'scope' => $scope,
'user' => [
'name' => $this->authInfo->userInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
2025-02-02 02:09:56 +00:00
],
];
2025-02-06 21:12:13 +00:00
if($this->authInfo->impersonating)
2025-02-02 02:09:56 +00:00
$result['user']['guise'] = [
'name' => $this->authInfo->realUserInfo->name,
'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
'revert_uri' => $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
2025-02-02 02:09:56 +00:00
];
return $result;
}
}