Implemented the device code flow.
This commit is contained in:
parent
c8432fa15e
commit
ce57936c3a
6 changed files with 242 additions and 63 deletions
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
146
src/OAuth2/OAuth2DevicesData.php
Normal file
146
src/OAuth2/OAuth2DevicesData.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue