Compare commits

...

3 commits

22 changed files with 569 additions and 159 deletions

View file

@ -28,6 +28,10 @@
background-color: #0a0;
margin: 2px;
}
.oauth2-scope-perm-icon-warn {
mask: url('/images/circle-regular.svg') no-repeat center, url('/images/exclamation-solid.svg') no-repeat center center / 10px 10px;
background-color: #c80;
}
.oauth2-scope-perm-text {
font-size: .8em;
line-height: 1.4em;

View file

@ -1,6 +1,10 @@
const HanyuuOAuth2AppScopeEntry = function(text) {
const HanyuuOAuth2AppScopeEntry = function(text, warn) {
const icon = <div class="oauth2-scope-perm-icon"/>;
if(warn)
icon.classList.add('oauth2-scope-perm-icon-warn');
const element = <div class="oauth2-scope-perm">
<div class="oauth2-scope-perm-icon"></div>
{icon}
<div class="oauth2-scope-perm-text">{text}</div>
</div>;
@ -9,14 +13,14 @@ const HanyuuOAuth2AppScopeEntry = function(text) {
};
};
const HanyuuOAuth2AppScopeList = function() {
// TODO: actual scope listing
const permsElem = <div class="oauth2-scope-perms">
{new HanyuuOAuth2AppScopeEntry('Do anything because I have not made up scopes yet.')}
{new HanyuuOAuth2AppScopeEntry('Eat soup.')}
{new HanyuuOAuth2AppScopeEntry('These are placeholders.')}
{new HanyuuOAuth2AppScopeEntry('This one is really long because I want to test wrapping and how the chevron icon thing will handle it so there will be a lot of text here, the app will not be gaining anything from it but yeah sometimes you just need to explode seventy times.')}
</div>;
const HanyuuOAuth2AppScopeList = function(scopes) {
const permsElem = <div class="oauth2-scope-perms"/>;
if(Array.isArray(scopes) && scopes.length > 0) {
for(const scope of scopes)
if(typeof scope === 'string')
permsElem.appendChild(new HanyuuOAuth2AppScopeEntry(scope).element);
} else
permsElem.appendChild(new HanyuuOAuth2AppScopeEntry('A limited amount of things. No scope was specified by the developer.', true).element);
const element = <div class="oauth2-scope">
<div class="oauth2-scope-header">This application will be able to:</div>

View file

@ -75,7 +75,7 @@ const HanyuuOAuth2Authorise = async () => {
window.location.assign(errorUri);
};
const translateError = serverError => {
const translateError = (serverError, detail) => {
if(serverError === 'auth')
return displayError('access_denied');
if(serverError === 'csrf')
@ -91,7 +91,7 @@ const HanyuuOAuth2Authorise = async () => {
if(serverError === 'required')
return displayError('invalid_request', 'A registered redirect URI must be specified.');
if(serverError === 'scope')
return displayError('invalid_scope');
return displayError('invalid_scope', detail === undefined ? undefined : `Requested scope "${detail.scope}" is ${detail.reason}.`);
if(serverError === 'authorise')
return displayError('server_error', 'Server was unable to complete authorisation.');
@ -162,7 +162,7 @@ const HanyuuOAuth2Authorise = async () => {
if(!resolved)
throw 'authorisation resolve failed';
if(typeof resolved.error === 'string')
return translateError(resolved.error);
return translateError(resolved.error, resolved);
const userHeader = new HanyuuOAuth2UserHeader(resolved.user);
header.setElement(userHeader);
@ -184,7 +184,7 @@ const HanyuuOAuth2Authorise = async () => {
if(!response)
throw 'authorisation failed';
if(typeof response.error === 'string')
return translateError(response.error);
return translateError(response.error, resolved);
const authoriseUri = new URL(response.redirect);
authoriseUri.searchParams.set('code', response.code);
@ -206,11 +206,8 @@ const HanyuuOAuth2Authorise = async () => {
return;
}
const appElem = new HanyuuOAuth2AppInfo(resolved.app);
eAuthsInfo.replaceWith(appElem.element);
const scopeElem = new HanyuuOAuth2AppScopeList();
eAuthsScope.replaceWith(scopeElem.element);
eAuthsInfo.replaceWith(new HanyuuOAuth2AppInfo(resolved.app).element);
eAuthsScope.replaceWith(new HanyuuOAuth2AppScopeList(resolved.scope).element);
fAuths.onsubmit = ev => {
ev.preventDefault();

View file

@ -40,9 +40,16 @@ const HanyuuOAuth2Verify = () => {
alert('This code is not associated with any authorisation request.');
else if(response.error === 'approval')
alert('The authorisation request associated with this code is not pending approval.');
else if(response.error === 'expired')
alert('The authorisation request has expired, please restart the process from the application or device.');
else if(response.error === 'invalid')
alert('Invalid approval state specified.');
else
else if(response.error === 'scope') {
alert(`Requested scope "${response.scope}" is ${response.reason}.`);
loading.visible = false;
rDenied.classList.remove('hidden');
return;
} else
alert(`An unknown error occurred: ${response.error}`);
loading.visible = false;
@ -83,7 +90,8 @@ const HanyuuOAuth2Verify = () => {
loading.visible = true;
fCode.classList.add('hidden');
$x.get(`/oauth2/resolve-verify?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${encodeURIComponent(eUserCode.value)}`, { type: 'json' })
userCode= encodeURIComponent(eUserCode.value);
$x.get(`/oauth2/resolve-verify?csrfp=${encodeURIComponent(HanyuuCSRFP.getToken())}&code=${userCode}`, { type: 'json' })
.then(result => {
const response = result.body();
@ -102,9 +110,16 @@ const HanyuuOAuth2Verify = () => {
alert('Request verification failed, please refresh and try again.');
else if(response.error === 'code')
alert('This code is not associated with any authorisation request.');
else if(response.error === 'expired')
alert('The authorisation request has expired, please restart the process from the application or device.');
else if(response.error === 'approval')
alert('The authorisation request associated with this code is not pending approval.');
else
else if(response.error === 'scope') {
verifyAuthsRequest(false).finally(() => {
alert(`Requested scope "${response.scope}" is ${response.reason}.`);
});
return;
} else
alert(`An unknown error occurred: ${response.error}`);
loading.visible = false;
@ -125,11 +140,8 @@ const HanyuuOAuth2Verify = () => {
return;
}
const appElem = new HanyuuOAuth2AppInfo(response.app);
eAuthsInfo.replaceWith(appElem.element);
const scopeElem = new HanyuuOAuth2AppScopeList();
eAuthsScope.replaceWith(scopeElem.element);
eAuthsInfo.replaceWith(new HanyuuOAuth2AppInfo(response.app).element);
eAuthsScope.replaceWith(new HanyuuOAuth2AppScopeList(response.scope).element);
loading.visible = false;
fAuths.classList.remove('hidden');

View file

@ -0,0 +1,43 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class ScopeTables_20240903_201657 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute(<<<SQL
CREATE TABLE hau_scopes (
scope_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
scope_string VARCHAR(50) NOT NULL COLLATE 'ascii_bin',
scope_restricted TINYINT(3) UNSIGNED NOT NULL,
scope_summary VARCHAR(255) NOT NULL DEFAULT '' COLLATE 'utf8mb4_unicode_520_ci',
scope_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
scope_deprecated TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (scope_id),
UNIQUE INDEX hau_scopes_string_unique (scope_string),
INDEX hau_scopes_created_index (scope_created),
INDEX hau_scopes_deprecated_index (scope_deprecated)
) COLLATE=utf8mb4_bin ENGINE=InnoDB;
SQL);
$conn->execute(<<<SQL
CREATE TABLE hau_apps_scopes (
app_id INT(10) UNSIGNED NOT NULL,
scope_id INT(10) UNSIGNED NOT NULL,
scope_allowed TINYINT(3) UNSIGNED NOT NULL,
PRIMARY KEY (app_id, scope_id),
INDEX hau_apps_scopes_app_foreign (app_id),
INDEX hau_apps_scopes_scope_foreign (scope_id),
CONSTRAINT hau_apps_scopes_app_foreign
FOREIGN KEY (app_id)
REFERENCES hau_apps (app_id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT hau_apps_scopes_scope_foreign
FOREIGN KEY (scope_id)
REFERENCES hau_scopes (scope_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) COLLATE=utf8mb4_bin ENGINE=InnoDB;
SQL);
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>

After

Width:  |  Height:  |  Size: 328 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M96 64c0-17.7-14.3-32-32-32S32 46.3 32 64l0 256c0 17.7 14.3 32 32 32s32-14.3 32-32L96 64zM64 480a40 40 0 1 0 0-80 40 40 0 1 0 0 80z"/></svg>

After

Width:  |  Height:  |  Size: 362 B

View file

@ -90,7 +90,7 @@ class AppInfo {
public function getClientSecret(): string {
return $this->clientSecret;
}
public function verifyClientSecret(string $input): bool {
public function verifyClientSecret(#[\SensitiveParameter] string $input): bool {
return $this->isConfidential()
&& password_verify($input, $this->clientSecret);
}

View file

@ -0,0 +1,27 @@
<?php
namespace Hanyuu\Apps;
class AppScopesInfo {
public function __construct(
private array $allowed,
private array $denied
) {}
public function getAllowed(): array {
return $this->allowed;
}
public function getDenied(): array {
return $this->denied;
}
public function isAllowed(string $scope, bool $requiresAllow): bool {
if(in_array($scope, $this->denied))
return false;
if($requiresAllow && !in_array($scope, $this->allowed))
return false;
return true;
}
}

View file

@ -1,16 +1,60 @@
<?php
namespace Hanyuu\Apps;
use RuntimeException;
use Index\Data\IDbConnection;
class AppsContext {
private AppsData $data;
private AppsData $apps;
private ScopesData $scopes;
public function __construct(IDbConnection $dbConn) {
$this->data = new AppsData($dbConn);
$this->apps = new AppsData($dbConn);
$this->scopes = new ScopesData($dbConn);
}
public function getData(): AppsData {
return $this->data;
public function getAppsData(): AppsData {
return $this->apps;
}
public function getScopesData(): ScopesData {
return $this->scopes;
}
public function handleScopeString(AppInfo|string $appInfo, string $scope, bool $allowDeprecated = false, bool $sort = true, bool $breakOnFail = true): array {
return $this->handleScopes($appInfo, explode(' ', $scope), $allowDeprecated, $sort, $breakOnFail);
}
public function handleScopes(AppInfo|string $appInfo, array $strings, bool $allowDeprecated = false, bool $sort = true, bool $breakOnFail = true): array {
if(is_string($appInfo))
$appInfo = $this->apps->getAppInfo(appId: $appInfo, deleted: false);
$infos = [];
foreach($strings as $string) {
try {
$scopeInfo = $this->scopes->getScopeInfo($string, ScopesData::BY_STRING);
if(!$allowDeprecated && $scopeInfo->isDeprecated()) {
$infos[$string] = 'deprecated';
if($breakOnFail) break; else continue;
}
if(!$this->apps->isAppScopeAllowed($appInfo, $scopeInfo)) {
$infos[$string] = 'restricted';
if($breakOnFail) break; else continue;
}
$infos[$string] = $scopeInfo;
} catch(RuntimeException $ex) {
$infos[$string] = 'unknown';
if($breakOnFail) break; else continue;
}
}
if($sort)
ksort($infos, SORT_STRING);
return $infos;
}
}

View file

@ -1,17 +1,18 @@
<?php
namespace Hanyuu\Apps;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\XString;
use Index\Data\{DbStatementCache,IDbConnection};
class AppsData {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
public function __construct(
private IDbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
@ -44,6 +45,54 @@ class AppsData {
return AppInfo::fromResult($result);
}
public const CLIENT_ID_LENGTH = 20;
public const CLIENT_SECRET_ALGO = PASSWORD_ARGON2ID;
public const TYPE_PUBLIC = 'public';
public const TYPE_CONFIDENTIAL = 'confidential';
public const TYPES = [self::TYPE_PUBLIC, self::TYPE_CONFIDENTIAL];
public function createApp(
string $name,
string $type,
bool $trusted = false,
string $summary = '',
string $website = '',
?string $clientId = null,
#[\SensitiveParameter] ?string $clientSecret = null,
?int $refreshLifetime = null
): AppInfo {
if(trim($name) === '')
throw new InvalidArgumentException('$name may not be empty');
if(!in_array($type, self::TYPES))
throw new InvalidArgumentException('$type is not a valid type');
if($clientId === null)
$clientId = XString::random(self::CLIENT_ID_LENGTH);
elseif(trim($clientId) === '')
throw new InvalidArgumentException('$clientId may not be empty');
$summary = trim($summary);
$website = trim($website);
$clientSecret = $clientSecret === null ? '' : password_hash($clientSecret, self::CLIENT_SECRET_ALGO);
if($type === self::TYPE_CONFIDENTIAL && $clientSecret === '')
throw new InvalidArgumentException('$clientSecret must be specified for confidential clients');
$stmt = $this->cache->get('INSERT INTO hau_apps (app_name, app_summary, app_website, app_trusted, app_type, app_refresh_lifetime, app_client_id, app_client_secret) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->addParameter(1, $name);
$stmt->addParameter(2, $summary);
$stmt->addParameter(3, $website);
$stmt->addParameter(4, $trusted ? 1 : 0);
$stmt->addParameter(5, $type);
$stmt->addParameter(6, $refreshLifetime);
$stmt->addParameter(7, $clientId);
$stmt->addParameter(8, $clientSecret);
$stmt->execute();
return $this->getScopeInfo(appId: (string)$this->dbConn->getLastInsertId());
}
public function countAppUris(AppInfo|string $appInfo): int {
$stmt = $this->cache->get('SELECT COUNT(*) FROM hau_apps_uris WHERE app_id = ?');
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
@ -87,4 +136,53 @@ class AppsData {
$result = $stmt->getResult();
return $result->next() ? $result->getStringOrNull(0) : null;
}
public function getAppScopes(AppInfo|string $appInfo): array {
$allowed = [];
$denied = [];
$stmt = $this->cache->get('SELECT (SELECT scope_string FROM hau_scopes AS _s WHERE _s.scope_id = _as.scope_id), scope_allowed FROM hau_apps_scopes AS _as WHERE app_id = ?');
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->execute();
$result = $stmt->getResult();
while($result->next()) {
$scopeStr = $result->getString(0);
if($result->getBoolean(1))
$allowed[] = $scopeStr;
else
$denied[] = $scopeStr;
}
return new AppScopesInfo($allowed, $denied);
}
public function setAppScopeAllow(AppInfo|string $appInfo, ScopeInfo $scopeInfo, ?bool $allowed): void {
if($allowed !== $scopeInfo->isRestricted()) {
$stmt = $this->cache->get('DELETE FROM hau_apps_scopes WHERE app_id = ? AND scope_id = ?');
} else {
$stmt = $this->cache->get('REPLACE INTO hau_apps_scopes (app_id, scope_id, scope_allowed) VALUES (?, ?, ?)');
$stmt->addParameter(3, $allowed ? 1 : 0);
}
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->addParameter(2, $scopeInfo->getId());
$stmt->execute();
}
public function isAppScopeAllowed(AppInfo|string $appInfo, ScopeInfo|string $scopeInfo): bool {
$stmt = $this->cache->get('SELECT ? AS _scope_id, COALESCE(
(SELECT scope_allowed FROM hau_apps_scopes WHERE app_id = ? AND scope_id = _scope_id),
(SELECT NOT scope_restricted FROM hau_scopes WHERE scope_id = _scope_id)
)');
$stmt->addParameter(1, $scopeInfo instanceof ScopeInfo ? $scopeInfo->getId() : $scopeInfo);
$stmt->addParameter(2, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('failed to check scope ACL');
return $result->getBoolean(1);
}
}

58
src/Apps/ScopeInfo.php Normal file
View file

@ -0,0 +1,58 @@
<?php
namespace Hanyuu\Apps;
use Index\Data\IDbResult;
class ScopeInfo {
public function __construct(
private string $id,
private string $string,
private bool $restricted,
private string $summary,
private int $created,
private ?int $deprecated
) {}
public static function fromResult(IDbResult $result): ScopeInfo {
return new ScopeInfo(
$result->getString(0),
$result->getString(1),
$result->getBoolean(2),
$result->getString(3),
$result->getInteger(4),
$result->getIntegerOrNull(5)
);
}
public function getId(): string {
return $this->id;
}
public function getString(): string {
return $this->string;
}
public function isRestricted(): bool {
return $this->restricted;
}
public function hasSummary(): bool {
return $this->summary !== '';
}
public function getSummary(): string {
return $this->summary;
}
public function getCreatedTime(): int {
return $this->created;
}
public function isDeprecated(): bool {
return $this->deprecated !== null;
}
public function getDeprecatedTime(): ?int {
return $this->deprecated;
}
}

56
src/Apps/ScopesData.php Normal file
View file

@ -0,0 +1,56 @@
<?php
namespace Hanyuu\Apps;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\{DbStatementCache,IDbConnection};
class ScopesData {
private DbStatementCache $cache;
public function __construct(
private IDbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
public const BY_ID = 'id';
public const BY_STRING = 'string';
public function getScopeInfo(string $value, string $mode): ScopeInfo {
$stmt = $this->cache->get(sprintf(
'SELECT scope_id, scope_string, scope_restricted, scope_summary, UNIX_TIMESTAMP(scope_created), UNIX_TIMESTAMP(scope_deprecated) FROM hau_scopes WHERE %s = ?',
match($mode) {
self::BY_ID => 'scope_id',
self::BY_STRING => 'scope_string',
default => throw new InvalidArgumentException('$mode is not a valid mode')
}
));
$stmt->addParameter(1, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Scope not found.');
return ScopeInfo::fromResult($result);
}
public function createScope(string $string, bool $restricted, string $summary = ''): ScopeInfo {
if(trim($string) === '')
throw new InvalidArgumentException('$string may not be empty');
if(preg_match('#[^A-Za-z0-9:-]#', $string))
throw new InvalidArgumentException('$string contains invalid characters');
$summary = trim($summary);
$stmt = $this->cache->get('INSERT INTO hau_scopes (scope_string, scope_restricted, scope_summary) VALUES (?, ?, ?)');
$stmt->addParameter(1, $string);
$stmt->addParameter(2, $restricted ? 1 : 0);
$stmt->addParameter(3, $summary);
$stmt->execute();
return $this->getScopeInfo((string)$this->dbConn->getLastInsertId(), self::BY_ID);
}
}

View file

@ -31,7 +31,8 @@ class HanyuuContext {
$this->appsCtx = new Apps\AppsContext($dbConn);
$this->oauth2Ctx = new OAuth2\OAuth2Context(
$config->scopeTo('oauth2'),
$dbConn
$dbConn,
$this->appsCtx
);
$this->templating = new SasaeEnvironment(
@ -67,6 +68,11 @@ class HanyuuContext {
return new FsDbMigrationRepo(HAU_DIR_MIGRATIONS);
}
public function pruneExpired($logAction = null): void {
$logAction ??= function() {};
$this->oauth2Ctx->pruneExpired($logAction);
}
public function getAuthInfo(): MisuzuAuthInfo {
return $this->authInfo;
}
@ -100,13 +106,9 @@ class HanyuuContext {
return 503;
});
$routingCtx->register(new OAuth2\OAuth2ApiRoutes(
$this->oauth2Ctx,
$this->appsCtx
));
$routingCtx->register(new OAuth2\OAuth2ApiRoutes($this->oauth2Ctx));
$routingCtx->register(new OAuth2\OAuth2WebRoutes(
$this->oauth2Ctx,
$this->appsCtx,
$this->templating,
$this->getAuthInfo(...),
$this->getCSRFPSecret(...)
@ -117,10 +119,7 @@ class HanyuuContext {
new HmacVerificationProvider(fn() => $this->config->getString('aleister:secret'))
));
$rpcServer->register(new OAuth2\OAuth2RpcActions(
$this->oauth2Ctx,
$this->appsCtx
));
$rpcServer->register(new OAuth2\OAuth2RpcActions($this->oauth2Ctx));
return $routingCtx;
}

View file

@ -4,12 +4,10 @@ namespace Hanyuu\OAuth2;
use RuntimeException;
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler};
use Syokuhou\IConfig;
use Hanyuu\Apps\AppsContext;
final class OAuth2ApiRoutes extends RouteHandler {
public function __construct(
private OAuth2Context $oauth2Ctx,
private AppsContext $appsCtx
private OAuth2Context $oauth2Ctx
) {}
private static function filter($response, array $result, bool $authzHeader = false): array {
@ -55,7 +53,8 @@ final class OAuth2ApiRoutes extends RouteHandler {
$clientSecret = '';
}
$appsData = $this->appsCtx->getData();
$appsCtx = $this->oauth2Ctx->getApps();
$appsData = $appsCtx->getAppsData();
try {
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
@ -132,7 +131,8 @@ final class OAuth2ApiRoutes extends RouteHandler {
$clientSecret = (string)$content->getParam('client_secret');
}
$appsData = $this->appsCtx->getData();
$appsCtx = $this->oauth2Ctx->getApps();
$appsData = $appsCtx->getAppsData();
try {
$appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {

View file

@ -98,4 +98,8 @@ class OAuth2AuthorisationData {
$stmt->addParameter(++$args, $value);
$stmt->execute();
}
public function pruneExpiredAuthorisations(): int {
return (int)$this->dbConn->execute('DELETE FROM hau_oauth2_authorise WHERE auth_expires <= NOW() - INTERVAL 1 HOUR');
}
}

View file

@ -2,7 +2,7 @@
namespace Hanyuu\OAuth2;
use RuntimeException;
use Hanyuu\Apps\AppInfo;
use Hanyuu\Apps\{AppsContext,AppInfo};
use Index\Data\IDbConnection;
use Syokuhou\IConfig;
@ -13,7 +13,8 @@ class OAuth2Context {
public function __construct(
private IConfig $config,
IDbConnection $dbConn
IDbConnection $dbConn,
private AppsContext $appsCtx
) {
$this->authorisations = new OAuth2AuthorisationData($dbConn);
$this->tokens = new OAuth2TokensData($dbConn);
@ -24,6 +25,10 @@ class OAuth2Context {
return $this->config;
}
public function getApps(): AppsContext {
return $this->appsCtx;
}
public function getAuthorisationData(): OAuth2AuthorisationData {
return $this->authorisations;
}
@ -36,6 +41,26 @@ class OAuth2Context {
return $this->devices;
}
public function pruneExpired($logAction = null): void {
$logAction ??= function() {};
$logAction('Pruning expired refresh tokens...');
$pruned = $this->tokens->pruneExpiredRefresh();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired access tokens...');
$pruned = $this->tokens->pruneExpiredAccess();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired device authorisation requests...');
$pruned = $this->devices->pruneExpiredDevices();
$logAction(' Removed %d!', $pruned);
$logAction('Pruning expired authorisation codes...');
$pruned = $this->authorisations->pruneExpiredAuthorisations();
$logAction(' Removed %d!', $pruned);
}
public function createRefresh(
AppInfo $appInfo,
OAuth2AccessInfo $accessInfo
@ -49,39 +74,34 @@ class OAuth2Context {
);
}
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
return false;
public function checkAndBuildScopeString(AppInfo $appInfo, ?string $scope = null, bool $skipFail = false): string|array {
if($scope === null)
return '';
return true;
}
$scopeInfos = $this->appsCtx->handleScopeString($appInfo, $scope, breakOnFail: !$skipFail);
$scope = [];
public static function filterScopes(string $source): array {
$scopes = [];
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
if($skipFail)
continue;
$source = explode(' ', $source);
foreach($source as $scope) {
$scope = trim($scope);
if($scope !== '' && !in_array($scope, $scopes))
$scopes[] = $scope;
return [
'error' => 'invalid_scope',
'error_description' => sprintf('Requested scope "%s" is %s.', $scopeName, $scopeInfo),
];
}
$scope[] = $scopeInfo->getString();
}
sort($scopes);
return $scopes;
return implode(' ', $scope);
}
public function createDeviceAuthorisationRequest(AppInfo $appInfo, ?string $scope = null) {
$scope ??= ''; // for now
$scopes = self::filterScopes($scope);
if(!$this->validateScopes($appInfo, $scopes))
return [
'error' => 'invalid_scope',
'error_description' => 'An invalid scope was requested.',
];
$scope = implode(' ', $scopes);
$scope = $this->checkAndBuildScopeString($appInfo, $scope);
if(is_array($scope))
return $scope;
$deviceInfo = $this->getDevicesData()->createDevice($appInfo, $scope);
@ -150,14 +170,7 @@ class OAuth2Context {
$authsData->deleteAuthorisation($authsInfo);
$scopes = $authsInfo->getScopes();
if(!$this->validateScopes($appInfo, $scopes))
return [
'error' => 'invalid_scope',
'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.',
];
$scope = implode(' ', $scopes);
$scope = $this->checkAndBuildScopeString($appInfo, $authsInfo->getScope(), true);
$tokensData = $this->getTokensData();
$accessInfo = $tokensData->createAccess(
@ -166,8 +179,7 @@ class OAuth2Context {
scope: $scope,
);
// 'scope' only has to be in the response if it differs from what was requested
if($scope === $authsInfo->getScope())
if($authsInfo->getScope() === $scope)
$scope = null;
$refreshInfo = $appInfo->shouldIssueRefreshToken()
@ -204,14 +216,16 @@ class OAuth2Context {
if($refreshInfo->hasAccessId())
$tokensData->deleteAccess(accessInfo: $refreshInfo->getAccessId());
$newScopes = self::filterScopes($scope ?? $refreshInfo->getScope());
if(!$this->validateScopes($appInfo, $newScopes))
if($scope === null)
$scope = $refreshInfo->getScope();
elseif(!empty(array_diff(explode(' ', $scope), $refreshInfo->getScopes())))
return [
'error' => 'invalid_scope',
'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.',
'error_description' => 'You cannot request a greater scope than during initial authorisation, please restart authorisation.',
];
$scope = implode(' ', $newScopes);
$scope = $this->checkAndBuildScopeString($appInfo, $scope, true);
$accessInfo = $tokensData->createAccess(
$appInfo,
$refreshInfo->getUserId(),
@ -240,17 +254,17 @@ class OAuth2Context {
'error_description' => 'Application must authenticate with client secret in order to use this grant type.',
];
$scope ??= ''; // for now
$scopes = self::filterScopes($scope);
if(!$this->validateScopes($appInfo, $scopes))
return self::error($response, 'invalid_scope', 'Requested scope is no longer valid for this application, please restart authorisation.');
$origScope = $scope;
$scope = implode(' ', $scopes);
$requestedScope = $scope;
$scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true);
$accessInfo = $this->getTokensData()->createAccess($appInfo, scope: $scope);
if($scope === $origScope)
if($requestedScope !== null && $scope === '')
return [
'error' => 'invalid_scope',
'error_description' => 'Requested scope is not valid.',
];
if($requestedScope === null || $scope === $requestedScope)
$scope = null;
return $this->packBearerTokenResult($accessInfo, scope: $scope);
@ -306,14 +320,7 @@ class OAuth2Context {
'error_description' => 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.',
];
$scopes = $deviceInfo->getScopes();
if(!$this->validateScopes($appInfo, $scopes))
return [
'error' => 'invalid_scope',
'error_description' => 'Requested scope is no longer valid for this application, please restart authorisation.',
];
$scope = implode(' ', $scopes);
$scope = $this->checkAndBuildScopeString($appInfo, $deviceInfo->getScope(), true);
$tokensData = $this->getTokensData();
$accessInfo = $tokensData->createAccess(
@ -323,7 +330,7 @@ class OAuth2Context {
);
// 'scope' only has to be in the response if it differs from what was requested
if($scope === $deviceInfo->getScope())
if($deviceInfo->getScope() === $scope)
$scope = null;
$refreshInfo = $appInfo->shouldIssueRefreshToken()

View file

@ -11,11 +11,11 @@ use Hanyuu\Apps\AppInfo;
class OAuth2DevicesData {
private const USER_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
public function __construct(
private IDbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
@ -138,4 +138,8 @@ class OAuth2DevicesData {
$stmt->addParameter(2, $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->getId() : $deviceInfo);
$stmt->execute();
}
public function pruneExpiredDevices(): int {
return (int)$this->dbConn->execute('DELETE FROM hau_oauth2_device WHERE dev_expires <= NOW() - INTERVAL 1 HOUR');
}
}

View file

@ -8,8 +8,7 @@ use Syokuhou\IConfig;
final class OAuth2RpcActions extends RpcActionHandler {
public function __construct(
private OAuth2Context $oauth2Ctx,
private AppsContext $appsCtx
private OAuth2Context $oauth2Ctx
) {}
private static function filterScopes(string $source): array {
@ -30,14 +29,14 @@ final class OAuth2RpcActions extends RpcActionHandler {
#[RpcProcedure('hanyuu:oauth2:attemptAppAuth')]
public function procAttemptAppAuth(string $remoteAddr, string $clientId, string $clientSecret = ''): array {
try {
$appInfo = $this->appsCtx->getData()->getAppInfo(clientId: $clientId, deleted: false);
$appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) {
return ['method' => 'basic', 'error' => 'app'];
}
$authed = false;
if($clientSecret !== '') {
// todo: rate limiting
// TODO: rate limiting
if(!$appInfo->verifyClientSecret($clientSecret))
return ['method' => 'basic', 'error' => 'secret'];
@ -77,7 +76,7 @@ final class OAuth2RpcActions extends RpcActionHandler {
#[RpcProcedure('hanyuu:oauth2:createAuthoriseRequest')]
public function procCreateAuthoriseRequest(string $appId, ?string $scope = null): array {
try {
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
$appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
@ -91,7 +90,7 @@ final class OAuth2RpcActions extends RpcActionHandler {
#[RpcProcedure('hanyuu:oauth2:createBearerToken:authorisationCode')]
public function procCreateBearerTokenAuthzCode(string $appId, bool $isAuthed, string $code, string $codeVerifier): array {
try {
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
$appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
@ -105,7 +104,7 @@ final class OAuth2RpcActions extends RpcActionHandler {
#[RpcProcedure('hanyuu:oauth2:createBearerToken:refreshToken')]
public function procCreateBearerTokenRefreshToken(string $appId, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
try {
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
$appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
@ -119,7 +118,7 @@ final class OAuth2RpcActions extends RpcActionHandler {
#[RpcProcedure('hanyuu:oauth2:createBearerToken:clientCredentials')]
public function procCreateBearerTokenClientCreds(string $appId, bool $isAuthed, ?string $scope = null): array {
try {
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
$appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',
@ -133,7 +132,7 @@ final class OAuth2RpcActions extends RpcActionHandler {
#[RpcProcedure('hanyuu:oauth2:createBearerToken:deviceCode')]
public function procCreateBearerTokenDeviceCode(string $appId, bool $isAuthed, string $deviceCode): array {
try {
$appInfo = $this->appsCtx->getData()->getAppInfo(appId: $appId, deleted: false);
$appInfo = $this->oauth2Ctx->getApps()->getAppsData()->getAppInfo(appId: $appId, deleted: false);
} catch(RuntimeException $ex) {
return [
'error' => 'invalid_client',

View file

@ -9,11 +9,11 @@ use Index\Data\IDbConnection;
use Hanyuu\Apps\AppInfo;
class OAuth2TokensData {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
public function __construct(
private IDbConnection $dbConn
) {
$this->cache = new DbStatementCache($dbConn);
}
@ -94,6 +94,10 @@ class OAuth2TokensData {
$stmt->execute();
}
public function pruneExpiredAccess(): int {
return (int)$this->dbConn->execute('DELETE FROM hau_oauth2_access WHERE acc_expires <= NOW() - INTERVAL 1 DAY');
}
public const REFRESH_BY_ID = 'id';
public const REFRESH_BY_ACCESS = 'access';
public const REFRESH_BY_TOKEN = 'token';
@ -185,4 +189,8 @@ class OAuth2TokensData {
$stmt->addParameter(++$args, $value);
$stmt->execute();
}
public function pruneExpiredRefresh(): int {
return (int)$this->dbConn->execute('DELETE FROM hau_oauth2_refresh WHERE ref_expires <= NOW()');
}
}

View file

@ -7,12 +7,10 @@ use Index\CSRFP;
use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler};
use Sasae\SasaeEnvironment;
use Syokuhou\IConfig;
use Hanyuu\Apps\AppsContext;
final class OAuth2WebRoutes extends RouteHandler {
public function __construct(
private OAuth2Context $oauth2Ctx,
private AppsContext $appsCtx,
private SasaeEnvironment $templating,
private $getAuthInfo,
private $getCSRFPSecret
@ -23,21 +21,6 @@ final class OAuth2WebRoutes extends RouteHandler {
throw new InvalidArgumentException('$getCSRFPSecret must be callable');
}
private static function filterScopes(string $source): array {
$scopes = [];
$source = explode(' ', $source);
foreach($source as $scope) {
$scope = trim($scope);
if($scope !== '' && !in_array($scope, $scopes))
$scopes[] = $scope;
}
sort($scopes);
return $scopes;
}
#[HttpGet('/oauth2/authorise')]
public function getAuthorise($response, $request) {
$authInfo = ($this->getAuthInfo)();
@ -88,21 +71,27 @@ final class OAuth2WebRoutes extends RouteHandler {
return ['error' => 'length'];
}
$appsData = $this->appsCtx->getData();
$appsCtx = $this->oauth2Ctx->getApps();
$appsData = $appsCtx->getAppsData();
try {
$appInfo = $appsData->getAppInfo(clientId: (string)$content->getParam('client'), deleted: false);
} catch(RuntimeException $ex) {
return ['error' => 'client'];
}
$scope = '';
if($content->hasParam('scope')) {
$scopes = self::filterScopes((string)$content->getParam('scope'));
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
return ['error' => 'scope'];
$scope = [];
$scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, (string)$content->getParam('scope'));
$scope = implode(' ', $scopes);
}
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->getString();
}
$scope = implode(' ', $scope);
} else $scope = '';
if($content->hasParam('redirect')) {
$redirectUri = (string)$content->getParam('redirect');
@ -152,7 +141,7 @@ final class OAuth2WebRoutes extends RouteHandler {
if(!$csrfp->verifyToken((string)$request->getParam('csrfp')))
return ['error' => 'csrf'];
$appsData = $this->appsCtx->getData();
$appsData = $this->oauth2Ctx->getApps()->getAppsData();
try {
$appInfo = $appsData->getAppInfo(clientId: (string)$request->getParam('client'), deleted: false);
} catch(RuntimeException $ex) {
@ -169,10 +158,16 @@ final class OAuth2WebRoutes extends RouteHandler {
return ['error' => 'required'];
}
$scope = [];
if($request->hasParam('scope')) {
$scopes = self::filterScopes((string)$request->getParam('scope'));
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes))
return ['error' => 'scope'];
$scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, (string)$request->getParam('scope'));
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->getSummary();
}
}
$userInfo = $authInfo->getUserInfo();
@ -191,6 +186,7 @@ final class OAuth2WebRoutes extends RouteHandler {
'profile_uri' => $userInfo->getProfileUrl(),
'avatar_uri' => $userInfo->getAvatar('x120'),
],
'scope' => $scope,
];
if($authInfo->isGuise()) {
@ -240,6 +236,10 @@ final class OAuth2WebRoutes extends RouteHandler {
if(!$csrfp->verifyToken((string)$content->getParam('_csrfp')))
return ['error' => 'csrf'];
$approve = (string)$content->getParam('approve');
if(!in_array($approve, ['yes', 'no']))
return ['error' => 'invalid'];
$devicesData = $this->oauth2Ctx->getDevicesData();
try {
$deviceInfo = $devicesData->getDeviceInfo(userCode: (string)$content->getParam('code'));
@ -247,16 +247,40 @@ final class OAuth2WebRoutes extends RouteHandler {
return ['error' => 'code'];
}
if($deviceInfo->hasExpired())
return ['error' => 'expired'];
if(!$deviceInfo->isPending())
return ['error' => 'approval'];
$approve = (string)$content->getParam('approve');
if(!in_array($approve, ['yes', 'no']))
return ['error' => 'invalid'];
$approved = $approve === 'yes';
$error = null;
if($approved) {
$appsData = $this->oauth2Ctx->getApps()->getAppsData();
try {
$appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, $deviceInfo->getScope());
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo)) {
$approved = false;
$error = ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
break;
}
$scope[] = $scopeInfo->getSummary();
}
}
$devicesData->setDeviceApproval($deviceInfo, $approved, $authInfo->getUserInfo()->getId());
if($error !== null)
return $error;
return [
'approval' => $approved ? 'approved' : 'denied',
];
@ -281,16 +305,28 @@ final class OAuth2WebRoutes extends RouteHandler {
return ['error' => 'code'];
}
if($deviceInfo->hasExpired())
return ['error' => 'expired'];
if(!$deviceInfo->isPending())
return ['error' => 'approval'];
$appsData = $this->appsCtx->getData();
$appsData = $this->oauth2Ctx->getApps()->getAppsData();
try {
$appInfo = $appsData->getAppInfo(appId: $deviceInfo->getAppId(), deleted: false);
} catch(RuntimeException $ex) {
return ['error' => 'code'];
}
$scope = [];
$scopeInfos = $this->oauth2Ctx->getApps()->handleScopeString($appInfo, $deviceInfo->getScope());
foreach($scopeInfos as $scopeName => $scopeInfo) {
if(is_string($scopeInfo))
return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
$scope[] = $scopeInfo->getSummary();
}
$userInfo = $authInfo->getUserInfo();
$result = [
'req' => [
@ -304,6 +340,7 @@ final class OAuth2WebRoutes extends RouteHandler {
['title' => 'Website', 'display' => $appInfo->getWebsiteDisplay(), 'uri' => $appInfo->getWebsite()],
],
],
'scope' => $scope,
'user' => [
'name' => $userInfo->getName(),
'colour' => $userInfo->getColour(),

7
tools/prune Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../hanyuu.php';
$hau->pruneExpired(function(string $format, ...$args) {
echo sprintf($format, ...$args) . PHP_EOL;
});