diff options
Diffstat (limited to 'OAuth/src')
75 files changed, 12780 insertions, 0 deletions
diff --git a/OAuth/src/AuthorizationProvider/AccessToken.php b/OAuth/src/AuthorizationProvider/AccessToken.php new file mode 100644 index 00000000..de4fd0e2 --- /dev/null +++ b/OAuth/src/AuthorizationProvider/AccessToken.php @@ -0,0 +1,35 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider; + +use League\OAuth2\Server\Exception\OAuthServerException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +abstract class AccessToken extends AuthorizationProvider implements IAccessTokenProvider { + + /** + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * + * @return ResponseInterface + * @throws OAuthServerException + */ + public function getAccessTokens( + ServerRequestInterface $request, ResponseInterface $response + ): ResponseInterface { + $this->logAccessTokenRequest( $request ); + return $this->server->respondToAccessTokenRequest( $request, $response ); + } + + /** + * @param ServerRequestInterface $request + */ + protected function logAccessTokenRequest( ServerRequestInterface $request ) { + $this->logger->info( + "OAuth2: Access token request - Grant type {grant}, client id: {client}", [ + 'grant' => $this->getGrantSingleton()->getIdentifier(), + 'client' => $this->getClientIdFromRequest( $request ) + ] ); + } +} diff --git a/OAuth/src/AuthorizationProvider/AuthorizationProvider.php b/OAuth/src/AuthorizationProvider/AuthorizationProvider.php new file mode 100644 index 00000000..177fbaff --- /dev/null +++ b/OAuth/src/AuthorizationProvider/AuthorizationProvider.php @@ -0,0 +1,178 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider; + +use Config; +use DateInterval; +use Exception; +use League\OAuth2\Server\AuthorizationServer; +use League\OAuth2\Server\Grant\GrantTypeInterface; +use MediaWiki\Extensions\OAuth\AuthorizationServerFactory; +use MediaWiki\Extensions\OAuth\Repository\AuthCodeRepository; +use MediaWiki\Extensions\OAuth\Repository\RefreshTokenRepository; +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Log\LoggerInterface; +use User; + +abstract class AuthorizationProvider implements IAuthorizationProvider { + /** + * @var AuthorizationServer + */ + protected $server; + + /** + * @var Config|null + */ + protected $config; + + /** + * @var User + */ + protected $user; + + /** + * @var LoggerInterface + */ + protected $logger; + + /** + * @var GrantTypeInterface + */ + protected $grant; + + /** + * @return AuthorizationProvider + * @throws Exception + */ + public static function factory() { + $services = MediaWikiServices::getInstance(); + $config = $services->getConfigFactory()->makeConfig( 'mwoauth' ); + $serverFactory = AuthorizationServerFactory::factory(); + $logger = LoggerFactory::getInstance( 'OAuth' ); + + // @phan-suppress-next-line PhanTypeInstantiateAbstractStatic + return new static( $config, $serverFactory->getAuthorizationServer(), $logger ); + } + + /** + * @param Config $config + * @param AuthorizationServer $server + * @param LoggerInterface $logger + * @throws Exception + */ + public function __construct( $config, $server, $logger ) { + $this->config = $config; + $this->server = $server; + $this->logger = $logger; + + $this->decorateAuthServer(); + } + + /** + * @inheritDoc + */ + public function setUser( User $user ) { + $this->user = $user; + } + + /** + * @inheritDoc + */ + public function needsUserApproval() { + return false; + } + + /** + * @return GrantTypeInterface + */ + abstract protected function getGrant() : GrantTypeInterface; + + /** + * @return GrantTypeInterface + */ + protected function getGrantSingleton() { + if ( !$this->grant ) { + $this->grant = $this->getGrant(); + } + + return $this->grant; + } + + /** + * @throws Exception + */ + protected function decorateAuthServer() { + $grant = $this->getGrantSingleton(); + $grant->setRefreshTokenTTL( $this->getRefreshTokenTTL() ); + $this->server->setDefaultScope( '#default' ); + $this->server->enableGrantType( + $grant, + $this->getGrantExpirationInterval() + ); + } + + /** + * @return RefreshTokenRepository + */ + protected function getRefreshTokenRepo() { + /** @var RefreshTokenRepository $repo */ + $repo = RefreshTokenRepository::factory(); + return $repo; + } + + /** + * @return AuthCodeRepository + */ + protected function getAuthCodeRepo() { + /** @var AuthCodeRepository $repo */ + $repo = AuthCodeRepository::factory(); + return $repo; + } + + /** + * @return DateInterval + * @throws Exception + */ + protected function getGrantExpirationInterval() { + $intervalSpec = 'PT1H'; + if ( $this->config->has( 'OAuth2GrantExpirationInterval' ) ) { + $intervalSpec = $this->parseExpiration( $this->config->get( 'OAuth2GrantExpirationInterval' ) ); + } + return new DateInterval( $intervalSpec ); + } + + /** + * @return DateInterval + * @throws Exception + */ + protected function getRefreshTokenTTL() { + $intervalSpec = 'PT1M'; + if ( $this->config->has( 'OAuth2RefreshTokenTTL' ) ) { + $intervalSpec = $this->parseExpiration( $this->config->get( 'OAuth2RefreshTokenTTL' ) ); + } + + return new DateInterval( $intervalSpec ); + } + + /** + * @param ServerRequestInterface $request + * @param string $default + * @return mixed|string + */ + protected function getClientIdFromRequest( ServerRequestInterface $request, $default = '' ) { + $params = (array)$request->getParsedBody(); + + return $params['client_id'] ?? $default; + } + + private function parseExpiration( $expiration ) { + if ( $expiration === false || $expiration === 'infinity' ) { + // Effectively non-expiring tokens + $expiration = 'P292277000000Y'; + } + + return $expiration; + } +} diff --git a/OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAccessTokens.php b/OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAccessTokens.php new file mode 100644 index 00000000..ab2d7aac --- /dev/null +++ b/OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAccessTokens.php @@ -0,0 +1,27 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant; + +use DateInterval; +use Exception; +use League\OAuth2\Server\Grant\AuthCodeGrant; +use League\OAuth2\Server\Grant\GrantTypeInterface; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\AccessToken; + +class AuthorizationCodeAccessTokens extends AccessToken { + + /** + * @return GrantTypeInterface + * @throws Exception + */ + protected function getGrant(): GrantTypeInterface { + $authCodeRepo = $this->getAuthCodeRepo(); + $refreshTokenRepo = $this->getRefreshTokenRepo(); + $grant = new AuthCodeGrant( $authCodeRepo, $refreshTokenRepo, new DateInterval( 'PT10M' ) ); + if ( !$this->config->get( 'OAuth2RequireCodeChallengeForPublicClients' ) ) { + $grant->disableRequireCodeChallengeForPublicClients(); + } + + return $grant; + } +} diff --git a/OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAuthorization.php b/OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAuthorization.php new file mode 100644 index 00000000..f1faab83 --- /dev/null +++ b/OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAuthorization.php @@ -0,0 +1,100 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant; + +use DateInterval; +use Exception; +use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\Grant\AuthCodeGrant; +use League\OAuth2\Server\Grant\GrantTypeInterface; +use League\OAuth2\Server\RequestTypes\AuthorizationRequest; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\AuthorizationProvider; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; +use MediaWiki\Extensions\OAuth\Entity\UserEntity; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +class AuthorizationCodeAuthorization extends AuthorizationProvider { + + /** + * @inheritDoc + */ + public function needsUserApproval() { + return true; + } + + /** + * @return GrantTypeInterface + * @throws Exception + */ + protected function getGrant(): GrantTypeInterface { + $authCodeRepo = $this->getAuthCodeRepo(); + $refreshTokenRepo = $this->getRefreshTokenRepo(); + $grant = new AuthCodeGrant( + $authCodeRepo, $refreshTokenRepo, new DateInterval( 'PT10M' ) + ); + if ( !$this->config->get( 'OAuth2RequireCodeChallengeForPublicClients' ) ) { + $grant->disableRequireCodeChallengeForPublicClients(); + } + + return $grant; + } + + /** + * @param ServerRequestInterface $request + * @return AuthorizationRequest + * @throws OAuthServerException + */ + public function init( ServerRequestInterface $request ): AuthorizationRequest { + $authRequest = $this->server->validateAuthorizationRequest( $request ); + /** @var ClientEntity $client */ + $client = $authRequest->getClient(); + '@phan-var ClientEntity $client'; + + if ( !$client->isUsableBy( $this->user ) ) { + throw OAuthServerException::accessDenied( + 'Client ' . $client->getIdentifier() . + ' is not usable by user with ID ' . $this->user->getId() + ); + } + $userEntity = UserEntity::newFromMWUser( $this->user ); + $authRequest->setUser( $userEntity ); + $this->logAuthorizationRequest( __METHOD__, $authRequest ); + + $this->logger->info( + "OAuth2: Starting authorization request for client {client} and user (id) {user} ", [ + 'client' => $authRequest->getClient()->getIdentifier(), + 'user' => $authRequest->getUser()->getIdentifier() + ] + ); + + return $authRequest; + } + + /** + * @param AuthorizationRequest $authRequest + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function authorize( + AuthorizationRequest $authRequest, ResponseInterface $response + ): ResponseInterface { + $this->logAuthorizationRequest( __METHOD__, $authRequest ); + return $this->server->completeAuthorizationRequest( $authRequest, $response ); + } + + /** + * @param string $method + * @param AuthorizationRequest $authRequest + */ + protected function logAuthorizationRequest( $method, AuthorizationRequest $authRequest ) { + $this->logger->info( + "OAuth2: Authorization request, func {func}, for client {client} " . + "and user (id) {user} using grant \"{grant}\"", [ + 'func' => $method, + 'client' => $authRequest->getClient()->getIdentifier(), + 'user' => $authRequest->getUser()->getIdentifier(), + 'grant' => $authRequest->getGrantTypeId() + ] ); + } +} diff --git a/OAuth/src/AuthorizationProvider/Grant/ClientCredentials.php b/OAuth/src/AuthorizationProvider/Grant/ClientCredentials.php new file mode 100644 index 00000000..086889f3 --- /dev/null +++ b/OAuth/src/AuthorizationProvider/Grant/ClientCredentials.php @@ -0,0 +1,17 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant; + +use League\OAuth2\Server\Grant\ClientCredentialsGrant; +use League\OAuth2\Server\Grant\GrantTypeInterface; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\AccessToken; + +class ClientCredentials extends AccessToken { + + /** + * @return GrantTypeInterface + */ + protected function getGrant(): GrantTypeInterface { + return new ClientCredentialsGrant(); + } +} diff --git a/OAuth/src/AuthorizationProvider/Grant/RefreshToken.php b/OAuth/src/AuthorizationProvider/Grant/RefreshToken.php new file mode 100644 index 00000000..7fbec0cf --- /dev/null +++ b/OAuth/src/AuthorizationProvider/Grant/RefreshToken.php @@ -0,0 +1,21 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant; + +use Exception; +use League\OAuth2\Server\Grant\GrantTypeInterface; +use League\OAuth2\Server\Grant\RefreshTokenGrant; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\AccessToken; + +class RefreshToken extends AccessToken { + + /** + * @return GrantTypeInterface + * @throws Exception + */ + protected function getGrant(): GrantTypeInterface { + return new RefreshTokenGrant( + $this->getRefreshTokenRepo() + ); + } +} diff --git a/OAuth/src/AuthorizationProvider/IAccessTokenProvider.php b/OAuth/src/AuthorizationProvider/IAccessTokenProvider.php new file mode 100644 index 00000000..56f330f3 --- /dev/null +++ b/OAuth/src/AuthorizationProvider/IAccessTokenProvider.php @@ -0,0 +1,18 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider; + +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +interface IAccessTokenProvider extends IAuthorizationProvider { + /** + * Retrieve access tokens + * + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @return ResponseInterface + */ + public function getAccessTokens( ServerRequestInterface $request, + ResponseInterface $response ) : ResponseInterface; +} diff --git a/OAuth/src/AuthorizationProvider/IAuthorizationProvider.php b/OAuth/src/AuthorizationProvider/IAuthorizationProvider.php new file mode 100644 index 00000000..70cfb4b8 --- /dev/null +++ b/OAuth/src/AuthorizationProvider/IAuthorizationProvider.php @@ -0,0 +1,25 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\AuthorizationProvider; + +use User; + +interface IAuthorizationProvider { + + /** + * Set user that on whose behalf + * the client is making the request + * + * @param User $user + */ + public function setUser( User $user ); + + /** + * Must user explicitly allow application + * to use this grant type + * + * @return bool + */ + public function needsUserApproval(); + +} diff --git a/OAuth/src/AuthorizationServerFactory.php b/OAuth/src/AuthorizationServerFactory.php new file mode 100644 index 00000000..36abbb4c --- /dev/null +++ b/OAuth/src/AuthorizationServerFactory.php @@ -0,0 +1,57 @@ +<?php + +namespace MediaWiki\Extensions\OAuth; + +use InvalidArgumentException; +use League\OAuth2\Server\AuthorizationServer; +use MediaWiki\Extensions\OAuth\Repository\AccessTokenRepository; +use MediaWiki\Extensions\OAuth\Repository\ClientRepository; +use MediaWiki\Extensions\OAuth\Repository\ScopeRepository; +use MediaWiki\MediaWikiServices; + +class AuthorizationServerFactory { + /** @var string */ + protected $privateKey; + /** @var string */ + protected $encryptionKey; + + /** + * @return static + */ + public static function factory() { + $services = MediaWikiServices::getInstance(); + $extConfig = $services->getConfigFactory()->makeConfig( 'mwoauth' ); + $mainConfig = $services->getMainConfig(); + $privateKey = $extConfig->get( 'OAuth2PrivateKey' ); + $encryptionKey = $extConfig->get( 'OAuthSecretKey' ) ?? $mainConfig->get( 'SecretKey' ); + + return new static( $privateKey, $encryptionKey ); + } + + /** + * @param string $privateKey + * @param string $encryptionKey + */ + public function __construct( $privateKey, $encryptionKey ) { + $this->privateKey = $privateKey; + $this->encryptionKey = trim( $encryptionKey ); + + if ( empty( $this->encryptionKey ) ) { + // Empty encryption key would not break the workflow, but would cause security issues + throw new InvalidArgumentException( 'Encryption key must be set' ); + } + } + + /** + * @return AuthorizationServer + */ + public function getAuthorizationServer() { + return new AuthorizationServer( + new ClientRepository(), + new AccessTokenRepository(), + new ScopeRepository(), + $this->privateKey, + $this->encryptionKey + ); + } +} diff --git a/OAuth/src/Backend/Consumer.php b/OAuth/src/Backend/Consumer.php new file mode 100644 index 00000000..071ca0ce --- /dev/null +++ b/OAuth/src/Backend/Consumer.php @@ -0,0 +1,800 @@ +<?php + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +namespace MediaWiki\Extensions\OAuth\Backend; + +use FormatJson; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity as OAuth2Client; +use MWException; +use User; +use Wikimedia\Rdbms\DBConnRef; + +/** + * Representation of an OAuth consumer. + */ +abstract class Consumer extends MWOAuthDAO { + const OAUTH_VERSION_1 = 1; + const OAUTH_VERSION_2 = 2; + + /** @var array Backwards-compatibility grant mappings */ + public static $mapBackCompatGrants = [ + 'useoauth' => 'basic', + 'authonly' => 'mwoauth-authonly', + 'authonlyprivate' => 'mwoauth-authonlyprivate', + ]; + + /** @var int Unique ID */ + protected $id; + /** @var string Hex token */ + protected $consumerKey; + /** @var string Name of connected application */ + protected $name; + /** @var int Publisher's central user ID. $wgMWOAuthSharedUserIDs defines which central ID + * provider to use. + */ + protected $userId; + /** @var string Version used for handshake breaking changes */ + protected $version; + /** @var string OAuth callback URL for authorization step */ + protected $callbackUrl; + /** + * @var int OAuth callback URL is a prefix and we allow all URLs which + * have callbackUrl as the prefix + */ + protected $callbackIsPrefix; + /** @var string Application description */ + protected $description; + /** @var string Publisher email address */ + protected $email; + /** @var string TS_MW timestamp of when email address was confirmed */ + protected $emailAuthenticated; + /** @var int User accepted the developer agreement */ + protected $developerAgreement; + /** @var int Consumer is for use by the owner only */ + protected $ownerOnly; + /** @var string Version of the OAuth protocol */ + protected $oauthVersion; + /** @var string Wiki ID the application can be used on (or "*" for all) */ + protected $wiki; + /** @var string TS_MW timestamp of proposal */ + protected $registration; + /** @var string Secret HMAC key */ + protected $secretKey; + /** @var string Public RSA key */ + protected $rsaKey; + /** @var array List of grants */ + protected $grants; + /** @var \MWRestrictions IP restrictions */ + protected $restrictions; + /** @var int MWOAuthConsumer::STAGE_* constant */ + protected $stage; + /** @var string TS_MW timestamp of last stage change */ + protected $stageTimestamp; + /** @var int Indicates (if non-zero) this consumer's information is suppressed */ + protected $deleted; + /** @var bool Indicates whether the client (consumer) is able to keep the secret */ + protected $oauth2IsConfidential; + /** @var array OAuth2 grant types available to the client */ + protected $oauth2GrantTypes; + + /* Stages that registered consumer takes (stored in DB) */ + const STAGE_PROPOSED = 0; + const STAGE_APPROVED = 1; + const STAGE_REJECTED = 2; + const STAGE_EXPIRED = 3; + const STAGE_DISABLED = 4; + + /** + * Maps stage ids to human-readable names which describe them as a state + * @var array + */ + public static $stageNames = [ + self::STAGE_PROPOSED => 'proposed', + self::STAGE_REJECTED => 'rejected', + self::STAGE_EXPIRED => 'expired', + self::STAGE_APPROVED => 'approved', + self::STAGE_DISABLED => 'disabled', + ]; + + /** + * Maps stage ids to human-readable names which describe them as an action (which would result + * in that stage) + * @var array + */ + public static $stageActionNames = [ + self::STAGE_PROPOSED => 'propose', + self::STAGE_REJECTED => 'reject', + self::STAGE_EXPIRED => 'propose', + self::STAGE_APPROVED => 'approve', + self::STAGE_DISABLED => 'disable', + ]; + + /** + * Get member => db field mapping + * Loads all fields to avoid unnecessary querying + * + * @return array + */ + protected static function getSchema() { + return [ + 'table' => 'oauth_registered_consumer', + 'fieldColumnMap' => [ + 'id' => 'oarc_id', + 'consumerKey' => 'oarc_consumer_key', + 'name' => 'oarc_name', + 'userId' => 'oarc_user_id', + 'version' => 'oarc_version', + 'callbackUrl' => 'oarc_callback_url', + 'callbackIsPrefix' => 'oarc_callback_is_prefix', + 'description' => 'oarc_description', + 'email' => 'oarc_email', + 'emailAuthenticated' => 'oarc_email_authenticated', + 'oauthVersion' => 'oarc_oauth_version', + 'developerAgreement' => 'oarc_developer_agreement', + 'ownerOnly' => 'oarc_owner_only', + 'wiki' => 'oarc_wiki', + 'grants' => 'oarc_grants', + 'registration' => 'oarc_registration', + 'secretKey' => 'oarc_secret_key', + 'rsaKey' => 'oarc_rsa_key', + 'restrictions' => 'oarc_restrictions', + 'stage' => 'oarc_stage', + 'stageTimestamp' => 'oarc_stage_timestamp', + 'deleted' => 'oarc_deleted', + 'oauth2IsConfidential' => 'oarc_oauth2_is_confidential', + 'oauth2GrantTypes' => 'oarc_oauth2_allowed_grants', + ], + 'idField' => 'id', + 'autoIncrField' => 'id', + ]; + } + + protected static function getFieldPermissionChecks() { + return [ + 'name' => 'userCanSee', + 'userId' => 'userCanSee', + 'version' => 'userCanSee', + 'callbackUrl' => 'userCanSee', + 'callbackIsPrefix' => 'userCanSee', + 'description' => 'userCanSee', + 'rsaKey' => 'userCanSee', + 'email' => 'userCanSeeEmail', + 'secretKey' => 'userCanSeeSecret', + 'restrictions' => 'userCanSeePrivate', + ]; + } + + /** + * @param array $data + * @return string + */ + protected static function getConsumerClass( array $data ) { + return static::isOAuth2( $data ) ? + OAuth2Client::class : + OAuth1Consumer::class; + } + + /** + * @param array $data + * @return bool + */ + protected static function isOAuth2( array $data = [] ) { + $oauthVersion = $data['oarc_oauth_version'] ?? $data['oauthVersion']; + return (int)$oauthVersion === self::OAUTH_VERSION_2; + } + + /** + * @param DBConnRef $db + * @param string $key + * @param int $flags MWOAuthConsumer::READ_* bitfield + * @return Consumer|bool + */ + public static function newFromKey( DBConnRef $db, $key, $flags = 0 ) { + $row = $db->selectRow( static::getTable(), + array_values( static::getFieldColumnMap() ), + [ 'oarc_consumer_key' => (string)$key ], + __METHOD__, + ( $flags & self::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] + ); + + if ( $row ) { + return static::newFromRow( $db, $row ); + } else { + return false; + } + } + + /** + * @param DBConnRef $db + * @param string $name + * @param string $version + * @param int $userId Central user ID + * @param int $flags MWOAuthConsumer::READ_* bitfield + * @return Consumer|bool + */ + public static function newFromNameVersionUser( + DBConnRef $db, $name, $version, $userId, $flags = 0 + ) { + $row = $db->selectRow( static::getTable(), + array_values( static::getFieldColumnMap() ), + [ + 'oarc_name' => (string)$name, + 'oarc_version' => (string)$version, + 'oarc_user_id' => (int)$userId + ], + __METHOD__, + ( $flags & self::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] + ); + + if ( $row ) { + return static::newFromRow( $db, $row ); + } else { + return false; + } + } + + /** + * @return array + */ + public static function newGrants() { + return []; + } + + /** + * @return array + */ + public static function getAllStages() { + return [ + self::STAGE_PROPOSED, + self::STAGE_REJECTED, + self::STAGE_EXPIRED, + self::STAGE_APPROVED, + self::STAGE_DISABLED, + ]; + } + + /** + * Internal ID (DB primary key). + * @return int + */ + public function getId() { + return $this->get( 'id' ); + } + + /** + * Consumer key (32-character hexadecimal string that's used in the OAuth protocol + * and in URLs). This is used as the consumer ID for most external purposes. + * @return string + */ + public function getConsumerKey() { + return $this->get( 'consumerKey' ); + } + + /** + * Name of the consumer. + * @return string + */ + public function getName() { + return $this->get( 'name' ); + } + + /** + * Central ID of the owner. + * @return int + */ + public function getUserId() { + return $this->get( 'userId' ); + } + + /** + * Consumer version. This is mostly meant for humans: different versions of the same + * application have different keys and are handled as different consumers internally. + * @return string + */ + public function getVersion() { + return $this->get( 'version' ); + } + + /** + * Callback URL (or prefix). The browser will be redirected to this URL at the end of + * an OAuth handshake. See getCallbackIsPrefix() for the interpretation of this field. + * @return string + */ + public function getCallbackUrl() { + return $this->get( 'callbackUrl' ); + } + + /** + * When true, getCallbackUrl() returns a prefix; the callback URL can be provided by the caller + * as long as the prefix matches. When false, the callback URL will be determined by + * getCallbackUrl(). + * @return bool + */ + public function getCallbackIsPrefix() { + return $this->get( 'callbackIsPrefix' ); + } + + /** + * Description of the consumer. Currently interpreted as plain text; might change to wikitext + * in the future. + * @return string + */ + public function getDescription() { + return $this->get( 'description' ); + } + + /** + * Email address of the owner. + * @return string + */ + public function getEmail() { + return $this->get( 'email' ); + } + + /** + * Date of verifying the email, in TS_MW format. In practice this will be the same as + * getRegistration(). + * @return string + */ + public function getEmailAuthenticated() { + return $this->get( 'emailAuthenticated' ); + } + + /** + * Did the user accept the developer agreement (the terms of use checkbox at the bottom of the + * registration form)? Except for very old users, always true. + * @return bool + */ + public function getDeveloperAgreement() { + return $this->get( 'developerAgreement' ); + } + + /** + * Owner-only consumers will use one-legged flow instead of three-legged (see + * https://github.com/Mashape/mashape-oauth/blob/master/FLOWS.md#oauth-10a-one-legged ); there + * is only one user (who is the same as the owner) and they learn the access token at + * consumer registration time. + * @return bool + */ + public function getOwnerOnly() { + return $this->get( 'ownerOnly' ); + } + + /** + * @return int + */ + abstract public function getOAuthVersion(); + + /** + * The wiki on which the consumer is allowed to access user accounts. A wiki ID or '*' for all. + * @return string + */ + public function getWiki() { + return $this->get( 'wiki' ); + } + + /** + * The list of grants required by this application. + * @return string[] + */ + public function getGrants() { + return $this->get( 'grants' ); + } + + /** + * Consumer registration date in TS_MW format. + * @return string + */ + public function getRegistration() { + return $this->get( 'registration' ); + } + + /** + * Secret key used to derive the consumer secret for HMAC-SHA1 signed OAuth requests. + * The actual consumer secret will be calculated via MWOAuthUtils::hmacDBSecret() to mitigate + * DB leaks. + * @return string + */ + public function getSecretKey() { + return $this->get( 'secretKey' ); + } + + /** + * Public RSA key for RSA-SHA1 signerd OAuth requests. + * @return string + */ + public function getRsaKey() { + return $this->get( 'rsaKey' ); + } + + /** + * Application restrictions (such as allowed IPs). + * @return \MWRestrictions + */ + public function getRestrictions() { + return $this->get( 'restrictions' ); + } + + /** + * Stage at which the consumer is in the review workflow (proposed, approved etc). + * @return int One of the STAGE_* constants + */ + public function getStage() { + return $this->get( 'stage' ); + } + + /** + * Date at which the consumer was moved to the current stage, in TS_MW format. + * @return string + */ + public function getStageTimestamp() { + return $this->get( 'stageTimestamp' ); + } + + /** + * Is the consumer suppressed? (There is no plain deletion; the closest equivalent is the + * rejected/disabled stage.) + * @return bool + */ + public function getDeleted() { + return $this->get( 'deleted' ); + } + + /** + * @param MWOAuthDataStore $dataStore + * @param string $verifyCode verification code + * @param string $requestKey original request key from /initiate + * @return string the url for redirection + */ + public function generateCallbackUrl( $dataStore, $verifyCode, $requestKey ) { + $callback = $dataStore->getCallbackUrl( $this->key, $requestKey ); + + if ( $callback === 'oob' ) { + $callback = $this->getCallbackUrl(); + } + + return wfAppendQuery( $callback, [ + 'oauth_verifier' => $verifyCode, + 'oauth_token' => $requestKey + ] ); + } + + /** + * Attempts to find an authorization by this user for this consumer. Since a user can + * accept a consumer multiple times (once for "*" and once for each specific wiki), + * there can several access tokens per-wiki (with varying grants) for a consumer. + * This will choose the most wiki-specific access token. The precedence is: + * a) The acceptance for wiki X if the consumer is applicable only to wiki X + * b) The acceptance for wiki $wikiId (if the consumer is applicable to it) + * c) The acceptance for wikis "*" (all wikis) + * + * Users might want more grants on some wikis than on "*". Note that the reverse would not + * make sense, since the consumer could just use the "*" acceptance if it has more grants. + * + * @param \User $mwUser (local wiki user) User who may or may not have authorizations + * @param string $wikiId + * @throws MWOAuthException + * @return ConsumerAcceptance|bool + */ + public function getCurrentAuthorization( User $mwUser, $wikiId ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + + $centralUserId = Utils::getCentralIdFromLocalUser( $mwUser ); + if ( !$centralUserId ) { + throw new MWOAuthException( + 'mwoauthserver-invalid-user', + [ + $this->getName(), + \Message::rawParam( + \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', + 'E008', + true + ) + ) + ] + ); + } + + $checkWiki = $this->getWiki() !== '*' ? $this->getWiki() : $wikiId; + + $cmra = ConsumerAcceptance::newFromUserConsumerWiki( + $dbr, + $centralUserId, + $this, + $checkWiki, + 0, + $this->getOAuthVersion() + ); + if ( !$cmra ) { + $cmra = ConsumerAcceptance::newFromUserConsumerWiki( + $dbr, + $centralUserId, + $this, + '*', + 0, + $this->getOAuthVersion() + ); + } + return $cmra; + } + + /** + * @param User $mwUser + * @param bool $update + * @param array $grants + * @param string|null $requestTokenKey + * @return mixed + */ + abstract public function authorize( User $mwUser, $update, $grants, $requestTokenKey = null ); + + /** + * Verify that this user can authorize this consumer + * + * @param User $mwUser + * @throws MWOAuthException + * @throws MWException + */ + protected function conductAuthorizationChecks( User $mwUser ) { + global $wgBlockDisablesLogin; + + // Check that user and consumer are in good standing + if ( $mwUser->isLocked() || $wgBlockDisablesLogin && $mwUser->isBlocked() ) { + throw new MWOAuthException( 'mwoauthserver-insufficient-rights', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E007', + 'E007', + true + ) ) + ] ); + } + + if ( $this->getDeleted() ) { + throw new MWOAuthException( 'mwoauthserver-bad-consumer-key', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E006', + 'E006', + true + ) ) + ] ); + } elseif ( !$this->isUsableBy( $mwUser ) ) { + $owner = Utils::getCentralUserNameFromId( + $this->getUserId(), + $mwUser + ); + throw new MWOAuthException( + 'mwoauthserver-bad-consumer', + [ $this->getName(), Utils::getCentralUserTalk( $owner ), \Message::rawParam( + \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E005', + 'E005', + true + ) + ) ] + ); + } elseif ( $this->getOwnerOnly() ) { + throw new MWOAuthException( 'mwoauthserver-consumer-owner-only', [ + $this->getName(), + \SpecialPage::getTitleFor( + 'OAuthConsumerRegistration', 'update/' . $this->getConsumerKey() + ), + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E010', + 'E010', + true + ) ) + ] ); + } + } + + /** + * @param User $mwUser + * @param bool $update + * @param array $grants + * @return ConsumerAcceptance + * @throws MWOAuthException + * @throws MWException + */ + protected function saveAuthorization( User $mwUser, $update, $grants ) { + // CentralAuth may abort here if there is no global account for this user + $centralUserId = Utils::getCentralIdFromLocalUser( $mwUser ); + if ( !$centralUserId ) { + throw new MWOAuthException( + 'mwoauthserver-invalid-user', + [ + $this->getName(), + \Message::rawParam( + \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', + 'E008', + true + ) + ) + ] + ); + } + + $dbw = Utils::getCentralDB( DB_MASTER ); + // Check if this authorization exists + $cmra = $this->getCurrentAuthorization( $mwUser, wfWikiID() ); + + if ( $update ) { + // This should be an update to an existing authorization + if ( !$cmra ) { + // update requested, but no existing key + throw new MWOAuthException( 'mwoauthserver-invalid-request' ); + } + $cmra->setFields( [ + 'wiki' => $this->getWiki(), + 'grants' => $grants + ] ); + $cmra->save( $dbw ); + } elseif ( !$cmra ) { + // Add the Authorization to the database + $accessToken = MWOAuthDataStore::newToken(); + $cmra = ConsumerAcceptance::newFromArray( [ + 'id' => null, + 'wiki' => $this->getWiki(), + 'userId' => $centralUserId, + 'consumerId' => $this->getId(), + 'accessToken' => $accessToken->key, + 'accessSecret' => $accessToken->secret, + 'grants' => $grants, + 'accepted' => wfTimestampNow(), + 'oauth_version' => $this->getOAuthVersion() + ] ); + $cmra->save( $dbw ); + } + + return $cmra; + } + + /** + * Check if the consumer is usable by $user + * + * "Usable by $user" includes: + * - Approved for multi-user use + * - Approved for owner-only use and is owned by $user + * - Still pending approval and is owned by $user + * + * @param \User $user + * @return bool + */ + public function isUsableBy( \User $user ) { + if ( $this->stage === self::STAGE_APPROVED && !$this->getOwnerOnly() ) { + return true; + } elseif ( $this->stage === self::STAGE_PROPOSED || $this->stage === self::STAGE_APPROVED ) { + $centralId = Utils::getCentralIdFromLocalUser( $user ); + return ( $centralId && $this->userId === $centralId ); + } + + return false; + } + + protected function normalizeValues() { + // Keep null values since we're constructing w/ them to auto-increment + $this->id = $this->id === null ? null : (int)$this->id; + $this->userId = (int)$this->userId; + $this->registration = wfTimestamp( TS_MW, $this->registration ); + $this->stage = (int)$this->stage; + $this->stageTimestamp = wfTimestamp( TS_MW, $this->stageTimestamp ); + $this->emailAuthenticated = wfTimestamp( TS_MW, $this->emailAuthenticated ); + $this->grants = (array)$this->grants; // sanity + $this->callbackIsPrefix = (bool)$this->callbackIsPrefix; + $this->ownerOnly = (bool)$this->ownerOnly; + $this->oauthVersion = (int)$this->oauthVersion; + $this->developerAgreement = (bool)$this->developerAgreement; + $this->deleted = (bool)$this->deleted; + $this->oauth2IsConfidential = (bool)$this->oauth2IsConfidential; + } + + protected function encodeRow( DBConnRef $db, $row ) { + // For compatibility with other wikis in the farm, un-remap some grants + foreach ( self::$mapBackCompatGrants as $old => $new ) { + while ( ( $i = array_search( $new, $row['oarc_grants'], true ) ) !== false ) { + $row['oarc_grants'][$i] = $old; + } + } + + $row['oarc_registration'] = $db->timestamp( $row['oarc_registration'] ); + $row['oarc_stage_timestamp'] = $db->timestamp( $row['oarc_stage_timestamp'] ); + $row['oarc_restrictions'] = $row['oarc_restrictions']->toJson(); + $row['oarc_grants'] = \FormatJson::encode( $row['oarc_grants'] ); + $row['oarc_email_authenticated'] = + $db->timestampOrNull( $row['oarc_email_authenticated'] ); + $row['oarc_oauth2_allowed_grants'] = FormatJson::encode( + $row['oarc_oauth2_allowed_grants'] + ); + return $row; + } + + protected function decodeRow( DBConnRef $db, $row ) { + $row['oarc_registration'] = wfTimestamp( TS_MW, $row['oarc_registration'] ); + $row['oarc_stage'] = (int)$row['oarc_stage']; + $row['oarc_stage_timestamp'] = wfTimestamp( TS_MW, $row['oarc_stage_timestamp'] ); + $row['oarc_restrictions'] = \MWRestrictions::newFromJson( $row['oarc_restrictions'] ); + $row['oarc_grants'] = \FormatJson::decode( $row['oarc_grants'], true ); + $row['oarc_user_id'] = (int)$row['oarc_user_id']; + $row['oarc_email_authenticated'] = + wfTimestampOrNull( TS_MW, $row['oarc_email_authenticated'] ); + $row['oarc_oauth2_allowed_grants'] = FormatJson::decode( + $row['oarc_oauth2_allowed_grants'], true + ); + + // For backwards compatibility, remap some grants + foreach ( self::$mapBackCompatGrants as $old => $new ) { + while ( ( $i = array_search( $old, $row['oarc_grants'], true ) ) !== false ) { + $row['oarc_grants'][$i] = $new; + } + } + + return $row; + } + + /** + * Magic method so that fields like $consumer->secret and $consumer->key work. + * This allows MWOAuthConsumer to be a replacement for OAuthConsumer + * in lib/OAuth.php without inheriting. + * @param mixed $prop + * @return mixed + */ + public function __get( $prop ) { + if ( $prop === 'key' ) { + return $this->consumerKey; + } elseif ( $prop === 'secret' ) { + return Utils::hmacDBSecret( $this->secretKey ); + } elseif ( $prop === 'callback_url' ) { + return $this->callbackUrl; + } else { + throw new \LogicException( 'Direct property access attempt: ' . $prop ); + } + } + + protected function userCanSee( $name, \IContextSource $context ) { + if ( $this->getDeleted() + && !$context->getUser()->isAllowed( 'mwoauthviewsuppressed' ) + ) { + return $context->msg( 'mwoauth-field-hidden' ); + } else { + return true; + } + } + + protected function userCanSeePrivate( $name, \IContextSource $context ) { + if ( !$context->getUser()->isAllowed( 'mwoauthviewprivate' ) ) { + return $context->msg( 'mwoauth-field-private' ); + } else { + return $this->userCanSee( $name, $context ); + } + } + + protected function userCanSeeEmail( $name, \IContextSource $context ) { + if ( !$context->getUser()->isAllowed( 'mwoauthmanageconsumer' ) ) { + return $context->msg( 'mwoauth-field-private' ); + } else { + return $this->userCanSee( $name, $context ); + } + } + + protected function userCanSeeSecret( $name, \IContextSource $context ) { + return $context->msg( 'mwoauth-field-private' ); + } +} diff --git a/OAuth/src/Backend/ConsumerAcceptance.php b/OAuth/src/Backend/ConsumerAcceptance.php new file mode 100644 index 00000000..da90914b --- /dev/null +++ b/OAuth/src/Backend/ConsumerAcceptance.php @@ -0,0 +1,274 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use Wikimedia\Rdbms\DBConnRef; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Representation of an OAuth consumer acceptance. + * Created when the user clicks through the OAuth authorization dialog, this allows + * the specified consumer to perform actions in the name of the user + * (subject to the grant and wiki restrictions stored in the acceptance object). + */ +class ConsumerAcceptance extends MWOAuthDAO { + /** @var int Unique ID */ + protected $id; + /** @var string Wiki ID the application can be used on (or "*" for all) */ + protected $wiki; + /** @var int Publisher user ID (on central wiki) */ + protected $userId; + /** @var int */ + protected $consumerId; + /** @var string Hex token */ + protected $accessToken; + /** @var string Secret HMAC key */ + protected $accessSecret; + /** @var array List of grants */ + protected $grants; + /** @var string TS_MW timestamp of acceptance */ + protected $accepted; + /** @var string */ + protected $oauthVersion; + + protected static function getSchema() { + return [ + 'table' => 'oauth_accepted_consumer', + 'fieldColumnMap' => [ + 'id' => 'oaac_id', + 'wiki' => 'oaac_wiki', + 'userId' => 'oaac_user_id', + 'consumerId' => 'oaac_consumer_id', + 'accessToken' => 'oaac_access_token', + 'accessSecret' => 'oaac_access_secret', + 'grants' => 'oaac_grants', + 'accepted' => 'oaac_accepted', + 'oauth_version' => 'oaac_oauth_version', + ], + 'idField' => 'id', + 'autoIncrField' => 'id', + ]; + } + + protected static function getFieldPermissionChecks() { + return [ + 'wiki' => 'userCanSee', + 'userId' => 'userCanSee', + 'consumerId' => 'userCanSee', + 'accessToken' => 'userCanSeePrivate', + 'accessSecret' => 'userCanSeeSecret', + 'grants' => 'userCanSee', + 'accepted' => 'userCanSee', + 'oauth_version' => 'userCanSee', + ]; + } + + /** + * @param DBConnRef $db + * @param string $token Access token + * @param int $flags MWOAuthConsumerAcceptance::READ_* bitfield + * @return ConsumerAcceptance|bool + */ + public static function newFromToken( DBConnRef $db, $token, $flags = 0 ) { + $row = $db->selectRow( static::getTable(), + array_values( static::getFieldColumnMap() ), + [ 'oaac_access_token' => (string)$token ], + __METHOD__, + ( $flags & self::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] + ); + + if ( $row ) { + $consumer = new self(); + $consumer->loadFromRow( $db, $row ); + return $consumer; + } else { + return false; + } + } + + /** + * @param DBConnRef $db + * @param string $userId of user who authorized (central wiki's id) + * @param Consumer $consumer + * @param string $wiki wiki associated with the acceptance + * @param int $flags MWOAuthConsumerAcceptance::READ_* bitfield + * @param string $oauthVersion + * @return ConsumerAcceptance|bool + */ + public static function newFromUserConsumerWiki( + DBConnRef $db, $userId, $consumer, + $wiki, $flags = 0, $oauthVersion = Consumer::OAUTH_VERSION_1 + ) { + $row = $db->selectRow( static::getTable(), + array_values( static::getFieldColumnMap() ), + [ + 'oaac_user_id' => (int)$userId, + 'oaac_consumer_id' => $consumer->getId(), + 'oaac_oauth_version' => $oauthVersion, + 'oaac_wiki' => (string)$wiki + ], + __METHOD__, + ( $flags & self::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] + ); + + if ( $row ) { + $consumer = new self(); + $consumer->loadFromRow( $db, $row ); + return $consumer; + } else { + return false; + } + } + + /** + * Database ID. + * @return int + */ + public function getId() { + return $this->get( 'id' ); + } + + /** + * Wiki on which the user has authorized the consumer to access their account. Wiki ID or '*' + * for all. + * @return string + */ + public function getWiki() { + return $this->get( 'wiki' ); + } + + /** + * Central user ID of the authorizing user. + * @return int + */ + public function getUserId() { + return $this->get( 'userId' ); + } + + /** + * Database ID of the consumer. + * @return int + */ + public function getConsumerId() { + return $this->get( 'consumerId' ); + } + + /** + * The access token for the OAuth protocol + * @return string + */ + public function getAccessToken() { + return $this->get( 'accessToken' ); + } + + /** + * Secret key used to derive the access secret for the OAuth protocol. + * The actual access secret will be calculated via MWOAuthUtils::hmacDBSecret() to mitigate + * DB leaks. + * @return string + */ + public function getAccessSecret() { + return $this->get( 'accessSecret' ); + } + + /** + * The list of grants which have been granted. + * @return string[] + */ + public function getGrants() { + return $this->get( 'grants' ); + } + + /** + * Date of the authorization, in TS_MW format. + * @return string + */ + public function getAccepted() { + return $this->get( 'accepted' ); + } + + /** + * @return int + */ + public function getOAuthVersion() { + return (int)$this->get( 'oauth_version' ); + } + + protected function normalizeValues() { + $this->userId = (int)$this->userId; + $this->consumerId = (int)$this->consumerId; + $this->accepted = wfTimestamp( TS_MW, $this->accepted ); + $this->grants = (array)$this->grants; // sanity + } + + protected function encodeRow( DBConnRef $db, $row ) { + if ( (int)$row['oaac_user_id'] === 0 ) { + throw new MWOAuthException( 'mwoauth-consumer-access-no-user' ); + } + // For compatibility with other wikis in the farm, un-remap some grants + foreach ( Consumer::$mapBackCompatGrants as $old => $new ) { + while ( ( $i = array_search( $new, $row['oaac_grants'], true ) ) !== false ) { + $row['oaac_grants'][$i] = $old; + } + } + + $row['oaac_grants'] = \FormatJson::encode( $row['oaac_grants'] ); + $row['oaac_accepted'] = $db->timestamp( $row['oaac_accepted'] ); + return $row; + } + + protected function decodeRow( DBConnRef $db, $row ) { + $row['oaac_grants'] = \FormatJson::decode( $row['oaac_grants'], true ); + $row['oaac_accepted'] = wfTimestamp( TS_MW, $row['oaac_accepted'] ); + + // For backwards compatibility, remap some grants + foreach ( Consumer::$mapBackCompatGrants as $old => $new ) { + while ( ( $i = array_search( $old, $row['oaac_grants'], true ) ) !== false ) { + $row['oaac_grants'][$i] = $new; + } + } + + return $row; + } + + protected function userCanSee( $name, \IContextSource $context ) { + $centralUserId = Utils::getCentralIdFromLocalUser( $context->getUser() ); + if ( $this->userId != $centralUserId + && !$context->getUser()->isAllowed( 'mwoauthviewprivate' ) + ) { + return $context->msg( 'mwoauth-field-private' ); + } else { + return true; + } + } + + protected function userCanSeePrivate( $name, \IContextSource $context ) { + if ( !$context->getUser()->isAllowed( 'mwoauthviewprivate' ) ) { + return $context->msg( 'mwoauth-field-private' ); + } else { + return $this->userCanSee( $name, $context ); + } + } + + protected function userCanSeeSecret( $name, \IContextSource $context ) { + return $context->msg( 'mwoauth-field-private' ); + } +} diff --git a/OAuth/src/Backend/Hooks.php b/OAuth/src/Backend/Hooks.php new file mode 100644 index 00000000..39bbeb97 --- /dev/null +++ b/OAuth/src/Backend/Hooks.php @@ -0,0 +1,214 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Frontend\OAuthLogFormatter; +use MediaWiki\MediaWikiServices; +use MediaWiki\Storage\NameTableAccessException; + +/** + * Class containing hooked functions for an OAuth environment + */ +class Hooks { + + /** + * Called right after configuration variables have been set. + */ + public static function onRegistration() { + global $wgWikimediaJenkinsCI, $wgOAuth2PrivateKey, $wgOAuth2PublicKey; + + // Set $wgOAuth2PrivateKey and $wgOAuth2PublicKey for Wikimedia Jenkins, PHPUnit. + if ( defined( 'MW_PHPUNIT_TEST' ) || ( $wgWikimediaJenkinsCI ?? false ) ) { + $wgOAuth2PrivateKey = <<<EOK +-----BEGIN RSA PRIVATE KEY----- +MIIBOwIBAAJBAMBGXQYJ2lXzLuQkRlWoqYJvSnNGfRvPBUVsbHfFPyCr8i6jBPcO +vtMLFMRAaq4quRDFgQ7YQLvKTqjpN+bo7RECAwEAAQJBAKP3XTzZCihhyYskpBZI +TsW8wnCrm+UrFgOuApHg04S3oeUXpNApxxGy+EX0aBsVoPBuisyBjiJDIFssdgJa +IwECIQDuMipv8QOzA9qJPPpXZCQQN6znXjSE3jZhrBH879SDBQIhAM6lgY0lWB0N +lhQZWtM8jRcxtJUFrApEizE6WFxj/LedAiEAyINgaAVqiMror3iugNyi4ygLHGWY +LnVlMAmKxvMZYQUCIAYTeb6ztWaNSrdmk3QYmLFw5bVoCEn4//q/k2+MBRdFAiA2 +MJWJuom6IpoP0UrM/gJbwGxwgZymb4jL+sKFoIqGmA== +-----END RSA PRIVATE KEY----- +EOK; + $wgOAuth2PublicKey = <<<EOK +-----BEGIN PUBLIC KEY----- +MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMBGXQYJ2lXzLuQkRlWoqYJvSnNGfRvP +BUVsbHfFPyCr8i6jBPcOvtMLFMRAaq4quRDFgQ7YQLvKTqjpN+bo7RECAwEAAQ== +-----END PUBLIC KEY----- +EOK; + } + } + + public static function onExtensionFunctions() { + global $wgLogTypes, $wgLogNames, + $wgLogHeaders, $wgLogActionsHandlers, $wgActionFilteredLogs; + + if ( Utils::isCentralWiki() ) { + $wgLogTypes[] = 'mwoauthconsumer'; + $wgLogNames['mwoauthconsumer'] = 'mwoauthconsumer-consumer-logpage'; + $wgLogHeaders['mwoauthconsumer'] = 'mwoauthconsumer-consumer-logpagetext'; + $wgLogActionsHandlers['mwoauthconsumer/*'] = OAuthLogFormatter::class; + $wgActionFilteredLogs['mwoauthconsumer'] = [ + 'approve' => [ 'approve' ], + 'create-owner-only' => [ 'create-owner-only' ], + 'disable' => [ 'disable' ], + 'propose' => [ 'propose' ], + 'reenable' => [ 'reenable' ], + 'reject' => [ 'reject' ], + 'update' => [ 'update' ], + ]; + } + } + + /** + * Reserve change tags that look like an OAuth change tag. + * + * @param string $tag + * @param \User|null $user + * @param \Status &$status + * @return bool + */ + public static function onChangeTagCanCreate( $tag, ?\User $user, \Status &$status ) { + if ( Utils::isReservedTagName( $tag ) ) { + $status->fatal( 'mwoauth-tag-reserved' ); + } + return true; + } + + public static function onMergeAccountFromTo( \User $oUser, \User $nUser ) { + global $wgMWOAuthSharedUserIDs; + + if ( !$wgMWOAuthSharedUserIDs ) { + $oldid = $oUser->getId(); + $newid = $nUser->getId(); + if ( $oldid && $newid ) { + self::doUserIdMerge( $oldid, $newid ); + } + } + + return true; + } + + public static function onCentralAuthGlobalUserMerged( $oldname, $newname, $oldid, $newid ) { + global $wgMWOAuthSharedUserIDs; + + if ( $wgMWOAuthSharedUserIDs && $oldid && $newid ) { + self::doUserIdMerge( $oldid, $newid ); + } + + return true; + } + + protected static function doUserIdMerge( $oldid, $newid ) { + $dbw = Utils::getCentralDB( DB_MASTER ); + // Merge any consumers register to this user + $dbw->update( 'oauth_registered_consumer', + [ 'oarc_user_id' => $newid ], + [ 'oarc_user_id' => $oldid ], + __METHOD__ + ); + // Delete any acceptance tokens by the old user ID + $dbw->delete( 'oauth_accepted_consumer', + [ 'oaac_user_id' => $oldid ], + __METHOD__ + ); + } + + public static function onListDefinedTags( &$tags ) { + return self::getUsedConsumerTags( false, $tags ); + } + + public static function onChangeTagsListActive( &$tags ) { + return self::getUsedConsumerTags( true, $tags ); + } + + /** + * List tags that should show as defined/active on Special:Tags + * + * Handles both the ChangeTagsListActive and ListDefinedTags hooks. Only + * lists those tags that are actually in use on the local wiki, to avoid + * flooding Special:Tags with tags for consumers that will never be making + * logged actions. + * + * @param bool $activeOnly true for ChangeTagsListActive, false for ListDefinedTags + * @param array &$tags + * @return bool + */ + private static function getUsedConsumerTags( $activeOnly, &$tags ) { + // Step 1: Get the list of (active) consumers' tags for this wiki + $db = Utils::getCentralDB( DB_REPLICA ); + $conds = [ + $db->makeList( [ + 'oarc_wiki = ' . $db->addQuotes( '*' ), + 'oarc_wiki = ' . $db->addQuotes( wfWikiId() ), + ], LIST_OR ), + 'oarc_deleted' => 0, + ]; + if ( $activeOnly ) { + $conds[] = $db->makeList( [ + 'oarc_stage = ' . Consumer::STAGE_APPROVED, + // Proposed consumers are active for the owner, so count them too + 'oarc_stage = ' . Consumer::STAGE_PROPOSED, + ], LIST_OR ); + } + $res = $db->select( + 'oauth_registered_consumer', + [ 'oarc_id' ], + $conds, + __METHOD__ + ); + $allTags = []; + foreach ( $res as $row ) { + $allTags[] = Utils::getTagName( $row->oarc_id ); + } + + // Step 2: Return only those that are in use. + $changeTagDefStore = MediaWikiServices::getInstance()->getChangeTagDefStore(); + $tagIds = []; + foreach ( $allTags as $tag ) { + try { + $tagIds[] = $changeTagDefStore->getId( $tag ); + } catch ( NameTableAccessException $ex ) { + continue; + } + } + if ( $tagIds === [] ) { + // Nothing to add, return + return true; + } + $conditions = [ 'ct_tag_id' => $tagIds ]; + $field = 'ct_tag_id'; + + if ( $allTags ) { + $db = wfGetDB( DB_REPLICA ); + $res = $db->select( + 'change_tag', + [ $field ], + $conditions, + __METHOD__, + [ 'DISTINCT' ] + ); + foreach ( $res as $row ) { + $tags[] = $changeTagDefStore->getName( intval( $row->ct_tag_id ) ); + } + } + + return true; + } + + public static function onSetupAfterCache() { + global $wgMWOAuthCentralWiki, $wgMWOAuthSharedUserIDs; + + if ( $wgMWOAuthCentralWiki === false ) { + // Treat each wiki as its own "central wiki" as there is no actual one + $wgMWOAuthCentralWiki = wfWikiId(); // default + } else { + // There is actually a central wiki, requiring global user IDs via hook + $wgMWOAuthSharedUserIDs = true; + } + } + + public static function onApiRsdServiceApis( array &$apis ) { + $apis['MediaWiki']['settings']['OAuth'] = true; + } +} diff --git a/OAuth/src/Backend/MWOAuthDAO.php b/OAuth/src/Backend/MWOAuthDAO.php new file mode 100644 index 00000000..b0c60b1c --- /dev/null +++ b/OAuth/src/Backend/MWOAuthDAO.php @@ -0,0 +1,478 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Logger\LoggerFactory; +use Wikimedia\Rdbms\DBConnRef; +use Wikimedia\Rdbms\DBError; +use Wikimedia\Rdbms\DBReadOnlyError; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Representation of a Data Access Object + */ +abstract class MWOAuthDAO implements \IDBAccessObject { + private $daoOrigin = 'new'; // string; object construction origin + private $daoPending = true; // bool; whether fields changed or the field is new + + /** @var \Psr\Log\LoggerInterface */ + protected $logger; + + /** + * @throws \LogicException + */ + final protected function __construct() { + $fields = array_keys( static::getFieldPermissionChecks() ); + if ( array_diff( $fields, $this->getFieldNames() ) ) { + throw new \LogicException( "Invalid field(s) defined in access check methods." ); + } + $this->logger = LoggerFactory::getInstance( 'OAuth' ); + } + + /** + * @param array $values (field => value) map + * @return static + */ + final public static function newFromArray( array $values ) { + $class = static::getConsumerClass( $values ); + $consumer = new $class(); + + // Make sure oauth_version is set - for backwards compat + $values['oauth_version'] = $values['oauth_version'] ?? Consumer::OAUTH_VERSION_1; + $consumer->loadFromValues( $values ); + return $consumer; + } + + /** + * Determine and return the correct consumer class + * + * @param array $data + * @return string + */ + protected static function getConsumerClass( array $data ) { + return static::class; + } + + /** + * @param DBConnRef $db + * @param array|\stdClass $row + * @return static + */ + final public static function newFromRow( DBConnRef $db, $row ) { + $class = static::getConsumerClass( (array)$row ); + $consumer = new $class(); + $consumer->loadFromRow( $db, $row ); + return $consumer; + } + + /** + * @param DBConnRef $db + * @param int $id + * @param int $flags MWOAuthDAO::READ_* bitfield + * @return static|bool Returns false if not found + * @throws DBError + */ + final public static function newFromId( DBConnRef $db, $id, $flags = 0 ) { + $row = $db->selectRow( static::getTable(), + array_values( static::getFieldColumnMap() ), + [ static::getIdColumn() => (int)$id ], + __METHOD__, + ( $flags & self::READ_LOCKING ) ? [ 'FOR UPDATE' ] : [] + ); + + if ( $row ) { + $class = static::getConsumerClass( (array)$row ); + $consumer = new $class(); + $consumer->loadFromRow( $db, $row ); + return $consumer; + } else { + return false; + } + } + + /** + * Get the value of a field + * + * @param string $name + * @return mixed + * @throws \LogicException + */ + final public function get( $name ) { + if ( !static::hasField( $name ) ) { + throw new \LogicException( "Object has no '$name' field." ); + } + return $this->$name; + } + + /** + * Set the value of a field + * + * @param string $name + * @param mixed $value + * @return mixed The old value + * @throws \Exception + */ + final public function setField( $name, $value ) { + $old = $this->setFields( [ $name => $value ] ); + return $old[$name]; + } + + /** + * Set the values for a set of fields + * + * @param array $values (field => value) map + * @throws \LogicException + * @return array Map of old values + */ + final public function setFields( array $values ) { + $old = []; + foreach ( $values as $name => $value ) { + if ( !static::hasField( $name ) ) { + throw new \LogicException( "Object has no '$name' field." ); + } + $old[$name] = $this->$name; + $this->$name = $value; + if ( $old[$name] !== $value ) { + $this->daoPending = true; + } + } + $this->normalizeValues(); + return $old; + } + + /** + * @return array + */ + final public function getFieldNames() { + return array_keys( static::getFieldColumnMap() ); + } + + /** + * @param DBConnRef $dbw + * @return bool + * @throws DBReadOnlyError + */ + public function save( DBConnRef $dbw ) { + $uniqueId = $this->getIdValue(); + $idColumn = static::getIdColumn(); + if ( !empty( $dbw->daoReadOnly ) ) { + throw new DBReadOnlyError( $dbw, get_class() . ": tried to save while db is read-only" ); + } + if ( $this->daoOrigin === 'db' ) { + if ( $this->daoPending ) { + $this->logger->debug( get_class( $this ) . ': performing DB update; object changed.' ); + $dbw->update( + static::getTable(), + $this->getRowArray( $dbw ), + [ $idColumn => $uniqueId ], + __METHOD__ + ); + $this->daoPending = false; + return $dbw->affectedRows() > 0; + } else { + $this->logger->debug( get_class( $this ) . ': skipping DB update; object unchanged.' ); + return false; // short-circuit + } + } else { + $this->logger->debug( get_class( $this ) . ': performing DB update; new object.' ); + $afield = static::getAutoIncrField(); + $acolumn = $afield !== null ? static::getColumn( $afield ) : null; + $row = $this->getRowArray( $dbw ); + if ( $acolumn !== null && $row[$acolumn] === null ) { + // auto-increment field should be omitted, not set null, for + // auto-incrementing behavior + unset( $row[$acolumn] ); + } + $dbw->insert( + static::getTable(), + $row, + __METHOD__ + ); + if ( $afield !== null ) { // update field for auto-increment field + $this->$afield = $dbw->insertId(); + } + $this->daoPending = false; + return true; + } + } + + /** + * @param DBConnRef $dbw + * @return bool + * @throws DBReadOnlyError + */ + public function delete( DBConnRef $dbw ) { + $uniqueId = $this->getIdValue(); + $idColumn = static::getIdColumn(); + if ( !empty( $dbw->daoReadOnly ) ) { + throw new DBReadOnlyError( $dbw, get_class() . ": tried to delete while db is read-only" ); + } + if ( $this->daoOrigin === 'db' ) { + $dbw->delete( + static::getTable(), + [ $idColumn => $uniqueId ], + __METHOD__ + ); + $this->daoPending = true; + return $dbw->affectedRows() > 0; + } else { + return false; + } + } + + /** + * Get the schema information for this object type + * + * This should return an associative array with: + * - idField : a field with an int/hex UNIQUE identifier + * - autoIncrField : a field that auto-increments in the DB (or NULL if none) + * - table : a table name + * - fieldColumnMap : a map of field names to column names + * + * @throws \MWException + * @return array + */ + protected static function getSchema() { + // Note: declaring this abstract raises E_STRICT + throw new \MWException( "getSchema() not defined in " . get_class() ); + } + + /** + * Get the access control check methods for this object type + * + * This returns a map of field names to method names. + * The methods check if a context user has access to the field, + * returning true if they do and a Message object otherwise. + * The methods take (field name, \IContextSource) as arguments. + * + * @see MWOAuthDAO::userCanAccess() + * @see MWOAuthDAOAccessControl + * + * @throws \LogicException Subclasses must override + * @return array Map of (field name => name of method that checks access) + */ + protected static function getFieldPermissionChecks() { + // Note: declaring this abstract raises E_STRICT + throw new \LogicException( "getFieldPermissionChecks() not defined in " . get_class() ); + } + + /** + * @return string + */ + final protected static function getTable() { + $schema = static::getSchema(); + return $schema['table']; + } + + /** + * @return array + */ + final protected static function getFieldColumnMap() { + $schema = static::getSchema(); + return $schema['fieldColumnMap']; + } + + /** + * @param string $field + * @return string + */ + final protected static function getColumn( $field ) { + $schema = static::getSchema(); + return $schema['fieldColumnMap'][$field]; + } + + /** + * @param string $field + * @return bool + */ + final protected static function hasField( $field ) { + $schema = static::getSchema(); + return isset( $schema['fieldColumnMap'][$field] ); + } + + /** + * @return string|null + */ + final protected static function getAutoIncrField() { + $schema = static::getSchema(); + return $schema['autoIncrField'] ?? null; + } + + /** + * @return string + */ + final protected static function getIdColumn() { + $schema = static::getSchema(); + return $schema['fieldColumnMap'][$schema['idField']]; + } + + /** + * @return int|string + */ + final protected function getIdValue() { + $schema = static::getSchema(); + $field = $schema['idField']; + return $this->$field; + } + + /** + * @param array $values + * @throws \MWException + */ + final protected function loadFromValues( array $values ) { + foreach ( static::getFieldColumnMap() as $field => $column ) { + if ( !array_key_exists( $field, $values ) ) { + throw new \MWException( get_class( $this ) . " requires '$field' field." ); + } + $this->$field = $values[$field]; + } + $this->normalizeValues(); + $this->daoOrigin = 'new'; + $this->daoPending = true; + } + + /** + * Subclasses should make this normalize fields (e.g. timestamps) + * + * @return void + */ + abstract protected function normalizeValues(); + + /** + * @param DBConnRef $db + * @param \stdClass|array $row + * @return void + */ + final protected function loadFromRow( DBConnRef $db, $row ) { + $row = $this->decodeRow( $db, (array)$row ); + $values = []; + foreach ( static::getFieldColumnMap() as $field => $column ) { + $values[$field] = $row[$column]; + } + $this->loadFromValues( $values ); + $this->daoOrigin = 'db'; + $this->daoPending = false; + } + + /** + * Subclasses should make this to encode DB fields (e.g. timestamps). + * This must also flatten any PHP data structures into flat values. + * + * @param DBConnRef $db + * @param array $row + * @return array + */ + abstract protected function encodeRow( DBConnRef $db, $row ); + + /** + * Subclasses should make this to decode DB fields (e.g. timestamps). + * This can also expand some flat values (e.g. JSON) into PHP data structures. + * Note: this does not need to handle what normalizeValues() already does. + * + * @param DBConnRef $db + * @param array $row + * @return array + */ + abstract protected function decodeRow( DBConnRef $db, $row ); + + /** + * @param DBConnRef $db + * @return array + */ + final protected function getRowArray( DBConnRef $db ) { + $row = []; + foreach ( static::getFieldColumnMap() as $field => $column ) { + $row[$column] = $this->$field; + } + return $this->encodeRow( $db, $row ); + } + + /** + * Check if a user (from the context) can view a field + * + * @see MWOAuthDAO::userCanAccess() + * @see MWOAuthDAOAccessControl + * + * @param string $name + * @param \IContextSource $context + * @return \Message|true Returns on success or a Message if the user lacks access + */ + final public function userCanAccess( $name, \IContextSource $context ) { + $map = static::getFieldPermissionChecks(); + if ( isset( $map[$name] ) ) { + $method = $map[$name]; + return $this->$method( $name, $context ); + } else { + return true; + } + } + + /** + * Get the current conflict token value for a user + * + * @param \IContextSource $context + * @return string Hex token + */ + final public function getChangeToken( \IContextSource $context ) { + $map = []; + foreach ( $this->getFieldNames() as $field ) { + if ( $this->userCanAccess( $field, $context ) ) { + $map[$field] = $this->$field; + } else { + $map[$field] = null; // don't convey this information + } + } + return hash_hmac( + 'sha1', + serialize( $map ), + "{$context->getUser()->getId()}#{$this->getIdValue()}" + ); + } + + /** + * Compare an old change token to the current one + * + * @param \IContextSource $context + * @param string $oldToken + * @return bool Whether the current is unchanged + */ + final public function checkChangeToken( \IContextSource $context, $oldToken ) { + return ( $this->getChangeToken( $context ) === $oldToken ); + } + + /** + * Update whether this object should be written to the data store + * @param bool $pending set to true to mark this object as needing to write its data + */ + public function setPending( $pending ) { + $this->daoPending = $pending; + } + + /** + * Update the origin of this object + * @param string $source source of the object + * 'new': Treat this as a new object to the datastore (insert on save) + * 'db': Treat this as already in the datastore (update on save) + */ + public function updateOrigin( $source ) { + $this->daoOrigin = $source; + } +} diff --git a/OAuth/src/Backend/MWOAuthDataStore.php b/OAuth/src/Backend/MWOAuthDataStore.php new file mode 100644 index 00000000..76c33522 --- /dev/null +++ b/OAuth/src/Backend/MWOAuthDataStore.php @@ -0,0 +1,257 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Lib\OAuthConsumer; +use MediaWiki\Extensions\OAuth\Lib\OAuthDataStore; +use MediaWiki\Logger\LoggerFactory; +use Wikimedia\Rdbms\DBConnRef; + +class MWOAuthDataStore extends OAuthDataStore { + /** @var DBConnRef DB for the consumer/grant registry */ + protected $centralReplica; + + /** @var DBConnRef|null Master DB for repeated lookup in case of replication lag problems; + * null if there is no separate master and replica DB + */ + protected $centralMaster; + + /** @var \BagOStuff Cache for Tokens and Nonces */ + protected $cache; + + /** @var \Psr\Log\LoggerInterface */ + protected $logger; + + /** + * @param DBConnRef $centralReplica Central DB replica + * @param DBConnRef|null $centralMaster Central DB master (if different) + * @param \BagOStuff $cache + */ + public function __construct( DBConnRef $centralReplica, $centralMaster, \BagOStuff $cache ) { + if ( $centralMaster !== null && !( $centralMaster instanceof DBConnRef ) ) { + throw new \InvalidArgumentException( + __METHOD__ . ': $centralMaster must be a DB or null' + ); + } + $this->centralReplica = $centralReplica; + $this->centralMaster = $centralMaster; + $this->cache = $cache; + $this->logger = LoggerFactory::getInstance( 'OAuth' ); + } + + /** + * Get an MWOAuthConsumer from the consumer's key + * + * @param string $consumerKey the string value of the Consumer's key + * @return Consumer|bool + */ + public function lookup_consumer( $consumerKey ) { + return Consumer::newFromKey( $this->centralReplica, $consumerKey ); + } + + /** + * Get either a request or access token from the data store + * + * @param OAuthConsumer|Consumer $consumer + * @param string $token_type + * @param string $token String the token + * @throws MWOAuthException + * @return MWOAuthToken + */ + public function lookup_token( $consumer, $token_type, $token ) { + $this->logger->debug( __METHOD__ . ": Looking up $token_type token '$token'" ); + + if ( $token_type === 'request' ) { + $returnToken = $this->cache->get( Utils::getCacheKey( + 'token', + $consumer->key, + $token_type, + $token + ) ); + if ( $returnToken === '**USED**' ) { + throw new MWOAuthException( 'mwoauthdatastore-request-token-already-used', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E009', + 'E009', + true + ) ) + ] ); + } + if ( $token === null || !( $returnToken instanceof MWOAuthToken ) ) { + throw new MWOAuthException( 'mwoauthdatastore-request-token-not-found', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E004', + 'E004', + true + ) ) + ] ); + } + } elseif ( $token_type === 'access' ) { + $cmra = ConsumerAcceptance::newFromToken( $this->centralReplica, $token ); + if ( !$cmra && $this->centralMaster ) { + // try master in case there is replication lag T124942 + $cmra = ConsumerAcceptance::newFromToken( $this->centralMaster, $token ); + } + if ( !$cmra ) { + throw new MWOAuthException( 'mwoauthdatastore-access-token-not-found' ); + } + + // Ensure the cmra's consumer matches the expected consumer (T103023) + $mwconsumer = ( $consumer instanceof Consumer ) + ? $consumer : $this->lookup_consumer( $consumer->key ); + if ( !$mwconsumer || $mwconsumer->getId() !== $cmra->getConsumerId() ) { + throw new MWOAuthException( 'mwoauthdatastore-access-token-not-found' ); + } + + $secret = Utils::hmacDBSecret( $cmra->getAccessSecret() ); + $returnToken = new MWOAuthToken( $cmra->getAccessToken(), $secret ); + } else { + throw new MWOAuthException( 'mwoauthdatastore-invalid-token-type' ); + } + + return $returnToken; + } + + /** + * Check that nonce has not been seen before. Add it on check, so we don't repeat it. + * Note, timestamp has already been checked, so this should be a fresh nonce. + * + * @param Consumer|OAuthConsumer $consumer + * @param string $token + * @param string $nonce + * @param int $timestamp + * @return bool + */ + public function lookup_nonce( $consumer, $token, $nonce, $timestamp ) { + $key = Utils::getCacheKey( 'nonce', $consumer->key, $token, $nonce ); + // Do an add for the key associated with this nonce to check if it was already used. + // Set timeout 5 minutes in the future of the timestamp as OAuthServer does. Use the + // timestamp so the client can also expire their nonce records after 5 mins. + if ( !$this->cache->add( $key, 1, $timestamp + 300 ) ) { + $this->logger->info( "$key exists, so nonce has been used by this consumer+token" ); + return true; + } + return false; + } + + /** + * Helper function to generate and return an MWOAuthToken. MWOAuthToken can be used as a + * request or access token. + * TODO: put in Utils? + * @return MWOAuthToken + */ + public static function newToken() { + return new MWOAuthToken( + \MWCryptRand::generateHex( 32 ), + \MWCryptRand::generateHex( 32 ) + ); + } + + /** + * Generate a new token (attached to this consumer), save it in the cache, and return it + * + * @param Consumer|OAuthConsumer $consumer + * @param string $callback + * @return MWOAuthToken + */ + public function new_request_token( $consumer, $callback = 'oob' ) { + $token = self::newToken(); + $cacheConsumerKey = Utils::getCacheKey( 'consumer', 'request', $token->key ); + $cacheTokenKey = Utils::getCacheKey( + 'token', $consumer->key, 'request', $token->key + ); + $cacheCallbackKey = Utils::getCacheKey( + 'callback', $consumer->key, 'request', $token->key + ); + $this->cache->add( $cacheConsumerKey, $consumer->key, 600 ); // 10 minutes. Kindof arbitray. + $this->cache->add( $cacheTokenKey, $token, 600 ); // 10 minutes. Kindof arbitray. + $this->cache->add( $cacheCallbackKey, $callback, 600 ); // 10 minutes. Kindof arbitray. + $this->logger->debug( __METHOD__ . + ": New request token {$token->key} for {$consumer->key} with callback {$callback}" ); + return $token; + } + + /** + * Return a consumer key associated with the given request token. + * + * @param MWOAuthToken $requestToken the request token + * @return string|false the consumer key or false if nothing is stored for the request token + */ + public function getConsumerKey( $requestToken ) { + $cacheKey = Utils::getCacheKey( 'consumer', 'request', $requestToken ); + $consumerKey = $this->cache->get( $cacheKey ); + return $consumerKey; + } + + /** + * Return a stored callback URL parameter given by the consumer in /initiate. + * It throws an exception if callback URL parameter does not exist in the cache. + * A stored callback URL parameter is deleted from the cache once read for the first + * time. + * + * @param string $consumerKey the consumer key + * @param string $requestKey original request key from /initiate + * @throws MWOAuthException + * @return string|false the stored callback URL parameter + */ + public function getCallbackUrl( $consumerKey, $requestKey ) { + $cacheKey = Utils::getCacheKey( 'callback', $consumerKey, 'request', $requestKey ); + $callback = $this->cache->get( $cacheKey ); + if ( $callback === null || !is_string( $callback ) ) { + throw new MWOAuthException( 'mwoauthdatastore-callback-not-found' ); + } + $this->cache->delete( $cacheKey ); + return $callback; + } + + /** + * Return a new access token attached to this consumer for the user associated with this + * token if the request token is authorized. Should also invalidate the request token. + * + * @param MWOAuthToken $token the request token that started this + * @param Consumer $consumer + * @param int|null $verifier + * @throws MWOAuthException + * @return MWOAuthToken the access token + */ + public function new_access_token( $token, $consumer, $verifier = null ) { + $this->logger->debug( __METHOD__ . + ": Getting new access token for token {$token->key}, consumer {$consumer->key}" ); + + if ( !$token->getVerifyCode() || !$token->getAccessKey() ) { + throw new MWOAuthException( 'mwoauthdatastore-bad-token' ); + } elseif ( $token->getVerifyCode() !== $verifier ) { + throw new MWOAuthException( 'mwoauthdatastore-bad-verifier' ); + } + + $cacheKey = Utils::getCacheKey( 'token', + $consumer->getConsumerKey(), 'request', $token->key ); + $accessToken = $this->lookup_token( $consumer, 'access', $token->getAccessKey() ); + $this->cache->set( $cacheKey, '**USED**', 600 ); + $this->logger->debug( __METHOD__ . + ": New access token {$accessToken->key} for {$consumer->key}" ); + return $accessToken; + } + + /** + * Update a request token. The token probably already exists, but had another attribute added. + * + * @param MWOAuthToken $token the token to store + * @param Consumer|OAuthConsumer $consumer + */ + public function updateRequestToken( $token, $consumer ) { + $cacheKey = Utils::getCacheKey( 'token', $consumer->key, 'request', $token->key ); + $this->cache->set( $cacheKey, $token, 600 ); // 10 more minutes. Kindof arbitray. + } + + /** + * Return the string representing the Consumer's public RSA key + * + * @param string $consumerKey the string value of the Consumer's key + * @return string|null + */ + public function getRSAKey( $consumerKey ) { + $cmr = Consumer::newFromKey( $this->centralReplica, $consumerKey ); + return $cmr ? $cmr->getRsaKey() : null; + } +} diff --git a/OAuth/src/Backend/MWOAuthException.php b/OAuth/src/Backend/MWOAuthException.php new file mode 100644 index 00000000..844bb210 --- /dev/null +++ b/OAuth/src/Backend/MWOAuthException.php @@ -0,0 +1,23 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Lib\OAuthException; + +class MWOAuthException extends OAuthException { + public $msg, $params; + + /** + * Exception that may be shown to an end user + * @param string $msg Message key (string) for error text + * @param array|null $params with parameters to wfMessage() + */ + public function __construct( $msg, $params = null ) { + $this->msg = $msg; + $this->params = $params; + parent::__construct( + wfMessage( $msg, $params )->inLanguage( 'en' )->useDatabase( false )->plain() + ); + } + +} diff --git a/OAuth/src/Backend/MWOAuthRequest.php b/OAuth/src/Backend/MWOAuthRequest.php new file mode 100644 index 00000000..00dec824 --- /dev/null +++ b/OAuth/src/Backend/MWOAuthRequest.php @@ -0,0 +1,78 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Lib\OAuthRequest; +use MediaWiki\Extensions\OAuth\Lib\OAuthUtil; +use MediaWiki\Logger\LoggerFactory; + +/** + * @file + * @ingroup OAuth + * + * @license GPL-2.0-or-later + * @author Chris Steipp + */ + +class MWOAuthRequest extends OAuthRequest { + private $sourceIP; + + public function __construct( $httpMethod, $httpUrl, $parameters, $sourcIP = false ) { + $this->sourceIP = $sourcIP; + parent::__construct( $httpMethod, $httpUrl, $parameters ); + } + + public function getConsumerKey() { + $key = ''; + if ( isset( $this->parameters['oauth_consumer_key'] ) ) { + $key = $this->parameters['oauth_consumer_key']; + } + return $key; + } + + /** + * Track the source IP of the request, so we can enforce the IP whitelist + * @return string $ip the ip of the source + */ + public function getSourceIP() { + return $this->sourceIP; + } + + public static function fromRequest( \WebRequest $request ) { + $httpMethod = strtoupper( $request->getMethod() ); + $httpUrl = $request->getFullRequestURL(); + $logger = LoggerFactory::getInstance( 'OAuth' ); + + // Find request headers + $requestHeaders = Utils::getHeaders(); + + // Parse the query-string to find GET parameters + $parameters = $request->getQueryValuesOnly(); + + // It's a POST request of the proper content-type, so parse POST + // parameters and add those overriding any duplicates from GET + if ( $request->wasPosted() + && isset( $requestHeaders['Content-Type'] ) + && strpos( + $requestHeaders['Content-Type'], + 'application/x-www-form-urlencoded' + ) === 0 + ) { + $postData = OAuthUtil::parse_parameters( $request->getRawPostString() ); + $parameters = array_merge( $parameters, $postData ); + } + + // We have a Authorization-header with OAuth data. Parse the header + // and add those overriding any duplicates from GET or POST + if ( isset( $requestHeaders['Authorization'] ) + && substr( $requestHeaders['Authorization'], 0, 6 ) == 'OAuth ' + ) { + $headerParameters = OAuthUtil::split_header( + $requestHeaders['Authorization'] + ); + $parameters = array_merge( $parameters, $headerParameters ); + } + + return new self( $httpMethod, $httpUrl, $parameters, $request->getIP() ); + } +} diff --git a/OAuth/src/Backend/MWOAuthServer.php b/OAuth/src/Backend/MWOAuthServer.php new file mode 100644 index 00000000..f57c8492 --- /dev/null +++ b/OAuth/src/Backend/MWOAuthServer.php @@ -0,0 +1,328 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Lib\OAuthServer; + +class MWOAuthServer extends OAuthServer { + /** @var MWOAuthDataStore */ + protected $data_store; + + /** + * Return a consumer key associated with the given request token. + * + * @param MWOAuthToken $requestToken the request token + * @return string|false the consumer key or false if nothing is stored for the request token + */ + public function getConsumerKey( $requestToken ) { + return $this->data_store->getConsumerKey( $requestToken ); + } + + /** + * Process a request_token request returns the request token on success. This + * also checks the IP restriction, which the OAuthServer method did not. + * + * @param MWOAuthRequest &$request the request + * @return MWOAuthToken + * @throws MWOAuthException + */ + public function fetch_request_token( &$request ) { + $this->get_version( $request ); + + /** @var Consumer $consumer */ + $consumer = $this->get_consumer( $request ); + + // Consumer must not be owner-only + if ( $consumer->getOwnerOnly() ) { + throw new MWOAuthException( 'mwoauthserver-consumer-owner-only', [ + $consumer->getName(), + \SpecialPage::getTitleFor( + 'OAuthConsumerRegistration', 'update/' . $consumer->getConsumerKey() + ), + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E010', + 'E010', + true + ) ) + ] ); + } + + // Consumer must have a key for us to verify + if ( !$consumer->getSecretKey() && !$consumer->getRsaKey() ) { + throw new MWOAuthException( 'mwoauthserver-consumer-no-secret', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E011', + 'E011', + true + ) ) + ] ); + } + + $this->checkSourceIP( $consumer, $request ); + + // no token required for the initial token request + $token = null; + + $this->check_signature( $request, $consumer, $token ); + + $callback = $request->get_parameter( 'oauth_callback' ); + + $this->checkCallback( $consumer, $callback ); + + $new_token = $this->data_store->new_request_token( $consumer, $callback ); + $new_token->oauth_callback_confirmed = 'true'; + return $new_token; + } + + /** + * Ensure the callback is "oob" or that the registered callback is a valid + * prefix of the supplied callback. It throws an exception if callback is + * invalid. + * + * In MediaWiki, we require the callback to be established at + * registration. OAuth 1.0a (rfc5849, section 2.1) specifies that + * oauth_callback is required for the temporary credentials, and "If the + * client is unable to receive callbacks or a callback URI has been + * established via other means, the parameter value MUST be set to "oob" + * (case sensitive), to indicate an out-of-band configuration." Otherwise, + * client can provide a callback and the configured callback must be + * a prefix of the supplied callback. The matching performed here is based + * on parsed URL components rather than strict string matching. Protocol + * upgrades from http to https are also allowed. + * + * @param Consumer $consumer + * @param string $callback + * @return void + * @throws MWOAuthException + */ + private function checkCallback( $consumer, $callback ) { + if ( !$consumer->getCallbackIsPrefix() ) { + if ( $callback !== 'oob' ) { + throw new MWOAuthException( 'mwoauth-callback-not-oob' ); + } + + return; + } + + if ( !$callback ) { + throw new MWOAuthException( 'mwoauth-callback-not-oob-or-prefix' ); + } + if ( $callback === 'oob' ) { + return; + } + + $reqCallback = wfParseUrl( $callback ); + if ( $reqCallback === false ) { + throw new MWOAuthException( 'mwoauth-callback-not-oob-or-prefix' ); + } + + $knownCallback = wfParseUrl( $consumer->getCallbackUrl() ); + $exactPath = array_key_exists( 'query', $knownCallback ); + + $match = + // Protocol can be upgraded from http to https + self::looseSchemeMatch( $knownCallback['scheme'], $reqCallback['scheme'] ) && + // Host must match exactly + $knownCallback['host'] === $reqCallback['host'] && + // Port must be either missing from both or an exact match + static::getOrNull( 'port', $knownCallback ) === + static::getOrNull( 'port', $reqCallback ) && + // Path must be an exact match if query is provided in the + // registered callback. Otherwise it must be a prefix match if + // provided in the registered callback or anything if no path was + // included in the registered callback at all. + static::componentMatches( 'path', $knownCallback, $reqCallback, $exactPath ) && + // Query string must be aprefix match if provided in the + // registered callback. + static::componentMatches( 'query', $knownCallback, $reqCallback ); + + if ( !$match ) { + throw new MWOAuthException( 'mwoauth-callback-not-oob-or-prefix' ); + } + } + + /** + * Compare URL schemes for a match. + * + * Allows 'https' to match an expected 'http' value. + * + * @param string $want + * @param string $got + * @return bool + */ + private static function looseSchemeMatch( $want, $got ) { + if ( $want === 'http' ) { + return in_array( $got, [ 'http', 'https' ], true ); + } else { + return $want === $got; + } + } + + /** + * Get a named value from an array or return null if the key does not + * exist. + * + * @param string $key + * @param array $arr + * @return mixed + */ + private static function getOrNull( $key, $arr ) { + return array_key_exists( $key, $arr ) ? $arr[$key] : null; + } + + /** + * Check that a callback URL component matches the expected value. + * + * @param string $part URL component name + * @param array $expect Expected URL components + * @param array $got Posted URl components + * @param bool $exact Perform exact match instead of prefix match + * @return bool + */ + private static function componentMatches( + $part, $expect, $got, $exact = false + ) { + $match = false; + if ( !array_key_exists( $part, $expect ) ) { + // Anything in the request is ok if we do not have the URL part in + // the expected values + $match = true; + } elseif ( !array_key_exists( $part, $got ) ) { + $match = false; + } elseif ( $exact ) { + $match = $expect[$part] === $got[$part]; + } else { + $want = (string)$expect[$part]; + $have = (string)$got[$part]; + $len = strlen( $want ); + $match = $want === substr( $have, 0, $len ); + } + return $match; + } + + /** + * process an access_token request + * returns the access token on success + * + * @param MWOAuthRequest &$request the request + * @return MWOAuthToken + * @throws MWOAuthException + */ + public function fetch_access_token( &$request ) { + $this->get_version( $request ); + + /** @var Consumer $consumer */ + $consumer = $this->get_consumer( $request ); + + // Consumer must not be owner-only + if ( $consumer->getOwnerOnly() ) { + throw new MWOAuthException( 'mwoauthserver-consumer-owner-only', [ + $consumer->getName(), + \SpecialPage::getTitleFor( + 'OAuthConsumerRegistration', 'update/' . $consumer->getConsumerKey() + ), + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E010', + 'E010', + true + ) ) + ] ); + } + + // Consumer must have a key for us to verify + if ( !$consumer->getSecretKey() && !$consumer->getRsaKey() ) { + throw new MWOAuthException( 'mwoauthserver-consumer-no-secret', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E011', + 'E011', + true + ) ) + ] ); + } + + $this->checkSourceIP( $consumer, $request ); + + // requires authorized request token + $token = $this->get_token( $request, $consumer, 'request' ); + + if ( !$token->secret ) { + // This token has a blank secret.. something is wrong + throw new MWOAuthException( 'mwoauthdatastore-bad-token' ); + } + + $this->check_signature( $request, $consumer, $token ); + + // Rev A change + $verifier = $request->get_parameter( 'oauth_verifier' ); + $this->logger->debug( __METHOD__ . ": verify code is '$verifier'" ); + $new_token = $this->data_store->new_access_token( $token, $consumer, $verifier ); + + return $new_token; + } + + /** + * Wrap the call to the parent function and check that the source IP of + * the request is allowed by this consumer's restrictions. + * @param MWOAuthRequest &$request + * @return array + */ + public function verify_request( &$request ) { + list( $consumer, $token ) = parent::verify_request( $request ); + $this->checkSourceIP( $consumer, $request ); + return [ $consumer, $token ]; + } + + /** + * Ensure the request comes from an approved IP address, if IP restriction has been + * setup by the Consumer. It throws an exception if IP address is invalid. + * + * @param Consumer $consumer + * @param MWOAuthRequest $request + * @throws MWOAuthException + */ + private function checkSourceIP( $consumer, $request ) { + $restrictions = $consumer->getRestrictions(); + if ( !$restrictions->checkIP( $request->getSourceIP() ) ) { + throw new MWOAuthException( 'mwoauthdatastore-bad-source-ip' ); + } + } + + /** + * @deprecated User MWOAuthConsumer::authorize(...) + * + * @param string $consumerKey + * @param string $requestTokenKey + * @param \User $mwUser + * @param bool $update + * @return string + */ + public function authorize( $consumerKey, $requestTokenKey, \User $mwUser, $update ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + $consumer = Consumer::newFromKey( $dbr, $consumerKey ); + return $consumer->authorize( $mwUser, $update, $consumer->getGrants(), $requestTokenKey ); + } + + /** + * Attempts to find an authorization by this user for this consumer. Since a user can + * accept a consumer multiple times (once for "*" and once for each specific wiki), + * there can several access tokens per-wiki (with varying grants) for a consumer. + * This will choose the most wiki-specific access token. The precedence is: + * a) The acceptance for wiki X if the consumer is applicable only to wiki X + * b) The acceptance for wiki $wikiId (if the consumer is applicable to it) + * c) The acceptance for wikis "*" (all wikis) + * + * Users might want more grants on some wikis than on "*". Note that the reverse would not + * make sense, since the consumer could just use the "*" acceptance if it has more grants. + * + * @param \User $mwUser (local wiki user) User who may or may not have authorizations + * @param Consumer $consumer + * @param string $wikiId + * @throws MWOAuthException + * @return ConsumerAcceptance + * @deprecated Use MWOAuthConsumer::getCurrentAuthorization(...) + */ + public function getCurrentAuthorization( \User $mwUser, $consumer, $wikiId ) { + wfDeprecated( __METHOD__ ); + return $consumer->getCurrentAuthorization( $mwUser, $wikiId ); + } +} diff --git a/OAuth/src/Backend/MWOAuthSignatureMethod_RSA_SHA1.php b/OAuth/src/Backend/MWOAuthSignatureMethod_RSA_SHA1.php new file mode 100644 index 00000000..46e7b63f --- /dev/null +++ b/OAuth/src/Backend/MWOAuthSignatureMethod_RSA_SHA1.php @@ -0,0 +1,61 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Lib\OAuthDataStore; +use MediaWiki\Extensions\OAuth\Lib\OAuthException; +use MediaWiki\Extensions\OAuth\Lib\OAuthRequest; +use MediaWiki\Extensions\OAuth\Lib\OAuthSignatureMethod_RSA_SHA1; + +class MWOAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod_RSA_SHA1 { + /** @var MWOAuthDataStore */ + protected $store; + /** @var string PEM encoded RSA private key */ + private $privateKey; + + /** + * @param OAuthDataStore $store + * @param string|null $privateKey RSA private key, passed to openssl_get_privatekey + */ + public function __construct( OAuthDataStore $store, $privateKey = null ) { + $this->store = $store; + $this->privateKey = $privateKey; + + if ( $privateKey !== null ) { + $key = openssl_pkey_get_private( $privateKey ); + if ( !$key ) { + throw new OAuthException( "Invalid private key given" ); + } + $details = openssl_pkey_get_details( $key ); + if ( $details['type'] !== OPENSSL_KEYTYPE_RSA ) { + throw new OAuthException( "Key is not an RSA key" ); + } + openssl_pkey_free( $key ); + } + } + + /** + * Get the public certificate, used to verify the request. In our case, we get + * the Consumer's key, and lookup the registered cert from the datastore. + * @param OAuthRequest &$request request recieved by the server, that we're going to verify + * @return string representing the public certificate + */ + protected function fetch_public_cert( &$request ) { + $consumerKey = $request->get_parameter( 'oauth_consumer_key' ); + return $this->store->getRSAKey( $consumerKey ); + } + + /** + * If you want to reuse this code to write your Consumer, implement + * this function to get your private key, so you can sign the request. + * @param OAuthRequest &$request + * @return string + * @throws OAuthException + */ + protected function fetch_private_cert( &$request ) { + if ( $this->privateKey === null ) { + throw new OAuthException( "No private key was set" ); + } + return $this->privateKey; + } +} diff --git a/OAuth/src/Backend/MWOAuthToken.php b/OAuth/src/Backend/MWOAuthToken.php new file mode 100644 index 00000000..82315403 --- /dev/null +++ b/OAuth/src/Backend/MWOAuthToken.php @@ -0,0 +1,28 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MediaWiki\Extensions\OAuth\Lib\OAuthToken; + +class MWOAuthToken extends OAuthToken { + // Keep the verification code here + protected $code; + // Token to find grant in oauth_accepted_consumer + protected $accessTokenKey; + + public function addVerifyCode( $code ) { + $this->code = $code; + } + + public function getVerifyCode() { + return $this->code; + } + + public function addAccessKey( $key ) { + $this->accessTokenKey = $key; + } + + public function getAccessKey() { + return $this->accessTokenKey; + } +} diff --git a/OAuth/src/Backend/OAuth1Consumer.php b/OAuth/src/Backend/OAuth1Consumer.php new file mode 100644 index 00000000..452c9f9c --- /dev/null +++ b/OAuth/src/Backend/OAuth1Consumer.php @@ -0,0 +1,80 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use MWException; +use User; + +/** + * (c) Dejan Savuljesku 2019, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * This class mainly exists to enable clean separation + * of OAuth 1.0a and OAuth 2.0 code + * + * Representation of an OAuth 1.0a consumer. + */ +class OAuth1Consumer extends Consumer { + + /** + * The user has authorized the request by this consumer, with this request token. Update + * everything so that the consumer can swap the request token for an access token. Then + * generate the callback URL where we will redirect our user back to the consumer. + * + * @param User $mwUser + * @param bool $update + * @param array $grants + * @param null $requestTokenKey + * @return string + * @throws MWOAuthException + * @throws MWException + */ + public function authorize( \User $mwUser, $update, $grants, $requestTokenKey = null ) { + $this->conductAuthorizationChecks( $mwUser ); + + // Generate and Update the tokens: + // * Generate a new Verification code, and add it to the request token + // * Either add or update the authorization + // ** Generate a new access token if this is a new authorization + // * Resave request token with the access token + $verifyCode = \MWCryptRand::generateHex( 32 ); + $store = Utils::newMWOAuthDataStore(); + $requestToken = $store->lookup_token( $this, 'request', $requestTokenKey ); + if ( !$requestToken || !( $requestToken instanceof MWOAuthToken ) ) { + throw new MWOAuthException( 'mwoauthserver-invalid-request-token' ); + } + $requestToken->addVerifyCode( $verifyCode ); + + $cmra = $this->saveAuthorization( $mwUser, $update, $grants ); + $accessToken = new MWOAuthToken( $cmra->getAccessToken(), '' ); + + $requestToken->addAccessKey( $accessToken->key ); + $store->updateRequestToken( $requestToken, $this ); + return $this->generateCallbackUrl( + $store, $requestToken->getVerifyCode(), $requestTokenKey + ); + } + + /** + * @return int + */ + public function getOAuthVersion() { + return static::OAUTH_VERSION_1; + } +} diff --git a/OAuth/src/Backend/UpdaterHooks.php b/OAuth/src/Backend/UpdaterHooks.php new file mode 100644 index 00000000..47eaae74 --- /dev/null +++ b/OAuth/src/Backend/UpdaterHooks.php @@ -0,0 +1,98 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +/** + * Class containing updater functions for an OAuth environment + */ +class UpdaterHooks { + /** + * @param \DatabaseUpdater $updater + * @return bool + */ + public static function addSchemaUpdates( \DatabaseUpdater $updater ) { + if ( !Utils::isCentralWiki() ) { + return true; // no tables to add + } + + $dbType = $updater->getDB()->getType(); + + if ( $dbType == 'mysql' || $dbType == 'sqlite' ) { + + $updater->addExtensionTable( + 'oauth_registered_consumer', + self::getPath( 'OAuth.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_registered_consumer', + 'oarc_callback_is_prefix', + self::getPath( 'callback_is_prefix.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_registered_consumer', + 'oarc_developer_agreement', + self::getPath( 'developer_agreement.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_registered_consumer', + 'oarc_owner_only', + self::getPath( 'owner_only.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_registered_consumer', + 'oarc_oauth_version', + self::getPath( 'oauth_version_registered.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_registered_consumer', + 'oarc_oauth2_is_confidential', + self::getPath( 'oauth2_is_confidential.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_registered_consumer', + 'oarc_oauth2_allowed_grants', + self::getPath( 'oauth2_allowed_grants.sql', $dbType ) + ); + + $updater->addExtensionField( + 'oauth_accepted_consumer', + 'oaac_oauth_version', + self::getPath( 'oauth_version_accepted.sql', $dbType ) + ); + + $updater->addExtensionTable( + 'oauth2_access_tokens', + self::getPath( 'oauth2_access_tokens.sql', $dbType ) + ); + + $updater->addExtensionIndex( + 'oauth2_access_tokens', + 'oaat_acceptance_id', + self::getPath( 'index_on_oaat_acceptance_id.sql', $dbType ) + ); + + } + return true; + } + + /** + * @param string $filename Name of the patch file (without path). + * The file should be in the schema/<dbtype>/ directory + * or the schema/ directory. + * @param string $dbType 'mysql' or 'sqlite' + * @return string + */ + protected static function getPath( $filename, $dbType ) { + $base = dirname( dirname( __DIR__ ) ) . '/schema'; + if ( file_exists( "$base/$dbType/$filename" ) ) { + return "$base/$dbType/$filename"; + } + return "$base/$filename"; + } +} diff --git a/OAuth/src/Backend/Utils.php b/OAuth/src/Backend/Utils.php new file mode 100644 index 00000000..7c6bafd7 --- /dev/null +++ b/OAuth/src/Backend/Utils.php @@ -0,0 +1,471 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Backend; + +use EchoEvent; +use MediaWiki\Extensions\OAuth\Lib\OAuthSignatureMethod_HMAC_SHA1; +use MediaWiki\MediaWikiServices; +use User; +use Wikimedia\Rdbms\DBConnRef; +use Wikimedia\Rdbms\IDatabase; + +/** + * Static utility functions for OAuth + * + * @file + * @ingroup OAuth + */ +class Utils { + /** + * @return bool + */ + public static function isCentralWiki() { + global $wgMWOAuthCentralWiki; + + return ( wfWikiId() === $wgMWOAuthCentralWiki ); + } + + /** + * @param int $index DB_MASTER/DB_REPLICA + * @return DBConnRef + */ + public static function getCentralDB( $index ) { + global $wgMWOAuthCentralWiki, $wgMWOAuthReadOnly; + + $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); + + // T244415: Use the master if there were changes + if ( $index === DB_REPLICA && $lbFactory->hasOrMadeRecentMasterChanges() ) { + $index = DB_MASTER; + } + + $db = $lbFactory->getMainLB( $wgMWOAuthCentralWiki )->getLazyConnectionRef( + $index, [], $wgMWOAuthCentralWiki ); + $db->daoReadOnly = $wgMWOAuthReadOnly; + return $db; + } + + /** + * @return \BagOStuff + */ + public static function getSessionCache() { + global $wgMWOAuthSessionCacheType; + global $wgSessionCacheType; + + $sessionCacheType = $wgMWOAuthSessionCacheType ?? $wgSessionCacheType; + return \ObjectCache::getInstance( $sessionCacheType ); + } + + /** + * @param DBConnRef $db + * @return array + */ + public static function getConsumerStateCounts( DBConnRef $db ) { + $res = $db->select( 'oauth_registered_consumer', + [ 'oarc_stage', 'count' => 'COUNT(*)' ], + [], + __METHOD__, + [ 'GROUP BY' => 'oarc_stage' ] + ); + $table = [ + Consumer::STAGE_APPROVED => 0, + Consumer::STAGE_DISABLED => 0, + Consumer::STAGE_EXPIRED => 0, + Consumer::STAGE_PROPOSED => 0, + Consumer::STAGE_REJECTED => 0, + ]; + foreach ( $res as $row ) { + $table[(int)$row->oarc_stage] = (int)$row->count; + } + return $table; + } + + /** + * Get request headers. + * Sanitizes the output of apache_request_headers because + * we always want the keys to be Cased-Like-This and arh() + * returns the headers in the same case as they are in the + * request + * @return array Header name => value + */ + public static function getHeaders() { + $request = \RequestContext::getMain()->getRequest(); + $headers = $request->getAllHeaders(); + + $out = []; + foreach ( $headers as $key => $value ) { + $key = str_replace( + " ", + "-", + ucwords( strtolower( str_replace( "-", " ", $key ) ) ) + ); + $out[$key] = $value; + } + return $out; + } + + /** + * Test this request for an OAuth Authorization header + * @param \WebRequest $request the MediaWiki request + * @return bool true if a header was found + */ + public static function hasOAuthHeaders( \WebRequest $request ) { + $header = $request->getHeader( 'Authorization' ); + if ( $header !== false && substr( $header, 0, 6 ) == 'OAuth ' ) { + return true; + } + return false; + } + + /** + * Make a cache key for the given arguments, that (hopefully) won't clash with + * anything else in your cache + * @param string ...$args + * @return string + */ + public static function getCacheKey( ...$args ) { + global $wgMWOAuthCentralWiki; + + return "OAUTH:$wgMWOAuthCentralWiki:" . implode( ':', $args ); + } + + /** + * @param DBConnRef $dbw + * @return void + */ + public static function runAutoMaintenance( DBConnRef $dbw ) { + global $wgMWOAuthRequestExpirationAge; + + if ( $wgMWOAuthRequestExpirationAge <= 0 ) { + return; + } + + $cutoff = time() - $wgMWOAuthRequestExpirationAge; + $fname = __METHOD__; + \DeferredUpdates::addUpdate( + new \AutoCommitUpdate( + $dbw, + __METHOD__, + function ( IDatabase $dbw ) use ( $cutoff, $fname ) { + $dbw->update( + 'oauth_registered_consumer', + [ + 'oarc_stage' => Consumer::STAGE_EXPIRED, + 'oarc_stage_timestamp' => $dbw->timestamp() + ], + [ + 'oarc_stage' => Consumer::STAGE_PROPOSED, + 'oarc_stage_timestamp < ' . + $dbw->addQuotes( $dbw->timestamp( $cutoff ) ) + ], + $fname + ); + } + ) + ); + } + + /** + * Get the pretty name of an OAuth wiki ID restriction value + * + * @param string $wikiId A wiki ID or '*' + * @return string + */ + public static function getWikiIdName( $wikiId ) { + if ( $wikiId === '*' ) { + return wfMessage( 'mwoauth-consumer-allwikis' )->text(); + } else { + $host = \WikiMap::getWikiName( $wikiId ); + if ( strpos( $host, '.' ) ) { + return $host; // e.g. "en.wikipedia.org" + } else { + return $wikiId; + } + } + } + + /** + * Get the pretty names of all local wikis + * + * @return array associative array of local wiki names indexed by wiki ID + */ + public static function getAllWikiNames() { + global $wgConf; + $wikiNames = []; + foreach ( $wgConf->getLocalDatabases() as $dbname ) { + $name = self::getWikiIdName( $dbname ); + if ( $name != $dbname ) { + $wikiNames[$dbname] = $name; + } + } + return $wikiNames; + } + + /** + * Quickly get a new server with all the default configurations + * + * @return MWOAuthServer with default configurations + */ + public static function newMWOAuthServer() { + $store = static::newMWOAuthDataStore(); + $server = new MWOAuthServer( $store ); + $server->add_signature_method( new OAuthSignatureMethod_HMAC_SHA1() ); + $server->add_signature_method( new MWOAuthSignatureMethod_RSA_SHA1( $store ) ); + + return $server; + } + + public static function newMWOAuthDataStore() { + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $dbr = self::getCentralDB( DB_REPLICA ); + $dbw = $lb->getServerCount() > 1 ? self::getCentralDB( DB_MASTER ) : null; + return new MWOAuthDataStore( $dbr, $dbw, self::getSessionCache() ); + } + + /** + * Given a central wiki user ID, get a central user name + * + * @param int $userId + * @param bool|\User|string $audience show hidden names based on this user, or false for public + * @throws \MWException + * @return string|bool User name, false if not found, empty string if name is hidden + */ + public static function getCentralUserNameFromId( $userId, $audience = false ) { + global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource; + + if ( $wgMWOAuthSharedUserIDs ) { // global ID required via hook + $lookup = \CentralIdLookup::factory( $wgMWOAuthSharedUserSource ); + $name = $lookup->nameFromCentralId( + $userId, + $audience === 'raw' + ? \CentralIdLookup::AUDIENCE_RAW + : ( $audience ?: \CentralIdLookup::AUDIENCE_PUBLIC ) + ); + if ( $name === null ) { + $name = false; + } + } else { + $name = ''; + $user = \User::newFromId( $userId ); + if ( $audience === 'raw' + || !$user->isHidden() + || ( $audience instanceof \User && $audience->isAllowed( 'hideuser' ) ) + ) { + $name = $user->getName(); + } + } + + return $name; + } + + /** + * Given a central wiki user ID, get a local User object + * + * @param int $userId + * @throws \MWException + * @return \User|bool User or false if not found + */ + public static function getLocalUserFromCentralId( $userId ) { + global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource; + + if ( $wgMWOAuthSharedUserIDs ) { // global ID required via hook + $lookup = \CentralIdLookup::factory( $wgMWOAuthSharedUserSource ); + $user = $lookup->localUserFromCentralId( $userId ); + if ( $user === null || !$lookup->isAttached( $user ) ) { + $user = false; + } + } else { + $user = \User::newFromId( $userId ); + } + + return $user; + } + + /** + * Given a local User object, get the user ID for that user on the central wiki + * + * @param \User $user + * @throws \MWException + * @return int|bool ID or false if not found + */ + public static function getCentralIdFromLocalUser( \User $user ) { + global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource; + + if ( $wgMWOAuthSharedUserIDs ) { // global ID required via hook + // T227688 do not rely on array autocreation for non-stdClass + if ( !isset( $user->oAuthUserData ) ) { + $user->oAuthUserData = []; + } + + if ( isset( $user->oAuthUserData['centralId'] ) ) { + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $id = $user->oAuthUserData['centralId']; + } else { + $lookup = \CentralIdLookup::factory( $wgMWOAuthSharedUserSource ); + if ( !$lookup->isAttached( $user ) ) { + $id = false; + } else { + $id = $lookup->centralIdFromLocalUser( $user ); + if ( $id === 0 ) { + $id = false; + } + } + // Process cache the result to avoid queries + $user->oAuthUserData['centralId'] = $id; + } + } else { + $id = $user->getId(); + } + + return $id; + } + + /** + * Given a username, get the user ID for that user on the central wiki. + * @param string $username + * @throws \MWException + * @return int|bool ID or false if not found + */ + public static function getCentralIdFromUserName( $username ) { + global $wgMWOAuthSharedUserIDs, $wgMWOAuthSharedUserSource; + + if ( $wgMWOAuthSharedUserIDs ) { // global ID required via hook + $lookup = \CentralIdLookup::factory( $wgMWOAuthSharedUserSource ); + $id = $lookup->centralIdFromName( $username ); + if ( $id === 0 ) { + $id = false; + } + } else { + $id = false; + $user = \User::newFromName( $username ); + if ( $user instanceof \User && $user->getId() > 0 ) { + $id = $user->getId(); + } + } + + return $id; + } + + /** + * Get the effective secret key/token to use for OAuth purposes. + * + * For example, the "secret key" and "access secret" values that are + * used for authenticating request should be the result of applying this + * function to the respective values stored in the DB. This means that + * a leak of DB values is not enough to impersonate consumers. + * + * @param string $secret + * @return string + */ + public static function hmacDBSecret( $secret ) { + global $wgOAuthSecretKey, $wgSecretKey; + + if ( empty( $wgOAuthSecretKey ) ) { + $secretKey = $wgSecretKey; + } else { + $secretKey = $wgOAuthSecretKey; + } + + return $secretKey ? hash_hmac( 'sha1', $secret, $secretKey ) : $secret; + } + + /** + * Get a link to the central wiki's user talk page of a user. + * + * @param string $username the username of the User Talk link + * @return string the (proto-relative, urlencoded) url of the central wiki's user talk page + */ + public static function getCentralUserTalk( $username ) { + global $wgMWOAuthCentralWiki, $wgMWOAuthSharedUserIDs; + + if ( $wgMWOAuthSharedUserIDs ) { + $url = \WikiMap::getForeignURL( + $wgMWOAuthCentralWiki, + "User_talk:$username" + ); + } else { + $url = \Title::makeTitleSafe( NS_USER_TALK, $username )->getFullURL(); + } + return $url; + } + + /** + * @param array $grants + * @return bool + */ + public static function grantsAreValid( array $grants ) { + // Remove our special grants before calling the core method + $grants = array_diff( $grants, [ 'mwoauth-authonly', 'mwoauth-authonlyprivate' ] ); + return \MWGrants::grantsAreValid( $grants ); + } + + /** + * Given an OAuth consumer stage change event, find out who needs to be notified. + * Will be used as an EchoAttributeManager::ATTR_LOCATORS callback. + * @param EchoEvent $event + * @return User[] + */ + public static function locateUsersToNotify( EchoEvent $event ) { + $agent = $event->getAgent(); + $owner = self::getLocalUserFromCentralId( $event->getExtraParam( 'owner-id' ) ); + + $users = []; + switch ( $event->getType() ) { + case 'oauth-app-propose': + // notify OAuth admins about new proposed apps + $oauthAdmins = self::getOAuthAdmins(); + foreach ( $oauthAdmins as $admin ) { + if ( $admin->equals( $owner ) ) { + continue; + } + $users[$admin->getId()] = $admin; + } + break; + case 'oauth-app-update': + case 'oauth-app-approve': + case 'oauth-app-reject': + case 'oauth-app-disable': + case 'oauth-app-reenable': + // notify owner if someone else changed the status of the app + if ( !$owner->equals( $agent ) ) { + $users[$owner->getId()] = $owner; + } + break; + } + return $users; + } + + /** + * Get the change tag name for a given consumer. + * @param int $consumerId + * @return string + */ + public static function getTagName( $consumerId ) { + return 'OAuth CID: ' . (int)$consumerId; + } + + /** + * Check if a given change tag name should be reserved for this extension. + * @param string $tagName + * @return bool + */ + public static function isReservedTagName( $tagName ) { + return strpos( strtolower( $tagName ), 'oauth cid:' ) === 0; + } + + /** + * Return a list of all OAuth admins (or the first 5000 in the unlikely case that there is more + * than that). + * Should be called on the central OAuth wiki. + * @return User[] + */ + protected static function getOAuthAdmins() { + global $wgOAuthGroupsToNotify; + + if ( !$wgOAuthGroupsToNotify ) { + return []; + } + + return iterator_to_array( User::findUsersByGroup( $wgOAuthGroupsToNotify ) ); + } +} diff --git a/OAuth/src/Control/ConsumerAcceptanceAccessControl.php b/OAuth/src/Control/ConsumerAcceptanceAccessControl.php new file mode 100644 index 00000000..43551f79 --- /dev/null +++ b/OAuth/src/Control/ConsumerAcceptanceAccessControl.php @@ -0,0 +1,105 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Control; + +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\Utils; + +class ConsumerAcceptanceAccessControl extends DAOAccessControl { + // accessor fields copied from MWOAuthConsumerAcceptance, except they can return a Message + // on access error + + /** + * Database ID. + * Returns a Message when the user does not have permission to see this field. + * @return int|\Message + */ + public function getId() { + return $this->get( 'id' ); + } + + /** + * Wiki on which the user has authorized the consumer to access their account. Wiki ID or '*' + * for all. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getWiki() { + return $this->get( 'wiki' ); + } + + /** + * Central user ID of the authorizing user. + * Returns a Message when the user does not have permission to see this field. + * @return int|\Message + */ + public function getUserId() { + return $this->get( 'userId' ); + } + + /** + * Database ID of the consumer. + * Returns a Message when the user does not have permission to see this field. + * @return int|\Message + */ + public function getConsumerId() { + return $this->get( 'consumerId' ); + } + + /** + * The access token for the OAuth protocol + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getAccessToken() { + return $this->get( 'accessToken' ); + } + + /** + * Secret key used to derive the access secret for the OAuth protocol. + * The actual access secret will be calculated via MWOAuthUtils::hmacDBSecret() to mitigate + * DB leaks. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getAccessSecret() { + return $this->get( 'accessSecret' ); + } + + /** + * The list of grants which have been granted. + * Returns a Message when the user does not have permission to see this field. + * @return string[]|\Message + */ + public function getGrants() { + return $this->get( 'grants' ); + } + + /** + * Date of the authorization, in TS_MW format. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getAccepted() { + return $this->get( 'accepted' ); + } + + // accessors for common formatting + + /** + * Pretty wiki name. + * @return string|\Message + */ + public function getWikiName() { + return $this->get( 'wiki', function ( $wikiId ) { + return Utils::getWikiIdName( $wikiId ); + } ); + } + + /** + * @return ConsumerAcceptance + */ + public function getDAO() { + return $this->dao; + } +} diff --git a/OAuth/src/Control/ConsumerAcceptanceSubmitControl.php b/OAuth/src/Control/ConsumerAcceptanceSubmitControl.php new file mode 100644 index 00000000..ce7d3fcb --- /dev/null +++ b/OAuth/src/Control/ConsumerAcceptanceSubmitControl.php @@ -0,0 +1,234 @@ +<?php + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +namespace MediaWiki\Extensions\OAuth\Control; + +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Lib\OAuthException; +use MediaWiki\Extensions\OAuth\Repository\AccessTokenRepository; +use MediaWiki\Logger\LoggerFactory; +use Wikimedia\Rdbms\DBConnRef; + +/** + * This handles the core logic of submitting/approving application + * consumer requests and the logic of managing approved consumers + * + * This control can be used on any wiki, not just the management one + * + * @TODO: improve error messages + */ +class ConsumerAcceptanceSubmitControl extends SubmitControl { + /** @var DBConnRef */ + protected $dbw; + + /** @var int */ + protected $oauthVersion; + + /** + * @param \IContextSource $context + * @param array $params + * @param DBConnRef $dbw Result of MWOAuthUtils::getCentralDB( DB_MASTER ) + * @param int $oauthVersion + */ + public function __construct( + \IContextSource $context, array $params, DBConnRef $dbw, $oauthVersion + ) { + parent::__construct( $context, $params ); + $this->dbw = $dbw; + $this->oauthVersion = (int)$oauthVersion; + } + + protected function getRequiredFields() { + $required = [ + 'update' => [ + 'acceptanceId' => '/^\d+$/', + 'grants' => function ( $s ) { + $grants = \FormatJson::decode( $s, true ); + return is_array( $grants ) && Utils::grantsAreValid( $grants ); + } + ], + 'renounce' => [ + 'acceptanceId' => '/^\d+$/', + ], + ]; + if ( $this->isOAuth2() ) { + $required['accept'] = [ + 'client_id' => '/^[0-9a-f]{32}$/', + 'confirmUpdate' => '/^[01]$/', + ]; + } else { + $required['accept'] = [ + 'consumerKey' => '/^[0-9a-f]{32}$/', + 'requestToken' => '/^[0-9a-f]{32}$/', + 'confirmUpdate' => '/^[01]$/', + ]; + } + + return $required; + } + + protected function checkBasePermissions() { + $user = $this->getUser(); + if ( !$user->getID() ) { + return $this->failure( 'not_logged_in', 'badaccess-group0' ); + } elseif ( !$user->isAllowed( 'mwoauthmanagemygrants' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( wfReadOnly() ) { + return $this->failure( 'readonly', 'readonlytext', wfReadOnlyReason() ); + } + return $this->success(); + } + + protected function processAction( $action ) { + $user = $this->getUser(); // proposer or admin + $dbw = $this->dbw; // convenience + + $centralUserId = Utils::getCentralIdFromLocalUser( $user ); + if ( !$centralUserId ) { // sanity + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + switch ( $action ) { + case 'accept': + $payload = []; + $identifier = $this->isOAuth2() ? 'client_id' : 'consumerKey'; + $cmr = Consumer::newFromKey( $this->dbw, $this->vals[$identifier] ); + if ( !$cmr ) { + return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); + } elseif ( !$cmr->isUsableBy( $user ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + try { + if ( $this->isOAuth2() ) { + $scopes = isset( $this->vals['scope'] ) ? explode( ' ', $this->vals['scope'] ) : []; + $payload = $cmr->authorize( $this->getUser(), (bool)$this->vals['confirmUpdate'], $scopes ); + } else { + $callback = $cmr->authorize( + $this->getUser(), + (bool)$this->vals[ 'confirmUpdate' ], + $cmr->getGrants(), + $this->vals[ 'requestToken' ] + ); + $payload = [ 'callbackUrl' => $callback ]; + } + } catch ( MWOAuthException $exception ) { + return $this->failure( 'oauth_exception', $exception->msg, $exception->params ); + } catch ( OAuthException $exception ) { + return $this->failure( 'oauth_exception', + 'mwoauth-oauth-exception', $exception->getMessage() ); + } + + LoggerFactory::getInstance( 'OAuth' )->info( + '{user} performed action {action} on consumer {consumer}', [ + 'action' => 'accept', + 'user' => $user->getName(), + 'consumer' => $cmr->getConsumerKey(), + 'target' => Utils::getCentralUserNameFromId( $cmr->getUserId(), 'raw' ), + 'comment' => '', + 'clientip' => $this->getContext()->getRequest()->getIP(), + ] + ); + + return $this->success( $payload ); + case 'update': + $cmra = ConsumerAcceptance::newFromId( $dbw, $this->vals['acceptanceId'] ); + if ( !$cmra ) { + return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' ); + } elseif ( $cmra->getUserId() !== $centralUserId ) { + return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' ); + } + $cmr = Consumer::newFromId( $dbw, $cmra->getConsumerId() ); + + $grants = \FormatJson::decode( $this->vals['grants'], true ); // requested grants + $grants = array_unique( array_intersect( + array_merge( + \MWGrants::getHiddenGrants(), // implied grants + $grants // requested grants + ), + $cmr->getGrants() // Only keep the applicable ones + ) ); + + LoggerFactory::getInstance( 'OAuth' )->info( + '{user} performed action {action} on consumer {consumer}', [ + 'action' => 'update-acceptance', + 'user' => $user->getName(), + 'consumer' => $cmr->getConsumerKey(), + 'target' => Utils::getCentralUserNameFromId( $cmr->getUserId(), 'raw' ), + 'comment' => '', + 'clientip' => $this->getContext()->getRequest()->getIP(), + ] + ); + $cmra->setFields( [ + 'grants' => array_intersect( $grants, $cmr->getGrants() ) // sanity + ] ); + $cmra->save( $dbw ); + + return $this->success( $cmra ); + case 'renounce': + $cmra = ConsumerAcceptance::newFromId( $dbw, $this->vals['acceptanceId'] ); + if ( !$cmra ) { + return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' ); + } elseif ( $cmra->getUserId() !== $centralUserId ) { + return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' ); + } + + $cmr = Consumer::newFromId( $dbw, $cmra->get( 'consumerId' ) ); + LoggerFactory::getInstance( 'OAuth' )->info( + '{user} performed action {action} on consumer {consumer}', [ + 'action' => 'renounce', + 'user' => $user->getName(), + 'consumer' => $cmr->getConsumerKey(), + 'target' => Utils::getCentralUserNameFromId( $cmr->getUserId(), 'raw' ), + 'comment' => '', + 'clientip' => $this->getContext()->getRequest()->getIP(), + ] + ); + + if ( $cmr->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) { + $this->removeOAuth2AccessTokens( $cmra->getId() ); + } + $cmra->delete( $dbw ); + + return $this->success( $cmra ); + } + } + + /** + * Convenience function + * + * @return bool + */ + private function isOAuth2() { + return $this->oauthVersion === Consumer::OAUTH_VERSION_2; + } + + /** + * @param int $approvalId + */ + private function removeOAuth2AccessTokens( $approvalId ) { + $accessTokenRepository = new AccessTokenRepository(); + $accessTokenRepository->deleteForApprovalId( $approvalId ); + } +} diff --git a/OAuth/src/Control/ConsumerAccessControl.php b/OAuth/src/Control/ConsumerAccessControl.php new file mode 100644 index 00000000..01ed9648 --- /dev/null +++ b/OAuth/src/Control/ConsumerAccessControl.php @@ -0,0 +1,262 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Control; + +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; + +class ConsumerAccessControl extends DAOAccessControl { + // accessor fields copied from MWOAuthConsumer, except they can return a Message on access error + + /** + * Internal ID (DB primary key). + * Returns a Message when the user does not have permission to see this field. + * @return int|\Message + */ + public function getId() { + return $this->get( 'id' ); + } + + /** + * Consumer key (32-character hexadecimal string that's used in the OAuth protocol + * and in URLs). This is used as the consumer ID for most external purposes. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getConsumerKey() { + return $this->get( 'consumerKey' ); + } + + /** + * Name of the consumer. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getName() { + return $this->get( 'name' ); + } + + /** + * @return int + */ + public function getOAuthVersion() { + return (int)$this->get( 'oauthVersion' ); + } + + /** + * Central ID of the owner. + * Returns a Message when the user does not have permission to see this field. + * @return int|\Message + */ + public function getUserId() { + return $this->get( 'userId' ); + } + + /** + * Consumer version. This is mostly meant for humans: different versions of the same + * application have different keys and are handled as different consumers internally. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getVersion() { + return $this->get( 'version' ); + } + + /** + * Callback URL (or prefix). The browser will be redirected to this URL at the end of + * an OAuth handshake. See getCallbackIsPrefix() for the interpretation of this field. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getCallbackUrl() { + return $this->get( 'callbackUrl' ); + } + + /** + * When true, getCallbackUrl() returns a prefix; the callback URL can be provided by the caller + * as long as the prefix matches. When false, the callback URL will be determined by + * getCallbackUrl(). + * Returns a Message when the user does not have permission to see this field. + * @return bool|\Message + */ + public function getCallbackIsPrefix() { + return $this->get( 'callbackIsPrefix' ); + } + + /** + * Description of the consumer. Currently interpreted as plain text; might change to wikitext + * in the future. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getDescription() { + return $this->get( 'description' ); + } + + /** + * Email address of the owner. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getEmail() { + return $this->get( 'email' ); + } + + /** + * Date of verifying the email, in TS_MW format. In practice this will be the same as + * getRegistration(). + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getEmailAuthenticated() { + return $this->get( 'emailAuthenticated' ); + } + + /** + * Did the user accept the developer agreement (the terms of use checkbox at the bottom of the + * registration form)? Except for very old users, always true. + * Returns a Message when the user does not have permission to see this field. + * @return bool|\Message + */ + public function getDeveloperAgreement() { + return $this->get( 'developerAgreement' ); + } + + /** + * Owner-only consumers will use one-legged flow instead of three-legged (see + * https://github.com/Mashape/mashape-oauth/blob/master/FLOWS.md#oauth-10a-one-legged ); there + * is only one user (who is the same as the owner) and they learn the access token at + * consumer registration time. + * Returns a Message when the user does not have permission to see this field. + * @return bool|\Message + */ + public function getOwnerOnly() { + return $this->get( 'ownerOnly' ); + } + + /** + * The wiki on which the consumer is allowed to access user accounts. A wiki ID or '*' for all. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getWiki() { + return $this->get( 'wiki' ); + } + + /** + * The list of grants required by this application. + * Returns a Message when the user does not have permission to see this field. + * @return string[]|\Message + */ + public function getGrants() { + return $this->get( 'grants' ); + } + + /** + * Consumer registration date in TS_MW format. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getRegistration() { + return $this->get( 'registration' ); + } + + /** + * Secret key used to derive the consumer secret for HMAC-SHA1 signed OAuth requests. + * The actual consumer secret will be calculated via MWOAuthUtils::hmacDBSecret() to mitigate + * DB leaks. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getSecretKey() { + return $this->get( 'secretKey' ); + } + + /** + * Public RSA key for RSA-SHA1 signerd OAuth requests. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getRsaKey() { + return $this->get( 'rsaKey' ); + } + + /** + * Application restrictions (such as allowed IPs). + * Returns a Message when the user does not have permission to see this field. + * @return \MWRestrictions|\Message + */ + public function getRestrictions() { + return $this->get( 'restrictions' ); + } + + /** + * Stage at which the consumer is in the review workflow (proposed, approved etc). + * Returns a Message when the user does not have permission to see this field. + * @return int|\Message One of the STAGE_* constants + */ + public function getStage() { + return $this->get( 'stage' ); + } + + /** + * Date at which the consumer was moved to the current stage, in TS_MW format. + * Returns a Message when the user does not have permission to see this field. + * @return string|\Message + */ + public function getStageTimestamp() { + return $this->get( 'stageTimestamp' ); + } + + /** + * Is the consumer suppressed? (There is no plain deletion; the closest equivalent is the + * rejected/disabled stage.) + * Returns a Message when the user does not have permission to see this field. + * @return bool|\Message + */ + public function getDeleted() { + return $this->get( 'deleted' ); + } + + // accessors for common formatting + + /** + * Owner username. + * Note that this method triggers a DB lookup. + * @param \User|bool $audience show hidden names based on this user, or false for public + * @return string|\Message + */ + public function getUserName( $audience = false ) { + return $this->get( 'userId', function ( $id ) use ( $audience ) { + return Utils::getCentralUserNameFromId( $id, $audience ); + } ); + } + + /** + * Pretty wiki name. + * @return string|\Message + */ + public function getWikiName() { + return $this->get( 'wiki', function ( $wikiId ) { + return Utils::getWikiIdName( $wikiId ); + } ); + } + + /** + * Consumer name and version in a "Foo [1.0]" format. + * @return string|\Message + */ + public function getNameAndVersion() { + return $this->get( 'name', function ( $s ) { + return $s . ' ' . $this->msg( 'brackets', $this->getVersion() )->plain(); + } ); + } + + /** + * @return Consumer|ClientEntity + */ + public function getDAO() { + return $this->dao; + } +} diff --git a/OAuth/src/Control/ConsumerSubmitControl.php b/OAuth/src/Control/ConsumerSubmitControl.php new file mode 100644 index 00000000..fa93712c --- /dev/null +++ b/OAuth/src/Control/ConsumerSubmitControl.php @@ -0,0 +1,550 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Control; + +use ExtensionRegistry; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthDataStore; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; +use MediaWiki\Logger\LoggerFactory; +use Wikimedia\Rdbms\DBConnRef; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * This handles the core logic of approving/disabling consumers + * from using particular user accounts + * + * This control can only be used on the management wiki + * + * @TODO: improve error messages + */ +class ConsumerSubmitControl extends SubmitControl { + /** + * Names of the actions that can be performed on a consumer. These are the same as the + * options in getRequiredFields(). + * @var array + */ + public static $actions = [ 'propose', 'update', 'approve', 'reject', 'disable', 'reenable' ]; + + /** @var DBConnRef */ + protected $dbw; + + /** + * @param \IContextSource $context + * @param array $params + * @param DBConnRef $dbw Result of MWOAuthUtils::getCentralDB( DB_MASTER ) + */ + public function __construct( \IContextSource $context, array $params, DBConnRef $dbw ) { + parent::__construct( $context, $params ); + $this->dbw = $dbw; + } + + protected function getRequiredFields() { + $validateRsaKey = function ( $s ) { + if ( trim( $s ) === '' ) { + return true; + } + $key = openssl_pkey_get_public( $s ); + if ( $key === false ) { + return false; + } + $info = openssl_pkey_get_details( $key ); + if ( $info['type'] !== OPENSSL_KEYTYPE_RSA ) { + return false; + } + return true; + }; + + return [ + // Proposer (application administrator) actions: + 'propose' => [ + 'name' => '/^.{1,128}$/', + 'version' => '/^\d{1,3}(\.\d{1,2}){0,2}(-(dev|alpha|beta))?$/', + 'callbackUrl' => function ( $s, $vals ) { + return $vals['ownerOnly'] || wfParseUrl( $s ) !== false; + }, + 'description' => '/^.*$/s', + 'email' => function ( $s ) { + return \Sanitizer::validateEmail( $s ); + }, + 'wiki' => function ( $s ) { + global $wgConf; + return ( $s === '*' + || in_array( $s, $wgConf->getLocalDatabases() ) + || array_search( $s, Utils::getAllWikiNames() ) !== false + ); + }, + 'granttype' => '/^(authonly|authonlyprivate|normal)$/', + 'grants' => function ( $s ) { + $grants = \FormatJson::decode( $s, true ); + return is_array( $grants ) && Utils::grantsAreValid( $grants ); + }, + 'rsaKey' => $validateRsaKey, + 'agreement' => function ( $s ) { + return ( $s == true ); + }, + ], + 'update' => [ + 'consumerKey' => '/^[0-9a-f]{32}$/', + 'rsaKey' => $validateRsaKey, + 'resetSecret' => function ( $s ) { + return is_bool( $s ); + }, + 'reason' => '/^.{0,255}$/', + 'changeToken' => '/^[0-9a-f]{40}$/' + ], + // Approver (project administrator) actions: + 'approve' => [ + 'consumerKey' => '/^[0-9a-f]{32}$/', + 'reason' => '/^.{0,255}$/', + 'changeToken' => '/^[0-9a-f]{40}$/' + ], + 'reject' => [ + 'consumerKey' => '/^[0-9a-f]{32}$/', + 'reason' => '/^.{0,255}$/', + 'suppress' => '/^[01]$/', + 'changeToken' => '/^[0-9a-f]{40}$/' + ], + 'disable' => [ + 'consumerKey' => '/^[0-9a-f]{32}$/', + 'reason' => '/^.{0,255}$/', + 'suppress' => '/^[01]$/', + 'changeToken' => '/^[0-9a-f]{40}$/' + ], + 'reenable' => [ + 'consumerKey' => '/^[0-9a-f]{32}$/', + 'reason' => '/^.{0,255}$/', + 'changeToken' => '/^[0-9a-f]{40}$/' + ] + ]; + } + + protected function checkBasePermissions() { + global $wgBlockDisablesLogin; + $user = $this->getUser(); + if ( !$user->getId() ) { + return $this->failure( 'not_logged_in', 'badaccess-group0' ); + } elseif ( $user->isLocked() || $wgBlockDisablesLogin && $user->isBlocked() ) { + return $this->failure( 'user_blocked', 'badaccess-group0' ); + } elseif ( wfReadOnly() ) { + return $this->failure( 'readonly', 'readonlytext', wfReadOnlyReason() ); + } elseif ( !Utils::isCentralWiki() ) { // sanity + // This logs consumer changes to the local logging table on the central wiki + throw new \LogicException( "This can only be used from the OAuth management wiki." ); + } + return $this->success(); + } + + protected function processAction( $action ) { + $context = $this->getContext(); + $user = $this->getUser(); // proposer or admin + $dbw = $this->dbw; // convenience + + $centralUserId = Utils::getCentralIdFromLocalUser( $user ); + if ( !$centralUserId ) { // sanity + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + switch ( $action ) { + case 'propose': + if ( !$user->isAllowed( 'mwoauthproposeconsumer' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( !$user->isEmailConfirmed() ) { + return $this->failure( 'email_not_confirmed', 'mwoauth-consumer-email-unconfirmed' ); + } elseif ( $user->getEmail() !== $this->vals['email'] ) { + // @TODO: allow any email and don't set emailAuthenticated below + return $this->failure( 'email_mismatched', 'mwoauth-consumer-email-mismatched' ); + } + + if ( Consumer::newFromNameVersionUser( + $dbw, $this->vals['name'], $this->vals['version'], $centralUserId + ) ) { + return $this->failure( 'consumer_exists', 'mwoauth-consumer-alreadyexists' ); + } + + $wikiNames = Utils::getAllWikiNames(); + $dbKey = array_search( $this->vals['wiki'], $wikiNames ); + if ( $dbKey !== false ) { + $this->vals['wiki'] = $dbKey; + } + + $curVer = $dbw->selectField( 'oauth_registered_consumer', + 'oarc_version', + [ 'oarc_name' => $this->vals['name'], 'oarc_user_id' => $centralUserId ], + __METHOD__, + [ 'ORDER BY' => 'oarc_registration DESC', 'FOR UPDATE' ] + ); + if ( $curVer !== false && version_compare( $curVer, $this->vals['version'], '>=' ) ) { + return $this->failure( 'consumer_exists', + 'mwoauth-consumer-alreadyexistsversion', $curVer ); + } + + // Handle owner-only mode + if ( $this->vals['ownerOnly'] ) { + $this->vals['callbackUrl'] = \SpecialPage::getTitleFor( 'OAuth', 'verified' ) + ->getLocalURL(); + $this->vals['callbackIsPrefix'] = ''; + $stage = Consumer::STAGE_APPROVED; + } else { + $stage = Consumer::STAGE_PROPOSED; + } + + // Handle grant types + $grants = []; + switch ( $this->vals['granttype'] ) { + case 'authonly': + $grants = [ 'mwoauth-authonly' ]; + break; + case 'authonlyprivate': + $grants = [ 'mwoauth-authonlyprivate' ]; + break; + case 'normal': + $grants = array_unique( array_merge( + \MWGrants::getHiddenGrants(), // implied grants + \FormatJson::decode( $this->vals['grants'], true ) + ) ); + break; + } + + $now = wfTimestampNow(); + $cmr = Consumer::newFromArray( + [ + 'id' => null, // auto-increment + 'consumerKey' => \MWCryptRand::generateHex( 32 ), + 'userId' => $centralUserId, + 'email' => $user->getEmail(), + 'emailAuthenticated' => $now, // see above + 'developerAgreement' => 1, + 'secretKey' => \MWCryptRand::generateHex( 32 ), + 'registration' => $now, + 'stage' => $stage, + 'stageTimestamp' => $now, + 'grants' => $grants, + 'restrictions' => $this->vals['restrictions'], + 'deleted' => 0 + ] + $this->vals + ); + $cmr->save( $dbw ); + + if ( $cmr->getOwnerOnly() ) { + $this->makeLogEntry( + $dbw, $cmr, 'create-owner-only', $user, $this->vals['description'] + ); + } else { + $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['description'] ); + $this->notify( $cmr, $user, $action, null ); + } + + // If it's owner-only, automatically accept it for the user too. + $accessToken = null; + if ( $cmr->getOwnerOnly() ) { + $accessToken = MWOAuthDataStore::newToken(); + $cmra = ConsumerAcceptance::newFromArray( [ + 'id' => null, + 'wiki' => $cmr->getWiki(), + 'userId' => $centralUserId, + 'consumerId' => $cmr->getId(), + 'accessToken' => $accessToken->key, + 'accessSecret' => $accessToken->secret, + 'grants' => $cmr->getGrants(), + 'accepted' => $now, + 'oauth_version' => $cmr->getOAuthVersion() + ] ); + $cmra->save( $dbw ); + if ( $cmr instanceof ClientEntity ) { + // OAuth2 client + try { + $accessToken = $cmr->getOwnerOnlyAccessToken( $cmra ); + } catch ( \Exception $ex ) { + return $this->failure( + 'unable_to_retrieve_access_token', + 'mwoauth-oauth2-unable-to-retrieve-access-token', + $ex->getMessage() + ); + } + } + } + + return $this->success( [ 'consumer' => $cmr, 'accessToken' => $accessToken ] ); + case 'update': + if ( !$user->isAllowed( 'mwoauthupdateownconsumer' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); + if ( !$cmr ) { + return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); + } elseif ( $cmr->getUserId() !== $centralUserId ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( + $cmr->getStage() !== Consumer::STAGE_APPROVED + && $cmr->getStage() !== Consumer::STAGE_PROPOSED + ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( $cmr->getDeleted() && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); // sanity + } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { + return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); + } + + $cmr->setFields( [ + 'rsaKey' => $this->vals['rsaKey'], + 'restrictions' => $this->vals['restrictions'], + 'secretKey' => $this->vals['resetSecret'] + ? \MWCryptRand::generateHex( 32 ) + : $cmr->getSecretKey(), + ] ); + + // Log if something actually changed + if ( $cmr->save( $dbw ) ) { + $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); + $this->notify( $cmr, $user, $action, $this->vals['reason'] ); + } + + $cmra = null; + $accessToken = null; + if ( $cmr->getOwnerOnly() && $this->vals['resetSecret'] ) { + $cmra = $cmr->getCurrentAuthorization( $user, wfWikiID() ); + $accessToken = MWOAuthDataStore::newToken(); + $fields = [ + 'wiki' => $cmr->getWiki(), + 'userId' => $centralUserId, + 'consumerId' => $cmr->getId(), + 'accessSecret' => $accessToken->secret, + 'grants' => $cmr->getGrants(), + ]; + + if ( $cmra ) { + $accessToken->key = $cmra->getAccessToken(); + $cmra->setFields( $fields ); + } else { + $cmra = ConsumerAcceptance::newFromArray( $fields + [ + 'id' => null, + 'accessToken' => $accessToken->key, + 'accepted' => wfTimestampNow(), + ] ); + } + $cmra->save( $dbw ); + if ( $cmr instanceof ClientEntity ) { + $accessToken = $cmr->getOwnerOnlyAccessToken( $cmra, true ); + } + } + + return $this->success( [ 'consumer' => $cmr, 'accessToken' => $accessToken ] ); + case 'approve': + if ( !$user->isAllowed( 'mwoauthmanageconsumer' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); + if ( !$cmr ) { + return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); + } elseif ( !in_array( $cmr->getStage(), [ + Consumer::STAGE_PROPOSED, + Consumer::STAGE_EXPIRED, + Consumer::STAGE_REJECTED, + ] ) ) { + return $this->failure( 'not_proposed', 'mwoauth-consumer-not-proposed' ); + } elseif ( $cmr->getDeleted() && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { + return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); + } + + $cmr->setFields( [ + 'stage' => Consumer::STAGE_APPROVED, + 'stageTimestamp' => wfTimestampNow(), + 'deleted' => 0 ] ); + + // Log if something actually changed + if ( $cmr->save( $dbw ) ) { + $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); + $this->notify( $cmr, $user, $action, $this->vals['reason'] ); + } + + return $this->success( $cmr ); + case 'reject': + if ( !$user->isAllowed( 'mwoauthmanageconsumer' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); + if ( !$cmr ) { + return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); + } elseif ( $cmr->getStage() !== Consumer::STAGE_PROPOSED ) { + return $this->failure( 'not_proposed', 'mwoauth-consumer-not-proposed' ); + } elseif ( $cmr->getDeleted() && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( $this->vals['suppress'] && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { + return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); + } + + $cmr->setFields( [ + 'stage' => Consumer::STAGE_REJECTED, + 'stageTimestamp' => wfTimestampNow(), + 'deleted' => $this->vals['suppress'] ] ); + + // Log if something actually changed + if ( $cmr->save( $dbw ) ) { + $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); + $this->notify( $cmr, $user, $action, $this->vals['reason'] ); + } + + return $this->success( $cmr ); + case 'disable': + if ( !$user->isAllowed( 'mwoauthmanageconsumer' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( $this->vals['suppress'] && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); + if ( !$cmr ) { + return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); + } elseif ( $cmr->getStage() !== Consumer::STAGE_APPROVED + && $cmr->getDeleted() == $this->vals['suppress'] + ) { + return $this->failure( 'not_approved', 'mwoauth-consumer-not-approved' ); + } elseif ( $cmr->getDeleted() && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { + return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); + } + + $cmr->setFields( [ + 'stage' => Consumer::STAGE_DISABLED, + 'stageTimestamp' => wfTimestampNow(), + 'deleted' => $this->vals['suppress'] ] ); + + // Log if something actually changed + if ( $cmr->save( $dbw ) ) { + $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); + $this->notify( $cmr, $user, $action, $this->vals['reason'] ); + } + + return $this->success( $cmr ); + case 'reenable': + if ( !$user->isAllowed( 'mwoauthmanageconsumer' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } + + $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] ); + if ( !$cmr ) { + return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' ); + } elseif ( $cmr->getStage() !== Consumer::STAGE_DISABLED ) { + return $this->failure( 'not_disabled', 'mwoauth-consumer-not-disabled' ); + } elseif ( $cmr->getDeleted() && !$user->isAllowed( 'mwoauthsuppress' ) ) { + return $this->failure( 'permission_denied', 'badaccess-group0' ); + } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) { + return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' ); + } + + $cmr->setFields( [ + 'stage' => Consumer::STAGE_APPROVED, + 'stageTimestamp' => wfTimestampNow(), + 'deleted' => 0 ] ); + + // Log if something actually changed + if ( $cmr->save( $dbw ) ) { + $this->makeLogEntry( $dbw, $cmr, $action, $user, $this->vals['reason'] ); + $this->notify( $cmr, $user, $action, $this->vals['reason'] ); + } + + return $this->success( $cmr ); + } + } + + /** + * @param DBConnRef $db + * @param int $userId + * @return \Title + */ + protected function getLogTitle( DBConnRef $db, $userId ) { + $name = Utils::getCentralUserNameFromId( $userId ); + return \Title::makeTitleSafe( NS_USER, $name ); + } + + /** + * @param DBConnRef $dbw + * @param Consumer $cmr + * @param string $action + * @param \User $performer + * @param string $comment + */ + protected function makeLogEntry( + $dbw, Consumer $cmr, $action, \User $performer, $comment + ) { + $logEntry = new \ManualLogEntry( 'mwoauthconsumer', $action ); + $logEntry->setPerformer( $performer ); + $target = $this->getLogTitle( $dbw, $cmr->getUserId() ); + $logEntry->setTarget( $target ); + $logEntry->setComment( $comment ); + $logEntry->setParameters( [ '4:consumer' => $cmr->getConsumerKey() ] ); + $logEntry->setRelations( [ + 'OAuthConsumer' => [ $cmr->getConsumerKey() ] + ] ); + $logEntry->insert( $dbw ); + + LoggerFactory::getInstance( 'OAuth' )->info( + '{user} performed action {action} on consumer {consumer}', [ + 'action' => $action, + 'user' => $performer->getName(), + 'consumer' => $cmr->getConsumerKey(), + 'target' => $target->getText(), + 'comment' => $comment, + 'clientip' => $this->getContext()->getRequest()->getIP(), + ] + ); + } + + /** + * @param Consumer $cmr Consumer which was the subject of the action + * @param \User $user User who performed the action + * @param string $actionType Action type + * @param string $comment + * @throws \MWException + */ + protected function notify( $cmr, $user, $actionType, $comment ) { + if ( !in_array( $actionType, self::$actions, true ) ) { + throw new \MWException( "Invalid action type: $actionType" ); + } elseif ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { + return; + } elseif ( !Utils::isCentralWiki() ) { + # sanity; should never get here on a replica wiki + return; + } + + \EchoEvent::create( [ + 'type' => 'oauth-app-' . $actionType, + 'agent' => $user, + 'extra' => [ + 'action' => $actionType, + 'app-key' => $cmr->getConsumerKey(), + 'owner-id' => $cmr->getUserId(), + 'comment' => $comment, + ], + ] ); + } +} diff --git a/OAuth/src/Control/DAOAccessControl.php b/OAuth/src/Control/DAOAccessControl.php new file mode 100644 index 00000000..d0d8d7e0 --- /dev/null +++ b/OAuth/src/Control/DAOAccessControl.php @@ -0,0 +1,126 @@ +<?php +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +namespace MediaWiki\Extensions\OAuth\Control; + +use MediaWiki\Extensions\OAuth\Backend\MWOAuthDAO; +use Message; + +/** + * Wrapper of an MWOAuthDAO that handles authorization to view fields + */ +class DAOAccessControl extends \ContextSource { + /** @var MWOAuthDAO */ + protected $dao; + + /** + * @param MWOAuthDAO $dao + * @param \IContextSource $context + */ + final protected function __construct( MWOAuthDAO $dao, \IContextSource $context ) { + $this->dao = $dao; + $this->setContext( $context ); + } + + /** + * @param MWOAuthDAO|false|null $dao + * @param \IContextSource $context + * @throws \LogicException + * @return static|null|false + */ + final public static function wrap( $dao, \IContextSource $context ) { + if ( $dao instanceof MWOAuthDAO ) { + return new static( $dao, $context ); + } elseif ( $dao === null || $dao === false ) { + return $dao; + } else { + throw new \LogicException( "Expected MWOAuthDAO object, null, or false." ); + } + } + + /** + * @return MWOAuthDAO + */ + public function getDAO() { + return $this->dao; + } + + /** + * Helper to make return value of get() safe for wikitext + * + * @param Message|string $value + * @return string For use in wikitext + * @param-taint $value escapes_escaped + */ + final public function escapeForWikitext( $value ) { + if ( $value instanceof Message ) { + return wfEscapeWikiText( $value->plain() ); + } else { + return wfEscapeWikiText( $value ); + } + } + + /** + * Helper to make return value of get() safe for HTML + * + * @param Message|string $value + * @return string HTML escaped + * @param-taint $value escapes_escaped + */ + final public function escapeForHtml( $value ) { + if ( $value instanceof Message ) { + return $value->parse(); + } else { + return htmlspecialchars( $value ); + } + } + + /** + * Get the value of a field, taking into account user permissions. + * An appropriate Message will be returned if access is denied. + * + * @param string $name + * @param callback|null $sCallback Optional callback to apply to result on access success + * @return mixed Returns a Message on access failure + */ + final public function get( $name, $sCallback = null ) { + $msg = $this->dao->userCanAccess( $name, $this->getContext() ); + if ( $msg !== true ) { + return $msg; // should be a Message object + } else { + $value = $this->dao->get( $name ); + return $sCallback ? call_user_func( $sCallback, $value ) : $value; + } + } + + /** + * Check whether the user can access the given field(s). + * @param string|array $names A field name or a list of names. + * @return bool + */ + final public function userCanAccess( $names ) { + foreach ( (array)$names as $name ) { + if ( !$this->dao->userCanAccess( $name, $this->getContext() ) ) { + return false; + } + } + return true; + } +} diff --git a/OAuth/src/Control/SubmitControl.php b/OAuth/src/Control/SubmitControl.php new file mode 100644 index 00000000..e80a30d5 --- /dev/null +++ b/OAuth/src/Control/SubmitControl.php @@ -0,0 +1,228 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Control; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Handle the logic of submitting a client request + */ +abstract class SubmitControl extends \ContextSource { + /** @var array (field name => value) */ + protected $vals; + + /** + * @param \IContextSource $context + * @param array $params + */ + public function __construct( \IContextSource $context, array $params ) { + $this->setContext( $context ); + $this->vals = $params; + } + + /** + * @param array $params + */ + public function setInputParameters( array $params ) { + $this->vals = $params; + } + + /** + * Attempt to validate and submit this data + * + * This will check basic permissions, validate the action and parameters + * and route the submission handling to the internal subclass function. + * + * @throws \MWException + * @return \Status + */ + public function submit() { + $status = $this->checkBasePermissions(); + if ( !$status->isOK() ) { + return $status; + } + + $action = $this->vals['action']; + $required = $this->getRequiredFields(); + if ( !isset( $required[$action] ) ) { + // @TODO: check for field-specific message first + return $this->failure( 'invalid_field_action', 'mwoauth-invalid-field', 'action' ); + } + + $status = $this->validateFields( $required[$action] ); + if ( !$status->isOK() ) { + return $status; + } + + $status = $this->processAction( $action ); + if ( $status instanceof \Status ) { + return $status; + } else { + throw new \MWException( "Submission action '$action' not handled." ); + } + } + + /** + * Given an HTMLForm descriptor array, register the field validation callbacks + * + * @param array $descriptors + * @return array + */ + public function registerValidators( array $descriptors ) { + foreach ( $descriptors as $field => &$description ) { + if ( array_key_exists( 'validation-callback', $description ) ) { + continue; // already set to something + } + $control = $this; + $description['validation-callback'] = + function ( $value, $allValues, $form ) use ( $control, $field ) { + return $control->validateFieldInternal( $field, $value, $allValues, $form ); + }; + } + return $descriptors; + } + + /** + * This method should not be called outside MWOAuthSubmitControl + * + * @param string $field + * @param string $value + * @param array $allValues + * @param \HTMLForm $form + * @throws \MWException + * @return bool|string + */ + public function validateFieldInternal( $field, $value, $allValues, $form ) { + if ( !isset( $allValues['action'] ) && isset( $this->vals['action'] ) ) { + // The action may be derived, especially for multi-button forms. + // Such an HTMLForm will not have an action key set in $allValues. + $allValues['action'] = $this->vals['action']; // injected + } + if ( !isset( $allValues['action'] ) ) { + throw new \MWException( "No form action defined; cannot validate fields." ); + } + $validators = $this->getRequiredFields(); + if ( !isset( $validators[$allValues['action']][$field] ) ) { + return true; // nothing to check + } + $validator = $validators[$allValues['action']][$field]; + $isValid = is_string( $validator ) // regex + ? preg_match( $validator, $value ) + : $validator( $value, $allValues ); + if ( !$isValid ) { + $errorMessage = $this->msg( 'mwoauth-invalid-field-' . $field ); + if ( !$errorMessage->isDisabled() ) { + return $errorMessage->text(); + } + + $generic = ''; + if ( $form->getField( $field )->canDisplayErrors() ) { + // error can be attached to the field so no need to mention the field name + $generic = '-generic'; + } + + $problem = 'invalid'; + if ( $value === '' && !$generic ) { + $problem = 'missing'; + } + + // messages: mwoauth-missing-field, mwoauth-invalid-field, mwoauth-invalid-field-generic + return $this->msg( "mwoauth-$problem-field$generic", $field )->text(); + } + return true; + } + + /** + * Get the field names and their validation regexes or functions + * (which return a boolean) for each action that this controller handles. + * When functions are used, they take (field value, field/value map) as params. + * + * @return array (action => (field name => validation regex or function)) + */ + abstract protected function getRequiredFields(); + + /** + * Check action-independent permissions against the user for this submission + * + * @return \Status + */ + abstract protected function checkBasePermissions(); + + /** + * Check that the action is valid and that the required fields are valid + * + * @param array $required (field => regex or callback) + * @return \Status + */ + protected function validateFields( array $required ) { + foreach ( $required as $field => $validator ) { + if ( !isset( $this->vals[$field] ) ) { + // @TODO: check for field-specific message first + return $this->failure( "missing_field_$field", 'mwoauth-missing-field', $field ); + } elseif ( !is_scalar( $this->vals[$field] ) && $field !== 'restrictions' ) { + // @TODO: check for field-specific message first + return $this->failure( "invalid_field_$field", 'mwoauth-invalid-field', $field ); + } + if ( is_string( $this->vals[$field] ) ) { + $this->vals[$field] = trim( $this->vals[$field] ); // trim all input + } + $valid = is_string( $validator ) // regex + ? preg_match( $validator, $this->vals[$field] ) + : $validator( $this->vals[$field], $this->vals ); + if ( !$valid ) { + // @TODO: check for field-specific message first + return $this->failure( "invalid_field_$field", 'mwoauth-invalid-field', $field ); + } + } + return $this->success(); + } + + /** + * Attempt to validate and submit this data for the given action + * + * @param string $action + * @return \Status + */ + abstract protected function processAction( $action ); + + /** + * @param string $error API error key + * @param string $msg Message key + * @param mixed ...$params Additional arguments used as message parameters + * @return \Status + */ + protected function failure( $error, $msg, ...$params ) { + // Use the same logic as wfMessage + if ( isset( $params[0] ) && is_array( $params[0] ) ) { + $params = $params[0]; + } + $status = \Status::newFatal( $this->msg( $msg, $params ) ); + $status->value = [ 'error' => $error, 'result' => null ]; + return $status; + } + + /** + * @param mixed|null $value + * @return \Status + */ + protected function success( $value = null ) { + return \Status::newGood( [ 'error' => null, 'result' => $value ] ); + } +} diff --git a/OAuth/src/Entity/AccessTokenEntity.php b/OAuth/src/Entity/AccessTokenEntity.php new file mode 100644 index 00000000..f7ab4f3a --- /dev/null +++ b/OAuth/src/Entity/AccessTokenEntity.php @@ -0,0 +1,150 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Entity; + +use InvalidArgumentException; +use League\OAuth2\Server\CryptKey; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\Traits\AccessTokenTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; +use League\OAuth2\Server\Exception\OAuthServerException; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\MediaWikiServices; +use Throwable; +use User; + +class AccessTokenEntity implements AccessTokenEntityInterface { + use AccessTokenTrait; + use EntityTrait; + use TokenEntityTrait; + + /** + * @var ClientEntity + */ + protected $client; + + /** + * User approval of the client + * + * @var ConsumerAcceptance|bool + */ + private $approval = false; + + /** + * @param ClientEntity $clientEntity + * @param ScopeEntityInterface[] $scopes + * @param string|null $userIdentifier + */ + public function __construct( + ClientEntity $clientEntity, array $scopes, $userIdentifier = null + ) { + $this->approval = $this->setApprovalFromClientScopesUser( + $clientEntity, $scopes, $userIdentifier + ); + + $this->setClient( $clientEntity ); + if ( $clientEntity->getOwnerOnly() ) { + if ( $userIdentifier !== null && $userIdentifier !== $clientEntity->getUserId() ) { + throw new InvalidArgumentException( + '$userIdentifier must be null, or match the client owner user id,' . + ' for owner-only clients, ' . $userIdentifier . ' given' + ); + } + foreach ( $clientEntity->getScopes() as $scope ) { + $this->addScope( $scope ); + } + $this->setUserIdentifier( $clientEntity->getUserId() ); + } else { + foreach ( $scopes as $scope ) { + if ( !in_array( $scope->getIdentifier(), $clientEntity->getGrants() ) ) { + continue; + } + $this->addScope( $scope ); + } + $this->setUserIdentifier( $userIdentifier ); + } + + $this->confirmClientUsable(); + } + + /** + * Get the approval that allows this AT to be created + * + * @return ConsumerAcceptance + */ + public function getApproval() { + return $this->approval; + } + + /** + * Set configured private key + */ + public function setPrivateKeyFromConfig() { + $oauthConfig = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'mwoauth' ); + // Private key to sign the token + $privateKey = new CryptKey( $oauthConfig->get( 'OAuth2PrivateKey' ) ); + $this->setPrivateKey( $privateKey ); + } + + /** + * Get the client that the token was issued to. + * + * @return ClientEntity + */ + public function getClient() { + return $this->client; + } + + /** + * @param ClientEntity $clientEntity + * @param array $scopes + * @param null $userIdentifier + * @return ConsumerAcceptance|bool + */ + private function setApprovalFromClientScopesUser( + ClientEntity $clientEntity, array $scopes, $userIdentifier = null + ) { + if ( $clientEntity->getOwnerOnly() && $userIdentifier === null ) { + $userIdentifier = $clientEntity->getUserId(); + $scopes = $clientEntity->getScopes(); + } + try { + $user = Utils::getLocalUserFromCentralId( $userIdentifier ); + $approval = $clientEntity->getCurrentAuthorization( $user, wfWikiID() ); + } catch ( Throwable $ex ) { + return false; + } + if ( !$approval ) { + return $approval; + } + + $approvedScopes = $approval->getGrants(); + $notApproved = array_filter( + $scopes, + function ( ScopeEntityInterface $scope ) use ( $approvedScopes ) { + return !in_array( $scope->getIdentifier(), $approvedScopes, true ); + } + ); + + return empty( $notApproved ) ? $approval : false; + } + + private function confirmClientUsable() { + $userId = $this->getUserIdentifier() ?? 0; + $user = Utils::getLocalUserFromCentralId( $userId ); + if ( !$user ) { + $user = User::newFromId( 0 ); + } + + if ( !$this->getClient()->isUsableBy( $user ) ) { + throw OAuthServerException::accessDenied( + 'Client ' . $this->getClient()->getIdentifier() . + ' is not usable by user with ID ' . $user->getId() + ); + } + } + +} diff --git a/OAuth/src/Entity/AuthCodeEntity.php b/OAuth/src/Entity/AuthCodeEntity.php new file mode 100644 index 00000000..41d4266b --- /dev/null +++ b/OAuth/src/Entity/AuthCodeEntity.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Entity; + +use JsonSerializable; +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; +use League\OAuth2\Server\Entities\Traits\AuthCodeTrait; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\TokenEntityTrait; + +class AuthCodeEntity implements AuthCodeEntityInterface, JsonSerializable { + use TokenEntityTrait; + use EntityTrait; + use AuthCodeTrait; + + public function jsonSerialize() { + return [ + 'user' => $this->getUserIdentifier(), + 'client' => $this->getClient()->getIdentifier(), + 'identifier' => $this->getIdentifier(), + 'redirectUri' => $this->getRedirectUri(), + 'scopes' => $this->getScopes(), + 'expires' => $this->getExpiryDateTime()->getTimestamp() + ]; + } +} diff --git a/OAuth/src/Entity/ClientEntity.php b/OAuth/src/Entity/ClientEntity.php new file mode 100644 index 00000000..0def1470 --- /dev/null +++ b/OAuth/src/Entity/ClientEntity.php @@ -0,0 +1,191 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Entity; + +use Exception; +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Repository\AccessTokenRepository; +use MWException; +use User; + +class ClientEntity extends Consumer implements ClientEntityInterface { + + /** + * Returns the registered redirect URI (as a string). + * + * Alternatively return an indexed array of redirect URIs. + * + * @return string|string[] + */ + public function getRedirectUri() { + return $this->getCallbackUrl(); + } + + /** + * Returns true if the client is confidential. + * + * @return bool + */ + public function isConfidential() { + return $this->oauth2IsConfidential; + } + + /** + * @return mixed + */ + public function getIdentifier() { + return $this->getConsumerKey(); + } + + /** + * @param mixed $identifier + */ + public function setIdentifier( $identifier ) { + $this->consumerKey = $identifier; + } + + /** + * Get the grant types this client is allowed to use + * + * @return array + */ + public function getAllowedGrants() { + return $this->oauth2GrantTypes; + } + + /** + * Convenience function, same as getGrants() + * it just returns array of ScopeEntity-es instead of strings + * + * @return ScopeEntityInterface[] + */ + public function getScopes() { + $scopeEntities = []; + foreach ( $this->getGrants() as $grant ) { + $scopeEntities[] = new ScopeEntity( $grant ); + } + + return $scopeEntities; + } + + /** + * @return bool|User + * @throws MWException + */ + public function getUser() { + return Utils::getLocalUserFromCentralId( $this->getUserId() ); + } + + /** + * @param null|string $secret + * @param null|string $grantType + * @return bool + */ + public function validate( $secret, $grantType ) { + if ( !$this->isSecretValid( $secret ) ) { + return false; + } + + if ( !$this->isGrantAllowed( $grantType ) ) { + return false; + } + + return true; + } + + /** + * @return int + */ + public function getOAuthVersion() { + return static::OAUTH_VERSION_2; + } + + private function isSecretValid( $secret ) { + return hash_equals( $secret, Utils::hmacDBSecret( $this->secretKey ) ); + } + + /** + * @param string $grantType + * @return bool + */ + public function isGrantAllowed( $grantType ) { + return in_array( $grantType, $this->getAllowedGrants() ); + } + + /** + * @param User $mwUser + * @param bool $update + * @param array $grants + * @param null $requestTokenKey + * @return bool + * @throws MWException + * @throws MWOAuthException + */ + public function authorize( User $mwUser, $update, $grants, $requestTokenKey = null ) { + $this->conductAuthorizationChecks( $mwUser ); + + $grants = $this->getVerifiedScopes( $grants ); + $this->saveAuthorization( $mwUser, $update, $grants ); + + return true; + } + + /** + * Get the access token to be used with a single user + * Should never be called outside of client registration/manage code + * + * @param ConsumerAcceptance $approval + * @param bool $revokeExisting - Delete all existing tokens + * + * @return AccessTokenEntityInterface + * @throws MWOAuthException + * @throws OAuthServerException + * @throws Exception + */ + public function getOwnerOnlyAccessToken( + ConsumerAcceptance $approval, $revokeExisting = false + ) { + if ( + count( $this->getAllowedGrants() ) !== 1 || + $this->getAllowedGrants()[0] !== 'client_credentials' + ) { + // sanity - make sure client is allowed *only* client_credentials grant, + // so that this AT cannot be used in other grant type requests + throw new MWOAuthException( 'mwoauth-oauth2-error-owner-only-invalid-grant' ); + } + $accessToken = null; + $accessTokenRepo = new AccessTokenRepository(); + if ( $revokeExisting ) { + $accessTokenRepo->deleteForApprovalId( $approval->getId() ); + } + /** @var AccessTokenEntity $accessToken */ + $accessToken = $accessTokenRepo->getNewToken( $this, $this->getScopes(), $approval->getUserId() ); + '@phan-var AccessTokenEntity $accessToken'; + $accessToken->setExpiryDateTime( ( new \DateTimeImmutable() )->add( + new \DateInterval( 'P292277000000Y' ) + ) ); + $accessToken->setPrivateKeyFromConfig(); + $accessToken->setIdentifier( bin2hex( random_bytes( 40 ) ) ); + + $accessTokenRepo->persistNewAccessToken( $accessToken ); + + return $accessToken; + } + + /** + * Filter out scopes that application cannot use + * + * @param array $requested + * @return array + */ + private function getVerifiedScopes( $requested ) { + return array_intersect( $requested, $this->getGrants() ); + } +} diff --git a/OAuth/src/Entity/RefreshTokenEntity.php b/OAuth/src/Entity/RefreshTokenEntity.php new file mode 100644 index 00000000..701b2aa7 --- /dev/null +++ b/OAuth/src/Entity/RefreshTokenEntity.php @@ -0,0 +1,21 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Entity; + +use JsonSerializable; +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; +use League\OAuth2\Server\Entities\Traits\RefreshTokenTrait; + +class RefreshTokenEntity implements RefreshTokenEntityInterface, JsonSerializable { + use RefreshTokenTrait; + use EntityTrait; + + public function jsonSerialize() { + return [ + 'identifier' => $this->getIdentifier(), + 'accessToken' => $this->getAccessToken()->getIdentifier(), + 'expires' => $this->getExpiryDateTime()->getTimestamp() + ]; + } +} diff --git a/OAuth/src/Entity/ScopeEntity.php b/OAuth/src/Entity/ScopeEntity.php new file mode 100644 index 00000000..8b3843d6 --- /dev/null +++ b/OAuth/src/Entity/ScopeEntity.php @@ -0,0 +1,26 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Entity; + +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Entities\Traits\EntityTrait; + +class ScopeEntity implements ScopeEntityInterface { + use EntityTrait; + + /** + * Create generic scope entity + * + * @param string $identifier + */ + public function __construct( $identifier ) { + $this->identifier = $identifier; + } + + /** + * @return string + */ + public function jsonSerialize() { + return $this->getIdentifier(); + } +} diff --git a/OAuth/src/Entity/UserEntity.php b/OAuth/src/Entity/UserEntity.php new file mode 100644 index 00000000..73c803b0 --- /dev/null +++ b/OAuth/src/Entity/UserEntity.php @@ -0,0 +1,55 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Entity; + +use League\OAuth2\Server\Entities\UserEntityInterface; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MWException; +use User; + +class UserEntity implements UserEntityInterface { + + /** + * @var int + */ + private $identifier = 0; + + /** + * @param User $user + * @return UserEntity|null + */ + public static function newFromMWUser( User $user ) { + try { + $userId = Utils::getCentralIdFromLocalUser( $user ); + if ( !$userId ) { + return null; + } + return new static( $userId ); + } catch ( MWException $ex ) { + return null; + } + } + + /** + * @param string $identifier + */ + public function __construct( $identifier ) { + $this->identifier = $identifier; + } + + /** + * Return the user's identifier. + * + * @return mixed + */ + public function getIdentifier() { + return $this->identifier; + } + + /** + * @return bool|User + */ + public function getMWUser() { + return Utils::getLocalUserFromCentralId( $this->identifier ); + } +} diff --git a/OAuth/src/Exception/ClientApprovalDenyException.php b/OAuth/src/Exception/ClientApprovalDenyException.php new file mode 100644 index 00000000..ad32b10d --- /dev/null +++ b/OAuth/src/Exception/ClientApprovalDenyException.php @@ -0,0 +1,19 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Exception; + +use League\OAuth2\Server\Exception\OAuthServerException; + +class ClientApprovalDenyException extends OAuthServerException { + + public function __construct( $redirectUri ) { + parent::__construct( + wfMessage( 'mwoauth-oauth2-error-user-approval-deny' )->plain(), + 401, + 'unauthorized_client', + 400, + null, + $redirectUri + ); + } +} diff --git a/OAuth/src/Frontend/EchoOAuthStageChangePresentationModel.php b/OAuth/src/Frontend/EchoOAuthStageChangePresentationModel.php new file mode 100644 index 00000000..f27c039b --- /dev/null +++ b/OAuth/src/Frontend/EchoOAuthStageChangePresentationModel.php @@ -0,0 +1,121 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend; + +use EchoAttributeManager; +use EchoEventPresentationModel; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MWException; +use SpecialPage; +use User; + +class EchoOAuthStageChangePresentationModel extends EchoEventPresentationModel { + /** @var User[] OAuth admins who should be notified about additiions to the review queue */ + protected static $oauthAdmins; + + /** @var Consumer|false */ + protected $consumer; + + /** @var User|false The owner of the OAuth consumer */ + protected $owner; + + /** + * Helper function for $wgEchoNotifications + * @param string $action One of the actions from MWOAuthConsumerSubmitControl::$actions + * @return array + */ + public static function getDefinition( $action ) { + if ( $action === 'propose' ) { + // notify admins + $category = 'oauth-admin'; + } else { + // notify owner + $category = 'oauth-owner'; + } + + return [ + EchoAttributeManager::ATTR_LOCATORS => [ Utils::class . '::locateUsersToNotify' ], + 'category' => $category, + 'presentation-model' => self::class, + 'icon' => 'oauth', + ]; + } + + public function getHeaderMessage() { + $action = $this->event->getExtraParam( 'action' ); + return $this->msg( "notification-oauth-app-$action-title", + $this->event->getAgent(), $this->getConsumerName(), $this->getOwner() ); + } + + public function getSubjectMessage() { + $action = $this->event->getExtraParam( 'action' ); + return $this->msg( "notification-oauth-app-$action-subject", + $this->event->getAgent(), $this->getConsumerName(), $this->getOwner() ); + } + + public function getBodyMessage() { + $comment = $this->event->getExtraParam( 'comment' ); + return $comment ? $this->msg( 'notification-oauth-app-body', $comment ) : false; + } + + public function getIconType() { + return 'oauth'; + } + + public function getPrimaryLink() { + $consumerKey = $this->event->getExtraParam( 'app-key' ); + $action = $this->event->getExtraParam( 'action' ); + + if ( $action === 'propose' ) { + // show management interface + $page = SpecialPage::getSafeTitleFor( 'OAuthManageConsumers', $consumerKey ); + } else { + // show public view + $page = SpecialPage::getSafeTitleFor( 'OAuthListConsumers', "view/$consumerKey" ); + } + if ( $page === null ) { + throw new MWException( "Invalid app ID: $consumerKey" ); + } + + return [ + 'url' => $page->getLocalURL(), + 'label' => $this->msg( "notification-oauth-app-$action-primary-link" )->text(), + ]; + } + + public function getSecondaryLinks() { + return [ $this->getAgentLink() ]; + } + + /** + * @return Consumer|false + */ + protected function getConsumer() { + if ( $this->consumer === null ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + $this->consumer = + Consumer::newFromKey( $dbr, $this->event->getExtraParam( 'app-key' ) ); + } + return $this->consumer; + } + + /** + * @return User|false + */ + protected function getOwner() { + if ( $this->owner === null ) { + $this->owner = Utils::getLocalUserFromCentralId( + $this->event->getExtraParam( 'owner-id' ) ); + } + return $this->owner; + } + + /** + * @return string + */ + protected function getConsumerName() { + $consumer = $this->getConsumer(); + return $consumer ? $consumer->getName() : false; + } +} diff --git a/OAuth/src/Frontend/OAuthLogFormatter.php b/OAuth/src/Frontend/OAuthLogFormatter.php new file mode 100644 index 00000000..a078954c --- /dev/null +++ b/OAuth/src/Frontend/OAuthLogFormatter.php @@ -0,0 +1,40 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend; + +use LogEntry; +use LogFormatter; +use MediaWiki\Linker\LinkRenderer; +use MediaWiki\MediaWikiServices; +use Message; +use Title; + +/** + * Formatter for OAuth log events + */ +class OAuthLogFormatter extends LogFormatter { + /** @var LinkRenderer */ + protected $linkRenderer; + + protected function __construct( LogEntry $entry ) { + parent::__construct( $entry ); + $this->linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + } + + protected function getMessageParameters() { + $params = parent::getMessageParameters(); + if ( isset( $params[3] ) ) { // sanity + $params[3] = $this->getConsumerLink( $params[3] ); + } + return $params; + } + + protected function getConsumerLink( $consumerKey ) { + $title = Title::newFromText( 'Special:OAuthListConsumers/view/' . $consumerKey ); + if ( $this->plaintext ) { + return '[[' . $title->getPrefixedText() . '|' . $consumerKey . ']]'; + } else { + return Message::rawParam( $this->linkRenderer->makeLink( $title, $consumerKey ) ); + } + } +} diff --git a/OAuth/src/Frontend/Pagers/ListConsumersPager.php b/OAuth/src/Frontend/Pagers/ListConsumersPager.php new file mode 100644 index 00000000..1e04844c --- /dev/null +++ b/OAuth/src/Frontend/Pagers/ListConsumersPager.php @@ -0,0 +1,128 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\Pagers; + +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Frontend\SpecialPages\SpecialMWOAuthListConsumers; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Query to list out consumers + * + * @TODO: use UserCache + */ +class ListConsumersPager extends \AlphabeticPager { + /** @var SpecialMWOAuthListConsumers */ + public $mForm; + + /** @var array */ + public $mConds; + + public function __construct( $form, $conds, $name, $centralUserID, $stage ) { + $this->mForm = $form; + $this->mConds = $conds; + + $this->mIndexField = null; + if ( $name !== '' ) { + $this->mConds['oarc_name'] = $name; + $this->mIndexField = 'oarc_id'; + } + if ( $centralUserID !== null ) { + $this->mConds['oarc_user_id'] = $centralUserID; + $this->mIndexField = 'oarc_id'; + } + if ( $stage >= 0 ) { + $this->mConds['oarc_stage'] = $stage; + if ( !$this->mIndexField ) { + $this->mIndexField = 'oarc_stage_timestamp'; + } + } + if ( !$this->mIndexField ) { + $this->mIndexField = 'oarc_id'; + } + + if ( !$this->getUser()->isAllowed( 'mwoauthviewsuppressed' ) ) { + $this->mConds['oarc_deleted'] = 0; + } + + $this->mDb = Utils::getCentralDB( DB_REPLICA ); + parent::__construct(); + + # Treat 20 as the default limit, since each entry takes up 5 rows. + $urlLimit = $this->mRequest->getInt( 'limit' ); + $this->mLimit = $urlLimit ?: 20; + } + + /** + * @return \Title + */ + public function getTitle() { + return $this->mForm->getFullTitle(); + } + + /** + * @param \stdClass $row + * @return string + */ + public function formatRow( $row ) { + return $this->mForm->formatRow( $this->mDb, $row ); + } + + /** + * @return string + */ + public function getStartBody() { + if ( $this->getNumRows() ) { + return '<ul>'; + } else { + return ''; + } + } + + /** + * @return string + */ + public function getEndBody() { + if ( $this->getNumRows() ) { + return '</ul>'; + } else { + return ''; + } + } + + /** + * @return array + */ + public function getQueryInfo() { + return [ + 'tables' => [ 'oauth_registered_consumer' ], + 'fields' => [ '*' ], + 'conds' => $this->mConds + ]; + } + + /** + * @return string + */ + public function getIndexField() { + return $this->mIndexField; + } +} diff --git a/OAuth/src/Frontend/Pagers/ListMyConsumersPager.php b/OAuth/src/Frontend/Pagers/ListMyConsumersPager.php new file mode 100644 index 00000000..47d9bf1c --- /dev/null +++ b/OAuth/src/Frontend/Pagers/ListMyConsumersPager.php @@ -0,0 +1,109 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\Pagers; + +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Frontend\SpecialPages\SpecialMWOAuthConsumerRegistration; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Query to list out consumers + * + * @TODO: use UserCache + */ +class ListMyConsumersPager extends \ReverseChronologicalPager { + /** @var SpecialMWOAuthConsumerRegistration */ + public $mForm; + + /** @var array */ + public $mConds; + + public function __construct( $form, $conds, $centralUserId ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->mConds['oarc_user_id'] = $centralUserId; + if ( !$this->getUser()->isAllowed( 'mwoauthviewsuppressed' ) ) { + $this->mConds['oarc_deleted'] = 0; + } + + $this->mDb = Utils::getCentralDB( DB_REPLICA ); + parent::__construct(); + + # Treat 20 as the default limit, since each entry takes up 5 rows. + $urlLimit = $this->mRequest->getInt( 'limit' ); + $this->mLimit = $urlLimit ?: 20; + } + + /** + * @return \Title + */ + public function getTitle() { + return $this->mForm->getFullTitle(); + } + + /** + * @param \stdClass $row + * @return string + */ + public function formatRow( $row ) { + return $this->mForm->formatRow( $this->mDb, $row ); + } + + /** + * @return string + */ + public function getStartBody() { + if ( $this->getNumRows() ) { + return '<ul>'; + } else { + return ''; + } + } + + /** + * @return string + */ + public function getEndBody() { + if ( $this->getNumRows() ) { + return '</ul>'; + } else { + return ''; + } + } + + /** + * @return array + */ + public function getQueryInfo() { + return [ + 'tables' => [ 'oauth_registered_consumer' ], + 'fields' => [ '*' ], + 'conds' => $this->mConds + ]; + } + + /** + * @return string + */ + public function getIndexField() { + return 'oarc_stage_timestamp'; + } +} diff --git a/OAuth/src/Frontend/Pagers/ManageConsumersPager.php b/OAuth/src/Frontend/Pagers/ManageConsumersPager.php new file mode 100644 index 00000000..ab181cfe --- /dev/null +++ b/OAuth/src/Frontend/Pagers/ManageConsumersPager.php @@ -0,0 +1,109 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\Pagers; + +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Frontend\SpecialPages\SpecialMWOAuthManageConsumers; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Query to list out consumers + * + * @TODO: use UserCache + */ +class ManageConsumersPager extends \ReverseChronologicalPager { + /** @var SpecialMWOAuthManageConsumers */ + public $mForm; + + /** @var array */ + public $mConds; + + public function __construct( $form, $conds, $stage ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->mConds['oarc_stage'] = $stage; + if ( !$this->getUser()->isAllowed( 'mwoauthviewsuppressed' ) ) { + $this->mConds['oarc_deleted'] = 0; + } + + $this->mDb = Utils::getCentralDB( DB_REPLICA ); + parent::__construct(); + + # Treat 20 as the default limit, since each entry takes up 5 rows. + $urlLimit = $this->mRequest->getInt( 'limit' ); + $this->mLimit = $urlLimit ?: 20; + } + + /** + * @return \Title + */ + public function getTitle() { + return $this->mForm->getFullTitle(); + } + + /** + * @param \stdClass $row + * @return string + */ + public function formatRow( $row ) { + return $this->mForm->formatRow( $this->mDb, $row ); + } + + /** + * @return string + */ + public function getStartBody() { + if ( $this->getNumRows() ) { + return '<ul>'; + } else { + return ''; + } + } + + /** + * @return string + */ + public function getEndBody() { + if ( $this->getNumRows() ) { + return '</ul>'; + } else { + return ''; + } + } + + /** + * @return array + */ + public function getQueryInfo() { + return [ + 'tables' => [ 'oauth_registered_consumer' ], + 'fields' => [ '*' ], + 'conds' => $this->mConds + ]; + } + + /** + * @return string + */ + public function getIndexField() { + return 'oarc_stage_timestamp'; + } +} diff --git a/OAuth/src/Frontend/Pagers/ManageMyGrantsPager.php b/OAuth/src/Frontend/Pagers/ManageMyGrantsPager.php new file mode 100644 index 00000000..fbd6c425 --- /dev/null +++ b/OAuth/src/Frontend/Pagers/ManageMyGrantsPager.php @@ -0,0 +1,111 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\Pagers; + +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Frontend\SpecialPages\SpecialMWOAuthManageMyGrants; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Query to list out consumers that have an access token for this user + * + * @TODO: use UserCache + */ +class ManageMyGrantsPager extends \ReverseChronologicalPager { + public $mForm, $mConds; + + /** + * @param SpecialMWOAuthManageMyGrants $form + * @param array $conds + * @param int $centralUserId + */ + public function __construct( $form, $conds, $centralUserId ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->mConds[] = 'oaac_consumer_id = oarc_id'; + $this->mConds['oaac_user_id'] = $centralUserId; + if ( !$this->getUser()->isAllowed( 'mwoauthviewsuppressed' ) ) { + $this->mConds['oarc_deleted'] = 0; + } + + $this->mDb = Utils::getCentralDB( DB_REPLICA ); + parent::__construct(); + + # Treat 20 as the default limit, since each entry takes up 5 rows. + $urlLimit = $this->mRequest->getInt( 'limit' ); + $this->mLimit = $urlLimit ?: 20; + } + + /** + * @return \Title + */ + public function getTitle() { + return $this->mForm->getFullTitle(); + } + + /** + * @param \stdClass $row + * @return string + */ + public function formatRow( $row ) { + return $this->mForm->formatRow( $this->mDb, $row ); + } + + /** + * @return string + */ + public function getStartBody() { + if ( $this->getNumRows() ) { + return '<ul>'; + } else { + return ''; + } + } + + /** + * @return string + */ + public function getEndBody() { + if ( $this->getNumRows() ) { + return '</ul>'; + } else { + return ''; + } + } + + /** + * @return array + */ + public function getQueryInfo() { + return [ + 'tables' => [ 'oauth_accepted_consumer', 'oauth_registered_consumer' ], + 'fields' => [ '*' ], + 'conds' => $this->mConds + ]; + } + + /** + * @return string + */ + public function getIndexField() { + return 'oaac_consumer_id'; + } +} diff --git a/OAuth/src/Frontend/SpecialPages/SpecialMWOAuth.php b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuth.php new file mode 100644 index 00000000..9a46a50d --- /dev/null +++ b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuth.php @@ -0,0 +1,743 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\SpecialPages; + +/** + * (c) Chris Steipp, Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +use Firebase\JWT\JWT; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthRequest; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthToken; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Control\ConsumerAcceptanceSubmitControl; +use MediaWiki\Extensions\OAuth\Control\ConsumerAccessControl; +use MediaWiki\Extensions\OAuth\Lib\OAuthException; +use MediaWiki\Extensions\OAuth\Lib\OAuthToken; +use MediaWiki\Extensions\OAuth\Lib\OAuthUtil; +use MediaWiki\Extensions\OAuth\UserStatementProvider; +use MediaWiki\Logger\LoggerFactory; +use Psr\Log\LoggerInterface; + +/** + * Page that handles OAuth consumer authorization and token exchange + */ +class SpecialMWOAuth extends \UnlistedSpecialPage { + /** @var LoggerInterface */ + protected $logger; + + /** @var int Defaults to OAuth1 */ + protected $oauthVersion = Consumer::OAUTH_VERSION_1; + + public function __construct() { + parent::__construct( 'OAuth' ); + $this->logger = LoggerFactory::getInstance( 'OAuth' ); + } + + public function doesWrites() { + return true; + } + + public function getLocalName() { + // Force the canonical name when OAuth headers are present, + // otherwise SpecialPageFactory redirects and breaks the signature. + if ( Utils::hasOAuthHeaders( $this->getRequest() ) ) { + return $this->getName(); + } + return parent::getLocalName(); + } + + public function execute( $subpage ) { + global $wgMWOAuthSecureTokenTransfer, $wgMWOAuthReadOnly, $wgBlockDisablesLogin; + + $this->setHeaders(); + + $user = $this->getUser(); + $request = $this->getRequest(); + $format = $request->getVal( 'format', 'raw' ); + + try { + if ( $wgMWOAuthReadOnly && + !in_array( $subpage, [ 'verified', 'grants', 'identify' ] ) + ) { + throw new MWOAuthException( 'mwoauth-db-readonly' ); + } + + $this->determineOAuthVersion( $request ); + switch ( $subpage ) { + case 'initiate': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 ); + $oauthServer = Utils::newMWOAuthServer(); + $oauthRequest = MWOAuthRequest::fromRequest( $request ); + $this->logger->debug( __METHOD__ . ": Getting temporary credentials" ); + // fetch_request_token does the version, freshness, and sig checks + $token = $oauthServer->fetch_request_token( $oauthRequest ); + $this->returnToken( $token, $format ); + break; + case 'approve': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_2 ); + $format = 'html'; + $clientId = $request->getVal( 'client_id', '' ); + $this->logger->debug( __METHOD__ . ": doing '$subpage' for OAuth2 with " . + "client_id '$clientId' for '{$user->getName()}'" ); + if ( $user->isAnon() ) { + // Should not happen, as user login status will already be checked at this point + // Just redirect back to REST, it will then redirect to login + return $this->redirectToREST(); + } + if ( $request->wasPosted() && $request->getCheck( 'cancel' ) ) { + $this->showCancelPage( $clientId ); + } else { + $this->handleAuthorizationForm( + null, $clientId, true + ); + } + + break; + case 'authorize': + case 'authenticate': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 ); + $format = 'html'; // for exceptions + + $requestToken = $request->getVal( 'requestToken', + $request->getVal( 'oauth_token' ) ); + $consumerKey = $request->getVal( 'consumerKey', + $request->getVal( 'oauth_consumer_key' ) ); + $this->logger->debug( __METHOD__ . ": doing '$subpage' with " . + "'$requestToken' '$consumerKey' for '{$user->getName()}'" ); + + // TODO? Test that $requestToken exists in memcache + if ( $user->isAnon() ) { + // Login required on provider wiki + $this->requireLogin( 'mwoauth-login-required-reason' ); + } else { + if ( $request->wasPosted() && $request->getCheck( 'cancel' ) ) { + // Show acceptance cancellation confirmation + $this->showCancelPage( $consumerKey ); + } else { + // Show form and redirect on submission for authorization + $this->handleAuthorizationForm( + $requestToken, $consumerKey, $subpage === 'authenticate' + ); + } + } + break; + case 'token': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 ); + $oauthServer = Utils::newMWOAuthServer(); + $oauthRequest = MWOAuthRequest::fromRequest( $request ); + + $isRsa = $oauthRequest->get_parameter( "oauth_signature_method" ) === 'RSA-SHA1'; + + // We want to use HTTPS when returning the credentials. But + // for RSA we don't need to return a token secret, so HTTP is ok. + if ( $wgMWOAuthSecureTokenTransfer && !$isRsa + && $request->detectProtocol() == 'http' + && substr( wfExpandUrl( '/', PROTO_HTTPS ), 0, 8 ) === 'https://' + ) { + $redirUrl = str_replace( + 'http://', 'https://', $request->getFullRequestURL() + ); + $this->getOutput()->redirect( $redirUrl ); + $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' ); + break; + } + + $token = $oauthServer->fetch_access_token( $oauthRequest ); + if ( $isRsa ) { + // RSA doesn't use the token secret, so don't return one. + $token->secret = '__unused__'; + } + $this->returnToken( $token, $format ); + break; + case 'verified': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 ); + $format = 'html'; // for exceptions + $verifier = $request->getVal( 'oauth_verifier' ); + $requestToken = $request->getVal( 'oauth_token' ); + if ( !$verifier || !$requestToken ) { + throw new MWOAuthException( 'mwoauth-bad-request-missing-params', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E001', + 'E001', + true + ) ) + ] ); + } + $this->getOutput()->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() ); + $this->showResponse( + $this->msg( 'mwoauth-verified', + wfEscapeWikiText( $verifier ), + wfEscapeWikiText( $requestToken ) + )->parse(), + $format + ); + break; + case 'grants': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 ); + // Backwards compatibility + $listGrants = \SpecialPage::getTitleFor( 'ListGrants' ); + $this->getOutput()->redirect( $listGrants->getFullURL() ); + break; + case 'identify': + $this->assertOAuthVersion( Consumer::OAUTH_VERSION_1 ); + $format = 'json'; // we only return JWT, so we assume json + $server = Utils::newMWOAuthServer(); + $oauthRequest = MWOAuthRequest::fromRequest( $request ); + // verify_request throws an exception if anything isn't verified + list( $consumer, $token ) = $server->verify_request( $oauthRequest ); + /** @var Consumer $consumer */ + /** @var MWOAuthToken $token */ + + $wiki = wfWikiID(); + $dbr = Utils::getCentralDB( DB_REPLICA ); + $access = ConsumerAcceptance::newFromToken( $dbr, $token->key ); + $localUser = Utils::getLocalUserFromCentralId( $access->getUserId() ); + if ( !$localUser || !$localUser->isLoggedIn() ) { + throw new MWOAuthException( 'mwoauth-invalid-authorization-invalid-user', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', + 'E008', + true + ) ) + ] ); + } elseif ( $localUser->isLocked() || + $wgBlockDisablesLogin && $localUser->isBlocked() + ) { + throw new MWOAuthException( 'mwoauth-invalid-authorization-blocked-user' ); + } + // Access token is for this wiki + if ( $access->getWiki() !== '*' && $access->getWiki() !== $wiki ) { + throw new MWOAuthException( + 'mwoauth-invalid-authorization-wrong-wiki', + [ $wiki ] + ); + } elseif ( !$consumer->isUsableBy( $localUser ) ) { + throw new MWOAuthException( 'mwoauth-invalid-authorization-not-approved', + $consumer->getName() ); + } + + // We know the identity of the user who granted the authorization + $this->outputJWT( $localUser, $consumer, $oauthRequest, $format, $access ); + break; + case 'rest_redirect': + $query = $this->getRequest()->getQueryValues(); + $restUrl = $query['rest_url']; + unset( $query['title'] ); + unset( $query['rest_url'] ); + + $target = wfExpandUrl( $restUrl ); + + $this->getOutput()->redirect( wfAppendQuery( $target, $query ) ); + break; + case '': + $output = $this->getOutput(); + $this->addHelpLink( 'Help:OAuth' ); + $output->addWikiMsg( 'mwoauth-nosubpage-explanation' ); + break; + default: + $format = $request->getVal( 'format', 'html' ); + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromKey( + $dbr, + $request->getVal( 'oauth_consumer_key', null ) + ), + $this->getContext() + ); + + if ( !$cmrAc || !$cmrAc->userCanAccess( 'userId' ) ) { + $this->showError( + $this->msg( 'mwoauth-bad-request-invalid-action' )->rawParams( + \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E002', + 'E002', + true + ) + ), + $format + ); + } else { + $owner = $cmrAc->getUserName( $this->getUser() ); + $this->showError( + $this->msg( 'mwoauth-bad-request-invalid-action-contact', + Utils::getCentralUserTalk( $owner ) + )->rawParams( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E003', + 'E003', + true + ) ), + $format + ); + } + } + } catch ( MWOAuthException $exception ) { + $this->logger->warning( __METHOD__ . ": Exception " . $exception->getMessage(), + [ 'exception' => $exception ] ); + $this->showError( $this->msg( $exception->msg, $exception->params ), $format ); + } catch ( OAuthException $exception ) { + $this->logger->warning( __METHOD__ . ": Exception " . $exception->getMessage(), + [ 'exception' => $exception ] ); + $this->showError( + $this->msg( 'mwoauth-oauth-exception', $exception->getMessage() ), + $format + ); + } + + $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' ); + } + + /** + * @param string $consumerKey + * @throws MWOAuthException + */ + protected function showCancelPage( $consumerKey ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromKey( $dbr, $consumerKey ), + $this->getContext() + ); + if ( !$cmrAc ) { + throw new MWOAuthException( 'mwoauth-invalid-consumer-key' ); + } + + if ( $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) { + // Respond to client with user approval denied error + $this->redirectToREST( [ + 'approval_cancel' => 1 + ] ); + return; + } + + $this->getOutput()->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() ); + $this->getOutput()->addWikiMsg( + 'mwoauth-acceptance-cancelled', + $cmrAc->getName() + ); + $this->getOutput()->addReturnTo( \Title::newMainPage() ); + } + + /** + * Make statements about the user, and sign the json with + * a key shared with the Consumer. + * @param \User $user the user who is the subject of this request + * @param Consumer $consumer + * @param MWOAuthRequest $request + * @param string $format the format of the response: raw, json, or html + * @param ConsumerAcceptance $access + */ + protected function outputJWT( $user, $consumer, $request, $format, $access ) { + $grants = $access->getGrants(); + $userStatementProvider = UserStatementProvider::factory( $user, $consumer, $grants ); + + $statement = $userStatementProvider->getUserStatement(); + // String value used to associate a Client session with an ID Token, and to mitigate + // replay attacks. The value is passed through unmodified from the Authorization Request. + $statement['nonce'] = $request->get_parameter( 'oauth_nonce' ); + $JWT = JWT::encode( $statement, $consumer->secret ); + $this->showResponse( $JWT, $format ); + } + + protected function handleAuthorizationForm( $requestToken, $consumerKey, $authenticate ) { + $this->getOutput()->addSubtitle( $this->msg( 'mwoauth-desc' )->escaped() ); + $user = $this->getUser(); + + $oauthServer = Utils::newMWOAuthServer(); + + if ( !$consumerKey && $this->oauthVersion === Consumer::OAUTH_VERSION_1 ) { + $consumerKey = $oauthServer->getConsumerKey( $requestToken ); + } + + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromKey( Utils::getCentralDB( DB_REPLICA ), $consumerKey ), + $this->getContext() + ); + + if ( !$cmrAc || !$cmrAc->userCanAccess( [ 'name', 'userId', 'grants' ] ) ) { + throw new MWOAuthException( 'mwoauthserver-bad-consumer-key', [ + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E006', + 'E006', + true + ) ) + ] ); + } elseif ( + !$cmrAc->getDAO()->isUsableBy( $user ) || + $cmrAc->getDAO()->getOAuthVersion() !== $this->oauthVersion + ) { + throw new MWOAuthException( + 'mwoauthserver-bad-consumer', + [ + $cmrAc->getName(), + Utils::getCentralUserTalk( $cmrAc->getUserName() ), + ] + ); + } + + $existing = $cmrAc->getDAO()->getCurrentAuthorization( $user, wfWikiID() ); + + // If only authentication was requested, and the existing authorization + // matches, and the only grants are 'mwoauth-authonly' or 'mwoauth-authonlyprivate', + // then don't bother prompting the user about it. + if ( $existing && $authenticate && + $existing->getWiki() === $cmrAc->getDAO()->getWiki() && + $existing->getGrants() === $cmrAc->getDAO()->getGrants() && + !array_diff( $existing->getGrants(), [ 'mwoauth-authonly', 'mwoauth-authonlyprivate' ] ) + ) { + if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) { + $this->redirectToREST( [ + 'approval_pass' => true + ] ); + } else { + $callback = $cmrAc->getDAO()->authorize( + $user, false, $cmrAc->getDAO()->getGrants(), $requestToken + ); + $this->getOutput()->redirect( $callback ); + } + return; + } + + $this->getOutput()->addModuleStyles( + [ 'mediawiki.ui', 'mediawiki.ui.button', 'ext.MWOAuth.Styles' ] + ); + $this->getOutput()->addModules( 'ext.MWOAuth.AuthorizeDialog' ); + + $control = new ConsumerAcceptanceSubmitControl( + $this->getContext(), [], Utils::getCentralDB( DB_MASTER ), $this->oauthVersion + ); + + $form = \HTMLForm::factory( 'table', + $control->registerValidators( $this->getRequestValidators( [ + 'existing' => $existing, + 'consumerKey' => $consumerKey, + 'requestToken' => $requestToken + ] ) ), + $this->getContext() + ); + $form->setSubmitCallback( + function ( array $data, \IContextSource $context ) use ( $control ) { + if ( $context->getRequest()->getCheck( 'cancel' ) ) { // sanity + throw new \MWException( 'Received request for a form cancellation.' ); + } + $control->setInputParameters( $data ); + return $control->submit(); + } + ); + $form->setId( 'mw-mwoauth-authorize-form' ); + + // Possible messages are: + // * mwoauth-form-description-allwikis + // * mwoauth-form-description-onewiki + // * mwoauth-form-description-allwikis-nogrants + // * mwoauth-form-description-onewiki-nogrants + // * mwoauth-form-description-allwikis-privateinfo + // * mwoauth-form-description-onewiki-privateinfo + // * mwoauth-form-description-allwikis-privateinfo-norealname + // * mwoauth-form-description-onewiki-privateinfo-norealname + $msgKey = 'mwoauth-form-description'; + $params = [ + $this->getUser()->getName(), + $cmrAc->getName(), + $cmrAc->getUserName(), + ]; + if ( $cmrAc->getWiki() === '*' ) { + $msgKey .= '-allwikis'; + } else { + $msgKey .= '-onewiki'; + $params[] = $cmrAc->getWikiName(); + } + $grants = $cmrAc->getGrants(); + if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) { + $grants = $this->getRequestedGrants( $cmrAc ); + } + + $grantsText = \MWGrants::getGrantsWikiText( $grants, $this->getLanguage() ); + if ( $grantsText === "\n" ) { + if ( in_array( 'mwoauth-authonlyprivate', $cmrAc->getGrants(), true ) ) { + $msgKey .= '-privateinfo'; + if ( !$this->useRealNames() ) { + // If the wiki does not use real names, don't mention them in the authorization + // dialog to avoid scaring users. The wiki where the authorization dialog is + // shown and the wiki where the user is actually identified might be different; + // there's not much we can do about that here so it is left to the wiki + // administrator to set up the farm in a non-misleading way. + $msgKey .= '-norealname'; + } + } else { + $msgKey .= '-nogrants'; + } + } else { + $params[] = $grantsText; + } + $form->addHeaderText( $this->msg( $msgKey, $params )->parseAsBlock() ); + $form->addHeaderText( $this->msg( 'mwoauth-form-legal' )->text() ); + + $form->suppressDefaultSubmit(); + $form->addButton( [ + 'name' => 'accept', + 'value' => $this->msg( 'mwoauth-form-button-approve' )->text(), + 'id' => 'mw-mwoauth-accept', + 'attribs' => [ + 'class' => 'mw-mwoauth-authorize-button mw-ui-button mw-ui-progressive' + ] + ] ); + $form->addButton( [ + 'name' => 'cancel', + 'value' => $this->msg( 'mwoauth-form-button-cancel' )->text(), + 'attribs' => [ + 'class' => 'mw-mwoauth-authorize-button mw-ui-button mw-ui-quiet' + ] + ] ); + + $form->addFooterText( $this->getSkin()->privacyLink() ); + + $this->getOutput()->addHTML( + '<div id="mw-mwoauth-authorize-dialog" class="mw-ui-container">' ); + $status = $form->show(); + + $this->getOutput()->addHTML( '</div>' ); + if ( $status instanceof \Status && $status->isOK() ) { + if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) { + $this->redirectToREST( [ + 'approval_pass' => true + ] ); + } else { + // Redirect to callback url + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $this->getOutput()->redirect( $status->value['result']['callbackUrl'] ); + } + } + } + + private function redirectToREST( $queryAppend = [] ) { + $redirectParams = [ + 'returnto' => $this->getRequest()->getText( + 'returnto', $this->getRequest()->getText( 'returnto' ) + ), + 'returntoquery' => wfCgiToArray( + $this->getRequest()->getText( + 'returntoquery', $this->getRequest()->getText( 'returntoquery' ) + ) + ) + ]; + + $expanded = wfExpandUrl( $redirectParams['returnto'] ); + if ( !$expanded ) { + return; + } + + $returnToQuery = array_merge( + $redirectParams['returntoquery'], + $queryAppend + ); + $returnToQuery = wfArrayToCgi( $returnToQuery ); + + $this->getOutput()->disable(); + $this->getOutput()->getRequest()->response()->header( + 'Location: ' . "$expanded?{$returnToQuery}" + ); + } + + private function getRequestValidators( $data = [] ) { + $validators = [ + 'action' => [ + 'type' => 'hidden', + 'default' => 'accept', + ], + 'confirmUpdate' => [ + 'type' => 'hidden', + 'default' => $data['existing'] ? 1 : 0, + ], + 'oauth_version' => [ + 'name' => 'oauth_version', + 'type' => 'hidden', + 'default' => $this->oauthVersion + ], + ]; + if ( $this->oauthVersion === Consumer::OAUTH_VERSION_2 ) { + $validators += [ + 'client_id' => [ + 'name' => 'client_id', + 'type' => 'hidden', + 'default' => $this->getRequest()->getText( 'client_id' ) + ], + 'scope' => [ + 'name' => 'scope', + 'type' => 'hidden', + 'default' => $this->getRequest()->getText( 'scope' ) + ], + 'returnto' => [ + 'name' => 'returnto', + 'type' => 'hidden', + 'default' => $this->getRequest()->getText( 'returnto' ) + ], + 'returntoquery' => [ + 'name' => 'returntoquery', + 'type' => 'hidden', + 'default' => $this->getRequest()->getText( 'returntoquery' ) + ], + ]; + } else { + $validators += [ + 'consumerKey' => [ + 'name' => 'consumerKey', + 'type' => 'hidden', + 'default' => $data['consumerKey'] + ], + 'requestToken' => [ + 'name' => 'requestToken', + 'type' => 'hidden', + 'default' => $data['requestToken'], + ], + ]; + } + + return $validators; + } + + /** + * OAuth 2.0 only + * Get only the grants (scopes) that were actually requested (and are allowed) + * + * @param ConsumerAccessControl $cmrAc + * @return array + */ + private function getRequestedGrants( $cmrAc ) { + $allowed = $cmrAc->getGrants(); + $requested = explode( ' ', $this->getRequest()->getText( 'scope', '' ) ); + + return array_intersect( $requested, $allowed ); + } + + /** + * @param \Message $message to return to the user + * @param string $format the format of the response: html, raw, or json + */ + private function showError( $message, $format ) { + if ( $format == 'raw' ) { + $this->showResponse( 'Error: ' . $message->escaped(), 'raw' ); + } elseif ( $format == 'json' ) { + $error = \FormatJson::encode( [ + 'error' => $message->getKey(), + 'message' => $message->text(), + ] ); + $this->showResponse( $error, 'json' ); + } elseif ( $format == 'html' ) { + $this->getOutput()->showErrorPage( 'mwoauth-error', $message ); + } + } + + /** + * @param OAuthToken $token + * @param string $format the format of the response: html, raw, or json + */ + private function returnToken( OAuthToken $token, $format ) { + if ( $format == 'raw' ) { + $return = 'oauth_token=' . OAuthUtil::urlencode_rfc3986( $token->key ); + $return .= '&oauth_token_secret=' . OAuthUtil::urlencode_rfc3986( $token->secret ); + $return .= '&oauth_callback_confirmed=true'; + $this->showResponse( $return, 'raw' ); + } elseif ( $format == 'json' ) { + $this->showResponse( \FormatJson::encode( $token ), 'json' ); + } elseif ( $format == 'html' ) { + $html = \Html::element( + 'li', + [], + 'oauth_token = ' . OAuthUtil::urlencode_rfc3986( $token->key ) + ); + $html .= \Html::element( + 'li', + [], + 'oauth_token_secret = ' . OAuthUtil::urlencode_rfc3986( $token->secret ) + ); + $html .= \Html::element( + 'li', + [], + 'oauth_callback_confirmed = true' + ); + $html = \Html::rawElement( 'ul', [], $html ); + $this->showResponse( $html, 'html' ); + } + } + + /** + * @param string $data html or string to pass back to the user. Already escaped. + * @param string $format the format of the response: raw, json, or html + * @param-taint $data escaped + */ + private function showResponse( $data, $format ) { + $out = $this->getOutput(); + if ( $format == 'raw' || $format == 'json' ) { + $this->getOutput()->disable(); + // Cancel output buffering and gzipping if set + wfResetOutputBuffers(); + // We must not allow the output to be Squid cached + $response = $this->getRequest()->response(); + $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); + $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' ); + $response->header( 'Pragma: no-cache' ); + $response->header( 'Content-length: ' . strlen( $data ) ); + if ( $format == 'json' ) { + $response->header( 'Content-type: application/json' ); + } else { + $response->header( 'Content-type: text/plain' ); + } + print $data; + } elseif ( $format == 'html' ) { // html + $out->addHTML( $data ); + } + } + + /** + * Check whether the wiki is configured to use/show real names. + * We assume that either all or none of the OAuth wikis in a farm use real names. + * @return bool + */ + private function useRealNames() { + $config = $this->getContext()->getConfig(); + return !in_array( 'realname', $config->get( 'HiddenPrefs' ), true ); + } + + /** + * Get the requested OAuth version from the request + * + * @param \WebRequest $request + * @return string + */ + private function determineOAuthVersion( \WebRequest $request ) { + $this->oauthVersion = $request->getInt( 'oauth_version', Consumer::OAUTH_VERSION_1 ); + + return $this->oauthVersion; + } + + /** + * @param string $allowed Allowed version + * @throws MWOAuthException + */ + private function assertOAuthVersion( $allowed ) { + if ( $this->oauthVersion !== $allowed ) { + throw new MWOAuthException( + 'mwoauth-oauth-unsupported-version', + $this->oauthVersion + ); + } + } +} diff --git a/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthConsumerRegistration.php b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthConsumerRegistration.php new file mode 100644 index 00000000..296f9371 --- /dev/null +++ b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthConsumerRegistration.php @@ -0,0 +1,618 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\SpecialPages; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Control\ConsumerAccessControl; +use MediaWiki\Extensions\OAuth\Control\ConsumerSubmitControl; +use MediaWiki\Extensions\OAuth\Frontend\Pagers\ListMyConsumersPager; +use MediaWiki\Extensions\OAuth\Frontend\UIUtils; +use MediaWiki\MediaWikiServices; +use User; +use Wikimedia\Rdbms\DBConnRef; + +/** + * Page that has registration request form and consumer update form + */ +class SpecialMWOAuthConsumerRegistration extends \SpecialPage { + + public function __construct() { + parent::__construct( 'OAuthConsumerRegistration' ); + } + + public function doesWrites() { + return true; + } + + public function userCanExecute( User $user ) { + return $user->isEmailConfirmed(); + } + + public function displayRestrictionError() { + throw new \PermissionsError( null, [ 'mwoauthconsumerregistration-need-emailconfirmed' ] ); + } + + public function execute( $par ) { + global $wgMWOAuthSecureTokenTransfer, $wgMWOAuthReadOnly; + $this->checkPermissions(); + + $request = $this->getRequest(); + $user = $this->getUser(); + $lang = $this->getLanguage(); + $centralUserId = Utils::getCentralIdFromLocalUser( $user ); + + // Redirect to HTTPs if attempting to access this page via HTTP. + // Proposals and updates to consumers can involve sending new secrets. + if ( $wgMWOAuthSecureTokenTransfer + && $request->detectProtocol() == 'http' + && substr( wfExpandUrl( '/', PROTO_HTTPS ), 0, 8 ) === 'https://' + ) { + $redirUrl = str_replace( 'http://', 'https://', $request->getFullRequestURL() ); + $this->getOutput()->redirect( $redirUrl ); + $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' ); + return; + } + + $this->setHeaders(); + $this->getOutput()->disallowUserJs(); + $this->addHelpLink( 'Help:OAuth' ); + + $block = $user->getBlock(); + if ( $block ) { + throw new \UserBlockedError( $block ); + } + $this->checkReadOnly(); + if ( !$this->getUser()->isLoggedIn() ) { + throw new \UserNotLoggedIn(); + } + + // Format is Special:OAuthConsumerRegistration[/propose|/list|/update/<consumer key>] + $navigation = explode( '/', $par ); + $action = $navigation[0] ?? null; + $consumerKey = $navigation[1] ?? null; + + if ( $wgMWOAuthReadOnly && $action !== 'list' ) { + throw new \ErrorPageError( 'mwoauth-error', 'mwoauth-db-readonly' ); + } + + switch ( $action ) { + case 'propose': + if ( !$user->isAllowed( 'mwoauthproposeconsumer' ) ) { + throw new \PermissionsError( 'mwoauthproposeconsumer' ); + } + + $allWikis = Utils::getAllWikiNames(); + + $showGrants = \MWGrants::getValidGrants(); + $config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'mwoauth' ); + + $dbw = Utils::getCentralDB( DB_MASTER ); // @TODO: lazy handle + $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw ); + $form = \HTMLForm::factory( 'ooui', + $control->registerValidators( [ + 'name' => [ + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-name', + 'size' => '45', + 'required' => true + ], + 'version' => [ + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-version', + 'required' => true, + 'default' => "1.0" + ], + 'oauthVersion' => [ + 'type' => 'select', + 'label-message' => 'mwoauth-oauth-version', + 'options' => [ + $this->msg( 'mwoauth-oauth-version-1' )->escaped() => + Consumer::OAUTH_VERSION_1, + $this->msg( 'mwoauth-oauth-version-2' )->escaped() => + Consumer::OAUTH_VERSION_2 + ], + 'required' => true, + 'default' => Consumer::OAUTH_VERSION_1 + ], + 'description' => [ + 'type' => 'textarea', + 'label-message' => 'mwoauth-consumer-description', + 'required' => true, + 'rows' => 5 + ], + 'ownerOnly' => [ + 'type' => 'check', + 'label-message' => [ 'mwoauth-consumer-owner-only', $user->getName() ], + 'help-message' => [ 'mwoauth-consumer-owner-only-help', $user->getName() ], + ], + 'callbackUrl' => [ + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-callbackurl', + 'required' => true, + 'hide-if' => [ '!==', 'ownerOnly', '' ], + ], + 'callbackIsPrefix' => [ + 'type' => 'check', + 'label-message' => 'mwoauth-consumer-callbackisprefix', + 'required' => true, + 'hide-if' => [ '!==', 'ownerOnly', '' ], + ], + 'email' => [ + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-email', + 'required' => true, + 'readonly' => true, + 'default' => $user->getEmail(), + 'help-message' => 'mwoauth-consumer-email-help', + ], + 'wiki' => [ + 'type' => $allWikis ? 'combobox' : 'select', + 'options' => [ + $this->msg( 'mwoauth-consumer-allwikis' )->escaped() => '*', + $this->msg( 'mwoauth-consumer-wiki-thiswiki', wfWikiID() ) + ->escaped() => wfWikiID() + ] + array_flip( $allWikis ), + 'label-message' => 'mwoauth-consumer-wiki', + 'required' => true, + 'default' => '*' + ], + 'oauth2IsConfidential' => [ + 'type' => 'check', + 'label-message' => 'mwoauth-oauth2-is-confidential', + 'help-message' => 'mwoauth-oauth2-is-confidential-help', + 'hide-if' => [ '!==', 'oauthVersion', (string)Consumer::OAUTH_VERSION_2 ], + 'default' => 1 + ], + 'oauth2GrantTypes' => [ + 'type' => 'multiselect', + 'label-message' => 'mwoauth-oauth2-granttypes', + 'hide-if' => [ 'OR', + [ '!==', 'oauthVersion', (string)Consumer::OAUTH_VERSION_2 ], + [ '!==', 'ownerOnly', '' ] + ], + 'options' => array_filter( [ + $this->msg( 'mwoauth-oauth2-granttype-auth-code' )->escaped() => + 'authorization_code', + $this->msg( 'mwoauth-oauth2-granttype-refresh-token' )->escaped() => + 'refresh_token', + $this->msg( 'mwoauth-oauth2-granttype-client-credentials' )->escaped() => + 'client_credentials', + ], function ( $grantType ) use ( $config ) { + return in_array( $grantType, $config->get( 'OAuth2EnabledGrantTypes' ) ); + } ), + 'dropdown' => true, + 'required' => true, + 'default' => [ 'authorization_code', 'refresh_token' ] + ], + 'granttype' => [ + 'type' => 'radio', + 'options-messages' => [ + 'grant-mwoauth-authonly' => 'authonly', + 'grant-mwoauth-authonlyprivate' => 'authonlyprivate', + 'mwoauth-granttype-normal' => 'normal', + ], + 'label-message' => 'mwoauth-consumer-granttypes', + 'default' => 'normal', + ], + 'grants' => [ + 'type' => 'checkmatrix', + 'label-message' => 'mwoauth-consumer-grantsneeded', + 'help-message' => 'mwoauth-consumer-grantshelp', + 'hide-if' => [ '!==', 'granttype', 'normal' ], + 'columns' => [ + $this->msg( 'mwoauth-consumer-required-grant' )->escaped() => 'grant' + ], + 'rows' => array_combine( + array_map( 'MWGrants::getGrantsLink', $showGrants ), + $showGrants + ), + 'tooltips' => array_combine( + array_map( 'MWGrants::getGrantsLink', $showGrants ), + array_map( + function ( $rights ) use ( $lang ) { + return $lang->semicolonList( array_map( + '\User::getRightDescription', $rights ) ); + }, + array_intersect_key( + \MWGrants::getRightsByGrant(), array_flip( $showGrants ) ) + ) + ), + 'force-options-on' => array_map( + function ( $g ) { + return "grant-$g"; + }, + \MWGrants::getHiddenGrants() + ), + 'validation-callback' => null, // different format + ], + 'restrictions' => [ + 'class' => 'HTMLRestrictionsField', + 'required' => true, + 'default' => \MWRestrictions::newDefault(), + ], + 'rsaKey' => [ + 'type' => 'textarea', + 'label-message' => 'mwoauth-consumer-rsakey', + 'help-message' => 'mwoauth-consumer-rsakey-help', + 'required' => false, + 'default' => '', + 'rows' => 5, + 'hide-if' => [ '===', 'oauthVersion', (string)Consumer::OAUTH_VERSION_2 ] + ], + 'agreement' => [ + 'type' => 'check', + 'label-message' => 'mwoauth-consumer-developer-agreement', + 'required' => true, + ], + 'action' => [ + 'type' => 'hidden', + 'default' => 'propose' + ] + ] ), + $this->getContext() + ); + $form->setSubmitCallback( + function ( array $data, \IContextSource $context ) use ( $control ) { + $data['grants'] = \FormatJson::encode( // adapt form to controller + preg_replace( '/^grant-/', '', $data['grants'] ) ); + // 'callbackUrl' must be present, + // otherwise MWOAuthSubmitControl::validateFields() fails. + if ( $data['ownerOnly'] && !isset( $data['callbackUrl'] ) ) { + $data['callbackUrl'] = ''; + } + // Force all ownerOnly clients to use client_credentials + if ( $data['ownerOnly'] ) { + $data['oauth2GrantTypes'] = [ 'client_credentials' ]; + } + + $control->setInputParameters( $data ); + return $control->submit(); + } + ); + $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-propose-legend' ); + $form->setSubmitTextMsg( 'mwoauthconsumerregistration-propose-submit' ); + $form->addPreText( + $this->msg( 'mwoauthconsumerregistration-propose-text' )->parseAsBlock() ); + + $status = $form->show(); + if ( $status instanceof \Status && $status->isOK() ) { + /** @var Consumer $cmr */ + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $cmr = $status->value['result']['consumer']; + if ( $cmr->getOwnerOnly() ) { + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $accessToken = $status->value['result']['accessToken']; + if ( $cmr->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) { + // If we just add raw AT to the page, it would go 3000px wide + $accessToken = \Html::element( 'span', [ + 'style' => 'overflow-wrap: break-word' + ], (string)$accessToken ); + + $this->getOutput()->addWikiMsg( + 'mwoauthconsumerregistration-created-owner-only-oauth2', + $cmr->getConsumerKey(), + Utils::hmacDBSecret( $cmr->getSecretKey() ), + \Message::rawParam( $accessToken ) + ); + } else { + $this->getOutput()->addWikiMsg( + 'mwoauthconsumerregistration-created-owner-only', + $cmr->getConsumerKey(), + Utils::hmacDBSecret( $cmr->getSecretKey() ), + $accessToken->key, + Utils::hmacDBSecret( $accessToken->secret ) + ); + } + } else { + $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-proposed', + $cmr->getConsumerKey(), + Utils::hmacDBSecret( $cmr->getSecretKey() ) ); + } + $this->getOutput()->returnToMain(); + } + break; + case 'update': + if ( !$user->isAllowed( 'mwoauthupdateownconsumer' ) ) { + throw new \PermissionsError( 'mwoauthupdateownconsumer' ); + } + + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromKey( $dbr, $consumerKey ), $this->getContext() ); + if ( !$cmrAc ) { + $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' ); + break; + } elseif ( $cmrAc->getDAO()->getDeleted() && !$user->isAllowed( 'mwoauthviewsuppressed' ) ) { + throw new \PermissionsError( 'mwoauthviewsuppressed' ); + } elseif ( $cmrAc->getDAO()->getUserId() !== $centralUserId ) { + // Do not show private information to other users + $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' ); + break; + } + $oldSecretKey = $cmrAc->getDAO()->getSecretKey(); + + $dbw = Utils::getCentralDB( DB_MASTER ); // @TODO: lazy handle + $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw ); + $form = \HTMLForm::factory( 'ooui', + $control->registerValidators( [ + 'info' => [ + 'type' => 'info', + 'raw' => true, + 'default' => UIUtils::generateInfoTable( [ + 'mwoauth-consumer-name' => $cmrAc->getName(), + 'mwoauth-consumer-version' => $cmrAc->getVersion(), + 'mwoauth-oauth-version' => $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ? + wfMessage( 'mwoauth-oauth-version-2' )->text() : + wfMessage( 'mwoauth-oauth-version-1' )->text(), + 'mwoauth-consumer-key' => $cmrAc->getConsumerKey(), + ], $this->getContext() ), + ], + 'restrictions' => [ + 'class' => 'HTMLRestrictionsField', + 'required' => true, + 'default' => $cmrAc->getDAO()->getRestrictions(), + ], + 'resetSecret' => [ + 'type' => 'check', + 'label-message' => 'mwoauthconsumerregistration-resetsecretkey', + 'default' => false, + ], + 'rsaKey' => [ + 'type' => 'textarea', + 'label-message' => 'mwoauth-consumer-rsakey', + 'required' => false, + 'default' => $cmrAc->getDAO()->getRsaKey(), + 'rows' => 5, + ], + 'reason' => [ + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-reason', + 'required' => true + ], + 'consumerKey' => [ + 'type' => 'hidden', + 'default' => $cmrAc->getConsumerKey(), + ], + 'changeToken' => [ + 'type' => 'hidden', + 'default' => $cmrAc->getDAO()->getChangeToken( $this->getContext() ), + ], + 'action' => [ + 'type' => 'hidden', + 'default' => 'update' + ] + ] ), + $this->getContext() + ); + $form->setSubmitCallback( + function ( array $data, \IContextSource $context ) use ( $control ) { + $control->setInputParameters( $data ); + return $control->submit(); + } + ); + $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-update-legend' ); + $form->setSubmitTextMsg( 'mwoauthconsumerregistration-update-submit' ); + $form->addPreText( + $this->msg( 'mwoauthconsumerregistration-update-text' )->parseAsBlock() ); + + $status = $form->show(); + if ( $status instanceof \Status && $status->isOK() ) { + /** @var Consumer $cmr */ + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $cmr = $status->value['result']['consumer']; + $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-updated' ); + $curSecretKey = $cmr->getSecretKey(); + if ( $oldSecretKey !== $curSecretKey ) { // token reset? + if ( $cmr->getOwnerOnly() ) { + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $accessToken = $status->value['result']['accessToken']; + if ( $cmr->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) { + // If we just add raw AT to the page, it would go 3000px wide + $accessToken = \Html::element( 'span', [ + 'style' => 'overflow-wrap: break-word' + ], (string)$accessToken ); + + $this->getOutput()->addWikiMsg( + 'mwoauthconsumerregistration-secretreset-owner-only-oauth2', + $cmr->getConsumerKey(), + Utils::hmacDBSecret( $cmr->getSecretKey() ), + \Message::rawParam( $accessToken ) + ); + } else { + $this->getOutput()->addWikiMsg( + 'mwoauthconsumerregistration-secretreset-owner-only', + $cmr->getConsumerKey(), + Utils::hmacDBSecret( $curSecretKey ), + $accessToken->key, + Utils::hmacDBSecret( $accessToken->secret ) + ); + } + } else { + $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-secretreset', + Utils::hmacDBSecret( $curSecretKey ) ); + } + } + $this->getOutput()->returnToMain(); + } else { + $out = $this->getOutput(); + // Show all of the status updates + $logPage = new \LogPage( 'mwoauthconsumer' ); + $out->addHTML( \Xml::element( 'h2', null, $logPage->getName()->text() ) ); + \LogEventsList::showLogExtract( $out, 'mwoauthconsumer', '', '', [ + 'conds' => [ + 'ls_field' => 'OAuthConsumer', + 'ls_value' => $cmrAc->getConsumerKey(), + ], + 'flags' => \LogEventsList::NO_EXTRA_USER_LINKS, + ] ); + } + break; + case 'list': + $pager = new ListMyConsumersPager( $this, [], $centralUserId ); + if ( $pager->getNumRows() ) { + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + $this->getOutput()->addHTML( $pager->getBody() ); + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + } else { + $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-none" ); + } + # Every 30th view, prune old deleted items + if ( 0 == mt_rand( 0, 29 ) ) { + Utils::runAutoMaintenance( Utils::getCentralDB( DB_MASTER ) ); + } + break; + default: + $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-maintext' ); + } + + $this->addSubtitleLinks( $action, $consumerKey ); + + $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' ); + } + + /** + * Show navigation links + * + * @param string $action + * @param string $consumerKey + * @return void + */ + protected function addSubtitleLinks( $action, $consumerKey ) { + $listLinks = []; + if ( $consumerKey || $action !== 'propose' ) { + $listLinks[] = \Linker::linkKnown( + $this->getPageTitle( 'propose' ), + $this->msg( 'mwoauthconsumerregistration-propose' )->escaped() ); + } else { + $listLinks[] = $this->msg( 'mwoauthconsumerregistration-propose' )->escaped(); + } + if ( $consumerKey || $action !== 'list' ) { + $listLinks[] = \Linker::linkKnown( + $this->getPageTitle( 'list' ), + $this->msg( 'mwoauthconsumerregistration-list' )->escaped() ); + } else { + $listLinks[] = $this->msg( 'mwoauthconsumerregistration-list' )->escaped(); + } + if ( $consumerKey && $action == 'update' ) { + $listLinks[] = \Linker::linkKnown( + \SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$consumerKey" ), + $this->msg( 'mwoauthconsumer-consumer-view' )->escaped() ); + } + + $linkHtml = $this->getLanguage()->pipeList( $listLinks ); + + $viewall = $this->msg( 'parentheses' )->rawParams( + \Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'mwoauthconsumerregistration-main' )->escaped() + ) + )->escaped(); + + $this->getOutput()->setSubtitle( + "<strong>" . $this->msg( 'mwoauthconsumerregistration-navigation' )->escaped() . + "</strong> [{$linkHtml}] <strong>{$viewall}</strong>" ); + } + + /** + * @param DBConnRef $db + * @param \stdClass $row + * @return string + */ + public function formatRow( DBConnRef $db, $row ) { + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromRow( $db, $row ), $this->getContext() ); + $cmrKey = $cmrAc->getConsumerKey(); + + $links = []; + $links[] = \Linker::linkKnown( + \SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$cmrKey" ), + $this->msg( 'mwoauthlistconsumers-view' )->escaped() + ); + + $links[] = \Linker::linkKnown( + $this->getPageTitle( 'update/' . $cmrKey ), + $this->msg( 'mwoauthconsumerregistration-manage' )->escaped() + ); + + $links = $this->getLanguage()->pipeList( $links ); + + $time = htmlspecialchars( $this->getLanguage()->timeanddate( + wfTimestamp( TS_MW, $cmrAc->getRegistration() ), true ) ); + + $stageKey = Consumer::$stageNames[$cmrAc->getStage()]; + $encStageKey = htmlspecialchars( $stageKey ); // sanity + // Show last log entry (@TODO: title namespace?) + // @TODO: inject DB + $logHtml = ''; + \LogEventsList::showLogExtract( $logHtml, 'mwoauthconsumer', '', '', [ + 'conds' => [ + 'ls_field' => 'OAuthConsumer', + 'ls_value' => $cmrAc->getConsumerKey(), + ], + 'lim' => 1, + 'flags' => \LogEventsList::NO_EXTRA_USER_LINKS, + ] ); + + $lang = $this->getLanguage(); + $oauthVersionMessage = $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ? + wfMessage( 'mwoauth-oauth-version-2' )->text() : + wfMessage( 'mwoauth-oauth-version-1' )->text(); + $data = [ + 'mwoauthconsumerregistration-name' => $cmrAc->escapeForHtml( $cmrAc->getNameAndVersion() ), + 'mwoauth-oauth-version' => $cmrAc->escapeForHtml( $oauthVersionMessage ), + // Messages: mwoauth-consumer-stage-proposed, mwoauth-consumer-stage-rejected, + // mwoauth-consumer-stage-expired, mwoauth-consumer-stage-approved, + // mwoauth-consumer-stage-disabled + 'mwoauthconsumerregistration-stage' => + $this->msg( "mwoauth-consumer-stage-$stageKey" )->escaped(), + 'mwoauthconsumerregistration-description' => $cmrAc->escapeForHtml( + $cmrAc->get( 'description', function ( $s ) use ( $lang ) { + return $lang->truncateForVisual( $s, 10024 ); + } ) + ), + 'mwoauthconsumerregistration-email' => $cmrAc->escapeForHtml( $cmrAc->getEmail() ), + 'mwoauthconsumerregistration-consumerkey' => $cmrAc->escapeForHtml( $cmrAc->getConsumerKey() ), + 'mwoauthconsumerregistration-lastchange' => $logHtml, + ]; + + $r = "<li class='mw-mwoauthconsumerregistration-{$encStageKey}'>"; + $r .= "<span>$time (<strong>{$links}</strong>)</span>"; + $r .= "<table class='mw-mwoauthconsumerregistration-body' " . + "cellspacing='1' cellpadding='3' border='1' width='100%'>"; + foreach ( $data as $msg => $encValue ) { + $r .= '<tr>' . + '<td><strong>' . $this->msg( $msg )->escaped() . '</strong></td>' . + '<td width=\'90%\'>' . $encValue . '</td>' . + '</tr>'; + } + $r .= '</table>'; + + $r .= '</li>'; + + return $r; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthListConsumers.php b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthListConsumers.php new file mode 100644 index 00000000..57a36176 --- /dev/null +++ b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthListConsumers.php @@ -0,0 +1,408 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\SpecialPages; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +use Html; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Control\ConsumerAccessControl; +use MediaWiki\Extensions\OAuth\Frontend\Pagers\ListConsumersPager; +use MediaWiki\Extensions\OAuth\Frontend\UIUtils; +use MediaWiki\MediaWikiServices; +use OOUI\HtmlSnippet; +use SpecialPage; +use Wikimedia\Rdbms\DBConnRef; + +/** + * Special page for listing the queue of consumer requests and managing + * their approval/rejection and also for listing approved/disabled consumers + */ +class SpecialMWOAuthListConsumers extends \SpecialPage { + public function __construct() { + parent::__construct( 'OAuthListConsumers' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $this->addHelpLink( 'Help:OAuth' ); + + // Format is Special:OAuthListConsumers[/list|/view/[<consumer key>]] + $navigation = explode( '/', $par ); + $type = $navigation[0] ?? null; + $consumerKey = $navigation[1] ?? null; + + $this->showConsumerListForm(); + + switch ( $type ) { + case 'view': + $this->showConsumerInfo( $consumerKey ); + break; + default: + $this->showConsumerList(); + break; + } + + $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' ); + } + + /** + * Show the form to approve/reject/disable/re-enable consumers + * + * @param string $consumerKey + * @throws \PermissionsError + */ + protected function showConsumerInfo( $consumerKey ) { + $user = $this->getUser(); + $out = $this->getOutput(); + + if ( !$consumerKey ) { + $out->addWikiMsg( 'mwoauth-missing-consumer-key' ); + } + + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromKey( $dbr, $consumerKey ), $this->getContext() ); + if ( !$cmrAc ) { + $out->addWikiMsg( 'mwoauth-invalid-consumer-key' ); + return; + } elseif ( $cmrAc->getDeleted() && !$user->isAllowed( 'mwoauthviewsuppressed' ) ) { + throw new \PermissionsError( 'mwoauthviewsuppressed' ); + } + + $grants = $cmrAc->getGrants(); + if ( $grants === [ 'mwoauth-authonly' ] || $grants === [ 'mwoauth-authonlyprivate' ] ) { + $s = $this->msg( 'grant-' . $grants[0] )->plain() . "\n"; + } else { + $s = \MWGrants::getGrantsWikiText( $grants, $this->getLanguage() ); + if ( $s == '' ) { + $s = $this->msg( 'mwoauthlistconsumers-basicgrantsonly' )->plain(); + } else { + $s .= "\n"; + } + } + + $stageKey = Consumer::$stageNames[$cmrAc->getDAO()->getStage()]; + $data = [ + 'mwoauthlistconsumers-name' => $cmrAc->getName(), + 'mwoauthlistconsumers-version' => $cmrAc->getVersion(), + 'mwoauth-oauth-version' => $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 + ? $this->msg( 'mwoauth-oauth-version-2' ) + : $this->msg( 'mwoauth-oauth-version-1' ), + 'mwoauthlistconsumers-user' => $cmrAc->getUserName(), + 'mwoauthlistconsumers-status' => $this->msg( "mwoauthlistconsumers-status-$stageKey" ), + 'mwoauthlistconsumers-description' => $cmrAc->getDescription(), + 'mwoauthlistconsumers-wiki' => $cmrAc->getWikiName(), + 'mwoauthlistconsumers-callbackurl' => $cmrAc->getCallbackUrl(), + 'mwoauthlistconsumers-callbackisprefix' => $cmrAc->getCallbackIsPrefix() ? + $this->msg( 'htmlform-yes' ) : $this->msg( 'htmlform-no' ), + ]; + + if ( $grants !== [ 'basic' ] ) { + $data[ 'mwoauthlistconsumers-grants' ] = new HtmlSnippet( $out->parseInlineAsInterface( $s ) ); + } + + $out->addHTML( UIUtils::generateInfoTable( $data, $this->getContext() ) ); + + $this->addNavigationSubtitle( $cmrAc ); + + if ( Utils::isCentralWiki() ) { + // Show all of the status updates + $logPage = new \LogPage( 'mwoauthconsumer' ); + $out->addHTML( \Xml::element( 'h2', null, $logPage->getName()->text() ) ); + \LogEventsList::showLogExtract( $out, 'mwoauthconsumer', '', '', [ + 'conds' => [ + 'ls_field' => 'OAuthConsumer', + 'ls_value' => $cmrAc->getConsumerKey(), + ], + 'flags' => \LogEventsList::NO_EXTRA_USER_LINKS, + ] ); + } + } + + /** + * Show a form for the paged list of consumers + */ + protected function showConsumerListForm() { + $form = \HTMLForm::factory( 'ooui', + [ + 'name' => [ + 'name' => 'name', + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-name', + 'required' => false, + ], + 'publisher' => [ + 'name' => 'publisher', + 'type' => 'text', + 'label-message' => 'mwoauth-consumer-user', + 'required' => false + ], + 'stage' => [ + 'name' => 'stage', + 'type' => 'select', + 'label-message' => 'mwoauth-consumer-stage', + 'options' => [ + $this->msg( 'mwoauth-consumer-stage-any' )->escaped() => -1, + $this->msg( 'mwoauth-consumer-stage-proposed' )->escaped() + => Consumer::STAGE_PROPOSED, + $this->msg( 'mwoauth-consumer-stage-approved' )->escaped() + => Consumer::STAGE_APPROVED, + $this->msg( 'mwoauth-consumer-stage-rejected' )->escaped() + => Consumer::STAGE_REJECTED, + $this->msg( 'mwoauth-consumer-stage-disabled' )->escaped() + => Consumer::STAGE_DISABLED, + $this->msg( 'mwoauth-consumer-stage-expired' )->escaped() + => Consumer::STAGE_EXPIRED + ], + 'default' => Consumer::STAGE_APPROVED, + 'required' => false + ] + ], + $this->getContext() + ); + $form->setAction( $this->getPageTitle()->getFullURL() ); // always go back to listings + $form->setSubmitCallback( function () { + return false; + } ); + $form->setMethod( 'get' ); + $form->setSubmitTextMsg( 'go' ); + $form->setWrapperLegendMsg( 'mwoauthlistconsumers-legend' ); + $form->show(); + } + + /** + * Show a paged list of consumers with links to details + * @suppress SecurityCheck-XSS For getNavigationBar, see T201811 for more information + */ + protected function showConsumerList() { + $request = $this->getRequest(); + + $name = $request->getVal( 'name', '' ); + $stage = $request->getInt( 'stage', Consumer::STAGE_APPROVED ); + if ( $request->getVal( 'publisher', '' ) !== '' ) { + $centralId = Utils::getCentralIdFromUserName( $request->getVal( 'publisher' ) ); + } else { + $centralId = null; + } + + $pager = new ListConsumersPager( $this, [], $name, $centralId, $stage ); + if ( $pager->getNumRows() ) { + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + $this->getOutput()->addHTML( $pager->getBody() ); + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + } else { + // Messages: mwoauthlistconsumers-none-proposed, mwoauthlistconsumers-none-rejected, + // mwoauthlistconsumers-none-expired, mwoauthlistconsumers-none-approved, + // mwoauthlistconsumers-none-disabled + $this->getOutput()->addWikiMsg( "mwoauthlistconsumers-none" ); + } + # Every 30th view, prune old deleted items + if ( 0 == mt_rand( 0, 29 ) ) { + Utils::runAutoMaintenance( Utils::getCentralDB( DB_MASTER ) ); + } + } + + /** + * @param DBConnRef $db + * @param \stdClass $row + * @return string + */ + public function formatRow( DBConnRef $db, $row ) { + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromRow( $db, $row ), $this->getContext() ); + + $cmrKey = $cmrAc->getConsumerKey(); + $stageKey = Consumer::$stageNames[$cmrAc->getStage()]; + + $links = []; + $links[] = \Linker::linkKnown( + $this->getPageTitle( "view/{$cmrKey}" ), + $this->msg( 'mwoauthlistconsumers-view' )->escaped(), + [], + $this->getRequest()->getValues( 'name', 'publisher', 'stage' ) // stick + ); + if ( $this->getUser()->isAllowed( 'mwoauthmanageconsumer' ) ) { + $links[] = \Linker::linkKnown( + \SpecialPage::getTitleFor( 'OAuthManageConsumers', $cmrKey ), + $this->msg( 'mwoauthmanageconsumers-review' )->escaped() + ); + } + $links = $this->getLanguage()->pipeList( $links ); + + $encStageKey = htmlspecialchars( $stageKey ); // sanity + $r = "<li class=\"mw-mwoauthlistconsumers-{$encStageKey}\">"; + + $name = $cmrAc->getNameAndVersion(); + $r .= '<strong>' . $cmrAc->escapeForHtml( $name ) . '</strong> ' . $this->msg( 'parentheses' ) + ->rawParams( "<strong>{$links}</strong>" )->escaped(); + + $lang = $this->getLanguage(); + $data = [ + 'mwoauth-oauth-version' => $cmrAc->escapeForHtml( + $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 + ? $this->msg( 'mwoauth-oauth-version-2' ) + : $this->msg( 'mwoauth-oauth-version-1' ) + ), + 'mwoauthlistconsumers-user' => $cmrAc->escapeForHtml( $cmrAc->getUserName() ), + 'mwoauthlistconsumers-description' => $cmrAc->escapeForHtml( + $cmrAc->get( 'description', function ( $s ) use ( $lang ) { + return $lang->truncateForVisual( $s, 10024 ); + } ) + ), + 'mwoauthlistconsumers-wiki' => $cmrAc->escapeForHtml( $cmrAc->getWikiName() ), + 'mwoauthlistconsumers-status' => + $this->msg( "mwoauthlistconsumers-status-$stageKey" )->escaped(), + ]; + + foreach ( $data as $msg => $encValue ) { + $r .= '<p>' . $this->msg( $msg )->escaped() . ': ' . $encValue . '</p>'; + } + + $rcUrl = SpecialPage::getTitleFor( 'Recentchanges' ) + ->getFullURL( [ 'tagfilter' => Utils::getTagName( $cmrAc->getId() ) ] ); + $rcLink = Html::element( 'a', [ 'href' => $rcUrl ], + $this->msg( 'mwoauthlistconsumers-rclink' )->plain() ); + $r .= '<p>' . $rcLink . '</p>'; + + $r .= '</li>'; + + return $r; + } + + protected function getGroupName() { + return 'users'; + } + + /** + * @param ConsumerAccessControl $cmrAc + * @throws \MWException + */ + private function addNavigationSubtitle( ConsumerAccessControl $cmrAc ): void { + $user = $this->getUser(); + $centralUserId = Utils::getCentralIdFromLocalUser( $user ); + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $consumer = $cmrAc->getDAO(); + + $siteLinks = array_merge( + $this->updateLink( $cmrAc, $centralUserId, $linkRenderer ), + $this->manageConsumerLink( $consumer, $user, $linkRenderer ), + $this->manageMyGrantsLink( $consumer, $centralUserId, $linkRenderer ) + ); + + if ( $siteLinks ) { + $links = $this->getLanguage()->pipeList( $siteLinks ); + $this->getOutput()->setSubtitle( + "<strong>" . $this->msg( 'mwoauthlistconsumers-navigation' )->escaped() . + "</strong> [{$links}]" ); + } + } + + /** + * @param ConsumerAccessControl $cmrAc + * @param int $centralUserId Add update link for this user id, if they can update the consumer + * @param \MediaWiki\Linker\LinkRenderer $linkRenderer + * @return array + * @throws \MWException + */ + private function updateLink( + ConsumerAccessControl $cmrAc, $centralUserId, + \MediaWiki\Linker\LinkRenderer $linkRenderer + ): array { + if ( Utils::isCentralWiki() && $cmrAc->getDAO()->getUserId() === $centralUserId ) { + return [ + $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'OAuthConsumerRegistration', + 'update/' . $cmrAc->getDAO()->getConsumerKey() ), + $this->msg( 'mwoauthlistconsumers-update-link' )->text() ) + ]; + } + + return []; + } + + /** + * @param Consumer $consumer + * @param \User $user + * @param \MediaWiki\Linker\LinkRenderer $linkRenderer + * @return array + * @throws \MWException + */ + private function manageConsumerLink( + Consumer $consumer, \User $user, \MediaWiki\Linker\LinkRenderer $linkRenderer + ): array { + if ( Utils::isCentralWiki() && $user->isAllowed( 'mwoauthmanageconsumer' ) ) { + return [ + $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'OAuthManageConsumers', + $consumer->getConsumerKey() ), + $this->msg( 'mwoauthlistconsumers-manage-link' )->text() ) + ]; + } + + return []; + } + + /** + * @param Consumer $consumer + * @param int $centralUserId Add link to manage grants for this user, if they've granted this + * consumer + * @param \MediaWiki\Linker\LinkRenderer $linkRenderer + * @return array + * @throws \MWException + */ + private function manageMyGrantsLink( + Consumer $consumer, $centralUserId, \MediaWiki\Linker\LinkRenderer $linkRenderer + ): array { + $acceptance = $this->userGrantedAcceptance( $consumer, $centralUserId ); + if ( $acceptance !== false ) { + return [ + $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'OAuthManageMyGrants', + 'update/' . $acceptance->getId() ), + $this->msg( 'mwoauthlistconsumers-grants-link' )->text() ) + ]; + } + + return []; + } + + /** + * @param Consumer $consumer + * @param int $centralUserId UserId to retrieve the grants for + * @return bool|ConsumerAcceptance + */ + private function userGrantedAcceptance( Consumer $consumer, $centralUserId ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + $wikiSpecificGrant = + ConsumerAcceptance::newFromUserConsumerWiki( + $dbr, $centralUserId, $consumer, wfWikiId() ); + + $allWikiGrant = ConsumerAcceptance::newFromUserConsumerWiki( + $dbr, $centralUserId, $consumer, '*' ); + + if ( $wikiSpecificGrant !== false ) { + return $wikiSpecificGrant; + } + if ( $allWikiGrant !== false ) { + return $allWikiGrant; + } + return false; + } +} diff --git a/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageConsumers.php b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageConsumers.php new file mode 100644 index 00000000..85083221 --- /dev/null +++ b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageConsumers.php @@ -0,0 +1,521 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\SpecialPages; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Control\ConsumerAccessControl; +use MediaWiki\Extensions\OAuth\Control\ConsumerSubmitControl; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; +use MediaWiki\Extensions\OAuth\Frontend\Pagers\ManageConsumersPager; +use MediaWiki\Extensions\OAuth\Frontend\UIUtils; +use OOUI\HtmlSnippet; +use Wikimedia\Rdbms\DBConnRef; + +/** + * Special page for listing the queue of consumer requests and managing + * their approval/rejection and also for listing approved/disabled consumers + */ +class SpecialMWOAuthManageConsumers extends \SpecialPage { + /** @var bool|int An Consumer::STAGE_* constant on queue/list subpages, false otherwise */ + protected $stage = false; + /** @var string A stage key from Consumer::$stageNames */ + protected $stageKey; + + /** + * Stages which are shown in a queue (they are in an actionable state and can form a backlog) + * @var array + */ + public static $queueStages = [ Consumer::STAGE_PROPOSED, + Consumer::STAGE_REJECTED, Consumer::STAGE_EXPIRED ]; + + /** + * Stages which cannot form a backlog and are shown in a list + * @var array + */ + public static $listStages = [ Consumer::STAGE_APPROVED, + Consumer::STAGE_DISABLED ]; + + public function __construct() { + parent::__construct( 'OAuthManageConsumers', 'mwoauthmanageconsumer' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + global $wgMWOAuthReadOnly; + + $user = $this->getUser(); + + $this->setHeaders(); + $this->getOutput()->disallowUserJs(); + $this->addHelpLink( 'Help:OAuth' ); + + if ( !$user->isLoggedIn() ) { + $this->getOutput()->addWikiMsg( 'mwoauthmanageconsumers-notloggedin' ); + return; + } elseif ( !$user->isAllowed( 'mwoauthmanageconsumer' ) ) { + throw new \PermissionsError( 'mwoauthmanageconsumer' ); + } + + if ( $wgMWOAuthReadOnly ) { + throw new \ErrorPageError( 'mwoauth-error', 'mwoauth-db-readonly' ); + } + + // Format is Special:OAuthManageConsumers[/<stage>|/<consumer key>] + // B/C format is Special:OAuthManageConsumers/<stage>/<consumer key> + $consumerKey = null; + $navigation = explode( '/', $par ); + if ( count( $navigation ) === 2 ) { + $this->stage = false; + $consumerKey = $navigation[1]; + } elseif ( count( $navigation ) === 1 && $navigation[0] ) { + $this->stage = array_search( $navigation[0], Consumer::$stageNames, true ); + if ( $this->stage !== false ) { + $consumerKey = null; + $this->stageKey = $navigation[0]; + } else { + $consumerKey = $navigation[0]; + } + } + + if ( $consumerKey ) { + $this->handleConsumerForm( $consumerKey ); + } elseif ( $this->stage !== false ) { + $this->showConsumerList(); + } else { + $this->showMainHub(); + } + + $this->addQueueSubtitleLinks( $consumerKey ); + + $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' ); + } + + /** + * Show other sub-queue links. Grey out the current one. + * When viewing a request, show them all and a link to current consumer view. + * + * @param string $consumerKey + * @return void + */ + protected function addQueueSubtitleLinks( $consumerKey ) { + $listLinks = []; + foreach ( self::$queueStages as $stage ) { + $stageKey = Consumer::$stageNames[$stage]; + if ( $consumerKey || $this->stageKey !== $stageKey ) { + $listLinks[] = \Linker::linkKnown( + $this->getPageTitle( $stageKey ), + // Messages: mwoauthmanageconsumers-showproposed, + // mwoauthmanageconsumers-showrejected, mwoauthmanageconsumers-showexpired, + $this->msg( 'mwoauthmanageconsumers-show' . $stageKey )->escaped() ); + } else { + $listLinks[] = $this->msg( 'mwoauthmanageconsumers-show' . $stageKey )->escaped(); + } + } + + if ( $consumerKey ) { + $consumerViewLink = "[" . \Linker::linkKnown( + \SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$consumerKey" ), + $this->msg( 'mwoauthconsumer-consumer-view' )->escaped() ) . "]"; + } else { + $consumerViewLink = ''; + } + + $linkHtml = $this->getLanguage()->pipeList( $listLinks ); + + $viewall = $this->msg( 'parentheses' )->rawParams( \Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'mwoauthmanageconsumers-main' )->escaped() + ) )->escaped(); + + $this->getOutput()->setSubtitle( + "<strong>" . $this->msg( 'mwoauthmanageconsumers-type' )->escaped() . + "</strong> [{$linkHtml}] {$consumerViewLink} <strong>{$viewall}</strong>" ); + } + + /** + * Show the links to all the queues and how many requests are in each. + * Also show the list of enabled and disabled consumers and how many there are of each. + * + * @return void + */ + protected function showMainHub() { + $keyStageMapQ = array_intersect( array_flip( Consumer::$stageNames ), + self::$queueStages ); + $keyStageMapL = array_intersect( array_flip( Consumer::$stageNames ), + self::$listStages ); + + $out = $this->getOutput(); + + $out->addWikiMsg( 'mwoauthmanageconsumers-maintext' ); + + $counts = Utils::getConsumerStateCounts( Utils::getCentralDB( DB_REPLICA ) ); + + $out->wrapWikiMsg( "<p><strong>$1</strong></p>", 'mwoauthmanageconsumers-queues' ); + $out->addHTML( '<ul>' ); + foreach ( $keyStageMapQ as $stageKey => $stage ) { + $tag = ( $stage === Consumer::STAGE_EXPIRED ) ? 'i' : 'b'; + $out->addHTML( + '<li>' . + "<$tag>" . + \Linker::linkKnown( + $this->getPageTitle( $stageKey ), + // Messages: mwoauthmanageconsumers-q-proposed, mwoauthmanageconsumers-q-rejected, + // mwoauthmanageconsumers-q-expired + $this->msg( 'mwoauthmanageconsumers-q-' . $stageKey )->escaped() + ) . + "</$tag> [$counts[$stage]]" . + '</li>' + ); + } + $out->addHTML( '</ul>' ); + + $out->wrapWikiMsg( "<p><strong>$1</strong></p>", 'mwoauthmanageconsumers-lists' ); + $out->addHTML( '<ul>' ); + foreach ( $keyStageMapL as $stageKey => $stage ) { + $out->addHTML( + '<li>' . + \Linker::linkKnown( + $this->getPageTitle( $stageKey ), + // Messages: mwoauthmanageconsumers-l-approved, mwoauthmanageconsumers-l-disabled + $this->msg( 'mwoauthmanageconsumers-l-' . $stageKey )->escaped() + ) . + " [$counts[$stage]]" . + '</li>' + ); + } + $out->addHTML( '</ul>' ); + } + + /** + * Show the form to approve/reject/disable/re-enable consumers + * + * @param string $consumerKey + * @throws \PermissionsError + */ + protected function handleConsumerForm( $consumerKey ) { + $user = $this->getUser(); + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromKey( $dbr, $consumerKey ), $this->getContext() ); + if ( !$cmrAc ) { + $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' ); + return; + } elseif ( $cmrAc->getDeleted() && !$user->isAllowed( 'mwoauthviewsuppressed' ) ) { + throw new \PermissionsError( 'mwoauthviewsuppressed' ); + } + $startingStage = $cmrAc->getStage(); + $pending = !in_array( $startingStage, [ + Consumer::STAGE_APPROVED, Consumer::STAGE_DISABLED ] ); + + if ( $pending ) { + $opts = [ + $this->msg( 'mwoauthmanageconsumers-approve' )->escaped() => 'approve', + $this->msg( 'mwoauthmanageconsumers-reject' )->escaped() => 'reject' + ]; + if ( $this->getUser()->isAllowed( 'mwoauthsuppress' ) ) { + $msg = $this->msg( 'mwoauthmanageconsumers-rsuppress' )->escaped(); + $opts["<strong>$msg</strong>"] = 'rsuppress'; + } + } else { + $opts = [ + $this->msg( 'mwoauthmanageconsumers-disable' )->escaped() => 'disable', + $this->msg( 'mwoauthmanageconsumers-reenable' )->escaped() => 'reenable' + ]; + if ( $this->getUser()->isAllowed( 'mwoauthsuppress' ) ) { + $msg = $this->msg( 'mwoauthmanageconsumers-dsuppress' )->escaped(); + $opts["<strong>$msg</strong>"] = 'dsuppress'; + } + } + + $dbw = Utils::getCentralDB( DB_MASTER ); // @TODO: lazy handle + $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw ); + $form = \HTMLForm::factory( 'ooui', + $control->registerValidators( [ + 'info' => [ + 'type' => 'info', + 'raw' => true, + 'default' => UIUtils::generateInfoTable( + $this->getInfoTableOptions( $cmrAc ), + $this->getContext() + ), + ], + 'action' => [ + 'type' => 'radio', + 'label-message' => 'mwoauthmanageconsumers-action', + 'required' => true, + 'options' => $opts, + 'default' => '', // no validate on GET + ], + 'reason' => [ + 'type' => 'text', + 'label-message' => 'mwoauthmanageconsumers-reason', + 'required' => true, + ], + 'consumerKey' => [ + 'type' => 'hidden', + 'default' => $cmrAc->getConsumerKey(), + ], + 'changeToken' => [ + 'type' => 'hidden', + 'default' => $cmrAc->getDAO()->getChangeToken( $this->getContext() ), + ], + ] ), + $this->getContext() + ); + $form->setSubmitCallback( + function ( array $data, \IContextSource $context ) use ( $control ) { + $data['suppress'] = 0; + if ( $data['action'] === 'dsuppress' ) { + $data = [ 'action' => 'disable', 'suppress' => 1 ] + $data; + } elseif ( $data['action'] === 'rsuppress' ) { + $data = [ 'action' => 'reject', 'suppress' => 1 ] + $data; + } + $control->setInputParameters( $data ); + return $control->submit(); + } + ); + + $form->setWrapperLegendMsg( 'mwoauthmanageconsumers-confirm-legend' ); + $form->setSubmitTextMsg( 'mwoauthmanageconsumers-confirm-submit' ); + $form->addPreText( + $this->msg( 'mwoauthmanageconsumers-confirm-text' )->parseAsBlock() ); + + $status = $form->show(); + if ( $status instanceof \Status && $status->isOK() ) { + /** @var Consumer $cmr */ + // @phan-suppress-next-line PhanTypeArraySuspiciousNullable + $cmr = $status->value['result']; + '@phan-var Consumer $cmr'; + $oldStageKey = Consumer::$stageNames[$startingStage]; + $newStageKey = Consumer::$stageNames[$cmr->getStage()]; + // Messages: mwoauthmanageconsumers-success-approved, mwoauthmanageconsumers-success-rejected, + // mwoauthmanageconsumers-success-disabled + $this->getOutput()->addWikiMsg( "mwoauthmanageconsumers-success-$newStageKey" ); + $returnTo = \Title::newFromText( 'Special:OAuthManageConsumers/' . $oldStageKey ); + $this->getOutput()->addReturnTo( $returnTo, [], + // Messages: mwoauthmanageconsumers-linkproposed, + // mwoauthmanageconsumers-linkrejected, mwoauthmanageconsumers-linkexpired, + // mwoauthmanageconsumers-linkapproved, mwoauthmanageconsumers-linkdisabled + $this->msg( 'mwoauthmanageconsumers-link' . $oldStageKey )->text() ); + } else { + $out = $this->getOutput(); + // Show all of the status updates + $logPage = new \LogPage( 'mwoauthconsumer' ); + $out->addHTML( \Xml::element( 'h2', null, $logPage->getName()->text() ) ); + \LogEventsList::showLogExtract( $out, 'mwoauthconsumer', '', '', [ + 'conds' => [ + 'ls_field' => 'OAuthConsumer', + 'ls_value' => $cmrAc->getConsumerKey(), + ], + 'flags' => \LogEventsList::NO_EXTRA_USER_LINKS, + ] ); + } + } + + /** + * @param ConsumerAccessControl $cmrAc + * @return array + */ + protected function getInfoTableOptions( $cmrAc ) { + $owner = $cmrAc->getUserName(); + $lang = $this->getLanguage(); + + $link = \Linker::linkKnown( + $title = \SpecialPage::getTitleFor( 'OAuthListConsumers' ), + $this->msg( 'mwoauthmanageconsumers-search-publisher' )->escaped(), + [], + [ 'publisher' => $owner ] + ); + $ownerLink = $cmrAc->escapeForHtml( $owner ) . ' ' . + $this->msg( 'parentheses' )->rawParams( $link )->escaped(); + $ownerOnly = $cmrAc->getDAO()->getOwnerOnly(); + $restrictions = $cmrAc->getRestrictions(); + + $options = [ + // Messages: mwoauth-consumer-stage-proposed, mwoauth-consumer-stage-rejected, + // mwoauth-consumer-stage-expired, mwoauth-consumer-stage-approved, + // mwoauth-consumer-stage-disabled + 'mwoauth-consumer-stage' => $cmrAc->getDeleted() + ? $this->msg( 'mwoauth-consumer-stage-suppressed' ) + : $this->msg( 'mwoauth-consumer-stage-' . + Consumer::$stageNames[$cmrAc->getStage()] ), + 'mwoauth-consumer-key' => $cmrAc->getConsumerKey(), + 'mwoauth-consumer-name' => new HtmlSnippet( $cmrAc->get( 'name', function ( $s ) { + $link = \Linker::linkKnown( + \SpecialPage::getTitleFor( 'OAuthListConsumers' ), + $this->msg( 'mwoauthmanageconsumers-search-name' )->escaped(), + [], + [ 'name' => $s ] + ); + return htmlspecialchars( $s ) . ' ' . + $this->msg( 'parentheses' )->rawParams( $link )->escaped(); + } ) ), + 'mwoauth-consumer-version' => $cmrAc->getVersion(), + 'mwoauth-oauth-version' => $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 + ? $this->msg( 'mwoauth-oauth-version-2' ) + : $this->msg( 'mwoauth-oauth-version-1' ), + 'mwoauth-consumer-user' => new HtmlSnippet( $ownerLink ), + 'mwoauth-consumer-description' => $cmrAc->getDescription(), + 'mwoauth-consumer-owner-only-label' => $ownerOnly ? + $this->msg( 'mwoauth-consumer-owner-only', $owner ) : null, + 'mwoauth-consumer-callbackurl' => $ownerOnly ? + null : $cmrAc->getCallbackUrl(), + 'mwoauth-consumer-callbackisprefix' => $ownerOnly ? + null : ( $cmrAc->getCallbackIsPrefix() ? + $this->msg( 'htmlform-yes' ) : $this->msg( 'htmlform-no' ) ), + 'mwoauth-consumer-grantsneeded' => $cmrAc->get( 'grants', + function ( $grants ) use ( $lang ) { + return $lang->semicolonList( \MWGrants::grantNames( $grants, $lang ) ); + } ), + 'mwoauth-consumer-email' => $cmrAc->getEmail(), + 'mwoauth-consumer-wiki' => $cmrAc->getWiki() + ]; + + // Add OAuth2 specific parameters + if ( $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) { + /** @var ClientEntity $consumer */ + $consumer = $cmrAc->getDAO(); + $options += [ + 'mwoauth-oauth2-is-confidential' => $consumer->isConfidential() ? + $this->msg( 'htmlform-yes' ) : $this->msg( 'htmlform-no' ), + 'mwoauth-oauth2-granttypes' => implode( ', ', array_map( function ( $grant ) { + $map = [ + 'authorization_code' => 'mwoauth-oauth2-granttype-auth-code', + 'refresh_token' => 'mwoauth-oauth2-granttype-refresh-token', + 'client_credentials' => 'mwoauth-oauth2-granttype-client-credentials' + ]; + return isset( $map[$grant] ) ? $this->msg( $map[$grant] ) : ''; + }, $consumer->getAllowedGrants() ) ) + ]; + } + + // Add optional parameters + $options += [ + 'mwoauth-consumer-restrictions-json' => $restrictions instanceof \MWRestrictions ? + $restrictions->toJson( true ) : $restrictions, + 'mwoauth-consumer-rsakey' => $cmrAc->getRsaKey(), + ]; + + return $options; + } + + /** + * Show a paged list of consumers with links to details + */ + protected function showConsumerList() { + $pager = new ManageConsumersPager( $this, [], $this->stage ); + if ( $pager->getNumRows() ) { + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + $this->getOutput()->addHTML( $pager->getBody() ); + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + } else { + // Messages: mwoauthmanageconsumers-none-proposed, mwoauthmanageconsumers-none-rejected, + // mwoauthmanageconsumers-none-expired, mwoauthmanageconsumers-none-approved, + // mwoauthmanageconsumers-none-disabled + $this->getOutput()->addWikiMsg( "mwoauthmanageconsumers-none-{$this->stageKey}" ); + } + # Every 30th view, prune old deleted items + if ( 0 == mt_rand( 0, 29 ) ) { + Utils::runAutoMaintenance( Utils::getCentralDB( DB_MASTER ) ); + } + } + + /** + * @param DBConnRef $db + * @param \stdClass $row + * @return string + */ + public function formatRow( DBConnRef $db, $row ) { + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromRow( $db, $row ), $this->getContext() ); + + $cmrKey = $cmrAc->getConsumerKey(); + $stageKey = Consumer::$stageNames[$cmrAc->getStage()]; + + $link = \Linker::linkKnown( + $this->getPageTitle( $cmrKey ), + $this->msg( 'mwoauthmanageconsumers-review' )->escaped() + ); + + $time = $this->getLanguage()->timeanddate( + wfTimestamp( TS_MW, $cmrAc->getRegistration() ), true ); + + $encStageKey = htmlspecialchars( $stageKey ); // sanity + $r = "<li class='mw-mwoauthmanageconsumers-{$encStageKey}'>"; + + $r .= $time . " (<strong>{$link}</strong>)"; + + // Show last log entry (@TODO: title namespace?) + // @TODO: inject DB + $logHtml = ''; + \LogEventsList::showLogExtract( $logHtml, 'mwoauthconsumer', '', '', [ + 'action' => Consumer::$stageActionNames[$cmrAc->getStage()], + 'conds' => [ + 'ls_field' => 'OAuthConsumer', + 'ls_value' => $cmrAc->getConsumerKey(), + ], + 'lim' => 1, + 'flags' => \LogEventsList::NO_EXTRA_USER_LINKS, + ] ); + + $lang = $this->getLanguage(); + $data = [ + 'mwoauthmanageconsumers-name' => $cmrAc->escapeForHtml( $cmrAc->getNameAndVersion() ), + 'mwoauthmanageconsumers-user' => $cmrAc->escapeForHtml( $cmrAc->getUserName() ), + 'mwoauth-oauth-version' => $cmrAc->escapeForHtml( + $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ? + $this->msg( 'mwoauth-oauth-version-2' ) : + $this->msg( 'mwoauth-oauth-version-1' ) + ), + 'mwoauthmanageconsumers-description' => $cmrAc->escapeForHtml( + $cmrAc->get( 'description', function ( $s ) use ( $lang ) { + return $lang->truncateForVisual( $s, 10024 ); + } ) + ), + 'mwoauthmanageconsumers-email' => $cmrAc->escapeForHtml( $cmrAc->getEmail() ), + 'mwoauthmanageconsumers-consumerkey' => $cmrAc->escapeForHtml( $cmrAc->getConsumerKey() ), + 'mwoauthmanageconsumers-lastchange' => $logHtml, + ]; + + $r .= "<table class='mw-mwoauthmanageconsumers-body' " . + "cellspacing='1' cellpadding='3' border='1' width='100%'>"; + foreach ( $data as $msg => $encValue ) { + $r .= '<tr>' . + '<td><strong>' . $this->msg( $msg )->escaped() . '</strong></td>' . + '<td width=\'90%\'>' . $encValue . '</td>' . + '</tr>'; + } + $r .= '</table>'; + + $r .= '</li>'; + + return $r; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageMyGrants.php b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageMyGrants.php new file mode 100644 index 00000000..62def2e3 --- /dev/null +++ b/OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageMyGrants.php @@ -0,0 +1,348 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend\SpecialPages; + +use Html; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Control\ConsumerAcceptanceAccessControl; +use MediaWiki\Extensions\OAuth\Control\ConsumerAcceptanceSubmitControl; +use MediaWiki\Extensions\OAuth\Control\ConsumerAccessControl; +use MediaWiki\Extensions\OAuth\Frontend\Pagers\ManageMyGrantsPager; +use MediaWiki\Extensions\OAuth\Frontend\UIUtils; +use SpecialPage; +use Wikimedia\Rdbms\DBConnRef; + +/** + * (c) Aaron Schulz 2013, GPL + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * http://www.gnu.org/copyleft/gpl.html + */ + +/** + * Special page for listing consumers this user granted access to and + * for manage the specific grants given or revoking access for the consumer + */ +class SpecialMWOAuthManageMyGrants extends SpecialPage { + private static $irrevocableGrants = null; + + public function __construct() { + parent::__construct( 'OAuthManageMyGrants', 'mwoauthmanagemygrants' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + global $wgMWOAuthReadOnly; + + $user = $this->getUser(); + + $this->setHeaders(); + $this->getOutput()->disallowUserJs(); + $this->addHelpLink( 'Help:OAuth' ); + + if ( !$this->getUser()->isLoggedIn() ) { + throw new \UserNotLoggedIn(); + } + if ( !$user->isAllowed( 'mwoauthmanagemygrants' ) ) { + throw new \PermissionsError( 'mwoauthmanagemygrants' ); + } + + // Format is Special:OAuthManageMyGrants[/list|/manage/<accesstoken>] + $navigation = explode( '/', $par ); + $typeKey = $navigation[0] ?? null; + $acceptanceId = $navigation[1] ?? null; + + if ( $wgMWOAuthReadOnly && in_array( $typeKey, [ 'update', 'revoke' ] ) ) { + throw new \ErrorPageError( 'mwoauth-error', 'mwoauth-db-readonly' ); + } + + switch ( $typeKey ) { + case 'update': + case 'revoke': + $this->handleConsumerForm( $acceptanceId, $typeKey ); + break; + default: + $this->showConsumerList(); + break; + } + + $this->addSubtitleLinks( $acceptanceId ); + + $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' ); + } + + /** + * Show navigation links + * + * @param string|null $acceptanceId + * @return void + */ + protected function addSubtitleLinks( $acceptanceId ) { + $listLinks = []; + + if ( $acceptanceId ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmraAc = ConsumerAcceptance::newFromId( $dbr, $acceptanceId ); + $listLinks[] = \Linker::linkKnown( + $this->getPageTitle(), + $this->msg( 'mwoauthmanagemygrants-showlist' )->escaped() ); + + if ( $cmraAc ) { + $cmrAc = Consumer::newFromId( $dbr, $cmraAc->getConsumerId() ); + $consumerKey = $cmrAc->getConsumerKey(); + $listLinks[] = \Linker::linkKnown( + \SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$consumerKey" ), + $this->msg( 'mwoauthconsumer-application-view' )->escaped() ); + } + } else { + $listLinks[] = $this->msg( 'mwoauthmanagemygrants-showlist' )->escaped(); + } + + $linkHtml = $this->getLanguage()->pipeList( $listLinks ); + + $this->getOutput()->setSubtitle( + "<strong>" . $this->msg( 'mwoauthmanagemygrants-navigation' )->escaped() . + "</strong> [{$linkHtml}]" ); + } + + /** + * Show the form to approve/reject/disable/re-enable consumers + * + * @param string $acceptanceId + * @param string $type One of (update,revoke) + * @throws \PermissionsError + */ + protected function handleConsumerForm( $acceptanceId, $type ) { + $user = $this->getUser(); + $lang = $this->getLanguage(); + $dbr = Utils::getCentralDB( DB_REPLICA ); + + $centralUserId = Utils::getCentralIdFromLocalUser( $user ); + if ( !$centralUserId ) { + $this->getOutput()->addWikiMsg( 'badaccess-group0' ); + return; + } + + $cmraAc = ConsumerAcceptanceAccessControl::wrap( + ConsumerAcceptance::newFromId( $dbr, $acceptanceId ), $this->getContext() ); + if ( !$cmraAc || $cmraAc->getUserId() !== $centralUserId ) { + $this->getOutput()->addHTML( $this->msg( 'mwoauth-invalid-access-token' )->escaped() ); + return; + } + + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromId( $dbr, $cmraAc->getConsumerId() ), $this->getContext() ); + if ( $cmrAc->getDeleted() && !$user->isAllowed( 'mwoauthviewsuppressed' ) ) { + throw new \PermissionsError( 'mwoauthviewsuppressed' ); + } + + $this->getOutput()->addModuleStyles( 'mediawiki.ui.button' ); + + $action = ''; + if ( $this->getRequest()->getCheck( 'renounce' ) ) { + $action = 'renounce'; + } elseif ( $this->getRequest()->getCheck( 'update' ) ) { + $action = 'update'; + } + + $data = [ 'action' => $action ]; + $control = new ConsumerAcceptanceSubmitControl( + $this->getContext(), $data, $dbr, $cmraAc->getDAO()->getOAuthVersion() + ); + $form = \HTMLForm::factory( 'ooui', + $control->registerValidators( [ + 'info' => [ + 'type' => 'info', + 'raw' => true, + 'default' => UIUtils::generateInfoTable( [ + 'mwoauth-consumer-name' => $cmrAc->getNameAndVersion(), + 'mwoauth-consumer-user' => $cmrAc->getUserName(), + 'mwoauth-consumer-description' => $cmrAc->getDescription(), + 'mwoauthmanagemygrants-wikiallowed' => $cmraAc->getWikiName(), + ], $this->getContext() ), + ], + 'grants' => [ + 'type' => 'checkmatrix', + 'label-message' => 'mwoauthmanagemygrants-applicablegrantsallowed', + 'columns' => [ + $this->msg( 'mwoauthmanagemygrants-grantaccept' )->escaped() => 'grant' + ], + 'rows' => array_combine( + array_map( 'MWGrants::getGrantsLink', $cmrAc->getGrants() ), + $cmrAc->getGrants() + ), + 'default' => array_map( + function ( $g ) { + return "grant-$g"; + }, + $cmraAc->getGrants() + ), + 'tooltips' => [ + \MWGrants::getGrantsLink( 'basic' ) => + $this->msg( 'mwoauthmanagemygrants-basic-tooltip' )->text(), + \MWGrants::getGrantsLink( 'mwoauth-authonly' ) => + $this->msg( 'mwoauthmanagemygrants-authonly-tooltip' )->text(), + \MWGrants::getGrantsLink( 'mwoauth-authonlyprivate' ) => + $this->msg( 'mwoauthmanagemygrants-authonly-tooltip' )->text(), + ], + 'force-options-on' => array_map( + function ( $g ) { + return "grant-$g"; + }, + ( $type === 'revoke' ) + ? array_merge( \MWGrants::getValidGrants(), self::irrevocableGrants() ) + : self::irrevocableGrants() + ), + 'validation-callback' => null // different format + ], + 'acceptanceId' => [ + 'type' => 'hidden', + 'default' => $cmraAc->getId(), + ] + ] ), + $this->getContext() + ); + $form->setSubmitCallback( + function ( array $data, \IContextSource $context ) use ( $action, $cmraAc ) { + $data['action'] = $action; + $data['grants'] = \FormatJson::encode( // adapt form to controller + preg_replace( '/^grant-/', '', $data['grants'] ) ); + + $dbw = Utils::getCentralDB( DB_MASTER ); + $control = new ConsumerAcceptanceSubmitControl( + $context, $data, $dbw, $cmraAc->getDAO()->getOAuthVersion() + ); + return $control->submit(); + } + ); + + $form->setWrapperLegendMsg( 'mwoauthmanagemygrants-confirm-legend' ); + $form->suppressDefaultSubmit(); + if ( $type === 'revoke' ) { + $form->addButton( [ + 'name' => 'renounce', + 'value' => $this->msg( 'mwoauthmanagemygrants-renounce' )->text(), + 'flags' => [ 'primary', 'destructive' ], + ] ); + } else { + $form->addButton( [ + 'name' => 'update', + 'value' => $this->msg( 'mwoauthmanagemygrants-update' )->text(), + 'flags' => [ 'primary', 'progressive' ], + ] ); + } + $form->addPreText( + $this->msg( "mwoauthmanagemygrants-$type-text" )->parseAsBlock() ); + + $status = $form->show(); + if ( $status instanceof \Status && $status->isOK() ) { + // Messages: mwoauthmanagemygrants-success-update, mwoauthmanagemygrants-success-renounce + $this->getOutput()->addWikiMsg( "mwoauthmanagemygrants-success-$action" ); + } + } + + /** + * Show a paged list of consumers with links to details + * + * @return void + */ + protected function showConsumerList() { + $this->getOutput()->addWikiMsg( 'mwoauthmanagemygrants-text' ); + + $centralUserId = Utils::getCentralIdFromLocalUser( $this->getUser() ); + $pager = new ManageMyGrantsPager( $this, [], $centralUserId ); + if ( $pager->getNumRows() ) { + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + $this->getOutput()->addHTML( $pager->getBody() ); + $this->getOutput()->addHTML( $pager->getNavigationBar() ); + } else { + $this->getOutput()->addWikiMsg( "mwoauthmanagemygrants-none" ); + } + } + + /** + * @param DBConnRef $db + * @param \stdClass $row + * @return string + */ + public function formatRow( DBConnRef $db, $row ) { + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromRow( $db, $row ), $this->getContext() ); + $cmraAc = ConsumerAcceptanceAccessControl::wrap( + ConsumerAcceptance::newFromRow( $db, $row ), $this->getContext() ); + + $links = []; + if ( array_diff( $cmrAc->getGrants(), self::irrevocableGrants() ) ) { + $links[] = \Linker::linkKnown( + $this->getPageTitle( 'update/' . $cmraAc->getId() ), + $this->msg( 'mwoauthmanagemygrants-review' )->escaped() + ); + } + $links[] = \Linker::linkKnown( + $this->getPageTitle( 'revoke/' . $cmraAc->getId() ), + $this->msg( 'mwoauthmanagemygrants-revoke' )->escaped() + ); + $reviewLinks = $this->getLanguage()->pipeList( $links ); + + $encName = $cmrAc->escapeForHtml( $cmrAc->getNameAndVersion() ); + + $r = '<li class="mw-mwoauthmanagemygrants-list-item">'; + $r .= "<strong dir='ltr'>{$encName}</strong> (<strong>$reviewLinks</strong>)"; + $data = [ + 'mwoauthmanagemygrants-user' => $cmrAc->getUserName(), + 'mwoauthmanagemygrants-wikiallowed' => $cmraAc->getWikiName(), + ]; + + foreach ( $data as $msg => $val ) { + $r .= '<p>' . $this->msg( $msg )->escaped() . ' ' . $cmrAc->escapeForHtml( $val ) . '</p>'; + } + + $editsUrl = SpecialPage::getTitleFor( 'Contributions', $this->getUser()->getName() ) + ->getFullURL( [ 'tagfilter' => Utils::getTagName( $cmrAc->getId() ) ] ); + $editsLink = Html::element( 'a', [ 'href' => $editsUrl ], + $this->msg( 'mwoauthmanagemygrants-editslink', $this->getUser() )->text() ); + $r .= '<p>' . $editsLink . '</p>'; + $actionsUrl = SpecialPage::getTitleFor( 'Log' )->getFullURL( [ + 'user' => $this->getUser()->getName(), + 'tagfilter' => Utils::getTagName( $cmrAc->getId() ), + ] ); + $actionsLink = Html::element( 'a', [ 'href' => $actionsUrl ], + $this->msg( 'mwoauthmanagemygrants-actionslink', $this->getUser() )->text() ); + $r .= '<p>' . $actionsLink . '</p>'; + + $r .= '</li>'; + + return $r; + } + + private static function irrevocableGrants() { + if ( self::$irrevocableGrants === null ) { + self::$irrevocableGrants = array_merge( + \MWGrants::getHiddenGrants(), + [ 'mwoauth-authonly', 'mwoauth-authonlyprivate' ] + ); + } + return self::$irrevocableGrants; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/OAuth/src/Frontend/UIHooks.php b/OAuth/src/Frontend/UIHooks.php new file mode 100644 index 00000000..5d6b2602 --- /dev/null +++ b/OAuth/src/Frontend/UIHooks.php @@ -0,0 +1,241 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend; + +use HTMLForm; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Control\ConsumerAccessControl; +use MediaWiki\Extensions\OAuth\Control\ConsumerSubmitControl; +use MediaWiki\Extensions\OAuth\Frontend\SpecialPages\SpecialMWOAuthConsumerRegistration; +use MediaWiki\Extensions\OAuth\Frontend\SpecialPages\SpecialMWOAuthManageConsumers; +use SpecialPage; + +/** + * Class containing GUI even handler functions for an OAuth environment + */ +class UIHooks { + + /** + * @param \User $user + * @param array &$preferences + * @return bool + * @throws \MWException + */ + public static function onGetPreferences( $user, &$preferences ) { + $dbr = Utils::getCentralDB( DB_REPLICA ); + $conds = [ + 'oaac_consumer_id = oarc_id', + 'oaac_user_id' => Utils::getCentralIdFromLocalUser( $user ), + ]; + if ( !$user->isAllowed( 'mwoauthviewsuppressed' ) ) { + $conds['oarc_deleted'] = 0; + } + $count = $dbr->selectField( + [ 'oauth_accepted_consumer', 'oauth_registered_consumer' ], + 'COUNT(*)', + $conds, + __METHOD__ + ); + + $control = new \OOUI\ButtonWidget( [ + 'href' => SpecialPage::getTitleFor( 'OAuthManageMyGrants' )->getLinkURL(), + 'label' => wfMessage( 'mwoauth-prefs-managegrantslink' )->numParams( $count )->text() + ] ); + + $prefInsert = [ 'mwoauth-prefs-managegrants' => + [ + 'section' => 'personal/info', + 'label-message' => 'mwoauth-prefs-managegrants', + 'type' => 'info', + 'raw' => true, + 'default' => (string)$control + ], + ]; + + if ( array_key_exists( 'usergroups', $preferences ) ) { + $preferences = wfArrayInsertAfter( $preferences, $prefInsert, 'usergroups' ); + } else { + $preferences += $prefInsert; + } + + return true; + } + + /** + * Override MediaWiki namespace for a message + * @param string $title Message name (no prefix) + * @param string &$message Message wikitext + * @param string $code Language code + * @return bool false if we replaced $message + */ + public static function onMessagesPreLoad( $title, &$message, $code ) { + // Quick fail check + if ( substr( $title, 0, 15 ) !== 'Tag-OAuth_CID:_' ) { + return true; + } + + // More expensive check + if ( !preg_match( '!^Tag-OAuth_CID:_(\d+)((?:-description)?)(?:/|$)!', $title, $m ) ) { + return true; + } + + // Put the correct language in the context, so that later uses of $context->msg() will use it + $context = new \DerivativeContext( \RequestContext::getMain() ); + $context->setLanguage( $code ); + + $dbr = Utils::getCentralDB( DB_REPLICA ); + $cmrAc = ConsumerAccessControl::wrap( + Consumer::newFromId( $dbr, $m[1] ), $context + ); + if ( !$cmrAc ) { + // Invalid consumer, skip it + return true; + } + + if ( $m[2] ) { + $message = $cmrAc->escapeForWikitext( $cmrAc->getDescription() ); + } else { + $target = \SpecialPage::getTitleFor( 'OAuthListConsumers', + 'view/' . $cmrAc->getConsumerKey() + ); + $encName = $cmrAc->escapeForWikitext( $cmrAc->getNameAndVersion() ); + $message = "[[$target|$encName]]"; + } + return false; + } + + /** + * Append OAuth-specific grants to Special:ListGrants + * @param SpecialPage $special + * @param string $par + * @return bool + */ + public static function onSpecialPageAfterExecute( SpecialPage $special, $par ) { + if ( $special->getName() != 'Listgrants' ) { + return true; + } + + $out = $special->getOutput(); + + $out->addWikiMsg( 'mwoauth-listgrants-extra-summary' ); + + $out->addHTML( + \Html::openElement( 'table', + [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . + '<tr>' . + \Html::element( 'th', null, $special->msg( 'listgrants-grant' )->text() ) . + \Html::element( 'th', null, $special->msg( 'listgrants-rights' )->text() ) . + '</tr>' + ); + + $grants = [ + 'mwoauth-authonly' => [], + 'mwoauth-authonlyprivate' => [], + ]; + + foreach ( $grants as $grant => $rights ) { + $descs = []; + $rights = array_filter( $rights ); // remove ones with 'false' + foreach ( $rights as $permission => $granted ) { + $descs[] = $special->msg( + 'listgrouprights-right-display', + \User::getRightDescription( $permission ), + '<span class="mw-listgrants-right-name">' . $permission . '</span>' + )->parse(); + } + if ( !count( $descs ) ) { + $grantCellHtml = ''; + } else { + sort( $descs ); + $grantCellHtml = '<ul><li>' . implode( "</li>\n<li>", $descs ) . '</li></ul>'; + } + + $id = \Sanitizer::escapeIdForAttribute( $grant ); + $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ], + "<td>" . $special->msg( "grant-$grant" )->escaped() . "</td>" . + "<td>" . $grantCellHtml . '</td>' + ) ); + } + + $out->addHTML( \Html::closeElement( 'table' ) ); + + return true; + } + + /** + * Add additional text to Special:BotPasswords + * @param string $name Special page name + * @param HTMLForm $form + * @return bool + */ + public static function onSpecialPageBeforeFormDisplay( $name, HTMLForm $form ) { + global $wgMWOAuthCentralWiki; + + if ( $name === 'BotPasswords' ) { + if ( Utils::isCentralWiki() ) { + $url = SpecialPage::getTitleFor( 'OAuthConsumerRegistration' )->getFullURL(); + } else { + $url = \WikiMap::getForeignURL( + $wgMWOAuthCentralWiki, + 'Special:OAuthConsumerRegistration' // Cross-wiki, so don't localize + ); + } + $form->addPreText( $form->msg( 'mwoauth-botpasswords-note', $url )->parseAsBlock() ); + } + return true; + } + + /** + * @param array &$notifications + * @param array &$notificationCategories + * @param array &$icons + */ + public static function onBeforeCreateEchoEvent( + &$notifications, &$notificationCategories, &$icons + ) { + global $wgOAuthGroupsToNotify; + + if ( !Utils::isCentralWiki() ) { + return; + } + + $notificationCategories['oauth-owner'] = [ + 'tooltip' => 'echo-pref-tooltip-oauth-owner', + ]; + $notificationCategories['oauth-admin'] = [ + 'tooltip' => 'echo-pref-tooltip-oauth-admin', + 'usergroups' => $wgOAuthGroupsToNotify, + ]; + + foreach ( ConsumerSubmitControl::$actions as $eventName ) { + // oauth-app-propose and oauth-app-update notifies admins of the app. + // oauth-app-approve, oauth-app-reject, oauth-app-disable and oauth-app-reenable + // notify owner of the change. + $notifications["oauth-app-$eventName"] = + EchoOAuthStageChangePresentationModel::getDefinition( $eventName ); + } + + $icons['oauth'] = [ 'path' => 'OAuth/resources/assets/echo-icon.png' ]; + } + + /** + * @param array &$specialPages + */ + public static function onSpecialPage_initList( array &$specialPages ) { + if ( Utils::isCentralWiki() ) { + $specialPages['OAuthConsumerRegistration'] = SpecialMWOAuthConsumerRegistration::class; + $specialPages['OAuthManageConsumers'] = SpecialMWOAuthManageConsumers::class; + } + } + + /** + * Show help text when a user is redirected to provider login page + * @param array &$messages + * @return bool + */ + public static function onLoginFormValidErrorMessages( &$messages ) { + $messages[] = 'mwoauth-login-required-reason'; + return true; + } +} diff --git a/OAuth/src/Frontend/UIUtils.php b/OAuth/src/Frontend/UIUtils.php new file mode 100644 index 00000000..58387d14 --- /dev/null +++ b/OAuth/src/Frontend/UIUtils.php @@ -0,0 +1,39 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Frontend; + +use IContextSource; +use Message; +use OOUI\Tag; + +/** + * Static utility class for the special pages + */ +class UIUtils { + /** + * Generate an information table for a consumer. The result will be suitable for use as a + * HTMLForm field with no label. + * @param array $info fieldname-message => description; description will be escaped (use + * HtmlSnippet to avoid); fields with null value will be ignored; messages will be interpreted + * as plaintext + * @param IContextSource $context + * @return string + */ + public static function generateInfoTable( $info, $context ) { + $dl = new Tag( 'dl' ); + $dl->addClasses( [ 'mw-mwoauth-infotable' ] ); + foreach ( $info as $fieldname => $description ) { + if ( $description === null ) { + continue; + } elseif ( $description instanceof Message ) { + $description = $description->plain(); + } + $dt = new Tag( 'dt' ); + $dd = new Tag( 'dd' ); + $dt->appendContent( $context->msg( $fieldname )->text() ); + $dd->appendContent( $description ); + $dl->appendContent( $dt, $dd ); + } + return $dl->toString(); + } +} diff --git a/OAuth/src/Lib/OAuthConsumer.php b/OAuth/src/Lib/OAuthConsumer.php new file mode 100644 index 00000000..a0c348fa --- /dev/null +++ b/OAuth/src/Lib/OAuthConsumer.php @@ -0,0 +1,43 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. +*/ + +namespace MediaWiki\Extensions\OAuth\Lib; + +class OAuthConsumer { + public $key; + public $secret; + public $callback_url; + + function __construct( $key, $secret, $callback_url = NULL ) { + $this->key = $key; + $this->secret = $secret; + $this->callback_url = $callback_url; + } + + function __toString() { + return "OAuthConsumer[key=$this->key,secret=$this->secret]"; + } +} diff --git a/OAuth/src/Lib/OAuthDataStore.php b/OAuth/src/Lib/OAuthDataStore.php new file mode 100644 index 00000000..b34d1b3b --- /dev/null +++ b/OAuth/src/Lib/OAuthDataStore.php @@ -0,0 +1,53 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +class OAuthDataStore { + function lookup_consumer( $consumer_key ) { + // implement me + } + + function lookup_token( $consumer, $token_type, $token ) { + // implement me + } + + function lookup_nonce( $consumer, $token, $nonce, $timestamp ) { + // implement me + } + + function new_request_token( $consumer, $callback = null ) { + // return a new token attached to this consumer + } + + function new_access_token( $token, $consumer, $verifier = null ) { + // return a new access token attached to this consumer + // for the user associated with this token if the request token + // is authorized + // should also invalidate the request token + } + +} diff --git a/OAuth/src/Lib/OAuthException.php b/OAuth/src/Lib/OAuthException.php new file mode 100644 index 00000000..bac7b119 --- /dev/null +++ b/OAuth/src/Lib/OAuthException.php @@ -0,0 +1,34 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +/** + * Generic exception class + */ +class OAuthException extends \Exception { + // pass +} diff --git a/OAuth/src/Lib/OAuthRequest.php b/OAuth/src/Lib/OAuthRequest.php new file mode 100644 index 00000000..5b5050ae --- /dev/null +++ b/OAuth/src/Lib/OAuthRequest.php @@ -0,0 +1,297 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthException; +use MediaWiki\Extensions\OAuth\Lib\OAuthUtil; +use MediaWiki\Logger\LoggerFactory; + +class OAuthRequest { + protected $parameters; + protected $http_method; + protected $http_url; + // for debug purposes + public $base_string; + public static $version = '1.0'; + public static $POST_INPUT = 'php://input'; + + /** @var \\Psr\\Log\\LoggerInterface */ + protected $logger; + + function __construct( $http_method, $http_url, $parameters = null ) { + $parameters = ( $parameters ) ? $parameters : array(); + $parameters = array_merge( + OAuthUtil::parse_parameters( parse_url( $http_url, PHP_URL_QUERY ) ), + $parameters + ); + $this->parameters = $parameters; + $this->http_method = $http_method; + $this->http_url = $http_url; + $this->logger = LoggerFactory::getInstance( 'OAuth' ); + } + + /** + * attempt to build up a request from what was passed to the server + */ + public static function from_request( $http_method = null, $http_url = null, $parameters = null ) { + $scheme = ( !isset( $_SERVER['HTTPS'] ) || $_SERVER['HTTPS'] != "on" ) ? 'http' : 'https'; + $http_url = ( $http_url ) ? $http_url : $scheme . '://' . $_SERVER['SERVER_NAME'] . ':' . $_SERVER['SERVER_PORT'] . $_SERVER['REQUEST_URI']; + $http_method = ( $http_method ) ? $http_method : $_SERVER['REQUEST_METHOD']; + + // We weren't handed any parameters, so let's find the ones relevant to + // this request. + // If you run XML-RPC or similar you should use this to provide your own + // parsed parameter-list + if ( !$parameters ) { + // Find request headers + $request_headers = OAuthUtil::get_headers(); + + // Parse the query-string to find GET parameters + $parameters = OAuthUtil::parse_parameters( $_SERVER['QUERY_STRING'] ); + + // It's a POST request of the proper content-type, so parse POST + // parameters and add those overriding any duplicates from GET + if ( $http_method == "POST" && isset( $request_headers['Content-Type'] ) && strstr( + $request_headers['Content-Type'], + 'application/x-www-form-urlencoded' + ) + ) { + $post_data = OAuthUtil::parse_parameters( file_get_contents( self::$POST_INPUT ) ); + $parameters = array_merge( $parameters, $post_data ); + } + + // We have a Authorization-header with OAuth data. Parse the header + // and add those overriding any duplicates from GET or POST + if ( isset( $request_headers['Authorization'] ) && substr( + $request_headers['Authorization'], + 0, + 6 + ) == 'OAuth ' + ) { + $header_parameters = OAuthUtil::split_header( $request_headers['Authorization'] ); + $parameters = array_merge( $parameters, $header_parameters ); + } + } + + return new OAuthRequest( $http_method, $http_url, $parameters ); + } + + /** + * pretty much a helper function to set up the request + */ + public static function from_consumer_and_token( $consumer, $token, $http_method, $http_url, $parameters = null ) { + $parameters = ( $parameters ) ? $parameters : array(); + $defaults = array( + "oauth_version" => OAuthRequest::$version, + "oauth_nonce" => OAuthRequest::generate_nonce(), + "oauth_timestamp" => OAuthRequest::generate_timestamp(), + "oauth_consumer_key" => $consumer->key + ); + if ( $token ) { + $defaults['oauth_token'] = $token->key; + } + + $parameters = array_merge( $defaults, $parameters ); + + return new OAuthRequest( $http_method, $http_url, $parameters ); + } + + public function set_parameter( $name, $value, $allow_duplicates = true ) { + if ( $allow_duplicates && isset( $this->parameters[$name] ) ) { + // We have already added parameter( s ) with this name, so add to the list + if ( is_scalar( $this->parameters[$name] ) ) { + // This is the first duplicate, so transform scalar ( string ) + // into an array so we can add the duplicates + $this->parameters[$name] = array( $this->parameters[$name] ); + } + + $this->parameters[$name][] = $value; + } else { + $this->parameters[$name] = $value; + } + } + + public function get_parameter( $name ) { + return isset( $this->parameters[$name] ) ? $this->parameters[$name] : null; + } + + public function get_parameters() { + return $this->parameters; + } + + public function unset_parameter( $name ) { + unset( $this->parameters[$name] ); + } + + /** + * The request parameters, sorted and concatenated into a normalized string. + * @return string + */ + public function get_signable_parameters() { + // Grab all parameters + $params = $this->parameters; + + // Remove oauth_signature if present + // Ref: Spec: 9.1.1 ( "The oauth_signature parameter MUST be excluded." ) + if ( isset( $params['oauth_signature'] ) ) { + unset( $params['oauth_signature'] ); + } + + return OAuthUtil::build_http_query( $params ); + } + + /** + * Returns the base string of this request + * + * The base string defined as the method, the url + * and the parameters ( normalized ), each urlencoded + * and the concated with &. + */ + public function get_signature_base_string() { + $parts = array( + $this->get_normalized_http_method(), + $this->get_normalized_http_url(), + $this->get_signable_parameters() + ); + + $parts = OAuthUtil::urlencode_rfc3986( $parts ); + + return implode( '&', $parts ); + } + + /** + * just uppercases the http method + */ + public function get_normalized_http_method() { + return strtoupper( $this->http_method ); + } + + /** + * parses the url and rebuilds it to be + * scheme://host/path + */ + public function get_normalized_http_url() { + $parts = parse_url( $this->http_url ); + + $scheme = ( isset( $parts['scheme'] ) ) ? $parts['scheme'] : 'http'; + $port = ( isset( $parts['port'] ) ) ? $parts['port'] : ( ( $scheme == 'https' ) ? '443' : '80' ); + $host = ( isset( $parts['host'] ) ) ? strtolower( $parts['host'] ) : ''; + $path = ( isset( $parts['path'] ) ) ? $parts['path'] : ''; + + if ( ( $scheme == 'https' && $port != '443' ) || ( $scheme == 'http' && $port != '80' ) ) { + $host = "$host:$port"; + } + + return "$scheme://$host$path"; + } + + /** + * builds a url usable for a GET request + */ + public function to_url() { + $post_data = $this->to_postdata(); + $out = $this->get_normalized_http_url(); + if ( $post_data ) { + $out .= '?' . $post_data; + } + + return $out; + } + + /** + * builds the data one would send in a POST request + */ + public function to_postdata() { + return OAuthUtil::build_http_query( $this->parameters ); + } + + /** + * builds the Authorization: header + */ + public function to_header( $realm = null ) { + $first = true; + if ( $realm ) { + $out = 'Authorization: OAuth realm="' . OAuthUtil::urlencode_rfc3986( $realm ) . '"'; + $first = false; + } else { + $out = 'Authorization: OAuth'; + } + $total = array(); + foreach ( $this->parameters as $k => $v ) { + if ( substr( $k, 0, 5 ) != "oauth" ) { + continue; + } + if ( is_array( $v ) ) { + throw new OAuthException( 'Arrays not supported in headers' ); + } + $out .= ( $first ) ? ' ' : ','; + $out .= OAuthUtil::urlencode_rfc3986( $k ) . '="' . OAuthUtil::urlencode_rfc3986( + $v + ) . '"'; + $first = false; + } + + return $out; + } + + public function __toString() { + return $this->to_url(); + } + + public function sign_request( $signature_method, $consumer, $token ) { + $this->set_parameter( + "oauth_signature_method", + $signature_method->get_name(), + false + ); + $signature = $this->build_signature( $signature_method, $consumer, $token ); + $this->set_parameter( "oauth_signature", $signature, false ); + } + + public function build_signature( $signature_method, $consumer, $token ) { + $signature = $signature_method->build_signature( $this, $consumer, $token ); + + return $signature; + } + + /** + * util function: current timestamp + */ + private static function generate_timestamp() { + return time(); + } + + /** + * util function: current nonce + */ + private static function generate_nonce() { + $mt = microtime(); + $rand = mt_rand(); + + return md5( $mt . $rand ); // md5s look nicer than numbers + } +} diff --git a/OAuth/src/Lib/OAuthServer.php b/OAuth/src/Lib/OAuthServer.php new file mode 100644 index 00000000..49d2aac6 --- /dev/null +++ b/OAuth/src/Lib/OAuthServer.php @@ -0,0 +1,270 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthDataStore; +use MediaWiki\Extensions\OAuth\Lib\OAuthException; +use MediaWiki\Extensions\OAuth\Lib\OAuthRequest; +use MediaWiki\Logger\LoggerFactory; + +class OAuthServer { + protected $timestamp_threshold = 300; // in seconds, five minutes + protected $version = '1.0'; // hi blaine + protected $signature_methods = array(); + + /** @var OAuthDataStore */ + protected $data_store; + + /** @var \\Psr\\Log\\LoggerInterface */ + protected $logger; + + function __construct( $data_store ) { + $this->data_store = $data_store; + $this->logger = LoggerFactory::getInstance( 'OAuth' ); + } + + public function add_signature_method( $signature_method ) { + $this->signature_methods[$signature_method->get_name()] = $signature_method; + } + + // high level functions + + /** + * process a request_token request + * returns the request token on success + */ + public function fetch_request_token( &$request ) { + $this->get_version( $request ); + + $consumer = $this->get_consumer( $request ); + + // no token required for the initial token request + $token = null; + + $this->check_signature( $request, $consumer, $token ); + + // Rev A change + $callback = $request->get_parameter( 'oauth_callback' ); + $new_token = $this->data_store->new_request_token( $consumer, $callback ); + + return $new_token; + } + + /** + * process an access_token request + * returns the access token on success + */ + public function fetch_access_token( &$request ) { + $this->get_version( $request ); + + $consumer = $this->get_consumer( $request ); + + // requires authorized request token + $token = $this->get_token( $request, $consumer, "request" ); + + $this->check_signature( $request, $consumer, $token ); + + // Rev A change + $verifier = $request->get_parameter( 'oauth_verifier' ); + $new_token = $this->data_store->new_access_token( $token, $consumer, $verifier ); + + return $new_token; + } + + /** + * verify an api call, checks all the parameters + */ + public function verify_request( &$request ) { + $this->get_version( $request ); + $consumer = $this->get_consumer( $request ); + $token = $this->get_token( $request, $consumer, "access" ); + $this->check_signature( $request, $consumer, $token ); + + return array( + $consumer, + $token + ); + } + + // Internals from here + + /** + * version 1 + */ + protected function get_version( &$request ) { + $version = $request->get_parameter( "oauth_version" ); + if ( !$version ) { + // Service Providers MUST assume the protocol version to be 1.0 if this parameter is not present. + // Chapter 7.0 ( "Accessing Protected Ressources" ) + $version = '1.0'; + } + if ( $version !== $this->version ) { + throw new OAuthException( "OAuth version '$version' not supported" ); + } + + return $version; + } + + /** + * figure out the signature with some defaults + */ + private function get_signature_method( $request ) { + $signature_method = $request instanceof OAuthRequest ? $request->get_parameter( + "oauth_signature_method" + ) : null; + + if ( !$signature_method ) { + // According to chapter 7 ( "Accessing Protected Ressources" ) the signature-method + // parameter is required, and we can't just fallback to PLAINTEXT + throw new OAuthException( 'No signature method parameter. This parameter is required' ); + } + + if ( !in_array( $signature_method, array_keys( $this->signature_methods ) ) ) { + throw new OAuthException( + "Signature method '$signature_method' not supported " . "try one of the following: " . implode( + ", ", + array_keys( $this->signature_methods ) + ) + ); + } + + return $this->signature_methods[$signature_method]; + } + + /** + * try to find the consumer for the provided request's consumer key + */ + protected function get_consumer( $request ) { + $consumer_key = $request instanceof OAuthRequest ? $request->get_parameter( + "oauth_consumer_key" + ) : null; + + if ( !$consumer_key ) { + throw new OAuthException( "Invalid consumer key" ); + } + $this->logger->debug( __METHOD__ . ": getting consumer for '$consumer_key'" ); + $consumer = $this->data_store->lookup_consumer( $consumer_key ); + if ( !$consumer ) { + throw new OAuthException( "Invalid consumer" ); + } + + return $consumer; + } + + /** + * try to find the token for the provided request's token key + */ + protected function get_token( $request, $consumer, $token_type = "access" ) { + $token_field = $request instanceof OAuthRequest ? $request->get_parameter( + 'oauth_token' + ) : null; + + $token = $this->data_store->lookup_token( + $consumer, + $token_type, + $token_field + ); + if ( !$token ) { + throw new OAuthException( "Invalid $token_type token: $token_field" ); + } + + return $token; + } + + /** + * all-in-one function to check the signature on a request + * should guess the signature method appropriately + */ + protected function check_signature( $request, $consumer, $token ) { + // this should probably be in a different method + $timestamp = $request instanceof OAuthRequest ? $request->get_parameter( + 'oauth_timestamp' + ) : null; + $nonce = $request instanceof OAuthRequest ? $request->get_parameter( 'oauth_nonce' ) : null; + + $this->check_timestamp( $timestamp ); + $this->check_nonce( $consumer, $token, $nonce, $timestamp ); + + $signature_method = $this->get_signature_method( $request ); + $signature = $request->get_parameter( 'oauth_signature' ); + $valid_sig = $signature_method->check_signature( + $request, + $consumer, + $token, + $signature + ); + + if ( !$valid_sig ) { + $this->logger->info( + __METHOD__ . ': Signature check (' . get_class( $signature_method ) . ') failed' + ); + throw new OAuthException( "Invalid signature" ); + } + } + + /** + * check that the timestamp is new enough + */ + private function check_timestamp( $timestamp ) { + if ( !$timestamp ) { + throw new OAuthException( + 'Missing timestamp parameter. The parameter is required' + ); + } + + // verify that timestamp is recentish + $now = time(); + if ( abs( $now - $timestamp ) > $this->timestamp_threshold ) { + throw new OAuthException( + "Expired timestamp, yours $timestamp, ours $now" + ); + } + } + + /** + * check that the nonce is not repeated + */ + private function check_nonce( $consumer, $token, $nonce, $timestamp ) { + if ( !$nonce ) { + throw new OAuthException( + 'Missing nonce parameter. The parameter is required' + ); + } + + // verify that the nonce is uniqueish + $found = $this->data_store->lookup_nonce( + $consumer, + $token, + $nonce, + $timestamp + ); + if ( $found ) { + throw new OAuthException( "Nonce already used: $nonce" ); + } + } + +} diff --git a/OAuth/src/Lib/OAuthSignatureMethod.php b/OAuth/src/Lib/OAuthSignatureMethod.php new file mode 100644 index 00000000..e417eff3 --- /dev/null +++ b/OAuth/src/Lib/OAuthSignatureMethod.php @@ -0,0 +1,94 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthConsumer; +use MediaWiki\Extensions\OAuth\Lib\OAuthRequest; +use MediaWiki\Extensions\OAuth\Lib\OAuthToken; +use MediaWiki\Logger\LoggerFactory; + +/** + * A class for implementing a Signature Method + * See section 9 ( "Signing Requests" ) in the spec + */ +abstract class OAuthSignatureMethod { + + /** @var \\Psr\\Log\\LoggerInterface */ + protected $logger; + + public function __construct() { + $this->logger = LoggerFactory::getInstance( 'OAuth' ); + } + + /** + * Needs to return the name of the Signature Method ( ie HMAC-SHA1 ) + * @return string + */ + abstract public function get_name(); + + /** + * Build up the signature + * NOTE: The output of this function MUST NOT be urlencoded. + * the encoding is handled in OAuthRequest when the final + * request is serialized + * @param OAuthRequest $request + * @param OAuthConsumer $consumer + * @param OAuthToken $token + * @return string + */ + abstract public function build_signature( $request, $consumer, $token ); + + /** + * Verifies that a given signature is correct + * @param OAuthRequest $request + * @param OAuthConsumer $consumer + * @param OAuthToken $token + * @param string $signature + * @return bool + */ + public function check_signature( $request, $consumer, $token, $signature ) { + $this->logger->debug( __METHOD__ . ": Expecting: '$signature'" ); + $built = $this->build_signature( $request, $consumer, $token ); + $this->logger->debug( __METHOD__ . ": Built: '$built'" ); + // Check for zero length, although unlikely here + if ( strlen( $built ) == 0 || strlen( $signature ) == 0 ) { + return false; + } + + if ( strlen( $built ) != strlen( $signature ) ) { + return false; + } + + // Avoid a timing leak with a ( hopefully ) time insensitive compare + $result = 0; + for ( $i = 0; $i < strlen( $signature ); $i++ ) { + $result |= ord( $built[$i] ) ^ ord( $signature[$i] ); + } + + return $result == 0; + } +} diff --git a/OAuth/src/Lib/OAuthSignatureMethod_HMAC_SHA1.php b/OAuth/src/Lib/OAuthSignatureMethod_HMAC_SHA1.php new file mode 100644 index 00000000..b4d00dae --- /dev/null +++ b/OAuth/src/Lib/OAuthSignatureMethod_HMAC_SHA1.php @@ -0,0 +1,60 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthSignatureMethod; +use MediaWiki\Extensions\OAuth\Lib\OAuthUtil; + +/** + * The HMAC-SHA1 signature method uses the HMAC-SHA1 signature algorithm as defined in [RFC2104] + * where the Signature Base String is the text and the key is the concatenated values ( each first + * encoded per Parameter Encoding ) of the Consumer Secret and Token Secret, separated by an '&' + * character ( ASCII code 38 ) even if empty. + * - Chapter 9.2 ( "HMAC-SHA1" ) + */ +class OAuthSignatureMethod_HMAC_SHA1 extends OAuthSignatureMethod { + function get_name() { + return "HMAC-SHA1"; + } + + public function build_signature( $request, $consumer, $token ) { + $base_string = $request->get_signature_base_string(); + $this->logger->debug( __METHOD__ . ": Base string: '$base_string'" ); + $request->base_string = $base_string; + + $key_parts = array( + $consumer->secret, + ( $token ) ? $token->secret : "" + ); + + $key_parts = OAuthUtil::urlencode_rfc3986( $key_parts ); + $key = implode( '&', $key_parts ); + $this->logger->debug( __METHOD__ . ": HMAC Key: '$key'" ); + + return base64_encode( hash_hmac( 'sha1', $base_string, $key, true ) ); + } +} diff --git a/OAuth/src/Lib/OAuthSignatureMethod_PLAINTEXT.php b/OAuth/src/Lib/OAuthSignatureMethod_PLAINTEXT.php new file mode 100644 index 00000000..5e888439 --- /dev/null +++ b/OAuth/src/Lib/OAuthSignatureMethod_PLAINTEXT.php @@ -0,0 +1,63 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthSignatureMethod; +use MediaWiki\Extensions\OAuth\Lib\OAuthUtil; + +/** + * The PLAINTEXT method does not provide any security protection and SHOULD only be used + * over a secure channel such as HTTPS. It does not use the Signature Base String. + * - Chapter 9.4 ( "PLAINTEXT" ) + */ +class OAuthSignatureMethod_PLAINTEXT extends OAuthSignatureMethod { + public function get_name() { + return "PLAINTEXT"; + } + + /** + * oauth_signature is set to the concatenated encoded values of the Consumer Secret and + * Token Secret, separated by a '&' character ( ASCII code 38 ), even if either secret is + * empty. The result MUST be encoded again. + * - Chapter 9.4.1 ( "Generating Signatures" ) + * + * Please note that the second encoding MUST NOT happen in the SignatureMethod, as + * OAuthRequest handles this! + */ + public function build_signature( $request, $consumer, $token ) { + $key_parts = array( + $consumer->secret, + ( $token ) ? $token->secret : "" + ); + + $key_parts = OAuthUtil::urlencode_rfc3986( $key_parts ); + $key = implode( '&', $key_parts ); + $request->base_string = $key; + + return $key; + } +} diff --git a/OAuth/src/Lib/OAuthSignatureMethod_RSA_SHA1.php b/OAuth/src/Lib/OAuthSignatureMethod_RSA_SHA1.php new file mode 100644 index 00000000..3b442108 --- /dev/null +++ b/OAuth/src/Lib/OAuthSignatureMethod_RSA_SHA1.php @@ -0,0 +1,96 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthSignatureMethod; + +/** + * The RSA-SHA1 signature method uses the RSASSA-PKCS1-v1_5 signature algorithm as defined in + * [RFC3447] section 8.2 ( more simply known as PKCS#1 ), using SHA-1 as the hash function for + * EMSA-PKCS1-v1_5. It is assumed that the Consumer has provided its RSA public key in a + * verified way to the Service Provider, in a manner which is beyond the scope of this + * specification. + * - Chapter 9.3 ( "RSA-SHA1" ) + */ +abstract class OAuthSignatureMethod_RSA_SHA1 extends OAuthSignatureMethod { + public function get_name() { + return "RSA-SHA1"; + } + + // Up to the SP to implement this lookup of keys. Possible ideas are: + // ( 1 ) do a lookup in a table of trusted certs keyed off of consumer + // ( 2 ) fetch via http using a url provided by the requester + // ( 3 ) some sort of specific discovery code based on request + // + // Either way should return a string representation of the certificate + protected abstract function fetch_public_cert( &$request ); + + // Up to the SP to implement this lookup of keys. Possible ideas are: + // ( 1 ) do a lookup in a table of trusted certs keyed off of consumer + // + // Either way should return a string representation of the certificate + protected abstract function fetch_private_cert( &$request ); + + public function build_signature( $request, $consumer, $token ) { + $base_string = $request->get_signature_base_string(); + $request->base_string = $base_string; + + // Fetch the private key cert based on the request + $cert = $this->fetch_private_cert( $request ); + + // Pull the private key ID from the certificate + $privatekeyid = openssl_get_privatekey( $cert ); + + // Sign using the key + $ok = openssl_sign( $base_string, $signature, $privatekeyid ); + + // Release the key resource + openssl_free_key( $privatekeyid ); + + return base64_encode( $signature ); + } + + public function check_signature( $request, $consumer, $token, $signature ) { + $decoded_sig = base64_decode( $signature ); + + $base_string = $request->get_signature_base_string(); + + // Fetch the public key cert based on the request + $cert = $this->fetch_public_cert( $request ); + + // Pull the public key ID from the certificate + $publickeyid = openssl_get_publickey( $cert ); + + // Check the computed signature against the one passed in the query + $ok = openssl_verify( $base_string, $decoded_sig, $publickeyid ); + + // Release the key resource + openssl_free_key( $publickeyid ); + + return $ok == 1; + } +} diff --git a/OAuth/src/Lib/OAuthToken.php b/OAuth/src/Lib/OAuthToken.php new file mode 100644 index 00000000..669393aa --- /dev/null +++ b/OAuth/src/Lib/OAuthToken.php @@ -0,0 +1,58 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Extensions\OAuth\Lib\OAuthUtil; + +class OAuthToken { + // access tokens and request tokens + public $key; + public $secret; + + /** + * key = the token + * secret = the token secret + */ + function __construct( $key, $secret ) { + $this->key = $key; + $this->secret = $secret; + } + + /** + * generates the basic string serialization of a token that a server + * would respond to request_token and access_token calls with + */ + function to_string() { + return "oauth_token=" . OAuthUtil::urlencode_rfc3986( + $this->key + ) . "&oauth_token_secret=" . OAuthUtil::urlencode_rfc3986( $this->secret ); + } + + function __toString() { + return $this->to_string(); + } +} diff --git a/OAuth/src/Lib/OAuthUtil.php b/OAuth/src/Lib/OAuthUtil.php new file mode 100644 index 00000000..8dca8c06 --- /dev/null +++ b/OAuth/src/Lib/OAuthUtil.php @@ -0,0 +1,209 @@ +<?php +// vim: foldmethod=marker +/** + * The MIT License + * + * Copyright (c) 2007 Andy Smith + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files ( the "Software" ), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +namespace MediaWiki\Extensions\OAuth\Lib; + +use MediaWiki\Logger\LoggerFactory; + +class OAuthUtil { + public static function urlencode_rfc3986( $input ) { + if ( is_array( $input ) ) { + return array_map( + array( + self::class, + 'urlencode_rfc3986' + ), + $input + ); + } else { + if ( is_scalar( $input ) ) { + return str_replace( + '+', + ' ', + str_replace( '%7E', '~', rawurlencode( $input ) ) + ); + } else { + return ''; + } + } + } + + + // This decode function isn't taking into consideration the above + // modifications to the encoding process. However, this method doesn't + // seem to be used anywhere so leaving it as is. + public static function urldecode_rfc3986( $string ) { + return urldecode( $string ); + } + + // Utility function for turning the Authorization: header into + // parameters, has to do some unescaping + // Can filter out any non-oauth parameters if needed ( default behaviour ) + // May 28th, 2010 - method updated to tjerk.meesters for a speed improvement. + // see http://code.google.com/p/oauth/issues/detail?id = 163 + public static function split_header( $header, $only_allow_oauth_parameters = true ) { + $logger = LoggerFactory::getInstance( 'OAuth' ); + $logger->debug( __METHOD__ . ": pulling headers from '$header'" ); + $params = array(); + if ( preg_match_all( + '/(' . ( $only_allow_oauth_parameters ? 'oauth_' : '' ) . '[a-z_-]*)=(:?"([^"]*)"|([^,]*))/', + $header, + $matches + ) + ) { + foreach ( $matches[1] as $i => $h ) { + $params[$h] = OAuthUtil::urldecode_rfc3986( + empty( $matches[3][$i] ) ? $matches[4][$i] : $matches[3][$i] + ); + } + if ( isset( $params['realm'] ) ) { + unset( $params['realm'] ); + } + } + + return $params; + } + + // helper to try to sort out headers for people who aren't running apache + public static function get_headers() { + if ( function_exists( 'apache_request_headers' ) ) { + // we need this to get the actual Authorization: header + // because apache tends to tell us it doesn't exist + $headers = apache_request_headers(); + + // sanitize the output of apache_request_headers because + // we always want the keys to be Cased-Like-This and arh() + // returns the headers in the same case as they are in the + // request + $out = array(); + foreach ( $headers as $key => $value ) { + $key = str_replace( + " ", + "-", + ucwords( strtolower( str_replace( "-", " ", $key ) ) ) + ); + $out[$key] = $value; + } + } else { + // otherwise we don't have apache and are just going to have to hope + // that $_SERVER actually contains what we need + $out = array(); + if ( isset( $_SERVER['CONTENT_TYPE'] ) ) { + $out['Content-Type'] = $_SERVER['CONTENT_TYPE']; + } + if ( isset( $_ENV['CONTENT_TYPE'] ) ) { + $out['Content-Type'] = $_ENV['CONTENT_TYPE']; + } + + foreach ( $_SERVER as $key => $value ) { + if ( substr( $key, 0, 5 ) == "HTTP_" ) { + // this is chaos, basically it is just there to capitalize the first + // letter of every word that is not an initial HTTP and strip HTTP + // code from przemek + $key = str_replace( + " ", + "-", + ucwords( strtolower( str_replace( "_", " ", substr( $key, 5 ) ) ) ) + ); + $out[$key] = $value; + } + } + } + + return $out; + } + + // This function takes a input like a=b&a=c&d=e and returns the parsed + // parameters like this + // array( 'a' => array( 'b','c' ), 'd' => 'e' ) + public static function parse_parameters( $input ) { + if ( !isset( $input ) || !$input ) { + return array(); + } + + $pairs = explode( '&', $input ); + + $parsed_parameters = array(); + foreach ( $pairs as $pair ) { + $split = explode( '=', $pair, 2 ); + $parameter = OAuthUtil::urldecode_rfc3986( $split[0] ); + $value = isset( $split[1] ) ? OAuthUtil::urldecode_rfc3986( $split[1] ) : ''; + + if ( isset( $parsed_parameters[$parameter] ) ) { + // We have already recieved parameter( s ) with this name, so add to the list + // of parameters with this name + + if ( is_scalar( $parsed_parameters[$parameter] ) ) { + // This is the first duplicate, so transform scalar ( string ) into an array + // so we can add the duplicates + $parsed_parameters[$parameter] = array( $parsed_parameters[$parameter] ); + } + + $parsed_parameters[$parameter][] = $value; + } else { + $parsed_parameters[$parameter] = $value; + } + } + + return $parsed_parameters; + } + + public static function build_http_query( $params ) { + LoggerFactory::getInstance( 'OAuth' )->debug( + __METHOD__ . " called with params:\n" . print_r( $params, true ) + ); + if ( !$params ) { + return ''; + } + + // Urlencode both keys and values + $keys = OAuthUtil::urlencode_rfc3986( array_keys( $params ) ); + $values = OAuthUtil::urlencode_rfc3986( array_values( $params ) ); + $params = array_combine( $keys, $values ); + + // Parameters are sorted by name, using lexicographical byte value ordering. + // Ref: Spec: 9.1.1 ( 1 ) + uksort( $params, 'strcmp' ); + + $pairs = array(); + foreach ( $params as $parameter => $value ) { + if ( is_array( $value ) ) { + // If two or more parameters share the same name, they are sorted by their value + // Ref: Spec: 9.1.1 ( 1 ) + // June 12th, 2010 - changed to sort because of issue 164 by hidetaka + sort( $value, SORT_STRING ); + foreach ( $value as $duplicate_value ) { + $pairs[] = $parameter . '=' . $duplicate_value; + } + } else { + $pairs[] = $parameter . '=' . $value; + } + } + // For each parameter, the name is separated from the corresponding value by an ' = ' character ( ASCII code 61 ) + // Each name-value pair is separated by an '&' character ( ASCII code 38 ) + return implode( '&', $pairs ); + } +} diff --git a/OAuth/src/Repository/AccessTokenRepository.php b/OAuth/src/Repository/AccessTokenRepository.php new file mode 100644 index 00000000..28a7fcc6 --- /dev/null +++ b/OAuth/src/Repository/AccessTokenRepository.php @@ -0,0 +1,147 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use League\OAuth2\Server\Entities\AccessTokenEntityInterface; +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Entity\AccessTokenEntity; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; + +class AccessTokenRepository extends DatabaseRepository implements AccessTokenRepositoryInterface { + const FIELD_EXPIRES = 'oaat_expires'; + const FIELD_ACCEPTANCE_ID = 'oaat_acceptance_id'; + const FIELD_REVOKED = 'oaat_revoked'; + + /** + * Create a new access token + * + * @param ClientEntityInterface|ClientEntity $clientEntity + * @param ScopeEntityInterface[] $scopes + * @param mixed|null $userIdentifier + * @throws MWOAuthException + * @return AccessTokenEntityInterface + */ + public function getNewToken( ClientEntityInterface $clientEntity, + array $scopes, $userIdentifier = null ) { + return new AccessTokenEntity( $clientEntity, $scopes, $userIdentifier ); + } + + /** + * Persists a new access token to permanent storage. + * + * @param AccessTokenEntityInterface|AccessTokenEntity $accessTokenEntity + * + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewAccessToken( AccessTokenEntityInterface $accessTokenEntity ) { + if ( $this->identifierExists( $accessTokenEntity->getIdentifier() ) ) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + + $data = $this->getDbDataFromTokenEntity( $accessTokenEntity ); + + $this->getDB( DB_MASTER )->insert( + $this->getTableName(), + $data, + __METHOD__ + ); + } + + /** + * Revoke an access token. + * + * @param string $tokenId + */ + public function revokeAccessToken( $tokenId ) { + if ( $this->identifierExists( $tokenId ) ) { + $this->getDB( DB_MASTER )->update( + $this->getTableName(), + [ static::FIELD_REVOKED => 1 ], + [ $this->getIdentifierField() => $tokenId ], + __METHOD__ + ); + } + } + + /** + * Check if the access token has been revoked. + * + * @param string $tokenId + * + * @return bool Return true if this token has been revoked + */ + public function isAccessTokenRevoked( $tokenId ) { + $row = $this->getDB()->selectRow( + $this->getTableName(), + [ static::FIELD_REVOKED ], + [ $this->getIdentifierField() => $tokenId ], + __METHOD__ + ); + if ( !$row ) { + return true; + } + return (bool)$row->{static::FIELD_REVOKED}; + } + + /** + * Delete all access tokens issued with provided approval + * + * @param int $approvalId + */ + public function deleteForApprovalId( $approvalId ) { + $this->getDB( DB_MASTER )->delete( + $this->getTableName(), + [ + static::FIELD_ACCEPTANCE_ID => $approvalId + ], + __METHOD__ + ); + } + + /** + * Get ID of the approval bound to this AT + * + * @param string $tokenId + * @return bool|int + */ + public function getApprovalId( $tokenId ) { + $row = $this->getDB()->selectRow( + $this->getTableName(), + [ static::FIELD_ACCEPTANCE_ID ], + [ $this->getIdentifierField() => $tokenId ], + __METHOD__ + ); + + if ( $row ) { + return (int)$row->{static::FIELD_ACCEPTANCE_ID}; + } + + return false; + } + + private function getDbDataFromTokenEntity( AccessTokenEntity $accessTokenEntity ) { + $expiry = $accessTokenEntity->getExpiryDateTime()->getTimestamp(); + if ( $expiry > 9223371197536780800 ) { + $expiry = 'infinity'; + } + return [ + $this->getIdentifierField() => $accessTokenEntity->getIdentifier(), + static::FIELD_EXPIRES => $this->getDB()->encodeExpiry( $expiry ), + static::FIELD_ACCEPTANCE_ID => $accessTokenEntity->getApproval() ? + $accessTokenEntity->getApproval()->getId() : + 0 + ]; + } + + protected function getTableName(): string { + return 'oauth2_access_tokens'; + } + + protected function getIdentifierField(): string { + return 'oaat_identifier'; + } +} diff --git a/OAuth/src/Repository/AuthCodeRepository.php b/OAuth/src/Repository/AuthCodeRepository.php new file mode 100644 index 00000000..beb620b5 --- /dev/null +++ b/OAuth/src/Repository/AuthCodeRepository.php @@ -0,0 +1,75 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use InvalidArgumentException; +use League\OAuth2\Server\Entities\AuthCodeEntityInterface; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface; +use MediaWiki\Extensions\OAuth\Entity\AuthCodeEntity; + +class AuthCodeRepository extends CacheRepository implements AuthCodeRepositoryInterface { + + /** + * Creates a new AuthCode + * + * @return AuthCodeEntityInterface + */ + public function getNewAuthCode() { + return new AuthCodeEntity(); + } + + /** + * Persists a new auth code to permanent storage. + * + * @param AuthCodeEntityInterface $authCodeEntity + * + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewAuthCode( AuthCodeEntityInterface $authCodeEntity ) { + if ( !$authCodeEntity instanceof AuthCodeEntity ) { + throw new InvalidArgumentException( + '$authCodeEntity must be instance of ' . + AuthCodeEntity::class . ', got ' . get_class( $authCodeEntity ) . ' instead' + ); + } + if ( $this->has( $authCodeEntity->getIdentifier() ) ) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + + $this->set( + $authCodeEntity->getIdentifier(), + $authCodeEntity->jsonSerialize(), + $authCodeEntity->getExpiryDateTime()->getTimestamp() + ); + } + + /** + * Revoke an auth code. + * + * @param string $codeId + */ + public function revokeAuthCode( $codeId ) { + $this->delete( $codeId ); + } + + /** + * Check if the auth code has been revoked. + * + * @param string $codeId + * + * @return bool Return true if this code has been revoked + */ + public function isAuthCodeRevoked( $codeId ) { + return $this->has( $codeId ) === false; + } + + /** + * Get object type for session key + * + * @return string + */ + protected function getCacheKeyType(): string { + return 'AuthCode'; + } +} diff --git a/OAuth/src/Repository/CacheRepository.php b/OAuth/src/Repository/CacheRepository.php new file mode 100644 index 00000000..825b38f7 --- /dev/null +++ b/OAuth/src/Repository/CacheRepository.php @@ -0,0 +1,85 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use BagOStuff; +use MediaWiki\Extensions\OAuth\Backend\Utils; + +abstract class CacheRepository { + + /** + * @var BagOStuff + */ + protected $cache; + + /** + * @return static + */ + public static function factory() { + $cache = Utils::getSessionCache(); + + // @phan-suppress-next-line PhanTypeInstantiateAbstractStatic + return new static( $cache ); + } + + /** + * @param BagOStuff $cache + */ + protected function __construct( BagOStuff $cache ) { + $this->cache = $cache; + } + + /** + * Get object type for session key + * + * @return string + */ + abstract protected function getCacheKeyType() : string; + + /** + * Get the cache key based on unique identifier + * + * @param string $id + * @return string + */ + protected function getCacheKey( $id ) { + return Utils::getCacheKey( $this->getCacheKeyType(), $id ); + } + + /** + * @param string $identifier + * @param int $flags + * @return mixed + */ + protected function get( $identifier, $flags = 0 ) { + return $this->cache->get( $this->getCacheKey( $identifier ), $flags ); + } + + /** + * @param string $identifier + * @param mixed $value + * @param int $expires + * @param int $flags + */ + protected function set( $identifier, $value, $expires = 0, $flags = 0 ) { + $this->cache->add( $this->getCacheKey( $identifier ), $value, $expires, $flags ); + } + + /** + * @param string $identifier + * @param int $flags + */ + protected function delete( $identifier, $flags = 0 ) { + $this->cache->delete( $this->getCacheKey( $identifier ), $flags ); + } + + /** + * Convenience method to determine if given key exists in cache + * + * @param string $identifier + * @return bool + */ + protected function has( $identifier ) { + return $this->cache->get( $this->getCacheKey( $identifier ) ) !== false; + } +} diff --git a/OAuth/src/Repository/ClientRepository.php b/OAuth/src/Repository/ClientRepository.php new file mode 100644 index 00000000..97b3efef --- /dev/null +++ b/OAuth/src/Repository/ClientRepository.php @@ -0,0 +1,64 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use InvalidArgumentException; +use League\OAuth2\Server\Repositories\ClientRepositoryInterface; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; + +class ClientRepository implements ClientRepositoryInterface { + + /** + * Get a client. + * + * @param string $clientIdentifier The client's identifier + * + * @return ClientEntity|bool + */ + public function getClientEntity( $clientIdentifier ) { + $client = ClientEntity::newFromKey( + Utils::getCentralDB( DB_REPLICA ), + $clientIdentifier + ); + if ( !$client instanceof ClientEntity ) { + return false; + } + + return $client; + } + + /** + * @param int $clientId + * @return ClientEntity|bool + */ + public function getClientEntityByDBId( $clientId ) { + $client = ClientEntity::newFromId( Utils::getCentralDB( DB_REPLICA ), $clientId ); + if ( !$client instanceof ClientEntity ) { + return false; + } + + return $client; + } + + /** + * Validate a client's secret. + * + * @param string $clientIdentifier The client's identifier + * @param null|string $clientSecret The client's secret (if sent) + * @param null|string $grantType The type of grant the client is using (if sent) + * + * @return bool + * @throws InvalidArgumentException + */ + public function validateClient( $clientIdentifier, $clientSecret, $grantType ) { + $client = $this->getClientEntity( $clientIdentifier ); + if ( !$client || !$client instanceof ClientEntity ) { + throw new InvalidArgumentException( + "Client with identifier $clientIdentifier does not exist!" + ); + } + + return $client->validate( $clientSecret, $grantType ); + } +} diff --git a/OAuth/src/Repository/DatabaseRepository.php b/OAuth/src/Repository/DatabaseRepository.php new file mode 100644 index 00000000..328008a1 --- /dev/null +++ b/OAuth/src/Repository/DatabaseRepository.php @@ -0,0 +1,36 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use MediaWiki\Extensions\OAuth\Backend\Utils; +use Wikimedia\Rdbms\DBConnRef; + +abstract class DatabaseRepository { + + /** + * @param int $index + * @return DBConnRef + */ + public function getDB( $index = DB_REPLICA ) { + return Utils::getCentralDB( $index ); + } + + /** + * Is given identifier stored in the DB + * + * @param string $identifier + * @return bool + */ + public function identifierExists( $identifier ) { + return $this->getDB()->selectRow( + $this->getTableName(), + [ $this->getIdentifierField() ], + [ $this->getIdentifierField() => $identifier ], + __METHOD__ + ) !== false; + } + + abstract protected function getTableName() : string; + + abstract protected function getIdentifierField() : string; +} diff --git a/OAuth/src/Repository/RefreshTokenRepository.php b/OAuth/src/Repository/RefreshTokenRepository.php new file mode 100644 index 00000000..b6a9a350 --- /dev/null +++ b/OAuth/src/Repository/RefreshTokenRepository.php @@ -0,0 +1,75 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use InvalidArgumentException; +use League\OAuth2\Server\Entities\RefreshTokenEntityInterface; +use League\OAuth2\Server\Exception\UniqueTokenIdentifierConstraintViolationException; +use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface; +use MediaWiki\Extensions\OAuth\Entity\RefreshTokenEntity; + +class RefreshTokenRepository extends CacheRepository implements RefreshTokenRepositoryInterface { + + /** + * Creates a new refresh token + * + * @return RefreshTokenEntityInterface|null + */ + public function getNewRefreshToken() { + return new RefreshTokenEntity(); + } + + /** + * Create a new refresh token_name. + * + * @param RefreshTokenEntityInterface $refreshTokenEntity + * + * @throws UniqueTokenIdentifierConstraintViolationException + */ + public function persistNewRefreshToken( RefreshTokenEntityInterface $refreshTokenEntity ) { + if ( !$refreshTokenEntity instanceof RefreshTokenEntity ) { + throw new InvalidArgumentException( + '$refreshTokenEntity must be instance of ' . + RefreshTokenEntity::class . ', got ' . get_class( $refreshTokenEntity ) . ' instead' + ); + } + if ( $this->has( $refreshTokenEntity->getIdentifier() ) ) { + throw UniqueTokenIdentifierConstraintViolationException::create(); + } + + $this->set( + $refreshTokenEntity->getIdentifier(), + $refreshTokenEntity->jsonSerialize(), + $refreshTokenEntity->getExpiryDateTime()->getTimestamp() + ); + } + + /** + * Revoke the refresh token. + * + * @param string $tokenId + */ + public function revokeRefreshToken( $tokenId ) { + $this->delete( $tokenId ); + } + + /** + * Check if the refresh token has been revoked. + * + * @param string $tokenId + * + * @return bool Return true if this token has been revoked + */ + public function isRefreshTokenRevoked( $tokenId ) { + return $this->has( $tokenId ) === false; + } + + /** + * Get object type for session key + * + * @return string + */ + protected function getCacheKeyType(): string { + return "RefreshToken"; + } +} diff --git a/OAuth/src/Repository/ScopeRepository.php b/OAuth/src/Repository/ScopeRepository.php new file mode 100644 index 00000000..5c6a5979 --- /dev/null +++ b/OAuth/src/Repository/ScopeRepository.php @@ -0,0 +1,116 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Repository; + +use League\OAuth2\Server\Entities\ClientEntityInterface; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Repositories\ScopeRepositoryInterface; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; +use MediaWiki\Extensions\OAuth\Entity\ScopeEntity; +use MediaWiki\Extensions\OAuth\Entity\UserEntity; +use MWGrants; + +class ScopeRepository implements ScopeRepositoryInterface { + /** + * @var array + */ + protected $allowedScopes = [ + '#default', + 'mwoauth-authonly', + 'mwoauth-authonlyprivate' + ]; + + public function __construct() { + $this->allowedScopes = array_merge( $this->allowedScopes, MWGrants::getValidGrants() ); + } + + /** + * Return information about a scope. + * + * @param string $identifier The scope identifier + * + * @return ScopeEntityInterface|null + */ + public function getScopeEntityByIdentifier( $identifier ) { + if ( in_array( $identifier, $this->allowedScopes, true ) ) { + return new ScopeEntity( $identifier ); + } + + return null; + } + + /** + * Given a client, grant type and optional user identifier + * validate the set of scopes requested are valid and optionally + * append additional scopes or remove requested scopes. + * + * @param ScopeEntityInterface[] $scopes + * @param string $grantType + * @param ClientEntityInterface|ClientEntity $clientEntity + * @param null|string $userIdentifier + * + * @return ScopeEntityInterface[] + */ + public function finalizeScopes( array $scopes, $grantType, + ClientEntityInterface $clientEntity, $userIdentifier = null ) { + $scopes = $this->replaceDefaultScope( $scopes, $clientEntity ); + + if ( $grantType !== 'authorization_code' ) { + // For grants that do not require approval, + // just filter out the scopes that are not allowed for the client + return array_filter( + $scopes, + function ( ScopeEntityInterface $scope ) use ( $clientEntity ) { + return in_array( $scope->getIdentifier(), $clientEntity->getGrants(), true ); + } + ); + } + if ( !is_numeric( $userIdentifier ) ) { + return []; + } + + $mwUser = Utils::getLocalUserFromCentralId( $userIdentifier ); + $userEntity = UserEntity::newFromMWUser( $mwUser ); + if ( $userEntity === null ) { + return []; + } + + // Filter out not approved scopes + try { + $approval = $clientEntity->getCurrentAuthorization( $mwUser, wfWikiID() ); + $approvedScopeIds = $approval->getGrants(); + } catch ( MWOAuthException $ex ) { + $approvedScopeIds = []; + } + + return array_filter( + $scopes, + function ( ScopeEntityInterface $scope ) use ( $approvedScopeIds ) { + return in_array( $scope->getIdentifier(), $approvedScopeIds, true ); + } + ); + } + + /** + * Detect "#default" scope and replace it with all client's allowed scopes + * + * @param array $scopes + * @param ClientEntityInterface|ClientEntity $client + * @return array + */ + private function replaceDefaultScope( array $scopes, ClientEntityInterface $client ) { + // Normally, #default scope would be an only scope set, but go through whole array in case + // someone explicitly made a request with that scope set + $index = array_search( '#default', array_map( function ( ScopeEntityInterface $scope ) { + return $scope->getIdentifier(); + }, $scopes ) ); + + if ( $index === false ) { + return $scopes; + } + + return $client->getScopes(); + } +} diff --git a/OAuth/src/ResourceServer.php b/OAuth/src/ResourceServer.php new file mode 100644 index 00000000..c5f715e6 --- /dev/null +++ b/OAuth/src/ResourceServer.php @@ -0,0 +1,232 @@ +<?php + +namespace MediaWiki\Extensions\OAuth; + +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Middleware\ResourceServerMiddleware; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; +use MediaWiki\Extensions\OAuth\Entity\ScopeEntity; +use MediaWiki\Extensions\OAuth\Repository\AccessTokenRepository; +use MediaWiki\Extensions\OAuth\Repository\ScopeRepository; +use MediaWiki\MediaWikiServices; +use MediaWiki\Rest\HttpException; +use MWException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use User; +use WebRequest; + +class ResourceServer { + /** @var ResourceServerMiddleware */ + protected $middleware; + /** @var User */ + protected $user; + /** @var ClientEntity */ + protected $client; + /** @var ScopeEntity[] */ + protected $scopes; + /** @var string */ + protected $accessTokenId; + /** @var bool */ + protected $verified = false; + + public static function factory() { + $config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'mwoauth' ); + return new static( $config->get( 'OAuth2PublicKey' ) ); + } + + /** + * @param string $publicKey + */ + protected function __construct( $publicKey ) { + $accessTokenRepository = new AccessTokenRepository(); + + $server = new \League\OAuth2\Server\ResourceServer( + $accessTokenRepository, + $publicKey + ); + $this->middleware = new ResourceServerMiddleware( $server ); + } + + /** + * Check if the request is an OAuth2 request + * + * @param WebRequest|ServerRequestInterface $request + * @return bool + */ + public static function isOAuth2Request( $request ) { + $authHeader = $request->getHeader( 'authorization' ); + + // Normalize to array + if ( is_string( $authHeader ) ) { + $authHeader = [ $authHeader ]; + } + if ( !empty( $authHeader ) && strpos( $authHeader[0], 'Bearer' ) === 0 ) { + return true; + } + return false; + } + + /** + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @param callable $callback + * @return ResponseInterface + */ + public function verify( $request, $response, $callback ) { + $this->verified = false; + + return $this->middleware->__invoke( + $request, + $response, + function ( $request, $response ) use ( $callback ) { + $this->setVerifiedInfo( $request ); + return $callback( $request, $response ); + } + ); + } + + /** + * @return User + * @throws MWOAuthException + */ + public function getUser() { + $this->assertVerified(); + return $this->user; + } + + /** + * @return ClientEntity + * @throws MWOAuthException + */ + public function getClient() { + $this->assertVerified(); + return $this->client; + } + + /** + * @return ScopeEntity[] + * @throws MWOAuthException + */ + public function getScopes() { + $this->assertVerified(); + return $this->scopes; + } + + /** + * Get access token this request was made with + * + * @return string + * @throws MWOAuthException + */ + public function getAccessTokenId() { + $this->assertVerified(); + return $this->accessTokenId; + } + + /** + * Check if the scope is allowed + * + * @param string|ScopeEntityInterface $scope + * @return bool + * @throws MWOAuthException + */ + public function isScopeAllowed( $scope ) { + $this->assertVerified(); + + if ( $scope instanceof ScopeEntityInterface ) { + $scope = $scope->getIdentifier(); + } + + return isset( $this->scopes[$scope] ); + } + + /** + * Read out the verified request and get relevant information + * + * @param ServerRequestInterface $request + * @throws HttpException + */ + public function setVerifiedInfo( ServerRequestInterface $request ) { + $this->setUser( $request ); + $this->setClient( $request ); + $this->setScopes( $request ); + $this->setAccessTokenId( $request ); + + $this->verified = true; + } + + /** + * Set authorized user to the global context + * + * @param ServerRequestInterface $request + * @throws HttpException + */ + private function setUser( ServerRequestInterface $request ) { + $userId = $request->getAttribute( 'oauth_user_id', 0 ); + if ( !$userId ) { + // Set anon user when no user id is present in the AT (machine grant) + $this->user = User::newFromId( 0 ); + return; + } + + try { + $user = Utils::getLocalUserFromCentralId( $userId ); + } catch ( MWException $ex ) { + throw new HttpException( $ex->getMessage(), 403 ); + } + + $this->user = $user; + } + + /** + * Set the ClientEntity from validated request + * + * @param ServerRequestInterface $request + * @throws HttpException + */ + private function setClient( ServerRequestInterface $request ) { + $this->client = ClientEntity::newFromKey( + Utils::getCentralDB( DB_REPLICA ), + $request->getAttribute( 'oauth_client_id' ) + ); + if ( !$this->client || $this->client->getOAuthVersion() !== Consumer::OAUTH_VERSION_2 ) { + throw new HttpException( 'Client represented by given access token is invalid', 403 ); + } + } + + /** + * Set validated scopes + * + * @param ServerRequestInterface $request + */ + private function setScopes( ServerRequestInterface $request ) { + $scopeNames = $request->getAttribute( 'oauth_scopes', [] ); + $scopeRepo = new ScopeRepository(); + foreach ( $scopeNames as $scopeName ) { + $scope = $scopeRepo->getScopeEntityByIdentifier( $scopeName ); + if ( !$scope ) { + continue; + } + $this->scopes[$scope->getIdentifier()] = $scope; + } + } + + /** + * Set the access token this request was made with + * + * @param ServerRequestInterface $request + */ + private function setAccessTokenId( ServerRequestInterface $request ) { + $this->accessTokenId = $request->getAttribute( 'oauth_access_token_id' ); + } + + private function assertVerified() { + if ( !$this->verified ) { + throw new MWOAuthException( 'mwoauth-oauth2-error-request-not-verified' ); + } + } +} diff --git a/OAuth/src/Response.php b/OAuth/src/Response.php new file mode 100644 index 00000000..890ccb37 --- /dev/null +++ b/OAuth/src/Response.php @@ -0,0 +1,139 @@ +<?php + +namespace MediaWiki\Extensions\OAuth; + +use MediaWiki\Rest\Response as RestResponse; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; + +class Response extends RestResponse implements ResponseInterface { + + public function __construct( $bodyContents = '' ) { + parent::__construct( $bodyContents ); + } + + /** + * Return an instance with the specified HTTP protocol version. + * + * The version string MUST contain only the HTTP version number (e.g., + * "1.1", "1.0"). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new protocol version. + * + * @param string $version HTTP protocol version + * @return static + */ + public function withProtocolVersion( $version ) { + $response = clone $this; + $response->setProtocolVersion( $version ); + return $response; + } + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader( $name, $value ) { + $response = clone $this; + $response->setHeader( $name, $value ); + return $response; + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader( $name, $value ) { + $response = clone $this; + $response->addHeader( $name, $value ); + return $response; + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader( $name ) { + $response = clone $this; + $response->removeHeader( $name ); + return $response; + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody( StreamInterface $body ) { + $response = clone $this; + $response->setBody( $body ); + return $response; + } + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function withStatus( $code, $reasonPhrase = '' ) { + $response = clone $this; + $response->setStatus( $code, $reasonPhrase ); + return $response; + } +} diff --git a/OAuth/src/Rest/Handler/AccessToken.php b/OAuth/src/Rest/Handler/AccessToken.php new file mode 100644 index 00000000..2ddc90d4 --- /dev/null +++ b/OAuth/src/Rest/Handler/AccessToken.php @@ -0,0 +1,127 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Rest\Handler; + +use GuzzleHttp\Psr7\ServerRequest; +use League\OAuth2\Server\Exception\OAuthServerException; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant\AuthorizationCodeAccessTokens; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant\ClientCredentials; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant\RefreshToken; +use MediaWiki\Extensions\OAuth\Response; +use MWExceptionHandler; +use Throwable; +use Wikimedia\ParamValidator\ParamValidator; + +/** + * Handles the oauth2/access_token endpoint, which can be used after the user has returned from + * the authorization dialog to trade the off the received authorization code for an access token. + */ +class AccessToken extends AuthenticationHandler { + + const GRANT_TYPE_CLIENT_CREDENTIALS = 'client_credentials'; + const GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code'; + const GRANT_TYPE_REFRESH_TOKEN = 'refresh_token'; + + /** + * @inheritDoc + */ + public function execute() { + $response = new Response(); + + try { + if ( $this->queuedError ) { + throw $this->queuedError; + } + $request = ServerRequest::fromGlobals()->withParsedBody( + $this->getValidatedParams() + ); + + $authProvider = $this->getAuthorizationProvider(); + return $authProvider->getAccessTokens( $request, $response ); + } catch ( OAuthServerException $exception ) { + return $this->errorResponse( $exception, $response ); + } catch ( Throwable $exception ) { + MWExceptionHandler::logException( $exception ); + return $this->errorResponse( + OAuthServerException::serverError( $exception->getMessage(), $exception ), + $response + ); + } + } + + /** + * @inheritDoc + */ + public function getParamSettings() { + return [ + 'grant_type' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => [ + self::GRANT_TYPE_CLIENT_CREDENTIALS, + self::GRANT_TYPE_AUTHORIZATION_CODE, + self::GRANT_TYPE_REFRESH_TOKEN, + ], + ParamValidator::PARAM_REQUIRED => true, + ], + 'client_id' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'client_secret' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'redirect_uri' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'scope' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'code' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'refresh_token' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'code_verifier' => [ + self::PARAM_SOURCE => 'post', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ] + ]; + } + + /** + * @return string + */ + protected function getGrantKey() { + return 'grant_type'; + } + + /** + * @param string $grantKey + * @return string|false + */ + protected function getGrantClass( $grantKey ) { + switch ( $grantKey ) { + case static::GRANT_TYPE_AUTHORIZATION_CODE: + return AuthorizationCodeAccessTokens::class; + case static::GRANT_TYPE_CLIENT_CREDENTIALS: + return ClientCredentials::class; + case static::GRANT_TYPE_REFRESH_TOKEN: + return RefreshToken::class; + default: + return false; + } + } +} diff --git a/OAuth/src/Rest/Handler/AuthenticationHandler.php b/OAuth/src/Rest/Handler/AuthenticationHandler.php new file mode 100644 index 00000000..f44a0b63 --- /dev/null +++ b/OAuth/src/Rest/Handler/AuthenticationHandler.php @@ -0,0 +1,197 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Rest\Handler; + +use Config; +use League\OAuth2\Server\Exception\OAuthServerException; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\AccessToken as AccessTokenProvider; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant\AuthorizationCodeAuthorization; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Response; +use MediaWiki\MediaWikiServices; +use MediaWiki\Rest\Handler; +use MediaWiki\Rest\HttpException; +use MediaWiki\Rest\Response as RestResponse; +use MediaWiki\Rest\StringStream; +use MediaWiki\Rest\Validator\Validator; +use Psr\Http\Message\ResponseInterface; +use RequestContext; +use User; + +abstract class AuthenticationHandler extends Handler { + + /** + * @var User + */ + protected $user; + + /** + * @var Config + */ + protected $config; + + /** + * @var OAuthServerException|null + */ + protected $queuedError; + + /** + * @return AuthenticationHandler + */ + public static function factory() { + $centralId = Utils::getCentralIdFromLocalUser( RequestContext::getMain()->getUser() ); + $user = $centralId ? Utils::getLocalUserFromCentralId( $centralId ) : User::newFromId( 0 ); + $config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'mwoauth' ); + // @phan-suppress-next-line PhanTypeInstantiateAbstractStatic + return new static( $user, $config ); + } + + /** + * @param User $user + * @param Config $config + */ + protected function __construct( User $user, Config $config ) { + $this->user = $user; + $this->config = $config; + } + + /** + * We do not want any permission checks + * + * @return bool + */ + public function needsReadAccess() { + return false; + } + + /** + * We do not want any permission checks + * + * @return bool + */ + public function needsWriteAccess() { + return false; + } + + /** + * @throws HttpException + * @return AccessTokenProvider|AuthorizationCodeAuthorization + */ + protected function getAuthorizationProvider() { + $grantKey = $this->getGrantKey(); + $validated = $this->getValidatedParams(); + $grantKeyValue = $validated[$grantKey]; + + $class = $this->getGrantClass( $grantKeyValue ); + if ( !$class || !is_callable( [ $class, 'factory' ] ) ) { + throw new HttpException( 'invalid_request', 400 ); + } + + /** @var AccessTokenProvider|AuthorizationCodeAuthorization $authProvider */ + $authProvider = $class::factory(); + '@phan-var AccessTokenProvider|AuthorizationCodeAuthorization $authProvider'; + return $authProvider; + } + + public function validate( Validator $restValidator ) { + try { + parent::validate( $restValidator ); + } catch ( HttpException $exception ) { + // Catch and store any validation errors, so they can be thrown + // during the execution, and get caught by appropriate error handling code + $type = $exception->getErrorData()['error'] ?? 'parameter-validation-failed'; + if ( $type === 'parameter-validation-failed' ) { + $missingParam = $exception->getErrorData()['name'] ?? ''; + return $this->queueError( OAuthServerException::invalidRequest( $missingParam ) ); + } + $this->queueError( OAuthServerException::serverError( $exception->getMessage() ) ); + } + } + + /** + * @param OAuthServerException $ex + */ + protected function queueError( OAuthServerException $ex ) { + // If already set, do not override, since we cannot throw more than one error, + // and it will probably be more useful to throw first error that occurred + if ( !$this->queuedError ) { + $this->queuedError = $ex; + } + } + + /** + * @param array $query + * @return string + */ + protected function getQueryParamsCgi( $query = [] ) { + $queryParams = $this->getRequest()->getQueryParams(); + unset( $queryParams['title'] ); + + $queryParams = array_merge( $queryParams, $query ); + return wfArrayToCgi( $queryParams ); + } + + /** + * @param OAuthServerException $exception + * @param Response|null $response + * @return ResponseInterface|RestResponse + */ + protected function errorResponse( $exception, $response = null ) { + $response = $response ?? new Response(); + $response = $exception->generateHttpResponse( $response ); + if ( $exception->hasRedirect() || $this->getRequest()->getMethod() === 'POST' ) { + return $response; + } + + $out = RequestContext::getMain()->getOutput(); + // TODO: Should we include message/hint eventhough they are not localized? + $out->showErrorPage( + 'mwoauth-error', + $this->getLocalizedErrorMessage( $exception->getErrorType() ) + ); + + ob_start(); + $out->output(); + $html = ob_get_clean(); + + $response = $this->getResponseFactory()->create(); + $stream = new StringStream( $html ); + $response->setHeader( 'Content-Type', 'text/html' ); + $response->setBody( $stream ); + + return $response; + } + + /** + * @param string $type + * @return string + */ + private function getLocalizedErrorMessage( $type ) { + $map = [ + 'invalid_client' => 'mwoauth-oauth2-error-invalid-client', + 'server_error' => 'mwoauth-oauth2-error-server-error', + 'invalid_request' => 'mwoauth-oauth2-error-invalid-request', + 'unauthorized_client' => 'mwoauth-oauth2-error-unauthorized-client', + 'access_denied' => 'mwoauth-oauth2-error-access-denied', + 'unsupported_response_type' => 'mwoauth-oauth2-error-unsupported-response-type', + 'invalid_scope' => 'mwoauth-oauth2-error-invalid-scope', + 'temporarily_unavailable' => 'mwoauth-oauth2-error-temporarily-unavailable' + ]; + if ( isset( $map[$type] ) ) { + return $map[$type]; + } + + return 'mwoauth-oauth2-error-server-error'; + } + + /** + * @return string + */ + abstract protected function getGrantKey(); + + /** + * @param string $grantKey + * @return string|false + */ + abstract protected function getGrantClass( $grantKey ); +} diff --git a/OAuth/src/Rest/Handler/Authorize.php b/OAuth/src/Rest/Handler/Authorize.php new file mode 100644 index 00000000..a1dd71b6 --- /dev/null +++ b/OAuth/src/Rest/Handler/Authorize.php @@ -0,0 +1,264 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Rest\Handler; + +use Exception; +use GuzzleHttp\Psr7\ServerRequest; +use League\OAuth2\Server\Entities\ScopeEntityInterface; +use League\OAuth2\Server\Exception\OAuthServerException; +use League\OAuth2\Server\RequestTypes\AuthorizationRequest; +use MediaWiki\Extensions\OAuth\AuthorizationProvider\Grant\AuthorizationCodeAuthorization; +use MediaWiki\Extensions\OAuth\Entity\ClientEntity; +use MediaWiki\Extensions\OAuth\Entity\UserEntity; +use MediaWiki\Extensions\OAuth\Exception\ClientApprovalDenyException; +use MediaWiki\Extensions\OAuth\Response; +use MediaWiki\Rest\Response as RestResponse; +use MWException; +use MWExceptionHandler; +use SpecialPage; +use Throwable; +use User; +use Wikimedia\ParamValidator\ParamValidator; + +/** + * Handles the oauth2/authorize endpoint, which displays an authorization dialog to the user if + * needed (by redirecting to Special:OAuth/approve), and returns an authorization code that can be + * traded for the access token. + */ +class Authorize extends AuthenticationHandler { + const RESPONSE_TYPE_CODE = 'code'; + + /** + * @inheritDoc + */ + public function execute() { + $response = new Response(); + + try { + if ( $this->queuedError ) { + throw $this->queuedError; + } + $request = ServerRequest::fromGlobals()->withQueryParams( + $this->getValidatedParams() + ); + // Note: Owner-only clients can only use client_credentials grant + // so would be rejected from this endpoint with invalid_client error + // automatically, no need for additional checks + if ( !$this->user instanceof User || $this->user->isAnon() ) { + return $this->getLoginRedirectResponse(); + } + + $authProvider = $this->getAuthorizationProvider(); + $authProvider->setUser( $this->user ); + /** @var AuthorizationRequest $authRequest */ + $authRequest = $authProvider->init( $request ); + $this->setValidScopes( $authRequest ); + if ( !$authProvider->needsUserApproval() ) { + return $authProvider->authorize( $authRequest, $response ); + } + + if ( $this->getValidatedParams()['approval_cancel'] ) { + throw new ClientApprovalDenyException( $authRequest->getRedirectUri() ); + } + + if ( + $this->getValidatedParams()['approval_pass'] && + $this->checkApproval( $authRequest ) + ) { + $authRequest->setAuthorizationApproved( true ); + return $authProvider->authorize( $authRequest, $response ); + } + + return $this->getApprovalRedirectResponse( $authRequest ); + } catch ( OAuthServerException $ex ) { + return $this->errorResponse( $ex, $response ); + } catch ( Throwable $ex ) { + MWExceptionHandler::logException( $ex ); + return $this->errorResponse( + OAuthServerException::serverError( $ex->getMessage() ), + $response + ); + } + } + + protected function setValidScopes( AuthorizationRequest &$authRequest ) { + /** @var ClientEntity $client */ + $client = $authRequest->getClient(); + '@phan-var ClientEntity $client'; + + $scopes = $this->getValidatedParams()['scope']; + if ( !$scopes ) { + // No scope parameter + $authRequest->setScopes( + $client->getScopes() + ); + return; + } + // Trim off any not allowed scopes + $allowedScopes = $client->getGrants(); + + $authRequest->setScopes( array_filter( + $authRequest->getScopes(), + function ( ScopeEntityInterface $scope ) use ( $allowedScopes ) { + return in_array( $scope->getIdentifier(), $allowedScopes ); + } + ) ); + } + + /** + * @inheritDoc + */ + public function getParamSettings() { + return [ + 'response_type' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => [ + self::RESPONSE_TYPE_CODE + ], + ParamValidator::PARAM_REQUIRED => true, + ], + 'client_id' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => true, + ], + 'redirect_uri' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'scope' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'state' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'code_challenge' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'code_challenge_method' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => [ + 'plain', + 'S256' + ], + ParamValidator::PARAM_REQUIRED => false, + ], + 'approval_cancel' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ], + 'approval_pass' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => false, + ] + ]; + } + + /** + * @param AuthorizationRequest $authRequest + * @return RestResponse + * @throws MWException + */ + private function getApprovalRedirectResponse( AuthorizationRequest $authRequest ) { + return $this->getResponseFactory()->createTemporaryRedirect( + SpecialPage::getTitleFor( 'OAuth', 'approve' )->getFullURL( [ + 'returnto' => $this->getRequest()->getUri()->getPath(), + 'returntoquery' => $this->getQueryParamsCgi(), + 'client_id' => $authRequest->getClient()->getIdentifier(), + 'oauth_version' => ClientEntity::OAUTH_VERSION_2, + 'scope' => implode( ' ', array_map( function ( ScopeEntityInterface $scope ) { + return $scope->getIdentifier(); + }, $authRequest->getScopes() ) ) + ] ) + ); + } + + private function getLoginRedirectResponse() { + return $this->getResponseFactory()->createTemporaryRedirect( + SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( [ + 'returnto' => SpecialPage::getTitleFor( 'OAuth', 'rest_redirect' ), + 'returntoquery' => $this->getQueryParamsCgi( [ + 'rest_url' => $this->getRequest()->getUri()->getPath() + ] ), + ] ) + ); + } + + /** + * @return string + */ + protected function getGrantKey() { + return 'response_type'; + } + + /** + * @param string $grantKey + * @return string|false + */ + protected function getGrantClass( $grantKey ) { + switch ( $grantKey ) { + case static::RESPONSE_TYPE_CODE: + return AuthorizationCodeAuthorization::class; + default: + return false; + } + } + + /** + * Check if user has approved the client, and scopes it requested + * + * @param AuthorizationRequest $authRequest + * @return bool + */ + private function checkApproval( AuthorizationRequest $authRequest ) { + /** @var ClientEntity $client */ + $client = $authRequest->getClient(); + '@phan-var ClientEntity $client'; + + /** @var UserEntity $userEntity */ + $userEntity = $authRequest->getUser(); + '@phan-var UserEntity $userEntity'; + + try { + $approval = $client->getCurrentAuthorization( + $userEntity->getMwUser(), + wfWikiID() + ); + } catch ( Exception $ex ) { + return false; + } + + if ( !$approval ) { + return false; + } + + // Scopes in OAuth 1.0 are called grants + $scopes = $approval->getGrants(); + $requestedScopes = $this->getFlatScopes( $authRequest->getScopes() ); + $missing = array_diff( $requestedScopes, $scopes ); + if ( !empty( $missing ) ) { + return false; + } + + return true; + } + + /** + * @param ScopeEntityInterface[] $scopeEntities + * @return string[] + */ + private function getFlatScopes( $scopeEntities ) { + return array_map( function ( ScopeEntityInterface $scope ) { + return $scope->getIdentifier(); + }, $scopeEntities ); + } +} diff --git a/OAuth/src/Rest/Handler/Resource.php b/OAuth/src/Rest/Handler/Resource.php new file mode 100644 index 00000000..2b283b4d --- /dev/null +++ b/OAuth/src/Rest/Handler/Resource.php @@ -0,0 +1,153 @@ +<?php + +namespace MediaWiki\Extensions\OAuth\Rest\Handler; + +use FormatJson; +use GuzzleHttp\Psr7\ServerRequest; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\ResourceServer; +use MediaWiki\Extensions\OAuth\Response; +use MediaWiki\Extensions\OAuth\UserStatementProvider; +use MediaWiki\Rest\Handler; +use MediaWiki\Rest\HttpException; +use MWException; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Wikimedia\ParamValidator\ParamValidator; + +/** + * Handles the oauth2/resource/profile and oauth2/resource/scope endpoints, which return + * information about the user and the grants of the application, respectively. + */ +class Resource extends Handler { + const TYPE_PROFILE = 'profile'; + + /** @var ResourceServer */ + protected $resourceServer; + + /** + * @return static + */ + public static function factory() { + return new static( + ResourceServer::factory() + ); + } + + /** + * @param ResourceServer $resourceServer + */ + protected function __construct( $resourceServer ) { + $this->resourceServer = $resourceServer; + } + + /** + * All access controls are handled over OAuth2 + * + * @return bool + */ + public function needsReadAccess() { + return false; + } + + /** + * @return bool + */ + public function needsWriteAccess() { + return false; + } + + /** + * @return ResponseInterface + */ + public function execute() { + $response = new Response(); + $request = ServerRequest::fromGlobals()->withHeader( + 'authorization', + $this->getRequest()->getHeader( 'authorization' ) + ); + + $callback = [ $this, 'doExecuteProtected' ]; + return $this->resourceServer->verify( $request, $response, $callback ); + } + + /** + * @param ServerRequestInterface $request + * @param ResponseInterface $response + * @throws HttpException + * @return ResponseInterface + * @throws MWOAuthException + */ + public function doExecuteProtected( $request, $response ) { + $type = $this->getRequest()->getPathParam( 'type' ); + + switch ( $type ) { + case 'profile': + return $this->getProfile( $response ); + case 'scopes': + return $this->getScopes( $response ); + } + + throw new HttpException( 'Invalid resource type', 400 ); + } + + /** + * Return appropriate profile info based on approved scopes + * + * @param ResponseInterface $response + * @return ResponseInterface + * @throws HttpException + * @throws MWOAuthException + */ + private function getProfile( $response ) { + // Intersection between approved and requested scopes + $scopes = array_keys( $this->resourceServer->getScopes() ); + $userStatementProvider = UserStatementProvider::factory( + $this->resourceServer->getUser(), + $this->resourceServer->getClient(), + $scopes + ); + + try { + $profile = $userStatementProvider->getUserProfile(); + } catch ( MWException $ex ) { + throw new HttpException( $ex->getMessage(), $ex->getCode() ); + } + + return $this->respond( $response, $profile ); + } + + /** + * Get all available scopes client application can use + * + * @param ResponseInterface $response + * @return ResponseInterface + * @throws MWOAuthException + */ + private function getScopes( $response ) { + $grants = $this->resourceServer->getClient()->getGrants(); + return $this->respond( $response, [ + 'scopes' => $grants + ] ); + } + + /** + * @param ResponseInterface $response + * @param array $data + * @return ResponseInterface + */ + private function respond( $response, $data = [] ) { + $response->getBody()->write( FormatJson::encode( $data ) ); + return $response; + } + + public function getParamSettings() { + return [ + 'type' => [ + self::PARAM_SOURCE => 'path', + ParamValidator::PARAM_TYPE => [ 'profile', 'scopes' ], + ParamValidator::PARAM_REQUIRED => true, + ], + ]; + } +} diff --git a/OAuth/src/SessionProvider.php b/OAuth/src/SessionProvider.php new file mode 100644 index 00000000..8d7e5085 --- /dev/null +++ b/OAuth/src/SessionProvider.php @@ -0,0 +1,440 @@ +<?php + +namespace MediaWiki\Extensions\OAuth; + +use ApiMessage; +use GuzzleHttp\Psr7\ServerRequest; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\ConsumerAcceptance; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthException; +use MediaWiki\Extensions\OAuth\Backend\MWOAuthRequest; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\Extensions\OAuth\Repository\AccessTokenRepository; +use MediaWiki\Session\SessionBackend; +use MediaWiki\Session\SessionInfo; +use MediaWiki\Session\SessionManager; +use MediaWiki\Session\UserInfo; +use User; +use WebRequest; +use Wikimedia\Rdbms\DBError; + +/** + * Session provider for OAuth + * + * This is a fairly standard ImmutableSessionProviderWithCookie implementation: + * the user identity is determined by the OAuth headers included in the + * request. But since we want to make sure to fail the request when OAuth + * headers are present but invalid, this takes the somewhat unusual step of + * returning a bogus SessionInfo and then hooking ApiBeforeMain to throw a + * fatal exception after MediaWiki is ready to handle it. + * + * It also takes advantage of the getAllowedUserRights() method for authz + * purposes (limiting the rights to those included in the grant), and + * registers some hooks to tag actions made via the provider. + */ +class SessionProvider extends \MediaWiki\Session\ImmutableSessionProviderWithCookie { + + public function __construct( array $params = [] ) { + global $wgHooks; + + parent::__construct( $params ); + + $wgHooks['ApiCheckCanExecute'][] = $this; + $wgHooks['RecentChange_save'][] = $this; + $wgHooks['MarkPatrolled'][] = $this; + } + + /** + * Throw an exception, later + * + * @param string $key Key for the error message + * @param mixed ...$params Parameters as strings. + * @return SessionInfo + */ + private function makeException( $key, ...$params ) { + global $wgHooks; + + // First, schedule the throwing of the exception for later when the API + // is ready to catch it + $msg = wfMessage( $key, $params ); + $exception = \ApiUsageException::newWithMessage( null, $msg ); + $wgHooks['ApiBeforeMain'][] = function () use ( $exception ) { + throw $exception; + }; + + // Then return an appropriate SessionInfo + $id = $this->hashToSessionId( 'bogus' ); + return new SessionInfo( SessionInfo::MAX_PRIORITY, [ + 'provider' => $this, + 'id' => $id, + 'userInfo' => UserInfo::newAnonymous(), + 'persisted' => false, + ] ); + } + + public function provideSessionInfo( WebRequest $request ) { + // For some reason MWOAuth is restricted to be API-only. + if ( !defined( 'MW_API' ) && !defined( 'MW_REST_API' ) ) { + return null; + } + + $oauthVersion = $this->getOAuthVersionFromRequest( $request ); + if ( $oauthVersion === null ) { + // Not an OAuth request + return null; + } + + $logData = [ + 'clientip' => $request->getIP(), + 'user' => false, + 'consumer' => '', + 'result' => 'fail', + ]; + + $dbr = Utils::getCentralDB( DB_REPLICA ); + $access = null; + try { + if ( $oauthVersion === Consumer::OAUTH_VERSION_2 ) { + $resourceServer = ResourceServer::factory(); + $accessTokenKey = $this->verifyOAuth2Request( $resourceServer, $request ); + $accessTokenRepo = new AccessTokenRepository(); + $accessId = $accessTokenRepo->getApprovalId( $accessTokenKey ); + if ( $accessId === 0 ) { + if ( + $resourceServer->getUser()->getId() === 0 && + $resourceServer->getClient()->getOwnerOnly() === false + ) { + // This tell us, with good degree of certainty, that the AT + // was issued to a machine and represents no particular user + $access = ConsumerAcceptance::newFromArray( [ + 'id' => null, + 'wiki' => $resourceServer->getClient()->getWiki(), + 'userId' => 0, + 'consumerId' => $resourceServer->getClient()->getId(), + 'accessToken' => '', + 'accessSecret' => '', + 'grants' => $resourceServer->getClient()->getGrants(), + 'accepted' => wfTimestampNow(), + 'oauth_version' => Consumer::OAUTH_VERSION_2 + ] ); + } + } else { + $access = ConsumerAcceptance::newFromId( + Utils::getCentralDB( DB_REPLICA ), $accessId + ); + } + if ( !$access ) { + throw new MWOAuthException( 'mwoauth-oauth2-error-create-at-no-user-approval' ); + } + + // Set the scopes that are verified for this request + $access->setField( 'grants', array_keys( $resourceServer->getScopes() ) ); + } else { + $server = Utils::newMWOAuthServer(); + $oauthRequest = MWOAuthRequest::fromRequest( $request ); + $logData['consumer'] = $oauthRequest->getConsumerKey(); + list( , $accessToken ) = $server->verify_request( $oauthRequest ); + $accessTokenKey = $accessToken->key; + $access = ConsumerAcceptance::newFromToken( $dbr, $accessTokenKey ); + } + } catch ( \Exception $ex ) { + $this->logger->info( 'Bad OAuth request from {ip}', $logData + [ 'exception' => $ex ] ); + return $this->makeException( 'mwoauth-invalid-authorization', $ex->getMessage() ); + } + + $logData['user'] = Utils::getCentralUserNameFromId( $access->getUserId(), 'raw' ); + + $wiki = wfWikiID(); + // Access token is for this wiki + if ( $access->getWiki() !== '*' && $access->getWiki() !== $wiki ) { + $this->logger->debug( 'OAuth request for wrong wiki from user {user}', $logData ); + return $this->makeException( 'mwoauth-invalid-authorization-wrong-wiki', $wiki ); + } + + // There exists a local user + $localUser = Utils::getLocalUserFromCentralId( $access->getUserId() ); + if ( !$localUser ) { + $localUser = User::newFromId( 0 ); + } + // If there is an actual approval, but user bound to it does not exist + if ( $access->getId() > 0 && $localUser->getId() === 0 ) { + $this->logger->debug( 'OAuth request for invalid or non-local user {user}', $logData ); + return $this->makeException( 'mwoauth-invalid-authorization-invalid-user', + \Message::rawParam( \Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Help:OAuth/Errors#E008', + 'E008', + true + ) ) + ); + } + if ( $localUser->isLocked() || + ( $this->config->get( 'BlockDisablesLogin' ) && $localUser->isBlocked() ) + ) { + $this->logger->debug( 'OAuth request for blocked user {user}', $logData ); + return $this->makeException( 'mwoauth-invalid-authorization-blocked-user' ); + } + + // The consumer is approved or owned by $localUser, and is for this wiki. + $consumer = Consumer::newFromId( $dbr, $access->getConsumerId() ); + if ( !$consumer->isUsableBy( $localUser ) ) { + $this->logger->debug( + 'OAuth request for consumer {consumer} not approved by user {user}', $logData + ); + return $this->makeException( 'mwoauth-invalid-authorization-not-approved', + $consumer->getName() ); + } elseif ( $consumer->getWiki() !== '*' && $consumer->getWiki() !== $wiki ) { + $this->logger->debug( 'OAuth request for consumer {consumer} to incorrect wiki', $logData ); + return $this->makeException( 'mwoauth-invalid-authorization-wrong-wiki', $wiki ); + } + + // Ok, use this user! + if ( $this->sessionCookieName === null ) { + // We're not configured to use cookies, so concatenate some of the + // internal consumer-acceptance state to generate an ID. + $id = $this->hashToSessionId( implode( "\n", [ + $access->getId(), + $access->getWiki(), + $access->getUserId(), + $access->getConsumerId(), + $access->getAccepted(), + $wiki, + ] ) ); + $persisted = false; + $forceUse = true; + } else { + $id = $this->getSessionIdFromCookie( $request ); + $persisted = $id !== null; + $forceUse = false; + } + + $logData['result'] = 'success'; + $this->logger->debug( 'OAuth request for consumer {consumer} by user {user}', $logData ); + + return new SessionInfo( SessionInfo::MAX_PRIORITY, [ + 'provider' => $this, + 'id' => $id, + 'userInfo' => UserInfo::newFromUser( $localUser, true ), + 'persisted' => $persisted, + 'forceUse' => $forceUse, + 'metadata' => [ + 'oauthVersion' => $oauthVersion, + 'consumerId' => $consumer->getOwnerOnly() ? null : $consumer->getId(), + 'key' => $accessTokenKey, + 'rights' => \MWGrants::getGrantRights( $access->getGrants() ), + ], + ] ); + } + + /** + * Determine OAuth version of the request + * + * @param WebRequest $request + * @return int|null if request is not using OAuth header + */ + private function getOAuthVersionFromRequest( WebRequest $request ) { + if ( Utils::hasOAuthHeaders( $request ) ) { + return Consumer::OAUTH_VERSION_1; + } + if ( ResourceServer::isOAuth2Request( $request ) ) { + return Consumer::OAUTH_VERSION_2; + } + + return null; + } + + /** + * @param ResourceServer &$resourceServer + * @param WebRequest $request + * @return string + * @throws MWOAuthException + */ + private function verifyOAuth2Request( ResourceServer &$resourceServer, WebRequest $request ) { + $request = ServerRequest::fromGlobals()->withHeader( + 'authorization', + $request->getHeader( 'authorization' ) + ); + + $response = new Response(); + $valid = false; + $resourceServer->verify( + $request, + $response, + function ( $request, $response ) use ( &$valid ) { + $valid = true; + } + ); + + if ( $valid ) { + return $resourceServer->getAccessTokenId(); + } + + throw new MWOAuthException( 'mwoauth-oauth2-invalid-access-token' ); + } + + public function preventSessionsForUser( $username ) { + $id = Utils::getCentralIdFromUserName( $username ); + $dbw = Utils::getCentralDB( DB_MASTER ); + + $dbw->startAtomic( __METHOD__ ); + try { + // Remove any approvals for the user's consumers before deleting them + $dbw->deleteJoin( + 'oauth_accepted_consumer', + 'oauth_registered_consumer', + 'oaac_consumer_id', + 'oarc_id', + [ 'oarc_user_id' => $id ], + __METHOD__ + ); + $dbw->delete( + 'oauth_registered_consumer', + [ 'oarc_user_id' => $id ], + __METHOD__ + ); + + // Remove any approvals by this user, too + $dbw->delete( + 'oauth_accepted_consumer', + [ 'oaac_user_id' => $id ], + __METHOD__ + ); + } catch ( DBError $e ) { + $dbw->rollback( __METHOD__ ); + throw $e; + } + $dbw->endAtomic( __METHOD__ ); + } + + public function getVaryHeaders() { + return [ + 'Authorization' => null, + ]; + } + + /** + * Fetch the access data, if any, for this user-session + * @param \User|null $user + * @return array|null + */ + private function getSessionData( \User $user = null ) { + if ( $user ) { + $session = $user->getRequest()->getSession(); + if ( $session->getProvider() === $this && + $user->equals( $session->getUser() ) + ) { + return $session->getProviderMetadata(); + } + } else { + $session = SessionManager::getGlobalSession(); + if ( $session->getProvider() === $this ) { + return $session->getProviderMetadata(); + } + } + + return null; + } + + public function getAllowedUserRights( SessionBackend $backend ) { + if ( $backend->getProvider() !== $this ) { + throw new \InvalidArgumentException( 'Backend\'s provider isn\'t $this' ); + } + $data = $backend->getProviderMetadata(); + if ( $data ) { + return $data['rights']; + } + + // Should never happen + $this->logger->debug( __METHOD__ . ': No provider metadata, returning no rights allowed' ); + return []; + } + + /** + * Disable certain API modules when used with OAuth + * + * @param \ApiBase $module + * @param \User $user + * @param string|array &$message + * @return bool + */ + public function onApiCheckCanExecute( \ApiBase $module, \User $user, &$message ) { + global $wgMWOauthDisabledApiModules; + if ( !$this->getSessionData( $user ) ) { + return true; + } + + foreach ( $wgMWOauthDisabledApiModules as $badModule ) { + if ( $module instanceof $badModule ) { + $message = ApiMessage::create( + [ 'mwoauth-api-module-disabled', $module->getModuleName() ], + 'mwoauth-api-module-disabled' + ); + return false; + } + } + + return true; + } + + /** + * Record the fact that OAuth was used for anything added to RecentChanges. + * + * @param \RecentChange $rc + * @return bool true + */ + public function onRecentChange_save( $rc ) { + $consumerId = $this->getPublicConsumerId( $rc->getPerformer() ?: null ); + if ( $consumerId !== null ) { + $rc->addTags( Utils::getTagName( $consumerId ) ); + } + return true; + } + + /** + * Get the consumer ID of the non-owner-only OAuth consumer associated with this user, or null. + * @param User|null $user + * @return int|null + */ + protected function getPublicConsumerId( User $user = null ) { + $data = $this->getSessionData( $user ); + if ( $data && isset( $data['consumerId'] ) ) { + return $data['consumerId']; + } + return null; + } + + /** + * Record the fact that OAuth was used for marking an existing RecentChange as patrolled. + * (RecentChange::doMarkPatrolled() does not use RecentChange::save() + * and therefore bypasses the above hook handler.) + * + * @param int $rcid + * @param User $user + * @param bool $wcOnlySysopsCanPatrol + * @param bool $auto + * @param string[] &$tags + * + * @return bool true + */ + public function onMarkPatrolled( + $rcid, + User $user, + $wcOnlySysopsCanPatrol, + $auto, + array &$tags + ) { + $consumerId = $this->getPublicConsumerId( $user ); + if ( $consumerId !== null ) { + $tags[] = Utils::getTagName( $consumerId ); + } + return true; + } + + /** + * OAuth tokens already protect against CSRF. CSRF tokens are not required. + * + * @return bool true + */ + public function safeAgainstCsrf() { + return true; + } +} diff --git a/OAuth/src/Setup.php b/OAuth/src/Setup.php new file mode 100644 index 00000000..8c69fd29 --- /dev/null +++ b/OAuth/src/Setup.php @@ -0,0 +1,41 @@ +<?php + +namespace MediaWiki\Extensions\OAuth; + +use MediaWiki\Extensions\OAuth\Backend\Utils; + +/** + * Class containing hooked functions for an OAuth environment + */ +class Setup { + const TTL_REFRESH_WINDOW = 600; // refresh if expiring in 10 minutes + + /** + * Prevent CentralAuth from issuing centralauthtokens if we have + * OAuth headers in this request. + * @return bool + */ + public static function onCentralAuthAbortCentralAuthToken() { + $request = \RequestContext::getMain()->getRequest(); + return !self::isOAuthRequest( $request ); + } + + /** + * Prevent redirects to canonical titles, since that's not what the OAuth + * request signed. + * @param \WebRequest $request + * @param \Title $title + * @param \OutputPage $output + * @return bool + */ + public static function onTestCanonicalRedirect( $request, $title, $output ) { + return !self::isOAuthRequest( $request ); + } + + protected static function isOAuthRequest( $request ) { + if ( Utils::hasOAuthHeaders( $request ) ) { + return true; + } + return ResourceServer::isOAuth2Request( $request ); + } +} diff --git a/OAuth/src/UserStatementProvider.php b/OAuth/src/UserStatementProvider.php new file mode 100644 index 00000000..ad967bd2 --- /dev/null +++ b/OAuth/src/UserStatementProvider.php @@ -0,0 +1,115 @@ +<?php + +namespace MediaWiki\Extensions\OAuth; + +use Config; +use MediaWiki\Extensions\OAuth\Backend\Consumer; +use MediaWiki\Extensions\OAuth\Backend\Utils; +use MediaWiki\MediaWikiServices; +use MWException; +use MWGrants; +use User; + +class UserStatementProvider { + /** @var Config */ + protected $config; + /** @var User */ + protected $user; + /** @var Consumer */ + protected $consumer; + /** @var array */ + protected $grants; + + /** + * @param User $user + * @param Consumer $consumer + * @param array $grants + * @return static + */ + public static function factory( User $user, Consumer $consumer, $grants = [] ) { + $mainConfig = MediaWikiServices::getInstance()->getMainConfig(); + return new static( $mainConfig, $user, $consumer, $grants ); + } + + /** + * UserStatementProvider constructor. + * @param Config $config + * @param User $user + * @param Consumer $consumer + * @param array $grants + */ + protected function __construct( $config, $user, $consumer, $grants ) { + $this->config = $config; + $this->user = $user; + $this->consumer = $consumer; + $this->grants = $grants; + } + + /** + * Retrieve user statement suitable for JWT encoding + * + * @return array + * @throws MWException + */ + public function getUserStatement() { + $statement = []; + + // Include some of the OpenID Connect attributes + // http://openid.net/specs/openid-connect-core-1_0.html (draft 14) + // Issuer Identifier for the Issuer of the response. + $statement['iss'] = $this->config->get( 'CanonicalServer' ); + // Subject identifier. A locally unique and never reassigned identifier. + $statement['sub'] = Utils::getCentralIdFromLocalUser( $this->user ); + // Audience(s) that this ID Token is intended for. + $statement['aud'] = $this->consumer->getConsumerKey(); + // Expiration time on or after which the ID Token MUST NOT be accepted for processing. + $statement['exp'] = wfTimestamp() + 100; + // Time at which the JWT was issued. + $statement['iat'] = (int)wfTimestamp(); + // TODO: Add auth_time, if we start tracking last login timestamp + + $statement += $this->getUserProfile(); + + return $statement; + } + + /** + * Retrieve user profile information + * + * @return array + */ + public function getUserProfile() { + $profile = []; + // Include some MediaWiki info about the user + if ( !$this->user->isHidden() ) { + $profile['username'] = $this->user->getName(); + $profile['editcount'] = intval( $this->user->getEditCount() ); + $profile['confirmed_email'] = $this->user->isEmailConfirmed(); + $profile['blocked'] = $this->user->getBlock() !== null; + $profile['registered'] = $this->user->getRegistration(); + $profile['groups'] = $this->user->getEffectiveGroups(); + $profile['rights'] = array_values( array_unique( + MediaWikiServices::getInstance()->getPermissionManager()->getUserPermissions( $this->user ) + ) ); + $profile['grants'] = $this->grants; + + if ( in_array( 'mwoauth-authonlyprivate', $this->grants ) || + in_array( 'viewmyprivateinfo', MWGrants::getGrantRights( $profile['grants'] ) ) + ) { + // Paranoia - avoid showing the real name if the wiki is not configured to use + // it but it somehow exists (from past configuration, or some identity management + // extension). This is important as the viewmyprivateinfo grant is presented + // to the user differently when useRealNames() is false. + // Don't omit the field completely to avoid a breaking change. + $profile['realname'] = !in_array( + 'realname', $this->config->get( 'HiddenPrefs' ), true + ) ? $this->user->getRealName() : ''; + $profile['email'] = $this->user->getEmail(); + } + } else { + $profile['blocked'] = true; + } + + return $profile; + } +} |