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