diff options
Diffstat (limited to 'OAuth/src/Control/ConsumerSubmitControl.php')
-rw-r--r-- | OAuth/src/Control/ConsumerSubmitControl.php | 550 |
1 files changed, 550 insertions, 0 deletions
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, + ], + ] ); + } +} |