Implemented core OAuth spec token flows.

This commit is contained in:
flash 2024-07-20 02:25:49 +00:00
parent 1a0456462c
commit 3ea982fd43
11 changed files with 362 additions and 152 deletions

View file

@ -1,7 +1,6 @@
<?php <?php
namespace Hanyuu; namespace Hanyuu;
use Index\Autoloader;
use Index\Environment; use Index\Environment;
use Index\Data\DbTools; use Index\Data\DbTools;
use Syokuhou\SharpConfig; use Syokuhou\SharpConfig;
@ -19,10 +18,8 @@ define('HAU_DIR_TEMPLATES', HAU_ROOT . '/templates');
require_once HAU_ROOT . '/vendor/autoload.php'; require_once HAU_ROOT . '/vendor/autoload.php';
Environment::setDebug(HAU_DEBUG); Environment::setDebug(HAU_DEBUG);
mb_internal_encoding('utf-8'); mb_internal_encoding('utf-8');
date_default_timezone_set('utc'); date_default_timezone_set('utc');
set_include_path(get_include_path() . PATH_SEPARATOR . HAU_ROOT);
$cfg = SharpConfig::fromFile(HAU_ROOT . '/hanyuu.cfg'); $cfg = SharpConfig::fromFile(HAU_ROOT . '/hanyuu.cfg');

View file

@ -12,7 +12,7 @@ class AppInfo {
private bool $trusted, private bool $trusted,
private string $type, private string $type,
private string $clientId, private string $clientId,
private string $clientSecret, // should this be stored hashed? private string $clientSecret,
private int $created, private int $created,
private int $updated, private int $updated,
private ?int $deleted private ?int $deleted

View file

@ -15,7 +15,7 @@ class AppsData {
$this->cache = new DbStatementCache($dbConn); $this->cache = new DbStatementCache($dbConn);
} }
public function getApp( public function getAppInfo(
?string $appId = null, ?string $appId = null,
?string $clientId = null, ?string $clientId = null,
?bool $deleted = null ?bool $deleted = null
@ -53,7 +53,7 @@ class AppsData {
return $result->next() ? $result->getInteger(0) : 0; return $result->next() ? $result->getInteger(0) : 0;
} }
public function getAppUris(AppInfo|string $appInfo): array { public function getAppUriInfos(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 = $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->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->execute(); $stmt->execute();
@ -66,7 +66,7 @@ class AppsData {
return $infos; return $infos;
} }
public function getAppUri(string $uriId): AppUriInfo { public function getAppUriInfo(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 = $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->addParameter(1, $uriId);
$stmt->execute(); $stmt->execute();

View file

@ -11,6 +11,14 @@ class MisuzuInterop {
private IConfig $config private IConfig $config
) {} ) {}
private function getEndpoint(): string {
return $this->config->getString('endpoint');
}
private function getSecret(): string {
return $this->config->getString('secret');
}
public function authCheck(string $method, string $token, string $remoteAddr, array $avatars = []): object { public function authCheck(string $method, string $token, string $remoteAddr, array $avatars = []): object {
$result = $this->callRpc('auth-check', body: [ $result = $this->callRpc('auth-check', body: [
'method' => $method, 'method' => $method,
@ -28,7 +36,7 @@ class MisuzuInterop {
private function callRpc(string $action, array $params = [], array $body = []): object { private function callRpc(string $action, array $params = [], array $body = []): object {
$time = time(); $time = time();
$url = sprintf('%s/_hanyuu/%s', $this->config->getString('endpoint'), $action); $url = sprintf('%s/_hanyuu/%s', $this->getEndpoint(), $action);
if(empty($params)) if(empty($params))
$params = ''; $params = '';
else { else {
@ -42,7 +50,7 @@ class MisuzuInterop {
$signature = UriBase64::encode(hash_hmac( $signature = UriBase64::encode(hash_hmac(
'sha256', 'sha256',
sprintf('[%s|%s|%s]', $time, $url, $body), sprintf('[%s|%s|%s]', $time, $url, $body),
$this->config->getString('secret'), $this->getSecret(),
true true
)); ));
$headers = [ $headers = [

View file

@ -7,7 +7,7 @@ class OAuth2AccessInfo {
public function __construct( public function __construct(
private string $id, private string $id,
private string $appId, private string $appId,
private string $userId, private ?string $userId,
private string $token, private string $token,
private string $scope, private string $scope,
private int $created, private int $created,
@ -18,7 +18,7 @@ class OAuth2AccessInfo {
return new OAuth2AccessInfo( return new OAuth2AccessInfo(
id: $result->getString(0), id: $result->getString(0),
appId: $result->getString(1), appId: $result->getString(1),
userId: $result->getString(2), userId: $result->getStringOrNull(2),
token: $result->getString(3), token: $result->getString(3),
scope: $result->getString(4), scope: $result->getString(4),
created: $result->getInteger(5), created: $result->getInteger(5),
@ -34,7 +34,10 @@ class OAuth2AccessInfo {
return $this->appId; return $this->appId;
} }
public function getUserId(): string { public function hasUserId(): bool {
return $this->userId !== null;
}
public function getUserId(): ?string {
return $this->userId; return $this->userId;
} }

View file

@ -9,7 +9,7 @@ use Index\Data\IDbConnection;
use Hanyuu\Apps\AppInfo; use Hanyuu\Apps\AppInfo;
use Hanyuu\Apps\AppUriInfo; use Hanyuu\Apps\AppUriInfo;
class OAuth2RequestsData { class OAuth2AuthoriseData {
private IDbConnection $dbConn; private IDbConnection $dbConn;
private DbStatementCache $cache; private DbStatementCache $cache;
@ -18,8 +18,8 @@ class OAuth2RequestsData {
$this->cache = new DbStatementCache($dbConn); $this->cache = new DbStatementCache($dbConn);
} }
public function getRequest( public function getAuthoriseInfo(
?string $requestId = null, ?string $authoriseId = null,
AppInfo|string|null $appInfo = null, AppInfo|string|null $appInfo = null,
?string $userId = null, ?string $userId = null,
AppUriInfo|string|null $appUriInfo = null, AppUriInfo|string|null $appUriInfo = null,
@ -28,12 +28,12 @@ class OAuth2RequestsData {
?string $challengeMethod = null, ?string $challengeMethod = null,
?string $scope = null, ?string $scope = null,
?string $code = null ?string $code = null
): OAuth2RequestInfo { ): OAuth2AuthoriseInfo {
$selectors = []; $selectors = [];
$values = []; $values = [];
if($requestId !== null) { if($authoriseId !== null) {
$selectors[] = 'req_id = ?'; $selectors[] = 'auth_id = ?';
$values[] = $requestId; $values[] = $authoriseId;
} }
if($appInfo !== null) { if($appInfo !== null) {
$selectors[] = 'app_id = ?'; $selectors[] = 'app_id = ?';
@ -48,43 +48,43 @@ class OAuth2RequestsData {
$values[] = $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo; $values[] = $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo;
} }
if($state !== null) { if($state !== null) {
$selectors[] = 'req_state = ?'; $selectors[] = 'auth_state = ?';
$values[] = $state; $values[] = $state;
} }
if($challengeCode !== null) { if($challengeCode !== null) {
$selectors[] = 'req_challenge_code = ?'; $selectors[] = 'auth_challenge_code = ?';
$values[] = $challengeCode; $values[] = $challengeCode;
} }
if($challengeMethod !== null) { if($challengeMethod !== null) {
$selectors[] = 'req_challenge_method = ?'; $selectors[] = 'auth_challenge_method = ?';
$values[] = $challengeMethod; $values[] = $challengeMethod;
} }
if($scope !== null) { if($scope !== null) {
$selectors[] = 'req_scope = ?'; $selectors[] = 'auth_scope = ?';
$values[] = $scope; $values[] = $scope;
} }
if($code !== null) { if($code !== null) {
$selectors[] = 'req_code = ?'; $selectors[] = 'auth_code = ?';
$values[] = $code; $values[] = $code;
} }
if(empty($selectors)) if(empty($selectors))
throw new RuntimeException('Insufficient data to do request lookup.'); throw new RuntimeException('Insufficient data to do authorisation request lookup.');
$args = 0; $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)); $stmt = $this->cache->get('SELECT auth_id, app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code, auth_approval, UNIX_TIMESTAMP(auth_created) FROM hau_oauth2_authorise WHERE ' . implode(' AND ', $selectors));
foreach($values as $value) foreach($values as $value)
$stmt->addParameter(++$args, $value); $stmt->addParameter(++$args, $value);
$stmt->execute(); $stmt->execute();
$result = $stmt->getResult(); $result = $stmt->getResult();
if(!$result->next()) if(!$result->next())
throw new RuntimeException('Request not found.'); throw new RuntimeException('Authorise request not found.');
return OAuth2RequestInfo::fromResult($result); return OAuth2AuthoriseInfo::fromResult($result);
} }
public function createRequest( public function createAuthorise(
AppInfo|string $appInfo, AppInfo|string $appInfo,
string $userId, string $userId,
AppUriInfo|string $appUriInfo, AppUriInfo|string $appUriInfo,
@ -92,8 +92,8 @@ class OAuth2RequestsData {
string $challengeCode, string $challengeCode,
string $challengeMethod, string $challengeMethod,
string $scope string $scope
): OAuth2RequestInfo { ): OAuth2AuthoriseInfo {
$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 = $this->cache->get('INSERT INTO hau_oauth2_authorise (app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo); $stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->addParameter(2, $userId); $stmt->addParameter(2, $userId);
$stmt->addParameter(3, $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo); $stmt->addParameter(3, $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo);
@ -104,20 +104,20 @@ class OAuth2RequestsData {
$stmt->addParameter(8, XString::random(100)); $stmt->addParameter(8, XString::random(100));
$stmt->execute(); $stmt->execute();
return $this->getRequest(requestId: (string)$this->dbConn->getLastInsertId()); return $this->getAuthoriseInfo(authoriseId: (string)$this->dbConn->getLastInsertId());
} }
public function deleteRequests( public function deleteAuthorise(
OAuth2RequestInfo|string|null $requestInfo = null, OAuth2AuthoriseInfo|string|null $authoriseId = null,
AppInfo|string|null $appInfo = null, AppInfo|string|null $appInfo = null,
?string $userId = null, ?string $userId = null,
?string $code = null ?string $code = null
): void { ): void {
$selectors = []; $selectors = [];
$values = []; $values = [];
if($requestInfo !== null) { if($authoriseId !== null) {
$selectors[] = 'req_id = ?'; $selectors[] = 'auth_id = ?';
$values[] = $requestInfo instanceof OAuth2RequestInfo ? $requestInfo->getId() : $requestInfo; $values[] = $authoriseId instanceof OAuth2AuthoriseInfo ? $authoriseId->getId() : $authoriseId;
} }
if($appInfo !== null) { if($appInfo !== null) {
$selectors[] = 'app_id = ?'; $selectors[] = 'app_id = ?';
@ -128,11 +128,11 @@ class OAuth2RequestsData {
$values[] = $userId; $values[] = $userId;
} }
if($code !== null) { if($code !== null) {
$selectors[] = 'req_code = ?'; $selectors[] = 'auth_code = ?';
$values[] = $code; $values[] = $code;
} }
$query = 'DELETE FROM hau_oauth2_requests'; $query = 'DELETE FROM hau_oauth2_authorise';
if(!empty($selectors)) if(!empty($selectors))
$query .= sprintf(' WHERE %s', implode(' AND ', $selectors)); $query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
@ -143,17 +143,17 @@ class OAuth2RequestsData {
$stmt->execute(); $stmt->execute();
} }
public function setRequestApproval(OAuth2RequestInfo|string $requestInfo, bool $approval): void { public function setAuthoriseApproval(OAuth2AuthoriseInfo|string $authoriseInfo, bool $approval): void {
if($requestInfo instanceof OAuth2RequestInfo) { if($authoriseInfo instanceof OAuth2AuthoriseInfo) {
if(!$requestInfo->isPending()) if(!$authoriseInfo->isPending())
return; return;
$requestInfo = $requestInfo->getId(); $authoriseInfo = $authoriseInfo->getId();
} }
$stmt = $this->cache->get('UPDATE hau_oauth2_requests SET req_approval = ? WHERE req_id = ? AND req_approval = "pending"'); $stmt = $this->cache->get('UPDATE hau_oauth2_authorise SET auth_approval = ? WHERE auth_id = ? AND auth_approval = "pending"');
$stmt->addParameter(1, $approval ? 'approved' : 'denied'); $stmt->addParameter(1, $approval ? 'approved' : 'denied');
$stmt->addParameter(2, $requestInfo); $stmt->addParameter(2, $authoriseInfo);
$stmt->execute(); $stmt->execute();
} }
} }

View file

@ -4,7 +4,7 @@ namespace Hanyuu\OAuth2;
use Index\Data\IDbResult; use Index\Data\IDbResult;
use Index\Serialisation\UriBase64; use Index\Serialisation\UriBase64;
class OAuth2RequestInfo { class OAuth2AuthoriseInfo {
private const EXPIRES_TIME = 10 * 60; private const EXPIRES_TIME = 10 * 60;
public function __construct( public function __construct(
@ -21,8 +21,8 @@ class OAuth2RequestInfo {
private int $created private int $created
) {} ) {}
public static function fromResult(IDbResult $result): OAuth2RequestInfo { public static function fromResult(IDbResult $result): OAuth2AuthoriseInfo {
return new OAuth2RequestInfo( return new OAuth2AuthoriseInfo(
id: $result->getString(0), id: $result->getString(0),
appId: $result->getString(1), appId: $result->getString(1),
userId: $result->getString(2), userId: $result->getString(2),
@ -57,10 +57,6 @@ class OAuth2RequestInfo {
return $this->state; return $this->state;
} }
public function hasCodeChallenge(): bool {
return $this->challengeCode !== '';
}
public function getChallengeCode(): string { public function getChallengeCode(): string {
return $this->challengeCode; return $this->challengeCode;
} }

View file

@ -5,22 +5,37 @@ use Index\Data\IDbConnection;
use Hanyuu\Apps\AppInfo; use Hanyuu\Apps\AppInfo;
class OAuth2Context { class OAuth2Context {
private OAuth2RequestsData $requests; private OAuth2AuthoriseData $authorise;
private OAuth2TokensData $tokens; private OAuth2TokensData $tokens;
public function __construct(IDbConnection $dbConn) { public function __construct(IDbConnection $dbConn) {
$this->requests = new OAuth2RequestsData($dbConn); $this->authorise = new OAuth2AuthoriseData($dbConn);
$this->tokens = new OAuth2TokensData($dbConn); $this->tokens = new OAuth2TokensData($dbConn);
} }
public function getRequestsData(): OAuth2RequestsData { public function getAuthoriseData(): OAuth2AuthoriseData {
return $this->requests; return $this->authorise;
} }
public function getTokensData(): OAuth2TokensData { public function getTokensData(): OAuth2TokensData {
return $this->tokens; return $this->tokens;
} }
public function createRefreshFromAccessInfo(
OAuth2AccessInfo $accessInfo,
?string $token = null,
?int $lifetime = null
): OAuth2RefreshInfo {
return $this->tokens->createRefresh(
$accessInfo->getAppId(),
$accessInfo,
$accessInfo->getUserId(),
$token,
$accessInfo->getScope(),
$lifetime
);
}
public function validateScopes(AppInfo $appInfo, array $scopes): bool { public function validateScopes(AppInfo $appInfo, array $scopes): bool {
foreach($scopes as $scope) foreach($scopes as $scope)
if(strlen($scope) > 128) // rather than this, actually check if they are defined/supported or smth if(strlen($scope) > 128) // rather than this, actually check if they are defined/supported or smth

View file

@ -7,7 +7,7 @@ class OAuth2RefreshInfo {
public function __construct( public function __construct(
private string $id, private string $id,
private string $appId, private string $appId,
private string $userId, private ?string $userId,
private ?string $accId, private ?string $accId,
private string $token, private string $token,
private string $scope, private string $scope,
@ -19,7 +19,7 @@ class OAuth2RefreshInfo {
return new OAuth2RefreshInfo( return new OAuth2RefreshInfo(
id: $result->getString(0), id: $result->getString(0),
appId: $result->getString(1), appId: $result->getString(1),
userId: $result->getString(2), userId: $result->getStringOrNull(2),
accId: $result->getStringOrNull(3), accId: $result->getStringOrNull(3),
token: $result->getString(4), token: $result->getString(4),
scope: $result->getString(5), scope: $result->getString(5),
@ -36,7 +36,10 @@ class OAuth2RefreshInfo {
return $this->appId; return $this->appId;
} }
public function getUserId(): string { public function hasUserId(): bool {
return $this->userId !== null;
}
public function getUserId(): ?string {
return $this->userId; return $this->userId;
} }

View file

@ -48,17 +48,6 @@ final class OAuth2Routes extends RouteHandler {
// //
} }
#[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')] #[HttpGet('/oauth2/authorise')]
public function getAuthorise($response, $request) { public function getAuthorise($response, $request) {
$redirectUri = (string)$request->getParam('redirect_uri'); $redirectUri = (string)$request->getParam('redirect_uri');
@ -71,7 +60,7 @@ final class OAuth2Routes extends RouteHandler {
$clientId = (string)$request->getParam('client_id'); $clientId = (string)$request->getParam('client_id');
$appsData = $this->appsCtx->getData(); $appsData = $this->appsCtx->getData();
try { try {
$appInfo = $appsData->getApp(clientId: $clientId, deleted: false); $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) { } catch(RuntimeException $ex) {
return $response->redirect(self::buildCallbackUri($redirectUri, [ return $response->redirect(self::buildCallbackUri($redirectUri, [
'error' => 'invalid_request', 'error' => 'invalid_request',
@ -95,7 +84,7 @@ final class OAuth2Routes extends RouteHandler {
'state' => $state, 'state' => $state,
])); ]));
$uriInfos = $appsData->getAppUris($appInfo); $uriInfos = $appsData->getAppUriInfos($appInfo);
if(count($uriInfos) !== 1) if(count($uriInfos) !== 1)
return $response->redirect(self::buildCallbackUri($redirectUri, [ return $response->redirect(self::buildCallbackUri($redirectUri, [
'error' => 'invalid_request', 'error' => 'invalid_request',
@ -130,14 +119,7 @@ final class OAuth2Routes extends RouteHandler {
$codeChallengeLength = strlen($codeChallenge); $codeChallengeLength = strlen($codeChallenge);
if($codeChallengeMethod === 'plain') { if($codeChallengeMethod === 'plain') {
if($codeChallengeLength === 0) { if($codeChallengeLength < 43)
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, [ return $response->redirect(self::buildCallbackUri($redirectUri, [
'error' => 'invalid_request', 'error' => 'invalid_request',
'error_description' => 'Code challenge must be at least 43 characters long.', 'error_description' => 'Code challenge must be at least 43 characters long.',
@ -179,9 +161,9 @@ final class OAuth2Routes extends RouteHandler {
'auth' => $authInfo, 'auth' => $authInfo,
]); ]);
$reqsData = $this->oauth2Ctx->getRequestsData(); $authoriseData = $this->oauth2Ctx->getAuthoriseData();
try { try {
$requestInfo = $reqsData->createRequest( $authoriseInfo = $authoriseData->createAuthorise(
$appInfo, $appInfo,
$authInfo->user->id, $authInfo->user->id,
$redirectUriId, $redirectUriId,
@ -199,7 +181,7 @@ final class OAuth2Routes extends RouteHandler {
return $this->templating->render('oauth2/authorise', [ return $this->templating->render('oauth2/authorise', [
'app' => $appInfo, 'app' => $appInfo,
'req' => $requestInfo, 'req' => $authoriseInfo,
'auth' => $authInfo, 'auth' => $authInfo,
]); ]);
} }
@ -224,9 +206,9 @@ final class OAuth2Routes extends RouteHandler {
if(strlen($code) !== 100) if(strlen($code) !== 100)
return 400; return 400;
$reqsData = $this->oauth2Ctx->getRequestsData(); $authoriseData = $this->oauth2Ctx->getAuthoriseData();
try { try {
$requestInfo = $reqsData->getRequest( $authoriseInfo = $authoriseData->getAuthoriseInfo(
userId: $authInfo->user->id, userId: $authInfo->user->id,
code: $code, code: $code,
); );
@ -234,28 +216,28 @@ final class OAuth2Routes extends RouteHandler {
return 404; return 404;
} }
if(!$requestInfo->isPending()) if(!$authoriseInfo->isPending())
return 410; return 410;
$appsData = $this->appsCtx->getData(); $appsData = $this->appsCtx->getData();
try { try {
$uriInfo = $appsData->getAppUri($requestInfo->getUriId()); $uriInfo = $appsData->getAppUriInfo($authoriseInfo->getUriId());
} catch(RuntimeException $ex) { } catch(RuntimeException $ex) {
return 400; return 400;
} }
$approved = $approve === 'yes'; $approved = $approve === 'yes';
$reqsData->setRequestApproval($requestInfo, $approved); $authoriseData->setAuthoriseApproval($authoriseInfo, $approved);
if($approved) if($approved)
$response->redirect(self::buildCallbackUri($uriInfo->getString(), [ $response->redirect(self::buildCallbackUri($uriInfo->getString(), [
'code' => $requestInfo->getCode(), 'code' => $authoriseInfo->getCode(),
'state' => $requestInfo->getState(), 'state' => $authoriseInfo->getState(),
])); ]));
else else
$response->redirect(self::buildCallbackUri($uriInfo->getString(), [ $response->redirect(self::buildCallbackUri($uriInfo->getString(), [
'error' => 'access_denied', 'error' => 'access_denied',
'state' => $requestInfo->getState(), 'state' => $authoriseInfo->getState(),
])); ]));
} }
@ -356,98 +338,142 @@ final class OAuth2Routes extends RouteHandler {
} }
$appsData = $this->appsCtx->getData(); $appsData = $this->appsCtx->getData();
$reqsData = $this->oauth2Ctx->getRequestsData();
try { try {
$appInfo = $appsData->getApp(clientId: $clientId, deleted: false); $appInfo = $appsData->getAppInfo(clientId: $clientId, deleted: false);
} catch(RuntimeException $ex) { } catch(RuntimeException $ex) {
$response->setStatusCode(400); $response->setStatusCode(400);
return self::error('invalid_client', 'No application has been registered with this client id.'); return self::error('invalid_client', 'No application has been registered with this client id.');
} }
$type = (string)$content->getParam('grant_type'); $appAuthenticated = false;
if($type === 'authorization_code') { if($clientSecret !== '') {
// require a code verifier be used if client_secret is not supplied or if the field for it is populated // TODO: rate limiting
// error code for this is: invalid_request $appAuthenticated = $appInfo->verifyClientSecret($clientSecret);
$codeVerifier = (string)$content->getParam('code_verifier'); if(!$appAuthenticated) {
$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); $response->setStatusCode(400);
return self::error('invalid_client', 'Provided client secret is not correct for this application.'); return self::error('invalid_client', 'Provided client secret is not correct for this application.');
} }
}
$type = (string)$content->getParam('grant_type');
if($type === 'authorization_code') {
$authoriseData = $this->oauth2Ctx->getAuthoriseData();
try { try {
$requestInfo = $reqsData->getRequest(code: (string)$content->getParam('code')); $authoriseInfo = $authoriseData->getAuthoriseInfo(
appInfo: $appInfo,
code: (string)$content->getParam('code'),
);
} catch(RuntimeException $ex) { } catch(RuntimeException $ex) {
$response->setStatusCode(400); $response->setStatusCode(400);
return self::error('invalid_grant', 'No authorisation request with this code exists.'); return self::error('invalid_grant', 'No authorisation request with this code exists.');
} }
if($requestInfo->hasExpired()) { if($authoriseInfo->hasExpired()) {
$response->setStatusCode(400); $response->setStatusCode(400);
return self::error('invalid_grant', 'Authorisation request has expired.'); return self::error('invalid_grant', 'Authorisation request has expired.');
} }
if($requestInfo->hasCodeChallenge()) { if(!$authoriseInfo->verifyCodeChallenge((string)$content->getParam('code_verifier'))) {
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); $response->setStatusCode(400);
return self::error('invalid_request', 'Code challenge verification failed.'); return self::error('invalid_request', 'Code challenge verification failed.');
} }
}
$redirectUri = (string)$content->getParam('redirect_uri'); if(!$authoriseInfo->isApproved()) {
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); $response->setStatusCode(400);
return self::error('invalid_grant', 'Authorisation request has not been approved.'); return self::error('invalid_grant', 'Authorisation request has not been approved.');
} }
$scopes = $requestInfo->getScopes(); // its entirely verified!
$authoriseData->deleteAuthorise($authoriseInfo);
$scopes = $authoriseInfo->getScopes();
if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) { if(!$this->oauth2Ctx->validateScopes($appInfo, $scopes)) {
$response->setStatusCode(400); $response->setStatusCode(400);
return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
} }
$scope = implode(' ', $scopes);
$tokensData = $this->oauth2Ctx->getTokensData();
$accessInfo = $tokensData->createAccess(
$appInfo,
$authoriseInfo->getUserId(),
scope: $scope,
);
// 'scope' only has to be in the response if it differs from what was requested
if($scope === $authoriseInfo->getScope())
unset($scope);
// this should probably check something else
if($appInfo->isConfidential())
$refreshInfo = $this->oauth2Ctx->createRefreshFromAccessInfo($accessInfo);
} elseif($type === 'refresh_token') { } elseif($type === 'refresh_token') {
$refreshToken = (string)$content->getParam('refresh_token'); $tokensData = $this->oauth2Ctx->getTokensData();
try {
$refreshInfo = $tokensData->getRefreshInfo((string)$content->getParam('refresh_token'), OAuth2TokensData::REFRESH_BY_TOKEN);
} catch(RuntimeException $ex) {
$response->setStatusCode(400);
return self::error('invalid_grant', 'No such refresh token exists.');
}
if($refreshInfo->getAppId() !== $appInfo->getId()) {
$response->setStatusCode(400);
return self::error('invalid_grant', 'This refresh token is not associated with this application.');
}
if($refreshInfo->hasExpired()) {
$response->setStatusCode(400);
return self::error('invalid_grant', 'This refresh token has expired.');
}
$tokensData->deleteRefresh($refreshInfo);
if($refreshInfo->hasAccessId())
$tokensData->deleteAccess(accessInfo: $refreshInfo->getAccessId());
// should not contain more than the original access_token, refresh info contains the stuff we need! // should not contain more than the original access_token, refresh info contains the stuff we need!
$newScopes = self::filterScopes((string)$content->getParam('scope')); $newScopes = self::filterScopes((string)$content->getParam('scope'));
$oldScopes = []; $oldScopes = [];
if($this->oauth2Ctx->validateScopes($appInfo, $newScopes)) { if(!$this->oauth2Ctx->validateScopes($appInfo, $newScopes)) {
$response->setStatusCode(400); $response->setStatusCode(400);
return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.'); return self::error('invalid_scope', 'One or more requested scopes are no longer valid for this application, please restart authorisation.');
} }
$accessInfo = $tokensData->createAccess(
$appInfo,
$refreshInfo->getUserId(),
scope: $refreshInfo->getScope(), // just copy from refreshInfo for now, should reverify!!
);
unset($refreshInfo);
// this should probably check something else
if($appInfo->isConfidential())
$refreshInfo = $this->oauth2Ctx->createRefreshFromAccessInfo($accessInfo);
} elseif($type === 'client_credentials') { } elseif($type === 'client_credentials') {
// uses client_secret if(!$appInfo->isConfidential()) {
} elseif($type === 'password') {
// 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); $response->setStatusCode(400);
return self::error('unauthorized_client', 'This application is not allowed to use this grant type.'); return self::error('unauthorized_client', 'This application is not allowed to use this grant type.');
} }
$userName = (string)$content->getParam('username'); if(!$appAuthenticated) {
$password = (string)$content->getParam('password'); $response->setStatusCode(400);
return self::error('invalid_client', 'Application must authenticate with client secret in order to use this grant type.');
}
$scopes = self::filterScopes((string)$content->getParam('scope'));
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.');
}
$scope = implode(' ', $scopes);
$accessInfo = $this->oauth2Ctx->getTokensData()->createAccess($appInfo, scope: $scope);
// i'll just unset it, really need to dive deeper into the scope sitch
unset($scope);
} elseif($type === 'device_code' || $type === 'urn:ietf:params:oauth:grant-type:device_code') { } elseif($type === 'device_code' || $type === 'urn:ietf:params:oauth:grant-type:device_code') {
$deviceCode = (string)$content->getParam('device_code'); $deviceCode = (string)$content->getParam('device_code');
@ -477,29 +503,22 @@ final class OAuth2Routes extends RouteHandler {
if(empty($accessInfo)) { if(empty($accessInfo)) {
$response->setStatusCode(400); $response->setStatusCode(400);
return self::error('invalid_grant', 'Failed to request access token.');
if($type === 'refresh_token')
$message = 'Failed to request new access token. Provided refresh token has likely expired or is invalid.';
elseif($type === 'password')
$message = 'Failed to request access token, user name or password was incorrect.';
elseif($type === 'client_credentials')
$message = 'Failed to request application access token.';
else
$message = 'Failed to request access token.';
return self::error('invalid_grant', $message);
} }
$result = [ $result = [
'access_token' => $accessInfo->getToken(), 'access_token' => $accessInfo->getToken(),
'token_type' => 'Bearer', 'token_type' => 'Bearer',
'expires_in' => $accessInfo->getRemainingLifetime(),
]; ];
if(isset($scopes)) $expiresIn = $accessInfo->getRemainingLifetime();
$result['scope'] = implode(' ', $scopes); if($expiresIn < 3600)
$result['expires_in'] = $expiresIn;
if(empty($refreshInfo)) if(isset($scope))
$result['scope'] = $scope;
if(!empty($refreshInfo))
$result['refresh_token'] = $refreshInfo->getToken(); $result['refresh_token'] = $refreshInfo->getToken();
return $result; return $result;

View file

@ -6,6 +6,7 @@ use RuntimeException;
use Index\XString; use Index\XString;
use Index\Data\DbStatementCache; use Index\Data\DbStatementCache;
use Index\Data\IDbConnection; use Index\Data\IDbConnection;
use Hanyuu\Apps\AppInfo;
class OAuth2TokensData { class OAuth2TokensData {
private IDbConnection $dbConn; private IDbConnection $dbConn;
@ -16,4 +17,172 @@ class OAuth2TokensData {
$this->cache = new DbStatementCache($dbConn); $this->cache = new DbStatementCache($dbConn);
} }
public const ACCESS_BY_ID = 'id';
public const ACCESS_BY_TOKEN = 'token';
public function getAccessInfo(string $value, string $select): OAuth2AccessInfo {
if($select === self::ACCESS_BY_ID)
$select = 'acc_id';
elseif($select === self::ACCESS_BY_TOKEN)
$select = 'acc_token';
else
throw new InvalidArgumentException('$select is not a valid select mode');
$stmt = $this->cache->get(sprintf(
'SELECT acc_id, app_id, user_id, acc_token, acc_scope, UNIX_TIMESTAMP(acc_created), UNIX_TIMESTAMP(acc_expires) FROM hau_oauth2_access WHERE %s = ?',
$select
));
$stmt->addParameter(1, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Access info not found.');
return OAuth2AccessInfo::fromResult($result);
}
public function createAccess(
AppInfo|string $appInfo,
?string $userId = null,
?string $token = null,
string $scope = '',
?int $lifetime = null
): OAuth2AccessInfo {
$token ??= XString::random(80);
$stmt = $this->cache->get(sprintf('INSERT INTO hau_oauth2_access (app_id, user_id, acc_token, acc_scope, acc_expires) VALUES (?, ?, ?, ?, IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(acc_expires)))'));
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->addParameter(2, $userId);
$stmt->addParameter(3, $token);
$stmt->addParameter(4, $scope);
$stmt->addParameter(5, $lifetime === null ? 0 : 1);
$stmt->addParameter(6, $lifetime);
$stmt->execute();
return $this->getAccessInfo((string)$this->dbConn->getLastInsertId(), self::ACCESS_BY_ID);
}
public function deleteAccess(
OAuth2AccessInfo|string|null $accessInfo = null,
AppInfo|string|null $appInfo = null,
?string $userId = null,
): void {
$selectors = [];
$values = [];
if($accessInfo !== null) {
$selectors[] = 'acc_id = ?';
$values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->getId() : $accessInfo;
}
if($appInfo !== null) {
$selectors[] = 'app_id = ?';
$values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo;
}
if($userId !== null) {
$selectors[] = 'user_id = ?';
$values[] = $userId;
}
$query = 'DELETE FROM hau_oauth2_access';
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 const REFRESH_BY_ID = 'id';
public const REFRESH_BY_ACCESS = 'access';
public const REFRESH_BY_TOKEN = 'token';
public function getRefreshInfo(object|string $value, string $select): OAuth2RefreshInfo {
if($select === self::REFRESH_BY_ID) {
$select = 'ref_id';
} elseif($select === self::REFRESH_BY_ACCESS) {
$select = 'acc_id';
if($value instanceof OAuth2AccessInfo)
$value = $value->getId();
} elseif($select === self::REFRESH_BY_TOKEN) {
$select = 'ref_token';
} else
throw new InvalidArgumentException('$select is not a valid select mode');
if(!is_string($value))
throw new InvalidArgumentException('$value must be a string');
$stmt = $this->cache->get(sprintf(
'SELECT ref_id, app_id, user_id, acc_id, ref_token, ref_scope, UNIX_TIMESTAMP(ref_created), UNIX_TIMESTAMP(ref_expires) FROM hau_oauth2_refresh WHERE %s = ?',
$select
));
$stmt->addParameter(1, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Refresh info not found.');
return OAuth2RefreshInfo::fromResult($result);
}
public function createRefresh(
AppInfo|string $appInfo,
OAuth2AccessInfo|string|null $accessInfo,
?string $userId = null,
?string $token = null,
string $scope = '',
?int $lifetime = null
): OAuth2RefreshInfo {
$token ??= XString::random(120);
$stmt = $this->cache->get(sprintf('INSERT INTO hau_oauth2_refresh (app_id, user_id, acc_id, ref_token, ref_scope, ref_expires) VALUES (?, ?, ?, ?, ?, IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(ref_expires)))'));
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
$stmt->addParameter(2, $userId);
$stmt->addParameter(3, $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->getId() : $accessInfo);
$stmt->addParameter(4, $token);
$stmt->addParameter(5, $scope);
$stmt->addParameter(6, $lifetime === null ? 0 : 1);
$stmt->addParameter(7, $lifetime);
$stmt->execute();
return $this->getRefreshInfo((string)$this->dbConn->getLastInsertId(), self::REFRESH_BY_ID);
}
public function deleteRefresh(
OAuth2RefreshInfo|string|null $refreshInfo = null,
AppInfo|string|null $appInfo = null,
?string $userId = null,
OAuth2AccessInfo|string|null $accessInfo = null
): void {
$selectors = [];
$values = [];
if($refreshInfo !== null) {
$selectors[] = 'ref_id = ?';
$values[] = $refreshInfo instanceof OAuth2RefreshInfo ? $refreshInfo->getId() : $refreshInfo;
}
if($appInfo !== null) {
$selectors[] = 'app_id = ?';
$values[] = $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo;
}
if($userId !== null) {
$selectors[] = 'user_id = ?';
$values[] = $userId;
}
if($accessInfo !== null) {
$selectors[] = 'acc_id = ?';
$values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->getId() : $accessInfo;
}
$query = 'DELETE FROM hau_oauth2_refresh';
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();
}
} }