diff --git a/src/OAuth2/OAuth2AccessInfo.php b/src/OAuth2/OAuth2AccessInfo.php index 33cdf9a..11c4391 100644 --- a/src/OAuth2/OAuth2AccessInfo.php +++ b/src/OAuth2/OAuth2AccessInfo.php @@ -4,6 +4,8 @@ namespace Hanyuu\OAuth2; use Index\Data\IDbResult; class OAuth2AccessInfo { + public const DEFAULT_LIFETIME = 3600; + public function __construct( private string $id, private string $appId, diff --git a/src/OAuth2/OAuth2AuthoriseData.php b/src/OAuth2/OAuth2AuthoriseData.php index 2757e59..804000a 100644 --- a/src/OAuth2/OAuth2AuthoriseData.php +++ b/src/OAuth2/OAuth2AuthoriseData.php @@ -22,11 +22,6 @@ class OAuth2AuthoriseData { ?string $authoriseId = null, AppInfo|string|null $appInfo = null, ?string $userId = null, - AppUriInfo|string|null $appUriInfo = null, - ?string $state = null, - ?string $challengeCode = null, - ?string $challengeMethod = null, - ?string $scope = null, ?string $code = null ): OAuth2AuthoriseInfo { $selectors = []; @@ -43,26 +38,6 @@ class OAuth2AuthoriseData { $selectors[] = 'user_id = ?'; $values[] = $userId; } - if($appUriInfo !== null) { - $selectors[] = 'uri_id = ?'; - $values[] = $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo; - } - if($state !== null) { - $selectors[] = 'auth_state = ?'; - $values[] = $state; - } - if($challengeCode !== null) { - $selectors[] = 'auth_challenge_code = ?'; - $values[] = $challengeCode; - } - if($challengeMethod !== null) { - $selectors[] = 'auth_challenge_method = ?'; - $values[] = $challengeMethod; - } - if($scope !== null) { - $selectors[] = 'auth_scope = ?'; - $values[] = $scope; - } if($code !== null) { $selectors[] = 'auth_code = ?'; $values[] = $code; @@ -108,28 +83,13 @@ class OAuth2AuthoriseData { } public function deleteAuthorise( - OAuth2AuthoriseInfo|string|null $authoriseId = null, - AppInfo|string|null $appInfo = null, - ?string $userId = null, - ?string $code = null + OAuth2AuthoriseInfo|string|null $authoriseInfo = null ): void { $selectors = []; $values = []; - if($authoriseId !== null) { + if($authoriseInfo !== null) { $selectors[] = 'auth_id = ?'; - $values[] = $authoriseId instanceof OAuth2AuthoriseInfo ? $authoriseId->getId() : $authoriseId; - } - if($appInfo !== null) { - $selectors[] = 'app_id = ?'; - $values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo; - } - if($userId !== null) { - $selectors[] = 'user_id = ?'; - $values[] = $userId; - } - if($code !== null) { - $selectors[] = 'auth_code = ?'; - $values[] = $code; + $values[] = $authoriseInfo instanceof OAuth2AuthoriseInfo ? $authoriseInfo->getId() : $authoriseInfo; } $query = 'DELETE FROM hau_oauth2_authorise'; diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php index fa9a21b..821c57e 100644 --- a/src/OAuth2/OAuth2Context.php +++ b/src/OAuth2/OAuth2Context.php @@ -7,10 +7,12 @@ use Hanyuu\Apps\AppInfo; class OAuth2Context { private OAuth2AuthoriseData $authorise; private OAuth2TokensData $tokens; + private OAuth2DevicesData $devices; public function __construct(IDbConnection $dbConn) { $this->authorise = new OAuth2AuthoriseData($dbConn); $this->tokens = new OAuth2TokensData($dbConn); + $this->devices = new OAuth2DevicesData($dbConn); } public function getAuthoriseData(): OAuth2AuthoriseData { @@ -21,6 +23,10 @@ class OAuth2Context { return $this->tokens; } + public function getDevicesData(): OAuth2DevicesData { + return $this->devices; + } + public function createRefreshFromAccessInfo( OAuth2AccessInfo $accessInfo, ?string $token = null, diff --git a/src/OAuth2/OAuth2DeviceInfo.php b/src/OAuth2/OAuth2DeviceInfo.php index 7b7b80f..c26da84 100644 --- a/src/OAuth2/OAuth2DeviceInfo.php +++ b/src/OAuth2/OAuth2DeviceInfo.php @@ -4,6 +4,9 @@ namespace Hanyuu\OAuth2; use Index\Data\IDbResult; class OAuth2DeviceInfo { + public const DEFAULT_LIFETIME = 600; + public const DEFAULT_POLL_INTERVAL = 5; + public function __construct( private string $id, private string $appId, @@ -13,13 +16,14 @@ class OAuth2DeviceInfo { private int $attempts, private int $interval, private int $polled, + private string $scope, private string $approval, private int $created, private int $expires ) {} - public static function fromResult(IDbResult $result): OAuth2AuthoriseInfo { - return new OAuth2AuthoriseInfo( + public static function fromResult(IDbResult $result): OAuth2DeviceInfo { + return new OAuth2DeviceInfo( id: $result->getString(0), appId: $result->getString(1), userId: $result->getStringOrNull(2), @@ -28,9 +32,10 @@ class OAuth2DeviceInfo { attempts: $result->getInteger(5), interval: $result->getInteger(6), polled: $result->getInteger(7), - approval: $result->getString(8), - created: $result->getInteger(9), - expires: $result->getInteger(10), + scope: $result->getString(8), + approval: $result->getString(9), + created: $result->getInteger(10), + expires: $result->getInteger(11), ); } @@ -56,10 +61,18 @@ class OAuth2DeviceInfo { public function getUserCode(): string { return $this->userCode; } + public function getUserCodeDashed(int $interval = 4): string { + // how is this a stdlib function????? i mean thanks but ????? + // update: it was too good to be true, i need to trim anyway... + return trim(chunk_split($this->userCode, $interval, '-'), '-'); + } public function getRemainingAttempts(): int { return $this->attempts; } + public function hasRemainingAttempts(): bool { + return $this->attempts > 0; + } public function getPollInterval(): int { return $this->interval; @@ -68,6 +81,16 @@ class OAuth2DeviceInfo { public function getPolledTime(): int { return $this->polled; } + public function isSpeedy(): bool { + return ($this->polled + $this->interval) > time(); + } + + public function getScope(): string { + return $this->scope; + } + public function getScopes(): array { + return explode(' ', $this->scope); + } public function getApproval(): string { return $this->approval; diff --git a/src/OAuth2/OAuth2DevicesData.php b/src/OAuth2/OAuth2DevicesData.php new file mode 100644 index 0000000..e70484d --- /dev/null +++ b/src/OAuth2/OAuth2DevicesData.php @@ -0,0 +1,146 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + public function getDeviceInfo( + ?string $deviceId = null, + AppInfo|string|null $appInfo = null, + string|null|false $userId = false, + ?string $code = null, + ?string $userCode = null + ): OAuth2DeviceInfo { + $selectors = []; + $values = []; + if($deviceId !== null) { + $selectors[] = 'dev_id = ?'; + $values[] = $deviceId; + } + if($appInfo !== null) { + $selectors[] = 'app_id = ?'; + $values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo; + } + if($userId !== false) { + if($userId === null) { + $selectors[] = 'user_id IS NULL'; + } else { + $selectors[] = 'user_id = ?'; + $values[] = $userId; + } + } + if($code !== null) { + $selectors[] = 'dev_code = ?'; + $values[] = $code; + } + if($userCode !== null) { + $selectors[] = 'dev_user_code = ?'; + $values[] = $userCode; + } + + if(empty($selectors)) + throw new RuntimeException('Insufficient data to do device authorisation request lookup.'); + + $args = 0; + $stmt = $this->cache->get('SELECT dev_id, app_id, user_id, dev_code, dev_user_code, dev_attempts, dev_interval, UNIX_TIMESTAMP(dev_polled), dev_scope, dev_approval, UNIX_TIMESTAMP(dev_created), UNIX_TIMESTAMP(dev_expires) FROM hau_oauth2_device WHERE ' . implode(' AND ', $selectors)); + foreach($values as $value) + $stmt->addParameter(++$args, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Device authorisation request not found.'); + + return OAuth2DeviceInfo::fromResult($result); + } + + public function createDevice( + AppInfo|string $appInfo, + string $scope, + ?string $userId = null, + ?string $code = null, + ?string $userCode = null + ): OAuth2DeviceInfo { + $code ??= XString::random(60); + $userCode ??= Base32::encode(random_bytes(5)); + + $stmt = $this->cache->get('INSERT INTO hau_oauth2_device (app_id, user_id, dev_code, dev_user_code, dev_scope) VALUES (?, ?, ?, ?, ?)'); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->addParameter(2, $userId); + $stmt->addParameter(3, $code); + $stmt->addParameter(4, $userCode); + $stmt->addParameter(5, $scope); + $stmt->execute(); + + return $this->getDeviceInfo(deviceId: (string)$this->dbConn->getLastInsertId()); + } + + public function deleteDevice( + OAuth2DeviceInfo|string|null $deviceInfo = null + ): void { + $selectors = []; + $values = []; + if($deviceInfo !== null) { + $selectors[] = 'dev_id = ?'; + $values[] = $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo; + } + + $query = 'DELETE FROM hau_oauth2_device'; + 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 function setDeviceApproval(OAuth2DeviceInfo|string $deviceInfo, bool $approval, ?string $userId = null): void { + if($deviceInfo instanceof OAuth2DeviceInfo) { + if(!$deviceInfo->isPending()) + return; + + $deviceInfo = $deviceInfo->getId(); + } + + $stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_approval = ?, user_id = COALESCE(user_id, ?) WHERE dev_id = ? AND dev_approval = "pending" AND user_id IS NULL'); + $stmt->addParameter(1, $approval ? 'approved' : 'denied'); + $stmt->addParameter(2, $userId); + $stmt->addParameter(3, $deviceInfo); + $stmt->execute(); + } + + public function decrementDeviceUserAttempts(OAuth2DeviceInfo|string $deviceInfo): void { + $stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_attempts = MAX(0, dev_attempts - 1) WHERE dev_id = ?'); + $stmt->addParameter(1, $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo); + $stmt->execute(); + } + + public function bumpDevicePollTime(OAuth2DeviceInfo|string $deviceInfo): void { + $stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_polled = NOW() WHERE dev_id = ?'); + $stmt->addParameter(1, $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo); + $stmt->execute(); + } + + public function incrementDevicePollInterval(OAuth2DeviceInfo|string $deviceInfo, int $amount = 5): void { + $stmt = $this->cache->get('UPDATE hau_oauth2_device SET dev_interval = dev_interval + ?, dev_polled = NOW() WHERE dev_id = ?'); + $stmt->addParameter(1, $amount); + $stmt->addParameter(2, $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo); + $stmt->execute(); + } +} diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php index 95fdb3e..13c0cbd 100644 --- a/src/OAuth2/OAuth2Routes.php +++ b/src/OAuth2/OAuth2Routes.php @@ -276,7 +276,6 @@ final class OAuth2Routes extends RouteHandler { } $appsData = $this->appsCtx->getData(); - try { $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { @@ -300,18 +299,23 @@ final class OAuth2Routes extends RouteHandler { } $scope = implode(' ', $scopes); - $userCode = 'SOAP-504P'; + $deviceInfo = $this->oauth2Ctx->getDevicesData()->createDevice($appInfo, $scope); + $userCode = $deviceInfo->getUserCodeDashed(); $result = [ - 'device_code' => 'soapsoapsoapsoapsoapsoapsoapsoap', + 'device_code' => $deviceInfo->getCode(), 'user_code' => $userCode, 'verification_uri' => $this->config->getString('device:verification_uri'), 'verification_uri_complete' => sprintf($this->config->getString('device:verification_uri_complete'), $userCode), - 'expires_in' => 1800, ]; - // in case interval isn't 5 - $result['interval'] = 5; + $expiresIn = $deviceInfo->getRemainingLifetime(); + if($expiresIn < OAuth2DeviceInfo::DEFAULT_LIFETIME) + $result['expires_in'] = $expiresIn; + + $interval = $deviceInfo->getPollInterval(); + if($interval > OAuth2DeviceInfo::DEFAULT_POLL_INTERVAL) + $result['interval'] = $interval; return $result; } @@ -368,7 +372,6 @@ final class OAuth2Routes extends RouteHandler { } $appsData = $this->appsCtx->getData(); - try { $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false); } catch(RuntimeException $ex) { @@ -414,7 +417,6 @@ final class OAuth2Routes extends RouteHandler { return self::error('invalid_grant', 'Authorisation request has not been approved.'); } - // its entirely verified! $authoriseData->deleteAuthorise($authoriseInfo); $scopes = $authoriseInfo->getScopes(); @@ -505,27 +507,67 @@ final class OAuth2Routes extends RouteHandler { // 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'); + $devicesData = $this->oauth2Ctx->getDevicesData(); + try { + $deviceInfo = $devicesData->getDeviceInfo( + appInfo: $appInfo, + code: (string)$content->getParam('device_code') + ); + } catch(RuntimeException) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'No such device code exists.'); + } - if($deviceCode === 'expired') { + if($deviceInfo->hasExpired()) { $response->setStatusCode(400); return self::error('expired_token', 'This device code has expired.'); } - if($deviceCode === 'speedy') { + if($deviceInfo->isSpeedy()) { + $devicesData->incrementDevicePollInterval($deviceInfo); $response->setStatusCode(400); return self::error('slow_down', 'You are polling too fast, please increase your interval by 5 seconds.'); } - if($deviceCode === 'denied') { + if($deviceInfo->isPending()) { + $devicesData->bumpDevicePollTime($deviceInfo); + $response->setStatusCode(400); + return self::error('authorization_pending', 'User has not yet completed authorisation, check again in a bit.'); + } + + $devicesData->deleteDevice($deviceInfo); + + if(!$deviceInfo->isApproved()) { $response->setStatusCode(400); return self::error('access_denied', 'User has rejected authorisation attempt.'); } - if($deviceCode === 'pending') { + if(!$deviceInfo->hasUserId()) { $response->setStatusCode(400); - return self::error('authorization_pending', 'User has not yet completed authorisation, check again in a bit.'); + return self::error('invalid_request', 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.'); } + + $scopes = $deviceInfo->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, + $deviceInfo->getUserId(), + scope: $scope, + ); + + // 'scope' only has to be in the response if it differs from what was requested + if($scope === $deviceInfo->getScope()) + unset($scope); + + // this should probably check something else + if($appInfo->isConfidential()) + $refreshInfo = $this->oauth2Ctx->createRefreshFromAccessInfo($accessInfo); } else { $response->setStatusCode(400); return self::error('unsupported_grant_type', 'Requested grant type is not supported by this server.'); @@ -542,7 +584,7 @@ final class OAuth2Routes extends RouteHandler { ]; $expiresIn = $accessInfo->getRemainingLifetime(); - if($expiresIn < 3600) + if($expiresIn < OAuth2AccessInfo::DEFAULT_LIFETIME) $result['expires_in'] = $expiresIn; if(isset($scope))