Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.92% covered (warning)
77.92%
60 / 77
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SecondaryAuthenticationProvider
77.92% covered (warning)
77.92%
60 / 77
40.00% covered (danger)
40.00%
4 / 10
43.02
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
 getAuthenticationRequests
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beginSecondaryAccountCreation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beginSecondaryAuthentication
54.17% covered (warning)
54.17%
13 / 24
0.00% covered (danger)
0.00%
0 / 1
9.47
 continueSecondaryAuthentication
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 getModule
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getModuleFromRequest
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 getDefaultModule
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
7.33
 getProviderForModule
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 maybeAddSelectAuthenticationRequest
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2declare( strict_types=1 );
3
4namespace MediaWiki\Extension\OATHAuth\Auth;
5
6use LogicException;
7use MediaWiki\Auth\AbstractSecondaryAuthenticationProvider;
8use MediaWiki\Auth\AuthenticationRequest;
9use MediaWiki\Auth\AuthenticationResponse;
10use MediaWiki\Context\RequestContext;
11use MediaWiki\Extension\OATHAuth\ExpiringRecoveryCodeGenerator;
12use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
13use MediaWiki\Extension\OATHAuth\OATHAuthLogger;
14use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
15use MediaWiki\Extension\OATHAuth\OATHUser;
16use MediaWiki\Extension\OATHAuth\OATHUserRepository;
17use MediaWiki\HookContainer\HookContainer;
18use MediaWiki\User\UserNameUtils;
19
20class SecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
21
22    public const array MODULE_PRIORITY = [ 'webauthn', 'totp', 'recoverycodes' ];
23    public const string SUCCESS_KEY = 'oathauth-skip-2fa';
24
25    public function __construct(
26        private readonly OATHAuthLogger $oathLogger,
27        private readonly OATHAuthModuleRegistry $moduleRegistry,
28        private readonly OATHUserRepository $userRepository,
29        private readonly HookContainer $hookContainer,
30        private readonly UserNameUtils $usernameUtils,
31    ) {
32    }
33
34    /** @inheritDoc */
35    public function getAuthenticationRequests( $action, array $options ) {
36        return [];
37    }
38
39    /** @inheritDoc */
40    public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
41        return AuthenticationResponse::newAbstain();
42    }
43
44    /**
45     * If the user has enabled two-factor authentication, request a second factor.
46     *
47     * @inheritDoc
48     */
49    public function beginSecondaryAuthentication( $user, array $reqs ) {
50        if ( $this->manager->getAuthenticationSessionData( self::SUCCESS_KEY ) ) {
51            // The user logged in with 2FA through a primary auth provider, like
52            // PasskeyPrimaryAuthenticationProvider or ReauthPrimaryAuthenticationProvider
53            return AuthenticationResponse::newAbstain();
54        }
55
56        $authUser = $this->userRepository->findByUser( $user );
57
58        if ( !$authUser->isTwoFactorAuthEnabled() ) {
59            // If we're enforcing 2FA for all users... give them an appropriate error message
60            if (
61                $this->config->get( 'OATHAuthEnforce2FAForAll' ) &&
62                $this->userRepository->userIsRequiredToHave2FAEnabled( $user )
63            ) {
64                return AuthenticationResponse::newFail(
65                    wfMessage( 'oathauth-2fa-required-nologin' )
66                        ->params(
67                            ExpiringRecoveryCodeGenerator::getSiteAdminContact(
68                                $user,
69                                // Use site language; prevents leaking of user language
70                                RequestContext::getMain()->getLanguage()->getCode()
71                            )
72                        )
73                );
74            }
75
76            return AuthenticationResponse::newAbstain();
77        }
78
79        $module = $this->getModule( $authUser, $reqs );
80        if ( !$module ) {
81            throw new LogicException( 'Not possible' );
82        }
83        $provider = $this->getProviderForModule( $module );
84        $this->oathLogger->logTwoFactorChallengePresented( $user, $provider->getUniqueId() );
85        $response = $provider->beginSecondaryAuthentication( $user, [] );
86
87        // Include information about used module in request so that the correct
88        // provider can be used when continuing
89        $this->maybeAddSelectAuthenticationRequest( $authUser, $response, $module );
90
91        return $response;
92    }
93
94    /** @inheritDoc */
95    public function continueSecondaryAuthentication( $user, array $reqs ) {
96        $authUser = $this->userRepository->findByUser( $user );
97
98        $module = $this->getModule( $authUser, $reqs );
99        if ( !$module ) {
100            return AuthenticationResponse::newFail( wfMessage( 'oathauth-invalidrequest' ) );
101        }
102        $provider = $this->getProviderForModule( $module );
103
104        /** @var TwoFactorModuleSelectAuthenticationRequest $request */
105        $request = AuthenticationRequest::getRequestByClass( $reqs, TwoFactorModuleSelectAuthenticationRequest::class );
106        if ( $request && $request->newModule ) {
107            // The user is switching modules, restart
108            $response = $provider->beginSecondaryAuthentication( $user, [] );
109        } else {
110            $response = $provider->continueSecondaryAuthentication( $user, $reqs );
111        }
112
113        if ( $response->status === AuthenticationResponse::PASS ) {
114            $this->oathLogger->logSuccessfulVerification( $user );
115        }
116
117        $this->maybeAddSelectAuthenticationRequest( $authUser, $response, $module );
118        return $response;
119    }
120
121    private function getModule( OATHUser $authUser, array $reqs ): ?string {
122        return $this->getModuleFromRequest( $authUser, $reqs )
123            ?? $this->getDefaultModule( $authUser );
124    }
125
126    /**
127     * Return the ID of the module corresponding to the 2FA type option the user selected in the
128     * login form (or null if not selected / invalid).
129     *
130     * @param OATHUser $authUser
131     * @param AuthenticationRequest[] $reqs
132     * @return string|null
133     */
134    private function getModuleFromRequest( OATHUser $authUser, array $reqs ): ?string {
135        /** @var TwoFactorModuleSelectAuthenticationRequest $request */
136        $request = AuthenticationRequest::getRequestByClass( $reqs, TwoFactorModuleSelectAuthenticationRequest::class );
137        if ( !$request ) {
138            return null;
139        }
140        $module = $request->newModule ?: $request->currentModule;
141
142        // Validate that the specified module ID is valid
143        // and enabled for the user.
144        foreach ( $authUser->getKeys() as $key ) {
145            if ( $key->getModule() === $module ) {
146                return $module;
147            }
148        }
149
150        return null;
151    }
152
153    private function getDefaultModule( OATHUser $authUser ): ?string {
154        // HACK: If the request came from the clientlogin API, and the user has both
155        // TOTP and other modules enabled, only present TOTP. This is needed to avoid
156        // breaking the Wikipedia mobile apps until they can handle users with multiple
157        // modules enabled. (T399654)
158        if ( defined( 'MW_API' ) && $authUser->getKeysForModule( 'totp' ) ) {
159            return 'totp';
160        }
161
162        // Use the highest-priority module the user has
163        foreach ( self::MODULE_PRIORITY as $module ) {
164            if ( $authUser->getKeysForModule( $module ) ) {
165                return $module;
166            }
167        }
168
169        // Return the first key from the db if the user doesn't have any of the prioritized modules
170        return $authUser->getKeys() ? $authUser->getKeys()[0]->getModule() : null;
171    }
172
173    private function getProviderForModule( string $moduleId ): AbstractSecondaryAuthenticationProvider {
174        $module = $this->moduleRegistry->getModuleByKey( $moduleId );
175
176        $provider = $module->getSecondaryAuthProvider();
177        $provider->init(
178            $this->logger,
179            $this->manager,
180            $this->hookContainer,
181            $this->config,
182            $this->usernameUtils,
183        );
184        return $provider;
185    }
186
187    private function maybeAddSelectAuthenticationRequest(
188        OATHUser $authUser,
189        AuthenticationResponse $response,
190        string $currentModule
191    ): void {
192        if ( !in_array( $response->status, [ AuthenticationResponse::UI, AuthenticationResponse::REDIRECT ] ) ) {
193            return;
194        }
195
196        $allowedModules = [];
197        foreach ( $authUser->getKeys() as $key ) {
198            $module = $this->moduleRegistry->getModuleByKey( $key->getModule() );
199            $allowedModules[$module->getName()] = $module->getDisplayName();
200        }
201        if ( ReauthPrimaryAuthenticationProvider::isRestrictedReauth( $this->manager ) ) {
202            unset( $allowedModules[RecoveryCodes::MODULE_NAME] );
203        }
204        // Do not add the select request if there's nothing else to select.
205        if ( count( $allowedModules ) > 1 ) {
206            $selectRequest = new TwoFactorModuleSelectAuthenticationRequest( $currentModule, $allowedModules );
207            $response->neededRequests[] = $selectRequest;
208        }
209    }
210}