Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
66.96% |
75 / 112 |
|
47.37% |
9 / 19 |
CRAP | |
0.00% |
0 / 1 |
Database | |
66.96% |
75 / 112 |
|
47.37% |
9 / 19 |
122.29 | |
0.00% |
0 / 1 |
authenticate | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
passwordHashingEnabled | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setUserPassword | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
2.50 | |||
exceptionIndicatesDuplicateKey | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
create | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
3.14 | |||
updatePassword | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
validateUsername | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
validatePassword | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
checkEmailVerified | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
4.68 | |||
checkPassword | |
25.00% |
2 / 8 |
|
0.00% |
0 / 1 |
6.80 | |||
emailAllowed | |
53.33% |
8 / 15 |
|
0.00% |
0 / 1 |
3.91 | |||
supportsCreation | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
supportsPasswordChange | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
supportsPasswordRecovery | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUsernamePolicy | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
getPasswordPolicy | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
collectParamsFromRequest | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
validateParams | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
createUserFromParams | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | /** |
4 | * Database authentication class |
5 | * |
6 | * PHP version 8 |
7 | * |
8 | * Copyright (C) Villanova University 2010. |
9 | * |
10 | * This program is free software; you can redistribute it and/or modify |
11 | * it under the terms of the GNU General Public License version 2, |
12 | * as published by the Free Software Foundation. |
13 | * |
14 | * This program is distributed in the hope that it will be useful, |
15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17 | * GNU General Public License for more details. |
18 | * |
19 | * You should have received a copy of the GNU General Public License |
20 | * along with this program; if not, write to the Free Software |
21 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA |
22 | * |
23 | * @category VuFind |
24 | * @package Authentication |
25 | * @author Chris Hallberg <challber@villanova.edu> |
26 | * @author Franck Borel <franck.borel@gbv.de> |
27 | * @author Demian Katz <demian.katz@villanova.edu> |
28 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
29 | * @link https://vufind.org/wiki/development:plugins:authentication_handlers Wiki |
30 | */ |
31 | |
32 | namespace VuFind\Auth; |
33 | |
34 | use Laminas\Crypt\Password\Bcrypt; |
35 | use Laminas\Http\PhpEnvironment\Request; |
36 | use VuFind\Db\Entity\UserEntityInterface; |
37 | use VuFind\Db\Service\UserServiceInterface; |
38 | use VuFind\Exception\Auth as AuthException; |
39 | use VuFind\Exception\AuthEmailNotVerified as AuthEmailNotVerifiedException; |
40 | |
41 | use function in_array; |
42 | use function is_object; |
43 | |
44 | /** |
45 | * Database authentication class |
46 | * |
47 | * @category VuFind |
48 | * @package Authentication |
49 | * @author Chris Hallberg <challber@villanova.edu> |
50 | * @author Franck Borel <franck.borel@gbv.de> |
51 | * @author Demian Katz <demian.katz@villanova.edu> |
52 | * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License |
53 | * @link https://vufind.org/wiki/development:plugins:authentication_handlers Wiki |
54 | */ |
55 | class Database extends AbstractBase |
56 | { |
57 | /** |
58 | * Username |
59 | * |
60 | * @var string |
61 | */ |
62 | protected $username; |
63 | |
64 | /** |
65 | * Password |
66 | * |
67 | * @var string |
68 | */ |
69 | protected $password; |
70 | |
71 | /** |
72 | * Attempt to authenticate the current user. Throws exception if login fails. |
73 | * |
74 | * @param Request $request Request object containing account credentials. |
75 | * |
76 | * @throws AuthException |
77 | * @return UserEntityInterface Object representing logged-in user. |
78 | */ |
79 | public function authenticate($request) |
80 | { |
81 | // Make sure the credentials are non-blank: |
82 | $this->username = trim($request->getPost()->get('username', '')); |
83 | $this->password = trim($request->getPost()->get('password', '')); |
84 | if ($this->username == '' || $this->password == '') { |
85 | throw new AuthException('authentication_error_blank'); |
86 | } |
87 | |
88 | // Validate the credentials: |
89 | $userService = $this->getUserService(); |
90 | $user = $userService->getUserByUsername($this->username); |
91 | if (!is_object($user) || !$this->checkPassword($this->password, $user)) { |
92 | throw new AuthException('authentication_error_invalid'); |
93 | } |
94 | |
95 | // Verify email address: |
96 | $this->checkEmailVerified($user); |
97 | |
98 | // If we got this far, the login was successful: |
99 | return $user; |
100 | } |
101 | |
102 | /** |
103 | * Is password hashing enabled? |
104 | * |
105 | * @return bool |
106 | */ |
107 | protected function passwordHashingEnabled() |
108 | { |
109 | $config = $this->getConfig(); |
110 | return $config->Authentication->hash_passwords ?? false; |
111 | } |
112 | |
113 | /** |
114 | * Set the password in a UserEntityInterface object. |
115 | * |
116 | * @param UserEntityInterface $user User to update |
117 | * @param string $pass Password to store |
118 | * |
119 | * @return void |
120 | */ |
121 | protected function setUserPassword(UserEntityInterface $user, string $pass): void |
122 | { |
123 | if ($this->passwordHashingEnabled()) { |
124 | $bcrypt = new Bcrypt(); |
125 | $user->setPasswordHash($bcrypt->create($pass)); |
126 | } else { |
127 | $user->setRawPassword($pass); |
128 | } |
129 | } |
130 | |
131 | /** |
132 | * Does the provided exception indicate that a duplicate key value has been |
133 | * created? |
134 | * |
135 | * @param \Exception $e Exception to check |
136 | * |
137 | * @return bool |
138 | */ |
139 | protected function exceptionIndicatesDuplicateKey(\Exception $e): bool |
140 | { |
141 | return strstr($e->getMessage(), 'Duplicate entry') !== false; |
142 | } |
143 | |
144 | /** |
145 | * Create a new user account from the request. |
146 | * |
147 | * @param Request $request Request object containing new account details. |
148 | * |
149 | * @throws AuthException |
150 | * @return UserEntityInterface New user entity. |
151 | */ |
152 | public function create($request) |
153 | { |
154 | // Collect POST parameters from request |
155 | $params = $this->collectParamsFromRequest($request); |
156 | |
157 | // Validate username and password |
158 | $this->validateUsername($params); |
159 | $this->validatePassword($params); |
160 | |
161 | // Get the user table |
162 | $userService = $this->getUserService(); |
163 | |
164 | // Make sure parameters are correct |
165 | $this->validateParams($params, $userService); |
166 | |
167 | // If we got this far, we're ready to create the account: |
168 | $user = $this->createUserFromParams($params, $userService); |
169 | try { |
170 | $userService->persistEntity($user); |
171 | } catch (\Laminas\Db\Adapter\Exception\RuntimeException $e) { |
172 | // In a scenario where the unique key of the user table is |
173 | // shorter than the username field length, it is possible that |
174 | // a user will pass validation but still get rejected due to |
175 | // the inability to generate a unique key. This is a very |
176 | // unlikely scenario, but if it occurs, we will treat it the |
177 | // same as a duplicate username. Other unexpected database |
178 | // errors will be passed through unmodified. |
179 | throw $this->exceptionIndicatesDuplicateKey($e) |
180 | ? new AuthException('That username is already taken') : $e; |
181 | } |
182 | |
183 | // Verify email address: |
184 | $this->checkEmailVerified($user); |
185 | |
186 | return $user; |
187 | } |
188 | |
189 | /** |
190 | * Update a user's password from the request. |
191 | * |
192 | * @param Request $request Request object containing new account details. |
193 | * |
194 | * @throws AuthException |
195 | * @return UserEntityInterface Updated user entity. |
196 | */ |
197 | public function updatePassword($request) |
198 | { |
199 | // Ensure that all expected parameters are populated to avoid notices |
200 | // in the code below. |
201 | $params = [ |
202 | 'username' => '', 'password' => '', 'password2' => '', |
203 | ]; |
204 | foreach ($params as $param => $default) { |
205 | $params[$param] = $request->getPost()->get($param, $default); |
206 | } |
207 | |
208 | // Validate username and password, but skip validation of username policy |
209 | // since the account already exists): |
210 | $this->validateUsername($params, false); |
211 | $this->validatePassword($params); |
212 | |
213 | // Create the row and send it back to the caller: |
214 | $user = $this->getUserService()->getUserByUsername($params['username']); |
215 | $this->setUserPassword($user, $params['password']); |
216 | $this->getUserService()->persistEntity($user); |
217 | return $user; |
218 | } |
219 | |
220 | /** |
221 | * Make sure username isn't blank and matches the policy. |
222 | * |
223 | * @param array $params Request parameters |
224 | * @param bool $checkPolicy Whether to check the policy as well (default is |
225 | * true) |
226 | * |
227 | * @return void |
228 | */ |
229 | protected function validateUsername($params, $checkPolicy = true) |
230 | { |
231 | // Needs a username |
232 | if (trim($params['username']) == '') { |
233 | throw new AuthException('Username cannot be blank'); |
234 | } |
235 | if ($checkPolicy) { |
236 | // Check username policy |
237 | $this->validateUsernameAgainstPolicy($params['username']); |
238 | } |
239 | } |
240 | |
241 | /** |
242 | * Make sure password isn't blank, matches the policy and passwords match. |
243 | * |
244 | * @param array $params Request parameters |
245 | * |
246 | * @return void |
247 | */ |
248 | protected function validatePassword($params) |
249 | { |
250 | // Needs a password |
251 | if (trim($params['password']) == '') { |
252 | throw new AuthException('Password cannot be blank'); |
253 | } |
254 | // Passwords don't match |
255 | if ($params['password'] != $params['password2']) { |
256 | throw new AuthException('Passwords do not match'); |
257 | } |
258 | // Check password policy |
259 | $this->validatePasswordAgainstPolicy($params['password']); |
260 | } |
261 | |
262 | /** |
263 | * Check if the user's email address has been verified (if necessary) and |
264 | * throws exception if not. |
265 | * |
266 | * @param UserEntityInterface $user User to check |
267 | * |
268 | * @return void |
269 | * @throws AuthEmailNotVerifiedException |
270 | */ |
271 | protected function checkEmailVerified($user) |
272 | { |
273 | $config = $this->getConfig(); |
274 | $verify_email = $config->Authentication->verify_email ?? false; |
275 | if ($verify_email && !$user->getEmailVerified()) { |
276 | throw new AuthEmailNotVerifiedException( |
277 | $user, |
278 | 'authentication_error_email_not_verified_html' |
279 | ); |
280 | } |
281 | } |
282 | |
283 | /** |
284 | * Check that the user's password matches the provided value. |
285 | * |
286 | * @param string $password Password to check. |
287 | * @param UserEntityInterface $userRow The user row. We pass this instead of the password |
288 | * because we may need to check different values depending on the password |
289 | * hashing configuration. |
290 | * |
291 | * @return bool |
292 | */ |
293 | protected function checkPassword($password, $userRow) |
294 | { |
295 | // Special case: hashing enabled: |
296 | if ($this->passwordHashingEnabled()) { |
297 | if ($userRow->getRawPassword()) { |
298 | throw new \VuFind\Exception\PasswordSecurity( |
299 | 'Unexpected unencrypted password found in database' |
300 | ); |
301 | } |
302 | |
303 | $bcrypt = new Bcrypt(); |
304 | return $bcrypt->verify($password, $userRow->getPasswordHash() ?? ''); |
305 | } |
306 | |
307 | // Default case: unencrypted passwords: |
308 | return $password == $userRow->getRawPassword(); |
309 | } |
310 | |
311 | /** |
312 | * Check that an email address is legal based on inclusion list (if configured). |
313 | * |
314 | * @param string $email Email address to check (assumed to be valid/well-formed) |
315 | * |
316 | * @return bool |
317 | */ |
318 | protected function emailAllowed($email) |
319 | { |
320 | // If no inclusion list is configured, all emails are allowed: |
321 | $fullConfig = $this->getConfig(); |
322 | $config = isset($fullConfig->Authentication) |
323 | ? $fullConfig->Authentication->toArray() : []; |
324 | $rawIncludeList = $config['legal_domains'] |
325 | ?? $config['domain_whitelist'] // deprecated configuration |
326 | ?? null; |
327 | if (empty($rawIncludeList)) { |
328 | return true; |
329 | } |
330 | |
331 | // Normalize the allowed list: |
332 | $includeList = array_map( |
333 | 'trim', |
334 | array_map('strtolower', $rawIncludeList) |
335 | ); |
336 | |
337 | // Extract the domain from the email address: |
338 | $parts = explode('@', $email); |
339 | $domain = strtolower(trim(array_pop($parts))); |
340 | |
341 | // Match domain against allowed list: |
342 | return in_array($domain, $includeList); |
343 | } |
344 | |
345 | /** |
346 | * Does this authentication method support account creation? |
347 | * |
348 | * @return bool |
349 | */ |
350 | public function supportsCreation() |
351 | { |
352 | return true; |
353 | } |
354 | |
355 | /** |
356 | * Does this authentication method support password changing |
357 | * |
358 | * @return bool |
359 | */ |
360 | public function supportsPasswordChange() |
361 | { |
362 | return true; |
363 | } |
364 | |
365 | /** |
366 | * Does this authentication method support password recovery |
367 | * |
368 | * @return bool |
369 | */ |
370 | public function supportsPasswordRecovery() |
371 | { |
372 | return true; |
373 | } |
374 | |
375 | /** |
376 | * Username policy for a new account (e.g. minLength, maxLength) |
377 | * |
378 | * @return array |
379 | */ |
380 | public function getUsernamePolicy() |
381 | { |
382 | $policy = parent::getUsernamePolicy(); |
383 | // Limit maxLength to the database limit |
384 | if (!isset($policy['maxLength']) || $policy['maxLength'] > 255) { |
385 | $policy['maxLength'] = 255; |
386 | } |
387 | return $policy; |
388 | } |
389 | |
390 | /** |
391 | * Password policy for a new password (e.g. minLength, maxLength) |
392 | * |
393 | * @return array |
394 | */ |
395 | public function getPasswordPolicy() |
396 | { |
397 | $policy = parent::getPasswordPolicy(); |
398 | // Limit maxLength to the database limit |
399 | if (!isset($policy['maxLength']) || $policy['maxLength'] > 32) { |
400 | $policy['maxLength'] = 32; |
401 | } |
402 | return $policy; |
403 | } |
404 | |
405 | /** |
406 | * Collect parameters from request and populate them. |
407 | * |
408 | * @param Request $request Request object containing new account details. |
409 | * |
410 | * @return string[] |
411 | */ |
412 | protected function collectParamsFromRequest($request) |
413 | { |
414 | // Ensure that all expected parameters are populated to avoid notices |
415 | // in the code below. |
416 | $params = [ |
417 | 'firstname' => '', 'lastname' => '', 'username' => '', |
418 | 'password' => '', 'password2' => '', 'email' => '', |
419 | ]; |
420 | foreach ($params as $param => $default) { |
421 | $params[$param] = $request->getPost()->get($param, $default); |
422 | } |
423 | |
424 | return $params; |
425 | } |
426 | |
427 | /** |
428 | * Validate parameters. |
429 | * |
430 | * @param string[] $params Parameters returned from collectParamsFromRequest() |
431 | * @param UserServiceInterface $userService User service |
432 | * |
433 | * @throws AuthException |
434 | * |
435 | * @return void |
436 | */ |
437 | protected function validateParams(array $params, UserServiceInterface $userService): void |
438 | { |
439 | // Invalid Email Check |
440 | $validator = new \Laminas\Validator\EmailAddress(); |
441 | if (!$validator->isValid($params['email'])) { |
442 | throw new AuthException('Email address is invalid'); |
443 | } |
444 | |
445 | // Check if Email is on allowed list (if applicable) |
446 | if (!$this->emailAllowed($params['email'])) { |
447 | throw new AuthException('authentication_error_creation_blocked'); |
448 | } |
449 | |
450 | // Make sure we have a unique username |
451 | if ($userService->getUserByUsername($params['username'])) { |
452 | throw new AuthException('That username is already taken'); |
453 | } |
454 | |
455 | // Make sure we have a unique email |
456 | if ($userService->getUserByEmail($params['email'])) { |
457 | throw new AuthException('That email address is already used'); |
458 | } |
459 | } |
460 | |
461 | /** |
462 | * Create a user entity object from given parameters. |
463 | * |
464 | * @param string[] $params Parameters returned from collectParamsFromRequest() |
465 | * @param UserServiceInterface $userService User service |
466 | * |
467 | * @return UserEntityInterface A user entity |
468 | */ |
469 | protected function createUserFromParams(array $params, UserServiceInterface $userService) |
470 | { |
471 | $user = $userService->createEntityForUsername($params['username']); |
472 | $user->setFirstname($params['firstname']); |
473 | $user->setLastname($params['lastname']); |
474 | $this->getUserService()->updateUserEmail($user, $params['email'], true); |
475 | $this->setUserPassword($user, $params['password']); |
476 | return $user; |
477 | } |
478 | } |