Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
140 / 140 |
|
100.00% |
11 / 11 |
CRAP | |
100.00% |
1 / 1 |
LocalPasswordPrimaryAuthenticationProvider | |
100.00% |
140 / 140 |
|
100.00% |
11 / 11 |
54 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getPasswordResetData | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
beginPrimaryAuthentication | |
100.00% |
29 / 29 |
|
100.00% |
1 / 1 |
13 | |||
testUserCanAuthenticate | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
4 | |||
testUserExists | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
providerAllowsAuthenticationDataChange | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
8 | |||
providerChangeAuthenticationData | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
6 | |||
accountCreationType | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
testForAccountCreation | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
6 | |||
beginPrimaryAccountCreation | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
finishAccountCreation | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | * @ingroup Auth |
20 | */ |
21 | |
22 | namespace MediaWiki\Auth; |
23 | |
24 | use IDBAccessObject; |
25 | use MediaWiki\Deferred\DeferredUpdates; |
26 | use MediaWiki\MainConfigNames; |
27 | use MediaWiki\User\UserRigorOptions; |
28 | use Wikimedia\Rdbms\IConnectionProvider; |
29 | |
30 | /** |
31 | * A primary authentication provider that uses the password field in the 'user' table. |
32 | * @ingroup Auth |
33 | * @since 1.27 |
34 | */ |
35 | class LocalPasswordPrimaryAuthenticationProvider |
36 | extends AbstractPasswordPrimaryAuthenticationProvider |
37 | { |
38 | |
39 | /** @var bool If true, this instance is for legacy logins only. */ |
40 | protected $loginOnly = false; |
41 | |
42 | /** @var IConnectionProvider */ |
43 | private $dbProvider; |
44 | |
45 | /** |
46 | * @param IConnectionProvider $dbProvider |
47 | * @param array $params Settings |
48 | * - loginOnly: If true, the local passwords are for legacy logins only: |
49 | * the local password will be invalidated when authentication is changed |
50 | * and new users will not have a valid local password set. |
51 | */ |
52 | public function __construct( IConnectionProvider $dbProvider, $params = [] ) { |
53 | parent::__construct( $params ); |
54 | $this->loginOnly = !empty( $params['loginOnly'] ); |
55 | $this->dbProvider = $dbProvider; |
56 | } |
57 | |
58 | /** |
59 | * Check if the password has expired and needs a reset |
60 | * |
61 | * @param string $username |
62 | * @param \stdClass $row A row from the user table |
63 | * @return \stdClass|null |
64 | */ |
65 | protected function getPasswordResetData( $username, $row ) { |
66 | $now = (int)wfTimestamp(); |
67 | $expiration = wfTimestampOrNull( TS_UNIX, $row->user_password_expires ); |
68 | if ( $expiration === null || (int)$expiration >= $now ) { |
69 | return null; |
70 | } |
71 | |
72 | $grace = $this->config->get( MainConfigNames::PasswordExpireGrace ); |
73 | if ( (int)$expiration + $grace < $now ) { |
74 | $data = [ |
75 | 'hard' => true, |
76 | 'msg' => \MediaWiki\Status\Status::newFatal( 'resetpass-expired' )->getMessage(), |
77 | ]; |
78 | } else { |
79 | $data = [ |
80 | 'hard' => false, |
81 | 'msg' => \MediaWiki\Status\Status::newFatal( 'resetpass-expired-soft' )->getMessage(), |
82 | ]; |
83 | } |
84 | |
85 | return (object)$data; |
86 | } |
87 | |
88 | public function beginPrimaryAuthentication( array $reqs ) { |
89 | $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); |
90 | if ( !$req || $req->username === null || $req->password === null ) { |
91 | return AuthenticationResponse::newAbstain(); |
92 | } |
93 | |
94 | $username = $this->userNameUtils->getCanonical( |
95 | $req->username, UserRigorOptions::RIGOR_USABLE ); |
96 | if ( $username === false ) { |
97 | return AuthenticationResponse::newAbstain(); |
98 | } |
99 | |
100 | $row = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
101 | ->select( [ 'user_id', 'user_password', 'user_password_expires' ] ) |
102 | ->from( 'user' ) |
103 | ->where( [ 'user_name' => $username ] ) |
104 | ->caller( __METHOD__ )->fetchRow(); |
105 | if ( !$row ) { |
106 | // Do not reveal whether its bad username or |
107 | // bad password to prevent username enumeration |
108 | // on private wikis. (T134100) |
109 | return $this->failResponse( $req ); |
110 | } |
111 | |
112 | $oldRow = clone $row; |
113 | // Check for *really* old password hashes that don't even have a type |
114 | // The old hash format was just an md5 hex hash, with no type information |
115 | if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { |
116 | $row->user_password = ":B:{$row->user_id}:{$row->user_password}"; |
117 | } |
118 | |
119 | $status = $this->checkPasswordValidity( $username, $req->password ); |
120 | if ( !$status->isOK() ) { |
121 | return $this->getFatalPasswordErrorResponse( $username, $status ); |
122 | } |
123 | |
124 | $pwhash = $this->getPassword( $row->user_password ); |
125 | if ( !$pwhash->verify( $req->password ) ) { |
126 | if ( $this->config->get( MainConfigNames::LegacyEncoding ) ) { |
127 | // Some wikis were converted from ISO 8859-1 to UTF-8, the passwords can't be converted |
128 | // Check for this with iconv |
129 | $cp1252Password = iconv( 'UTF-8', 'WINDOWS-1252//TRANSLIT', $req->password ); |
130 | if ( $cp1252Password === $req->password || !$pwhash->verify( $cp1252Password ) ) { |
131 | return $this->failResponse( $req ); |
132 | } |
133 | } else { |
134 | return $this->failResponse( $req ); |
135 | } |
136 | } |
137 | |
138 | // @codeCoverageIgnoreStart |
139 | if ( $this->getPasswordFactory()->needsUpdate( $pwhash ) ) { |
140 | $newHash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); |
141 | $fname = __METHOD__; |
142 | DeferredUpdates::addCallableUpdate( function () use ( $newHash, $oldRow, $fname ) { |
143 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
144 | $dbw->newUpdateQueryBuilder() |
145 | ->update( 'user' ) |
146 | ->set( [ 'user_password' => $newHash->toString() ] ) |
147 | ->where( [ |
148 | 'user_id' => $oldRow->user_id, |
149 | 'user_password' => $oldRow->user_password, |
150 | ] ) |
151 | ->caller( $fname )->execute(); |
152 | } ); |
153 | } |
154 | // @codeCoverageIgnoreEnd |
155 | |
156 | $this->setPasswordResetFlag( $username, $status, $row ); |
157 | |
158 | return AuthenticationResponse::newPass( $username ); |
159 | } |
160 | |
161 | public function testUserCanAuthenticate( $username ) { |
162 | $username = $this->userNameUtils->getCanonical( |
163 | $username, UserRigorOptions::RIGOR_USABLE ); |
164 | if ( $username === false ) { |
165 | return false; |
166 | } |
167 | |
168 | $row = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder() |
169 | ->select( [ 'user_password' ] ) |
170 | ->from( 'user' ) |
171 | ->where( [ 'user_name' => $username ] ) |
172 | ->caller( __METHOD__ )->fetchRow(); |
173 | if ( !$row ) { |
174 | return false; |
175 | } |
176 | |
177 | // Check for *really* old password hashes that don't even have a type |
178 | // The old hash format was just an md5 hex hash, with no type information |
179 | if ( preg_match( '/^[0-9a-f]{32}$/', $row->user_password ) ) { |
180 | return true; |
181 | } |
182 | |
183 | return !$this->getPassword( $row->user_password ) instanceof \InvalidPassword; |
184 | } |
185 | |
186 | public function testUserExists( $username, $flags = IDBAccessObject::READ_NORMAL ) { |
187 | $username = $this->userNameUtils->getCanonical( |
188 | $username, UserRigorOptions::RIGOR_USABLE ); |
189 | if ( $username === false ) { |
190 | return false; |
191 | } |
192 | |
193 | $db = \DBAccessObjectUtils::getDBFromRecency( $this->dbProvider, $flags ); |
194 | return (bool)$db->newSelectQueryBuilder() |
195 | ->select( [ 'user_id' ] ) |
196 | ->from( 'user' ) |
197 | ->where( [ 'user_name' => $username ] ) |
198 | ->recency( $flags ) |
199 | ->caller( __METHOD__ )->fetchField(); |
200 | } |
201 | |
202 | public function providerAllowsAuthenticationDataChange( |
203 | AuthenticationRequest $req, $checkData = true |
204 | ) { |
205 | // We only want to blank the password if something else will accept the |
206 | // new authentication data, so return 'ignore' here. |
207 | if ( $this->loginOnly ) { |
208 | return \StatusValue::newGood( 'ignored' ); |
209 | } |
210 | |
211 | if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { |
212 | if ( !$checkData ) { |
213 | return \StatusValue::newGood(); |
214 | } |
215 | |
216 | $username = $this->userNameUtils->getCanonical( $req->username, |
217 | UserRigorOptions::RIGOR_USABLE ); |
218 | if ( $username !== false ) { |
219 | $row = $this->dbProvider->getPrimaryDatabase()->newSelectQueryBuilder() |
220 | ->select( [ 'user_id' ] ) |
221 | ->from( 'user' ) |
222 | ->where( [ 'user_name' => $username ] ) |
223 | ->caller( __METHOD__ )->fetchRow(); |
224 | if ( $row ) { |
225 | $sv = \StatusValue::newGood(); |
226 | if ( $req->password !== null ) { |
227 | if ( $req->password !== $req->retype ) { |
228 | $sv->fatal( 'badretype' ); |
229 | } else { |
230 | $sv->merge( $this->checkPasswordValidity( $username, $req->password ) ); |
231 | } |
232 | } |
233 | return $sv; |
234 | } |
235 | } |
236 | } |
237 | |
238 | return \StatusValue::newGood( 'ignored' ); |
239 | } |
240 | |
241 | public function providerChangeAuthenticationData( AuthenticationRequest $req ) { |
242 | $username = $req->username !== null ? |
243 | $this->userNameUtils->getCanonical( $req->username, UserRigorOptions::RIGOR_USABLE ) |
244 | : false; |
245 | if ( $username === false ) { |
246 | return; |
247 | } |
248 | |
249 | $pwhash = null; |
250 | |
251 | if ( get_class( $req ) === PasswordAuthenticationRequest::class ) { |
252 | if ( $this->loginOnly ) { |
253 | $pwhash = $this->getPasswordFactory()->newFromCiphertext( null ); |
254 | $expiry = null; |
255 | } else { |
256 | $pwhash = $this->getPasswordFactory()->newFromPlaintext( $req->password ); |
257 | $expiry = $this->getNewPasswordExpiry( $username ); |
258 | } |
259 | } |
260 | |
261 | if ( $pwhash ) { |
262 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
263 | $dbw->newUpdateQueryBuilder() |
264 | ->update( 'user' ) |
265 | ->set( [ |
266 | 'user_password' => $pwhash->toString(), |
267 | // @phan-suppress-next-line PhanPossiblyUndeclaredVariable expiry is set together with pwhash |
268 | 'user_password_expires' => $dbw->timestampOrNull( $expiry ), |
269 | ] ) |
270 | ->where( [ 'user_name' => $username ] ) |
271 | ->caller( __METHOD__ )->execute(); |
272 | } |
273 | } |
274 | |
275 | public function accountCreationType() { |
276 | return $this->loginOnly ? self::TYPE_NONE : self::TYPE_CREATE; |
277 | } |
278 | |
279 | public function testForAccountCreation( $user, $creator, array $reqs ) { |
280 | $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); |
281 | |
282 | $ret = \StatusValue::newGood(); |
283 | if ( !$this->loginOnly && $req && $req->username !== null && $req->password !== null ) { |
284 | if ( $req->password !== $req->retype ) { |
285 | $ret->fatal( 'badretype' ); |
286 | } else { |
287 | $ret->merge( |
288 | $this->checkPasswordValidity( $user->getName(), $req->password ) |
289 | ); |
290 | } |
291 | } |
292 | return $ret; |
293 | } |
294 | |
295 | public function beginPrimaryAccountCreation( $user, $creator, array $reqs ) { |
296 | if ( $this->accountCreationType() === self::TYPE_NONE ) { |
297 | throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); |
298 | } |
299 | |
300 | $req = AuthenticationRequest::getRequestByClass( $reqs, PasswordAuthenticationRequest::class ); |
301 | if ( $req && $req->username !== null && $req->password !== null ) { |
302 | // Nothing we can do besides claim it, because the user isn't in |
303 | // the DB yet |
304 | if ( $req->username !== $user->getName() ) { |
305 | $req = clone $req; |
306 | $req->username = $user->getName(); |
307 | } |
308 | $ret = AuthenticationResponse::newPass( $req->username ); |
309 | $ret->createRequest = $req; |
310 | return $ret; |
311 | } |
312 | return AuthenticationResponse::newAbstain(); |
313 | } |
314 | |
315 | public function finishAccountCreation( $user, $creator, AuthenticationResponse $res ) { |
316 | if ( $this->accountCreationType() === self::TYPE_NONE ) { |
317 | throw new \BadMethodCallException( 'Shouldn\'t call this when accountCreationType() is NONE' ); |
318 | } |
319 | |
320 | // Now that the user is in the DB, set the password on it. |
321 | $this->providerChangeAuthenticationData( $res->createRequest ); |
322 | |
323 | return null; |
324 | } |
325 | } |