More parts of the OAuth2 implementation.

This commit is contained in:
flash 2024-07-19 00:22:47 +00:00
parent 865c48d26d
commit 1a0456462c
22 changed files with 1239 additions and 94 deletions

View file

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

18
package-lock.json generated
View file

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

95
src/Apps/AppInfo.php Normal file
View file

@ -0,0 +1,95 @@
<?php
namespace Hanyuu\Apps;
use Index\Data\IDbResult;
class AppInfo {
public function __construct(
private string $id,
private string $name,
private string $summary,
private string $website,
private bool $trusted,
private string $type,
private string $clientId,
private string $clientSecret, // should this be stored hashed?
private int $created,
private int $updated,
private ?int $deleted
) {}
public static function fromResult(IDbResult $result): AppInfo {
return new AppInfo(
id: $result->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;
}
}

41
src/Apps/AppUriInfo.php Normal file
View file

@ -0,0 +1,41 @@
<?php
namespace Hanyuu\Apps;
use Index\Data\IDbResult;
class AppUriInfo {
public function __construct(
private string $id,
private string $appId,
private string $string,
private int $created
) {}
public static function fromResult(IDbResult $result): AppUriInfo {
return new AppUriInfo(
id: $result->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;
}
}

16
src/Apps/AppsContext.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace Hanyuu\Apps;
use Index\Data\IDbConnection;
class AppsContext {
private AppsData $data;
public function __construct(IDbConnection $dbConn) {
$this->data = new AppsData($dbConn);
}
public function getData(): AppsData {
return $this->data;
}
}

90
src/Apps/AppsData.php Normal file
View file

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

View file

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

103
src/MisuzuInterop.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace Hanyuu;
use stdClass;
use RuntimeException;
use Syokuhou\IConfig;
use Index\Serialisation\UriBase64;
class MisuzuInterop {
public function __construct(
private IConfig $config
) {}
public function authCheck(string $method, string $token, string $remoteAddr, array $avatars = []): object {
$result = $this->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;
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace Hanyuu\OAuth2;
use Index\Data\IDbResult;
class OAuth2AccessInfo {
public function __construct(
private string $id,
private string $appId,
private string $userId,
private string $token,
private string $scope,
private int $created,
private int $expires
) {}
public static function fromResult(IDbResult $result): OAuth2AccessInfo {
return new OAuth2AccessInfo(
id: $result->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;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Hanyuu\OAuth2;
use Index\Data\IDbConnection;
use Hanyuu\Apps\AppInfo;
class OAuth2Context {
private OAuth2RequestsData $requests;
private OAuth2TokensData $tokens;
public function __construct(IDbConnection $dbConn) {
$this->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;
}
}

View file

@ -0,0 +1,76 @@
<?php
namespace Hanyuu\OAuth2;
use Index\Data\IDbResult;
class OAuth2RefreshInfo {
public function __construct(
private string $id,
private string $appId,
private string $userId,
private ?string $accId,
private string $token,
private string $scope,
private int $created,
private int $expires
) {}
public static function fromResult(IDbResult $result): OAuth2RefreshInfo {
return new OAuth2RefreshInfo(
id: $result->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;
}
}

View file

@ -0,0 +1,115 @@
<?php
namespace Hanyuu\OAuth2;
use Index\Data\IDbResult;
use Index\Serialisation\UriBase64;
class OAuth2RequestInfo {
private const EXPIRES_TIME = 10 * 60;
public function __construct(
private string $id,
private string $appId,
private string $userId,
private string $uriId,
private string $state,
private string $challengeCode,
private string $challengeMethod,
private string $scope,
private string $code,
private string $approval,
private int $created
) {}
public static function fromResult(IDbResult $result): OAuth2RequestInfo {
return new OAuth2RequestInfo(
id: $result->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);
}
}

View file

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

View file

@ -1,48 +1,262 @@
<?php
namespace Hanyuu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler};
use Sasae\SasaeEnvironment;
use Hanyuu\Apps\AppsContext;
final class OAuth2Routes extends RouteHandler {
#[HttpGet('/oauth2/authorise')]
public function getAuthorise($response, $request) {
$type = (string)$request->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;
}

View file

@ -0,0 +1,19 @@
<?php
namespace Hanyuu\OAuth2;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
class OAuth2TokensData {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
}

View file

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>{{ http_error_title }} :: {{ globals.siteInfo.name }}</title>
<link href="/errors.css" type="text/css" rel="stylesheet">
<link href="/errors.css" rel="stylesheet">
</head>
<body>
<div class="http-err">

View file

@ -4,10 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>{% if title is defined %}{{ title }} :: {% endif %}{{ globals.siteInfo.name }}</title>
<link href="/assets/hanyuu.css" type="text/css" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
<script src="/assets/hanyuu.js" type="text/javascript"></script>
</body>
</html>

View file

@ -0,0 +1,23 @@
{% extends 'oauth2/master.twig' %}
{% block body %}
<h1><a href="{{ app.website }}" target="_blank">{{ app.name }}</a></h1>
<p>{{ app.summary }}</p>
<p>{% 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 %}</p>
<p>You are logged in as <a href="{{ auth.user.profile_url }}" target="_blank" style="color: {{ auth.user.colour }}">{{ auth.user.name }}</a>.</p>
{% if auth.guise is defined %}
<p>Are you <a href="{{ auth.guise.profile_url }}" target="_blank" style="color: {{ auth.guise.colour }}">{{ auth.guise.name }}</a> and did you mean to use your own account? <a href="{{ auth.guise.revert_url }}" target="_blank">Click here</a> and reload this page.</p>
{% endif %}
<form method="post" action="/oauth2/authorise">
<input type="hidden" name="code" value="{{ req.code }}">
<input type="hidden" name="approve" value="yes">
<button>Authorise</button>
</form>
<form method="post" action="/oauth2/authorise">
<input type="hidden" name="code" value="{{ req.code }}">
<input type="hidden" name="approve" value="no">
<button>Cancel</button>
</form>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% extends 'oauth2/master.twig' %}
{% block body %}
<h1><a href="{{ app.website }}" target="_blank">{{ app.name }}</a></h1>
<p>{{ app.summary }}</p>
<p>{% 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 %}</p>
<p>You must be logged in to authorise applications. <a href="{{ auth.login_url }}" target="_blank">Log in</a> or <a href="{{ auth.register_url }}" target="_blank">create an account</a> and reload this page to try again.</p>
{% endblock %}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>{% if title is defined %}{{ title }} :: {% endif %}{{ globals.siteInfo.name }}</title>
<link href="{{ asset('oauth2.css') }}" rel="stylesheet">
</head>
<body>
{% block body %}{% endblock %}
<script src="{{ asset('oauth2.js') }}"></script>
</body>
</html>