Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
98.97% |
96 / 97 |
|
85.71% |
6 / 7 |
CRAP | |
0.00% |
0 / 1 |
CaptchaPreAuthenticationProvider | |
98.97% |
96 / 97 |
|
85.71% |
6 / 7 |
30 | |
0.00% |
0 / 1 |
getAuthenticationRequests | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
10 | |||
testForAuthentication | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
testForAccountCreation | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
postAuthentication | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
verifyCaptcha | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
makeError | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getLoginAttemptCounter | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\ConfirmEdit\Auth; |
4 | |
5 | use MediaWiki\Auth\AbstractPreAuthenticationProvider; |
6 | use MediaWiki\Auth\AuthenticationRequest; |
7 | use MediaWiki\Auth\AuthenticationResponse; |
8 | use MediaWiki\Auth\AuthManager; |
9 | use MediaWiki\Extension\ConfirmEdit\Hooks; |
10 | use MediaWiki\Extension\ConfirmEdit\SimpleCaptcha\SimpleCaptcha; |
11 | use MediaWiki\Logger\LoggerFactory; |
12 | use MediaWiki\Status\Status; |
13 | use MediaWiki\User\User; |
14 | |
15 | class 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 | } |