Implemented the device code flow.

This commit is contained in:
flash 2024-07-20 18:51:39 +00:00
parent c8432fa15e
commit ce57936c3a
6 changed files with 242 additions and 63 deletions

View file

@ -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,

View file

@ -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';

View file

@ -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,

View file

@ -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;

View file

@ -0,0 +1,146 @@
<?php
namespace Hanyuu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\Serialisation\Base32;
use Hanyuu\Apps\AppInfo;
class OAuth2DevicesData {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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();
}
}

View file

@ -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))