Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.14% covered (warning)
86.14%
553 / 642
52.94% covered (warning)
52.94%
18 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
OATHManage
86.14% covered (warning)
86.14%
553 / 642
52.94% covered (warning)
52.94%
18 / 34
163.31
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
 getRestriction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLoginSecurityLevel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 checkPermissions
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
13.15
 setAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setModule
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getKeyNameAndDescription
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
4.29
 canRemoveKeys
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 get2FAGroupsData
96.97% covered (success)
96.97%
32 / 33
0.00% covered (danger)
0.00%
0 / 1
12
 build2FARequiredNotice
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 buildIrremovableKeyNotice
71.11% covered (warning)
71.11%
32 / 45
0.00% covered (danger)
0.00%
0 / 1
6.87
 buildKeyAccordion
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 buildVueData
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
5
 displayNewUI
91.94% covered (success)
91.94%
114 / 124
0.00% covered (danger)
0.00%
0 / 1
17.15
 addModuleHTML
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 getGenericContent
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 exceedsKeyLimit
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addCustomContent
64.29% covered (warning)
64.29%
18 / 28
0.00% covered (danger)
0.00%
0 / 1
7.64
 shouldShowGenericButtons
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isModuleRequested
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isModuleEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isModuleAvailable
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 ensureRequiredFormFields
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 clearPage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getSpecialModules
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 showDeleteWarning
95.24% covered (success)
95.24%
80 / 84
0.00% covered (danger)
0.00%
0 / 1
10
 addSpecialModulesHTML
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 addSpecialModuleHTML
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getRecoveryCodesHTML
99.11% covered (success)
99.11%
111 / 112
0.00% covered (danger)
0.00%
0 / 1
6
 hasSpecialModules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types=1 );
3
4/**
5 * @license GPL-2.0-or-later
6 */
7
8namespace MediaWiki\Extension\OATHAuth\Special;
9
10use MediaWiki\Auth\AuthManager;
11use MediaWiki\Auth\PasswordAuthenticationRequest;
12use MediaWiki\CheckUser\Services\CheckUserInsert;
13use MediaWiki\Exception\ErrorPageError;
14use MediaWiki\Extension\OATHAuth\Enforce2FA\Mandatory2FAChecker;
15use MediaWiki\Extension\OATHAuth\HTMLForm\OATHAuthOOUIHTMLForm;
16use MediaWiki\Extension\OATHAuth\HTMLForm\RecoveryCodesTrait;
17use MediaWiki\Extension\OATHAuth\Key\AuthKey;
18use MediaWiki\Extension\OATHAuth\Key\RecoveryCode;
19use MediaWiki\Extension\OATHAuth\Module\IModule;
20use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
21use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
22use MediaWiki\Extension\OATHAuth\OATHUser;
23use MediaWiki\Extension\OATHAuth\OATHUserRepository;
24use MediaWiki\Html\Html;
25use MediaWiki\HTMLForm\HTMLForm;
26use MediaWiki\Logging\ManualLogEntry;
27use MediaWiki\MainConfigNames;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Message\Message;
30use MediaWiki\Registration\ExtensionRegistry;
31use MediaWiki\SpecialPage\SpecialPage;
32use MediaWiki\Title\Title;
33use MediaWiki\User\UserGroupManager;
34use MediaWiki\WikiMap\WikiMap;
35use OOUI\ButtonWidget;
36use OOUI\HorizontalLayout;
37use OOUI\HtmlSnippet;
38use OOUI\LabelWidget;
39use OOUI\PanelLayout;
40use Wikimedia\Codex\Utility\Codex;
41
42/**
43 * Initializes a page to manage available 2FA modules
44 */
45class OATHManage extends SpecialPage {
46    use RecoveryCodesTrait;
47
48    public const ACTION_ENABLE = 'enable';
49    public const ACTION_DELETE = 'delete';
50
51    protected OATHUser $oathUser;
52
53    protected string $action;
54
55    protected ?IModule $requestedModule;
56
57    private array $groupsRequiring2FA;
58
59    public function __construct(
60        private readonly OATHUserRepository $userRepo,
61        private readonly OATHAuthModuleRegistry $moduleRegistry,
62        private readonly Mandatory2FAChecker $mandatory2FAChecker,
63        private readonly AuthManager $authManager,
64        private readonly UserGroupManager $userGroupManager,
65    ) {
66        // messages used: oathmanage (display "name" on Special:SpecialPages)
67        parent::__construct( 'OATHManage' );
68    }
69
70    /** @inheritDoc */
71    public function getRestriction(): string {
72        // messages used: right-oathauth-enable, action-oathauth-enable
73        return 'oathauth-enable';
74    }
75
76    /** @inheritDoc */
77    protected function getGroupName() {
78        return 'login';
79    }
80
81    /** @inheritDoc */
82    protected function getLoginSecurityLevel() {
83        return $this->getName();
84    }
85
86    /** @inheritDoc */
87    public function doesWrites() {
88        return true;
89    }
90
91    /** @inheritDoc */
92    public function getDescription() {
93        return $this->msg( 'accountsecurity' );
94    }
95
96    /** @inheritDoc */
97    public function execute( $subPage ) {
98        $this->oathUser = $this->userRepo->findByUser( $this->getUser() );
99        $this->groupsRequiring2FA = $this->get2FAGroupsData();
100
101        $this->getOutput()->enableOOUI();
102        $this->getOutput()->disallowUserJs();
103        $this->setAction();
104        $this->setModule();
105
106        parent::execute( $subPage );
107
108        if ( $this->action === self::ACTION_DELETE ) {
109            $this->showDeleteWarning();
110            return;
111        } elseif ( $this->requestedModule instanceof IModule ) {
112            // Performing an action on a requested module
113            $this->clearPage();
114            $this->addModuleHTML( $this->requestedModule );
115            return;
116        }
117
118        $this->displayNewUI();
119
120        // recovery codes
121        if ( $this->hasSpecialModules() ) {
122            $this->addSpecialModulesHTML();
123        }
124    }
125
126    public function checkPermissions() {
127        $this->requireNamedUser();
128
129        if ( !$this->oathUser->getCentralId() ) {
130            throw new ErrorPageError(
131                'oathauth-enable',
132                'oathauth-must-be-central',
133                [ $this->getUser()->getName() ]
134            );
135        }
136
137        $canEnable = $this->getUser()->isAllowed( 'oathauth-enable' );
138
139        if ( $this->action === static::ACTION_ENABLE && !$canEnable ) {
140            $this->displayRestrictionError();
141        }
142
143        if ( !$canEnable && !$this->oathUser->isTwoFactorAuthEnabled() ) {
144            // No enabled module and cannot enable - nothing to do
145            $this->displayRestrictionError();
146        }
147    }
148
149    private function setAction(): void {
150        $this->action = $this->getRequest()->getVal( 'action', '' );
151    }
152
153    private function setModule(): void {
154        $moduleKey = $this->getRequest()->getVal( 'module', '' );
155        $this->requestedModule = ( $moduleKey && $this->moduleRegistry->moduleExists( $moduleKey ) )
156            ? $this->moduleRegistry->getModuleByKey( $moduleKey )
157            : null;
158    }
159
160    /**
161     * Get the name, description, and timestamp to display for a given key.
162     * @param AuthKey $key
163     * @return array{name:string, description?:string, timestamp:?string}
164     */
165    private function getKeyNameAndDescription( AuthKey $key ): array {
166        $keyName = $key->getFriendlyName();
167        $moduleName = $this->moduleRegistry->getModuleByKey( $key->getModule() )->getDisplayName()->text();
168        $createdTimestamp = null;
169        $timestamp = $key->getCreatedTimestamp();
170
171        if ( $timestamp !== null ) {
172            $createdTimestamp = $this->msg(
173                'oathauth-created-at',
174                Message::dateParam( $timestamp )
175            )->text();
176        }
177
178        // Use the key if it has a non-empty name and set the description to the module name
179        if ( $keyName !== null && trim( $keyName ) !== '' ) {
180            return [
181                'name' => $keyName,
182                'description' => $moduleName,
183                'timestamp' => $createdTimestamp
184            ];
185        }
186
187        // If the key has no name, use the module name as the name and send the timestamp
188        return [
189            'name' => $moduleName,
190            'timestamp' => $createdTimestamp
191        ];
192    }
193
194    private function canRemoveKeys(): bool {
195        if ( !$this->groupsRequiring2FA ) {
196            return true;
197        }
198        $numKeys = 0;
199        foreach ( $this->oathUser->getNonSpecialKeys() as $key ) {
200            if ( !$key->supportsPasswordlessLogin() ) {
201                $numKeys++;
202            }
203        }
204        // If there's exactly one proper key (non-special and non-passwordless), it cannot be removed, because
205        // then whole 2FA would be disabled for the user.
206        return $numKeys !== 1;
207    }
208
209    /**
210     * Prepares data about the current user's groups that require 2FA
211     */
212    private function get2FAGroupsData(): array {
213        global $wgConf;
214        '@phan-var \MediaWiki\Config\SiteConfiguration $wgConf';
215
216        $groupsRequiring2FA = $this->mandatory2FAChecker->getGroupsRequiring2FAAcrossWikiFarm( $this->getUser() );
217
218        // Keyed by wiki, then by page, with the value being an array of groups
219        $splitGroups = [];
220        foreach ( $groupsRequiring2FA as $wikiId => $groupsOnWiki ) {
221            if ( WikiMap::isCurrentWikiId( $wikiId ) ) {
222                $groupRemovalPages = $this->getConfig()->get( 'OATH2FARequiredGroupRemovalPages' ) ?? [];
223            } else {
224                $groupRemovalPages = $wgConf->get( 'wgOATH2FARequiredGroupRemovalPages', $wikiId ) ?? [];
225            }
226            foreach ( $groupsOnWiki as $group ) {
227                $relevantPage = $groupRemovalPages[$group] ?? $groupRemovalPages['*'] ?? '';
228                $splitGroups[$wikiId][$relevantPage][] = $group;
229            }
230        }
231
232        $lang = $this->getLanguage();
233        $result = [];
234        foreach ( $splitGroups as $wikiId => $pages ) {
235            $wiki = WikiMap::getWiki( $wikiId );
236            if ( $wiki === null && !WikiMap::isCurrentWikiId( $wikiId ) ) {
237                // Skip remote wikis that cannot be resolved via WikiMap.
238                continue;
239            }
240
241            foreach ( $pages as $page => $groups ) {
242                $groupNames = array_map(
243                    fn ( $group ) => $lang->getGroupMemberName( $group, $this->getUser() ),
244                    $groups
245                );
246
247                if ( $wiki !== null ) {
248                    $wikiName = $wiki->getDisplayName();
249                    $url = $page !== '' ? $wiki->getUrl( $page ) : '';
250                } else {
251                    // If there's no $wgConf nor sites table, the current wiki may not be resolvable using WikiMap
252                    // Fallback to local resolution of relevant settings
253                    $wikiName = $this->getConfig()->get( MainConfigNames::Sitename );
254                    $title = $page !== '' ? Title::newFromText( $page ) : null;
255                    $url = $title ? $title->getFullURL() : '';
256                }
257
258                $result[] = [
259                    'wiki' => $wikiName,
260                    'page' => $page,
261                    'url' => $url,
262                    'groupNames' => $groupNames,
263                ];
264            }
265        }
266        return $result;
267    }
268
269    private function build2FARequiredNotice(): string {
270        $codex = new Codex();
271        $lang = $this->getLanguage();
272        $message = $codex->message();
273        $message->setAttributes( [ 'class' => 'mw-special-OATHManage-mandatory-2fa' ] );
274        if ( $this->oathUser->isTwoFactorAuthEnabled() ) {
275            $message->setInline( true )
276                ->setContentText( $this->msg( 'oathauth-2fa-required' )->text() );
277        } else {
278            $groupsPerWiki = [];
279            foreach ( $this->groupsRequiring2FA as $entry ) {
280                $groupsPerWiki[$entry['wiki']] = array_merge(
281                    $groupsPerWiki[$entry['wiki']] ?? [],
282                    $entry['groupNames']
283                );
284            }
285
286            $content = $this->msg( 'oathauth-2fa-required' )->parse();
287            $listItems = '';
288            foreach ( $groupsPerWiki as $wiki => $groups ) {
289                $listItems .= Html::rawElement( 'li', [], $this->msg(
290                    'oathauth-2fa-required-groups-on-project',
291                    count( $groups ),
292                    $lang->listToText( $groups ),
293                    $wiki
294                )->parse() );
295            }
296            $content .= Html::rawElement( 'ul', [], $listItems );
297            $message->setContentHtml( $codex->htmlSnippet()->setContent( $content )->build() );
298        }
299        return $message->build()->getHtml();
300    }
301
302    private function buildIrremovableKeyNotice(): string {
303        $lang = $this->getLanguage();
304        if ( count( $this->groupsRequiring2FA ) === 1 ) {
305            $entry = $this->groupsRequiring2FA[0];
306            if ( $entry['url'] ) {
307                $pageLink = '[' . $entry['url'] . ' ' . $entry['page'] . ']';
308            } else {
309                $pageLink = $this->msg( 'oathauth-2fa-groups-notice-unknown-page' )->parse();
310            }
311            $noticeContent = $this->msg( 'oathauth-2fa-groups-notice-single' )
312                ->params(
313                    count( $entry['groupNames'] ),
314                    $lang->listToText( $entry['groupNames'] ),
315                    $entry['wiki'],
316                    $pageLink
317                )
318                ->parse();
319        } else {
320            $totalGroups = 0;
321            foreach ( $this->groupsRequiring2FA as $entry ) {
322                $totalGroups += count( $entry['groupNames'] );
323            }
324
325            $noticeContent = $this->msg( 'oathauth-2fa-groups-notice-multiple' )->params( $totalGroups )->parse();
326            $noticeContent .= Html::rawElement(
327                'div',
328                [ 'class' => 'mw-special-OATHManage-2fa-groups-list-intro' ],
329                $this->msg( 'oathauth-2fa-groups-notice-multiple-links-intro' )->params( $totalGroups )->parse()
330            );
331            $noticeContent .= Html::openElement( 'ul' );
332
333            foreach ( $this->groupsRequiring2FA as $entry ) {
334                if ( $entry['url'] ) {
335                    $pageLink = '[' . $entry['url'] . ' ' . $entry['page'] . ']';
336                } else {
337                    $pageLink = $this->msg( 'oathauth-2fa-groups-notice-unknown-page' )->parse();
338                }
339                $content = $this->msg( 'oathauth-2fa-groups-notice-multiple-links-entry' )
340                    ->params(
341                        count( $entry['groupNames'] ),
342                        $lang->listToText( $entry['groupNames'] ),
343                        $entry['wiki'],
344                        $pageLink
345                    )
346                    ->parse();
347                $noticeContent .= Html::rawElement( 'li', [], $content );
348            }
349            $noticeContent .= Html::closeElement( 'ul' );
350        }
351        $codex = new Codex();
352        return $codex->message()
353            ->setType( 'warning' )
354            ->setAttributes( [ 'class' => 'mw-special-OATHManage-2fa-groups-notice' ] )
355            ->setContentHtml( $codex->htmlSnippet()->setContent( $noticeContent )->build() )
356            ->build()
357            ->getHtml();
358    }
359
360    private function buildKeyAccordion( AuthKey $key, string $noticeHtml = '', bool $removable = true ): string {
361        $codex = new Codex();
362        $keyData = $this->getKeyNameAndDescription( $key );
363        $keyAccordion = $codex->accordion()
364            ->setTitle( $keyData['name'] )
365            // TODO support outlined Accordions in Codex-PHP (T416645)
366            ->setAttributes( [ 'class' => 'cdx-accordion--separation-outline' ] );
367
368        $accordionDescription = $keyData['timestamp'] ?? $keyData['description'] ?? null;
369        if ( $accordionDescription !== null ) {
370            $keyAccordion->setDescription( $accordionDescription );
371        }
372
373        $keyAccordion
374            ->setContentHtml( $codex->htmlSnippet()->setContent(
375                Html::rawElement( 'form', [
376                        'action' => wfScript(),
377                        'class' => 'mw-special-OATHManage-authmethods__method-actions'
378                    ],
379                    Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
380                    Html::hidden( 'module', $key->getModule() ) .
381                    Html::hidden( 'keyId', $key->getId() ) .
382                    Html::hidden( 'warn', '1' ) .
383                    // TODO implement rename (T401775)
384                    $codex->button()
385                        ->setLabel( $this->msg( 'oathauth-authenticator-delete' )->text() )
386                        ->setAction( 'destructive' )
387                        ->setWeight( 'primary' )
388                        ->setDisabled( !$removable )
389                        ->setType( 'submit' )
390                        ->setAttributes( [ 'name' => 'action', 'value' => self::ACTION_DELETE ] )
391                        ->build()
392                        ->getHtml()
393                ) . $noticeHtml
394            )->build() );
395        return $keyAccordion->build()->getHtml();
396    }
397
398    private function buildVueData(): array {
399        $data = [
400            'modules' => [],
401            'keys' => [],
402            'passkeys' => [],
403            'groupsRequiring2FA' => $this->groupsRequiring2FA
404        ];
405
406        foreach ( $this->moduleRegistry->getAllModules() as $module ) {
407            $labelMessage = $module->getAddKeyMessage();
408            if ( $labelMessage ) {
409                $data['modules'][] = [
410                    'name' => $module->getName(),
411                    'labelMessage' => $labelMessage->text()
412                ];
413            }
414        }
415
416        foreach ( $this->oathUser->getNonSpecialKeys() as $key ) {
417            $keyData = [
418                'id' => $key->getId(),
419                'module' => $key->getModule()
420            ] + $this->getKeyNameAndDescription( $key );
421
422            if ( $key->supportsPasswordlessLogin() ) {
423                $data['passkeys'][] = $keyData;
424            } else {
425                $data['keys'][] = $keyData;
426            }
427        }
428
429        return $data;
430    }
431
432    private function displayNewUI(): void {
433        $output = $this->getOutput();
434        $output->addModuleStyles( 'ext.oath.manage.styles' );
435        $output->addModules( 'ext.oath.manage' );
436        $output->addJsConfigVars( 'wgOATHManageData', $this->buildVueData() );
437        $codex = new Codex();
438
439        // Show the delete success message, if applicable
440        $deletedKeyName = $this->getRequest()->getVal( 'deletesuccess' );
441        if ( $deletedKeyName !== null ) {
442            $output->addHTML( Html::successBox(
443                $this->msg( 'oathauth-delete-success' )->parse()
444            ) );
445        }
446
447        // Add the success message for newly enabled key
448        $addedKeyName = $this->getRequest()->getVal( 'addsuccess' );
449        if ( $addedKeyName !== null ) {
450            $output->addHTML(
451                Html::successBox(
452                    $this->msg( 'oathauth-enable-success' )->parse()
453                )
454            );
455        }
456
457        // Password section
458        if ( $this->authManager->allowsAuthenticationDataChange(
459            new PasswordAuthenticationRequest(), false )->isGood()
460        ) {
461            $output->addHTML(
462                Html::rawElement( 'div', [ 'class' => 'mw-special-OATHManage-password' ],
463                    Html::element( 'h3', [], $this->msg( 'oathauth-password-header' )->text() ) .
464                    Html::rawElement( 'form', [
465                            'action' => wfScript(),
466                            'class' => 'mw-special-OATHManage-password__form'
467                        ],
468                        Html::hidden( 'title', self::getTitleFor( 'ChangePassword' )->getPrefixedDBkey() ) .
469                        Html::hidden( 'returnto', $this->getPageTitle()->getPrefixedDBkey() ) .
470                        Html::element( 'p',
471                            [ 'class' => 'mw-special-OATHManage-password__label' ],
472                            $this->msg( 'oathauth-password-label' )->text()
473                        ) .
474                        $codex->button()
475                            ->setLabel( $this->msg( 'oathauth-password-action' )->text() )
476                            ->setType( 'submit' )
477                            ->build()
478                            ->getHtml()
479                    )
480                )
481            );
482        }
483
484        // 2FA section
485        $canRemoveKeys = $this->canRemoveKeys();
486        $irremovableKeyNotice = '';
487        if ( !$canRemoveKeys ) {
488            $irremovableKeyNotice = $this->buildIrremovableKeyNotice();
489        }
490
491        $keyAccordions = '';
492        $keyPlaceholder = '';
493        $mandatory2FAMessage = '';
494        $authmethodsClasses = [
495            'mw-special-OATHManage-authmethods'
496        ];
497        foreach ( $this->oathUser->getNonSpecialKeys() as $key ) {
498            if ( $key->supportsPasswordlessLogin() ) {
499                // Keys that support passwordless login are displayed in the passkeys section instead
500                continue;
501            }
502
503            $keyAccordions .= $this->buildKeyAccordion( $key, $irremovableKeyNotice, $canRemoveKeys );
504        }
505        if ( $keyAccordions === '' ) {
506            // User has no keys, display the placeholder message instead
507            $keyPlaceholder = Html::element( 'p',
508                [ 'class' => 'mw-special-OATHManage-authmethods__placeholder' ],
509                $this->msg( 'oathauth-authenticator-placeholder' )->text()
510            );
511            $authmethodsClasses[] = 'mw-special-OATHManage-authmethods--no-keys';
512        }
513
514        if ( $this->groupsRequiring2FA ) {
515            $mandatory2FAMessage = $this->build2FARequiredNotice();
516        }
517
518        $moduleButtons = '';
519        foreach ( $this->moduleRegistry->getAllModules() as $module ) {
520            $labelMessage = $module->getAddKeyMessage();
521            if ( !$labelMessage ) {
522                continue;
523            }
524            $moduleButtons .= $codex
525                ->button()
526                ->setLabel( $labelMessage->text() )
527                ->setType( 'submit' )
528                ->setAttributes( [ 'name' => 'module', 'value' => $module->getName() ] )
529                ->build()
530                ->getHtml();
531        }
532
533        $authMethodsSection = Html::rawElement( 'div', [ 'class' => $authmethodsClasses ],
534            Html::element( 'h3', [], $this->msg( 'oathauth-authenticator-header' )->text() ) .
535            $mandatory2FAMessage . $keyAccordions .
536            Html::rawElement( 'form', [
537                    'action' => wfScript(),
538                    'class' => 'mw-special-OATHManage-authmethods__addform'
539                ],
540                Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
541                Html::hidden( 'action', 'enable' ) .
542                $keyPlaceholder .
543                $moduleButtons
544            )
545        );
546
547        // Passkeys section
548        $passkeySection = '';
549        $passkeyAccordions = '';
550        $passkeyPlaceholder = '';
551        $passkeyClasses = [ 'mw-special-OATHManage-passkeys' ];
552        foreach ( $this->oathUser->getNonSpecialKeys() as $key ) {
553            if ( !$key->supportsPasswordlessLogin() ) {
554                // Regular 2FA keys are displayed in the 2FA section below
555                continue;
556            }
557            $passkeyAccordions .= $this->buildKeyAccordion( $key );
558        }
559        if ( $passkeyAccordions === '' ) {
560            $passkeyPlaceholder = Html::element( 'p',
561                [ 'class' => 'mw-special-OATHManage-passkeys__placeholder' ],
562                $this->msg( 'oathauth-passkeys-placeholder' )->text()
563            );
564            // Display an additional message if the user can't add passkeys
565            if ( $keyAccordions === '' ) {
566                $passkeyPlaceholder .= Html::element( 'p',
567                    [ 'class' => 'mw-special-OATHManage-passkeys__placeholder' ],
568                     $this->msg( 'oathauth-passkeys-no2fa' )->text()
569                );
570            }
571            $passkeyClasses[] = 'mw-special-OATHManage-passkeys--no-keys';
572        }
573        // Only display the "Add passkey" button if the user can add passkeys
574        $passkeyAddButton = $keyAccordions === '' ? '' : $codex->button()
575            ->setLabel( $this->msg( 'oathauth-passkeys-add' )->text() )
576            ->setAttributes( [ 'class' => 'mw-special-OATHManage-passkeys__addbutton' ] )
577            ->build()
578            ->getHtml();
579        $passkeySection = Html::rawElement( 'div', [ 'class' => $passkeyClasses ],
580            Html::element( 'h3', [], $this->msg( 'oathauth-passkeys-header' )->text() ) .
581            $passkeyAccordions .
582            Html::rawElement( 'div', [ 'class' => 'mw-special-OATHManage-authmethods__addform' ],
583                $passkeyPlaceholder .
584                $passkeyAddButton
585            )
586        );
587
588        $output->addHTML( Html::rawElement( 'div', [ 'class' => 'mw-special-OATHManage-vue-container' ],
589            // If 2FA is enabled then put passkeys first, otherwise put 2FA first
590            $keyAccordions === '' ?
591                $authMethodsSection . $passkeySection :
592                $passkeySection . $authMethodsSection
593        ) );
594    }
595
596    private function addModuleHTML( IModule $module ): void {
597        if ( $this->isModuleRequested( $module ) ) {
598            $this->addCustomContent( $module );
599            return;
600        }
601
602        $panel = $this->getGenericContent( $module );
603        if ( $this->isModuleEnabled( $module ) ) {
604            $this->addCustomContent( $module, $panel );
605        }
606
607        $this->getOutput()->addHTML( (string)$panel );
608    }
609
610    /**
611     * Get the panel with generic content for a module
612     */
613    private function getGenericContent( IModule $module ): PanelLayout {
614        $modulePanel = new PanelLayout( [
615            'framed' => true,
616            'expanded' => false,
617            'padded' => true
618        ] );
619        $headerLayout = new HorizontalLayout();
620
621        if ( $this->shouldShowGenericButtons() && !$this->isModuleEnabled( $module ) ) {
622            $urlParams = [
623                'action' => static::ACTION_ENABLE,
624                'module' => $module->getName(),
625            ];
626            $button = new ButtonWidget( [
627                'label' => $this
628                    ->msg( 'oathauth-enable-generic' )
629                    ->text(),
630                'href' => $this->getOutput()->getTitle()->getLocalURL( $urlParams )
631            ] );
632            $headerLayout->addItems( [ $button ] );
633        }
634        $headerLayout->addItems( [
635            new LabelWidget( [
636                'label' => $module->getDisplayName()->text()
637            ] )
638        ] );
639
640        $modulePanel->appendContent( $headerLayout );
641        $modulePanel->appendContent( new HtmlSnippet(
642            $module->getDescriptionMessage()->parseAsBlock()
643        ) );
644        return $modulePanel;
645    }
646
647    /**
648     * Check max keys for a user and return true if max is exceeded
649     * @return bool
650     */
651    private function exceedsKeyLimit(): bool {
652        return count( $this->oathUser->getNonSpecialKeys() ) >= $this->getConfig()->get( 'OATHMaxKeysPerUser' );
653    }
654
655    private function addCustomContent( IModule $module, ?PanelLayout $panel = null ): void {
656        if ( $this->action === self::ACTION_ENABLE && $this->exceedsKeyLimit() ) {
657            throw new ErrorPageError(
658                'oathauth-max-keys-exceeded',
659                'oathauth-max-keys-exceeded-message',
660                [ Message::numParam( $this->getConfig()->get( 'OATHMaxKeysPerUser' ) ) ]
661            );
662        }
663
664        $form = $module->getManageForm(
665            $this->action,
666            $this->oathUser,
667            $this->userRepo,
668            $this->getContext(),
669            $this->moduleRegistry
670        );
671
672        if ( $form === null ) {
673            return;
674        }
675
676        $form->setTitle( $this->getOutput()->getTitle() );
677        $this->ensureRequiredFormFields( $form, $module );
678        $form->setSubmitCallback( [ $form, 'onSubmit' ] );
679        if ( $form->show( $panel ) ) {
680            $form->onSuccess();
681
682            // Only redirect for enabling a new key
683            if ( $this->action === self::ACTION_ENABLE ) {
684                $addedKeyName = $module->getDisplayName()->text();
685                $this->getOutput()->redirect(
686                    $this->getPageTitle()->getLocalURL( [
687                        'addsuccess' => $addedKeyName
688                    ] )
689                );
690                // Stop further rendering
691                return;
692            }
693        }
694    }
695
696    private function shouldShowGenericButtons(): bool {
697        return !$this->requestedModule instanceof IModule;
698    }
699
700    private function isModuleRequested( ?IModule $module ): bool {
701        return (
702            $this->requestedModule instanceof IModule
703            && $module instanceof IModule
704            && $this->requestedModule->getName() === $module->getName()
705        );
706    }
707
708    private function isModuleEnabled( IModule $module ): bool {
709        return (bool)$this->oathUser->getKeysForModule( $module->getName() );
710    }
711
712    /**
713     * Verifies if the module can be enabled
714     */
715    private function isModuleAvailable( IModule $module ): bool {
716        return $module->getManageForm(
717            static::ACTION_ENABLE,
718            $this->oathUser,
719            $this->userRepo,
720            $this->getContext(),
721            $this->moduleRegistry
722        ) !== null;
723    }
724
725    private function ensureRequiredFormFields( OATHAuthOOUIHTMLForm $form, IModule $module ): void {
726        if ( !$form->hasField( 'module' ) ) {
727            $form->addHiddenField( 'module', $module->getName() );
728        }
729        if ( !$form->hasField( 'action' ) ) {
730            $form->addHiddenField( 'action', $this->action );
731        }
732    }
733
734    /**
735     * When performing an action on a module (like enable/disable),
736     * the page should contain only the form for that action.
737     */
738    private function clearPage(): void {
739        $displayName = $this->requestedModule->getDisplayName();
740        $this->getOutput()->setPageTitleMsg(
741            $this->msg( 'oathauth-enable-page-title', $displayName )
742        );
743
744        $this->getOutput()->clearHTML();
745        $this->getOutput()->addBacklinkSubtitle( $this->getOutput()->getTitle() );
746    }
747
748    /**
749     * Returns special modules, which do not follow the constraints of standard modules.
750     * @return IModule[]
751     */
752    private function getSpecialModules(): array {
753        $modules = [];
754        foreach ( $this->moduleRegistry->getAllModules() as $module ) {
755            if ( $this->isModuleAvailable( $module ) && $module->isSpecial() ) {
756                $modules[] = $module;
757            }
758        }
759        return $modules;
760    }
761
762    /**
763     * Show the delete key warning/confirmation form using HTMLForm.
764     */
765    private function showDeleteWarning(): void {
766        $keyId = $this->getRequest()->getInt( 'keyId' );
767        $keyToDelete = $this->oathUser->getKeyById( $keyId );
768        if ( !$keyToDelete ) {
769            throw new ErrorPageError(
770                'oathauth-disable',
771                'oathauth-remove-nosuchkey'
772            );
773        }
774
775        if ( !$keyToDelete->supportsPasswordlessLogin() && !$this->canRemoveKeys() ) {
776            throw new ErrorPageError(
777                'oathauth-disable',
778                'oathauth-remove-lastkey-required'
779            );
780        }
781
782        $keyName = $this->getKeyNameAndDescription( $keyToDelete )['name'];
783        $remainingKeys = array_filter(
784            $this->oathUser->getNonSpecialKeys(),
785            static fn ( $key ) => $key->getId() !== $keyId && !$key->supportsPasswordlessLogin()
786        );
787        $lastKey = count( $remainingKeys ) === 0;
788
789        $this->getOutput()->setPageTitleMsg( $this->msg( 'oathauth-delete-warning-header', $keyName ) );
790        $this->getOutput()->addModuleStyles( 'ext.oath.manage.styles' );
791
792        $formDescriptor = [];
793        $warningDescription = $this->msg( 'oathauth-delete-warning' )->parse();
794
795        if ( $lastKey ) {
796            $formDescriptor['warning'] = [
797                'type' => 'info',
798                'raw' => true,
799                'default' => Html::warningBox( $this->msg( 'oathauth-delete-warning-final' )->parse() ),
800            ];
801        }
802
803        $formDescriptor['warning-description'] = [
804            'type' => 'info',
805            'raw' => true,
806            'default' => $warningDescription,
807        ];
808
809        if ( $lastKey ) {
810            $formDescriptor['remove-confirm-box'] = [
811                'type' => 'text',
812                'label-message' => 'oathauth-delete-confirm-box',
813                'required' => true,
814                'validation-callback' => function ( $value ) {
815                    $expectedText = $this->msg( 'oathauth-authenticator-delete-text' )->text();
816                    return $value !== $expectedText
817                        ? $this->msg( 'oathauth-delete-wrong-confirm-message' )->text()
818                        : true;
819                },
820            ];
821        }
822
823        $form = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
824        $form->setTitle( $this->getPageTitle() );
825
826        $form->addHiddenField( 'action', self::ACTION_DELETE );
827        $form->addHiddenField( 'module', $keyToDelete->getModule() );
828        $form->addHiddenField( 'keyId', (string)$keyId );
829
830        $form->setSubmitDestructive();
831        $form->setSubmitTextMsg( 'oathauth-authenticator-delete' );
832        $form->showCancel();
833        $form->setCancelTarget( $this->getPageTitle() );
834        $form->setWrapperLegend( false );
835
836        $form->setSubmitCallback( function ( $formData ) use ( $keyToDelete, $keyName, $lastKey ) {
837            $this->userRepo->removeKey(
838                $this->oathUser,
839                $keyToDelete,
840                $this->getRequest()->getIP(),
841                true
842            );
843
844            if ( $lastKey ) {
845                $this->userRepo->removeAll(
846                $this->oathUser,
847                $this->getRequest()->getIP(),
848                true
849                );
850
851                if ( ExtensionRegistry::getInstance()->isLoaded( 'CheckUser' ) ) {
852                    $logEntry = new ManualLogEntry( 'oath', 'disable-self' );
853                    $logEntry->setPerformer( $this->getUser() );
854                    $logEntry->setTarget( $this->getUser()->getUserPage() );
855                    /** @var CheckUserInsert $checkUserInsert */
856                    $checkUserInsert = MediaWikiServices::getInstance()->get( 'CheckUserInsert' );
857                    $checkUserInsert->updateCheckUserData( $logEntry->getRecentChange() );
858                }
859            }
860
861            $this->getOutput()->redirect( $this->getPageTitle()->getFullURL( [
862                'deletesuccess' => $keyName
863            ] ) );
864
865            return true;
866        } );
867
868        $this->getOutput()->addHTML( Html::openElement( 'div',
869            [ 'class' => 'mw-special-OATHManage-delete-warning' ]
870        ) );
871
872        $form->show();
873        $this->getOutput()->addHTML( Html::closeElement( 'div' ) );
874    }
875
876    /**
877     * Adds HTML for all available special modules
878     */
879    private function addSpecialModulesHTML(): void {
880        if ( !$this->oathUser->getKeys() ) {
881            return;
882        }
883        foreach ( $this->getSpecialModules() as $module ) {
884            $this->addSpecialModuleHTML( $module );
885        }
886    }
887
888    /**
889     * Adds special module HTML content
890     *
891     * Since special modules can vary in a number of ways from standard modules,
892     * there isn't much benefit to further abstracting/genericizing display logic
893     */
894    private function addSpecialModuleHTML( IModule $module ): void {
895        // only one special module type is currently supported
896        if ( $module instanceof RecoveryCodes ) {
897            $this->getRecoveryCodesHTML( $module );
898        }
899    }
900
901    private function getRecoveryCodesHTML( RecoveryCodes $module ): void {
902        $key = $module->ensureExistence( $this->oathUser );
903
904        $this->getOutput()->addModuleStyles( 'ext.oath.recovery.styles' );
905        $this->getOutput()->addModules( 'ext.oath.recovery' );
906        $codex = new Codex();
907        $placeholderMessage = '';
908
909        $this->setOutputJsConfigVars(
910            $this->getRecoveryCodesForDisplay( $key )
911        );
912
913        $keyAccordion = $codex->accordion()
914            ->setTitle( $module->getDisplayName()->text() )
915            ->setDescription(
916                $this->msg( 'oathauth-recoverycodes' )->text()
917            )
918            // TODO support outlined Accordions in Codex-PHP (T416645)
919            ->setAttributes( [ 'class' => 'cdx-accordion--separation-outline' ] )
920            ->setContentHtml( $codex->htmlSnippet()->setContent(
921                Html::rawElement( 'form', [
922                        'action' => wfScript(),
923                        'class' => 'mw-special-OATHManage-authmethods__method-actions'
924                    ],
925                    Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
926                    Html::hidden( 'module', $key->getModule() ) .
927                    Html::hidden( 'keyId', $key->getId() ) .
928                    $this->createRecoveryCodesCopyButton() .
929                    $this->createRecoveryCodesDownloadLink(
930                        $this->getRecoveryCodesForDisplay( $key )
931                    ) .
932                    $codex->button()
933                        ->setLabel( $this->msg(
934                            'oathauth-recoverycodes-create-label',
935                            $this->getConfig()->get( 'OATHRecoveryCodesCount' )
936                        )->parse() )
937                        ->setType( 'submit' )
938                        ->setAttributes( [ 'name' => 'action', 'value' => 'create-' . $module->getName() ] )
939                        ->build()
940                        ->getHtml()
941                )
942            )->build() );
943        $accordionsHtml = $keyAccordion->build()->getHtml();
944
945        $expiringCodes = array_filter(
946            $key->getRecoveryCodes(),
947            static fn ( RecoveryCode $code ) => !$code->isPermanent()
948        );
949        if ( $expiringCodes ) {
950            $expiringCodesCount = count( $expiringCodes );
951            $maxExpiry = '';
952            foreach ( $expiringCodes as $code ) {
953                $codeExpiry = $code->getExpiryTimestamp();
954                if ( $codeExpiry && $codeExpiry > $maxExpiry ) {
955                    $maxExpiry = $codeExpiry;
956                }
957            }
958            $temporaryCodesAccordion = $codex->accordion()
959                ->setTitle(
960                    $this->msg( 'oathauth-recoverycodes-temporary' )
961                        ->numParams( $expiringCodesCount )
962                        ->text()
963                )
964                ->setDescription(
965                    $this->msg( 'oathauth-recoverycodes-temporary-desc' )
966                        ->numParams( $expiringCodesCount )
967                        ->dateTimeParams( $maxExpiry )
968                        ->dateParams( $maxExpiry )
969                        ->timeParams( $maxExpiry )
970                        ->text()
971                )
972                // TODO support outlined Accordions in Codex-PHP (T416645)
973                ->setAttributes( [ 'class' => 'cdx-accordion--separation-outline' ] )
974                ->setContentHtml( $codex->htmlSnippet()->setContent(
975                    $this->msg( 'oathauth-recoverycodes-temporary-intro' )
976                        ->numParams( $expiringCodesCount )
977                        ->parse() .
978                    Html::rawElement( 'form', [
979                        'action' => wfScript(),
980                        'class' => 'mw-special-OATHManage-authmethods__method-actions'
981                    ],
982                        Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
983                        Html::hidden( 'module', $key->getModule() ) .
984                        Html::hidden( 'keyId', $key->getId() ) .
985                        $codex->button()
986                            ->setLabel( $this->msg(
987                                'oathauth-recoverycodes-temporary-remove-label'
988                            )->parse() )
989                            ->setAction( 'destructive' )
990                            ->setWeight( 'primary' )
991                            ->setType( 'submit' )
992                            ->setAttributes( [
993                                'name' => 'action',
994                                'value' => RecoveryCodes::ACTION_REMOVE_TEMPORARY
995                            ] )
996                            ->build()
997                            ->getHtml()
998                    )
999                )->build() );
1000            $accordionsHtml .= $temporaryCodesAccordion->build()->getHtml();
1001        }
1002
1003        $authmethodsClasses = [
1004            'mw-special-OATHManage-authmethods'
1005        ];
1006        if ( !$this->oathUser->getKeys() ) {
1007            $authmethodsClasses[] = 'mw-special-OATHManage-authmethods--no-keys';
1008        }
1009
1010        $this->getOutput()->addHTML(
1011            Html::rawElement( 'div', [ 'class' => $authmethodsClasses ],
1012                Html::element( 'h3', [], $this->msg( 'oathauth-' . $module->getName() . '-header' )->text() ) .
1013                $accordionsHtml .
1014                Html::rawElement( 'form', [
1015                        'action' => wfScript(),
1016                        'class' => 'mw-special-OATHManage-authmethods__addform'
1017                    ],
1018                    Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
1019                    Html::hidden( 'action', 'enable' ) .
1020                    $placeholderMessage
1021                )
1022            )
1023        );
1024    }
1025
1026    private function hasSpecialModules(): bool {
1027        return $this->getSpecialModules() !== [];
1028    }
1029}