Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.24% covered (warning)
62.24%
122 / 196
48.89% covered (danger)
48.89%
22 / 45
CRAP
0.00% covered (danger)
0.00%
0 / 1
Manager
62.24% covered (warning)
62.24%
122 / 196
48.89% covered (danger)
48.89%
22 / 45
614.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getAuth
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 makeAuth
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 supportsCreation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsRecovery
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 supportsEmailChange
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 supportsPasswordChange
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 supportsConnectingLibraryCard
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 supportsPersistentLogin
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getPersistentLoginLifetime
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUsernamePolicy
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getPasswordPolicy
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionInitiator
16.67% covered (danger)
16.67%
1 / 6
0.00% covered (danger)
0.00%
0 / 1
8.21
 getAuthClassForTemplateRendering
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getSelectableAuthOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getLoginTargets
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getDefaultLoginTarget
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getAuthMethod
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSelectedAuthMethod
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 loginEnabled
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
6.32
 ajaxEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dropdownEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 logout
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 userHasLoggedOut
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLoggedIn
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUserObject
50.00% covered (danger)
50.00%
7 / 14
0.00% covered (danger)
0.00%
0 / 1
13.12
 getCsrfHash
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIdentity
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkForExpiredCredentials
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 inPrivacyMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 updateSession
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 create
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 updatePassword
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateEmail
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 updateUserVerifyHash
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 login
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
12.67
 deleteToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 deleteUserLoginTokens
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setAuthMethod
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 validateCredentials
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getILSLoginMethod
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 connectLibraryCard
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 updateUser
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 allowsUserIlsLogin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processPolicyConfig
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3/**
4 * Wrapper class for handling logged-in user in session.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2007.
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   Demian Katz <demian.katz@villanova.edu>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
27 * @link     https://vufind.org Main Page
28 */
29
30namespace VuFind\Auth;
31
32use Laminas\Config\Config;
33use Laminas\Session\SessionManager;
34use LmcRbacMvc\Identity\IdentityInterface;
35use VuFind\Cookie\CookieManager;
36use VuFind\Db\Entity\UserEntityInterface;
37use VuFind\Db\Service\UserServiceInterface;
38use VuFind\Exception\Auth as AuthException;
39use VuFind\ILS\Connection;
40use VuFind\Validator\CsrfInterface;
41
42use function in_array;
43use function is_callable;
44
45/**
46 * Wrapper class for handling logged-in user in session.
47 *
48 * @category VuFind
49 * @package  Authentication
50 * @author   Demian Katz <demian.katz@villanova.edu>
51 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
52 * @link     https://vufind.org Main Page
53 */
54class Manager implements
55    \LmcRbacMvc\Identity\IdentityProviderInterface,
56    \Laminas\Log\LoggerAwareInterface
57{
58    use \VuFind\Log\LoggerAwareTrait;
59
60    /**
61     * Authentication modules
62     *
63     * @var \VuFind\Auth\AbstractBase[]
64     */
65    protected $auth = [];
66
67    /**
68     * Currently selected authentication module
69     *
70     * @var string
71     */
72    protected $activeAuth;
73
74    /**
75     * List of values allowed to be set into $activeAuth
76     *
77     * @var array
78     */
79    protected $legalAuthOptions;
80
81    /**
82     * Cache for current logged in user object
83     *
84     * @var ?UserEntityInterface
85     */
86    protected $currentUser = null;
87
88    /**
89     * Cache for hideLogin setting
90     *
91     * @var ?bool
92     */
93    protected $hideLogin = null;
94
95    /**
96     * Constructor
97     *
98     * @param Config                          $config            VuFind configuration
99     * @param UserServiceInterface            $userService       User database service
100     * @param UserSessionPersistenceInterface $userSession       User session persistence service
101     * @param SessionManager                  $sessionManager    Session manager
102     * @param PluginManager                   $pluginManager     Authentication plugin manager
103     * @param CookieManager                   $cookieManager     Cookie manager
104     * @param CsrfInterface                   $csrf              CSRF validator
105     * @param LoginTokenManager               $loginTokenManager Login Token manager
106     * @param Connection                      $ils               ILS connection
107     */
108    public function __construct(
109        protected Config $config,
110        protected UserServiceInterface $userService,
111        protected UserSessionPersistenceInterface $userSession,
112        protected SessionManager $sessionManager,
113        protected PluginManager $pluginManager,
114        protected CookieManager $cookieManager,
115        protected CsrfInterface $csrf,
116        protected LoginTokenManager $loginTokenManager,
117        protected Connection $ils
118    ) {
119        // Initialize active authentication setting (defaulting to Database
120        // if no setting passed in):
121        $method = $config->Authentication->method ?? 'Database';
122        $this->legalAuthOptions = [$method];   // mark it as legal
123        $this->setAuthMethod($method);         // load it
124    }
125
126    /**
127     * Get the authentication handler.
128     *
129     * @param string $name Auth module to load (null for currently active one)
130     *
131     * @return AbstractBase
132     */
133    protected function getAuth($name = null)
134    {
135        $name = empty($name) ? $this->activeAuth : $name;
136        if (!isset($this->auth[$name])) {
137            $this->auth[$name] = $this->makeAuth($name);
138        }
139        return $this->auth[$name];
140    }
141
142    /**
143     * Helper
144     *
145     * @param string $method auth method to instantiate
146     *
147     * @return AbstractBase
148     */
149    protected function makeAuth($method)
150    {
151        $legalAuthList = array_map('strtolower', $this->legalAuthOptions);
152        // If an illegal option was passed in, don't allow the object to load:
153        if (!in_array(strtolower($method), $legalAuthList)) {
154            throw new \Exception("Illegal authentication method: $method");
155        }
156        $auth = $this->pluginManager->get($method);
157        $auth->setConfig($this->config);
158        return $auth;
159    }
160
161    /**
162     * Does the current configuration support account creation?
163     *
164     * @param string $authMethod optional; check this auth method rather than
165     *  the one in config file
166     *
167     * @return bool
168     */
169    public function supportsCreation($authMethod = null)
170    {
171        return $this->getAuth($authMethod)->supportsCreation();
172    }
173
174    /**
175     * Does the current configuration support password recovery?
176     *
177     * @param string $authMethod optional; check this auth method rather than
178     *  the one in config file
179     *
180     * @return bool
181     */
182    public function supportsRecovery($authMethod = null)
183    {
184        return ($this->config->Authentication->recover_password ?? false)
185            && $this->getAuth($authMethod)->supportsPasswordRecovery();
186    }
187
188    /**
189     * Is email changing currently allowed?
190     *
191     * @param string $authMethod optional; check this auth method rather than
192     * the one in config file
193     *
194     * @return bool
195     */
196    public function supportsEmailChange($authMethod = null)
197    {
198        return $this->config->Authentication->change_email ?? false;
199    }
200
201    /**
202     * Is new passwords currently allowed?
203     *
204     * @param string $authMethod optional; check this auth method rather than
205     * the one in config file
206     *
207     * @return bool
208     */
209    public function supportsPasswordChange($authMethod = null)
210    {
211        return ($this->config->Authentication->change_password ?? false)
212            && $this->getAuth($authMethod)->supportsPasswordChange();
213    }
214
215    /**
216     * Is connecting library card allowed and supported?
217     *
218     * @param string $authMethod optional; check this auth method rather than
219     * the one in config file
220     *
221     * @return bool
222     */
223    public function supportsConnectingLibraryCard($authMethod = null)
224    {
225        return ($this->config->Catalog->auth_based_library_cards ?? false)
226            && $this->getAuth($authMethod)->supportsConnectingLibraryCard();
227    }
228
229    /**
230     * Is persistent login supported by the authentication method?
231     *
232     * @param string $method Authentication method (overrides currently selected method)
233     *
234     * @return bool
235     */
236    public function supportsPersistentLogin(?string $method = null): bool
237    {
238        if (!empty($this->config->Authentication->persistent_login)) {
239            return in_array(
240                strtolower($method ?? $this->getSelectedAuthMethod()),
241                explode(',', strtolower($this->config->Authentication->persistent_login))
242            );
243        }
244        return false;
245    }
246
247    /**
248     * Get persistent login lifetime in days
249     *
250     * @return int
251     */
252    public function getPersistentLoginLifetime()
253    {
254        return $this->config->Authentication->persistent_login_lifetime ?? 14;
255    }
256
257    /**
258     * Username policy for a new account (e.g. minLength, maxLength)
259     *
260     * @param string $authMethod optional; check this auth method rather than
261     * the one in config file
262     *
263     * @return array
264     */
265    public function getUsernamePolicy($authMethod = null)
266    {
267        return $this->processPolicyConfig(
268            $this->getAuth($authMethod)->getUsernamePolicy()
269        );
270    }
271
272    /**
273     * Password policy for a new password (e.g. minLength, maxLength)
274     *
275     * @param string $authMethod optional; check this auth method rather than
276     * the one in config file
277     *
278     * @return array
279     */
280    public function getPasswordPolicy($authMethod = null)
281    {
282        return $this->processPolicyConfig(
283            $this->getAuth($authMethod)->getPasswordPolicy()
284        );
285    }
286
287    /**
288     * Get the URL to establish a session (needed when the internal VuFind login
289     * form is inadequate). Returns false when no session initiator is needed.
290     *
291     * @param string $target Full URL where external authentication method should
292     * send user after login (some drivers may override this).
293     *
294     * @return bool|string
295     */
296    public function getSessionInitiator($target)
297    {
298        try {
299            return $this->getAuth()->getSessionInitiator($target);
300        } catch (InvalidArgumentException $e) {
301            // If the authentication is in an illegal state but there is an
302            // active user session, we should clear everything out so the user
303            // can try again. This is useful, for example, if a user is logged
304            // in at the same time that an administrator changes the [ChoiceAuth]
305            // settings in config.ini. However, if the user is not logged in,
306            // they are probably attempting something nasty and should be given
307            // an error message.
308            if (!$this->getIdentity()) {
309                throw $e;
310            }
311            $this->logout('');
312            return $this->getAuth()->getSessionInitiator($target);
313        }
314    }
315
316    /**
317     * In VuFind, views are tied to the name of the active authentication class.
318     * This method returns that name so that an appropriate template can be
319     * selected. It supports authentication methods that proxy other authentication
320     * methods (see ChoiceAuth for an example).
321     *
322     * @return string
323     */
324    public function getAuthClassForTemplateRendering()
325    {
326        $auth = $this->getAuth();
327        if (is_callable([$auth, 'getSelectedAuthOption'])) {
328            $selected = $auth->getSelectedAuthOption();
329            if ($selected) {
330                $auth = $this->getAuth($selected);
331            }
332        }
333        return $auth::class;
334    }
335
336    /**
337     * Return an array of all of the authentication options supported by the
338     * current auth class. In most cases (except for ChoiceAuth), this will
339     * just contain a single value.
340     *
341     * @return array
342     */
343    public function getSelectableAuthOptions()
344    {
345        $auth = $this->getAuth();
346        if (is_callable([$auth, 'getSelectableAuthOptions'])) {
347            if ($methods = $auth->getSelectableAuthOptions()) {
348                return $methods;
349            }
350        }
351        return [$this->getAuthMethod()];
352    }
353
354    /**
355     * Does the current auth class allow for authentication from more than
356     * one target? (e.g. MultiILS)
357     * If so return an array that lists the targets.
358     *
359     * @return array
360     */
361    public function getLoginTargets()
362    {
363        $auth = $this->getAuth();
364        return is_callable([$auth, 'getLoginTargets'])
365            ? $auth->getLoginTargets() : [];
366    }
367
368    /**
369     * Does the current auth class allow for authentication from more than
370     * one target? (e.g. MultiILS)
371     * If so return the default target.
372     *
373     * @return string
374     */
375    public function getDefaultLoginTarget()
376    {
377        $auth = $this->getAuth();
378        return is_callable([$auth, 'getDefaultLoginTarget'])
379            ? $auth->getDefaultLoginTarget() : null;
380    }
381
382    /**
383     * Get the name of the current authentication method.
384     *
385     * @return string
386     */
387    public function getAuthMethod()
388    {
389        return $this->activeAuth;
390    }
391
392    /**
393     * Get the name of the currently selected authentication method (if applicable)
394     * or the active authentication method.
395     *
396     * @return string
397     */
398    public function getSelectedAuthMethod()
399    {
400        $auth = $this->getAuth();
401        return is_callable([$auth, 'getSelectedAuthOption'])
402            ? $auth->getSelectedAuthOption()
403            : $this->getAuthMethod();
404    }
405
406    /**
407     * Is login currently allowed?
408     *
409     * @return bool
410     */
411    public function loginEnabled()
412    {
413        if (null === $this->hideLogin) {
414            // Assume login is enabled unless explicitly turned off:
415            $this->hideLogin = ($this->config->Authentication->hideLogin ?? false);
416
417            if (!$this->hideLogin) {
418                try {
419                    // Check if the catalog wants to hide the login link, and override
420                    // the configuration if necessary.
421                    if ($this->ils->loginIsHidden()) {
422                        $this->hideLogin = true;
423                    }
424                } catch (\Exception $e) {
425                    // Ignore exceptions; if the catalog is broken, throwing an exception
426                    // here may interfere with UI rendering. If we ignore it now, it will
427                    // still get handled appropriately later in processing.
428                    $this->logError('Could not check loginIsHidden:' . (string)$e);
429                }
430            }
431        }
432        return !$this->hideLogin;
433    }
434
435    /**
436     * Is login currently allowed?
437     *
438     * @return bool
439     */
440    public function ajaxEnabled()
441    {
442        // Assume ajax is enabled unless explicitly turned off:
443        return $this->config->Authentication->enableAjax ?? true;
444    }
445
446    /**
447     * Is login currently allowed?
448     *
449     * @return bool
450     */
451    public function dropdownEnabled()
452    {
453        // Assume dropdown is disabled unless explicitly turned on:
454        return $this->config->Authentication->enableDropdown ?? false;
455    }
456
457    /**
458     * Log out the current user.
459     *
460     * @param string $url     URL to redirect user to after logging out.
461     * @param bool   $destroy Should we destroy the session (true) or just reset it
462     * (false); destroy is for log out, reset is for expiration.
463     *
464     * @return string     Redirect URL (usually same as $url, but modified in
465     * some authentication modules).
466     */
467    public function logout($url, $destroy = true)
468    {
469        // Perform authentication-specific cleanup and modify redirect URL if
470        // necessary.
471        $url = $this->getAuth()->logout($url);
472
473        // Reset authentication state
474        $this->getAuth()->resetState();
475
476        // Clear out the cached user object and session entry.
477        $this->currentUser = null;
478        $this->userSession->clearUserFromSession();
479        $this->cookieManager->set('loggedOut', 1);
480        $this->loginTokenManager->deleteActiveToken();
481
482        // Destroy the session for good measure, if requested.
483        if ($destroy) {
484            $this->sessionManager->destroy();
485        } else {
486            // If we don't want to destroy the session, we still need to empty it.
487            // There should be a way to do this through Laminas\Session, but there
488            // apparently isn't (TODO -- do this better):
489            $_SESSION = [];
490        }
491
492        return $url;
493    }
494
495    /**
496     * Checks whether the user has recently logged out.
497     *
498     * @return bool
499     */
500    public function userHasLoggedOut()
501    {
502        return (bool)$this->cookieManager->get('loggedOut');
503    }
504
505    /**
506     * Checks whether the user is logged in.
507     *
508     * @return UserEntityInterface|false Object if user is logged in, false otherwise.
509     *
510     * @deprecated Use getIdentity() or getUserObject() instead.
511     */
512    public function isLoggedIn()
513    {
514        return $this->getUserObject() ?? false;
515    }
516
517    /**
518     * Checks whether the user is logged in.
519     *
520     * @return ?UserEntityInterface Object if user is logged in, null otherwise.
521     */
522    public function getUserObject(): ?UserEntityInterface
523    {
524        // If user object is not in cache, but user ID is in session,
525        // load the object from the database:
526        if (!$this->currentUser) {
527            if ($this->userSession->hasUserSessionData()) {
528                $this->currentUser = $this->userSession->getUserFromSession();
529                // End the session if the logged-in user cannot be found:
530                if (null === $this->currentUser) {
531                    $this->logout('');
532                }
533            } elseif ($user = $this->loginTokenManager->tokenLogin($this->sessionManager->getId())) {
534                if ($this->getAuth() instanceof ChoiceAuth) {
535                    $this->getAuth()->setStrategy($user->getAuthMethod());
536                }
537                if ($this->supportsPersistentLogin()) {
538                    $this->updateUser($user, null);
539                    $this->updateSession($user);
540                } else {
541                    $this->currentUser = null;
542                }
543            } else {
544                // not logged in
545                $this->currentUser = null;
546            }
547        }
548        return $this->currentUser;
549    }
550
551    /**
552     * Retrieve CSRF token
553     *
554     * If no CSRF token currently exists, or should be regenerated, generates one.
555     *
556     * @param bool $regenerate Should we regenerate token? (default false)
557     * @param int  $maxTokens  The maximum number of tokens to store in the
558     * session.
559     *
560     * @return string
561     */
562    public function getCsrfHash($regenerate = false, $maxTokens = 5)
563    {
564        // Reset token store if we've overflowed the limit:
565        $this->csrf->trimTokenList($maxTokens);
566        return $this->csrf->getHash($regenerate);
567    }
568
569    /**
570     * Get the logged-in user's identity (null if not logged in)
571     *
572     * @return ?IdentityInterface
573     */
574    public function getIdentity()
575    {
576        return $this->getUserObject();
577    }
578
579    /**
580     * Resets the session if the logged in user's credentials have expired.
581     *
582     * @return bool True if session has expired.
583     */
584    public function checkForExpiredCredentials()
585    {
586        if ($this->getIdentity() && $this->getAuth()->isExpired()) {
587            $this->logout(null, false);
588            return true;
589        }
590        return false;
591    }
592
593    /**
594     * Are we in privacy mode?
595     *
596     * @return bool
597     */
598    public function inPrivacyMode()
599    {
600        return $this->config->Authentication->privacy ?? false;
601    }
602
603    /**
604     * Updates the user information in the session.
605     *
606     * @param UserEntityInterface $user User object to store in the session
607     *
608     * @return void
609     */
610    public function updateSession($user)
611    {
612        $this->currentUser = $user;
613        if ($this->inPrivacyMode()) {
614            $this->userSession->addUserDataToSession($user);
615        } else {
616            $this->userSession->addUserIdToSession($user->getId());
617        }
618        $this->cookieManager->clear('loggedOut');
619    }
620
621    /**
622     * Create a new user account from the request.
623     *
624     * @param \Laminas\Http\PhpEnvironment\Request $request Request object containing
625     * new account details.
626     *
627     * @throws AuthException
628     * @return UserEntityInterface New user entity.
629     */
630    public function create($request)
631    {
632        $user = $this->getAuth()->create($request);
633        $this->updateUser($user, $this->getSelectedAuthMethod());
634        $this->updateSession($user);
635        return $user;
636    }
637
638    /**
639     * Update a user's password from the request.
640     *
641     * @param \Laminas\Http\PhpEnvironment\Request $request Request object containing
642     * password change details.
643     *
644     * @throws AuthException
645     * @return UserEntityInterface Updated user entity.
646     */
647    public function updatePassword($request)
648    {
649        $user = $this->getAuth()->updatePassword($request);
650        $this->updateSession($user);
651        return $user;
652    }
653
654    /**
655     * Update a user's email from the request.
656     *
657     * @param UserEntityInterface $user  Object representing user being updated.
658     * @param string              $email New email address to set (must be pre-validated!).
659     *
660     * @throws AuthException
661     * @return void
662     *
663     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
664     */
665    public function updateEmail(UserEntityInterface $user, $email)
666    {
667        // Depending on verification setting, either do a direct update or else
668        // put the new address into a pending state.
669        if ($this->config->Authentication->verify_email ?? false) {
670            // If new email address is the current address, just reset any pending
671            // email address:
672            $user->setPendingEmail($email === $user->getEmail() ? '' : $email);
673        } else {
674            $this->userService->updateUserEmail($user, $email, true);
675            $user->setPendingEmail('');
676        }
677        $this->userService->persistEntity($user);
678        $this->updateSession($user);
679    }
680
681    /**
682     * Update the verification hash for the provided user.
683     *
684     * @param UserEntityInterface $user User to update
685     *
686     * @return void
687     */
688    public function updateUserVerifyHash(UserEntityInterface $user): void
689    {
690        $hash = md5($user->getUsername() . $user->getRawCatPassword() . $user->getPasswordHash() . rand());
691        // Make totally sure the timestamp is exactly 10 characters:
692        $time = str_pad(substr((string)time(), 0, 10), 10, '0', STR_PAD_LEFT);
693        $user->setVerifyHash($hash . $time);
694        $this->userService->persistEntity($user);
695    }
696
697    /**
698     * Try to log in the user using current query parameters; return User object
699     * on success, throws exception on failure.
700     *
701     * @param \Laminas\Http\PhpEnvironment\Request $request Request object containing
702     * account credentials.
703     *
704     * @throws AuthException
705     * @throws \VuFind\Exception\PasswordSecurity
706     * @throws \VuFind\Exception\AuthInProgress
707     * @return UserEntityInterface Object representing logged-in user.
708     */
709    public function login($request)
710    {
711        // Wrap everything in try-catch so that we can reset the state on failure:
712        try {
713            // Allow the auth module to inspect the request (used by ChoiceAuth,
714            // for example):
715            $this->getAuth()->preLoginCheck($request);
716
717            // Get the main auth method before switching to any delegate:
718            $mainAuthMethod = $this->getSelectedAuthMethod();
719
720            // Check if the current auth method wants to delegate the request to another
721            // method:
722            if ($delegate = $this->getAuth()->getDelegateAuthMethod($request)) {
723                $this->setAuthMethod($delegate, true);
724            }
725
726            // Validate CSRF for form-based authentication methods:
727            if (
728                !$this->getAuth()->getSessionInitiator('')
729                && $this->getAuth()->needsCsrfCheck($request)
730            ) {
731                if (!$this->csrf->isValid($request->getPost()->get('csrf'))) {
732                    $this->getAuth()->resetState();
733                    $this->logWarning('Invalid CSRF token passed to login');
734                    throw new AuthException('authentication_error_technical');
735                } else {
736                    // After successful token verification, clear list to shrink session:
737                    $this->csrf->trimTokenList(0);
738                }
739            }
740
741            // Perform authentication:
742            try {
743                $user = $this->getAuth()->authenticate($request);
744            } catch (AuthException $e) {
745                // Pass authentication exceptions through unmodified
746                throw $e;
747            } catch (\VuFind\Exception\PasswordSecurity $e) {
748                // Pass password security exceptions through unmodified
749                throw $e;
750            } catch (\Exception $e) {
751                // Catch other exceptions, log verbosely, and treat them as technical
752                // difficulties
753                $this->logError((string)$e);
754                throw new AuthException('authentication_error_technical', 0, $e);
755            }
756
757            // Update user object
758            $this->updateUser($user, $mainAuthMethod);
759
760            if ($request->getPost()->get('remember_me') && $this->supportsPersistentLogin($mainAuthMethod)) {
761                try {
762                    $this->loginTokenManager->createToken($user, $this->sessionManager->getId());
763                } catch (\Exception $e) {
764                    $this->logError((string)$e);
765                    throw new AuthException('authentication_error_technical', 0, $e);
766                }
767            }
768            // Store the user in the session and send it back to the caller:
769            $this->updateSession($user);
770            return $user;
771        } catch (\Exception $e) {
772            $this->getAuth()->resetState();
773            throw $e;
774        }
775    }
776
777    /**
778     * Delete a login token
779     *
780     * @param string $series Series to identify the token
781     *
782     * @return void
783     */
784    public function deleteToken(string $series)
785    {
786        $this->loginTokenManager->deleteTokenSeries($series);
787    }
788
789    /**
790     * Delete all login tokens for a user
791     *
792     * @param int $userId User identifier
793     *
794     * @return void
795     */
796    public function deleteUserLoginTokens(int $userId)
797    {
798        $this->loginTokenManager->deleteUserLoginTokens($userId);
799    }
800
801    /**
802     * Setter
803     *
804     * @param string $method     The auth class to proxy
805     * @param bool   $forceLegal Whether to force the new method legal
806     *
807     * @return void
808     */
809    public function setAuthMethod($method, $forceLegal = false)
810    {
811        // Change the setting:
812        $this->activeAuth = $method;
813
814        if ($forceLegal) {
815            if (!in_array($method, $this->legalAuthOptions)) {
816                $this->legalAuthOptions[] = $method;
817            }
818        }
819
820        // If this method supports switching to a different method and we haven't
821        // already initialized it, add those options to the legal list. If the object
822        // is already initialized, that means we've already gone through this step
823        // and can save ourselves the trouble.
824
825        // This code also has the side effect of validating $method, since if an
826        // invalid value was passed in, the call to getSelectableAuthOptions will
827        // throw an exception.
828        if (!isset($this->auth[$method])) {
829            $this->legalAuthOptions = array_unique(
830                array_merge(
831                    $this->legalAuthOptions,
832                    $this->getSelectableAuthOptions()
833                )
834            );
835        }
836    }
837
838    /**
839     * Validate the credentials in the provided request, but do not change the state
840     * of the current logged-in user. Return true for valid credentials, false
841     * otherwise.
842     *
843     * @param \Laminas\Http\PhpEnvironment\Request $request Request object containing
844     * account credentials.
845     *
846     * @throws AuthException
847     * @return bool
848     */
849    public function validateCredentials($request)
850    {
851        return $this->getAuth()->validateCredentials($request);
852    }
853
854    /**
855     * What login method does the ILS use (password, email, vufind)
856     *
857     * @param string $target Login target (MultiILS only)
858     *
859     * @return array|false
860     */
861    public function getILSLoginMethod($target = '')
862    {
863        $auth = $this->getAuth();
864        if (is_callable([$auth, 'getILSLoginMethod'])) {
865            return $auth->getILSLoginMethod($target);
866        }
867        return false;
868    }
869
870    /**
871     * Connect authenticated user as library card to his account.
872     *
873     * @param \Laminas\Http\PhpEnvironment\Request $request Request object
874     * containing account credentials.
875     * @param UserEntityInterface                  $user    Connect newly created
876     * library card to this user.
877     *
878     * @return void
879     * @throws \Exception
880     */
881    public function connectLibraryCard($request, $user)
882    {
883        $auth = $this->getAuth();
884        if (!$auth->supportsConnectingLibraryCard()) {
885            throw new \Exception('Connecting of library cards is not supported');
886        }
887        $auth->connectLibraryCard($request, $user);
888    }
889
890    /**
891     * Update common user attributes on login
892     *
893     * @param UserEntityInterface $user       User object
894     * @param ?string             $authMethod Authentication method to user
895     *
896     * @return void
897     */
898    protected function updateUser($user, $authMethod)
899    {
900        if ($authMethod) {
901            $user->setAuthMethod(strtolower($authMethod));
902        }
903        $user->setLastLogin(new \DateTime());
904        $this->userService->persistEntity($user);
905    }
906
907    /**
908     * Is the user allowed to log directly into the ILS?
909     *
910     * @return bool
911     */
912    public function allowsUserIlsLogin(): bool
913    {
914        return $this->config->Catalog->allowUserLogin ?? true;
915    }
916
917    /**
918     * Process a raw policy configuration
919     *
920     * @param array $policy Policy configuration
921     *
922     * @return array
923     */
924    protected function processPolicyConfig(array $policy): array
925    {
926        // Convert 'numeric' or 'alphanumeric' pattern to a regular expression:
927        switch ($policy['pattern'] ?? '') {
928            case 'numeric':
929                $policy['pattern'] = '\d+';
930                break;
931            case 'alphanumeric':
932                $policy['pattern'] = '[\da-zA-Z]+';
933        }
934
935        // Map settings to attributes for a text input field:
936        $inputMap = [
937            'minLength' => 'data-minlength',
938            'maxLength' => 'maxlength',
939            'pattern' => 'pattern',
940        ];
941        $policy['inputAttrs'] = [];
942        foreach ($inputMap as $from => $to) {
943            if (isset($policy[$from])) {
944                $policy['inputAttrs'][$to] = $policy[$from];
945            }
946        }
947        return $policy;
948    }
949}