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