Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
76.54% covered (warning)
76.54%
447 / 584
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMWOAuthConsumerRegistration
76.54% covered (warning)
76.54%
447 / 584
50.00% covered (danger)
50.00%
5 / 10
153.54
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 userCanExecute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 displayRestrictionError
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
72.50% covered (warning)
72.50%
145 / 200
0.00% covered (danger)
0.00%
0 / 1
70.63
 addSubtitleLinks
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
9
 formatRow
95.65% covered (success)
95.65%
66 / 69
0.00% covered (danger)
0.00%
0 / 1
5
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 proposeOAuth
74.70% covered (warning)
74.70%
189 / 253
0.00% covered (danger)
0.00%
0 / 1
20.14
 fillDefaultFields
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\OAuth\Frontend\SpecialPages;
4
5/**
6 * (c) Aaron Schulz 2013, GPL
7 *
8 * @license GPL-2.0-or-later
9 */
10
11use InvalidArgumentException;
12use MediaWiki\Exception\ErrorPageError;
13use MediaWiki\Exception\PermissionsError;
14use MediaWiki\Exception\UserBlockedError;
15use MediaWiki\Extension\OAuth\Backend\Consumer;
16use MediaWiki\Extension\OAuth\Backend\Utils;
17use MediaWiki\Extension\OAuth\Control\ConsumerAccessControl;
18use MediaWiki\Extension\OAuth\Control\ConsumerSubmitControl;
19use MediaWiki\Extension\OAuth\Entity\ClientEntity;
20use MediaWiki\Extension\OAuth\Frontend\Pagers\ListMyConsumersPager;
21use MediaWiki\Extension\OAuth\Frontend\UIUtils;
22use MediaWiki\Extension\OAuth\OAuthServices;
23use MediaWiki\Html\Html;
24use MediaWiki\HTMLForm\Field\HTMLHiddenField;
25use MediaWiki\HTMLForm\Field\HTMLRestrictionsField;
26use MediaWiki\HTMLForm\HTMLForm;
27use MediaWiki\Json\FormatJson;
28use MediaWiki\Logging\LogEventsList;
29use MediaWiki\Logging\LogPage;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Message\Message;
32use MediaWiki\Permissions\GrantsInfo;
33use MediaWiki\Permissions\GrantsLocalization;
34use MediaWiki\Permissions\PermissionManager;
35use MediaWiki\SpecialPage\SpecialPage;
36use MediaWiki\Status\Status;
37use MediaWiki\User\User;
38use MediaWiki\Utils\MWRestrictions;
39use MediaWiki\Utils\UrlUtils;
40use MediaWiki\WikiMap\WikiMap;
41use stdClass;
42use Wikimedia\Rdbms\IReadableDatabase;
43use Wikimedia\Timestamp\TimestampFormat;
44
45/**
46 * Page that has registration request form and consumer update form
47 */
48class SpecialMWOAuthConsumerRegistration extends SpecialPage {
49    public function __construct(
50        private readonly PermissionManager $permissionManager,
51        private readonly GrantsInfo $grantsInfo,
52        private readonly GrantsLocalization $grantsLocalization,
53        private readonly UrlUtils $urlUtils,
54    ) {
55        parent::__construct( 'OAuthConsumerRegistration' );
56    }
57
58    /** @inheritDoc */
59    public function doesWrites() {
60        return true;
61    }
62
63    /** @inheritDoc */
64    public function userCanExecute( User $user ) {
65        return $user->isEmailConfirmed();
66    }
67
68    /** @inheritDoc */
69    public function displayRestrictionError() {
70        throw new PermissionsError( null, [ 'mwoauthconsumerregistration-need-emailconfirmed' ] );
71    }
72
73    /** @inheritDoc */
74    public function execute( $par ) {
75        $this->setHeaders();
76        $this->getOutput()->disallowUserJs();
77        $this->addHelpLink( 'Help:OAuth' );
78
79        if ( !Utils::isCentralWiki() ) {
80            $this->getOutput()->addWikiMsg( 'mwoauth-consumers-central-wiki' );
81            $wiki = WikiMap::getWiki( Utils::getCentralWiki() ?: WikiMap::getCurrentWikiId() );
82            if ( $wiki ) {
83                $this->getOutput()->addHTML( Html::element( 'a', [
84                    // Cross-wiki, so don't localize
85                    'href' => $wiki->getUrl( 'Special:OAuthConsumerRegistration' . ( $par !== null ? "/$par" : '' ) ),
86                ], $this->msg( 'mwoauth-consumers-central-wiki-go', $wiki->getDisplayName() )->text() ) );
87            }
88            return;
89        }
90
91        $this->requireNamedUser( 'mwoauth-named-account-required-reason' );
92
93        $this->checkPermissions();
94
95        $consumerRepository = OAuthServices::wrap( MediaWikiServices::getInstance() )->getConsumerRepository();
96        $request = $this->getRequest();
97        $user = $this->getUser();
98        $centralUserId = Utils::getCentralIdFromLocalUser( $user );
99
100        // Redirect to HTTPs if attempting to access this page via HTTP.
101        // Proposals and updates to consumers can involve sending new secrets.
102        if ( $this->getConfig()->get( 'MWOAuthSecureTokenTransfer' )
103            && $request->getProtocol() === 'http'
104            && str_starts_with( $this->urlUtils->expand( '/', PROTO_HTTPS ) ?? '', 'https://' )
105        ) {
106            $redirUrl = str_replace( 'http://', 'https://', $request->getFullRequestURL() );
107            $this->getOutput()->redirect( $redirUrl );
108            $this->getOutput()->addVaryHeader( 'X-Forwarded-Proto' );
109            return;
110        }
111
112        $this->getOutput()->addModules( 'mediawiki.special' );
113
114        $block = $user->getBlock();
115        if ( $block ) {
116            throw new UserBlockedError( $block );
117        }
118        $this->checkReadOnly();
119
120        // Format is Special:
121        // OAuthConsumerRegistration[/propose/<oauth1a|oauth2>|/list|/update/<consumer key>|/copy/<consumer key>]
122        $navigation = $par !== null ? explode( '/', $par ) : [];
123        $action = $navigation[0] ?? '';
124        $subPage = $navigation[1] ?? '';
125
126        if ( $this->getConfig()->get( 'MWOAuthReadOnly' ) && $action !== 'list' ) {
127            throw new ErrorPageError( 'mwoauth-error', 'mwoauth-db-readonly' );
128        }
129
130        switch ( $action ) {
131            case 'propose':
132                if ( !$this->permissionManager->userHasRight( $user, 'mwoauthproposeconsumer' ) ) {
133                    throw new PermissionsError( 'mwoauthproposeconsumer' );
134                }
135
136                if ( $subPage === '' ) {
137                    $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-propose-text' );
138                    break;
139                }
140
141                $allWikis = Utils::getAllWikiNames();
142                $showGrants = $this->grantsInfo->getValidGrants();
143                if ( $subPage === 'oauth2' ) {
144                    $this->proposeOAuth( Consumer::OAUTH_VERSION_2, $user, $allWikis, $showGrants );
145                    break;
146                } elseif ( $subPage === 'oauth1a' ) {
147                    $this->proposeOAuth( Consumer::OAUTH_VERSION_1, $user, $allWikis, $showGrants );
148                    break;
149                } else {
150                    $this->getOutput()->redirect( $this->getPageTitle( 'propose' )->getLocalURL() );
151                }
152                break;
153            case 'copy':
154                if ( !$this->permissionManager->userHasRight( $user, 'mwoauthproposeconsumer' ) ) {
155                    throw new PermissionsError( 'mwoauthproposeconsumer' );
156                }
157
158                $cmrAc = ConsumerAccessControl::wrap(
159                    $consumerRepository->getByKey( $subPage ), $this->getContext() );
160                if ( !$cmrAc ) {
161                    $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' );
162                    break;
163                } elseif ( $cmrAc->getDAO()->getUserId() !== $centralUserId ) {
164                    // Do not allow copying another user's consumer
165                    $this->getOutput()->addWikiMsg( 'mwoauth-consumer-not-owned-by-user' );
166                    break;
167                }
168
169                $allWikis = Utils::getAllWikiNames();
170                $showGrants = $this->grantsInfo->getValidGrants();
171                $this->proposeOAuth(
172                    $cmrAc->getDAO()->getOAuthVersion(), $user, $allWikis, $showGrants, $cmrAc->getDAO()
173                );
174                break;
175            case 'update':
176                if ( !$this->permissionManager->userHasRight( $user, 'mwoauthupdateownconsumer' ) ) {
177                    throw new PermissionsError( 'mwoauthupdateownconsumer' );
178                }
179
180                $cmrAc = ConsumerAccessControl::wrap(
181                    $consumerRepository->getByKey( $subPage ), $this->getContext() );
182                if ( !$cmrAc ) {
183                    $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' );
184                    break;
185                } elseif ( $cmrAc->getDAO()->getDeleted()
186                    && !$this->permissionManager->userHasRight( $user, 'mwoauthviewsuppressed' )
187                ) {
188                    throw new PermissionsError( 'mwoauthviewsuppressed' );
189                } elseif ( $cmrAc->getDAO()->getUserId() !== $centralUserId ) {
190                    // Do not show private information to other users
191                    $this->getOutput()->addWikiMsg( 'mwoauth-invalid-consumer-key' );
192                    break;
193                } elseif ( $cmrAc->getDAO()->isConfigurationBased() ) {
194                    $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-error-configbased' );
195                    break;
196                }
197                $oldSecretKey = $cmrAc->getDAO()->getSecretKey();
198
199                $dbw = Utils::getOAuthDB( DB_PRIMARY );
200                $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw );
201                $form = HTMLForm::factory( 'ooui',
202                    $control->registerValidators( [
203                        'info' => [
204                            'type' => 'info',
205                            'raw' => true,
206                            'default' => UIUtils::generateInfoTable( [
207                                'mwoauth-consumer-name' => $cmrAc->getName(),
208                                'mwoauth-consumer-version' => $cmrAc->getVersion(),
209                                'mwoauth-oauth-version' => $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ?
210                                    $this->msg( 'mwoauth-oauth-version-2' )->text() :
211                                    $this->msg( 'mwoauth-oauth-version-1' )->text(),
212                                'mwoauth-consumer-key' => $cmrAc->getConsumerKey(),
213                                'mwoauth-consumer-wiki' => $cmrAc->getWikiName(),
214                            ], $this->getContext() ),
215                        ],
216                        'restrictions' => [
217                            'class' => HTMLRestrictionsField::class,
218                            'required' => true,
219                            'default' => $cmrAc->getDAO()->getRestrictions(),
220                        ],
221                        'resetSecret' => [
222                            'type' => 'check',
223                            'label-message' => 'mwoauthconsumerregistration-resetsecretkey',
224                            'default' => false,
225                        ],
226                        'rsaKey' => $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_1 ? [
227                            'type' => 'textarea',
228                            'label-message' => 'mwoauth-consumer-rsakey',
229                            'required' => false,
230                            'default' => $cmrAc->getDAO()->getRsaKey(),
231                            'rows' => 5,
232                        ] : [
233                            'type' => 'hidden',
234                            'default' => '',
235                        ],
236                        'reason' => [
237                            'type' => 'text',
238                            'label-message' => 'mwoauth-consumer-reason',
239                            'required' => !$cmrAc->getOwnerOnly(),
240                        ],
241                        'consumerKey' => [
242                            'type' => 'hidden',
243                            'default' => $cmrAc->getConsumerKey(),
244                        ],
245                        'changeToken' => [
246                            'type'    => 'hidden',
247                            'default' => $cmrAc->getDAO()->getChangeToken( $this->getContext() ),
248                        ],
249                        'action' => [
250                            'type'    => 'hidden',
251                                'default' => 'update'
252                        ]
253                    ] ),
254                    $this->getContext()
255                );
256                $form->setSubmitCallback(
257                    static function ( array $data ) use ( $control ) {
258                        $control->setInputParameters( $data );
259                        return $control->submit();
260                    }
261                );
262                $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-update-legend' );
263                $form->setSubmitTextMsg( 'mwoauthconsumerregistration-update-submit' );
264                $form->addPreHtml(
265                    $this->msg( 'mwoauthconsumerregistration-update-text' )->parseAsBlock() );
266
267                $status = $form->show();
268                if ( $status instanceof Status && $status->isOK() ) {
269                    /** @var Consumer $cmr */
270                    $cmr = $status->value['result']['consumer'];
271                    $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-updated' );
272                    $curSecretKey = $cmr->getSecretKey();
273                    // token reset?
274                    if ( $oldSecretKey !== $curSecretKey ) {
275                        if ( $cmr->getOwnerOnly() ) {
276                            $accessToken = $status->value['result']['accessToken'];
277                            if ( $cmr->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ) {
278                                // If we just add raw AT to the page, it would go 3000px wide
279                                $accessToken = Html::element( 'span', [
280                                    'style' => 'overflow-wrap: break-word'
281                                ], (string)$accessToken );
282
283                                $this->getOutput()->addWikiMsg(
284                                    'mwoauthconsumerregistration-secretreset-owner-only-oauth2',
285                                    $cmr->getConsumerKey(),
286                                    Utils::hmacDBSecret( $cmr->getSecretKey() ),
287                                    Message::rawParam( $accessToken )
288                                );
289                            } else {
290                                $this->getOutput()->addWikiMsg(
291                                    'mwoauthconsumerregistration-secretreset-owner-only-oauth1',
292                                    $cmr->getConsumerKey(),
293                                    Utils::hmacDBSecret( $curSecretKey ),
294                                    $accessToken->key,
295                                    Utils::hmacDBSecret( $accessToken->secret )
296                                );
297                            }
298                        } else {
299                            $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-secretreset',
300                                Utils::hmacDBSecret( $curSecretKey ) );
301                        }
302                    }
303                    $this->getOutput()->returnToMain();
304                } else {
305                    $out = $this->getOutput();
306                    // Show all of the status updates
307                    $logPage = new LogPage( 'mwoauthconsumer' );
308                    $out->addHTML( Html::element( 'h2', [], $logPage->getName()->text() ) );
309                    LogEventsList::showLogExtract( $out, 'mwoauthconsumer', '', '', [
310                        'conds'  => [
311                            'ls_field' => 'OAuthConsumer',
312                            'ls_value' => $cmrAc->getConsumerKey(),
313                        ],
314                        'flags'  => LogEventsList::NO_EXTRA_USER_LINKS,
315                    ] );
316                }
317                break;
318            case 'list':
319                $pager = new ListMyConsumersPager( $this, [], $centralUserId );
320                if ( $pager->getNumRows() ) {
321                    $this->getOutput()->addHTML( $pager->getNavigationBar() );
322                    $this->getOutput()->addHTML( $pager->getBody() );
323                    $this->getOutput()->addHTML( $pager->getNavigationBar() );
324                } else {
325                    $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-none" );
326                }
327                # Every 30th view, prune old deleted items
328                if ( mt_rand( 0, 29 ) == 0 ) {
329                    Utils::runAutoMaintenance( Utils::getOAuthDB( DB_PRIMARY ) );
330                }
331                break;
332            default:
333                $this->getOutput()->addWikiMsg( 'mwoauthconsumerregistration-maintext' );
334        }
335
336        $this->addSubtitleLinks( $action, $subPage );
337
338        $this->getOutput()->addModuleStyles( 'ext.MWOAuth.styles' );
339    }
340
341    /**
342     * Show navigation links
343     *
344     * @param string $action
345     * @param string $subPage
346     * @return void
347     */
348    protected function addSubtitleLinks( $action, $subPage ) {
349        $listLinks = [];
350        if ( $action === 'propose' && $subPage ) {
351            if ( $subPage === 'oauth1a' ) {
352                $listLinks[] = $this->msg( 'mwoauthconsumerregistration-propose-oauth1' )->escaped();
353                $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
354                    $this->getPageTitle( 'propose/oauth2' ),
355                    $this->msg( 'mwoauthconsumerregistration-propose-oauth2' )->text()
356                );
357            } elseif ( $subPage === 'oauth2' ) {
358                $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
359                    $this->getPageTitle( 'propose/oauth1a' ),
360                    $this->msg( 'mwoauthconsumerregistration-propose-oauth1' )->text()
361                );
362                $listLinks[] = $this->msg( 'mwoauthconsumerregistration-propose-oauth2' )->escaped();
363            }
364        } else {
365            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
366                $this->getPageTitle( 'propose/oauth1a' ),
367                $this->msg( 'mwoauthconsumerregistration-propose-oauth1' )->text()
368            );
369            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
370                $this->getPageTitle( 'propose/oauth2' ),
371                $this->msg( 'mwoauthconsumerregistration-propose-oauth2' )->text()
372            );
373        }
374        if ( $subPage || $action !== 'list' ) {
375            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
376                $this->getPageTitle( 'list' ),
377                $this->msg( 'mwoauthconsumerregistration-list' )->text()
378            );
379        } else {
380            $listLinks[] = $this->msg( 'mwoauthconsumerregistration-list' )->escaped();
381        }
382        if ( $subPage && $action === 'update' ) {
383            $listLinks[] = $this->getLinkRenderer()->makeKnownLink(
384                SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$subPage" ),
385                $this->msg( 'mwoauthconsumer-consumer-view' )->text()
386            );
387        }
388
389        $linkHtml = $this->getLanguage()->pipeList( $listLinks );
390
391        $viewall = $this->msg( 'parentheses' )->rawParams(
392            $this->getLinkRenderer()->makeKnownLink(
393                $this->getPageTitle(),
394                $this->msg( 'mwoauthconsumerregistration-main' )->text()
395            )
396        )->escaped();
397
398        $this->getOutput()->setSubtitle(
399            "<strong>" . $this->msg( 'mwoauthconsumerregistration-navigation' )->escaped() .
400            "</strong> [{$linkHtml}] <strong>{$viewall}</strong>" );
401    }
402
403    /**
404     * @param IReadableDatabase $db
405     * @param stdClass $row
406     * @return string
407     */
408    public function formatRow( IReadableDatabase $db, $row ) {
409        $consumerRepository = OAuthServices::wrap( MediaWikiServices::getInstance() )->getConsumerRepository();
410        $cmrAc = ConsumerAccessControl::wrap(
411            $consumerRepository->newFromRow( $row ), $this->getContext() );
412        $cmrKey = $cmrAc->getConsumerKey();
413
414        $links = [];
415        $links[] = $this->getLinkRenderer()->makeKnownLink(
416            SpecialPage::getTitleFor( 'OAuthListConsumers', "view/$cmrKey" ),
417            $this->msg( 'mwoauthlistconsumers-view' )->text()
418        );
419        if ( !$cmrAc->getDAO()->isConfigurationBased() ) {
420            $links[] = $this->getLinkRenderer()->makeKnownLink(
421                $this->getPageTitle( 'update/' . $cmrKey ),
422                $this->msg( 'mwoauthconsumerregistration-manage' )->text()
423            );
424            $links[] = $this->getLinkRenderer()->makeKnownLink(
425                $this->getPageTitle( 'copy/' . $cmrKey ),
426                $this->msg( 'mwoauthconsumerregistration-copy' )->text()
427            );
428        }
429
430        $links = $this->getLanguage()->pipeList( $links );
431
432        $time = htmlspecialchars( $this->getLanguage()->timeanddate(
433            wfTimestamp( TimestampFormat::MW, $cmrAc->getRegistration() ), true ) );
434
435        $stageKey = Consumer::$stageNames[$cmrAc->getStage()];
436        $encStageKey = htmlspecialchars( $stageKey );
437
438        $lang = $this->getLanguage();
439        $oauthVersionMessage = $cmrAc->getOAuthVersion() === Consumer::OAUTH_VERSION_2 ?
440            $this->msg( 'mwoauth-oauth-version-2' )->text() :
441            $this->msg( 'mwoauth-oauth-version-1' )->text();
442        $data = [
443            'mwoauthconsumerregistration-name' => $cmrAc->escapeForHtml( $cmrAc->getNameAndVersion() ),
444            'mwoauth-oauth-version' => $cmrAc->escapeForHtml( $oauthVersionMessage ),
445            'mwoauthconsumerregistration-description' => $cmrAc->escapeForHtml(
446                $cmrAc->get( 'description', static function ( $s ) use ( $lang ) {
447                    return $lang->truncateForVisual( $s, 10024 );
448                } )
449            ),
450            'mwoauthconsumerregistration-wiki' => $cmrAc->escapeForHtml( $cmrAc->getWikiName() ),
451            'mwoauthconsumerregistration-consumerkey' => $cmrAc->escapeForHtml( $cmrAc->getConsumerKey() ),
452        ];
453        if ( $cmrAc->getDAO()->isConfigurationBased() ) {
454            $data += [
455                'mwoauthconsumerregistration-stage' => $this->msg( 'mwoauth-configuration-based-notice' )->escaped(),
456            ];
457        } else {
458            // Show last log entry (@TODO: title namespace?)
459            // @TODO: inject DB
460            $logHtml = '';
461            LogEventsList::showLogExtract( $logHtml, 'mwoauthconsumer', '', '', [
462                'conds'  => [
463                    'ls_field' => 'OAuthConsumer',
464                    'ls_value' => $cmrAc->getConsumerKey(),
465                ],
466                'lim'    => 1,
467                'flags'  => LogEventsList::NO_EXTRA_USER_LINKS,
468            ] );
469
470            $data += [
471                // Messages: mwoauth-consumer-stage-proposed, mwoauth-consumer-stage-rejected,
472                // mwoauth-consumer-stage-expired, mwoauth-consumer-stage-approved,
473                // mwoauth-consumer-stage-disabled, mwoauth-consumer-stage-configuration-based
474                'mwoauthconsumerregistration-stage' =>
475                    $this->msg( "mwoauth-consumer-stage-$stageKey" )->escaped(),
476                'mwoauthconsumerregistration-email' => $cmrAc->escapeForHtml( $cmrAc->getEmail() ),
477                'mwoauthconsumerregistration-lastchange' => $logHtml,
478            ];
479        }
480
481        $r = "<li class='mw-mwoauthconsumerregistration-{$encStageKey}'>";
482        $r .= "<span class='mw-mwoauth-stage-icon'></span> ";
483        $r .= "<span>$time (<strong>{$links}</strong>)</span>";
484        $r .= "<table class='mw-mwoauthconsumerregistration-body mw-datatable'>";
485        foreach ( $data as $msg => $encValue ) {
486            $r .= '<tr>' .
487                '<th>' . $this->msg( $msg )->escaped() . '</th>' .
488                '<td width=\'90%\'>' . $encValue . '</td>' .
489                '</tr>';
490        }
491        $r .= '</table>';
492
493        $r .= '</li>';
494
495        return $r;
496    }
497
498    /** @inheritDoc */
499    protected function getGroupName() {
500        return 'users';
501    }
502
503    /**
504     * @param int $oauthVersion
505     * @param User $user
506     * @param string[] $allWikis
507     * @param array $showGrants
508     * @param Consumer|null $copyFrom If set, pre-fill the form with this consumer's details
509     */
510    private function proposeOAuth( int $oauthVersion, User $user, $allWikis, $showGrants, ?Consumer $copyFrom = null ) {
511        if ( !in_array( $oauthVersion, [ Consumer::OAUTH_VERSION_1, Consumer::OAUTH_VERSION_2 ] ) ) {
512            throw new InvalidArgumentException( 'Invalid OAuth version' );
513        }
514        $dbw = Utils::getOAuthDB( DB_PRIMARY );
515        $control = new ConsumerSubmitControl( $this->getContext(), [], $dbw );
516
517        $grantNames = $this->grantsLocalization->getGrantDescriptionsWithClasses(
518            $showGrants, $this->getLanguage() );
519        $formDescriptor = [
520            'oauthVersion' => [
521                'class' => HTMLHiddenField::class,
522                'default' => $oauthVersion,
523            ],
524            'name' => [
525                'type' => 'text',
526                'label-message' => 'mwoauth-consumer-name',
527                'size' => '45',
528                'required' => true
529            ],
530            'version' => [
531                'type' => 'text',
532                'label-message' => 'mwoauth-consumer-version',
533                'required' => true,
534                'default' => "1.0"
535            ],
536            'description' => [
537                'type' => 'textarea',
538                'label-message' => 'mwoauth-consumer-description',
539                'required' => true,
540                'rows' => 5
541            ],
542            'ownerOnly' => [
543                'type' => 'check',
544                'label-message' => [ 'mwoauth-consumer-owner-only', $user->getName() ],
545                'help-message' => [ 'mwoauth-consumer-owner-only-help', $user->getName() ],
546            ],
547            'callbackUrl' => [
548                'type' => 'text',
549                'label-message' => 'mwoauth-consumer-callbackurl',
550                'help-messages' => ( $oauthVersion === Consumer::OAUTH_VERSION_2 )
551                    ? [
552                            'mwoauth-consumer-callbackurl-help',
553                            'mwoauth-consumer-callbackurl-custom-scheme'
554                        ] : null,
555                'required' => true,
556                'hide-if' => [ '!==', 'ownerOnly', '' ],
557            ],
558            'callbackIsPrefix' => [
559                'oauthVersion' => Consumer::OAUTH_VERSION_1,
560                'type' => 'check',
561                'label-message' => 'mwoauth-consumer-callbackisprefix',
562                'hide-if' => [ '!==', 'ownerOnly', '' ],
563            ],
564            'email' => [
565                'type' => 'text',
566                'label-message' => 'mwoauth-consumer-email',
567                'required' => true,
568                'readonly' => true,
569                'default' => $user->getEmail(),
570                'help-message' => 'mwoauth-consumer-email-help',
571                'hide-if' => [ '!==', 'ownerOnly', '' ],
572            ],
573            'wiki' => [
574                'type' => $allWikis ? 'combobox' : 'select',
575                'options' => [
576                     $this->msg( 'mwoauth-consumer-allwikis' )->escaped() => '*',
577                     $this->msg( 'mwoauth-consumer-wiki-thiswiki', WikiMap::getCurrentWikiId() )
578                         ->escaped() => WikiMap::getCurrentWikiId()
579                 ] + array_flip( $allWikis ),
580                'label-message' => 'mwoauth-consumer-wiki',
581                'required' => true,
582                'default' => '*'
583            ],
584            'oauth2IsConfidential' => [
585                'oauthVersion' => Consumer::OAUTH_VERSION_2,
586                'type' => 'check',
587                'label-message' => 'mwoauth-oauth2-is-confidential',
588                'help-message' => 'mwoauth-oauth2-is-confidential-help',
589                'default' => 1,
590                'hide-if' => [ '!==', 'ownerOnly', '' ],
591            ],
592            'oauth2GrantTypes' => [
593                'oauthVersion' => Consumer::OAUTH_VERSION_2,
594                'type' => 'multiselect',
595                'label-message' => 'mwoauth-oauth2-granttypes',
596                'hide-if' => [ '!==', 'ownerOnly', '' ],
597                'options' => array_filter( [
598                    $this->msg( 'mwoauth-oauth2-granttype-auth-code' )->escaped()
599                        => ClientEntity::GRANT_TYPE_AUTHORIZATION_CODE,
600                    $this->msg( 'mwoauth-oauth2-granttype-refresh-token' )->escaped()
601                        => ClientEntity::GRANT_TYPE_REFRESH_TOKEN,
602                    $this->msg( 'mwoauth-oauth2-granttype-client-credentials' )->escaped()
603                        => ClientEntity::GRANT_TYPE_CLIENT_CREDENTIALS,
604                ], fn ( $grantType ) => in_array( $grantType, $this->getConfig()->get( 'OAuth2EnabledGrantTypes' ) ) ),
605                'required' => true,
606                'default' => [
607                    ClientEntity::GRANT_TYPE_AUTHORIZATION_CODE,
608                    ClientEntity::GRANT_TYPE_REFRESH_TOKEN,
609                ],
610            ],
611            'granttype' => [
612                'type' => 'radio',
613                'options-messages' => [
614                    'grant-mwoauth-authonly' => 'authonly',
615                    'grant-mwoauth-authonlyprivate' => 'authonlyprivate',
616                    'mwoauth-granttype-normal' => 'normal',
617                ],
618                'label-message' => 'mwoauth-consumer-granttypes',
619                'default' => 'normal',
620                'hide-if' => [ '!==', 'ownerOnly', '' ],
621            ],
622            // HACK separate field from grants because OOUI HTMLFormField cannot position help text on top.
623            // If this form is changed to Codex, this could use 'description-message' instead.
624            'grantsHelp' => [
625                'type' => 'info',
626                'default' => '',
627                'help-message' => 'mwoauth-consumer-grantshelp',
628                // Keep 'hide-if' in sync between 'grants' and 'grantsHelp'
629                'hide-if' => [ 'AND',
630                    [ '!==', 'granttype', 'normal' ],
631                    [ '===', 'ownerOnly', '' ]
632                ],
633            ],
634            'grants' => [
635                'type' => 'checkmatrix',
636                'label-message' => 'mwoauth-consumer-grantsneeded',
637                // Keep 'hide-if' in sync between 'grants' and 'grantsHelp'
638                'hide-if' => [ 'AND',
639                    [ '!==', 'granttype', 'normal' ],
640                    [ '===', 'ownerOnly', '' ]
641                ],
642                'columns' => [
643                    $this->msg( 'mwoauth-consumer-required-grant' )->escaped() => 'grant'
644                ],
645                'rows' => array_combine(
646                    $grantNames,
647                    $showGrants
648                ),
649                'tooltips-html' => array_combine(
650                    $grantNames,
651                    array_map(
652                        fn ( $rights ) => Html::rawElement( 'ul', [], implode( '', array_map(
653                            fn ( $right ) => Html::rawElement( 'li', [], $this->msg( "right-$right" )->parse() ),
654                            $rights
655                        ) ) ),
656                        array_intersect_key( $this->grantsInfo->getRightsByGrant(),
657                            array_fill_keys( $showGrants, true ) )
658                    )
659                ),
660                'force-options-on' => array_map(
661                    static function ( $g ) {
662                        return "grant-$g";
663                    },
664                    $this->grantsInfo->getHiddenGrants()
665                ),
666                // different format
667                'validation-callback' => null,
668            ],
669            'restrictions' => [
670                'class' => HTMLRestrictionsField::class,
671                'required' => true,
672                'default' => MWRestrictions::newDefault(),
673            ],
674            'rsaKey' => [
675                'oauthVersion' => Consumer::OAUTH_VERSION_1,
676                'type' => 'textarea',
677                'label-message' => 'mwoauth-consumer-rsakey',
678                'help-message' => 'mwoauth-consumer-rsakey-help',
679                'required' => false,
680                'default' => '',
681                'rows' => 5
682            ],
683            'agreement' => [
684                'type' => 'check',
685                'label-message' => 'mwoauth-consumer-developer-agreement',
686                'required' => true,
687            ],
688            'action' => [
689                'type'    => 'hidden',
690                'default' => 'propose'
691            ]
692        ];
693
694        // Pre-fill fields from an existing consumer when copying
695        if ( $copyFrom !== null ) {
696            $formDescriptor['name']['default'] = $copyFrom->getName();
697            $formDescriptor['version']['default'] = $copyFrom->getVersion();
698            $formDescriptor['description']['default'] = $copyFrom->getDescription();
699            $formDescriptor['ownerOnly']['default'] = $copyFrom->getOwnerOnly();
700            $formDescriptor['callbackUrl']['default'] = $copyFrom->getCallbackUrl();
701            $formDescriptor['callbackIsPrefix']['default'] = $copyFrom->getCallbackIsPrefix();
702            $formDescriptor['wiki']['default'] = $copyFrom->getWiki();
703            $formDescriptor['rsaKey']['default'] = $copyFrom->getRsaKey();
704            if ( $oauthVersion === Consumer::OAUTH_VERSION_2 ) {
705                $formDescriptor['oauth2IsConfidential']['default'] = $copyFrom->get( 'oauth2IsConfidential' );
706                $formDescriptor['oauth2GrantTypes']['default'] = $copyFrom->get( 'oauth2GrantTypes' );
707            }
708            // Derive granttype and grants from the stored grants array
709            $grants = $copyFrom->getGrants();
710            if ( in_array( 'mwoauth-authonly', $grants ) ) {
711                $formDescriptor['granttype']['default'] = 'authonly';
712            } elseif ( in_array( 'mwoauth-authonlyprivate', $grants ) ) {
713                $formDescriptor['granttype']['default'] = 'authonlyprivate';
714            } else {
715                $formDescriptor['granttype']['default'] = 'normal';
716                // Strip hidden or implied grants. they are re-added automatically on submit
717                $hiddenGrants = $this->grantsInfo->getHiddenGrants();
718                $visibleGrants = array_values( array_diff( $grants, $hiddenGrants ) );
719                $formDescriptor['grants']['default'] = array_map(
720                    static fn ( $g ) => "grant-$g",
721                    $visibleGrants
722                );
723            }
724        }
725
726        $formDescriptor = array_filter( $formDescriptor,
727            static fn ( $field ) => !isset( $field['oauthVersion'] ) || $field['oauthVersion'] === $oauthVersion
728        );
729
730        $form = HTMLForm::factory( 'ooui',
731            $control->registerValidators( $formDescriptor ),
732            $this->getContext()
733        );
734        $form->setSubmitCallback(
735            function ( array $data ) use ( $control ) {
736                // adapt form to controller
737                $data = $this->fillDefaultFields( $data );
738
739                $data['grants'] = FormatJson::encode(
740                    preg_replace( '/^grant-/', '', $data['grants'] )
741                );
742
743                // Force all ownerOnly clients to use client_credentials
744                if ( $data['ownerOnly'] ) {
745                    $data['oauth2GrantTypes'] = [ ClientEntity::GRANT_TYPE_CLIENT_CREDENTIALS ];
746                }
747
748                $control->setInputParameters( $data );
749                return $control->submit();
750            }
751        );
752        $form->setSubmitTextMsg( 'mwoauthconsumerregistration-propose-submit' );
753        if ( $copyFrom !== null ) {
754            $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-copy-legend' );
755            $form->addPreHtml(
756            $this->msg( 'mwoauthconsumerregistration-copy-text' )->parseAsBlock() );
757        } else {
758            $form->setWrapperLegendMsg( 'mwoauthconsumerregistration-propose-legend' );
759            $form->addPreHtml(
760                // mwoauthconsumerregistration-propose-text-oauth1
761                // mwoauthconsumerregistration-propose-text-oauth2
762                $this->msg( "mwoauthconsumerregistration-propose-text-oauth$oauthVersion" )->parseAsBlock() );
763        }
764
765        $status = $form->show();
766        if ( $status instanceof Status && $status->isOK() ) {
767            /** @var Consumer $cmr */
768            $cmr = $status->value['result']['consumer'];
769            if ( $cmr->getOwnerOnly() ) {
770                $accessToken = $status->value['result']['accessToken'];
771                if ( $oauthVersion === Consumer::OAUTH_VERSION_1 ) {
772                    $this->getOutput()->addWikiMsg(
773                        "mwoauthconsumerregistration-created-owner-only-oauth1",
774                        $cmr->getConsumerKey(),
775                        Utils::hmacDBSecret( $cmr->getSecretKey() ),
776                        $accessToken->key,
777                        Utils::hmacDBSecret( $accessToken->secret )
778                    );
779                } else {
780                    // OAuth 2 access tokens are very long
781                    $accessToken = Html::element( 'span', [
782                        'style' => 'overflow-wrap: break-word'
783                    ], (string)$accessToken );
784                    $this->getOutput()->addWikiMsg(
785                        'mwoauthconsumerregistration-created-owner-only-oauth2',
786                        $cmr->getConsumerKey(),
787                        Utils::hmacDBSecret( $cmr->getSecretKey() ),
788                        Message::rawParam( $accessToken )
789                    );
790                }
791            } elseif ( $cmr->getStage() === Consumer::STAGE_APPROVED ) {
792                // mwoauthconsumerregistration-autoapproved-oauth1
793                // mwoauthconsumerregistration-autoapproved-oauth2
794                $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-autoapproved-oauth$oauthVersion",
795                    $cmr->getConsumerKey(),
796                    Utils::hmacDBSecret( $cmr->getSecretKey() )
797                );
798            } else {
799                // mwoauthconsumerregistration-proposed-oauth1
800                // mwoauthconsumerregistration-proposed-oauth2
801                $this->getOutput()->addWikiMsg( "mwoauthconsumerregistration-proposed-oauth$oauthVersion",
802                    $cmr->getConsumerKey(),
803                    Utils::hmacDBSecret( $cmr->getSecretKey() ) );
804            }
805            $this->getOutput()->returnToMain();
806        }
807    }
808
809    /**
810     * Used to adapt both OAuth forms to the same structure so SubmitControl::validateFields() doesn't fail
811     *
812     * @param array $form
813     * @return array
814     */
815    private function fillDefaultFields( array $form ): array {
816        // These defaults are taken from the legacy form and are present regardless of OAuth version
817        $defaults = [
818            'callbackIsPrefix' => false,
819            'oauth2IsConfidential' => true,
820            'oauth2GrantTypes'  => [
821                ClientEntity::GRANT_TYPE_AUTHORIZATION_CODE,
822                ClientEntity::GRANT_TYPE_REFRESH_TOKEN,
823            ],
824            'granttype' => 'normal',
825            'rsaKey' => '',
826        ];
827
828        $form = array_merge( $defaults, $form );
829
830        // 'callbackUrl' must be present,
831        // otherwise SubmitControl::validateFields() fails.
832        // @phan-suppress-next-line PhanTypeInvalidDimOffset
833        if ( $form['ownerOnly'] && !isset( $form['callbackUrl'] ) ) {
834            $form['callbackUrl'] = '';
835        }
836
837        return $form;
838    }
839}