Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.28% covered (danger)
3.28%
6 / 183
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookHandler
3.28% covered (danger)
3.28%
6 / 183
33.33% covered (danger)
33.33%
3 / 9
2132.73
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
 onAuthChangeFormFields
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 1
600
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
30
 getOathManageModuleData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onUserRequirementsCondition
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 onReadPrivateUserRequirementsCondition
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 onUserModifyCreateAccountEmail
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 onSiteNoticeAfter
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2declare( strict_types=1 );
3
4namespace MediaWiki\Extension\OATHAuth\Hook;
5
6use MediaWiki\Auth\AuthenticationRequest;
7use MediaWiki\Auth\ElevatedSecurityAuthenticationRequest;
8use MediaWiki\Config\Config;
9use MediaWiki\Extension\OATHAuth\Auth\SecondaryAuthenticationProvider;
10use MediaWiki\Extension\OATHAuth\Auth\WebAuthnAuthenticationRequest;
11use MediaWiki\Extension\OATHAuth\ExpiringRecoveryCodeGenerator;
12use MediaWiki\Extension\OATHAuth\HTMLField\NoJsInfoField;
13use MediaWiki\Extension\OATHAuth\Key\AuthKey;
14use MediaWiki\Extension\OATHAuth\Key\RecoveryCode;
15use MediaWiki\Extension\OATHAuth\Key\RecoveryCodeKeys;
16use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
17use MediaWiki\Extension\OATHAuth\OATHAuthLogger;
18use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
19use MediaWiki\Extension\OATHAuth\OATHUserRepository;
20use MediaWiki\Html\Html;
21use MediaWiki\Message\Message;
22use MediaWiki\Output\Hook\BeforePageDisplayHook;
23use MediaWiki\Permissions\PermissionManager;
24use MediaWiki\Preferences\Hook\GetPreferencesHook;
25use MediaWiki\ResourceLoader\Context;
26use MediaWiki\Skin\Hook\SiteNoticeAfterHook;
27use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
28use MediaWiki\SpecialPage\SpecialPage;
29use MediaWiki\User\Hook\ReadPrivateUserRequirementsConditionHook;
30use MediaWiki\User\Hook\UserModifyCreateAccountEmailHook;
31use MediaWiki\User\Hook\UserRequirementsConditionHook;
32use MediaWiki\User\User;
33use MediaWiki\User\UserIdentity;
34use OOUI\ButtonWidget;
35use OOUI\HorizontalLayout;
36use OOUI\LabelWidget;
37use Wikimedia\Message\ListParam;
38use Wikimedia\Message\ListType;
39use Wikimedia\Timestamp\ConvertibleTimestamp;
40use Wikimedia\Timestamp\TimestampFormat as TS;
41
42class HookHandler implements
43    AuthChangeFormFieldsHook,
44    BeforePageDisplayHook,
45    GetPreferencesHook,
46    ReadPrivateUserRequirementsConditionHook,
47    SiteNoticeAfterHook,
48    UserModifyCreateAccountEmailHook,
49    UserRequirementsConditionHook
50{
51    public function __construct(
52        private readonly OATHUserRepository $userRepo,
53        private readonly OATHAuthModuleRegistry $moduleRegistry,
54        private readonly OATHAuthLogger $oathLogger,
55        private readonly ExpiringRecoveryCodeGenerator $recoveryCodeGenerator,
56        private readonly PermissionManager $permissionManager,
57        private readonly Config $config,
58    ) {
59    }
60
61    /** @inheritDoc */
62    public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) {
63        if ( isset( $fieldInfo['OATHToken'] ) ) {
64            $formDescriptor['OATHToken'] += [
65                'cssClass' => 'loginText',
66                'id' => 'wpOATHToken',
67                'size' => 20,
68                'dir' => 'ltr',
69                'autofocus' => true,
70                'persistent' => false,
71                'autocomplete' => 'one-time-code',
72                'spellcheck' => false,
73                'inputmode' => 'numeric',
74                'help-message' => 'oathauth-auth-token-help-ui',
75            ];
76        }
77
78        if ( isset( $fieldInfo['RecoveryCode'] ) ) {
79            $formDescriptor['RecoveryCode'] += [
80                'dir' => 'ltr',
81                'autofocus' => true,
82                'persistent' => false,
83                'autocomplete' => 'off',
84                'spellcheck' => false,
85                'help-message' => $fieldInfo['RecoveryCode']['help']
86            ];
87        }
88
89        if ( isset( $fieldInfo['info-temporary-recovery-code'] ) ) {
90            // Transform the info-temporary-recovery-code field into a notice box
91            $formDescriptor['info-temporary-recovery-code']['default'] = Html::noticeBox(
92                wfMessage( 'oathauth-auth-initial-recovery-code-info' )->parseAsBlock()
93            );
94            $formDescriptor['info-temporary-recovery-code']['raw'] = true;
95        }
96
97        if ( isset( $fieldInfo['newModule'] ) ) {
98            // HACK: Hide the newModule <select>, but keep it in form, otherwise HTMLForm won't
99            // understand the button weirdness below. There's no great way for us to inject CSS, so
100            // abuse a CSS class from core that has display: none; on it.
101            // TODO: Make this multi-button thing a real HTMLForm field (T404664)
102            $formDescriptor['newModule']['cssclass'] = 'emptyPortlet';
103            if ( isset( $formDescriptor['OATHToken'] ) ) {
104                // Don't make the TOTP token field required. Otherwise, the "Switch to XYZ" submit
105                // buttons can't be used without filling in this field
106                $formDescriptor['OATHToken']['required'] = false;
107            }
108            // Check the weight of the form submit button to make sure other authentication
109            // options are placed below it
110            $loginButtonWeight = $formDescriptor['loginattempt']['weight'] ?? 100;
111
112            $availableModules = $fieldInfo['newModule']['options'];
113            // Remove the empty option for not switching first
114            unset( $availableModules[''] );
115
116            // Reorder 2FA types according to SecondaryAuthenticationProvider Module Priority
117            $orderedModules = [];
118            foreach ( SecondaryAuthenticationProvider::MODULE_PRIORITY as $moduleName ) {
119                if ( isset( $availableModules[$moduleName] ) ) {
120                    $orderedModules[$moduleName] = $availableModules[$moduleName];
121                    unset( $availableModules[$moduleName] );
122                }
123            }
124            // Append any remaining modules that weren’t in the priority list
125            $availableModules = $orderedModules + $availableModules;
126
127            $extraWeight = 1;
128            foreach ( $availableModules as $moduleName => $ignored ) {
129                // Add a switch button for each alternative module, all with name="newModule"
130                // Whichever button is clicked will submit the form, with newModule set to its value
131                $buttonMessage = $this->moduleRegistry->getModuleByKey( $moduleName )->getLoginSwitchButtonMessage();
132                $formDescriptor["newModule_$moduleName"] = [
133                    'type' => 'submit',
134                    'name' => 'newModule',
135                    // Set and explicit id, as HTMLFormField assigns autogenerates ids based on the name field.
136                    // As all buttons share the same name="newModule" field, all would get the same id otherwise
137                    // Duplicate IDs confuse VoiceOver, making it announce all buttons with the same label.
138                    'id' => "mw-input-newModule_$moduleName",
139                    'default' => $moduleName,
140                    'buttonlabel' => $buttonMessage->text(),
141                    // Make sure these buttons appear after the loginattempt button
142                    'weight' => $loginButtonWeight + $extraWeight,
143                    'flags' => [],
144                ];
145                $extraWeight++;
146            }
147        }
148
149        $webauthnReq = AuthenticationRequest::getRequestByClass( $requests, WebAuthnAuthenticationRequest::class );
150        // Display a message about needing JavaScript for WebAuthn, but don't display it if we're on
151        // the initial login page (the WebAuthnAuthenticationRequest there is for passwordless login)
152        if ( $webauthnReq && !isset( $fieldInfo['username'] ) ) {
153            $formDescriptor['webauthn-nojs'] = [
154                'class' => NoJsInfoField::class,
155                'weight' => -50,
156            ];
157        }
158
159        $reauthReq = AuthenticationRequest::getRequestByClass( $requests,
160            ElevatedSecurityAuthenticationRequest::class );
161        if ( $reauthReq ) {
162            // If this is a reauth and we're offering a 2FA method directly, remove the password field
163            if ( $webauthnReq || isset( $fieldInfo['OATHToken'] ) ) {
164                unset( $formDescriptor['password'] );
165            }
166            // Also remove the "Log in" button if there is a WebAuthn request: in that case the
167            // passkey or security key button will be primary
168            if ( $webauthnReq ) {
169                unset( $formDescriptor['loginattempt'] );
170            }
171        }
172
173        if (
174            $this->config->get( 'OATHAuth2FAForAllWarnings' ) &&
175            !$this->config->get( 'OATHAuthEnforce2FAForAll' ) &&
176            isset( $fieldInfo['username'] )
177        ) {
178            // 2FA is going to be required for all users, but is not yet. Show a message at the top
179            // of the first stage of the login form.
180            $formDescriptor['info-2fa-required-soon'] = [
181                'type' => 'info',
182                'default' => Html::noticeBox(
183                    wfMessage( 'oathauth-2fa-required-soon' )->parseAsBlock()
184                ),
185                'raw' => true,
186                'weight' => -100,
187            ];
188        }
189
190        if ( $this->config->get( 'OATHPasswordlessLogin' ) ) {
191            if ( isset( $fieldInfo['username'] ) && isset( $fieldInfo['credential'] ) ) {
192                $formDescriptor['username']['autocomplete'] = 'username webauthn';
193
194                // HACK autofocus the username even when it's prepopulated
195                $formDescriptor['username']['autofocus'] = true;
196                if ( isset( $formDescriptor['password']['autofocus'] ) ) {
197                    unset( $formDescriptor['password']['autofocus'] );
198                }
199            }
200
201            if ( isset( $fieldInfo['passwordlessButton'] ) && !$reauthReq ) {
202                // Make the "Log in with passkey" button a non-primary, non-submit button, make it
203                // progressive, and put it below the login button
204                $formDescriptor['passwordlessButton']['type'] = 'button';
205                $formDescriptor['passwordlessButton']['flags'] = [ 'progressive' ];
206                $formDescriptor['passwordlessButton']['weight'] = 110;
207            }
208        }
209
210        return true;
211    }
212
213    /** @inheritDoc */
214    public function onGetPreferences( $user, &$preferences ) {
215        $oathUser = $this->userRepo->findByUser( $user );
216
217        // If there is no existing module for the user, and the user is not allowed to enable it,
218        // we have nothing to show.
219        if (
220            !$oathUser->isTwoFactorAuthEnabled() &&
221            !$this->permissionManager->userHasRight( $user, 'oathauth-enable' )
222        ) {
223            return true;
224        }
225
226        $modules = array_unique( array_map(
227            static fn ( AuthKey $key ) => $key->getModule(),
228            $oathUser->getKeys(),
229        ) );
230        $moduleNames = array_map(
231            fn ( string $moduleId ) => $this->moduleRegistry
232                ->getModuleByKey( $moduleId )
233                ->getDisplayName(),
234            $modules
235        );
236
237        if ( count( $moduleNames ) > 1 ) {
238            $moduleLabel = wfMessage( 'rawmessage' )
239                ->params( new ListParam( ListType::AND, $moduleNames ) );
240        } elseif ( $moduleNames ) {
241            $moduleLabel = $moduleNames[0];
242        } else {
243            $moduleLabel = wfMessage( 'oathauth-ui-no-module' );
244        }
245
246        $manageButton = new ButtonWidget( [
247            'href' => SpecialPage::getTitleFor( 'OATHManage' )->getLocalURL(),
248            'label' => wfMessage( 'oathauth-ui-manage' )->text()
249        ] );
250
251        $currentModuleLabel = new LabelWidget( [
252            'label' => $moduleLabel->text(),
253        ] );
254
255        $control = new HorizontalLayout( [
256            'items' => [
257                $currentModuleLabel,
258                $manageButton
259            ]
260        ] );
261
262        $preferences['oathauth-module'] = [
263            'type' => 'info',
264            'raw' => true,
265            'default' => (string)$control,
266            'label-message' => 'oathauth-prefs-label',
267            'section' => 'personal/accountsecurity',
268        ];
269
270        return true;
271    }
272
273    /**
274     * Callback that generates the contents of the virtual data.json file in the ext.oath.manage
275     * ResourceLoader module.
276     */
277    public static function getOathManageModuleData( Context $context ): array {
278        return [
279            'passkeyDialogTextHtml' => $context->msg( 'oathauth-passkey-dialog-text' )->parseAsBlock()
280        ];
281    }
282
283    /** @inheritDoc */
284    public function onUserRequirementsCondition(
285        string|int $type,
286        array $args,
287        UserIdentity $user,
288        bool $isPerformingRequest,
289        ?bool &$result
290    ): void {
291        if ( $type !== APCOND_OATH_HAS2FA ) {
292            return;
293        }
294        $result = $this->userRepo->userHas2FAEnabled( $user );
295    }
296
297    /** @inheritDoc */
298    public function onReadPrivateUserRequirementsCondition(
299        UserIdentity $performer,
300        UserIdentity $target,
301        array $conditions
302    ): void {
303        if ( in_array( APCOND_OATH_HAS2FA, $conditions ) ) {
304            $this->oathLogger->logImplicitVerification( $performer, $target );
305        }
306    }
307
308    /** @inheritDoc */
309    public function onBeforePageDisplay( $out, $skin ): void {
310        if (
311            $this->config->get( 'OATHPasswordlessLogin' ) &&
312            $out->getTitle()->isSpecial( 'Userlogin' )
313        ) {
314            $out->addModules( 'ext.webauthn.passwordlessLogin' );
315            $out->addModuleStyles( 'ext.webauthn.passwordlessLogin.styles' );
316        }
317    }
318
319    public function onUserModifyCreateAccountEmail(
320        User $user,
321        User $performer,
322        Message &$subject,
323        Message &$body
324    ): void {
325        if ( !$this->config->get( 'OATHAuthEnforce2FAForAll' ) && !$this->userRepo->userHas2FAEnabled( $user ) ) {
326            return;
327        }
328
329        $status = $this->recoveryCodeGenerator->attemptToCreateInitial2FACodes(
330            performer: $performer,
331            username: $user->getName(),
332            email: $user->getEmail(),
333            sendEmail: false,
334        );
335
336        $recoveryCodes = $status->getValue();
337        $expiryTimestamp = ConvertibleTimestamp::convert(
338            TS::MW,
339            (int)ConvertibleTimestamp::now( TS::UNIX ) +
340                $this->config->get( 'OATHInitialRecoveryCodesValidityDays' ) * 86_400
341        );
342        $newBody = wfMessage( 'oathauth-createaccount-text-with-2fa' )
343            ->params(
344                $body,
345                count( $recoveryCodes ),
346                implode( "\n", $recoveryCodes ),
347                Message::dateParam( $expiryTimestamp )
348            );
349        $body = $newBody;
350    }
351
352    /** @inheritDoc */
353    public function onSiteNoticeAfter( &$siteNotice, $skin ): void {
354        if (
355            !$this->config->get( 'OATHAuthEnforce2FAForAll' ) ||
356            !$this->config->get( 'OATHAuth2FAForAllWarnings' )
357        ) {
358            return;
359        }
360        $oathUser = $this->userRepo->findByUser( $skin->getUser() );
361        if ( !$oathUser->isTwoFactorAuthEnabled() || $oathUser->userHasNonSpecialEnabledKeys() ) {
362            return;
363        }
364
365        // The user only has recovery codes. Display a message urging them to set up other 2FA.
366        $recoveryCodes = $oathUser->getKeysForModule( RecoveryCodes::MODULE_NAME )[ 0 ] ?? null;
367        if ( !$recoveryCodes instanceof RecoveryCodeKeys ) {
368            return;
369        }
370        // Get the expiry date of the user's initial recovery codes
371        $expiryTimestamp = max( array_map(
372            static fn ( RecoveryCode $code ) => $code->isInitial() ? $code->getExpiryTimestamp() : null,
373            $recoveryCodes->getRecoveryCodes()
374        ) );
375
376        $siteNotice .= Html::warningBox(
377            $skin->msg( 'oathauth-recovery-code-only-sitenotice' )
378                ->dateParams( $expiryTimestamp )
379                ->parseAsBlock(),
380            'mw-oathauth-sitenotice'
381        );
382    }
383}