Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.97% covered (success)
98.97%
96 / 97
85.71% covered (warning)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CaptchaPreAuthenticationProvider
98.97% covered (success)
98.97%
96 / 97
85.71% covered (warning)
85.71%
6 / 7
30
0.00% covered (danger)
0.00%
0 / 1
 getAuthenticationRequests
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
10
 testForAuthentication
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 testForAccountCreation
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 postAuthentication
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 verifyCaptcha
100.00% covered (success)
100.00%
5 / 5
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\Hooks;
10use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha;
11use MediaWiki\Logger\LoggerFactory;
12use MediaWiki\Status\Status;
13use MediaWiki\User\User;
14
15class CaptchaPreAuthenticationProvider extends AbstractPreAuthenticationProvider {
16    /**
17     * @inheritDoc
18     */
19    public function getAuthenticationRequests( $action, array $options ) {
20        $captcha = Hooks::getInstance();
21        $user = User::newFromName( $options['username'] );
22
23        $needed = false;
24        switch ( $action ) {
25            case AuthManager::ACTION_CREATE:
26                $u = $user ?: new User();
27                $needed = $captcha->needCreateAccountCaptcha( $u );
28                if ( $needed ) {
29                    $captcha->setAction( 'accountcreate' );
30                    // This is debug level simply because generally
31                    // captchas are either always or never triggered on
32                    // view of create account, so it gets pretty noisy
33                    LoggerFactory::getInstance( 'captcha' )
34                        ->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                $loginCounter = $this->getLoginAttemptCounter( $captcha );
43                // Captcha is shown on login when there were too many failed attempts from the current IP
44                // or using a given username. The latter 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                // but 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 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                $session = $this->manager->getRequest()->getSession();
57                $userProbablyNeedsCaptcha = $session->get( 'ConfirmEdit:loginCaptchaPerUserTriggered' );
58                $suggestedUsername = $session->suggestLoginUsername();
59                if (
60                    $loginCounter->isBadLoginTriggered()
61                    || $userProbablyNeedsCaptcha
62                    || ( $suggestedUsername && $loginCounter->isBadLoginPerUserTriggered( $suggestedUsername ) )
63                ) {
64                    $needed = true;
65                    $captcha->setAction( 'badlogin' );
66                    LoggerFactory::getInstance( 'captcha' )
67                        ->info( 'Captcha shown on login by {clientip} for {suggestedUser}', [
68                            'event' => 'captcha.display',
69                            'eventType' => 'accountcreation',
70                            'suggestedUser' => $suggestedUsername,
71                            'clientip' => $this->manager->getRequest()->getIP()
72                        ] );
73                    break;
74                }
75                break;
76        }
77
78        if ( $needed ) {
79            return [ $captcha->createAuthenticationRequest() ];
80        } else {
81            return [];
82        }
83    }
84
85    /**
86     * @inheritDoc
87     */
88    public function testForAuthentication( array $reqs ) {
89        $captcha = Hooks::getInstance();
90        $username = AuthenticationRequest::getUsernameFromRequests( $reqs );
91        $loginCounter = $this->getLoginAttemptCounter( $captcha );
92        $success = true;
93        $isBadLoginPerUserTriggered = $username ?
94            $loginCounter->isBadLoginPerUserTriggered( $username ) : false;
95
96        if ( $loginCounter->isBadLoginTriggered() || $isBadLoginPerUserTriggered ) {
97            $captcha->setAction( 'badlogin' );
98            $captcha->setTrigger( "post-badlogin login '$username'" );
99            $success = $this->verifyCaptcha( $captcha, $reqs, new User() );
100            $ip = $this->manager->getRequest()->getIP();
101            LoggerFactory::getInstance( 'captcha' )->info( 'Captcha submitted on login for {user}', [
102                'event' => 'captcha.submit',
103                'eventType' => 'login',
104                'successful' => $success,
105                'user' => $username,
106                'clientip' => $ip
107            ] );
108        }
109
110        if ( $isBadLoginPerUserTriggered ) {
111            $session = $this->manager->getRequest()->getSession();
112            // A captcha is needed to log in with this username, so display it on the next attempt.
113            $session->set( 'ConfirmEdit:loginCaptchaPerUserTriggered', true );
114        }
115
116        // Make brute force attacks harder by not telling whether the password or the
117        // captcha failed.
118        return $success ? Status::newGood() : $this->makeError( 'wrongpassword', $captcha );
119    }
120
121    /**
122     * @inheritDoc
123     */
124    public function testForAccountCreation( $user, $creator, array $reqs ) {
125        $captcha = Hooks::getInstance();
126
127        if ( $captcha->needCreateAccountCaptcha( $creator ) ) {
128            $username = $user->getName();
129            $captcha->setAction( 'accountcreate' );
130            $captcha->setTrigger( "new account '$username'" );
131            $success = $this->verifyCaptcha( $captcha, $reqs, $user );
132            $ip = $this->manager->getRequest()->getIP();
133            LoggerFactory::getInstance( 'captcha' )->info(
134                'Captcha submitted on account creation for {user}', [
135                    'event' => 'captcha.submit',
136                    'eventType' => 'accountcreation',
137                    'successful' => $success,
138                    'user' => $username,
139                    'clientip' => $ip
140                ]
141            );
142            if ( !$success ) {
143                return $this->makeError( 'captcha-createaccount-fail', $captcha );
144            }
145        }
146        return Status::newGood();
147    }
148
149    /**
150     * @inheritDoc
151     */
152    public function postAuthentication( $user, AuthenticationResponse $response ) {
153        $captcha = Hooks::getInstance();
154        $loginCounter = $this->getLoginAttemptCounter( $captcha );
155        switch ( $response->status ) {
156            case AuthenticationResponse::PASS:
157            case AuthenticationResponse::RESTART:
158                $session = $this->manager->getRequest()->getSession();
159                $session->remove( 'ConfirmEdit:loginCaptchaPerUserTriggered' );
160                $loginCounter->resetBadLoginCounter( $user ? $user->getName() : null );
161                break;
162            case AuthenticationResponse::FAIL:
163                $loginCounter->increaseBadLoginCounter( $user ? $user->getName() : null );
164                break;
165        }
166    }
167
168    /**
169     * Verify submitted captcha.
170     * Assumes that the user has to pass the capctha (permission checks are caller's responsibility).
171     * @param SimpleCaptcha $captcha
172     * @param AuthenticationRequest[] $reqs
173     * @param User $user
174     * @return bool
175     */
176    protected function verifyCaptcha( SimpleCaptcha $captcha, array $reqs, User $user ) {
177        /** @var CaptchaAuthenticationRequest $req */
178        $req = AuthenticationRequest::getRequestByClass( $reqs,
179            CaptchaAuthenticationRequest::class, true );
180        if ( !$req ) {
181            return false;
182        }
183        return $captcha->passCaptchaLimited( $req->captchaId, $req->captchaWord, $user );
184    }
185
186    /**
187     * @param string $message Message key
188     * @param SimpleCaptcha $captcha
189     * @return Status
190     */
191    protected function makeError( $message, SimpleCaptcha $captcha ) {
192        $error = $captcha->getError();
193        if ( $error ) {
194            return Status::newFatal( wfMessage( 'captcha-error', $error ) );
195        }
196        return Status::newFatal( $message );
197    }
198
199    protected function getLoginAttemptCounter( SimpleCaptcha $captcha ): LoginAttemptCounter {
200        // Overridable for testing
201        return new LoginAttemptCounter( $captcha );
202    }
203}