Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
50.49% covered (warning)
50.49%
52 / 103
56.25% covered (warning)
56.25%
9 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ILSAuthenticator
50.49% covered (warning)
50.49%
52 / 103
56.25% covered (warning)
56.25%
9 / 16
256.14
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 passwordEncryptionEnabled
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 decrypt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encrypt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encryptOrDecrypt
42.31% covered (danger)
42.31%
11 / 26
0.00% covered (danger)
0.00%
0 / 1
24.55
 getCatPasswordForUser
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
9.83
 setUserCatalogCredentials
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 saveUserCatalogCredentials
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateUserHomeLibrary
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getStoredCatalogCredentials
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 storedCatalogLogin
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 newCatalogLogin
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 sendEmailLoginLink
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 processEmailLoginHash
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 updateUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getAuthManager
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Class for managing ILS-specific authentication.
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\Crypt\BlockCipher;
34use Laminas\Crypt\Symmetric\Openssl;
35use VuFind\Db\Entity\UserEntityInterface;
36use VuFind\Db\Service\DbServiceAwareInterface;
37use VuFind\Db\Service\DbServiceAwareTrait;
38use VuFind\Db\Service\UserCardServiceInterface;
39use VuFind\Db\Service\UserServiceInterface;
40use VuFind\Exception\ILS as ILSException;
41use VuFind\ILS\Connection as ILSConnection;
42
43/**
44 * Class for managing ILS-specific authentication.
45 *
46 * @category VuFind
47 * @package  Authentication
48 * @author   Demian Katz <demian.katz@villanova.edu>
49 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
50 * @link     https://vufind.org Main Page
51 */
52class ILSAuthenticator implements DbServiceAwareInterface
53{
54    use DbServiceAwareTrait;
55
56    /**
57     * Callback for retrieving the authentication manager
58     *
59     * @var callable
60     */
61    protected $authManagerCallback;
62
63    /**
64     * Authentication manager
65     *
66     * @var Manager
67     */
68    protected $authManager = null;
69
70    /**
71     * Cache for ILS account information (keyed by username)
72     *
73     * @var array
74     */
75    protected $ilsAccount = [];
76
77    /**
78     * Is encryption enabled?
79     *
80     * @var bool
81     */
82    protected $encryptionEnabled = null;
83
84    /**
85     * Encryption key used for catalog passwords (null if encryption disabled):
86     *
87     * @var string
88     */
89    protected $encryptionKey = null;
90
91    /**
92     * Constructor
93     *
94     * @param callable            $authCB             Auth manager callback
95     * @param ILSConnection       $catalog            ILS connection
96     * @param ?EmailAuthenticator $emailAuthenticator Email authenticator
97     * @param ?Config             $config             Configuration from config.ini
98     */
99    public function __construct(
100        callable $authCB,
101        protected ILSConnection $catalog,
102        protected ?EmailAuthenticator $emailAuthenticator = null,
103        protected ?Config $config = null
104    ) {
105        $this->authManagerCallback = $authCB;
106    }
107
108    /**
109     * Is ILS password encryption enabled?
110     *
111     * @return bool
112     */
113    public function passwordEncryptionEnabled()
114    {
115        if (null === $this->encryptionEnabled) {
116            $this->encryptionEnabled
117                = $this->config->Authentication->encrypt_ils_password ?? false;
118        }
119        return $this->encryptionEnabled;
120    }
121
122    /**
123     * Decrypt text.
124     *
125     * @param ?string $text The text to decrypt (null values will be returned as null)
126     *
127     * @return ?string|bool The decrypted string (null if empty or false if invalid)
128     * @throws \VuFind\Exception\PasswordSecurity
129     */
130    public function decrypt(?string $text)
131    {
132        return $this->encryptOrDecrypt($text, false);
133    }
134
135    /**
136     * Encrypt text.
137     *
138     * @param ?string $text The text to encrypt (null values will be returned as null)
139     *
140     * @return ?string|bool The encrypted string (null if empty or false if invalid)
141     * @throws \VuFind\Exception\PasswordSecurity
142     */
143    public function encrypt(?string $text)
144    {
145        return $this->encryptOrDecrypt($text, true);
146    }
147
148    /**
149     * This is a central function for encrypting and decrypting so that
150     * logic is all in one location
151     *
152     * @param ?string $text    The text to be encrypted or decrypted
153     * @param bool    $encrypt True if we wish to encrypt text, False if we wish to
154     * decrypt text.
155     *
156     * @return ?string|bool    The encrypted/decrypted string (null = empty input; false = error)
157     * @throws \VuFind\Exception\PasswordSecurity
158     */
159    protected function encryptOrDecrypt(?string $text, bool $encrypt = true)
160    {
161        // Ignore empty text:
162        if ($text === null || $text === '') {
163            return null;
164        }
165
166        $configAuth = $this->config->Authentication ?? new \Laminas\Config\Config([]);
167
168        // Load encryption key from configuration if not already present:
169        if ($this->encryptionKey === null) {
170            if (empty($configAuth->ils_encryption_key)) {
171                throw new \VuFind\Exception\PasswordSecurity(
172                    'ILS password encryption on, but no key set.'
173                );
174            }
175
176            $this->encryptionKey = $configAuth->ils_encryption_key;
177        }
178
179        // Perform encryption:
180        $algo = $configAuth->ils_encryption_algo ?? 'blowfish';
181
182        // Check if OpenSSL error is caused by blowfish support
183        try {
184            $cipher = new BlockCipher(new Openssl(['algorithm' => $algo]));
185            if ($algo == 'blowfish') {
186                trigger_error(
187                    'Deprecated encryption algorithm (blowfish) detected',
188                    E_USER_DEPRECATED
189                );
190            }
191        } catch (\InvalidArgumentException $e) {
192            if ($algo == 'blowfish') {
193                throw new \VuFind\Exception\PasswordSecurity(
194                    'The blowfish encryption algorithm ' .
195                    'is not supported by your version of OpenSSL. ' .
196                    'Please visit /Upgrade/CriticalFixBlowfish for further details.'
197                );
198            } else {
199                throw $e;
200            }
201        }
202        $cipher->setKey($this->encryptionKey);
203        return $encrypt ? $cipher->encrypt($text) : $cipher->decrypt($text);
204    }
205
206    /**
207     * Given a user object, retrieve the decrypted password (or null if unset/invalid).
208     *
209     * @param UserEntityInterface $user User
210     *
211     * @return ?string
212     */
213    public function getCatPasswordForUser(UserEntityInterface $user)
214    {
215        if ($this->passwordEncryptionEnabled()) {
216            $encrypted = $user->getCatPassEnc();
217            $decrypted = !empty($encrypted) ? $this->decrypt($encrypted) : null;
218            if ($decrypted === false) {
219                // Unexpected error decrypting password; let's treat it as unset for now:
220                return null;
221            }
222            return $decrypted;
223        }
224        return $user->getRawCatPassword();
225    }
226
227    /**
228     * Set ILS login credentials for a user without saving them.
229     *
230     * @param UserEntityInterface $user     User to update
231     * @param string              $username Username to save
232     * @param ?string             $password Password to save (null for none)
233     *
234     * @return void
235     */
236    public function setUserCatalogCredentials(UserEntityInterface $user, string $username, ?string $password): void
237    {
238        $user->setCatUsername($username);
239        if ($this->passwordEncryptionEnabled()) {
240            $user->setRawCatPassword(null);
241            $user->setCatPassEnc($this->encrypt($password));
242        } else {
243            $user->setRawCatPassword($password);
244            $user->setCatPassEnc(null);
245        }
246    }
247
248    /**
249     * Save ILS login credentials.
250     *
251     * @param UserEntityInterface $user     User to update
252     * @param string              $username Username to save
253     * @param ?string             $password Password to save
254     *
255     * @return void
256     * @throws \VuFind\Exception\PasswordSecurity
257     */
258    public function saveUserCatalogCredentials(UserEntityInterface $user, string $username, ?string $password): void
259    {
260        $this->setUserCatalogCredentials($user, $username, $password);
261        $this->getDbService(UserServiceInterface::class)->persistEntity($user);
262
263        // Update library card entry after saving the user so that we always have a
264        // user id:
265        $this->getDbService(UserCardServiceInterface::class)->synchronizeUserLibraryCardData($user);
266    }
267
268    /**
269     * Change and persist the user's home library.
270     *
271     * @param UserEntityInterface $user        User to update
272     * @param ?string             $homeLibrary New home library value (or null to clear)
273     *
274     * @return void
275     */
276    public function updateUserHomeLibrary(UserEntityInterface $user, ?string $homeLibrary): void
277    {
278        // Update the home library and make sure library cards are kept in sync:
279        $user->setHomeLibrary($homeLibrary);
280        $this->getDbService(UserCardServiceInterface::class)->synchronizeUserLibraryCardData($user);
281        $this->getDbService(UserServiceInterface::class)->persistEntity($user);
282        $this->getAuthManager()->updateSession($user);
283    }
284
285    /**
286     * Get stored catalog credentials for the current user.
287     *
288     * Returns associative array of cat_username and cat_password if they are
289     * available, false otherwise. This method does not verify that the credentials
290     * are valid.
291     *
292     * @return array|bool
293     */
294    public function getStoredCatalogCredentials()
295    {
296        // Fail if no username is found, but allow a missing password (not every ILS
297        // requires a password to connect).
298        if (($user = $this->getAuthManager()->getUserObject()) && ($username = $user->getCatUsername())) {
299            return [
300                'cat_username' => $username,
301                'cat_password' => $this->getCatPasswordForUser($user),
302            ];
303        }
304        return false;
305    }
306
307    /**
308     * Log the current user into the catalog using stored credentials; if this
309     * fails, clear the user's stored credentials so they can enter new, corrected
310     * ones.
311     *
312     * Returns associative array of patron data on success, false on failure.
313     *
314     * @return array|bool
315     */
316    public function storedCatalogLogin()
317    {
318        // Fail if no username is found, but allow a missing password (not every ILS
319        // requires a password to connect).
320        if (($user = $this->getAuthManager()->getUserObject()) && ($username = $user->getCatUsername())) {
321            // Do we have a previously cached ILS account?
322            if (isset($this->ilsAccount[$username])) {
323                return $this->ilsAccount[$username];
324            }
325            $patron = $this->catalog->patronLogin(
326                $username,
327                $this->getCatPasswordForUser($user)
328            );
329            if (empty($patron)) {
330                // Problem logging in -- clear user credentials so they can be
331                // prompted again; perhaps their password has changed in the
332                // system!
333                $user->setCatUsername(null)->setRawCatPassword(null)->setCatPassEnc(null);
334            } else {
335                // cache for future use
336                $this->ilsAccount[$username] = $patron;
337                return $patron;
338            }
339        }
340
341        return false;
342    }
343
344    /**
345     * Attempt to log in the user to the ILS, and save credentials if it works.
346     *
347     * @param string $username Catalog username
348     * @param string $password Catalog password
349     *
350     * Returns associative array of patron data on success, false on failure.
351     *
352     * @return array|bool
353     * @throws ILSException
354     */
355    public function newCatalogLogin($username, $password)
356    {
357        $result = $this->catalog->patronLogin($username, $password);
358        if ($result) {
359            $this->updateUser($username, $password, $result);
360            return $result;
361        }
362        return false;
363    }
364
365    /**
366     * Send email authentication link
367     *
368     * @param string $email       Email address
369     * @param string $route       Route for the login link
370     * @param array  $routeParams Route parameters
371     * @param array  $urlParams   URL parameters
372     *
373     * @return void
374     */
375    public function sendEmailLoginLink($email, $route, $routeParams = [], $urlParams = [])
376    {
377        if (null === $this->emailAuthenticator) {
378            throw new \Exception('Email authenticator not set');
379        }
380
381        $userData = $this->catalog->patronLogin($email, '');
382        if ($userData) {
383            $this->emailAuthenticator->sendAuthenticationLink(
384                $userData['email'],
385                compact('userData'),
386                ['auth_method' => 'ILS'] + $urlParams,
387                $route,
388                $routeParams
389            );
390        }
391    }
392
393    /**
394     * Process email login
395     *
396     * @param string $hash Login hash
397     *
398     * @return array|bool
399     * @throws ILSException
400     */
401    public function processEmailLoginHash($hash)
402    {
403        if (null === $this->emailAuthenticator) {
404            throw new \Exception('Email authenticator not set');
405        }
406
407        try {
408            $loginData = $this->emailAuthenticator->authenticate($hash);
409            // Check if we have more granular data available:
410            $patron = $loginData['userData'] ?? $loginData;
411        } catch (\VuFind\Exception\Auth $e) {
412            return false;
413        }
414        $this->updateUser($patron['cat_username'], '', $patron);
415        return $patron;
416    }
417
418    /**
419     * Update current user account with the patron information
420     *
421     * @param string $catUsername Catalog username
422     * @param string $catPassword Catalog password
423     * @param array  $patron      Patron
424     *
425     * @return void
426     */
427    protected function updateUser($catUsername, $catPassword, $patron)
428    {
429        $user = $this->getAuthManager()->getUserObject();
430        if ($user) {
431            $this->saveUserCatalogCredentials($user, $catUsername, $catPassword);
432            $this->getAuthManager()->updateSession($user);
433            // cache for future use
434            $this->ilsAccount[$catUsername] = $patron;
435        }
436    }
437
438    /**
439     * Get authentication manager
440     *
441     * @return Manager
442     */
443    protected function getAuthManager(): Manager
444    {
445        if (null === $this->authManager) {
446            $this->authManager = ($this->authManagerCallback)();
447        }
448        return $this->authManager;
449    }
450}