Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.37% covered (warning)
80.37%
86 / 107
48.15% covered (danger)
48.15%
13 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractBase
80.37% covered (warning)
80.37%
86 / 107
48.15% covered (danger)
48.15%
13 / 27
68.90
0.00% covered (danger)
0.00%
0 / 1
 getConfig
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 preLoginCheck
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 resetState
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 needsCsrfCheck
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDelegateAuthMethod
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
n/a
0 / 0
n/a
0 / 0
0
 validateCredentials
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isExpired
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 updatePassword
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionInitiator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logout
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 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
 supportsConnectingLibraryCard
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCannedPolicyHint
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getPolicyConfig
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 getUsernamePolicy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPasswordPolicy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserService
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateUsernameAgainstPolicy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validatePasswordAgainstPolicy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 validateStringAgainstPolicy
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
14
 getOrCreateUserByUsername
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setUserValueByField
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3/**
4 * Abstract authentication base 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   Franck Borel <franck.borel@gbv.de>
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org Main Page
29 */
30
31namespace VuFind\Auth;
32
33use Exception;
34use Laminas\Http\PhpEnvironment\Request;
35use VuFind\Db\Entity\UserEntityInterface;
36use VuFind\Db\Service\UserServiceInterface;
37use VuFind\Exception\Auth as AuthException;
38
39use function get_class;
40use function in_array;
41use function is_callable;
42
43/**
44 * Abstract authentication base class
45 *
46 * @category VuFind
47 * @package  Authentication
48 * @author   Franck Borel <franck.borel@gbv.de>
49 * @author   Demian Katz <demian.katz@villanova.edu>
50 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
51 * @link     https://vufind.org Main Page
52 */
53abstract class AbstractBase implements
54    \VuFind\Db\Service\DbServiceAwareInterface,
55    \VuFind\I18n\Translator\TranslatorAwareInterface,
56    \Laminas\Log\LoggerAwareInterface
57{
58    use \VuFind\Db\Service\DbServiceAwareTrait;
59    use \VuFind\I18n\Translator\TranslatorAwareTrait;
60    use \VuFind\Log\LoggerAwareTrait;
61
62    /**
63     * Has the configuration been validated?
64     *
65     * @var bool
66     */
67    protected $configValidated = false;
68
69    /**
70     * Configuration settings
71     *
72     * @var \Laminas\Config\Config
73     */
74    protected $config = null;
75
76    /**
77     * Map of database column name to setter method for UserEntityInterface objects.
78     *
79     * @return array
80     */
81    protected $userSetterMap = [
82        'cat_username' => 'setCatUsername',
83        'college' => 'setCollege',
84        'email' => 'setEmail',
85        'firstname' => 'setFirstname',
86        'lastname' => 'setLastname',
87        'home_library' => 'setHomeLibrary',
88        'major' => 'setMajor',
89    ];
90
91    /**
92     * Get configuration (load automatically if not previously set). Throw an
93     * exception if the configuration is invalid.
94     *
95     * @throws AuthException
96     * @return \Laminas\Config\Config
97     */
98    public function getConfig()
99    {
100        // Validate configuration if not already validated:
101        if (!$this->configValidated) {
102            $this->validateConfig();
103            $this->configValidated = true;
104        }
105
106        return $this->config;
107    }
108
109    /**
110     * Inspect the user's request prior to processing a login request; this is
111     * essentially an event hook which most auth modules can ignore. See
112     * ChoiceAuth for a use case example.
113     *
114     * @param Request $request Request object.
115     *
116     * @throws AuthException
117     * @return void
118     *
119     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
120     */
121    public function preLoginCheck($request)
122    {
123        // By default, do no checking.
124    }
125
126    /**
127     * Reset any internal status; this is essentially an event hook which most auth
128     * modules can ignore. See ChoiceAuth for a use case example.
129     *
130     * @return void
131     */
132    public function resetState()
133    {
134        // By default, do no checking.
135    }
136
137    /**
138     * Set configuration.
139     *
140     * @param \Laminas\Config\Config $config Configuration to set
141     *
142     * @return void
143     */
144    public function setConfig($config)
145    {
146        $this->config = $config;
147        $this->configValidated = false;
148    }
149
150    /**
151     * Whether this authentication method needs CSRF checking for the request.
152     *
153     * @param Request $request Request object.
154     *
155     * @return bool
156     *
157     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
158     */
159    public function needsCsrfCheck($request)
160    {
161        // Enabled by default
162        return true;
163    }
164
165    /**
166     * Returns any authentication method this request should be delegated to.
167     *
168     * @param Request $request Request object.
169     *
170     * @return string|bool
171     *
172     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
173     */
174    public function getDelegateAuthMethod(Request $request)
175    {
176        // No delegate by default
177        return false;
178    }
179
180    /**
181     * Validate configuration parameters. This is a support method for getConfig(),
182     * so the configuration MUST be accessed using $this->config; do not call
183     * $this->getConfig() from within this method!
184     *
185     * @throws AuthException
186     * @return void
187     */
188    protected function validateConfig()
189    {
190        // By default, do no checking.
191    }
192
193    /**
194     * Attempt to authenticate the current user. Throws exception if login fails.
195     *
196     * @param Request $request Request object containing account credentials.
197     *
198     * @throws AuthException
199     * @return UserEntityInterface Object representing logged-in user.
200     */
201    abstract public function authenticate($request);
202
203    /**
204     * Validate the credentials in the provided request, but do not change the state
205     * of the current logged-in user. Return true for valid credentials, false
206     * otherwise.
207     *
208     * @param Request $request Request object containing account credentials.
209     *
210     * @throws AuthException
211     * @return bool
212     */
213    public function validateCredentials($request)
214    {
215        try {
216            $user = $this->authenticate($request);
217        } catch (AuthException $e) {
218            return false;
219        }
220        return $user instanceof UserEntityInterface;
221    }
222
223    /**
224     * Has the user's login expired?
225     *
226     * @return bool
227     */
228    public function isExpired()
229    {
230        // By default, logins do not expire:
231        return false;
232    }
233
234    /**
235     * Create a new user account from the request.
236     *
237     * @param Request $request Request object containing new account details.
238     *
239     * @throws AuthException
240     * @return UserEntityInterface New user entity.
241     *
242     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
243     */
244    public function create($request)
245    {
246        throw new AuthException(
247            'Account creation not supported by ' . get_class($this)
248        );
249    }
250
251    /**
252     * Update a user's password from the request.
253     *
254     * @param Request $request Request object containing new account details.
255     *
256     * @throws AuthException
257     * @return UserEntityInterface Updated user entity.
258     *
259     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
260     */
261    public function updatePassword($request)
262    {
263        throw new AuthException(
264            'Account password updating not supported by ' . get_class($this)
265        );
266    }
267
268    /**
269     * Get the URL to establish a session (needed when the internal VuFind login
270     * form is inadequate). Returns false when no session initiator is needed.
271     *
272     * @param string $target Full URL where external authentication method should
273     * send user after login (some drivers may override this).
274     *
275     * @return bool|string
276     *
277     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
278     */
279    public function getSessionInitiator($target)
280    {
281        return false;
282    }
283
284    /**
285     * Perform cleanup at logout time.
286     *
287     * @param string $url URL to redirect user to after logging out.
288     *
289     * @return string     Redirect URL (usually same as $url, but modified in
290     * some authentication modules).
291     */
292    public function logout($url)
293    {
294        // No special cleanup or URL modification needed by default.
295        return $url;
296    }
297
298    /**
299     * Does this authentication method support account creation?
300     *
301     * @return bool
302     */
303    public function supportsCreation()
304    {
305        // By default, account creation is not supported.
306        return false;
307    }
308
309    /**
310     * Does this authentication method support password changing
311     *
312     * @return bool
313     */
314    public function supportsPasswordChange()
315    {
316        // By default, password changing is not supported.
317        return false;
318    }
319
320    /**
321     * Does this authentication method support password recovery
322     *
323     * @return bool
324     */
325    public function supportsPasswordRecovery()
326    {
327        // By default, password recovery is not supported.
328        return false;
329    }
330
331    /**
332     * Does this authentication method support connecting library card of
333     * currently authenticated user?
334     *
335     * @return bool
336     */
337    public function supportsConnectingLibraryCard()
338    {
339        return method_exists($this, 'connectLibraryCard');
340    }
341
342    /**
343     * Return a canned username or password policy hint when available
344     *
345     * @param string  $type    Policy type (password or username)
346     * @param ?string $pattern Current policy pattern
347     *
348     * @return ?string
349     */
350    protected function getCannedPolicyHint(string $type, ?string $pattern): ?string
351    {
352        /* Return a value according to the policy and pattern type, e.g.:
353         *
354         * 'numeric'      => password_only_numeric or username_only_numeric
355         * 'alphanumeric' => password_only_alphanumeric or username_only_alphanumeric
356         * others         => null (any hint should be defined by the password_hint or
357         *                   username_hint setting)
358         */
359        return (in_array($pattern, ['numeric', 'alphanumeric']))
360            ? $type . '_only_' . $pattern : null;
361    }
362
363    /**
364     * Get a policy configuration
365     *
366     * @param string $type Policy type (password or username)
367     *
368     * @return array
369     */
370    public function getPolicyConfig(string $type): array
371    {
372        $policy = [];
373        $config = $this->getConfig();
374        $authConfig = isset($config->Authentication)
375            ? $config->Authentication->toArray()
376            : [];
377        /* Map settings to the policy array, e.g.:
378         *
379         * password_minimum_length or username_minimum_length => minLength
380         * password_maximum_length or username_maximum_length => maxLength
381         * password_pattern or username_pattern => pattern
382         * password_hint or username_hint => hint
383         */
384        $map = [
385            "minimum_{$type}_length" => 'minLength',
386            "maximum_{$type}_length" => 'maxLength',
387            "{$type}_pattern" => 'pattern',
388            "{$type}_hint" => 'hint',
389        ];
390        foreach ($map as $iniSetting => $returnKey) {
391            if (null !== ($value = $authConfig[$iniSetting] ?? null)) {
392                $policy[$returnKey] = $value;
393            }
394        }
395        if (!isset($policy['hint'])) {
396            $policy['hint'] = $this->getCannedPolicyHint(
397                $type,
398                $policy['pattern'] ?? null
399            );
400        }
401        return $policy;
402    }
403
404    /**
405     * Get username policy for a new account (e.g. minLength, maxLength)
406     *
407     * @return array
408     */
409    public function getUsernamePolicy()
410    {
411        return $this->getPolicyConfig('username');
412    }
413
414    /**
415     * Get password policy for a new password (e.g. minLength, maxLength)
416     *
417     * @return array
418     */
419    public function getPasswordPolicy()
420    {
421        return $this->getPolicyConfig('password');
422    }
423
424    /**
425     * Get access to the user table.
426     *
427     * @return UserServiceInterface
428     */
429    public function getUserService(): UserServiceInterface
430    {
431        return $this->getDbService(UserServiceInterface::class);
432    }
433
434    /**
435     * Verify that a username fulfills the username policy. Throws exception if
436     * the username is invalid.
437     *
438     * @param string $username Password to verify
439     *
440     * @return void
441     * @throws AuthException
442     */
443    protected function validateUsernameAgainstPolicy(string $username): void
444    {
445        $this->validateStringAgainstPolicy(
446            'username',
447            $this->getUsernamePolicy(),
448            $username
449        );
450    }
451
452    /**
453     * Verify that a password fulfills the password policy. Throws exception if
454     * the password is invalid.
455     *
456     * @param string $password Password to verify
457     *
458     * @return void
459     * @throws AuthException
460     */
461    protected function validatePasswordAgainstPolicy(string $password): void
462    {
463        $this->validateStringAgainstPolicy(
464            'password',
465            $this->getPasswordPolicy(),
466            $password
467        );
468    }
469
470    /**
471     * Verify that a username or password fulfills the given policy. Throws exception
472     * if the string is invalid.
473     *
474     * @param string $type   Policy type (password or username)
475     * @param array  $policy Policy configuration
476     * @param string $string String to verify
477     *
478     * @return void
479     * @throws AuthException
480     */
481    protected function validateStringAgainstPolicy(
482        string $type,
483        array $policy,
484        string $string
485    ): void {
486        if (
487            isset($policy['minLength'])
488            && mb_strlen($string, 'UTF-8') < $policy['minLength']
489        ) {
490            // e.g. password_minimum_length or username_minimum_length:
491            throw new AuthException(
492                $this->translate(
493                    "{$type}_minimum_length",
494                    ['%%minlength%%' => $policy['minLength']]
495                )
496            );
497        }
498        if (
499            isset($policy['maxLength'])
500            && mb_strlen($string, 'UTF-8') > $policy['maxLength']
501        ) {
502            // e.g. password_maximum_length or username_maximum_length:
503            throw new AuthException(
504                $this->translate(
505                    "{$type}_maximum_length",
506                    ['%%maxlength%%' => $policy['maxLength']]
507                )
508            );
509        }
510        if (!empty($policy['pattern'])) {
511            $valid = true;
512            if ($policy['pattern'] == 'numeric') {
513                if (!ctype_digit($string)) {
514                    $valid = false;
515                }
516            } elseif ($policy['pattern'] == 'alphanumeric') {
517                if (preg_match('/[^\da-zA-Z]/', $string)) {
518                    $valid = false;
519                }
520            } else {
521                $result = @preg_match(
522                    "/({$policy['pattern']})/u",
523                    $string,
524                    $matches
525                );
526                if ($result === false) {
527                    throw new \Exception(
528                        "Invalid regexp in $type pattern: " . $policy['pattern']
529                    );
530                }
531                if (!$result || $matches[1] != $string) {
532                    $valid = false;
533                }
534            }
535            if (!$valid) {
536                // e.g. password_error_invalid or username_error_invalid:
537                throw new AuthException($this->translate("{$type}_error_invalid"));
538            }
539        }
540    }
541
542    /**
543     * Look up a user by username; create a new entity if no match is found.
544     *
545     * @param string $username Username
546     *
547     * @return UserEntityInterface
548     * @throws Exception
549     */
550    protected function getOrCreateUserByUsername(string $username): UserEntityInterface
551    {
552        $userService = $this->getUserService();
553        $user = $userService->getUserByUsername($username);
554        return $user ? $user : $userService->createEntityForUsername($username);
555    }
556
557    /**
558     * Set a value in a UserEntityObject using a field name.
559     *
560     * @param UserEntityInterface $user  User to update
561     * @param string              $field Field name being updated
562     * @param mixed               $value New value to set
563     *
564     * @return void
565     * @throws Exception
566     */
567    protected function setUserValueByField(UserEntityInterface $user, string $field, $value): void
568    {
569        $setter = $this->userSetterMap[$field] ?? null;
570        if (!$setter || !is_callable([$user, $setter])) {
571            throw new Exception("Unsupported field: $field");
572        }
573        $user->$setter($value);
574    }
575}