Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReauthPrimaryAuthenticationProvider
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 10
1260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthenticationRequests
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
 beginPrimaryAuthentication
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 continuePrimaryAuthentication
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 accountCreationType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beginPrimaryAccountCreation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 testUserExists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 providerAllowsAuthenticationDataChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 providerChangeAuthenticationData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRestrictedReauth
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2declare( strict_types=1 );
3
4namespace MediaWiki\Extension\OATHAuth\Auth;
5
6use BadMethodCallException;
7use MediaWiki\Auth\AbstractPrimaryAuthenticationProvider;
8use MediaWiki\Auth\AuthenticationRequest;
9use MediaWiki\Auth\AuthenticationResponse;
10use MediaWiki\Auth\AuthManager;
11use MediaWiki\Auth\ElevatedSecurityAuthenticationRequest;
12use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
13use MediaWiki\Extension\OATHAuth\Module\WebAuthn;
14use MediaWiki\Extension\OATHAuth\OATHAuthModuleRegistry;
15use MediaWiki\Extension\OATHAuth\OATHUserRepository;
16use MediaWiki\Status\Status;
17
18/**
19 * Dummy authentication provider that only exists to bypass primary authentication when the user is
20 * reauthenticating and has 2FA enabled. For these users, reauthentication only requires their second
21 * factor, and is handled in SecondaryAuthenticationProvider.
22 */
23class ReauthPrimaryAuthenticationProvider extends AbstractPrimaryAuthenticationProvider {
24
25    public function __construct(
26        private readonly OATHUserRepository $userRepo,
27        private readonly OATHAuthModuleRegistry $moduleRegistry
28    ) {
29    }
30
31    /** @inheritDoc */
32    public function getAuthenticationRequests( $action, array $options ) {
33        if ( $action !== AuthManager::ACTION_LOGIN || !isset( $options['securityLevel'] ) ) {
34            // Not a reauthentication, skip
35            return [];
36        }
37
38        $this->manager->setAuthenticationSessionData(
39            'oathauth-reauth-securitylevel', $options['securityLevel'] );
40
41        $user = $this->manager->getRequest()->getSession()->getUser();
42        $oathUser = $this->userRepo->findByUser( $user );
43        $availableModules = [];
44        $hasPasswordless = false;
45        $hasNonpasswordlessWebAuthn = false;
46        // TODO the logic for building $availableModules is duplicated from SecondaryAuthenticationProvider
47        foreach ( $oathUser->getKeys() as $key ) {
48            $module = $this->moduleRegistry->getModuleByKey( $key->getModule() );
49            $availableModules[$module->getName()] = $module->getDisplayName();
50
51            if ( $key->supportsPasswordlessLogin() ) {
52                $hasPasswordless = true;
53            } elseif ( $module->getName() === WebAuthn::MODULE_NAME ) {
54                $hasNonpasswordlessWebAuthn = true;
55            }
56        }
57
58        // Don't allow recovery codes to be used for reauthentication, except for OATHManage (T428982)
59        if ( $options['securityLevel'] !== 'OATHManage' ) {
60            unset( $availableModules[RecoveryCodes::MODULE_NAME] );
61        }
62
63        // If the user doesn't have 2FA, or if they only have recovery codes, skip and let them
64        // reauthenticate with a password instead
65        $availableModulesWithoutRecoveryCodes = array_diff_key(
66            $availableModules, [ RecoveryCodes::MODULE_NAME => true ]
67        );
68        if ( !$availableModulesWithoutRecoveryCodes ) {
69            return [];
70        }
71
72        if ( $hasPasswordless && $this->config->get( 'OATHPasswordlessLogin' ) ) {
73            // The user has a passkey. PasskeyPrimaryAuthentiationProvider will already generate a
74            // "Log in with passkey" button, we just have to generate switch buttons for the other
75            // methods
76            return [ new TwoFactorModuleSelectAuthenticationRequest(
77                // If the user has non-passkey WebAuthn keys, also include a switch button for WebAuthn,
78                // by lying about the current module name
79                $hasNonpasswordlessWebAuthn ? 'passkey' : WebAuthn::MODULE_NAME,
80                $availableModules
81            ) ];
82        }
83
84        // The user doesn't have a passkey. Let SecondaryAuthenticationProvider generate both a
85        // request for the primary method and a switch request
86        /** @var SecondaryAuthenticationProvider|null $secondaryAuthProvider */
87        $secondaryAuthProvider = $this->manager->getAuthenticationProvider( SecondaryAuthenticationProvider::class );
88        '@phan-var SecondaryAuthenticationProvider|null $secondaryAuthProvider';
89        if ( !$secondaryAuthProvider ) {
90            // Not supposed to be possible
91            return [];
92        }
93        $reqs = $secondaryAuthProvider->beginSecondaryAuthentication( $user, [] )->neededRequests;
94
95        // Display a "Continue with security key" button if the primary method is a security key
96        $webauthnReq = AuthenticationRequest::getRequestByClass( $reqs, WebAuthnAuthenticationRequest::class );
97        if ( $webauthnReq ) {
98            $webauthnReq->options['showButton'] = 'interstitial';
99            $webauthnReq->options['showPrompt'] = false;
100        }
101
102        return $reqs;
103    }
104
105    /** @inheritDoc */
106    public function beginPrimaryAuthentication( array $reqs ) {
107        $elevReq = AuthenticationRequest::getRequestByClass( $reqs, ElevatedSecurityAuthenticationRequest::class );
108        // AuthManager wipes the authn session on first submit, so re-stash here for continuations.
109        $this->manager->setAuthenticationSessionData(
110            'oathauth-reauth-securitylevel', $elevReq?->securityLevel );
111        $oathUser = $this->userRepo->findByUser( $this->manager->getRequest()->getSession()->getUser() );
112
113        // Skip non-reauth logins, or users without 2FA
114        if (
115            // Skip non-reauth logins
116            !$elevReq ||
117            // Skip users without 2FA
118            !$oathUser->isTwoFactorAuthEnabled() ||
119            // Skip users with only recovery codes
120            !$oathUser->userHasNonSpecialEnabledKeys()
121        ) {
122            return AuthenticationResponse::newAbstain();
123        }
124
125        // If the user is logging in with a passkey through a request that looks like it was added
126        // by PasskeyPrimaryAuthenticationProvider, abstain so that PasskeyPrimaryAuthenticationProvider
127        // can handle it instead
128        $webauthnReq = AuthenticationRequest::getRequestByClass( $reqs, WebAuthnAuthenticationRequest::class );
129        if (
130            $webauthnReq
131            && $webauthnReq->credential !== ''
132            && ( $webauthnReq->options['showButton'] ?? false ) === 'passwordless'
133        ) {
134            return AuthenticationResponse::newAbstain();
135        }
136
137        return $this->continuePrimaryAuthentication( $reqs );
138    }
139
140    /** @inheritDoc */
141    public function continuePrimaryAuthentication( array $reqs ) {
142        $user = $this->manager->getRequest()->getSession()->getUser();
143        /** @var SecondaryAuthenticationProvider|null $secondaryAuthProvider */
144        $secondaryAuthProvider = $this->manager->getAuthenticationProvider( SecondaryAuthenticationProvider::class );
145        '@phan-var SecondaryAuthenticationProvider|null $secondaryAuthProvider';
146        if ( !$secondaryAuthProvider ) {
147            // Not supposed to be possible
148            return AuthenticationResponse::newAbstain();
149        }
150
151        $selectReq = AuthenticationRequest::getRequestByClass( $reqs,
152            TwoFactorModuleSelectAuthenticationRequest::class );
153        if ( $selectReq && $selectReq->newModule ) {
154            if ( $selectReq->newModule === RecoveryCodes::MODULE_NAME && self::isRestrictedReauth( $this->manager ) ) {
155                // Recovery codes are not allowed for reauthentication (except for OATHManage)
156                // This shouldn't happen because recovery codes shouldn't be offered
157                return AuthenticationResponse::newFail( wfMessage( 'oathauth-invalidrequest' ) );
158            }
159            // User wants to switch, regenerate the requests
160            return $secondaryAuthProvider->beginSecondaryAuthentication( $user, $reqs );
161        }
162
163        $response = $secondaryAuthProvider->continueSecondaryAuthentication( $user, $reqs );
164        if ( $response->status === AuthenticationResponse::PASS ) {
165            $response->username = $user->getName();
166            $this->manager->setAuthenticationSessionData( SecondaryAuthenticationProvider::SUCCESS_KEY, true );
167        }
168        return $response;
169    }
170
171    /** @inheritDoc */
172    public function accountCreationType() {
173        return self::TYPE_NONE;
174    }
175
176    /** @inheritDoc */
177    public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) {
178        // @phan-suppress-previous-line PhanPluginNeverReturnMethod
179        throw new BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' );
180    }
181
182    /** @inheritDoc */
183    public function testUserExists( $username, $flags = \Wikimedia\Rdbms\IDBAccessObject::READ_NORMAL ) {
184        // We rely on other primary authentication providers to manage user existence
185        return false;
186    }
187
188    /** @inheritDoc */
189    public function providerAllowsAuthenticationDataChange( AuthenticationRequest $req, $checkData = true ) {
190        // Not supported
191        return Status::newGood( 'ignored' );
192    }
193
194    /** @inheritDoc */
195    public function providerChangeAuthenticationData( AuthenticationRequest $req ) {
196        // Do nothing; this is not applicable for this provider
197    }
198
199    /**
200     * Whether the user is currently reauthenticating for an action other than Special:OATHManage.
201     * Recovery codes must not be offered or accepted in this situation (T428982).
202     * The security level is stashed in the auth session by {@link self::getAuthenticationRequests}
203     * and {@link self::beginPrimaryAuthentication}.
204     */
205    public static function isRestrictedReauth( AuthManager $manager ): bool {
206        // TODO replace this with a method in AuthManager
207        $level = $manager->getAuthenticationSessionData( 'oathauth-reauth-securitylevel' );
208        return $level !== null && $level !== 'OATHManage';
209    }
210}