Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.88% covered (warning)
51.88%
221 / 426
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConsumerSubmitControl
51.88% covered (warning)
51.88%
221 / 426
50.00% covered (danger)
50.00%
4 / 8
1866.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRequiredFields
80.70% covered (warning)
80.70%
92 / 114
0.00% covered (danger)
0.00%
0 / 1
33.63
 checkBasePermissions
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
9.36
 processAction
29.32% covered (danger)
29.32%
73 / 249
0.00% covered (danger)
0.00%
0 / 1
2007.76
 getLogTitle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeLogEntry
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 notify
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
4.11
 isSecureContext
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3namespace MediaWiki\Extension\OAuth\Control;
4
5use Composer\Semver\VersionParser;
6use Exception;
7use LogicException;
8use MediaWiki\Api\ApiMessage;
9use MediaWiki\Context\IContextSource;
10use MediaWiki\Extension\Notifications\Model\Event;
11use MediaWiki\Extension\OAuth\Backend\Consumer;
12use MediaWiki\Extension\OAuth\Backend\ConsumerAcceptance;
13use MediaWiki\Extension\OAuth\Backend\MWOAuthDataStore;
14use MediaWiki\Extension\OAuth\Backend\Utils;
15use MediaWiki\Extension\OAuth\Entity\ClientEntity;
16use MediaWiki\Extension\OAuth\OAuthServices;
17use MediaWiki\Json\FormatJson;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\Logging\ManualLogEntry;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Parser\Sanitizer;
22use MediaWiki\Registration\ExtensionRegistry;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Status\Status;
25use MediaWiki\Title\Title;
26use MediaWiki\User\User;
27use MediaWiki\WikiMap\WikiMap;
28use MWCryptRand;
29use StatusValue;
30use UnexpectedValueException;
31use Wikimedia\Rdbms\IDatabase;
32use Wikimedia\Rdbms\SelectQueryBuilder;
33
34/**
35 * (c) Aaron Schulz 2013, GPL
36 *
37 * @license GPL-2.0-or-later
38 */
39
40/**
41 * This handles the core logic of approving/disabling consumers
42 * from using particular user accounts
43 *
44 * This control can only be used on the management wiki
45 *
46 * @todo improve error messages
47 */
48class ConsumerSubmitControl extends SubmitControl {
49    /**
50     * Names of the actions that can be performed on a consumer. These are the same as the
51     * options in getRequiredFields().
52     * @var string[]
53     */
54    public static $actions = [ 'propose', 'update', 'approve', 'reject', 'disable', 'reenable' ];
55
56    /** @var IDatabase */
57    protected $dbw;
58
59    /**
60     * MySQL Blob Size is 2^16 - 1 = 65535 as per "L + 2 bytes, where L < 216" on
61     * https://dev.mysql.com/doc/refman/8.0/en/storage-requirements.html
62     */
63    private const BLOB_SIZE = 65535;
64
65    /**
66     * @param IContextSource $context
67     * @param array $params
68     * @param IDatabase $dbw Result of Utils::getOAuthDB( DB_PRIMARY )
69     */
70    public function __construct( IContextSource $context, array $params, IDatabase $dbw ) {
71        parent::__construct( $context, $params );
72        $this->dbw = $dbw;
73    }
74
75    /** @inheritDoc */
76    protected function getRequiredFields() {
77        $validateRsaKey = static function ( $s ) {
78            if ( trim( $s ) === '' ) {
79                return true;
80            }
81            if ( strlen( $s ) > self::BLOB_SIZE ) {
82                return false;
83            }
84            $key = openssl_pkey_get_public( $s );
85            if ( $key === false ) {
86                return false;
87            }
88            $info = openssl_pkey_get_details( $key );
89
90            return ( $info['type'] === OPENSSL_KEYTYPE_RSA );
91        };
92
93        $suppress = [ 'suppress' => '/^[01]$/' ];
94        $base = [
95            'consumerKey'  => '/^[0-9a-f]{32}$/',
96            'reason'       => '/^.{0,255}$/',
97            'changeToken'  => '/^[0-9a-f]{40}$/'
98        ];
99
100        $validateBlobSize = static function ( $s ) {
101            return strlen( $s ?? '' ) < self::BLOB_SIZE;
102        };
103
104        return [
105            // Proposer (application administrator) actions:
106            'propose' => [
107                'name' => '/^.{1,128}$/',
108                'version' => static function ( $s ) {
109                    if ( strlen( $s ) > 32 ) {
110                        return false;
111                    }
112                    $parser = new VersionParser();
113                    try {
114                        $parser->normalize( $s );
115                        return true;
116                    } catch ( UnexpectedValueException ) {
117                        return false;
118                    }
119                },
120                'oauthVersion' => static function ( $i ) {
121                    return in_array( $i, [ Consumer::OAUTH_VERSION_1, Consumer::OAUTH_VERSION_2 ] );
122                },
123                'callbackUrl' => static function ( $s, $vals ) {
124                    $isOAuth1 = (int)$vals['oauthVersion'] === Consumer::OAUTH_VERSION_1;
125                    $isOAuth2 = !$isOAuth1;
126                    $clientIsConfidential = $isOAuth1 || $vals['oauth2IsConfidential'];
127
128                    if ( strlen( $s ?? '' ) > 2000 ) {
129                        return false;
130                    } elseif ( $vals['ownerOnly'] ) {
131                        return true;
132                    }
133
134                    $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
135                    $urlParts = $urlUtils->parse( $s );
136                    if ( !$urlParts ) {
137                        return false;
138                    }
139                    $isCustomProtocol = !in_array( $urlParts['scheme'], [ '', 'http', 'https' ], true );
140
141                    if ( $isCustomProtocol ) {
142                        if ( $clientIsConfidential ) {
143                            // Custom protocols are handled by an application installed on the device;
144                            // so it cannot possibly be confidential.
145                            return StatusValue::newFatal(
146                                new ApiMessage( 'mwoauth-error-callback-url-custom-protocol-nonconfidential',
147                                    'invalid_callback_url' )
148                            );
149                        }
150                    } elseif ( $isOAuth2 && !self::isSecureContext( $urlParts ) ) {
151                        // The OAuth 2 spec requires an encrypted transport.
152                        return StatusValue::newFatal(
153                            new ApiMessage( 'mwoauth-error-callback-url-must-be-https', 'invalid_callback_url' )
154                        );
155                    } elseif ( $clientIsConfidential && WikiMap::getWikiFromUrl( $s ) ) {
156                        // Reduce noise from clueless people using Wikipedia's URL as callback
157                        // (except for public clients; it can be valid e.g. for gadgets).
158                        return StatusValue::newGood()->warning(
159                            new ApiMessage( 'mwoauth-error-callback-server-url', 'invalid_callback_url' )
160                        );
161                    } elseif ( ( $isOAuth2 || !$vals['callbackIsPrefix'] )
162                        && in_array( $urlParts['path'] ?? '', [ '', '/' ], true )
163                        && !( $urlParts['query'] ?? false )
164                        && !( $urlParts['fragment'] ?? false )
165                    ) {
166                        // Warn people using a bare domain name with no path or query part as
167                        // the exact callback URL. It is valid, but it's rare that they actually mean it.
168                        $message = $isOAuth1
169                            ? 'mwoauth-error-callback-bare-domain-oauth1'
170                            : 'mwoauth-error-callback-bare-domain-oauth2';
171                        return StatusValue::newGood()->warning(
172                            new ApiMessage( $message, 'invalid_callback_url' )
173                        );
174                    }
175                    return true;
176                },
177                'description' => $validateBlobSize,
178                'email' => static function ( $s ) {
179                    return Sanitizer::validateEmail( $s );
180                },
181                'wiki' => static function ( $s ) {
182                    global $wgConf;
183                    return ( $s === '*'
184                        || in_array( $s, $wgConf->getLocalDatabases() )
185                        || in_array( $s, Utils::getAllWikiNames() )
186                    );
187                },
188                'oauth2GrantTypes' => static function ( $a, $vals ) {
189                    if ( $vals['oauthVersion'] == Consumer::OAUTH_VERSION_1 ) {
190                        return true;
191                    }
192
193                    // OAuth 2 apps must have at least one grant type
194                    return count( $a ) > 0 && strlen( FormatJson::encode( $a ) ) <= self::BLOB_SIZE;
195                },
196                'granttype' => '/^(authonly|authonlyprivate|normal)$/',
197                'grants' => static function ( $s ) {
198                    if ( strlen( $s ) > self::BLOB_SIZE ) {
199                        return false;
200                    }
201                    $grants = FormatJson::decode( $s, true );
202                    return is_array( $grants ) && Utils::grantsAreValid( $grants );
203                },
204                'restrictions' => $validateBlobSize,
205                'rsaKey' => $validateRsaKey,
206                'agreement' => static function ( $s ) {
207                    return ( $s == true );
208                },
209            ],
210            'update' => array_merge( $base, [
211                'restrictions' => $validateBlobSize,
212                'rsaKey' => $validateRsaKey,
213                'resetSecret' => static function ( $s ) {
214                    return is_bool( $s );
215                },
216            ] ),
217            // Approver (project administrator) actions:
218            'approve'     => $base,
219            'reject'      => array_merge( $base, $suppress ),
220            'disable'     => array_merge( $base, $suppress ),
221            'reenable'    => $base
222        ];
223    }
224
225    /** @inheritDoc */
226    protected function checkBasePermissions() {
227        global $wgBlockDisablesLogin;
228        $user = $this->getUser();
229        $readOnlyMode = MediaWikiServices::getInstance()->getReadOnlyMode();
230        if ( !$user->getId() ) {
231            return $this->failure( 'not_logged_in', 'badaccess-group0' );
232        } elseif ( $user->isLocked() || ( $wgBlockDisablesLogin && $user->getBlock() ) ) {
233            return $this->failure( 'user_blocked', 'badaccess-group0' );
234        } elseif ( $readOnlyMode->isReadOnly() ) {
235            return $this->failure( 'readonly', 'readonlytext', $readOnlyMode->getReason() );
236        } elseif ( !Utils::isCentralWiki() ) {
237            // This logs consumer changes to the local logging table on the central wiki
238            throw new LogicException( "This can only be used from the OAuth management wiki." );
239        }
240        return $this->success();
241    }
242
243    /** @inheritDoc */
244    protected function processAction( $action ): Status {
245        $context = $this->getContext();
246        // proposer or admin
247        $user = $this->getUser();
248        $dbw = $this->dbw;
249
250        $centralUserId = Utils::getCentralIdFromLocalUser( $user );
251        if ( !$centralUserId ) {
252            return $this->failure( 'permission_denied', 'badaccess-group0' );
253        }
254
255        $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
256
257        switch ( $action ) {
258            case 'propose':
259                if ( !$permissionManager->userHasRight( $user, 'mwoauthproposeconsumer' ) ) {
260                    return $this->failure( 'permission_denied', 'badaccess-group0' );
261                } elseif ( !$user->isEmailConfirmed() ) {
262                    return $this->failure( 'email_not_confirmed', 'mwoauth-consumer-email-unconfirmed' );
263                } elseif ( $user->getEmail() !== $this->vals['email'] ) {
264                    // @TODO: allow any email and don't set emailAuthenticated below
265                    return $this->failure( 'email_mismatched', 'mwoauth-consumer-email-mismatched' );
266                }
267
268                if ( Consumer::newFromNameVersionUser(
269                    $dbw, $this->vals['name'], $this->vals['version'], $centralUserId
270                ) ) {
271                    return $this->failure( 'consumer_exists', 'mwoauth-consumer-alreadyexists' );
272                }
273
274                $wikiNames = Utils::getAllWikiNames();
275                $dbKey = array_search( $this->vals['wiki'], $wikiNames );
276                if ( $dbKey !== false ) {
277                    $this->vals['wiki'] = $dbKey;
278                }
279
280                $curVer = $dbw->newSelectQueryBuilder()
281                    ->select( 'oarc_version' )
282                    ->from( 'oauth_registered_consumer' )
283                    ->where( [ 'oarc_name' => $this->vals['name'], 'oarc_user_id' => $centralUserId ] )
284                    ->orderBy( 'oarc_registration', SelectQueryBuilder::SORT_DESC )
285                    ->forUpdate()
286                    ->caller( __METHOD__ )
287                    ->fetchField();
288                if ( $curVer !== false && version_compare( $curVer, $this->vals['version'], '>=' ) ) {
289                    return $this->failure( 'consumer_exists',
290                        'mwoauth-consumer-alreadyexistsversion', $curVer );
291                }
292
293                // Handle owner-only mode
294                if ( $this->vals['ownerOnly'] ) {
295                    $this->vals['callbackUrl'] = SpecialPage::getTitleFor( 'OAuth', 'verified' )
296                        ->getLocalURL();
297                    $this->vals['callbackIsPrefix'] = '';
298                    $stage = Consumer::STAGE_APPROVED;
299                } else {
300                    $stage = Consumer::STAGE_PROPOSED;
301                }
302
303                // Handle grant types
304                $grants = [];
305                switch ( $this->vals['granttype'] ) {
306                    case 'authonly':
307                        $grants = [ 'mwoauth-authonly' ];
308                        break;
309                    case 'authonlyprivate':
310                        $grants = [ 'mwoauth-authonlyprivate' ];
311                        break;
312                    case 'normal':
313                        $grants = array_unique( array_merge(
314                            // implied grants
315                            MediaWikiServices::getInstance()
316                                ->getGrantsInfo()
317                                ->getHiddenGrants(),
318                            FormatJson::decode( $this->vals['grants'], true )
319                        ) );
320                        break;
321                }
322
323                $now = wfTimestampNow();
324                $cmr = Consumer::newFromArray(
325                    [
326                        'id'                 => null,
327                        'consumerKey'        => MWCryptRand::generateHex( 32 ),
328                        'userId'             => $centralUserId,
329                        'email'              => $user->getEmail(),
330                        'emailAuthenticated' => $now,
331                        'developerAgreement' => 1,
332                        'secretKey'          => MWCryptRand::generateHex( 32 ),
333                        'registration'       => $now,
334                        'stage'              => $stage,
335                        'stageTimestamp'     => $now,
336                        'grants'             => $grants,
337                        'restrictions'       => $this->vals['restrictions'],
338                        'deleted'            => 0
339                    ] + $this->vals
340                );
341
342                $logAction = 'propose';
343                $oauthServices = OAuthServices::wrap( MediaWikiServices::getInstance() );
344                $workflow = $oauthServices->getWorkflow();
345                $autoApproved = $workflow->consumerCanBeAutoApproved( $cmr );
346                if ( $cmr->getOwnerOnly() ) {
347                    // FIXME the stage is set a few dozen lines earlier - should simplify this
348                    $logAction = 'create-owner-only';
349                } elseif ( $autoApproved ) {
350                    $cmr->setField( 'stage', Consumer::STAGE_APPROVED );
351                    $logAction = 'propose-autoapproved';
352                }
353
354                $cmr->save( $dbw );
355                $this->makeLogEntry( Utils::getCentralWikiDB(), $cmr, $logAction, $user, $this->vals['description'] );
356                if ( !$cmr->getOwnerOnly() && !$autoApproved ) {
357                    // Notify admins if the consumer needs to be approved.
358                    if ( $cmr->getStage() === Consumer::STAGE_PROPOSED ) {
359                        $this->notify( $cmr, $user, $action, '' );
360                    }
361                }
362
363                // If it's owner-only, automatically accept it for the user too.
364                $accessToken = null;
365                if ( $cmr->getOwnerOnly() ) {
366                    $accessToken = MWOAuthDataStore::newToken();
367                    $cmra = ConsumerAcceptance::newFromArray( [
368                        'id'           => null,
369                        'wiki'         => $cmr->getWiki(),
370                        'userId'       => $centralUserId,
371                        'consumerId'   => $cmr->getId(),
372                        'accessToken'  => $accessToken->key,
373                        'accessSecret' => $accessToken->secret,
374                        'grants'       => $cmr->getGrants(),
375                        'accepted'     => $now,
376                        'oauth_version' => $cmr->getOAuthVersion()
377                    ] );
378                    $cmra->save( $dbw );
379                    if ( $cmr instanceof ClientEntity ) {
380                        // OAuth2 client
381                        try {
382                            $accessToken = $cmr->getOwnerOnlyAccessToken( $cmra );
383                        } catch ( Exception $ex ) {
384                            return $this->failure(
385                                'unable_to_retrieve_access_token',
386                                'mwoauth-oauth2-unable-to-retrieve-access-token',
387                                $ex->getMessage()
388                            );
389                        }
390                    }
391                }
392
393                return $this->success( [ 'consumer' => $cmr, 'accessToken' => $accessToken ] );
394            case 'update':
395                if ( !$permissionManager->userHasRight( $user, 'mwoauthupdateownconsumer' ) ) {
396                    return $this->failure( 'permission_denied', 'badaccess-group0' );
397                }
398
399                $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] );
400                if ( !$cmr ) {
401                    return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' );
402                } elseif ( $cmr->getUserId() !== $centralUserId ) {
403                    return $this->failure( 'permission_denied', 'badaccess-group0' );
404                } elseif (
405                    $cmr->getStage() !== Consumer::STAGE_APPROVED
406                    && $cmr->getStage() !== Consumer::STAGE_PROPOSED
407                ) {
408                    return $this->failure( 'permission_denied', 'badaccess-group0' );
409                } elseif ( $cmr->getDeleted()
410                    && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
411                    return $this->failure( 'permission_denied', 'badaccess-group0' );
412                } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) {
413                    return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' );
414                }
415
416                $cmr->setFields( [
417                    'rsaKey'       => $this->vals['rsaKey'],
418                    'restrictions' => $this->vals['restrictions'],
419                    'secretKey'    => $this->vals['resetSecret']
420                        ? MWCryptRand::generateHex( 32 )
421                        : $cmr->getSecretKey(),
422                ] );
423
424                // Log if something actually changed
425                if ( $cmr->save( $dbw ) ) {
426                    $this->makeLogEntry( Utils::getCentralWikiDB(), $cmr, $action, $user, $this->vals['reason'] );
427                    $this->notify( $cmr, $user, $action, $this->vals['reason'] );
428                }
429
430                $accessToken = null;
431                if ( $cmr->getOwnerOnly() && $this->vals['resetSecret'] ) {
432                    $cmra = $cmr->getCurrentAuthorization( $user, WikiMap::getCurrentWikiId() );
433                    $accessToken = MWOAuthDataStore::newToken();
434                    $fields = [
435                        'wiki'         => $cmr->getWiki(),
436                        'userId'       => $centralUserId,
437                        'consumerId'   => $cmr->getId(),
438                        'accessSecret' => $accessToken->secret,
439                        'grants'       => $cmr->getGrants(),
440                    ];
441
442                    if ( $cmra ) {
443                        $accessToken->key = $cmra->getAccessToken();
444                        $cmra->setFields( $fields );
445                    } else {
446                        $cmra = ConsumerAcceptance::newFromArray( $fields + [
447                            'id'           => null,
448                            'accessToken'  => $accessToken->key,
449                            'accepted'     => wfTimestampNow(),
450                        ] );
451                    }
452                    $cmra->save( $dbw );
453                    if ( $cmr instanceof ClientEntity ) {
454                        $accessToken = $cmr->getOwnerOnlyAccessToken( $cmra, true );
455                    }
456                }
457
458                return $this->success( [ 'consumer' => $cmr, 'accessToken' => $accessToken ] );
459            case 'approve':
460                if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) {
461                    return $this->failure( 'permission_denied', 'badaccess-group0' );
462                }
463
464                $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] );
465                if ( !$cmr ) {
466                    return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' );
467                } elseif ( !in_array( $cmr->getStage(), [
468                    Consumer::STAGE_PROPOSED,
469                    Consumer::STAGE_EXPIRED,
470                    Consumer::STAGE_REJECTED,
471                ] ) ) {
472                    return $this->failure( 'not_proposed', 'mwoauth-consumer-not-proposed' );
473                } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
474                    return $this->failure( 'permission_denied', 'badaccess-group0' );
475                } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) {
476                    return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' );
477                }
478
479                $cmr->setFields( [
480                    'stage'          => Consumer::STAGE_APPROVED,
481                    'stageTimestamp' => wfTimestampNow(),
482                    'deleted'        => 0 ] );
483
484                // Log if something actually changed
485                if ( $cmr->save( $dbw ) ) {
486                    $this->makeLogEntry( Utils::getCentralWikiDB(), $cmr, $action, $user, $this->vals['reason'] );
487                    $this->notify( $cmr, $user, $action, $this->vals['reason'] );
488                }
489
490                return $this->success( $cmr );
491            case 'reject':
492                if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) {
493                    return $this->failure( 'permission_denied', 'badaccess-group0' );
494                }
495
496                $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] );
497                if ( !$cmr ) {
498                    return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' );
499                } elseif ( $cmr->getStage() !== Consumer::STAGE_PROPOSED ) {
500                    return $this->failure( 'not_proposed', 'mwoauth-consumer-not-proposed' );
501                } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
502                    return $this->failure( 'permission_denied', 'badaccess-group0' );
503                } elseif ( $this->vals['suppress'] && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
504                    return $this->failure( 'permission_denied', 'badaccess-group0' );
505                } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) {
506                    return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' );
507                }
508
509                $cmr->setFields( [
510                    'stage'          => Consumer::STAGE_REJECTED,
511                    'stageTimestamp' => wfTimestampNow(),
512                    'deleted'        => $this->vals['suppress'] ] );
513
514                // Log if something actually changed
515                if ( $cmr->save( $dbw ) ) {
516                    $this->makeLogEntry( Utils::getCentralWikiDB(), $cmr, $action, $user, $this->vals['reason'] );
517                    $this->notify( $cmr, $user, $action, $this->vals['reason'] );
518                }
519
520                return $this->success( $cmr );
521            case 'disable':
522                if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) {
523                    return $this->failure( 'permission_denied', 'badaccess-group0' );
524                } elseif ( $this->vals['suppress'] && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
525                    return $this->failure( 'permission_denied', 'badaccess-group0' );
526                }
527
528                $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] );
529                if ( !$cmr ) {
530                    return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' );
531                } elseif ( $cmr->getStage() !== Consumer::STAGE_APPROVED
532                && $cmr->getDeleted() == $this->vals['suppress']
533                ) {
534                    return $this->failure( 'not_approved', 'mwoauth-consumer-not-approved' );
535                } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
536                    return $this->failure( 'permission_denied', 'badaccess-group0' );
537                } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) {
538                    return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' );
539                }
540
541                $cmr->setFields( [
542                    'stage'          => Consumer::STAGE_DISABLED,
543                    'stageTimestamp' => wfTimestampNow(),
544                    'deleted'        => $this->vals['suppress'] ] );
545
546                // Log if something actually changed
547                if ( $cmr->save( $dbw ) ) {
548                    $this->makeLogEntry( Utils::getCentralWikiDB(), $cmr, $action, $user, $this->vals['reason'] );
549                    $this->notify( $cmr, $user, $action, $this->vals['reason'] );
550                }
551
552                return $this->success( $cmr );
553            case 'reenable':
554                if ( !$permissionManager->userHasRight( $user, 'mwoauthmanageconsumer' ) ) {
555                    return $this->failure( 'permission_denied', 'badaccess-group0' );
556                }
557
558                $cmr = Consumer::newFromKey( $dbw, $this->vals['consumerKey'] );
559                if ( !$cmr ) {
560                    return $this->failure( 'invalid_consumer_key', 'mwoauth-invalid-consumer-key' );
561                } elseif ( $cmr->getStage() !== Consumer::STAGE_DISABLED ) {
562                    return $this->failure( 'not_disabled', 'mwoauth-consumer-not-disabled' );
563                } elseif ( $cmr->getDeleted() && !$permissionManager->userHasRight( $user, 'mwoauthsuppress' ) ) {
564                    return $this->failure( 'permission_denied', 'badaccess-group0' );
565                } elseif ( !$cmr->checkChangeToken( $context, $this->vals['changeToken'] ) ) {
566                    return $this->failure( 'change_conflict', 'mwoauth-consumer-conflict' );
567                }
568
569                $cmr->setFields( [
570                    'stage'          => Consumer::STAGE_APPROVED,
571                    'stageTimestamp' => wfTimestampNow(),
572                    'deleted'        => 0 ] );
573
574                // Log if something actually changed
575                if ( $cmr->save( $dbw ) ) {
576                    $this->makeLogEntry( Utils::getCentralWikiDB(), $cmr, $action, $user, $this->vals['reason'] );
577                    $this->notify( $cmr, $user, $action, $this->vals['reason'] );
578                }
579
580                return $this->success( $cmr );
581        }
582    }
583
584    /**
585     * @param IDatabase $db
586     * @param int $userId
587     * @return Title
588     */
589    protected function getLogTitle( IDatabase $db, $userId ) {
590        $name = Utils::getCentralUserNameFromId( $userId );
591        return Title::makeTitleSafe( NS_USER, $name );
592    }
593
594    /**
595     * @param IDatabase $dbw
596     * @param Consumer $cmr
597     * @param string $action
598     * @param User $performer
599     * @param string $comment
600     */
601    protected function makeLogEntry(
602        $dbw, Consumer $cmr, $action, User $performer, $comment
603    ) {
604        $logEntry = new ManualLogEntry( 'mwoauthconsumer', $action );
605        $logEntry->setPerformer( $performer );
606        $target = $this->getLogTitle( $dbw, $cmr->getUserId() );
607        $logEntry->setTarget( $target );
608        $logEntry->setComment( $comment );
609        $logEntry->setParameters( [ '4:consumer' => $cmr->getConsumerKey() ] );
610        $logEntry->setRelations( [
611            'OAuthConsumer' => [ $cmr->getConsumerKey() ]
612        ] );
613        $logEntry->insert( $dbw );
614
615        LoggerFactory::getInstance( 'OAuth' )->info(
616            '{user} performed action {action} on consumer {consumer}', [
617                'action' => $action,
618                'user' => $performer->getName(),
619                'consumer' => $cmr->getConsumerKey(),
620                'target' => $target->getText(),
621                'comment' => $comment,
622                'clientip' => $this->getContext()->getRequest()->getIP(),
623            ]
624        );
625    }
626
627    /**
628     * @param Consumer $cmr Consumer which was the subject of the action
629     * @param User $user User who performed the action
630     * @param string $actionType
631     * @param string $comment
632     */
633    protected function notify( $cmr, $user, $actionType, $comment ) {
634        if ( !in_array( $actionType, self::$actions, true ) ) {
635            throw new LogicException( "Invalid action type: $actionType" );
636        } elseif ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) {
637            return;
638        } elseif ( !Utils::isCentralWiki() ) {
639            # sanity; should never get here on a replica wiki
640            return;
641        }
642
643        Event::create( [
644            'type' => 'oauth-app-' . $actionType,
645            'agent' => $user,
646            'extra' => [
647                'action' => $actionType,
648                'app-key' => $cmr->getConsumerKey(),
649                'owner-id' => $cmr->getUserId(),
650                'comment' => $comment,
651            ],
652        ] );
653    }
654
655    /**
656     * Decide whether the given (parsed) URL corresponds to a secure context.
657     * (This is only an approximation of the algorithm browsers use,
658     * since some considerations such as frames don't apply here.)
659     *
660     * @see https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
661     *
662     * @param array $urlParts As returned by {@link UrlUtils::parse()}.
663     * @return bool
664     */
665    protected static function isSecureContext( array $urlParts ): bool {
666        if ( $urlParts['scheme'] === 'https' ) {
667            return true;
668        }
669
670        $host = $urlParts['host'];
671        if ( $host === 'localhost'
672            || $host === '127.0.0.1'
673            || $host === '[::1]'
674            || str_ends_with( $host, '.localhost' )
675            // The wmftest.{com,net,org} domains hosted by the Wikimedia
676            // Foundation include a '*.local IN A 127.0.0.1' that is used in
677            // some local development environments.
678            || str_ends_with( $host, '.local.wmftest.com' )
679            || str_ends_with( $host, '.local.wmftest.net' )
680            || str_ends_with( $host, '.local.wmftest.org' )
681        ) {
682            return true;
683        }
684
685        return false;
686    }
687}