Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.88% covered (warning)
87.88%
58 / 66
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SecondaryAuthenticationProvider
87.88% covered (warning)
87.88%
58 / 66
33.33% covered (danger)
33.33%
3 / 9
29.40
0.00% covered (danger)
0.00%
0 / 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
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 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%
13 / 13
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
4
1<?php
2
3namespace MediaWiki\Extension\OATHAuth\Auth;
4
5use LogicException;
6use MediaWiki\Auth\AbstractSecondaryAuthenticationProvider;
7use MediaWiki\Auth\AuthenticationRequest;
8use MediaWiki\Auth\AuthenticationResponse;
9use MediaWiki\Extension\OATHAuth\OATHAuthServices;
10use MediaWiki\Extension\OATHAuth\OATHUser;
11use MediaWiki\MediaWikiServices;
12
13class SecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
14
15    public const MODULE_PRIORITY = [ 'webauthn', 'totp', 'recoverycodes' ];
16
17    /** @inheritDoc */
18    public function getAuthenticationRequests( $action, array $options ) {
19        return [];
20    }
21
22    /** @inheritDoc */
23    public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
24        return AuthenticationResponse::newAbstain();
25    }
26
27    /**
28     * If the user has enabled two-factor authentication, request a second factor.
29     *
30     * @inheritDoc
31     */
32    public function beginSecondaryAuthentication( $user, array $reqs ) {
33        if ( $this->manager->getAuthenticationSessionData( PasskeyPrimaryAuthenticationProvider::SUCCESS_KEY ) ) {
34            // The user logged in with a passwordless passkey; skip 2FA
35            return AuthenticationResponse::newAbstain();
36        }
37
38        $authUser = OATHAuthServices::getInstance()->getUserRepository()->findByUser( $user );
39
40        if ( !$authUser->isTwoFactorAuthEnabled() ) {
41            return AuthenticationResponse::newAbstain();
42        }
43
44        $module = $this->getModule( $authUser, $reqs );
45        if ( !$module ) {
46            throw new LogicException( 'Not possible' );
47        }
48        $response = $this->getProviderForModule( $module )->beginSecondaryAuthentication( $user, [] );
49
50        // Include information about used module in request so that the correct
51        // provider can be used when continuing
52        $this->maybeAddSelectAuthenticationRequest( $authUser, $response, $module );
53
54        return $response;
55    }
56
57    /** @inheritDoc */
58    public function continueSecondaryAuthentication( $user, array $reqs ) {
59        $authUser = OATHAuthServices::getInstance()->getUserRepository()->findByUser( $user );
60
61        $module = $this->getModule( $authUser, $reqs );
62        if ( !$module ) {
63            return AuthenticationResponse::newFail( wfMessage( 'oathauth-invalidrequest' ) );
64        }
65        $provider = $this->getProviderForModule( $module );
66
67        /** @var TwoFactorModuleSelectAuthenticationRequest $request */
68        $request = AuthenticationRequest::getRequestByClass( $reqs, TwoFactorModuleSelectAuthenticationRequest::class );
69        if ( $request && $request->newModule ) {
70            // The user is switching modules, restart
71            $response = $provider->beginSecondaryAuthentication( $user, [] );
72        } else {
73            $response = $provider->continueSecondaryAuthentication( $user, $reqs );
74        }
75
76        if ( $response->status === AuthenticationResponse::PASS ) {
77            OATHAuthServices::getInstance()->getLogger()->logSuccessfulVerification( $user );
78        }
79
80        $this->maybeAddSelectAuthenticationRequest( $authUser, $response, $module );
81        return $response;
82    }
83
84    private function getModule( OATHUser $authUser, array $reqs ): ?string {
85        return $this->getModuleFromRequest( $authUser, $reqs )
86            ?? $this->getDefaultModule( $authUser );
87    }
88
89    /**
90     * Return the ID of the module corresponding to the 2FA type option the user selected in the
91     * login form (or null if not selected / invalid).
92     *
93     * @param OATHUser $authUser
94     * @param AuthenticationRequest[] $reqs
95     * @return string|null
96     */
97    private function getModuleFromRequest( OATHUser $authUser, array $reqs ): ?string {
98        /** @var TwoFactorModuleSelectAuthenticationRequest $request */
99        $request = AuthenticationRequest::getRequestByClass( $reqs, TwoFactorModuleSelectAuthenticationRequest::class );
100        if ( !$request ) {
101            return null;
102        }
103        $module = $request->newModule ?: $request->currentModule;
104
105        // Validate that the specified module ID is valid
106        // and enabled for the user.
107        foreach ( $authUser->getKeys() as $key ) {
108            if ( $key->getModule() === $module ) {
109                return $module;
110            }
111        }
112
113        return null;
114    }
115
116    private function getDefaultModule( OATHUser $authUser ): ?string {
117        // HACK: If the request came from the clientlogin API, and the user has both
118        // TOTP and other modules enabled, only present TOTP. This is needed to avoid
119        // breaking the Wikipedia mobile apps until they can handle users with multiple
120        // modules enabled. (T399654)
121        if ( defined( 'MW_API' ) && $authUser->getKeysForModule( 'totp' ) ) {
122            return 'totp';
123        }
124
125        // Use the highest-priority module the user has
126        foreach ( self::MODULE_PRIORITY as $module ) {
127            if ( $authUser->getKeysForModule( $module ) ) {
128                return $module;
129            }
130        }
131
132        // Return the first key from the db if the user doesn't have any of the prioritized modules
133        return $authUser->getKeys() ? $authUser->getKeys()[0]->getModule() : null;
134    }
135
136    private function getProviderForModule( string $moduleId ): AbstractSecondaryAuthenticationProvider {
137        $module = OATHAuthServices::getInstance()
138            ->getModuleRegistry()
139            ->getModuleByKey( $moduleId );
140
141        $provider = $module->getSecondaryAuthProvider();
142        $services = MediaWikiServices::getInstance();
143        $provider->init(
144            $this->logger,
145            $this->manager,
146            $services->getHookContainer(),
147            $this->config,
148            $services->getUserNameUtils()
149        );
150        return $provider;
151    }
152
153    private function maybeAddSelectAuthenticationRequest(
154        OATHUser $authUser,
155        AuthenticationResponse $response,
156        string $currentModule
157    ): void {
158        if ( !in_array( $response->status, [ AuthenticationResponse::UI, AuthenticationResponse::REDIRECT ] ) ) {
159            return;
160        }
161
162        $allowedModules = [];
163        $moduleRegistry = OATHAuthServices::getInstance( MediaWikiServices::getInstance() )
164            ->getModuleRegistry();
165        foreach ( $authUser->getKeys() as $key ) {
166            $module = $moduleRegistry->getModuleByKey( $key->getModule() );
167            $allowedModules[$module->getName()] = $module->getDisplayName();
168        }
169        // Do not add the select request if there's nothing else to select.
170        if ( count( $allowedModules ) > 1 ) {
171            $selectRequest = new TwoFactorModuleSelectAuthenticationRequest( $currentModule, $allowedModules );
172            $response->neededRequests[] = $selectRequest;
173        }
174    }
175}