Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
RecoveryCodesSecondaryAuthenticationProvider
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 5
342
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 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 beginSecondaryAuthentication
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
90
 continueSecondaryAuthentication
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 beginSecondaryAccountCreation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types=1 );
3/**
4 * @license GPL-2.0-or-later
5 */
6
7namespace MediaWiki\Extension\OATHAuth\Auth;
8
9use MediaWiki\Auth\AbstractSecondaryAuthenticationProvider;
10use MediaWiki\Auth\AuthenticationRequest;
11use MediaWiki\Auth\AuthenticationResponse;
12use MediaWiki\Auth\AuthManager;
13use MediaWiki\Extension\OATHAuth\Key\RecoveryCode;
14use MediaWiki\Extension\OATHAuth\Key\RecoveryCodeKeys;
15use MediaWiki\Extension\OATHAuth\Module\RecoveryCodes;
16use MediaWiki\Extension\OATHAuth\OATHAuthLogger;
17use MediaWiki\Extension\OATHAuth\OATHUserRepository;
18use MediaWiki\Message\Message;
19
20/**
21 * AuthManager secondary authentication provider for Recovery Codes second-factor authentication.
22 *
23 * After a successful primary authentication, requests a recovery code from the user.
24 *
25 * @see AuthManager
26 */
27class RecoveryCodesSecondaryAuthenticationProvider extends AbstractSecondaryAuthenticationProvider {
28
29    public function __construct(
30        private readonly RecoveryCodes $module,
31        private readonly OATHUserRepository $userRepository,
32        private readonly OATHAuthLogger $oathLogger,
33    ) {
34    }
35
36    /** @inheritDoc */
37    public function getAuthenticationRequests( $action, array $options ): array {
38        // don't ask for anything initially, so the second factor is on a separate screen
39        return [];
40    }
41
42    /**
43     * If the user has a recovery code module enabled, request a second factor.
44     *
45     * @inheritDoc
46     */
47    public function beginSecondaryAuthentication( $user, array $reqs ): AuthenticationResponse {
48        if ( ReauthPrimaryAuthenticationProvider::isRestrictedReauth( $this->manager ) ) {
49            return AuthenticationResponse::newAbstain();
50        }
51
52        $oathUser = $this->userRepository->findByUser( $user );
53
54        if ( !$this->module->isEnabled( $oathUser ) ) {
55            return AuthenticationResponse::newAbstain();
56        }
57
58        // If the user only has recovery codes, and is reauthenticating, allow them to skip 2FA.
59        // This prevents users with initial recovery codes from needing two codes to set up 2FA.
60        // If we got here, the reauth has to be for 'OATHManage', otherwise the isRestrictedReauth
61        // check would have prevented us from getting here.
62        if (
63            !$oathUser->userHasNonSpecialEnabledKeys() &&
64            $this->manager->getAuthenticationSessionData( 'oathauth-reauth-securitylevel' )
65        ) {
66            return AuthenticationResponse::newAbstain();
67        }
68
69        $initialCodesOnly = false;
70        if ( $this->config->get( 'OATHAuthEnforce2FAForAll' ) ) {
71            // Figure out whether the user has initial recovery codes
72            /** @var RecoveryCodeKeys $recoveryCodes */
73            $recoveryCodes = $oathUser->getKeysForModule( $this->module->getName() )[ 0 ];
74            '@phan-var RecoveryCodeKeys $recoveryCodes';
75            $initialCodes = array_filter( $recoveryCodes->getRecoveryCodes(),
76                static fn ( RecoveryCode $code ) => $code->isInitial() );
77            $temporaryCodes = array_filter( $recoveryCodes->getRecoveryCodes(),
78                static fn ( RecoveryCode $code ) => !$code->isPermanent() );
79
80            if ( $initialCodes === [] && $temporaryCodes === [] && !$oathUser->userHasNonSpecialEnabledKeys() ) {
81                // The user has no initial or temporary recovery codes, no other keys, and 2FA is required.
82                // This means their initial recovery codes have expired and they cannot log in.
83                // We do have to allow non-initial temporary recovery codes here, so that users in this
84                // situation can use assisted account recovery.
85                return AuthenticationResponse::newFail( wfMessage( 'oathauth-recovery-codes-expired-error' ) );
86            }
87            $initialCodesOnly = $initialCodes !== [];
88
89        }
90        return AuthenticationResponse::newUI( [ new RecoveryCodesAuthenticationRequest( $initialCodesOnly ) ] );
91    }
92
93    /** @inheritDoc */
94    public function continueSecondaryAuthentication( $user, array $reqs ) {
95        if ( ReauthPrimaryAuthenticationProvider::isRestrictedReauth( $this->manager ) ) {
96            return AuthenticationResponse::newFail( wfMessage( 'oathauth-recovery-code-login-failed' ) );
97        }
98
99        /** @var RecoveryCodesAuthenticationRequest $request */
100        $request = AuthenticationRequest::getRequestByClass( $reqs, RecoveryCodesAuthenticationRequest::class );
101        if ( !$request ) {
102            return AuthenticationResponse::newUI( [ new RecoveryCodesAuthenticationRequest() ],
103                wfMessage( 'oathauth-recovery-code-login-failed' ), 'error' );
104        }
105
106        // Check for (and increment) rate limiter before doing the auth
107        if ( $user->pingLimiter( 'badoath' ) || $user->pingLimiter( 'badoath-long' ) ) {
108            return AuthenticationResponse::newUI(
109                [ new RecoveryCodesAuthenticationRequest() ],
110                new Message( 'oathauth-throttled' ),
111                'error'
112            );
113        }
114
115        $authUser = $this->userRepository->findByUser( $user );
116        $recoveryCode = $request->RecoveryCode;
117
118        if ( $this->module->verify( $authUser, [ 'recoverycode' => $recoveryCode ] ) ) {
119            return AuthenticationResponse::newPass();
120        }
121
122        $this->logger->info( 'OATHAuth user {user} failed recovery code from {clientip}', [
123            'user'     => $user->getName(),
124            'clientip' => $user->getRequest()->getIP(),
125        ] );
126
127        $this->oathLogger->logFailedVerification( $user );
128
129        return AuthenticationResponse::newUI(
130            [ new RecoveryCodesAuthenticationRequest() ],
131            wfMessage( 'oathauth-recovery-code-login-failed' ),
132            'error'
133        );
134    }
135
136    /** @inheritDoc */
137    public function beginSecondaryAccountCreation( $user, $creator, array $reqs ) {
138        return AuthenticationResponse::newAbstain();
139    }
140}