More parts of the OAuth2 implementation.
This commit is contained in:
parent
865c48d26d
commit
1a0456462c
22 changed files with 1239 additions and 94 deletions
4
build.js
4
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', },
|
||||
|
|
18
package-lock.json
generated
18
package-lock.json
generated
|
@ -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
95
src/Apps/AppInfo.php
Normal 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
41
src/Apps/AppUriInfo.php
Normal 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
16
src/Apps/AppsContext.php
Normal 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
90
src/Apps/AppsData.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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
103
src/MisuzuInterop.php
Normal 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;
|
||||
}
|
||||
}
|
67
src/OAuth2/OAuth2AccessInfo.php
Normal file
67
src/OAuth2/OAuth2AccessInfo.php
Normal 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;
|
||||
}
|
||||
}
|
31
src/OAuth2/OAuth2Context.php
Normal file
31
src/OAuth2/OAuth2Context.php
Normal 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;
|
||||
}
|
||||
}
|
76
src/OAuth2/OAuth2RefreshInfo.php
Normal file
76
src/OAuth2/OAuth2RefreshInfo.php
Normal 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;
|
||||
}
|
||||
}
|
115
src/OAuth2/OAuth2RequestInfo.php
Normal file
115
src/OAuth2/OAuth2RequestInfo.php
Normal 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);
|
||||
}
|
||||
}
|
159
src/OAuth2/OAuth2RequestsData.php
Normal file
159
src/OAuth2/OAuth2RequestsData.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
19
src/OAuth2/OAuth2TokensData.php
Normal file
19
src/OAuth2/OAuth2TokensData.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
23
templates/oauth2/authorise.twig
Normal file
23
templates/oauth2/authorise.twig
Normal 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 %}
|
9
templates/oauth2/login.twig
Normal file
9
templates/oauth2/login.twig
Normal 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 %}
|
13
templates/oauth2/master.twig
Normal file
13
templates/oauth2/master.twig
Normal 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>
|
Reference in a new issue