Compare commits
3 commits
926bd02bfc
...
12d48cce2e
Author | SHA1 | Date | |
---|---|---|---|
12d48cce2e | |||
2ba03885ed | |||
dcb5cf0175 |
22 changed files with 569 additions and 159 deletions
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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');
|
||||
|
|
43
database/2024_09_03_201657_scope_tables.php
Normal file
43
database/2024_09_03_201657_scope_tables.php
Normal 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);
|
||||
}
|
||||
}
|
1
public/images/circle-regular.svg
Normal file
1
public/images/circle-regular.svg
Normal 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 |
1
public/images/exclamation-solid.svg
Normal file
1
public/images/exclamation-solid.svg
Normal 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 |
|
@ -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);
|
||||
}
|
||||
|
|
27
src/Apps/AppScopesInfo.php
Normal file
27
src/Apps/AppScopesInfo.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
58
src/Apps/ScopeInfo.php
Normal 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
56
src/Apps/ScopesData.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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()');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
7
tools/prune
Executable 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;
|
||||
});
|
Loading…
Reference in a new issue