Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.67% covered (danger)
4.67%
7 / 150
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
HookHandler
4.67% covered (danger)
4.67%
7 / 150
30.00% covered (danger)
30.00%
3 / 10
1426.29
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 / 58
0.00% covered (danger)
0.00%
0 / 1
156
 onGetPreferences
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
72
 getDisabledGroups
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onUserEffectiveGroups
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onUserGetRights
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 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%
4 / 4
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 / 3
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\Extension\OATHAuth\Hook;
4
5use BadMethodCallException;
6use MediaWiki\Auth\AuthenticationRequest;
7use MediaWiki\Config\Config;
8use MediaWiki\Context\RequestContext;
9use MediaWiki\Extension\OATHAuth\Auth\WebAuthnAuthenticationRequest;
10use MediaWiki\Extension\OATHAuth\HTMLField\NoJsInfoField;
11use MediaWiki\Extension\OATHAuth\Key\AuthKey;
12use MediaWiki\Extension\OATHAuth\OATHAuthLogger;
13use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
14use MediaWiki\Extension\OATHAuth\OATHUserRepository;
15use MediaWiki\Message\Message;
16use MediaWiki\Output\Hook\BeforePageDisplayHook;
17use MediaWiki\Permissions\Hook\UserGetRightsHook;
18use MediaWiki\Permissions\PermissionManager;
19use MediaWiki\Preferences\Hook\GetPreferencesHook;
20use MediaWiki\ResourceLoader\Context;
21use MediaWiki\SpecialPage\Hook\AuthChangeFormFieldsHook;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\User\Hook\ReadPrivateUserRequirementsConditionHook;
24use MediaWiki\User\Hook\UserEffectiveGroupsHook;
25use MediaWiki\User\Hook\UserRequirementsConditionHook;
26use MediaWiki\User\User;
27use MediaWiki\User\UserGroupManager;
28use MediaWiki\User\UserGroupMembership;
29use MediaWiki\User\UserIdentity;
30use OOUI\ButtonWidget;
31use OOUI\HorizontalLayout;
32use OOUI\LabelWidget;
33use Wikimedia\Message\ListParam;
34use Wikimedia\Message\ListType;
35
36class HookHandler implements
37    AuthChangeFormFieldsHook,
38    BeforePageDisplayHook,
39    GetPreferencesHook,
40    ReadPrivateUserRequirementsConditionHook,
41    UserEffectiveGroupsHook,
42    UserGetRightsHook,
43    UserRequirementsConditionHook
44{
45    public function __construct(
46        private readonly OATHUserRepository $userRepo,
47        private readonly OATHAuthModuleRegistry $moduleRegistry,
48        private readonly OATHAuthLogger $oathLogger,
49        private readonly PermissionManager $permissionManager,
50        private readonly Config $config,
51        private readonly UserGroupManager $userGroupManager,
52    ) {
53    }
54
55    /** @inheritDoc */
56    public function onAuthChangeFormFields( $requests, $fieldInfo, &$formDescriptor, $action ) {
57        if ( isset( $fieldInfo['OATHToken'] ) ) {
58            $formDescriptor['OATHToken'] += [
59                'cssClass' => 'loginText',
60                'id' => 'wpOATHToken',
61                'size' => 20,
62                'dir' => 'ltr',
63                'autofocus' => true,
64                'persistent' => false,
65                'autocomplete' => 'one-time-code',
66                'spellcheck' => false,
67                'help-message' => 'oathauth-auth-token-help-ui',
68            ];
69        }
70
71        if ( isset( $fieldInfo['RecoveryCode'] ) ) {
72            $formDescriptor['RecoveryCode'] += [
73                'dir' => 'ltr',
74                'autofocus' => true,
75                'persistent' => false,
76                'autocomplete' => 'off',
77                'spellcheck' => false,
78                'help-message' => 'oathauth-auth-recovery-code-help',
79            ];
80        }
81
82        if ( isset( $fieldInfo['newModule'] ) ) {
83            // HACK: Hide the newModule <select>, but keep it in form, otherwise HTMLForm won't
84            // understand the button weirdness below. There's no great way for us to inject CSS, so
85            // abuse a CSS class from core that has display: none; on it.
86            // TODO: Make this multi-button thing a real HTMLForm field (T404664)
87            $formDescriptor['newModule']['cssclass'] = 'emptyPortlet';
88            if ( isset( $formDescriptor['OATHToken'] ) ) {
89                // Don't make the TOTP token field required. Otherwise, the "Switch to XYZ" submit
90                // buttons can't be used without filling in this field
91                $formDescriptor['OATHToken']['required'] = false;
92            }
93            // Check the weight of the form submit button to make sure other authentication
94            // options are placed below it
95            $loginButtonWeight = $formDescriptor['loginattempt']['weight'] ?? 100;
96
97            $availableModules = $fieldInfo['newModule']['options'];
98            // Remove the empty option for not switching first
99            unset( $availableModules[''] );
100
101            // Reorder 2FA types according to OATHPrioritizedModules
102            $orderedModules = [];
103            foreach ( $this->config->get( 'OATHPrioritizedModules' ) as $moduleName ) {
104                if ( isset( $availableModules[$moduleName] ) ) {
105                    $orderedModules[$moduleName] = $availableModules[$moduleName];
106                    unset( $availableModules[$moduleName] );
107                }
108            }
109            // Append any remaining modules that weren’t in the priority list
110            $availableModules = $orderedModules + $availableModules;
111
112            $extraWeight = 1;
113            foreach ( $availableModules as $moduleName => $ignored ) {
114                // Add a switch button for each alternative module, all with name="newModule"
115                // Whichever button is clicked will submit the form, with newModule set to its value
116                $buttonMessage = $this->moduleRegistry->getModuleByKey( $moduleName )->getLoginSwitchButtonMessage();
117                $formDescriptor["newModule_$moduleName"] = [
118                    'type' => 'submit',
119                    'name' => 'newModule',
120                    'default' => $moduleName,
121                    'buttonlabel' => $buttonMessage->text(),
122                    // Make sure these buttons appear after the loginattempt button
123                    'weight' => $loginButtonWeight + $extraWeight,
124                    'flags' => [],
125                ];
126                $extraWeight++;
127            }
128        }
129
130        $req = AuthenticationRequest::getRequestByClass( $requests, WebAuthnAuthenticationRequest::class );
131        if ( $req ) {
132            $formDescriptor['webauthn-nojs'] = [
133                'class' => NoJsInfoField::class,
134                'weight' => -50,
135            ];
136        }
137
138        if ( $this->config->get( 'OATHPasswordlessLogin' ) && isset( $fieldInfo['username'] ) ) {
139            $formDescriptor['username']['autocomplete'] = 'username webauthn';
140
141            // HACK autofocus the username even when it's prepopulated
142            $formDescriptor['username']['autofocus'] = true;
143            if ( isset( $formDescriptor['password']['autofocus'] ) ) {
144                unset( $formDescriptor['password']['autofocus'] );
145            }
146        }
147
148        return true;
149    }
150
151    /** @inheritDoc */
152    public function onGetPreferences( $user, &$preferences ) {
153        $oathUser = $this->userRepo->findByUser( $user );
154
155        // If there is no existing module for the user, and the user is not allowed to enable it,
156        // we have nothing to show.
157        if (
158            !$oathUser->isTwoFactorAuthEnabled() &&
159            !$this->permissionManager->userHasRight( $user, 'oathauth-enable' )
160        ) {
161            return true;
162        }
163
164        $modules = array_unique( array_map(
165            static fn ( AuthKey $key ) => $key->getModule(),
166            $oathUser->getKeys(),
167        ) );
168        $moduleNames = array_map(
169            fn ( string $moduleId ) => $this->moduleRegistry
170                ->getModuleByKey( $moduleId )
171                ->getDisplayName(),
172            $modules
173        );
174
175        if ( count( $moduleNames ) > 1 ) {
176            $moduleLabel = wfMessage( 'rawmessage' )
177                ->params( new ListParam( ListType::AND, $moduleNames ) );
178        } elseif ( $moduleNames ) {
179            $moduleLabel = $moduleNames[0];
180        } else {
181            $moduleLabel = wfMessage( 'oathauth-ui-no-module' );
182        }
183
184        $manageButton = new ButtonWidget( [
185            'href' => SpecialPage::getTitleFor( 'OATHManage' )->getLocalURL(),
186            'label' => wfMessage( 'oathauth-ui-manage' )->text()
187        ] );
188
189        $currentModuleLabel = new LabelWidget( [
190            'label' => $moduleLabel->text(),
191        ] );
192
193        $control = new HorizontalLayout( [
194            'items' => [
195                $currentModuleLabel,
196                $manageButton
197            ]
198        ] );
199
200        $preferences['oathauth-module'] = [
201            'type' => 'info',
202            'raw' => true,
203            'default' => (string)$control,
204            'label-message' => 'oathauth-prefs-label',
205            'section' => 'personal/info',
206        ];
207
208        $disabledGroups = $this->getDisabledGroups( $user, $this->userGroupManager->getUserGroups( $user ) );
209        if ( $disabledGroups && !$oathUser->isTwoFactorAuthEnabled() ) {
210            $context = RequestContext::getMain();
211            $list = [];
212            foreach ( $disabledGroups as $disabledGroup ) {
213                $list[] = UserGroupMembership::getLinkHTML( $disabledGroup, $context );
214            }
215            $info = $context->getLanguage()->commaList( $list );
216            $disabledInfo = [ 'oathauth-disabledgroups' => [
217                'type' => 'info',
218                'label-message' => [ 'oathauth-prefs-disabledgroups',
219                    Message::numParam( count( $disabledGroups ) ) ],
220                'help-message' => [ 'oathauth-prefs-disabledgroups-help',
221                    Message::numParam( count( $disabledGroups ) ), $user->getName() ],
222                'default' => $info,
223                'raw' => true,
224                'section' => 'personal/info',
225            ] ];
226            // Insert right after "Member of groups"
227            $preferences = wfArrayInsertAfter( $preferences, $disabledInfo, 'usergroups' );
228        }
229
230        return true;
231    }
232
233    /**
234     * Return the groups that this user is supposed to be in, but are disabled
235     * because 2FA isn't enabled
236     *
237     * @param User $user
238     * @param string[] $groups All groups the user is supposed to be in
239     * @return string[] Groups the user should be disabled in
240     */
241    private function getDisabledGroups( User $user, array $groups ): array {
242        $requiredGroups = $this->config->get( 'OATHRequiredForGroups' );
243        // Bail early if:
244        // * No configured restricted groups
245        // * The user is not in any of the restricted groups
246        $intersect = array_intersect( $groups, $requiredGroups );
247        if ( !$requiredGroups || !$intersect ) {
248            return [];
249        }
250
251        $oathUser = $this->userRepo->findByUser( $user );
252        if ( !$oathUser->isTwoFactorAuthEnabled() ) {
253            // Not enabled, strip the groups
254            return $intersect;
255        }
256
257        return [];
258    }
259
260    /**
261     * Remove groups if 2FA is required for them and it's not enabled
262     *
263     * @inheritDoc
264     */
265    public function onUserEffectiveGroups( $user, &$groups ) {
266        // If the user has 2FA disabled, don't leak that information to other users (T412061)
267        try {
268            if ( !$user->equals( RequestContext::getMain()->getUser() ) ) {
269                return;
270            }
271        } catch ( BadMethodCallException ) {
272            // If we got this exception, it means we are in a session-less entry point.
273            // Treat this as if the current user is not the same as $user, and don't expose
274            // $user's potential lack of 2FA
275            return;
276        }
277
278        $disabledGroups = $this->getDisabledGroups( $user, $groups );
279        if ( $disabledGroups ) {
280            $groups = array_diff( $groups, $disabledGroups );
281        }
282    }
283
284    /**
285     * If a user has groups disabled for not having 2FA enabled, make sure they
286     * have "oathauth-enable" so they can turn it on
287     *
288     * @inheritDoc
289     */
290    public function onUserGetRights( $user, &$rights ) {
291        if ( in_array( 'oathauth-enable', $rights ) ) {
292            return;
293        }
294
295        $dbGroups = $this->userGroupManager->getUserGroups( $user );
296        if ( $this->getDisabledGroups( $user, $dbGroups ) ) {
297            // User has some disabled groups, add oathauth-enable
298            $rights[] = 'oathauth-enable';
299        }
300    }
301
302    /**
303     * Callback that generates the contents of the virtual data.json file in the ext.oath.manage
304     * ResourceLoader module.
305     */
306    public static function getOathManageModuleData( Context $context ): array {
307        return [
308            'passkeyDialogTextHtml' => $context->msg( 'oathauth-passkey-dialog-text' )->parseAsBlock()
309        ];
310    }
311
312    /** @inheritDoc */
313    public function onUserRequirementsCondition(
314        string|int $type,
315        array $args,
316        UserIdentity $user,
317        bool $isPerformingRequest,
318        ?bool &$result
319    ): void {
320        if ( $type !== APCOND_OATH_HAS2FA ) {
321            return;
322        }
323
324        $oathUser = $this->userRepo->findByUser( $user );
325        $result = $oathUser->isTwoFactorAuthEnabled();
326    }
327
328    /** @inheritDoc */
329    public function onReadPrivateUserRequirementsCondition(
330        UserIdentity $performer,
331        UserIdentity $target,
332        array $conditions
333    ): void {
334        if ( in_array( APCOND_OATH_HAS2FA, $conditions ) ) {
335            $this->oathLogger->logImplicitVerification( $performer, $target );
336        }
337    }
338
339    /** @inheritDoc */
340    public function onBeforePageDisplay( $out, $skin ): void {
341        if (
342            $this->config->get( 'OATHPasswordlessLogin' ) &&
343            $out->getTitle()->isSpecial( 'Userlogin' )
344        ) {
345            $out->addModules( 'ext.webauthn.passwordlessLogin' );
346        }
347    }
348}