Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.96% covered (warning)
66.96%
75 / 112
47.37% covered (danger)
47.37%
9 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Database
66.96% covered (warning)
66.96%
75 / 112
47.37% covered (danger)
47.37%
9 / 19
122.29
0.00% covered (danger)
0.00%
0 / 1
 authenticate
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 passwordHashingEnabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUserPassword
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 exceptionIndicatesDuplicateKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
3.14
 updatePassword
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 validateUsername
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 validatePassword
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 checkEmailVerified
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 checkPassword
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
6.80
 emailAllowed
53.33% covered (warning)
53.33%
8 / 15
0.00% covered (danger)
0.00%
0 / 1
3.91
 supportsCreation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsPasswordChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supportsPasswordRecovery
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUsernamePolicy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 getPasswordPolicy
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 collectParamsFromRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 validateParams
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 createUserFromParams
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
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
32namespace VuFind\Auth;
33
34use Laminas\Crypt\Password\Bcrypt;
35use Laminas\Http\PhpEnvironment\Request;
36use VuFind\Db\Entity\UserEntityInterface;
37use VuFind\Db\Service\UserServiceInterface;
38use VuFind\Exception\Auth as AuthException;
39use VuFind\Exception\AuthEmailNotVerified as AuthEmailNotVerifiedException;
40
41use function in_array;
42use 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 */
55class 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}