From acbe9ebf9e8755b90413f8b8d3dbc338ccd597e9 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 8 Jun 2021 06:32:24 +0000 Subject: [PATCH] API: New classes for OAuth and basic auth --- include/api.php | 4 +- src/Module/Api/Friendica/Events/Index.php | 7 +- src/Module/Api/Friendica/Profile/Show.php | 11 +- src/Module/Api/Twitter/ContactEndpoint.php | 4 +- src/Module/BaseApi.php | 185 +++--------------- src/Module/OAuth/Authorize.php | 7 +- src/Module/OAuth/Token.php | 5 +- src/Security/BasicAuth.php | 85 ++++++++ src/Security/OAuth.php | 216 +++++++++++++++++++++ 9 files changed, 346 insertions(+), 178 deletions(-) create mode 100644 src/Security/BasicAuth.php create mode 100644 src/Security/OAuth.php diff --git a/include/api.php b/include/api.php index 935bd11d63..421086002a 100644 --- a/include/api.php +++ b/include/api.php @@ -44,7 +44,6 @@ use Friendica\Model\Photo; use Friendica\Model\Post; use Friendica\Model\User; use Friendica\Model\Verb; -use Friendica\Module\BaseApi; use Friendica\Network\HTTPException; use Friendica\Network\HTTPException\BadRequestException; use Friendica\Network\HTTPException\ExpectationFailedException; @@ -58,6 +57,7 @@ use Friendica\Object\Image; use Friendica\Protocol\Activity; use Friendica\Protocol\Diaspora; use Friendica\Security\FKOAuth1; +use Friendica\Security\OAuth; use Friendica\Security\OAuth1\OAuthRequest; use Friendica\Security\OAuth1\OAuthUtil; use Friendica\Util\DateTimeFormat; @@ -89,7 +89,7 @@ $called_api = []; */ function api_user() { - $user = BaseApi::getCurrentUserID(true); + $user = OAuth::getCurrentUserID(); if (!empty($user)) { return $user; } diff --git a/src/Module/Api/Friendica/Events/Index.php b/src/Module/Api/Friendica/Events/Index.php index 53408541a2..c52d1581e7 100644 --- a/src/Module/Api/Friendica/Events/Index.php +++ b/src/Module/Api/Friendica/Events/Index.php @@ -35,16 +35,15 @@ class Index extends BaseApi { public static function rawContent(array $parameters = []) { - if (self::login(self::SCOPE_READ) === false) { - throw new HTTPException\ForbiddenException(); - } + self::login(self::SCOPE_READ); + $uid = self::getCurrentUserID(); $request = self::getRequest([ 'since_id' => 0, 'count' => 0, ]); - $condition = ["`id` > ? AND `uid` = ?", $request['since_id'], self::$current_user_id]; + $condition = ["`id` > ? AND `uid` = ?", $request['since_id'], $uid]; $params = ['limit' => $request['count']]; $events = DBA::selectToArray('event', [], $condition, $params); diff --git a/src/Module/Api/Friendica/Profile/Show.php b/src/Module/Api/Friendica/Profile/Show.php index e550f839cc..0e38bb02e5 100644 --- a/src/Module/Api/Friendica/Profile/Show.php +++ b/src/Module/Api/Friendica/Profile/Show.php @@ -37,16 +37,15 @@ class Show extends BaseApi { public static function rawContent(array $parameters = []) { - if (self::login(self::SCOPE_READ) === false) { - throw new HTTPException\ForbiddenException(); - } + self::login(self::SCOPE_READ); + $uid = self::getCurrentUserID(); // retrieve general information about profiles for user $directory = DI::config()->get('system', 'directory'); - $profile = Profile::getByUID(self::$current_user_id); + $profile = Profile::getByUID($uid); - $profileFields = DI::profileField()->select(['uid' => self::$current_user_id, 'psid' => PermissionSet::PUBLIC]); + $profileFields = DI::profileField()->select(['uid' => $uid, 'psid' => PermissionSet::PUBLIC]); $profile = self::formatProfile($profile, $profileFields); @@ -58,7 +57,7 @@ class Show extends BaseApi } // return settings, authenticated user and profiles data - $self = Contact::selectFirst(['nurl'], ['uid' => self::$current_user_id, 'self' => true]); + $self = Contact::selectFirst(['nurl'], ['uid' => $uid, 'self' => true]); $result = [ 'multi_profiles' => false, diff --git a/src/Module/Api/Twitter/ContactEndpoint.php b/src/Module/Api/Twitter/ContactEndpoint.php index 3231d8b132..d18b485b8b 100644 --- a/src/Module/Api/Twitter/ContactEndpoint.php +++ b/src/Module/Api/Twitter/ContactEndpoint.php @@ -54,7 +54,7 @@ abstract class ContactEndpoint extends BaseApi */ protected static function getUid(int $contact_id = null, string $screen_name = null) { - $uid = self::$current_user_id; + $uid = self::getCurrentUserID(); if ($contact_id || $screen_name) { // screen_name trumps user_id when both are provided @@ -129,7 +129,7 @@ abstract class ContactEndpoint extends BaseApi protected static function ids($rel, int $uid, int $cursor = -1, int $count = self::DEFAULT_COUNT, bool $stringify_ids = false) { $hide_friends = false; - if ($uid != self::$current_user_id) { + if ($uid != self::getCurrentUserID()) { $profile = Profile::getByUID($uid); if (empty($profile)) { throw new HTTPException\NotFoundException(DI::l10n()->t('Profile not found')); diff --git a/src/Module/BaseApi.php b/src/Module/BaseApi.php index 015324569b..e629ad9910 100644 --- a/src/Module/BaseApi.php +++ b/src/Module/BaseApi.php @@ -24,34 +24,29 @@ namespace Friendica\Module; use Friendica\BaseModule; use Friendica\Core\Logger; use Friendica\Core\System; -use Friendica\Database\Database; -use Friendica\Database\DBA; use Friendica\DI; use Friendica\Network\HTTPException; -use Friendica\Util\DateTimeFormat; +use Friendica\Security\BasicAuth; +use Friendica\Security\OAuth; use Friendica\Util\HTTPInputData; require_once __DIR__ . '/../../include/api.php'; class BaseApi extends BaseModule { + /** @deprecated Use OAuth class constant */ const SCOPE_READ = 'read'; + /** @deprecated Use OAuth class constant */ const SCOPE_WRITE = 'write'; + /** @deprecated Use OAuth class constant */ const SCOPE_FOLLOW = 'follow'; + /** @deprecated Use OAuth class constant */ const SCOPE_PUSH = 'push'; /** * @var string json|xml|rss|atom */ protected static $format = 'json'; - /** - * @var bool|int - */ - protected static $current_user_id; - /** - * @var array - */ - protected static $current_token = []; public static function init(array $parameters = []) { @@ -184,7 +179,6 @@ class BaseApi extends BaseModule * * @param string $scope the requested scope (read, write, follow) * - * @return bool Was a user authenticated? * @throws HTTPException\ForbiddenException * @throws HTTPException\UnauthorizedException * @throws HTTPException\InternalServerErrorException @@ -197,40 +191,34 @@ class BaseApi extends BaseModule */ protected static function login(string $scope) { - if (empty(self::$current_user_id)) { - self::$current_token = self::getTokenByBearer(); - if (!empty(self::$current_token['uid'])) { - self::$current_user_id = self::$current_token['uid']; - } else { - self::$current_user_id = 0; - } - } - - if (!empty($scope) && !empty(self::$current_token)) { - if (empty(self::$current_token[$scope])) { - Logger::warning('The requested scope is not allowed', ['scope' => $scope, 'application' => self::$current_token]); + $token = OAuth::getCurrentApplicationToken(); + if (!empty($token)) { + if (!OAuth::isAllowedScope($scope)) { DI::mstdnError()->Forbidden(); } + $uid = OAuth::getCurrentUserID(); } - if (empty(self::$current_user_id)) { + if (empty($uid)) { // The execution stops here if no one is logged in - api_login(DI::app()); + BasicAuth::getCurrentUserID(true); } - - self::$current_user_id = api_user(); - - return (bool)self::$current_user_id; } /** - * Get current application + * Get current application token * * @return array token */ protected static function getCurrentApplication() { - return self::$current_token; + $token = OAuth::getCurrentApplicationToken(); + + if (empty($token)) { + $token = BasicAuth::getCurrentApplicationToken(); + } + + return $token; } /** @@ -238,136 +226,15 @@ class BaseApi extends BaseModule * * @return int User ID */ - public static function getCurrentUserID(bool $nologin = false) + public static function getCurrentUserID() { - if (empty(self::$current_user_id)) { - self::$current_token = self::getTokenByBearer(); - if (!empty(self::$current_token['uid'])) { - self::$current_user_id = self::$current_token['uid']; - } else { - self::$current_user_id = 0; - } + $uid = OAuth::getCurrentUserID(); + + if (empty($uid)) { + $uid = BasicAuth::getCurrentUserID(false); } - if ($nologin) { - return (int)self::$current_user_id; - } - - if (empty(self::$current_user_id)) { - // Fetch the user id if logged in - but don't fail if not - api_login(DI::app(), false); - - self::$current_user_id = api_user(); - } - - return (int)self::$current_user_id; - } - - /** - * Get the user token via the Bearer token - * - * @return array User Token - */ - private static function getTokenByBearer() - { - $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; - - if (substr($authorization, 0, 7) != 'Bearer ') { - return []; - } - - $bearer = trim(substr($authorization, 7)); - $condition = ['access_token' => $bearer]; - $token = DBA::selectFirst('application-view', ['uid', 'id', 'name', 'website', 'created_at', 'read', 'write', 'follow', 'push'], $condition); - if (!DBA::isResult($token)) { - Logger::warning('Token not found', $condition); - return []; - } - Logger::debug('Token found', $token); - return $token; - } - - /** - * Get the application record via the proved request header fields - * - * @param string $client_id - * @param string $client_secret - * @param string $redirect_uri - * @return array application record - */ - public static function getApplication(string $client_id, string $client_secret, string $redirect_uri) - { - $condition = ['client_id' => $client_id]; - if (!empty($client_secret)) { - $condition['client_secret'] = $client_secret; - } - if (!empty($redirect_uri)) { - $condition['redirect_uri'] = $redirect_uri; - } - - $application = DBA::selectFirst('application', [], $condition); - if (!DBA::isResult($application)) { - Logger::warning('Application not found', $condition); - return []; - } - return $application; - } - - /** - * Check if an token for the application and user exists - * - * @param array $application - * @param integer $uid - * @return boolean - */ - public static function existsTokenForUser(array $application, int $uid) - { - return DBA::exists('application-token', ['application-id' => $application['id'], 'uid' => $uid]); - } - - /** - * Fetch the token for the given application and user - * - * @param array $application - * @param integer $uid - * @return array application record - */ - public static function getTokenForUser(array $application, int $uid) - { - return DBA::selectFirst('application-token', [], ['application-id' => $application['id'], 'uid' => $uid]); - } - - /** - * Create and fetch an token for the application and user - * - * @param array $application - * @param integer $uid - * @param string $scope - * @return array application record - */ - public static function createTokenForUser(array $application, int $uid, string $scope) - { - $code = bin2hex(random_bytes(32)); - $access_token = bin2hex(random_bytes(32)); - - $fields = ['application-id' => $application['id'], 'uid' => $uid, 'code' => $code, 'access_token' => $access_token, 'scopes' => $scope, - 'read' => (stripos($scope, self::SCOPE_READ) !== false), - 'write' => (stripos($scope, self::SCOPE_WRITE) !== false), - 'follow' => (stripos($scope, self::SCOPE_FOLLOW) !== false), - 'push' => (stripos($scope, self::SCOPE_PUSH) !== false), - 'created_at' => DateTimeFormat::utcNow(DateTimeFormat::MYSQL)]; - - foreach ([self::SCOPE_READ, self::SCOPE_WRITE, self::SCOPE_WRITE, self::SCOPE_PUSH] as $scope) { - if ($fields[$scope] && !$application[$scope]) { - Logger::warning('Requested token scope is not allowed for the application', ['token' => $fields, 'application' => $application]); - } - } - - if (!DBA::insert('application-token', $fields, Database::INSERT_UPDATE)) { - return []; - } - - return DBA::selectFirst('application-token', [], ['application-id' => $application['id'], 'uid' => $uid]); + return (int)$uid; } /** diff --git a/src/Module/OAuth/Authorize.php b/src/Module/OAuth/Authorize.php index a46c4c7ac5..3fcee92469 100644 --- a/src/Module/OAuth/Authorize.php +++ b/src/Module/OAuth/Authorize.php @@ -24,6 +24,7 @@ namespace Friendica\Module\OAuth; use Friendica\Core\Logger; use Friendica\DI; use Friendica\Module\BaseApi; +use Friendica\Security\OAuth; /** * @see https://docs.joinmastodon.org/spec/oauth/ @@ -56,7 +57,7 @@ class Authorize extends BaseApi DI::mstdnError()->UnprocessableEntity(DI::l10n()->t('Incomplete request data')); } - $application = self::getApplication($request['client_id'], $request['client_secret'], $request['redirect_uri']); + $application = OAuth::getApplication($request['client_id'], $request['client_secret'], $request['redirect_uri']); if (empty($application)) { DI::mstdnError()->UnprocessableEntity(); } @@ -75,14 +76,14 @@ class Authorize extends BaseApi Logger::info('Already logged in user', ['uid' => $uid]); } - if (!self::existsTokenForUser($application, $uid) && !DI::session()->get('oauth_acknowledge')) { + if (!OAuth::existsTokenForUser($application, $uid) && !DI::session()->get('oauth_acknowledge')) { Logger::info('Redirect to acknowledge'); DI::app()->redirect('oauth/acknowledge?' . http_build_query(['return_path' => $redirect, 'application' => $application['name']])); } DI::session()->remove('oauth_acknowledge'); - $token = self::createTokenForUser($application, $uid, $request['scope']); + $token = OAuth::createTokenForUser($application, $uid, $request['scope']); if (!$token) { DI::mstdnError()->UnprocessableEntity(); } diff --git a/src/Module/OAuth/Token.php b/src/Module/OAuth/Token.php index 780fc7cea6..3a8be825ff 100644 --- a/src/Module/OAuth/Token.php +++ b/src/Module/OAuth/Token.php @@ -26,6 +26,7 @@ use Friendica\Core\System; use Friendica\Database\DBA; use Friendica\DI; use Friendica\Module\BaseApi; +use Friendica\Security\OAuth; /** * @see https://docs.joinmastodon.org/spec/oauth/ @@ -57,7 +58,7 @@ class Token extends BaseApi DI::mstdnError()->UnprocessableEntity(DI::l10n()->t('Incomplete request data')); } - $application = self::getApplication($request['client_id'], $request['client_secret'], $request['redirect_uri']); + $application = OAuth::getApplication($request['client_id'], $request['client_secret'], $request['redirect_uri']); if (empty($application)) { DI::mstdnError()->UnprocessableEntity(); } @@ -65,7 +66,7 @@ class Token extends BaseApi if ($request['grant_type'] == 'client_credentials') { // the "client_credentials" are used as a token for the application itself. // see https://aaronparecki.com/oauth-2-simplified/#client-credentials - $token = self::createTokenForUser($application, 0, ''); + $token = OAuth::createTokenForUser($application, 0, ''); } elseif ($request['grant_type'] == 'authorization_code') { // For security reasons only allow freshly created tokens $condition = ["`redirect_uri` = ? AND `id` = ? AND `code` = ? AND `created_at` > UTC_TIMESTAMP() - INTERVAL ? MINUTE", diff --git a/src/Security/BasicAuth.php b/src/Security/BasicAuth.php new file mode 100644 index 0000000000..002ecd6eb3 --- /dev/null +++ b/src/Security/BasicAuth.php @@ -0,0 +1,85 @@ +. + * + */ + +namespace Friendica\Security; + +use Friendica\Database\DBA; +use Friendica\DI; + +/** + * Authentification via the basic auth method + */ +class BasicAuth +{ + /** + * @var bool|int + */ + protected static $current_user_id = 0; + /** + * @var array + */ + protected static $current_token = []; + + /** + * Fetch a dummy application token + * + * @return array token + */ + public static function getCurrentApplicationToken() + { + if (empty(self::getCurrentUserID())) { + return []; + } + + if (!empty(self::$current_token)) { + return self::$current_token; + } + + self::$current_token = [ + 'uid' => self::$current_user_id, + 'id' => 0, + 'name' => api_source(), + 'website' => '', + 'created_at' => DBA::NULL_DATETIME, + 'read' => true, + 'write' => true, + 'follow' => true, + 'push' => false]; + + return self::$current_token; + } + + /** + * Get current user id, returns 0 if not logged in + * + * @return int User ID + */ + public static function getCurrentUserID(bool $login = true) + { + if (empty(self::$current_user_id)) { + api_login(DI::app(), $login); + + self::$current_user_id = api_user(); + } + + return (int)self::$current_user_id; + } +} diff --git a/src/Security/OAuth.php b/src/Security/OAuth.php new file mode 100644 index 0000000000..7aac406ba7 --- /dev/null +++ b/src/Security/OAuth.php @@ -0,0 +1,216 @@ +. + * + */ + +namespace Friendica\Security; + +use Friendica\Core\Logger; +use Friendica\Database\Database; +use Friendica\Database\DBA; +use Friendica\Util\DateTimeFormat; + +/** + * OAuth Server + */ +class OAuth +{ + const SCOPE_READ = 'read'; + const SCOPE_WRITE = 'write'; + const SCOPE_FOLLOW = 'follow'; + const SCOPE_PUSH = 'push'; + + /** + * @var bool|int + */ + protected static $current_user_id = 0; + /** + * @var array + */ + protected static $current_token = []; + + /** + * Check if the provided scope does exist + * + * @param string $scope the requested scope (read, write, follow, push) + * + * @return bool "true" if the scope is allowed + */ + public static function isAllowedScope(string $scope) + { + $token = self::getCurrentApplicationToken(); + + if (empty($token)) { + Logger::notice('Empty application token'); + return false; + } + + if (!isset($token[$scope])) { + Logger::warning('The requested scope does not exist', ['scope' => $scope, 'application' => $token]); + return false; + } + + if (empty($token[$scope])) { + Logger::warning('The requested scope is not allowed', ['scope' => $scope, 'application' => $token]); + return false; + } + + return true; + } + + /** + * Get current application token + * + * @return array token + */ + public static function getCurrentApplicationToken() + { + if (empty(self::$current_token)) { + self::$current_token = self::getTokenByBearer(); + } + + return self::$current_token; + } + + /** + * Get current user id, returns 0 if not logged in + * + * @return int User ID + */ + public static function getCurrentUserID() + { + if (empty(self::$current_user_id)) { + $token = self::getCurrentApplicationToken(); + if (!empty($token['uid'])) { + self::$current_user_id = $token['uid']; + } else { + self::$current_user_id = 0; + } + } + + return (int)self::$current_user_id; + } + + /** + * Get the user token via the Bearer token + * + * @return array User Token + */ + private static function getTokenByBearer() + { + $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + + if (substr($authorization, 0, 7) != 'Bearer ') { + return []; + } + + $bearer = trim(substr($authorization, 7)); + $condition = ['access_token' => $bearer]; + $token = DBA::selectFirst('application-view', ['uid', 'id', 'name', 'website', 'created_at', 'read', 'write', 'follow', 'push'], $condition); + if (!DBA::isResult($token)) { + Logger::warning('Token not found', $condition); + return []; + } + Logger::debug('Token found', $token); + return $token; + } + + /** + * Get the application record via the provided request header fields + * + * @param string $client_id + * @param string $client_secret + * @param string $redirect_uri + * @return array application record + */ + public static function getApplication(string $client_id, string $client_secret, string $redirect_uri) + { + $condition = ['client_id' => $client_id]; + if (!empty($client_secret)) { + $condition['client_secret'] = $client_secret; + } + if (!empty($redirect_uri)) { + $condition['redirect_uri'] = $redirect_uri; + } + + $application = DBA::selectFirst('application', [], $condition); + if (!DBA::isResult($application)) { + Logger::warning('Application not found', $condition); + return []; + } + return $application; + } + + /** + * Check if an token for the application and user exists + * + * @param array $application + * @param integer $uid + * @return boolean + */ + public static function existsTokenForUser(array $application, int $uid) + { + return DBA::exists('application-token', ['application-id' => $application['id'], 'uid' => $uid]); + } + + /** + * Fetch the token for the given application and user + * + * @param array $application + * @param integer $uid + * @return array application record + */ + public static function getTokenForUser(array $application, int $uid) + { + return DBA::selectFirst('application-token', [], ['application-id' => $application['id'], 'uid' => $uid]); + } + + /** + * Create and fetch an token for the application and user + * + * @param array $application + * @param integer $uid + * @param string $scope + * @return array application record + */ + public static function createTokenForUser(array $application, int $uid, string $scope) + { + $code = bin2hex(random_bytes(32)); + $access_token = bin2hex(random_bytes(32)); + + $fields = ['application-id' => $application['id'], 'uid' => $uid, 'code' => $code, 'access_token' => $access_token, 'scopes' => $scope, + 'read' => (stripos($scope, self::SCOPE_READ) !== false), + 'write' => (stripos($scope, self::SCOPE_WRITE) !== false), + 'follow' => (stripos($scope, self::SCOPE_FOLLOW) !== false), + 'push' => (stripos($scope, self::SCOPE_PUSH) !== false), + 'created_at' => DateTimeFormat::utcNow(DateTimeFormat::MYSQL)]; + + foreach ([self::SCOPE_READ, self::SCOPE_WRITE, self::SCOPE_WRITE, self::SCOPE_PUSH] as $scope) { + if ($fields[$scope] && !$application[$scope]) { + Logger::warning('Requested token scope is not allowed for the application', ['token' => $fields, 'application' => $application]); + } + } + + if (!DBA::insert('application-token', $fields, Database::INSERT_UPDATE)) { + return []; + } + + return DBA::selectFirst('application-token', [], ['application-id' => $application['id'], 'uid' => $uid]); + } +}