diff --git a/assets/hanyuu.css/main.css b/assets/oauth2.css/main.css similarity index 100% rename from assets/hanyuu.css/main.css rename to assets/oauth2.css/main.css diff --git a/assets/hanyuu.js/main.js b/assets/oauth2.js/main.js similarity index 100% rename from assets/hanyuu.js/main.js rename to assets/oauth2.js/main.js diff --git a/build.js b/build.js index c2467b7..3142bac 100644 --- a/build.js +++ b/build.js @@ -17,11 +17,11 @@ const fs = require('fs'); const tasks = { js: [ - { source: 'hanyuu.js', target: '/assets', name: 'hanyuu.{hash}.js', }, + { source: 'oauth2.js', target: '/assets', name: 'oauth2.{hash}.js', }, ], css: [ { source: 'errors.css', target: '/', name: 'errors.css', }, - { source: 'hanyuu.css', target: '/assets', name: 'hanyuu.{hash}.css', }, + { source: 'oauth2.css', target: '/assets', name: 'oauth2.{hash}.css', }, ], twig: [ { source: 'errors/400', target: '/', name: 'error-400.html', }, diff --git a/package-lock.json b/package-lock.json index 6bc5688..6e8cefa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -703,9 +703,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.827", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz", - "integrity": "sha512-VY+J0e4SFcNfQy19MEoMdaIcZLmDCprqvBtkii1WTCTQHpRvf5N8+3kTYCgL/PcntvwQvmMJWTuDPsq+IlhWKQ==", + "version": "1.4.829", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.829.tgz", + "integrity": "sha512-5qp1N2POAfW0u1qGAxXEtz6P7bO1m6gpZr5hdf5ve6lxpLM7MpiM4jIPz7xcrNlClQMafbyUDDWjlIQZ1Mw0Rw==", "license": "ISC" }, "node_modules/entities": { @@ -831,9 +831,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.17.tgz", + "integrity": "sha512-Ww6ZlOiEQfPfXM45v17oabk77Z7mg5bOt7AjDyzy7RjK9OrLrLC8dyZQoAPEOtFX9SaNf1Tdvr5gRJWdTJj7GA==", "license": "MIT" }, "node_modules/normalize-range": { @@ -1429,9 +1429,9 @@ } }, "node_modules/terser": { - "version": "5.31.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.2.tgz", - "integrity": "sha512-LGyRZVFm/QElZHy/CPr/O4eNZOZIzsrQ92y4v9UJe/pFJjypje2yI3C2FmPtvUEnhadlSbmG2nXtdcjHOjCfxw==", + "version": "5.31.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.3.tgz", + "integrity": "sha512-pAfYn3NIZLyZpa83ZKigvj6Rn9c/vd5KfYGX7cN1mnzqgDcxWvrU5ZtAfIKhEXz9nRecw4z3LXkjaq96/qZqAA==", "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", diff --git a/src/Apps/AppInfo.php b/src/Apps/AppInfo.php new file mode 100644 index 0000000..24ec250 --- /dev/null +++ b/src/Apps/AppInfo.php @@ -0,0 +1,95 @@ +getString(0), + name: $result->getString(1), + summary: $result->getString(2), + website: $result->getString(3), + trusted: $result->getBoolean(4), + type: $result->getString(5), + clientId: $result->getString(6), + clientSecret: $result->getString(7), + created: $result->getInteger(8), + updated: $result->getInteger(9), + deleted: $result->getIntegerOrNull(10), + ); + } + + public function getId(): string { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function getSummary(): string { + return $this->summary; + } + + public function getWebsite(): string { + return $this->website; + } + + public function isTrusted(): bool { + return $this->trusted; + } + + public function getType(): string { + return $this->type; + } + public function isPublic(): bool { + return strcasecmp($this->type, 'public') === 0; + } + public function isConfidential(): bool { + return strcasecmp($this->type, 'confidential') === 0; + } + + public function getClientId(): string { + return $this->clientId; + } + + public function getClientSecret(): string { + return $this->clientSecret; + } + public function verifyClientSecret(string $input): bool { + return password_verify($input, $this->clientSecret); + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getUpdatedTime(): int { + return $this->updated; + } + public function isUpdated(): bool { + return $this->updated > $this->created; + } + + public function getDeletedTime(): ?int { + return $this->deleted; + } + public function isDeleted(): bool { + return $this->deleted !== null; + } +} diff --git a/src/Apps/AppUriInfo.php b/src/Apps/AppUriInfo.php new file mode 100644 index 0000000..4336626 --- /dev/null +++ b/src/Apps/AppUriInfo.php @@ -0,0 +1,41 @@ +getString(0), + appId: $result->getString(1), + string: $result->getString(2), + created: $result->getInteger(3) + ); + } + + public function getId(): string { + return $this->id; + } + + public function getAppId(): string { + return $this->appId; + } + + public function getString(): string { + return $this->string; + } + public function compareString(string $other): int { + return strcmp($this->string, $other); + } + + public function getCreatedTime(): int { + return $this->created; + } +} diff --git a/src/Apps/AppsContext.php b/src/Apps/AppsContext.php new file mode 100644 index 0000000..5e0803a --- /dev/null +++ b/src/Apps/AppsContext.php @@ -0,0 +1,16 @@ +data = new AppsData($dbConn); + } + + public function getData(): AppsData { + return $this->data; + } +} diff --git a/src/Apps/AppsData.php b/src/Apps/AppsData.php new file mode 100644 index 0000000..abeedb3 --- /dev/null +++ b/src/Apps/AppsData.php @@ -0,0 +1,90 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + public function getApp( + ?string $appId = null, + ?string $clientId = null, + ?bool $deleted = null + ): AppInfo { + $hasAppId = $appId !== null; + $hasClientId = $clientId !== null; + $hasDeleted = $deleted !== null; + + if($hasAppId === $hasClientId) + throw new InvalidArgumentException('you must specify either $appId or $clientId'); + + $values = []; + $query = 'SELECT app_id, app_name, app_summary, app_website, app_trusted, app_type, app_client_id, app_client_secret, UNIX_TIMESTAMP(app_created), UNIX_TIMESTAMP(app_updated), UNIX_TIMESTAMP(app_deleted) FROM hau_apps'; + $query .= sprintf(' WHERE %s = ?', $hasAppId ? 'app_id' : 'app_client_id'); + if($hasDeleted) + $query .= sprintf(' AND app_deleted %s NULL', $deleted ? 'IS NOT' : 'IS'); + + $stmt = $this->cache->get($query); + $stmt->addParameter(1, $hasAppId ? $appId : $clientId); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Application not found.'); + + return AppInfo::fromResult($result); + } + + 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); + $stmt->execute(); + + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } + + public function getAppUris(AppInfo|string $appInfo): array { + $stmt = $this->cache->get('SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created) FROM hau_apps_uris WHERE app_id = ?'); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->execute(); + + $infos = []; + $result = $stmt->getResult(); + while($result->next()) + $infos[] = AppUriInfo::fromResult($result); + + return $infos; + } + + public function getAppUri(string $uriId): AppUriInfo { + $stmt = $this->cache->get('SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created) FROM hau_apps_uris WHERE uri_id = ?'); + $stmt->addParameter(1, $uriId); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('URI not found.'); + + return AppUriInfo::fromResult($result); + } + + public function getAppUriId(AppInfo|string $appInfo, string $uriString): ?string { + $stmt = $this->cache->get('SELECT uri_id FROM hau_apps_uris WHERE app_id = ? AND uri_string = ?'); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->addParameter(2, $uriString); + $stmt->execute(); + + $result = $stmt->getResult(); + return $result->next() ? $result->getStringOrNull(0) : null; + } +} diff --git a/src/HanyuuContext.php b/src/HanyuuContext.php index 90874fb..d62f8bb 100644 --- a/src/HanyuuContext.php +++ b/src/HanyuuContext.php @@ -2,6 +2,7 @@ namespace Hanyuu; use Index\Environment; +use Index\Colour\Colour; use Index\Data\IDbConnection; use Index\Data\Migration\{IDbMigrationRepo,DbMigrationManager,FsDbMigrationRepo}; use Sasae\SasaeEnvironment; @@ -10,13 +11,40 @@ use Syokuhou\IConfig; class HanyuuContext { private IConfig $config; private IDbConnection $dbConn; - private ?SasaeEnvironment $templating = null; + private SasaeEnvironment $templating; private SiteInfo $siteInfo; + private MisuzuInterop $misuzuInterop; + private object $authInfo; + + private Apps\AppsContext $appsCtx; + private OAuth2\OAuth2Context $oauth2Ctx; + public function __construct(IConfig $config, IDbConnection $dbConn) { $this->config = $config; $this->dbConn = $dbConn; $this->siteInfo = new SiteInfo($config->scopeTo('site')); + $this->misuzuInterop = new MisuzuInterop($config->scopeTo('misuzu')); + + $this->appsCtx = new Apps\AppsContext($dbConn); + $this->oauth2Ctx = new OAuth2\OAuth2Context($dbConn); + + $isDebug = Environment::isDebug(); + + $this->templating = new SasaeEnvironment( + HAU_DIR_TEMPLATES, + cache: null,//$isDebug ? null : ['Hanyuu', GitInfo::hash(true)], + debug: $isDebug, + ); + $this->templating->addExtension(new HanyuuSasaeExtension($this)); + + $this->templating->addGlobal('globals', [ + 'siteInfo' => $this->siteInfo, + ]); + } + + public function getMisuzu(): MisuzuInterop { + return $this->misuzuInterop; } public function getDatabase(): IDbConnection { @@ -36,42 +64,36 @@ class HanyuuContext { return new FsDbMigrationRepo(HAU_DIR_MIGRATIONS); } + public function getAuthInfo(): object { + return $this->authInfo; + } + public function getWebAssetInfo(): ?object { $path = HAU_DIR_ASSETS . '/current.json'; return is_file($path) ? json_decode(file_get_contents($path)) : null; } public function getTemplating(): SasaeEnvironment { - if($this->templating === null) { - $isDebug = Environment::isDebug(); - - $this->templating = new SasaeEnvironment( - HAU_DIR_TEMPLATES, - cache: null,//$isDebug ? null : ['Hanyuu', GitInfo::hash(true)], - debug: $isDebug, - ); - $this->templating->addExtension(new HanyuuSasaeExtension($this)); - - $this->templating->addGlobal('globals', [ - 'siteInfo' => $this->siteInfo, - ]); - } - return $this->templating; } - public function renderTemplate(...$args): string { - return $this->getTemplating()->render(...$args); - } - public function createRouting(): RoutingContext { $routingCtx = new RoutingContext($this->getTemplating()); + $routingCtx->getRouter()->use('/', function($response, $request) { + $this->authInfo = $this->misuzuInterop->authCheck('Misuzu', (string)$request->getCookie('msz_auth'), $_SERVER['REMOTE_ADDR']); + }); + $routingCtx->getRouter()->get('/', function($response, $request) { return 503; }); - $routingCtx->register(new OAuth2\OAuth2Routes); + $routingCtx->register(new OAuth2\OAuth2Routes( + $this->oauth2Ctx, + $this->appsCtx, + $this->templating, + $this->getAuthInfo(...) + )); return $routingCtx; } diff --git a/src/MisuzuInterop.php b/src/MisuzuInterop.php new file mode 100644 index 0000000..fe961c0 --- /dev/null +++ b/src/MisuzuInterop.php @@ -0,0 +1,103 @@ +callRpc('auth-check', body: [ + 'method' => $method, + 'token' => $token, + 'remote_addr' => $remoteAddr, + 'avatars' => implode(',', $avatars), + ]); + + if(!str_starts_with($result->name, 'auth:check:')) + return new stdClass; + + return $result->attrs; + } + + private function callRpc(string $action, array $params = [], array $body = []): object { + $time = time(); + + $url = sprintf('%s/_hanyuu/%s', $this->config->getString('endpoint'), $action); + if(empty($params)) + $params = ''; + else { + $params = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + $url .= sprintf('?%s', $params); + } + + $hasBody = !empty($body); + $body = $hasBody ? http_build_query($body, '', '&', PHP_QUERY_RFC3986) : ''; + + $signature = UriBase64::encode(hash_hmac( + 'sha256', + sprintf('[%s|%s|%s]', $time, $url, $body), + $this->config->getString('secret'), + true + )); + $headers = [ + sprintf('X-Hanyuu-Timestamp: %s', $time), + sprintf('X-Hanyuu-Signature: %s', $signature), + ]; + + if($hasBody) + $headers[] = 'Content-Type: application/x-www-form-urlencoded'; + + $req = curl_init($url); + try { + curl_setopt_array($req, [ + CURLOPT_AUTOREFERER => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_HEADER => false, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_MAXREDIRS => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Hanyuu', + CURLOPT_HTTPHEADER => $headers, + ]); + + if($hasBody) + curl_setopt_array($req, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + ]); + + $response = curl_exec($req); + if($response === false) + throw new RuntimeException(curl_error($req)); + } finally { + curl_close($req); + } + + $decoded = json_decode($response); + if($decoded === null) + throw new RuntimeException($response); + + if(empty($decoded->name) || !isset($decoded->attrs)) + throw new RuntimeException('Missing name or attrs attribute in response body.'); + + if($decoded->name === 'error') { + $line = $decoded->attrs->code ?? 'unknown'; + if(!empty($decoded->attrs->text)) + $line = sprintf('[%s] %s', $line, $decoded->attrs->text); + + throw new RuntimeException($line); + } + + return $decoded; + } +} diff --git a/src/OAuth2/OAuth2AccessInfo.php b/src/OAuth2/OAuth2AccessInfo.php new file mode 100644 index 0000000..f305a80 --- /dev/null +++ b/src/OAuth2/OAuth2AccessInfo.php @@ -0,0 +1,67 @@ +getString(0), + appId: $result->getString(1), + userId: $result->getString(2), + token: $result->getString(3), + scope: $result->getString(4), + created: $result->getInteger(5), + expires: $result->getInteger(6), + ); + } + + public function getId(): string { + return $this->id; + } + + public function getAppId(): string { + return $this->appId; + } + + public function getUserId(): string { + return $this->userId; + } + + public function getToken(): string { + return $this->token; + } + + public function getScope(): string { + return $this->scope; + } + public function getScopes(): array { + return explode(' ', $this->scope); + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getExpiresTime(): int { + return $this->expires; + } + + public function getRemainingLifetime(): int { + return max(0, $this->expires - time()); + } + + public function hasExpired(): bool { + return time() > $this->expires; + } +} diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php new file mode 100644 index 0000000..4d931f8 --- /dev/null +++ b/src/OAuth2/OAuth2Context.php @@ -0,0 +1,31 @@ +requests = new OAuth2RequestsData($dbConn); + $this->tokens = new OAuth2TokensData($dbConn); + } + + public function getRequestsData(): OAuth2RequestsData { + return $this->requests; + } + + public function getTokensData(): OAuth2TokensData { + return $this->tokens; + } + + 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; + + return true; + } +} diff --git a/src/OAuth2/OAuth2RefreshInfo.php b/src/OAuth2/OAuth2RefreshInfo.php new file mode 100644 index 0000000..5482509 --- /dev/null +++ b/src/OAuth2/OAuth2RefreshInfo.php @@ -0,0 +1,76 @@ +getString(0), + appId: $result->getString(1), + userId: $result->getString(2), + accId: $result->getStringOrNull(3), + token: $result->getString(4), + scope: $result->getString(5), + created: $result->getInteger(6), + expires: $result->getInteger(7), + ); + } + + public function getId(): string { + return $this->id; + } + + public function getAppId(): string { + return $this->appId; + } + + public function getUserId(): string { + return $this->userId; + } + + public function hasAccessId(): bool { + return $this->accId !== null; + } + public function getAccessId(): ?string { + return $this->accId; + } + + public function getToken(): string { + return $this->token; + } + + public function getScope(): string { + return $this->scope; + } + public function getScopes(): array { + return explode(' ', $this->scope); + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getExpiresTime(): int { + return $this->expires; + } + + public function getRemainingLifetime(): int { + return max(0, $this->expires - time()); + } + + public function hasExpired(): bool { + return time() > $this->expires; + } +} diff --git a/src/OAuth2/OAuth2RequestInfo.php b/src/OAuth2/OAuth2RequestInfo.php new file mode 100644 index 0000000..eb34ff1 --- /dev/null +++ b/src/OAuth2/OAuth2RequestInfo.php @@ -0,0 +1,115 @@ +getString(0), + appId: $result->getString(1), + userId: $result->getString(2), + uriId: $result->getString(3), + state: $result->getString(4), + challengeCode: $result->getString(5), + challengeMethod: $result->getString(6), + scope: $result->getString(7), + code: $result->getString(8), + approval: $result->getString(9), + created: $result->getInteger(10), + ); + } + + public function getId(): string { + return $this->id; + } + + public function getAppId(): string { + return $this->appId; + } + + public function getUriId(): string { + return $this->uriId; + } + + public function getUserId(): string { + return $this->userId; + } + + public function getState(): string { + return $this->state; + } + + public function hasCodeChallenge(): bool { + return $this->challengeCode !== ''; + } + + public function getChallengeCode(): string { + return $this->challengeCode; + } + + public function getChallengeMethod(): string { + return $this->challengeMethod; + } + + public function verifyCodeChallenge(string $codeVerifier): bool { + if($this->challengeMethod === 'plain') + return hash_equals($this->challengeCode, $codeVerifier); + + if($this->challengeMethod === 'S256') { + $knownHash = UriBase64::decode($this->challengeCode); + $userHash = hash('sha256', $codeVerifier, true); + return hash_equals($knownHash, $userHash); + } + + return false; + } + + public function getScope(): string { + return $this->scope; + } + public function getScopes(): array { + return explode(' ', $this->scope); + } + + public function getCode(): string { + return $this->code; + } + + public function getApproval(): string { + return $this->approval; + } + public function isPending(): bool { + return strcasecmp($this->approval, 'pending') === 0; + } + public function isApproved(): bool { + return strcasecmp($this->approval, 'approved') === 0; + } + public function isDenied(): bool { + return strcasecmp($this->approval, 'denied') === 0; + } + + public function getCreatedTime(): int { + return $this->created; + } + public function hasExpired(): bool { + return time() > ($this->created + self::EXPIRES_TIME); + } +} diff --git a/src/OAuth2/OAuth2RequestsData.php b/src/OAuth2/OAuth2RequestsData.php new file mode 100644 index 0000000..397b097 --- /dev/null +++ b/src/OAuth2/OAuth2RequestsData.php @@ -0,0 +1,159 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + public function getRequest( + ?string $requestId = 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 + ): OAuth2RequestInfo { + $selectors = []; + $values = []; + if($requestId !== null) { + $selectors[] = 'req_id = ?'; + $values[] = $requestId; + } + if($appInfo !== null) { + $selectors[] = 'app_id = ?'; + $values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo; + } + if($userId !== null) { + $selectors[] = 'user_id = ?'; + $values[] = $userId; + } + if($appUriInfo !== null) { + $selectors[] = 'uri_id = ?'; + $values[] = $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo; + } + if($state !== null) { + $selectors[] = 'req_state = ?'; + $values[] = $state; + } + if($challengeCode !== null) { + $selectors[] = 'req_challenge_code = ?'; + $values[] = $challengeCode; + } + if($challengeMethod !== null) { + $selectors[] = 'req_challenge_method = ?'; + $values[] = $challengeMethod; + } + if($scope !== null) { + $selectors[] = 'req_scope = ?'; + $values[] = $scope; + } + if($code !== null) { + $selectors[] = 'req_code = ?'; + $values[] = $code; + } + + if(empty($selectors)) + throw new RuntimeException('Insufficient data to do request lookup.'); + + $args = 0; + $stmt = $this->cache->get('SELECT req_id, app_id, user_id, uri_id, req_state, req_challenge_code, req_challenge_method, req_scope, req_code, req_approval, UNIX_TIMESTAMP(req_created) FROM hau_oauth2_requests WHERE ' . implode(' AND ', $selectors)); + foreach($values as $value) + $stmt->addParameter(++$args, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Request not found.'); + + return OAuth2RequestInfo::fromResult($result); + } + + public function createRequest( + AppInfo|string $appInfo, + string $userId, + AppUriInfo|string $appUriInfo, + string $state, + string $challengeCode, + string $challengeMethod, + string $scope + ): OAuth2RequestInfo { + $stmt = $this->cache->get('INSERT INTO hau_oauth2_requests (app_id, user_id, uri_id, req_state, req_challenge_code, req_challenge_method, req_scope, req_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'); + $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); + $stmt->addParameter(2, $userId); + $stmt->addParameter(3, $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo); + $stmt->addParameter(4, $state); + $stmt->addParameter(5, $challengeCode); + $stmt->addParameter(6, $challengeMethod); + $stmt->addParameter(7, $scope); + $stmt->addParameter(8, XString::random(100)); + $stmt->execute(); + + return $this->getRequest(requestId: (string)$this->dbConn->getLastInsertId()); + } + + public function deleteRequests( + OAuth2RequestInfo|string|null $requestInfo = null, + AppInfo|string|null $appInfo = null, + ?string $userId = null, + ?string $code = null + ): void { + $selectors = []; + $values = []; + if($requestInfo !== null) { + $selectors[] = 'req_id = ?'; + $values[] = $requestInfo instanceof OAuth2RequestInfo ? $requestInfo->getId() : $requestInfo; + } + 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[] = 'req_code = ?'; + $values[] = $code; + } + + $query = 'DELETE FROM hau_oauth2_requests'; + 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 setRequestApproval(OAuth2RequestInfo|string $requestInfo, bool $approval): void { + if($requestInfo instanceof OAuth2RequestInfo) { + if(!$requestInfo->isPending()) + return; + + $requestInfo = $requestInfo->getId(); + } + + $stmt = $this->cache->get('UPDATE hau_oauth2_requests SET req_approval = ? WHERE req_id = ? AND req_approval = "pending"'); + $stmt->addParameter(1, $approval ? 'approved' : 'denied'); + $stmt->addParameter(2, $requestInfo); + $stmt->execute(); + } +} diff --git a/src/OAuth2/OAuth2Routes.php b/src/OAuth2/OAuth2Routes.php index 4a1c300..393a48c 100644 --- a/src/OAuth2/OAuth2Routes.php +++ b/src/OAuth2/OAuth2Routes.php @@ -1,48 +1,262 @@ getParam('response_type'); - if($type !== 'code') - return 400; // TODO: descriptive/better looking error notice + public function __construct( + private OAuth2Context $oauth2Ctx, + private AppsContext $appsCtx, + private SasaeEnvironment $templating, + private $getAuthInfo + ) { + if(!is_callable($getAuthInfo)) + throw new InvalidArgumentException('$getAuthInfo must be callable'); + } - $state = (string)$request->getParam('state'); - if($state === '') // technically state is not required by the spec but its probably best to enforce it - return 400; // TODO: ^ + private static function buildCallbackUri(string $target, array $params): string { + if($target === '') + $target = '/oauth2/error'; - $clientId = (string)$request->getParam('client_id'); - if($clientId === 'wrong') - return 404; // TODO: is this the correct error code for this situation? + $target .= strpos($target, '?') === false ? '?' : '&'; + $target .= http_build_query($params, '', '&', PHP_QUERY_RFC3986); + return $target; + } - $scope = explode(' ', (string)$request->getParam('scope')); - if(in_array('invalid', $scope)) - return 403; // TODO: ensure the set of scopes we have is valid for this application + private static function filterScopes(string $source): array { + $scopes = []; - $codeChallengeMethod = (string)$request->getParam('code_challenge_method'); - if($codeChallengeMethod === '') - $codeChallengeMethod = 'plain'; - elseif(!in_array($codeChallengeMethod, ['plain', 'S256'])) - return 400; // TODO: see $type check - - $codeChallenge = (string)$request->getParam('code_challenge'); - if($codeChallengeMethod !== 'plain' && $codeChallenge === '') - return 400; // TODO: see $type check, not technically part of the spec but if you're bothering to specify a method then like ??? - elseif($codeChallengeMethod === 'S256' && strlen($codeChallenge) !== 32) - return 400; // TODO: see $type check, code_challenge should always be 32 chars long if S256 - - // optional, yoink default from database - $redirectUri = (string)$request->getParam('redirect_uri'); - if($redirectUri !== '') { - if($redirectUri === 'bad') - return 403; // TODO: if specified, ensure the redirect_uri is allowed, make sure to do this again in the token request + $source = explode(' ', $source); + foreach($source as $scope) { + $scope = trim($scope); + if($scope !== '' && !in_array($scope, $scopes)) + $scopes[] = $scope; } - // display confirm diag - // upon yes, redir to whatever with the code and the state + sort($scopes); + + return $scopes; + } + + #[HttpGet('/oauth2/error')] + public function getError($response, $request) { + // + } + + #[HttpGet('/oauth2/test')] + public function getTest($response) { + $response->redirect(self::buildCallbackUri('/oauth2/authorise', [ + 'response_type' => 'code', + 'client_id' => 'rGKPeDeWQfOVhhHi2qFz', + 'redirect_uri' => 'https://edgii.net/auth/return', + 'state' => 'beanz', + 'scope' => 'windows:xp microsoft:vista windows:forworkgroups', + ])); + } + + #[HttpGet('/oauth2/authorise')] + public function getAuthorise($response, $request) { + $redirectUri = (string)$request->getParam('redirect_uri'); + if($redirectUri !== '' && filter_var($redirectUri, FILTER_VALIDATE_URL) === false) + return $response->redirect(self::buildCallbackUri('', [ + 'error' => 'invalid_request', + 'error_description' => 'Request URI must be a correctly formatted absolute URI.', + ])); + + $clientId = (string)$request->getParam('client_id'); + $appsData = $this->appsCtx->getData(); + try { + $appInfo = $appsData->getApp(clientId: $clientId, deleted: false); + } catch(RuntimeException $ex) { + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Client ID is missing or is not associated with an existing application.', + ])); + } + + $state = (string)$request->getParam('state'); + if(strlen($state) > 255) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'State parameter may not be longer than 255 characters.', + ])); + + if($redirectUri === '') { + $uriInfos = $appsData->countAppUris($appInfo); + if($uriInfos !== 1) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'A registered redirect URI must be specified.', + 'state' => $state, + ])); + + $uriInfos = $appsData->getAppUris($appInfo); + if(count($uriInfos) !== 1) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'A registered redirect URI must be specified (congrats, you hit an edge case!).', + 'state' => $state, + ])); + + $uriInfo = array_pop($uriInfos); + $redirectUriId = $uriInfo->getId(); + $redirectUri = $uriInfo->getString(); + } else { + $redirectUriId = $appsData->getAppUriId($appInfo, $redirectUri); + if($redirectUriId === null) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Specified redirect URI is not registered with the application.', + 'state' => $state, + ])); + } + + $type = (string)$request->getParam('response_type'); + if($type !== 'code') + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'unsupported_response_type', + 'state' => $state, + ])); + + $codeChallengeMethod = (string)$request->getParam('code_challenge_method'); + if($codeChallengeMethod === '') $codeChallengeMethod = 'plain'; + + $codeChallenge = (string)$request->getParam('code_challenge'); + $codeChallengeLength = strlen($codeChallenge); + + if($codeChallengeMethod === 'plain') { + if($codeChallengeLength === 0) { + if($appInfo->isPublic()) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Public clients are required to specify a code challenge.', + 'state' => $state, + ])); + } elseif($codeChallengeLength < 43) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Code challenge must be at least 43 characters long.', + 'state' => $state, + ])); + elseif($codeChallengeLength > 128) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Code challenge may not be longer than 128 characters.', + 'state' => $state, + ])); + } elseif($codeChallengeMethod === 'S256') { + if($codeChallengeLength !== 43) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Specified code challenge is not a valid SHA-256 hash.', + 'state' => $state, + ])); + } else { + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_request', + 'error_description' => 'Request code challenge method is not supported.', + 'state' => $state, + ])); + } + + $scopes = self::filterScopes((string)$request->getParam('scope')); + if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'invalid_scope', + 'state' => $state, + ])); + $scope = implode(' ', $scopes); + + $authInfo = ($this->getAuthInfo)(); + if(!isset($authInfo->user)) + return $this->templating->render('oauth2/login', [ + 'app' => $appInfo, + 'auth' => $authInfo, + ]); + + $reqsData = $this->oauth2Ctx->getRequestsData(); + try { + $requestInfo = $reqsData->createRequest( + $appInfo, + $authInfo->user->id, + $redirectUriId, + $state, + $codeChallenge, + $codeChallengeMethod, + $scope + ); + } catch(RuntimeException $ex) { + return $response->redirect(self::buildCallbackUri($redirectUri, [ + 'error' => 'server_error', + 'state' => $state, + ])); + } + + return $this->templating->render('oauth2/authorise', [ + 'app' => $appInfo, + 'req' => $requestInfo, + 'auth' => $authInfo, + ]); + } + + + #[HttpPost('/oauth2/authorise')] + public function postAuthorise($response, $request) { + if(!$request->isFormContent()) + return 400; + + $authInfo = ($this->getAuthInfo)(); + if(!isset($authInfo->user)) + return 401; + + // TODO: CSRFP!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + $content = $request->getContent(); + $approve = (string)$content->getParam('approve'); + if(!in_array($approve, ['yes', 'no'])) + return 400; + + $code = (string)$content->getParam('code'); + if(strlen($code) !== 100) + return 400; + + $reqsData = $this->oauth2Ctx->getRequestsData(); + try { + $requestInfo = $reqsData->getRequest( + userId: $authInfo->user->id, + code: $code, + ); + } catch(RuntimeException $ex) { + return 404; + } + + if(!$requestInfo->isPending()) + return 410; + + $appsData = $this->appsCtx->getData(); + try { + $uriInfo = $appsData->getAppUri($requestInfo->getUriId()); + } catch(RuntimeException $ex) { + return 400; + } + + $approved = $approve === 'yes'; + $reqsData->setRequestApproval($requestInfo, $approved); + + if($approved) + $response->redirect(self::buildCallbackUri($uriInfo->getString(), [ + 'code' => $requestInfo->getCode(), + 'state' => $requestInfo->getState(), + ])); + else + $response->redirect(self::buildCallbackUri($uriInfo->getString(), [ + 'error' => 'access_denied', + 'state' => $requestInfo->getState(), + ])); } private static function error(string $code, string $message = '', string $url = ''): array { @@ -70,7 +284,7 @@ final class OAuth2Routes extends RouteHandler { return self::error('invalid_client', 'No application has been registered with this client id.'); } - $scope = explode(' ', (string)$content->getParam('scope')); + $scope = self::filterScopes((string)$content->getParam('scope')); if(in_array('invalid', $scope)) { $response->setStatusCode(400); return self::error('invalid_scope', 'An invalid scope was requested.'); @@ -93,6 +307,8 @@ final class OAuth2Routes extends RouteHandler { #[HttpOptions('/oauth2/token')] #[HttpPost('/oauth2/token')] public function postToken($response, $request) { + $response->setHeader('Cache-Control', 'no-store'); + $originHeaders = ['Origin', 'X-Origin', 'Referer']; $origins = []; foreach($originHeaders as $originHeader) { @@ -123,6 +339,7 @@ final class OAuth2Routes extends RouteHandler { $content = $request->getContent(); + // authz header should be the preferred method $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization')); if($authzHeader[0] === 'Basic') { $authzHeader = explode(':', base64_decode($authzHeader[1] ?? '')); @@ -138,42 +355,97 @@ final class OAuth2Routes extends RouteHandler { $clientSecret = (string)$content->getParam('client_secret'); } - if($clientId === 'wrong') { // verify and look up + $appsData = $this->appsCtx->getData(); + $reqsData = $this->oauth2Ctx->getRequestsData(); + + try { + $appInfo = $appsData->getApp(clientId: $clientId, deleted: false); + } catch(RuntimeException $ex) { $response->setStatusCode(400); return self::error('invalid_client', 'No application has been registered with this client id.'); } $type = (string)$content->getParam('grant_type'); if($type === 'authorization_code') { - $code = (string)$content->getParam('code'); - // require a code verifier be used if client_secret is not supplied or if the field for it is populated // error code for this is: invalid_request $codeVerifier = (string)$content->getParam('code_verifier'); + $hasCodeVerifier = $codeVerifier !== ''; + if($clientSecret === '') { + if(!$hasCodeVerifier) { + $response->setStatusCode(400); + return self::error('invalid_request', 'Application authentication through client secret is required if no code verifier is specified.'); + } + } elseif(!$appInfo->verifyClientSecret($clientSecret)) { + $response->setStatusCode(400); + return self::error('invalid_client', 'Provided client secret is not correct for this application.'); + } + + try { + $requestInfo = $reqsData->getRequest(code: (string)$content->getParam('code')); + } catch(RuntimeException $ex) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'No authorisation request with this code exists.'); + } + + if($requestInfo->hasExpired()) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'Authorisation request has expired.'); + } + + if($requestInfo->hasCodeChallenge()) { + if(!$hasCodeVerifier) { + $response->setStatusCode(400); + return self::error('invalid_request', 'Authorisation required included a code challenge, but no code verifier is supplied.'); + } + + if(!$requestInfo->verifyCodeChallenge($codeVerifier)) { + $response->setStatusCode(400); + return self::error('invalid_request', 'Code challenge verification failed.'); + } + } - // required if it was specified in the authorise req, check! - // error code for this is also likely: invalid_request $redirectUri = (string)$content->getParam('redirect_uri'); + if($redirectUri === '') { + if(!$hasCodeVerifier && $appInfo->isPublic()) { + $response->setStatusCode(400); + return self::error('invalid_request', 'You must specified the redirect URI if no code verifier is specified.'); + } + } elseif($appsData->getAppUriId($appInfo, $redirectUri) !== $requestInfo->getUriId()) { + $response->setStatusCode(400); + return self::error('invalid_request', 'Provided redirect URI does not match up with the one used during the authorisation request.'); + } + + if(!$requestInfo->isApproved()) { + $response->setStatusCode(400); + return self::error('invalid_grant', 'Authorisation request has not been approved.'); + } + + $scopes = $requestInfo->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.'); + } } elseif($type === 'refresh_token') { $refreshToken = (string)$content->getParam('refresh_token'); - // should not contain more than the original access_token, prolly just omit - $scope = explode(' ', (string)$content->getParam('scope')); + // should not contain more than the original access_token, refresh info contains the stuff we need! + $newScopes = self::filterScopes((string)$content->getParam('scope')); + $oldScopes = []; - // i'm honestly not sure if this is the proper place for it but - if(in_array('invalid', $scope)) { + if($this->oauth2Ctx->validateScopes($appInfo, $newScopes)) { $response->setStatusCode(400); - return self::error('invalid_scope', 'An invalid scope was requested.'); + return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); } } elseif($type === 'client_credentials') { // uses client_secret } elseif($type === 'password') { - if($clientId !== 'flashii') { + // still really not sure if i should bother with implementing this, especially since its omitted from OAuth 2.1 entirely + if(!$appInfo->isTrusted()) { $response->setStatusCode(400); - return self::error('unauthorized_client', 'This application is not allowed t ouse this grant type.'); + return self::error('unauthorized_client', 'This application is not allowed to use this grant type.'); } - // this should only be allowed for certain applications $userName = (string)$content->getParam('username'); $password = (string)$content->getParam('password'); } elseif($type === 'device_code' || $type === 'urn:ietf:params:oauth:grant-type:device_code') { @@ -203,7 +475,7 @@ final class OAuth2Routes extends RouteHandler { return self::error('unsupported_grant_type', 'Requested grant type is not supported by this server.'); } - if($clientId === 'invalid_grant') { + if(empty($accessInfo)) { $response->setStatusCode(400); if($type === 'refresh_token') @@ -218,21 +490,17 @@ final class OAuth2Routes extends RouteHandler { return self::error('invalid_grant', $message); } - // TODO: ensure the set of requested scopes is still valid for this application - $scope ??= []; - if(in_array('invalid', $scope)) { - $response->setStatusCode(400); - return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); - } - $result = [ - 'access_token' => 'abcdef', + 'access_token' => $accessInfo->getToken(), 'token_type' => 'Bearer', - 'expires_in' => 3600, // should be bigger on the server side to offer leeway + 'expires_in' => $accessInfo->getRemainingLifetime(), ]; - // only if refreshable - $result['refresh_token'] = 'ghijkl'; + if(isset($scopes)) + $result['scope'] = implode(' ', $scopes); + + if(empty($refreshInfo)) + $result['refresh_token'] = $refreshInfo->getToken(); return $result; } diff --git a/src/OAuth2/OAuth2TokensData.php b/src/OAuth2/OAuth2TokensData.php new file mode 100644 index 0000000..512a8ac --- /dev/null +++ b/src/OAuth2/OAuth2TokensData.php @@ -0,0 +1,19 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + +} diff --git a/templates/errors/master.twig b/templates/errors/master.twig index 7641d1d..9747280 100644 --- a/templates/errors/master.twig +++ b/templates/errors/master.twig @@ -4,7 +4,7 @@ {{ http_error_title }} :: {{ globals.siteInfo.name }} - +
diff --git a/templates/master.twig b/templates/master.twig index ae91556..e0cf1bc 100644 --- a/templates/master.twig +++ b/templates/master.twig @@ -4,10 +4,8 @@ {% if title is defined %}{{ title }} :: {% endif %}{{ globals.siteInfo.name }} - {% block body %}{% endblock %} - diff --git a/templates/oauth2/authorise.twig b/templates/oauth2/authorise.twig new file mode 100644 index 0000000..0e5cfe1 --- /dev/null +++ b/templates/oauth2/authorise.twig @@ -0,0 +1,23 @@ +{% extends 'oauth2/master.twig' %} + +{% block body %} +

{{ app.name }}

+

{{ app.summary }}

+

{% if app.isTrusted %}This is an official application. Things should probably just implicitly authorise if we hit this.{% else %}This is a third-party application.{% endif %}

+ +

You are logged in as {{ auth.user.name }}.

+ {% if auth.guise is defined %} +

Are you {{ auth.guise.name }} and did you mean to use your own account? Click here and reload this page.

+ {% endif %} + +
+ + + +
+
+ + + +
+{% endblock %} diff --git a/templates/oauth2/login.twig b/templates/oauth2/login.twig new file mode 100644 index 0000000..2f8478b --- /dev/null +++ b/templates/oauth2/login.twig @@ -0,0 +1,9 @@ +{% extends 'oauth2/master.twig' %} + +{% block body %} +

{{ app.name }}

+

{{ app.summary }}

+

{% if app.isTrusted %}This is an official application. Things should probably just implicitly authorise if we hit this.{% else %}This is a third-party application.{% endif %}

+ +

You must be logged in to authorise applications. Log in or create an account and reload this page to try again.

+{% endblock %} diff --git a/templates/oauth2/master.twig b/templates/oauth2/master.twig new file mode 100644 index 0000000..d412fa4 --- /dev/null +++ b/templates/oauth2/master.twig @@ -0,0 +1,13 @@ + + + + + + {% if title is defined %}{{ title }} :: {% endif %}{{ globals.siteInfo.name }} + + + + {% block body %}{% endblock %} + + +