441 lines
15 KiB
PHP
441 lines
15 KiB
PHP
<?php
|
|
namespace Misuzu\OAuth2;
|
|
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
use Index\XArray;
|
|
use Index\Http\{HttpResponseBuilder,HttpRequest};
|
|
use Index\Http\Content\FormContent;
|
|
use Index\Http\Routing\Processors\Before;
|
|
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
|
|
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
|
|
use Misuzu\{CsrfContext,Template};
|
|
use Misuzu\Auth\AuthInfo;
|
|
use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
|
|
use Misuzu\Users\UsersContext;
|
|
|
|
#[HandlerRoles('main')]
|
|
final class OAuth2WebRoutes implements RouteHandler, UrlSource {
|
|
use RouteHandlerCommon, UrlSourceCommon;
|
|
|
|
/** @var string[] */
|
|
private const array RESPONSE_TYPES = ['code', 'code id_token'];
|
|
|
|
public function __construct(
|
|
private OAuth2Context $oauth2Ctx,
|
|
private UsersContext $usersCtx,
|
|
private UrlRegistry $urls,
|
|
private CsrfContext $csrfCtx,
|
|
private AuthInfo $authInfo,
|
|
) {}
|
|
|
|
#[PatternRoute('GET', '/oauth2/authori[sz]e')]
|
|
#[Before('authz:cookie', required: false)]
|
|
#[UrlFormat('oauth2-authorise', '/oauth2/authorize')]
|
|
public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
|
|
return Template::renderRaw('oauth2.authorise');
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* error: 'auth'|'csrf'|'method'|'length'|'client'|'scope'|'format'|'required'|'authorise'|'resptype',
|
|
* scope?: string,
|
|
* reason?: string,
|
|
* }|array{
|
|
* code: string,
|
|
* redirect: string,
|
|
* }
|
|
*/
|
|
#[ExactRoute('POST', '/oauth2/authorize')]
|
|
#[Before('authz:cookie', required: false)]
|
|
#[Before('input:urlencoded')]
|
|
public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
|
|
// TODO: RATE LIMITING
|
|
|
|
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
|
|
return ['error' => 'csrf'];
|
|
|
|
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
|
|
|
|
if(!$this->authInfo->loggedIn)
|
|
return ['error' => 'auth'];
|
|
|
|
$responseTypes = explode(' ', (string)$content->getParam('rt'), 2);
|
|
sort($responseTypes);
|
|
$responseType = implode(' ', $responseTypes);
|
|
if(!in_array($responseType, self::RESPONSE_TYPES))
|
|
return ['error' => 'resptype'];
|
|
|
|
$codeChallengeMethod = 'plain';
|
|
if($content->hasParam('ccm')) {
|
|
$codeChallengeMethod = $content->getParam('ccm');
|
|
if(!in_array($codeChallengeMethod, ['plain', 'S256']))
|
|
return ['error' => 'method'];
|
|
}
|
|
|
|
$codeChallenge = $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(
|
|
(string)$content->getParam('client'),
|
|
deleted: false
|
|
);
|
|
} catch(RuntimeException $ex) {
|
|
return ['error' => 'client'];
|
|
}
|
|
|
|
if($content->hasParam('scope')) {
|
|
$scope = [];
|
|
$scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$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($content->hasParam('redirect')) {
|
|
$redirectUri = (string)$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()];
|
|
}
|
|
|
|
$result = [
|
|
'redirect' => $redirectUri,
|
|
'code' => $authsInfo->code,
|
|
];
|
|
|
|
if(in_array('id_token', $responseTypes))
|
|
$result['id_token'] = $this->oauth2Ctx->createIdToken($appInfo, $authsInfo);
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @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[],
|
|
* }
|
|
*/
|
|
#[ExactRoute('GET', '/oauth2/resolve-authorise-app')]
|
|
#[Before('authz:cookie', required: false)]
|
|
#[UrlFormat('oauth2-resolve-authorise-app', '/oauth2/resolve-authorise-app')]
|
|
public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
|
|
// TODO: RATE LIMITING
|
|
|
|
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
|
|
return ['error' => 'csrf'];
|
|
|
|
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
|
|
|
|
if(!$this->authInfo->loggedIn)
|
|
return ['error' => 'auth'];
|
|
|
|
try {
|
|
$appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
|
|
(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]),
|
|
],
|
|
'scope' => $scope,
|
|
];
|
|
|
|
if($this->authInfo->impersonating)
|
|
$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' => $this->csrfCtx->createToken()]),
|
|
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
|
|
];
|
|
|
|
return $result;
|
|
}
|
|
|
|
#[ExactRoute('GET', '/oauth2/verify')]
|
|
#[Before('authz:cookie', required: false)]
|
|
#[UrlFormat('oauth2-verify', '/oauth2/verify')]
|
|
public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
|
|
return Template::renderRaw('oauth2.verify');
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* error: 'auth'|'csrf'|'invalid'|'code'|'expired'|'approval'|'code'|'scope',
|
|
* scope?: string,
|
|
* reason?: string,
|
|
* }|array{
|
|
* approval: 'approved'|'denied',
|
|
* }
|
|
*/
|
|
#[ExactRoute('POST', '/oauth2/verify')]
|
|
#[Before('authz:cookie', required: false)]
|
|
#[Before('input:urlencoded')]
|
|
public function postVerify(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
|
|
// TODO: RATE LIMITING
|
|
|
|
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
|
|
return ['error' => 'csrf'];
|
|
|
|
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
|
|
|
|
if(!$this->authInfo->loggedIn)
|
|
return ['error' => 'auth'];
|
|
|
|
$approve = (string)$content->getParam('approve');
|
|
if(!in_array($approve, ['yes', 'no']))
|
|
return ['error' => 'invalid'];
|
|
|
|
try {
|
|
$deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(
|
|
userCode: (string)$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(
|
|
$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;
|
|
}
|
|
|
|
$scope[] = $scopeInfo->summary;
|
|
}
|
|
}
|
|
|
|
$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[],
|
|
* }
|
|
*/
|
|
#[ExactRoute('GET', '/oauth2/resolve-verify')]
|
|
#[Before('authz:cookie', required: false)]
|
|
#[UrlFormat('oauth2-resolve-verify', '/oauth2/resolve-verify')]
|
|
public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
|
|
// TODO: RATE LIMITING
|
|
|
|
if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
|
|
return ['error' => 'csrf'];
|
|
|
|
$response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
|
|
|
|
if(!$this->authInfo->loggedIn)
|
|
return ['error' => 'auth'];
|
|
|
|
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(
|
|
$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];
|
|
|
|
$scope[] = $scopeInfo->summary;
|
|
}
|
|
|
|
$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]),
|
|
],
|
|
];
|
|
|
|
if($this->authInfo->impersonating)
|
|
$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' => $this->csrfCtx->createToken()]),
|
|
'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
|
|
];
|
|
|
|
return $result;
|
|
}
|
|
}
|