From 3ea982fd437c6ebca15c69160799b42fd0b0421b Mon Sep 17 00:00:00 2001 From: flashwave Date: Sat, 20 Jul 2024 02:25:49 +0000 Subject: [PATCH] Implemented core OAuth spec token flows. --- hanyuu.php | 3 - src/Apps/AppInfo.php | 2 +- src/Apps/AppsData.php | 6 +- src/MisuzuInterop.php | 12 +- src/OAuth2/OAuth2AccessInfo.php | 9 +- ...questsData.php => OAuth2AuthoriseData.php} | 66 +++--- ...equestInfo.php => OAuth2AuthoriseInfo.php} | 10 +- src/OAuth2/OAuth2Context.php | 23 +- src/OAuth2/OAuth2RefreshInfo.php | 9 +- src/OAuth2/OAuth2Routes.php | 205 ++++++++++-------- src/OAuth2/OAuth2TokensData.php | 169 +++++++++++++++ 11 files changed, 362 insertions(+), 152 deletions(-) rename src/OAuth2/{OAuth2RequestsData.php => OAuth2AuthoriseData.php} (63%) rename src/OAuth2/{OAuth2RequestInfo.php => OAuth2AuthoriseInfo.php} (94%) diff --git a/hanyuu.php b/hanyuu.php index 019f53d..324da86 100644 --- a/hanyuu.php +++ b/hanyuu.php @@ -1,7 +1,6 @@ cache = new DbStatementCache($dbConn); } - public function getApp( + public function getAppInfo( ?string $appId = null, ?string $clientId = null, ?bool $deleted = null @@ -53,7 +53,7 @@ class AppsData { return $result->next() ? $result->getInteger(0) : 0; } - public function getAppUris(AppInfo|string $appInfo): array { + public function getAppUriInfos(AppInfo|string $appInfo): array { $stmt = $this->cache->get('SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created) FROM hau_apps_uris WHERE app_id = ?'); $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); $stmt->execute(); @@ -66,7 +66,7 @@ class AppsData { return $infos; } - public function getAppUri(string $uriId): AppUriInfo { + public function getAppUriInfo(string $uriId): AppUriInfo { $stmt = $this->cache->get('SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created) FROM hau_apps_uris WHERE uri_id = ?'); $stmt->addParameter(1, $uriId); $stmt->execute(); diff --git a/src/MisuzuInterop.php b/src/MisuzuInterop.php index fe961c0..cf9719c 100644 --- a/src/MisuzuInterop.php +++ b/src/MisuzuInterop.php @@ -11,6 +11,14 @@ class MisuzuInterop { private IConfig $config ) {} + private function getEndpoint(): string { + return $this->config->getString('endpoint'); + } + + private function getSecret(): string { + return $this->config->getString('secret'); + } + public function authCheck(string $method, string $token, string $remoteAddr, array $avatars = []): object { $result = $this->callRpc('auth-check', body: [ 'method' => $method, @@ -28,7 +36,7 @@ class MisuzuInterop { private function callRpc(string $action, array $params = [], array $body = []): object { $time = time(); - $url = sprintf('%s/_hanyuu/%s', $this->config->getString('endpoint'), $action); + $url = sprintf('%s/_hanyuu/%s', $this->getEndpoint(), $action); if(empty($params)) $params = ''; else { @@ -42,7 +50,7 @@ class MisuzuInterop { $signature = UriBase64::encode(hash_hmac( 'sha256', sprintf('[%s|%s|%s]', $time, $url, $body), - $this->config->getString('secret'), + $this->getSecret(), true )); $headers = [ diff --git a/src/OAuth2/OAuth2AccessInfo.php b/src/OAuth2/OAuth2AccessInfo.php index f305a80..c8f2c0b 100644 --- a/src/OAuth2/OAuth2AccessInfo.php +++ b/src/OAuth2/OAuth2AccessInfo.php @@ -7,7 +7,7 @@ class OAuth2AccessInfo { public function __construct( private string $id, private string $appId, - private string $userId, + private ?string $userId, private string $token, private string $scope, private int $created, @@ -18,7 +18,7 @@ class OAuth2AccessInfo { return new OAuth2AccessInfo( id: $result->getString(0), appId: $result->getString(1), - userId: $result->getString(2), + userId: $result->getStringOrNull(2), token: $result->getString(3), scope: $result->getString(4), created: $result->getInteger(5), @@ -34,7 +34,10 @@ class OAuth2AccessInfo { return $this->appId; } - public function getUserId(): string { + public function hasUserId(): bool { + return $this->userId !== null; + } + public function getUserId(): ?string { return $this->userId; } diff --git a/src/OAuth2/OAuth2RequestsData.php b/src/OAuth2/OAuth2AuthoriseData.php similarity index 63% rename from src/OAuth2/OAuth2RequestsData.php rename to src/OAuth2/OAuth2AuthoriseData.php index 397b097..978de77 100644 --- a/src/OAuth2/OAuth2RequestsData.php +++ b/src/OAuth2/OAuth2AuthoriseData.php @@ -9,7 +9,7 @@ use Index\Data\IDbConnection; use Hanyuu\Apps\AppInfo; use Hanyuu\Apps\AppUriInfo; -class OAuth2RequestsData { +class OAuth2AuthoriseData { private IDbConnection $dbConn; private DbStatementCache $cache; @@ -18,8 +18,8 @@ class OAuth2RequestsData { $this->cache = new DbStatementCache($dbConn); } - public function getRequest( - ?string $requestId = null, + public function getAuthoriseInfo( + ?string $authoriseId = null, AppInfo|string|null $appInfo = null, ?string $userId = null, AppUriInfo|string|null $appUriInfo = null, @@ -28,12 +28,12 @@ class OAuth2RequestsData { ?string $challengeMethod = null, ?string $scope = null, ?string $code = null - ): OAuth2RequestInfo { + ): OAuth2AuthoriseInfo { $selectors = []; $values = []; - if($requestId !== null) { - $selectors[] = 'req_id = ?'; - $values[] = $requestId; + if($authoriseId !== null) { + $selectors[] = 'auth_id = ?'; + $values[] = $authoriseId; } if($appInfo !== null) { $selectors[] = 'app_id = ?'; @@ -48,43 +48,43 @@ class OAuth2RequestsData { $values[] = $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo; } if($state !== null) { - $selectors[] = 'req_state = ?'; + $selectors[] = 'auth_state = ?'; $values[] = $state; } if($challengeCode !== null) { - $selectors[] = 'req_challenge_code = ?'; + $selectors[] = 'auth_challenge_code = ?'; $values[] = $challengeCode; } if($challengeMethod !== null) { - $selectors[] = 'req_challenge_method = ?'; + $selectors[] = 'auth_challenge_method = ?'; $values[] = $challengeMethod; } if($scope !== null) { - $selectors[] = 'req_scope = ?'; + $selectors[] = 'auth_scope = ?'; $values[] = $scope; } if($code !== null) { - $selectors[] = 'req_code = ?'; + $selectors[] = 'auth_code = ?'; $values[] = $code; } if(empty($selectors)) - throw new RuntimeException('Insufficient data to do request lookup.'); + throw new RuntimeException('Insufficient data to do authorisation request lookup.'); $args = 0; - $stmt = $this->cache->get('SELECT req_id, app_id, user_id, uri_id, req_state, req_challenge_code, req_challenge_method, req_scope, req_code, req_approval, UNIX_TIMESTAMP(req_created) FROM hau_oauth2_requests WHERE ' . implode(' AND ', $selectors)); + $stmt = $this->cache->get('SELECT auth_id, app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code, auth_approval, UNIX_TIMESTAMP(auth_created) FROM hau_oauth2_authorise WHERE ' . implode(' AND ', $selectors)); foreach($values as $value) $stmt->addParameter(++$args, $value); $stmt->execute(); $result = $stmt->getResult(); if(!$result->next()) - throw new RuntimeException('Request not found.'); + throw new RuntimeException('Authorise request not found.'); - return OAuth2RequestInfo::fromResult($result); + return OAuth2AuthoriseInfo::fromResult($result); } - public function createRequest( + public function createAuthorise( AppInfo|string $appInfo, string $userId, AppUriInfo|string $appUriInfo, @@ -92,8 +92,8 @@ class OAuth2RequestsData { string $challengeCode, string $challengeMethod, string $scope - ): OAuth2RequestInfo { - $stmt = $this->cache->get('INSERT INTO hau_oauth2_requests (app_id, user_id, uri_id, req_state, req_challenge_code, req_challenge_method, req_scope, req_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + ): OAuth2AuthoriseInfo { + $stmt = $this->cache->get('INSERT INTO hau_oauth2_authorise (app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); $stmt->addParameter(2, $userId); $stmt->addParameter(3, $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo); @@ -104,20 +104,20 @@ class OAuth2RequestsData { $stmt->addParameter(8, XString::random(100)); $stmt->execute(); - return $this->getRequest(requestId: (string)$this->dbConn->getLastInsertId()); + return $this->getAuthoriseInfo(authoriseId: (string)$this->dbConn->getLastInsertId()); } - public function deleteRequests( - OAuth2RequestInfo|string|null $requestInfo = null, + public function deleteAuthorise( + OAuth2AuthoriseInfo|string|null $authoriseId = null, AppInfo|string|null $appInfo = null, ?string $userId = null, ?string $code = null ): void { $selectors = []; $values = []; - if($requestInfo !== null) { - $selectors[] = 'req_id = ?'; - $values[] = $requestInfo instanceof OAuth2RequestInfo ? $requestInfo->getId() : $requestInfo; + if($authoriseId !== null) { + $selectors[] = 'auth_id = ?'; + $values[] = $authoriseId instanceof OAuth2AuthoriseInfo ? $authoriseId->getId() : $authoriseId; } if($appInfo !== null) { $selectors[] = 'app_id = ?'; @@ -128,11 +128,11 @@ class OAuth2RequestsData { $values[] = $userId; } if($code !== null) { - $selectors[] = 'req_code = ?'; + $selectors[] = 'auth_code = ?'; $values[] = $code; } - $query = 'DELETE FROM hau_oauth2_requests'; + $query = 'DELETE FROM hau_oauth2_authorise'; if(!empty($selectors)) $query .= sprintf(' WHERE %s', implode(' AND ', $selectors)); @@ -143,17 +143,17 @@ class OAuth2RequestsData { $stmt->execute(); } - public function setRequestApproval(OAuth2RequestInfo|string $requestInfo, bool $approval): void { - if($requestInfo instanceof OAuth2RequestInfo) { - if(!$requestInfo->isPending()) + public function setAuthoriseApproval(OAuth2AuthoriseInfo|string $authoriseInfo, bool $approval): void { + if($authoriseInfo instanceof OAuth2AuthoriseInfo) { + if(!$authoriseInfo->isPending()) return; - $requestInfo = $requestInfo->getId(); + $authoriseInfo = $authoriseInfo->getId(); } - $stmt = $this->cache->get('UPDATE hau_oauth2_requests SET req_approval = ? WHERE req_id = ? AND req_approval = "pending"'); + $stmt = $this->cache->get('UPDATE hau_oauth2_authorise SET auth_approval = ? WHERE auth_id = ? AND auth_approval = "pending"'); $stmt->addParameter(1, $approval ? 'approved' : 'denied'); - $stmt->addParameter(2, $requestInfo); + $stmt->addParameter(2, $authoriseInfo); $stmt->execute(); } } diff --git a/src/OAuth2/OAuth2RequestInfo.php b/src/OAuth2/OAuth2AuthoriseInfo.php similarity index 94% rename from src/OAuth2/OAuth2RequestInfo.php rename to src/OAuth2/OAuth2AuthoriseInfo.php index eb34ff1..dbd7086 100644 --- a/src/OAuth2/OAuth2RequestInfo.php +++ b/src/OAuth2/OAuth2AuthoriseInfo.php @@ -4,7 +4,7 @@ namespace Hanyuu\OAuth2; use Index\Data\IDbResult; use Index\Serialisation\UriBase64; -class OAuth2RequestInfo { +class OAuth2AuthoriseInfo { private const EXPIRES_TIME = 10 * 60; public function __construct( @@ -21,8 +21,8 @@ class OAuth2RequestInfo { private int $created ) {} - public static function fromResult(IDbResult $result): OAuth2RequestInfo { - return new OAuth2RequestInfo( + public static function fromResult(IDbResult $result): OAuth2AuthoriseInfo { + return new OAuth2AuthoriseInfo( id: $result->getString(0), appId: $result->getString(1), userId: $result->getString(2), @@ -57,10 +57,6 @@ class OAuth2RequestInfo { return $this->state; } - public function hasCodeChallenge(): bool { - return $this->challengeCode !== ''; - } - public function getChallengeCode(): string { return $this->challengeCode; } diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index 4d931f8..fa9a21b 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -5,22 +5,37 @@ use Index\Data\IDbConnection; use Hanyuu\Apps\AppInfo; class OAuth2Context { - private OAuth2RequestsData $requests; + private OAuth2AuthoriseData $authorise; private OAuth2TokensData $tokens; public function __construct(IDbConnection $dbConn) { - $this->requests = new OAuth2RequestsData($dbConn); + $this->authorise = new OAuth2AuthoriseData($dbConn); $this->tokens = new OAuth2TokensData($dbConn); } - public function getRequestsData(): OAuth2RequestsData { - return $this->requests; + public function getAuthoriseData(): OAuth2AuthoriseData { + return $this->authorise; } public function getTokensData(): OAuth2TokensData { return $this->tokens; } + public function createRefreshFromAccessInfo( + OAuth2AccessInfo $accessInfo, + ?string $token = null, + ?int $lifetime = null + ): OAuth2RefreshInfo { + return $this->tokens->createRefresh( + $accessInfo->getAppId(), + $accessInfo, + $accessInfo->getUserId(), + $token, + $accessInfo->getScope(), + $lifetime + ); + } + public function validateScopes(AppInfo $appInfo, array $scopes): bool { foreach($scopes as $scope) if(strlen($scope) > 128) // rather than this, actually check if they are defined/supported or smth diff --git a/src/OAuth2/OAuth2RefreshInfo.php b/src/OAuth2/OAuth2RefreshInfo.php index 5482509..562c9e1 100644 --- a/src/OAuth2/OAuth2RefreshInfo.php +++ b/src/OAuth2/OAuth2RefreshInfo.php @@ -7,7 +7,7 @@ class OAuth2RefreshInfo { public function __construct( private string $id, private string $appId, - private string $userId, + private ?string $userId, private ?string $accId, private string $token, private string $scope, @@ -19,7 +19,7 @@ class OAuth2RefreshInfo { return new OAuth2RefreshInfo( id: $result->getString(0), appId: $result->getString(1), - userId: $result->getString(2), + userId: $result->getStringOrNull(2), accId: $result->getStringOrNull(3), token: $result->getString(4), scope: $result->getString(5), @@ -36,7 +36,10 @@ class OAuth2RefreshInfo { return $this->appId; } - public function getUserId(): string { + public function hasUserId(): bool { + return $this->userId !== null; + } + public function getUserId(): ?string { return $this->userId; } diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php index 393a48c..fa58a08 100644 --- a/src/OAuth2/OAuth2Routes.php +++ b/src/OAuth2/OAuth2Routes.php @@ -48,17 +48,6 @@ final class OAuth2Routes extends RouteHandler { // } - #[HttpGet('/oauth2/test')] - public function getTest($response) { - $response->redirect(self::buildCallbackUri('/oauth2/authorise', [ - 'response_type' => 'code', - 'client_id' => 'rGKPeDeWQfOVhhHi2qFz', - 'redirect_uri' => 'https://edgii.net/auth/return', - 'state' => 'beanz', - 'scope' => 'windows:xp microsoft:vista windows:forworkgroups', - ])); - } - #[HttpGet('/oauth2/authorise')] public function getAuthorise($response, $request) { $redirectUri = (string)$request->getParam('redirect_uri'); @@ -71,7 +60,7 @@ final class OAuth2Routes extends RouteHandler { $clientId = (string)$request->getParam('client_id'); $appsData = $this->appsCtx->getData(); try { - $appInfo = $appsData->getApp(clientId: $clientId, deleted: false); + $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { return $response->redirect(self::buildCallbackUri($redirectUri, [ 'error' => 'invalid_request', @@ -95,7 +84,7 @@ final class OAuth2Routes extends RouteHandler { 'state' => $state, ])); - $uriInfos = $appsData->getAppUris($appInfo); + $uriInfos = $appsData->getAppUriInfos($appInfo); if(count($uriInfos) !== 1) return $response->redirect(self::buildCallbackUri($redirectUri, [ 'error' => 'invalid_request', @@ -130,14 +119,7 @@ final class OAuth2Routes extends RouteHandler { $codeChallengeLength = strlen($codeChallenge); if($codeChallengeMethod === 'plain') { - if($codeChallengeLength === 0) { - if($appInfo->isPublic()) - return $response->redirect(self::buildCallbackUri($redirectUri, [ - 'error' => 'invalid_request', - 'error_description' => 'Public clients are required to specify a code challenge.', - 'state' => $state, - ])); - } elseif($codeChallengeLength < 43) + if($codeChallengeLength < 43) return $response->redirect(self::buildCallbackUri($redirectUri, [ 'error' => 'invalid_request', 'error_description' => 'Code challenge must be at least 43 characters long.', @@ -179,9 +161,9 @@ final class OAuth2Routes extends RouteHandler { 'auth' => $authInfo, ]); - $reqsData = $this->oauth2Ctx->getRequestsData(); + $authoriseData = $this->oauth2Ctx->getAuthoriseData(); try { - $requestInfo = $reqsData->createRequest( + $authoriseInfo = $authoriseData->createAuthorise( $appInfo, $authInfo->user->id, $redirectUriId, @@ -199,7 +181,7 @@ final class OAuth2Routes extends RouteHandler { return $this->templating->render('oauth2/authorise', [ 'app' => $appInfo, - 'req' => $requestInfo, + 'req' => $authoriseInfo, 'auth' => $authInfo, ]); } @@ -224,9 +206,9 @@ final class OAuth2Routes extends RouteHandler { if(strlen($code) !== 100) return 400; - $reqsData = $this->oauth2Ctx->getRequestsData(); + $authoriseData = $this->oauth2Ctx->getAuthoriseData(); try { - $requestInfo = $reqsData->getRequest( + $authoriseInfo = $authoriseData->getAuthoriseInfo( userId: $authInfo->user->id, code: $code, ); @@ -234,28 +216,28 @@ final class OAuth2Routes extends RouteHandler { return 404; } - if(!$requestInfo->isPending()) + if(!$authoriseInfo->isPending()) return 410; $appsData = $this->appsCtx->getData(); try { - $uriInfo = $appsData->getAppUri($requestInfo->getUriId()); + $uriInfo = $appsData->getAppUriInfo($authoriseInfo->getUriId()); } catch(RuntimeException $ex) { return 400; } $approved = $approve === 'yes'; - $reqsData->setRequestApproval($requestInfo, $approved); + $authoriseData->setAuthoriseApproval($authoriseInfo, $approved); if($approved) $response->redirect(self::buildCallbackUri($uriInfo->getString(), [ - 'code' => $requestInfo->getCode(), - 'state' => $requestInfo->getState(), + 'code' => $authoriseInfo->getCode(), + 'state' => $authoriseInfo->getState(), ])); else $response->redirect(self::buildCallbackUri($uriInfo->getString(), [ 'error' => 'access_denied', - 'state' => $requestInfo->getState(), + 'state' => $authoriseInfo->getState(), ])); } @@ -356,98 +338,142 @@ final class OAuth2Routes extends RouteHandler { } $appsData = $this->appsCtx->getData(); - $reqsData = $this->oauth2Ctx->getRequestsData(); try { - $appInfo = $appsData->getApp(clientId: $clientId, deleted: false); + $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { $response->setStatusCode(400); return self::error('invalid_client', 'No application has been registered with this client id.'); } - $type = (string)$content->getParam('grant_type'); - if($type === 'authorization_code') { - // require a code verifier be used if client_secret is not supplied or if the field for it is populated - // error code for this is: invalid_request - $codeVerifier = (string)$content->getParam('code_verifier'); - $hasCodeVerifier = $codeVerifier !== ''; - if($clientSecret === '') { - if(!$hasCodeVerifier) { - $response->setStatusCode(400); - return self::error('invalid_request', 'Application authentication through client secret is required if no code verifier is specified.'); - } - } elseif(!$appInfo->verifyClientSecret($clientSecret)) { + $appAuthenticated = false; + if($clientSecret !== '') { + // TODO: rate limiting + $appAuthenticated = $appInfo->verifyClientSecret($clientSecret); + if(!$appAuthenticated) { $response->setStatusCode(400); return self::error('invalid_client', 'Provided client secret is not correct for this application.'); } + } + $type = (string)$content->getParam('grant_type'); + if($type === 'authorization_code') { + $authoriseData = $this->oauth2Ctx->getAuthoriseData(); try { - $requestInfo = $reqsData->getRequest(code: (string)$content->getParam('code')); + $authoriseInfo = $authoriseData->getAuthoriseInfo( + appInfo: $appInfo, + code: (string)$content->getParam('code'), + ); } catch(RuntimeException $ex) { $response->setStatusCode(400); return self::error('invalid_grant', 'No authorisation request with this code exists.'); } - if($requestInfo->hasExpired()) { + if($authoriseInfo->hasExpired()) { $response->setStatusCode(400); return self::error('invalid_grant', 'Authorisation request has expired.'); } - if($requestInfo->hasCodeChallenge()) { - if(!$hasCodeVerifier) { - $response->setStatusCode(400); - return self::error('invalid_request', 'Authorisation required included a code challenge, but no code verifier is supplied.'); - } - - if(!$requestInfo->verifyCodeChallenge($codeVerifier)) { - $response->setStatusCode(400); - return self::error('invalid_request', 'Code challenge verification failed.'); - } - } - - $redirectUri = (string)$content->getParam('redirect_uri'); - if($redirectUri === '') { - if(!$hasCodeVerifier && $appInfo->isPublic()) { - $response->setStatusCode(400); - return self::error('invalid_request', 'You must specified the redirect URI if no code verifier is specified.'); - } - } elseif($appsData->getAppUriId($appInfo, $redirectUri) !== $requestInfo->getUriId()) { + if(!$authoriseInfo->verifyCodeChallenge((string)$content->getParam('code_verifier'))) { $response->setStatusCode(400); - return self::error('invalid_request', 'Provided redirect URI does not match up with the one used during the authorisation request.'); + return self::error('invalid_request', 'Code challenge verification failed.'); } - if(!$requestInfo->isApproved()) { + if(!$authoriseInfo->isApproved()) { $response->setStatusCode(400); return self::error('invalid_grant', 'Authorisation request has not been approved.'); } - $scopes = $requestInfo->getScopes(); + // its entirely verified! + $authoriseData->deleteAuthorise($authoriseInfo); + + $scopes = $authoriseInfo->getScopes(); if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) { $response->setStatusCode(400); return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); } + $scope = implode(' ', $scopes); + + $tokensData = $this->oauth2Ctx->getTokensData(); + $accessInfo = $tokensData->createAccess( + $appInfo, + $authoriseInfo->getUserId(), + scope: $scope, + ); + + // 'scope' only has to be in the response if it differs from what was requested + if($scope === $authoriseInfo->getScope()) + unset($scope); + + // this should probably check something else + if($appInfo->isConfidential()) + $refreshInfo = $this->oauth2Ctx->createRefreshFromAccessInfo($accessInfo); } elseif($type === 'refresh_token') { - $refreshToken = (string)$content->getParam('refresh_token'); + $tokensData = $this->oauth2Ctx->getTokensData(); + try { + $refreshInfo = $tokensData->getRefreshInfo((string)$content->getParam('refresh_token'), OAuth2TokensData::REFRESH_BY_TOKEN); + } catch(RuntimeException $ex) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'No such refresh token exists.'); + } + + if($refreshInfo->getAppId() !== $appInfo->getId()) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'This refresh token is not associated with this application.'); + } + + if($refreshInfo->hasExpired()) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'This refresh token has expired.'); + } + + $tokensData->deleteRefresh($refreshInfo); + + if($refreshInfo->hasAccessId()) + $tokensData->deleteAccess(accessInfo: $refreshInfo->getAccessId()); // should not contain more than the original access_token, refresh info contains the stuff we need! $newScopes = self::filterScopes((string)$content->getParam('scope')); $oldScopes = []; - if($this->oauth2Ctx->validateScopes($appInfo, $newScopes)) { + if(!$this->oauth2Ctx->validateScopes($appInfo, $newScopes)) { $response->setStatusCode(400); return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); } + + $accessInfo = $tokensData->createAccess( + $appInfo, + $refreshInfo->getUserId(), + scope: $refreshInfo->getScope(), // just copy from refreshInfo for now, should reverify!! + ); + + unset($refreshInfo); + + // this should probably check something else + if($appInfo->isConfidential()) + $refreshInfo = $this->oauth2Ctx->createRefreshFromAccessInfo($accessInfo); } elseif($type === 'client_credentials') { - // uses client_secret - } elseif($type === 'password') { - // still really not sure if i should bother with implementing this, especially since its omitted from OAuth 2.1 entirely - if(!$appInfo->isTrusted()) { + if(!$appInfo->isConfidential()) { $response->setStatusCode(400); return self::error('unauthorized_client', 'This application is not allowed to use this grant type.'); } - $userName = (string)$content->getParam('username'); - $password = (string)$content->getParam('password'); + if(!$appAuthenticated) { + $response->setStatusCode(400); + return self::error('invalid_client', 'Application must authenticate with client secret in order to use this grant type.'); + } + + $scopes = self::filterScopes((string)$content->getParam('scope')); + if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) { + $response->setStatusCode(400); + return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); + } + $scope = implode(' ', $scopes); + + $accessInfo = $this->oauth2Ctx->getTokensData()->createAccess($appInfo, scope: $scope); + + // i'll just unset it, really need to dive deeper into the scope sitch + unset($scope); } elseif($type === 'device_code' || $type === 'urn:ietf:params:oauth:grant-type:device_code') { $deviceCode = (string)$content->getParam('device_code'); @@ -477,29 +503,22 @@ final class OAuth2Routes extends RouteHandler { if(empty($accessInfo)) { $response->setStatusCode(400); - - if($type === 'refresh_token') - $message = 'Failed to request new access token. Provided refresh token has likely expired or is invalid.'; - elseif($type === 'password') - $message = 'Failed to request access token, user name or password was incorrect.'; - elseif($type === 'client_credentials') - $message = 'Failed to request application access token.'; - else - $message = 'Failed to request access token.'; - - return self::error('invalid_grant', $message); + return self::error('invalid_grant', 'Failed to request access token.'); } $result = [ 'access_token' => $accessInfo->getToken(), 'token_type' => 'Bearer', - 'expires_in' => $accessInfo->getRemainingLifetime(), ]; - if(isset($scopes)) - $result['scope'] = implode(' ', $scopes); + $expiresIn = $accessInfo->getRemainingLifetime(); + if($expiresIn < 3600) + $result['expires_in'] = $expiresIn; - if(empty($refreshInfo)) + if(isset($scope)) + $result['scope'] = $scope; + + if(!empty($refreshInfo)) $result['refresh_token'] = $refreshInfo->getToken(); return $result; diff --git a/src/OAuth2/OAuth2TokensData.php b/src/OAuth2/OAuth2TokensData.php index 512a8ac..d11fa81 100644 --- a/src/OAuth2/OAuth2TokensData.php +++ b/src/OAuth2/OAuth2TokensData.php @@ -6,6 +6,7 @@ use RuntimeException; use Index\XString; use Index\Data\DbStatementCache; use Index\Data\IDbConnection; +use Hanyuu\Apps\AppInfo; class OAuth2TokensData { private IDbConnection $dbConn; @@ -16,4 +17,172 @@ class OAuth2TokensData { $this->cache = new DbStatementCache($dbConn); } + public const ACCESS_BY_ID = 'id'; + public const ACCESS_BY_TOKEN = 'token'; + + public function getAccessInfo(string $value, string $select): OAuth2AccessInfo { + if($select === self::ACCESS_BY_ID) + $select = 'acc_id'; + elseif($select === self::ACCESS_BY_TOKEN) + $select = 'acc_token'; + else + throw new InvalidArgumentException('$select is not a valid select mode'); + + $stmt = $this->cache->get(sprintf( + 'SELECT acc_id, app_id, user_id, acc_token, acc_scope, UNIX_TIMESTAMP(acc_created), UNIX_TIMESTAMP(acc_expires) FROM hau_oauth2_access WHERE %s = ?', + $select + )); + $stmt->addParameter(1, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Access info not found.'); + + return OAuth2AccessInfo::fromResult($result); + } + + public function createAccess( + AppInfo|string $appInfo, + ?string $userId = null, + ?string $token = null, + string $scope = '', + ?int $lifetime = null + ): OAuth2AccessInfo { + $token ??= XString::random(80); + + $stmt = $this->cache->get(sprintf('INSERT INTO hau_oauth2_access (app_id, user_id, acc_token, acc_scope, acc_expires) VALUES (?, ?, ?, ?, IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(acc_expires)))')); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->addParameter(2, $userId); + $stmt->addParameter(3, $token); + $stmt->addParameter(4, $scope); + $stmt->addParameter(5, $lifetime === null ? 0 : 1); + $stmt->addParameter(6, $lifetime); + $stmt->execute(); + + return $this->getAccessInfo((string)$this->dbConn->getLastInsertId(), self::ACCESS_BY_ID); + } + + public function deleteAccess( + OAuth2AccessInfo|string|null $accessInfo = null, + AppInfo|string|null $appInfo = null, + ?string $userId = null, + ): void { + $selectors = []; + $values = []; + if($accessInfo !== null) { + $selectors[] = 'acc_id = ?'; + $values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->getId() : $accessInfo; + } + if($appInfo !== null) { + $selectors[] = 'app_id = ?'; + $values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo; + } + if($userId !== null) { + $selectors[] = 'user_id = ?'; + $values[] = $userId; + } + + $query = 'DELETE FROM hau_oauth2_access'; + if(!empty($selectors)) + $query .= sprintf(' WHERE %s', implode(' AND ', $selectors)); + + $args = 0; + $stmt = $this->cache->get($query); + foreach($values as $value) + $stmt->addParameter(++$args, $value); + $stmt->execute(); + } + + public const REFRESH_BY_ID = 'id'; + public const REFRESH_BY_ACCESS = 'access'; + public const REFRESH_BY_TOKEN = 'token'; + + public function getRefreshInfo(object|string $value, string $select): OAuth2RefreshInfo { + if($select === self::REFRESH_BY_ID) { + $select = 'ref_id'; + } elseif($select === self::REFRESH_BY_ACCESS) { + $select = 'acc_id'; + if($value instanceof OAuth2AccessInfo) + $value = $value->getId(); + } elseif($select === self::REFRESH_BY_TOKEN) { + $select = 'ref_token'; + } else + throw new InvalidArgumentException('$select is not a valid select mode'); + + if(!is_string($value)) + throw new InvalidArgumentException('$value must be a string'); + + $stmt = $this->cache->get(sprintf( + 'SELECT ref_id, app_id, user_id, acc_id, ref_token, ref_scope, UNIX_TIMESTAMP(ref_created), UNIX_TIMESTAMP(ref_expires) FROM hau_oauth2_refresh WHERE %s = ?', + $select + )); + $stmt->addParameter(1, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Refresh info not found.'); + + return OAuth2RefreshInfo::fromResult($result); + } + + public function createRefresh( + AppInfo|string $appInfo, + OAuth2AccessInfo|string|null $accessInfo, + ?string $userId = null, + ?string $token = null, + string $scope = '', + ?int $lifetime = null + ): OAuth2RefreshInfo { + $token ??= XString::random(120); + + $stmt = $this->cache->get(sprintf('INSERT INTO hau_oauth2_refresh (app_id, user_id, acc_id, ref_token, ref_scope, ref_expires) VALUES (?, ?, ?, ?, ?, IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(ref_expires)))')); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->addParameter(2, $userId); + $stmt->addParameter(3, $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->getId() : $accessInfo); + $stmt->addParameter(4, $token); + $stmt->addParameter(5, $scope); + $stmt->addParameter(6, $lifetime === null ? 0 : 1); + $stmt->addParameter(7, $lifetime); + $stmt->execute(); + + return $this->getRefreshInfo((string)$this->dbConn->getLastInsertId(), self::REFRESH_BY_ID); + } + + public function deleteRefresh( + OAuth2RefreshInfo|string|null $refreshInfo = null, + AppInfo|string|null $appInfo = null, + ?string $userId = null, + OAuth2AccessInfo|string|null $accessInfo = null + ): void { + $selectors = []; + $values = []; + if($refreshInfo !== null) { + $selectors[] = 'ref_id = ?'; + $values[] = $refreshInfo instanceof OAuth2RefreshInfo ? $refreshInfo->getId() : $refreshInfo; + } + if($appInfo !== null) { + $selectors[] = 'app_id = ?'; + $values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo; + } + if($userId !== null) { + $selectors[] = 'user_id = ?'; + $values[] = $userId; + } + if($accessInfo !== null) { + $selectors[] = 'acc_id = ?'; + $values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->getId() : $accessInfo; + } + + $query = 'DELETE FROM hau_oauth2_refresh'; + if(!empty($selectors)) + $query .= sprintf(' WHERE %s', implode(' AND ', $selectors)); + + $args = 0; + $stmt = $this->cache->get($query); + foreach($values as $value) + $stmt->addParameter(++$args, $value); + $stmt->execute(); + } }