Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.21% covered (warning)
63.21%
67 / 106
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ILS
63.21% covered (warning)
63.21%
67 / 106
57.14% covered (warning)
57.14%
8 / 14
124.72
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
 getCatalog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCatalog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authenticate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 supportsPasswordChange
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getPasswordPolicy
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
6.74
 updatePassword
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 getILSLoginMethod
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getDelegateAuthMethod
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 handleLogin
26.92% covered (danger)
26.92%
7 / 26
0.00% covered (danger)
0.00%
0 / 1
58.22
 processILSUser
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
6.49
 validatePasswordUpdate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getLoggedInPatron
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getUsernameField
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * ILS authentication module.
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/wiki/development:plugins:authentication_handlers Wiki
29 */
30
31namespace VuFind\Auth;
32
33use Laminas\Http\PhpEnvironment\Request;
34use VuFind\Db\Entity\UserEntityInterface;
35use VuFind\Db\Service\UserServiceInterface;
36use VuFind\Exception\Auth as AuthException;
37use VuFind\Exception\ILS as ILSException;
38
39use function get_class;
40
41/**
42 * ILS authentication module.
43 *
44 * @category VuFind
45 * @package  Authentication
46 * @author   Franck Borel <franck.borel@gbv.de>
47 * @author   Demian Katz <demian.katz@villanova.edu>
48 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
49 * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
50 */
51class ILS extends AbstractBase
52{
53    /**
54     * Catalog connection
55     *
56     * @var \VuFind\ILS\Connection
57     */
58    protected $catalog = null;
59
60    /**
61     * Constructor
62     *
63     * @param \VuFind\ILS\Connection        $connection         ILS connection to set
64     * @param \VuFind\Auth\ILSAuthenticator $authenticator      ILS authenticator
65     * @param ?EmailAuthenticator           $emailAuthenticator Email authenticator
66     */
67    public function __construct(
68        \VuFind\ILS\Connection $connection,
69        protected \VuFind\Auth\ILSAuthenticator $authenticator,
70        protected ?EmailAuthenticator $emailAuthenticator = null
71    ) {
72        $this->setCatalog($connection);
73    }
74
75    /**
76     * Get the ILS driver associated with this object (or load the default from
77     * the service manager.
78     *
79     * @return \VuFind\ILS\Connection
80     */
81    public function getCatalog()
82    {
83        return $this->catalog;
84    }
85
86    /**
87     * Set the ILS connection for this object.
88     *
89     * @param \VuFind\ILS\Connection $connection ILS connection to set
90     *
91     * @return void
92     */
93    public function setCatalog(\VuFind\ILS\Connection $connection)
94    {
95        $this->catalog = $connection;
96    }
97
98    /**
99     * Attempt to authenticate the current user. Throws exception if login fails.
100     *
101     * @param Request $request Request object containing account credentials.
102     *
103     * @throws AuthException
104     * @return UserEntityInterface Object representing logged-in user.
105     */
106    public function authenticate($request)
107    {
108        $username = trim($request->getPost()->get('username', ''));
109        $password = trim($request->getPost()->get('password', ''));
110        $loginMethod = $this->getILSLoginMethod();
111        $rememberMe = (bool)$request->getPost()->get('remember_me', false);
112
113        return $this->handleLogin($username, $password, $loginMethod, $rememberMe);
114    }
115
116    /**
117     * Does this authentication method support password changing
118     *
119     * @return bool
120     */
121    public function supportsPasswordChange()
122    {
123        try {
124            return false !== $this->getCatalog()->checkFunction(
125                'changePassword',
126                ['patron' => $this->authenticator->getStoredCatalogCredentials()]
127            );
128        } catch (ILSException $e) {
129            return false;
130        }
131    }
132
133    /**
134     * Password policy for a new password (e.g. minLength, maxLength)
135     *
136     * @return array
137     */
138    public function getPasswordPolicy()
139    {
140        $policy = $this->getCatalog()->getPasswordPolicy($this->getLoggedInPatron());
141        if ($policy === false) {
142            return parent::getPasswordPolicy();
143        }
144        if (isset($policy['pattern']) && empty($policy['hint'])) {
145            $policy['hint'] = $this->getCannedPolicyHint(
146                'password',
147                $policy['pattern']
148            );
149        }
150        return $policy;
151    }
152
153    /**
154     * Update a user's password from the request.
155     *
156     * @param Request $request Request object containing new account details.
157     *
158     * @throws AuthException
159     * @return UserEntityInterface Updated user entity.
160     */
161    public function updatePassword($request)
162    {
163        // Ensure that all expected parameters are populated to avoid notices
164        // in the code below.
165        $params = [];
166        foreach (['oldpwd', 'password', 'password2'] as $param) {
167            $params[$param] = $request->getPost()->get($param, '');
168        }
169
170        // Connect to catalog:
171        if (!($patron = $this->authenticator->storedCatalogLogin())) {
172            throw new AuthException('authentication_error_technical');
173        }
174
175        // Validate Input
176        $this->validatePasswordUpdate($params);
177
178        $result = $this->getCatalog()->changePassword(
179            [
180                'patron' => $patron,
181                'oldPassword' => $params['oldpwd'],
182                'newPassword' => $params['password'],
183            ]
184        );
185        if (!$result['success']) {
186            throw new AuthException($result['status']);
187        }
188
189        // Update the user and send it back to the caller:
190        $username = $patron[$this->getUsernameField()];
191        $user = $this->getOrCreateUserByUsername($username);
192        $this->authenticator->saveUserCatalogCredentials($user, $patron['cat_username'], $params['password']);
193        return $user;
194    }
195
196    /**
197     * What login method does the ILS use (password, email, vufind)
198     *
199     * @param string $target Login target (MultiILS only)
200     *
201     * @return string
202     */
203    public function getILSLoginMethod($target = '')
204    {
205        $config = $this->getCatalog()->checkFunction(
206            'patronLogin',
207            ['patron' => ['cat_username' => "$target.login"]]
208        );
209        return $config['loginMethod'] ?? 'password';
210    }
211
212    /**
213     * Returns any authentication method this request should be delegated to.
214     *
215     * @param Request $request Request object.
216     *
217     * @return string|bool
218     */
219    public function getDelegateAuthMethod(Request $request)
220    {
221        return (null !== $this->emailAuthenticator
222            && $this->emailAuthenticator->isValidLoginRequest($request))
223                ? 'Email' : false;
224    }
225
226    /**
227     * Handle the actual login with the ILS.
228     *
229     * @param string $username    User name
230     * @param string $password    Password
231     * @param string $loginMethod Login method
232     * @param bool   $rememberMe  Whether to remember the login
233     *
234     * @throws AuthException
235     * @return UserEntityInterface Processed User object.
236     */
237    protected function handleLogin($username, $password, $loginMethod, $rememberMe)
238    {
239        if ($username == '' || ('password' === $loginMethod && $password == '')) {
240            throw new AuthException('authentication_error_blank');
241        }
242
243        // Connect to catalog:
244        try {
245            $patron = $this->getCatalog()->patronLogin($username, $password);
246        } catch (AuthException $e) {
247            // Pass Auth exceptions through
248            throw $e;
249        } catch (\Exception $e) {
250            throw new AuthException('authentication_error_technical');
251        }
252
253        // Did the patron successfully log in?
254        if ('email' === $loginMethod) {
255            if (null === $this->emailAuthenticator) {
256                throw new \Exception('Email authenticator not set');
257            }
258            if ($patron) {
259                $class = get_class($this);
260                if ($p = strrpos($class, '\\')) {
261                    $class = substr($class, $p + 1);
262                }
263                $this->emailAuthenticator->sendAuthenticationLink(
264                    $patron['email'],
265                    [
266                        'userData' => $patron,
267                        'rememberMe' => $rememberMe,
268                    ],
269                    ['auth_method' => $class]
270                );
271            }
272            // Don't reveal the result
273            throw new \VuFind\Exception\AuthInProgress('email_login_link_sent');
274        }
275        if ($patron) {
276            return $this->processILSUser($patron);
277        }
278
279        // If we got this far, we have a problem:
280        throw new AuthException('authentication_error_invalid');
281    }
282
283    /**
284     * Update the database using details from the ILS, then return the User object.
285     *
286     * @param array $info User details returned by ILS driver.
287     *
288     * @throws AuthException
289     * @return UserEntityInterface Processed User object.
290     */
291    protected function processILSUser($info)
292    {
293        // Figure out which field of the response to use as an identifier; fail
294        // if the expected field is missing or empty:
295        $usernameField = $this->getUsernameField();
296        if (!isset($info[$usernameField]) || empty($info[$usernameField])) {
297            throw new AuthException('authentication_error_technical');
298        }
299
300        // Check to see if we already have an account for this user:
301        $userService = $this->getUserService();
302        if (!empty($info['id'])) {
303            $user = $userService->getUserByCatId($info['id']);
304            if (empty($user)) {
305                $user = $this->getOrCreateUserByUsername($info[$usernameField]);
306                $user->setCatId($info['id']);
307                $this->getDbService(UserServiceInterface::class)->persistEntity($user);
308            }
309        } else {
310            $user = $this->getOrCreateUserByUsername($info[$usernameField]);
311        }
312
313        // No need to store the ILS password in VuFind's main password field:
314        $user->setRawPassword('');
315
316        // Update user information based on ILS data:
317        $fields = ['firstname', 'lastname', 'major', 'college'];
318        foreach ($fields as $field) {
319            $this->setUserValueByField($user, $field, $info[$field] ?? ' ');
320        }
321        $userService->updateUserEmail($user, $info['email'] ?? '');
322
323        // Update the user in the database, then return it to the caller:
324        $this->authenticator->saveUserCatalogCredentials(
325            $user,
326            $info['cat_username'] ?? ' ',
327            $info['cat_password'] ?? ' '
328        );
329
330        return $user;
331    }
332
333    /**
334     * Make sure passwords match and fulfill ILS policy
335     *
336     * @param array $params request parameters
337     *
338     * @return void
339     */
340    protected function validatePasswordUpdate($params)
341    {
342        // Needs a password
343        if (trim($params['password']) == '') {
344            throw new AuthException('Password cannot be blank');
345        }
346        // Passwords don't match
347        if ($params['password'] != $params['password2']) {
348            throw new AuthException('Passwords do not match');
349        }
350
351        $this->validatePasswordAgainstPolicy($params['password']);
352    }
353
354    /**
355     * Get the Currently Logged-In Patron
356     *
357     * @throws AuthException
358     *
359     * @return array|null Patron or null if no credentials exist
360     */
361    protected function getLoggedInPatron()
362    {
363        $patron = $this->authenticator->storedCatalogLogin();
364        return $patron ? $patron : null;
365    }
366
367    /**
368     * Gets the configured username field.
369     *
370     * @return string
371     */
372    protected function getUsernameField()
373    {
374        $config = $this->getConfig();
375        return $config->Authentication->ILS_username_field ?? 'cat_username';
376    }
377}