Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConsumerAcceptanceSubmitControl
0.00% covered (danger)
0.00%
0 / 125
0.00% covered (danger)
0.00%
0 / 6
756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getRequiredFields
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
12
 checkBasePermissions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 processAction
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
306
 isOAuth2
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeOAuth2AccessTokens
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * (c) Aaron Schulz 2013, GPL
5 *
6 * @license GPL-2.0-or-later
7 */
8
9namespace MediaWiki\Extension\OAuth\Control;
10
11use MediaWiki\Context\IContextSource;
12use MediaWiki\Extension\OAuth\Backend\Consumer;
13use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
14use MediaWiki\Extension\OAuth\Backend\MWOAuthException;
15use MediaWiki\Extension\OAuth\Backend\Utils;
16use MediaWiki\Extension\OAuth\Lib\OAuthException;
17use MediaWiki\Extension\OAuth\Repository\AccessTokenRepository;
18use MediaWiki\Json\FormatJson;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Status\Status;
22use Wikimedia\Rdbms\IDatabase;
23
24/**
25 * This handles the core logic of submitting/approving application
26 * consumer requests and the logic of managing approved consumers
27 *
28 * This control can be used on any wiki, not just the management one
29 *
30 * @todo improve error messages
31 */
32class ConsumerAcceptanceSubmitControl extends SubmitControl {
33    /** @var IDatabase */
34    protected $dbw;
35
36    /** @var int */
37    protected $oauthVersion;
38
39    /**
40     * @param IContextSource $context
41     * @param array $params
42     * @param IDatabase $dbw Result of Utils::getOAuthDB( DB_PRIMARY )
43     * @param int $oauthVersion
44     */
45    public function __construct(
46        IContextSource $context, array $params, IDatabase $dbw, $oauthVersion
47    ) {
48        parent::__construct( $context, $params );
49        $this->dbw = $dbw;
50        $this->oauthVersion = (int)$oauthVersion;
51    }
52
53    /** @inheritDoc */
54    protected function getRequiredFields() {
55        $required = [
56            'update'   => [
57                'acceptanceId' => '/^\d+$/',
58                'grants'      => static function ( $s ) {
59                    $grants = FormatJson::decode( $s, true );
60                    return is_array( $grants ) && Utils::grantsAreValid( $grants );
61                }
62            ],
63            'renounce' => [
64                'acceptanceId' => '/^\d+$/',
65            ],
66        ];
67        if ( $this->isOAuth2() ) {
68            $required['accept'] = [
69                'client_id' => '/^[0-9a-f]{32}$/',
70                'confirmUpdate' => '/^[01]$/',
71            ];
72        } else {
73            $required['accept'] = [
74                'consumerKey'   => '/^[0-9a-f]{32}$/',
75                'requestToken'  => '/^[0-9a-f]{32}$/',
76                'confirmUpdate' => '/^[01]$/',
77            ];
78        }
79
80        return $required;
81    }
82
83    /** @inheritDoc */
84    protected function checkBasePermissions() {
85        $user = $this->getUser();
86        $services = MediaWikiServices::getInstance();
87        $permissionManager = $services->getPermissionManager();
88        $readOnlyMode = $services->getReadOnlyMode();
89
90        if ( !$user->getID() ) {
91            return $this->failure( 'not_logged_in', 'badaccess-group0' );
92        } elseif ( !$permissionManager->userHasRight( $user, 'mwoauthmanagemygrants' ) ) {
93            return $this->failure( 'permission_denied', 'badaccess-group0' );
94        } elseif ( $readOnlyMode->isReadOnly() ) {
95            return $this->failure( 'readonly', 'readonlytext', $readOnlyMode->getReason() );
96        }
97        return $this->success();
98    }
99
100    /** @inheritDoc */
101    protected function processAction( $action ): Status {
102        // proposer or admin
103        $user = $this->getUser();
104        $dbw = $this->dbw;
105
106        $centralUserId = Utils::getCentralIdFromLocalUser( $user );
107        if ( !$centralUserId ) {
108            return $this->failure( 'permission_denied', 'badaccess-group0' );
109        }
110
111        switch ( $action ) {
112            case 'accept':
113                $payload = [];
114                $identifier = $this->isOAuth2() ? 'client_id' : 'consumerKey';
115                $cmr = Consumer::newFromKey( $this->dbw, $this->vals[$identifier] );
116                if ( !$cmr ) {
117                    return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' );
118                } elseif ( !$cmr->isUsableBy( $user ) ) {
119                    return $this->failure( 'permission_denied', 'badaccess-group0' );
120                }
121
122                try {
123                    if ( $this->isOAuth2() ) {
124                        $scopes = isset( $this->vals['scope'] ) ? explode( ' ', $this->vals['scope'] ) : [];
125
126                        // T413947: Ensure that the 'basic'/'useoauth' scope can't be removed, e.g. by setting
127                        // the 'scope' parameter to the /oauth2/authorize endpoint incorrectly
128                        $scopes = $this->getAcceptedConsumerGrants( $scopes, $cmr );
129
130                        $payload = $cmr->authorize( $this->getUser(), (bool)$this->vals['confirmUpdate'], $scopes );
131                    } else {
132                        $callback = $cmr->authorize(
133                            $this->getUser(),
134                            (bool)$this->vals[ 'confirmUpdate' ],
135                            $cmr->getGrants(),
136                            $this->vals[ 'requestToken' ]
137                        );
138                        $payload = [ 'callbackUrl' => $callback ];
139                    }
140                } catch ( MWOAuthException $exception ) {
141                    return $this->failure( 'oauth_exception', $exception->getMessageObject() );
142                } catch ( OAuthException $exception ) {
143                    return $this->failure( 'oauth_exception',
144                        'mwoauth-oauth-exception', $exception->getMessage() );
145                }
146
147                LoggerFactory::getInstance( 'OAuth' )->info(
148                    '{user} performed action {action} on consumer {consumer}', [
149                        'action' => 'accept',
150                        'user' => $user->getName(),
151                        'consumer' => $cmr->getConsumerKey(),
152                        'target' => Utils::getCentralUserNameFromId( $cmr->getUserId(), 'raw' ),
153                        'comment' => '',
154                        'clientip' => $this->getContext()->getRequest()->getIP(),
155                    ]
156                );
157
158                return $this->success( $payload );
159            case 'update':
160                $cmra = ConsumerAcceptance::newFromId( $dbw, $this->vals['acceptanceId'] );
161                if ( !$cmra ) {
162                    return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' );
163                } elseif ( $cmra->getUserId() !== $centralUserId ) {
164                    return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' );
165                }
166                $cmr = Consumer::newFromId( $dbw, $cmra->getConsumerId() );
167
168                // requested grants
169                $grants = FormatJson::decode( $this->vals['grants'], true );
170                // T413947: If the grant(s) match(es) the authonly case, treat them differently
171                // otherwise we might end up with an empty array in the DB when this is clearly not a revoke
172                // action.
173                $grants = $this->getAcceptedConsumerGrants( $grants, $cmr );
174
175                LoggerFactory::getInstance( 'OAuth' )->info(
176                    '{user} performed action {action} on consumer {consumer}', [
177                        'action' => 'update-acceptance',
178                        'user' => $user->getName(),
179                        'consumer' => $cmr->getConsumerKey(),
180                        'target' => Utils::getCentralUserNameFromId( $cmr->getUserId(), 'raw' ),
181                        'comment' => '',
182                        'clientip' => $this->getContext()->getRequest()->getIP(),
183                    ]
184                );
185                $cmra->setFields( [
186                    'grants' => $grants
187                ] );
188                $cmra->save( $dbw );
189
190                return $this->success( $cmra );
191            case 'renounce':
192                $cmra = ConsumerAcceptance::newFromId( $dbw, $this->vals['acceptanceId'] );
193                if ( !$cmra ) {
194                    return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' );
195                } elseif ( $cmra->getUserId() !== $centralUserId ) {
196                    return $this->failure( 'invalid_access_token', 'mwoauth-invalid-access-token' );
197                }
198
199                $cmr = Consumer::newFromId( $dbw, $cmra->get( 'consumerId' ) );
200                LoggerFactory::getInstance( 'OAuth' )->info(
201                    '{user} performed action {action} on consumer {consumer}', [
202                        'action' => 'renounce',
203                        'user' => $user->getName(),
204                        'consumer' => $cmr->getConsumerKey(),
205                        'target' => Utils::getCentralUserNameFromId( $cmr->getUserId(), 'raw' ),
206                        'comment' => '',
207                        'clientip' => $this->getContext()->getRequest()->getIP(),
208                    ]
209                );
210
211                if ( $cmr->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) {
212                    $this->removeOAuth2AccessTokens( $cmra->getId() );
213                }
214                $cmra->delete( $dbw );
215
216                return $this->success( $cmra );
217        }
218    }
219
220    /**
221     * Convenience function
222     *
223     * @return bool
224     */
225    private function isOAuth2() {
226        return $this->oauthVersion === Consumer::OAUTH_VERSION_2;
227    }
228
229    /**
230     * @param int $approvalId
231     */
232    private function removeOAuth2AccessTokens( $approvalId ) {
233        $accessTokenRepository = new AccessTokenRepository();
234        $accessTokenRepository->deleteForApprovalId( $approvalId );
235    }
236}