Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.52% covered (warning)
88.52%
108 / 122
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CaptchaPreAuthenticationProvider
88.52% covered (warning)
88.52%
108 / 122
57.14% covered (warning)
57.14%
4 / 7
37.96
0.00% covered (danger)
0.00%
0 / 1
 getAuthenticationRequests
79.59% covered (warning)
79.59%
39 / 49
0.00% covered (danger)
0.00%
0 / 1
13.22
 testForAuthentication
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
10.11
 testForAccountCreation
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 postAuthentication
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 verifyCaptcha
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 makeError
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getLoginAttemptCounter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Extension\ConfirmEdit\Auth;
4
5use MediaWiki\Auth\AbstractPreAuthenticationProvider;
6use MediaWiki\Auth\AuthenticationRequest;
7use MediaWiki\Auth\AuthenticationResponse;
8use MediaWiki\Auth\AuthManager;
9use MediaWiki\Extension\ConfirmEdit\CaptchaTriggers;
10use MediaWiki\Extension\ConfirmEdit\Hooks;
11use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\Status\Status;
14use MediaWiki\User\User;
15
16class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
17    /** @inheritDoc */
18    public function getAuthenticationRequests( $action, array $options ) {
19        $user = User::newFromName( $options['username'] );
20
21        $logger = LoggerFactory::getInstance( 'captcha' );
22        $needed = false;
23        $captcha = null;
24        switch ( $action ) {
25            case AuthManager::ACTION_CREATE:
26                $u = $user ?: new User();
27                $captcha = Hooks::getInstance( CaptchaTriggers::CREATE_ACCOUNT );
28                $needed = $captcha->needCreateAccountCaptcha( $u );
29                if ( $needed ) {
30                    $captcha->setAction( CaptchaTriggers::CREATE_ACCOUNT );
31                    // This is debug level simply because generally a
32                    // captcha is either always or never triggered on
33                    // view of Special:CreateAccount, so it gets pretty noisy
34                    $logger->debug( 'Captcha shown on account creation for {user}', [
35                        'event' => 'captcha.display',
36                        'eventType' => 'accountcreation',
37                        'user' => $u->getName()
38                    ] );
39                }
40                break;
41            case AuthManager::ACTION_LOGIN:
42                // Captcha is shown on login when there were too many failed attempts from the current IP
43                // or using a given username, or if a hook handler says that a CAPTCHA should be shown.
44                // The varying on username is a bit awkward because we don't know the
45                // username yet. The username from the last successful login is stored in a cookie.
46                // We still must make sure to not lock out other usernames, so after the first
47                // failed login attempt using a username that needs a captcha, set a session flag
48                // to display a captcha on login from that point on. This will result in confusing
49                // error messages if the browser cannot persist the session (because we'll never show
50                // a required captcha field), but then login would be impossible anyway, so no big deal.
51
52                // If the username ends up to be one that does not trigger the captcha, that will
53                // result in weird behavior (if the user leaves the captcha field empty, they get
54                // a required field error; if they fill it with an invalid answer, it will pass)
55                // - again, not a huge deal.
56                $captcha = Hooks::getInstance( CaptchaTriggers::LOGIN_ATTEMPT );
57                $session = $this->manager->getRequest()->getSession();
58                $suggestedUsername = $session->suggestLoginUsername();
59                if ( $captcha->triggersCaptcha( CaptchaTriggers::LOGIN_ATTEMPT ) ) {
60                    $captcha->setAction( CaptchaTriggers::LOGIN_ATTEMPT );
61                    $logger->info( 'Captcha shown on login attempt by {clientip} for {suggestedUser}', [
62                        'event' => 'captcha.display',
63                        'eventType' => 'loginattempt',
64                        'suggestedUser' => $suggestedUsername,
65                        'clientip' => $this->manager->getRequest()->getIP(),
66                        'ua' => $this->manager->getRequest()->getHeader( 'User-Agent' )
67                    ] );
68                    $needed = true;
69                    break;
70                }
71
72                $captcha = Hooks::getInstance( CaptchaTriggers::BAD_LOGIN );
73                $loginCounter = $this->getLoginAttemptCounter( $captcha );
74
75                $userProbablyNeedsCaptcha = $session->get( 'ConfirmEdit:loginCaptchaPerUserTriggered' );
76                if (
77                    $userProbablyNeedsCaptcha
78                    || $loginCounter->isBadLoginTriggered()
79                    || ( $suggestedUsername && $loginCounter->isBadLoginPerUserTriggered( $suggestedUsername ) )
80                ) {
81                    $captcha->setAction( CaptchaTriggers::BAD_LOGIN );
82                    $logger->info( 'Captcha shown on login by {clientip} for {suggestedUser}', [
83                        'event' => 'captcha.display',
84                        'eventType' => 'badlogin',
85                        'suggestedUser' => $suggestedUsername,
86                        'clientip' => $this->manager->getRequest()->getIP(),
87                        'ua' => $this->manager->getRequest()->getHeader( 'User-Agent' )
88                    ] );
89                    $needed = true;
90                    break;
91                }
92                break;
93        }
94
95        // Return the CaptchaAuthenticationRequest instance if a captcha is needed and defined.
96        if ( $needed && $captcha ) {
97            return [ $captcha->createAuthenticationRequest() ];
98        } else {
99            return [];
100        }
101    }
102
103    /** @inheritDoc */
104    public function testForAuthentication( array $reqs ) {
105        $captcha = Hooks::getInstance( CaptchaTriggers::CREATE_ACCOUNT );
106        $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
107        $loginCounter = $this->getLoginAttemptCounter( $captcha );
108        $success = true;
109        $isBadLoginPerUserTriggered = $username && $loginCounter->isBadLoginPerUserTriggered( $username );
110        $loginTriggersCaptcha = $captcha->triggersCaptcha( CaptchaTriggers::LOGIN_ATTEMPT );
111
112        if (
113            $isBadLoginPerUserTriggered ||
114            $loginCounter->isBadLoginTriggered() ||
115            $loginTriggersCaptcha
116        ) {
117
118            if ( $loginTriggersCaptcha ) {
119                $captcha = Hooks::getInstance( CaptchaTriggers::LOGIN_ATTEMPT );
120                $captcha->setAction( CaptchaTriggers::LOGIN_ATTEMPT );
121                $captcha->setTrigger( "loginattempt login '$username'" );
122            } else {
123                $captcha = Hooks::getInstance( CaptchaTriggers::BAD_LOGIN );
124                $captcha->setAction( CaptchaTriggers::BAD_LOGIN );
125                $captcha->setTrigger( "post-badlogin login '$username'" );
126            }
127            $success = $this->verifyCaptcha( $captcha, $reqs, new User() );
128            $action = $loginTriggersCaptcha ? 'login page' : 'bad login';
129            LoggerFactory::getInstance( 'captcha' )->info( "Captcha shown on $action for {user}", [
130                'event' => 'captcha.submit',
131                'eventType' => $loginTriggersCaptcha ? 'loginattempt' : 'badlogin',
132                'successful' => $success,
133                'user' => $username ?? 'unknown',
134                'clientip' => $this->manager->getRequest()->getIP(),
135                'ua' => $this->manager->getRequest()->getHeader( 'User-Agent' )
136            ] );
137        }
138
139        if ( $isBadLoginPerUserTriggered ) {
140            $session = $this->manager->getRequest()->getSession();
141            // A captcha is needed to log in with this username, so display it on the next attempt.
142            $session->set( 'ConfirmEdit:loginCaptchaPerUserTriggered', true );
143        }
144
145        // Make brute force attacks harder by not telling whether the password or the
146        // captcha failed.
147        return $success ? Status::newGood() : $this->makeError( 'wrongpassword', $captcha );
148    }
149
150    /** @inheritDoc */
151    public function testForAccountCreation( $user, $creator, array $reqs ) {
152        $captcha = Hooks::getInstance( CaptchaTriggers::CREATE_ACCOUNT );
153
154        if ( $captcha->needCreateAccountCaptcha( $creator ) ) {
155            $username = $user->getName();
156            $captcha->setAction( CaptchaTriggers::CREATE_ACCOUNT );
157            $captcha->setTrigger( "new account '$username'" );
158            $success = $this->verifyCaptcha( $captcha, $reqs, $user );
159            $ip = $this->manager->getRequest()->getIP();
160            LoggerFactory::getInstance( 'captcha' )->info(
161                'Captcha submitted on account creation for {user}', [
162                    'event' => 'captcha.submit',
163                    'eventType' => 'accountcreation',
164                    'successful' => $success,
165                    'user' => $username,
166                    'clientip' => $ip,
167                    'ua' => $this->manager->getRequest()->getHeader( 'User-Agent' )
168                ]
169            );
170            if ( !$success ) {
171                return $this->makeError( 'captcha-createaccount-fail', $captcha );
172            }
173        }
174        return Status::newGood();
175    }
176
177    /** @inheritDoc */
178    public function postAuthentication( $user, AuthenticationResponse $response ) {
179        $captcha = Hooks::getInstance( CaptchaTriggers::BAD_LOGIN );
180        $loginCounter = $this->getLoginAttemptCounter( $captcha );
181        switch ( $response->status ) {
182            case AuthenticationResponse::PASS:
183            case AuthenticationResponse::RESTART:
184                $this->manager->getRequest()->getSession()->remove( 'ConfirmEdit:loginCaptchaPerUserTriggered' );
185                $loginCounter->resetBadLoginCounter( $user ? $user->getName() : null );
186                break;
187            case AuthenticationResponse::FAIL:
188                $loginCounter->increaseBadLoginCounter( $user ? $user->getName() : null );
189                break;
190        }
191    }
192
193    /**
194     * Verify submitted captcha.
195     * Assumes that the user has to pass the captcha (permission checks are caller's responsibility).
196     * @param SimpleCaptcha $captcha
197     * @param AuthenticationRequest[] $reqs
198     * @param User $user
199     * @return bool
200     */
201    protected function verifyCaptcha( SimpleCaptcha $captcha, array $reqs, User $user ) {
202        /** @var CaptchaAuthenticationRequest $req */
203        $req = AuthenticationRequest::getRequestByClass(
204            $reqs,
205            CaptchaAuthenticationRequest::class,
206            true
207        );
208        if ( !$req ) {
209            return false;
210        }
211        return $captcha->passCaptchaLimited( $req->captchaId, $req->captchaWord, $user );
212    }
213
214    /**
215     * @param string $message Message key
216     * @param SimpleCaptcha $captcha
217     * @return Status
218     */
219    protected function makeError( $message, SimpleCaptcha $captcha ) {
220        $error = $captcha->getError();
221        if ( $error ) {
222            return Status::newFatal( wfMessage( 'captcha-error', $error ) );
223        }
224        return Status::newFatal( $message );
225    }
226
227    protected function getLoginAttemptCounter( SimpleCaptcha $captcha ): LoginAttemptCounter {
228        // Overridable for testing
229        return new LoginAttemptCounter( $captcha );
230    }
231}