summaryrefslogtreecommitdiff
path: root/OAuth/src
diff options
context:
space:
mode:
Diffstat (limited to 'OAuth/src')
-rw-r--r--OAuth/src/AuthorizationProvider/AccessToken.php35
-rw-r--r--OAuth/src/AuthorizationProvider/AuthorizationProvider.php178
-rw-r--r--OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAccessTokens.php27
-rw-r--r--OAuth/src/AuthorizationProvider/Grant/AuthorizationCodeAuthorization.php100
-rw-r--r--OAuth/src/AuthorizationProvider/Grant/ClientCredentials.php17
-rw-r--r--OAuth/src/AuthorizationProvider/Grant/RefreshToken.php21
-rw-r--r--OAuth/src/AuthorizationProvider/IAccessTokenProvider.php18
-rw-r--r--OAuth/src/AuthorizationProvider/IAuthorizationProvider.php25
-rw-r--r--OAuth/src/AuthorizationServerFactory.php57
-rw-r--r--OAuth/src/Backend/Consumer.php800
-rw-r--r--OAuth/src/Backend/ConsumerAcceptance.php274
-rw-r--r--OAuth/src/Backend/Hooks.php214
-rw-r--r--OAuth/src/Backend/MWOAuthDAO.php478
-rw-r--r--OAuth/src/Backend/MWOAuthDataStore.php257
-rw-r--r--OAuth/src/Backend/MWOAuthException.php23
-rw-r--r--OAuth/src/Backend/MWOAuthRequest.php78
-rw-r--r--OAuth/src/Backend/MWOAuthServer.php328
-rw-r--r--OAuth/src/Backend/MWOAuthSignatureMethod_RSA_SHA1.php61
-rw-r--r--OAuth/src/Backend/MWOAuthToken.php28
-rw-r--r--OAuth/src/Backend/OAuth1Consumer.php80
-rw-r--r--OAuth/src/Backend/UpdaterHooks.php98
-rw-r--r--OAuth/src/Backend/Utils.php471
-rw-r--r--OAuth/src/Control/ConsumerAcceptanceAccessControl.php105
-rw-r--r--OAuth/src/Control/ConsumerAcceptanceSubmitControl.php234
-rw-r--r--OAuth/src/Control/ConsumerAccessControl.php262
-rw-r--r--OAuth/src/Control/ConsumerSubmitControl.php550
-rw-r--r--OAuth/src/Control/DAOAccessControl.php126
-rw-r--r--OAuth/src/Control/SubmitControl.php228
-rw-r--r--OAuth/src/Entity/AccessTokenEntity.php150
-rw-r--r--OAuth/src/Entity/AuthCodeEntity.php26
-rw-r--r--OAuth/src/Entity/ClientEntity.php191
-rw-r--r--OAuth/src/Entity/RefreshTokenEntity.php21
-rw-r--r--OAuth/src/Entity/ScopeEntity.php26
-rw-r--r--OAuth/src/Entity/UserEntity.php55
-rw-r--r--OAuth/src/Exception/ClientApprovalDenyException.php19
-rw-r--r--OAuth/src/Frontend/EchoOAuthStageChangePresentationModel.php121
-rw-r--r--OAuth/src/Frontend/OAuthLogFormatter.php40
-rw-r--r--OAuth/src/Frontend/Pagers/ListConsumersPager.php128
-rw-r--r--OAuth/src/Frontend/Pagers/ListMyConsumersPager.php109
-rw-r--r--OAuth/src/Frontend/Pagers/ManageConsumersPager.php109
-rw-r--r--OAuth/src/Frontend/Pagers/ManageMyGrantsPager.php111
-rw-r--r--OAuth/src/Frontend/SpecialPages/SpecialMWOAuth.php743
-rw-r--r--OAuth/src/Frontend/SpecialPages/SpecialMWOAuthConsumerRegistration.php618
-rw-r--r--OAuth/src/Frontend/SpecialPages/SpecialMWOAuthListConsumers.php408
-rw-r--r--OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageConsumers.php521
-rw-r--r--OAuth/src/Frontend/SpecialPages/SpecialMWOAuthManageMyGrants.php348
-rw-r--r--OAuth/src/Frontend/UIHooks.php241
-rw-r--r--OAuth/src/Frontend/UIUtils.php39
-rw-r--r--OAuth/src/Lib/OAuthConsumer.php43
-rw-r--r--OAuth/src/Lib/OAuthDataStore.php53
-rw-r--r--OAuth/src/Lib/OAuthException.php34
-rw-r--r--OAuth/src/Lib/OAuthRequest.php297
-rw-r--r--OAuth/src/Lib/OAuthServer.php270
-rw-r--r--OAuth/src/Lib/OAuthSignatureMethod.php94
-rw-r--r--OAuth/src/Lib/OAuthSignatureMethod_HMAC_SHA1.php60
-rw-r--r--OAuth/src/Lib/OAuthSignatureMethod_PLAINTEXT.php63
-rw-r--r--OAuth/src/Lib/OAuthSignatureMethod_RSA_SHA1.php96
-rw-r--r--OAuth/src/Lib/OAuthToken.php58
-rw-r--r--OAuth/src/Lib/OAuthUtil.php209
-rw-r--r--OAuth/src/Repository/AccessTokenRepository.php147
-rw-r--r--OAuth/src/Repository/AuthCodeRepository.php75
-rw-r--r--OAuth/src/Repository/CacheRepository.php85
-rw-r--r--OAuth/src/Repository/ClientRepository.php64
-rw-r--r--OAuth/src/Repository/DatabaseRepository.php36
-rw-r--r--OAuth/src/Repository/RefreshTokenRepository.php75
-rw-r--r--OAuth/src/Repository/ScopeRepository.php116
-rw-r--r--OAuth/src/ResourceServer.php232
-rw-r--r--OAuth/src/Response.php139
-rw-r--r--OAuth/src/Rest/Handler/AccessToken.php127
-rw-r--r--OAuth/src/Rest/Handler/AuthenticationHandler.php197
-rw-r--r--OAuth/src/Rest/Handler/Authorize.php264
-rw-r--r--OAuth/src/Rest/Handler/Resource.php153
-rw-r--r--OAuth/src/SessionProvider.php440
-rw-r--r--OAuth/src/Setup.php41
-rw-r--r--OAuth/src/UserStatementProvider.php115
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;
+ }
+}