Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
14.58% covered (danger)
14.58%
14 / 96
30.00% covered (danger)
30.00%
3 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
LDAP
14.58% covered (danger)
14.58%
14 / 96
30.00% covered (danger)
30.00%
3 / 10
1037.12
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
 validateConfig
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 getSetting
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 authenticate
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 checkLdap
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 connect
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 bindForSearch
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 findUsername
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 validateCredentialsInLdap
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 processLDAPUser
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
132
1<?php
2
3/**
4 * LDAP 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   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 VuFind\Db\Entity\UserEntityInterface;
34use VuFind\Exception\Auth as AuthException;
35
36use function in_array;
37
38/**
39 * LDAP authentication class
40 *
41 * @category VuFind
42 * @package  Authentication
43 * @author   Franck Borel <franck.borel@gbv.de>
44 * @author   Demian Katz <demian.katz@villanova.edu>
45 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
46 * @link     https://vufind.org/wiki/development:plugins:authentication_handlers Wiki
47 */
48class LDAP extends AbstractBase
49{
50    /**
51     * Constructor
52     *
53     * @param ILSAuthenticator $ilsAuthenticator ILS authenticator
54     */
55    public function __construct(protected ILSAuthenticator $ilsAuthenticator)
56    {
57    }
58
59    /**
60     * Validate configuration parameters. This is a support method for getConfig(),
61     * so the configuration MUST be accessed using $this->config; do not call
62     * $this->getConfig() from within this method!
63     *
64     * @throws AuthException
65     * @return void
66     */
67    protected function validateConfig()
68    {
69        // Check for missing parameters:
70        $requiredParams = ['host', 'port', 'basedn', 'username'];
71        foreach ($requiredParams as $param) {
72            if (
73                !isset($this->config->LDAP->$param)
74                || empty($this->config->LDAP->$param)
75            ) {
76                throw new AuthException(
77                    'One or more LDAP parameters are missing. Check your config.ini!'
78                );
79            }
80        }
81    }
82
83    /**
84     * Get the requested configuration setting (or blank string if unset).
85     *
86     * @param string $name Name of parameter to retrieve.
87     *
88     * @return string
89     */
90    protected function getSetting($name)
91    {
92        $config = $this->getConfig();
93        $value = $config->LDAP->$name ?? '';
94
95        // Normalize all values to lowercase except for potentially case-sensitive
96        // bind and basedn credentials.
97        $doNotLower = ['bind_username', 'bind_password', 'basedn'];
98        return (in_array($name, $doNotLower)) ? $value : strtolower($value);
99    }
100
101    /**
102     * Attempt to authenticate the current user. Throws exception if login fails.
103     *
104     * @param \Laminas\Http\PhpEnvironment\Request $request Request object containing
105     * account credentials.
106     *
107     * @throws AuthException
108     * @return UserEntityInterface Object representing logged-in user.
109     */
110    public function authenticate($request)
111    {
112        $username = trim($request->getPost()->get('username', ''));
113        $password = trim($request->getPost()->get('password', ''));
114        if ($username == '' || $password == '') {
115            throw new AuthException('authentication_error_blank');
116        }
117        return $this->checkLdap($username, $password);
118    }
119
120    /**
121     * Communicate with LDAP and obtain user details.
122     *
123     * @param string $username Username
124     * @param string $password Password
125     *
126     * @throws AuthException
127     * @return UserEntityInterface Object representing logged-in user.
128     */
129    protected function checkLdap($username, $password)
130    {
131        // Establish a connection:
132        $connection = $this->connect();
133
134        // If necessary, bind in order to perform a search:
135        $this->bindForSearch($connection);
136
137        // Search for username
138        $info = $this->findUsername($connection, $username);
139        if ($info['count']) {
140            $data = $this->validateCredentialsInLdap($connection, $info, $password);
141            if ($data) {
142                return $this->processLDAPUser($username, $data);
143            }
144        } else {
145            $this->debug('user not found');
146        }
147
148        throw new AuthException('authentication_error_invalid');
149    }
150
151    /**
152     * Establish the LDAP connection.
153     *
154     * @return resource
155     */
156    protected function connect()
157    {
158        // Try to connect to LDAP and die if we can't; note that some LDAP setups
159        // will successfully return a resource from ldap_connect even if the server
160        // is unavailable -- we need to check for bad return values again at search
161        // time!
162        $host = $this->getSetting('host');
163        $port = $this->getSetting('port');
164        $this->debug("connecting to host=$host, port=$port");
165        $connection = @ldap_connect($host, $port);
166        if (!$connection) {
167            $this->debug('connection failed');
168            throw new AuthException('authentication_error_technical');
169        }
170
171        // Set LDAP options -- use protocol version 3
172        if (!@ldap_set_option($connection, LDAP_OPT_PROTOCOL_VERSION, 3)) {
173            $this->debug('Failed to set protocol version 3');
174        }
175
176        // if the host parameter is not specified as ldaps://
177        // then (unless TLS is disabled) we need to initiate TLS so we
178        // can have a secure connection over the standard LDAP port.
179        $disableTls = isset($this->config->LDAP->disable_tls)
180            && $this->config->LDAP->disable_tls;
181        if (stripos($host, 'ldaps://') === false && !$disableTls) {
182            $this->debug('Starting TLS');
183            if (!@ldap_start_tls($connection)) {
184                $this->debug('TLS failed');
185                throw new AuthException('authentication_error_technical');
186            }
187        }
188
189        return $connection;
190    }
191
192    /**
193     * If configured, bind an administrative user in order to perform a search
194     *
195     * @param resource $connection LDAP connection
196     *
197     * @return void
198     */
199    protected function bindForSearch($connection)
200    {
201        // If bind_username and bind_password were supplied in the config file, use
202        // them to access LDAP before proceeding. In some LDAP setups, these
203        // settings can be excluded in order to skip this step.
204        $user = $this->getSetting('bind_username');
205        $pass = $this->getSetting('bind_password');
206        if ($user != '' && $pass != '') {
207            $this->debug("binding as $user");
208            $ldapBind = @ldap_bind($connection, $user, $pass);
209            if (!$ldapBind) {
210                $this->debug('bind failed -- ' . ldap_error($connection));
211                throw new AuthException('authentication_error_technical');
212            }
213        }
214    }
215
216    /**
217     * Find the specified username in the directory
218     *
219     * @param resource $connection LDAP connection
220     * @param string   $username   Username
221     *
222     * @return array
223     */
224    protected function findUsername($connection, $username)
225    {
226        $ldapFilter = $this->getSetting('username') . '=' . $username;
227        $basedn = $this->getSetting('basedn');
228        $this->debug("search for $ldapFilter using basedn=$basedn");
229        $ldapSearch = @ldap_search($connection, $basedn, $ldapFilter);
230        if (!$ldapSearch) {
231            $this->debug('search failed -- ' . ldap_error($connection));
232            throw new AuthException('authentication_error_technical');
233        }
234
235        return ldap_get_entries($connection, $ldapSearch);
236    }
237
238    /**
239     * Validate credentials
240     *
241     * @param resource $connection LDAP connection
242     * @param array    $info       Data from findUsername()
243     * @param string   $password   Password to try
244     *
245     * @return bool|array Array of user data on success, false otherwise
246     */
247    protected function validateCredentialsInLdap($connection, $info, $password)
248    {
249        // Validate the user credentials by attempting to bind to LDAP:
250        $dn = $info[0]['dn'];
251        $this->debug("binding as $dn");
252        $ldapBind = @ldap_bind($connection, $dn, $password);
253        if (!$ldapBind) {
254            $this->debug('bind failed -- ' . ldap_error($connection));
255            return false;
256        }
257        // If the bind was successful, we can look up the full user info:
258        $this->debug('bind successful; reading details');
259        $ldapSearch = ldap_read($connection, $dn, 'objectclass=*');
260        $data = ldap_get_entries($connection, $ldapSearch);
261        if ($data === false) {
262            $this->debug('Read failed -- ' . ldap_error($connection));
263            throw new AuthException('authentication_error_technical');
264        }
265        return $data;
266    }
267
268    /**
269     * Build a User object from details obtained via LDAP.
270     *
271     * @param string $username Username
272     * @param array  $data     Details from ldap_get_entries call.
273     *
274     * @return UserEntityInterface Object representing logged-in user.
275     */
276    protected function processLDAPUser($username, $data)
277    {
278        // Database fields that we may be able to load from LDAP:
279        $fields = [
280            'firstname', 'lastname', 'email', 'cat_username', 'cat_password',
281            'college', 'major',
282        ];
283
284        // User object to populate from LDAP:
285        $user = $this->getOrCreateUserByUsername($username);
286
287        // Variable to hold catalog password (handled separately from other
288        // attributes since we need to use setUserCatalogCredentials method to store it):
289        $catPassword = null;
290
291        // Loop through LDAP response and map fields to database object based
292        // on configuration settings:
293        for ($i = 0; $i < $data['count']; $i++) {
294            for ($j = 0; $j < $data[$i]['count']; $j++) {
295                foreach ($fields as $field) {
296                    $configValue = $this->getSetting($field);
297                    if ($data[$i][$j] == $configValue && !empty($configValue)) {
298                        $value = $data[$i][$configValue];
299                        $separator = $this->config->LDAP->separator;
300                        // if no separator is given map only the first value
301                        if (isset($separator)) {
302                            $tmp = [];
303                            for ($k = 0; $k < $value['count']; $k++) {
304                                $tmp[] = $value[$k];
305                            }
306                            $value = implode($separator, $tmp);
307                        } else {
308                            $value = $value[0];
309                        }
310
311                        if ($field != 'cat_password') {
312                            $this->setUserValueByField($user, $field, $value ?? '');
313                        } else {
314                            $catPassword = $value;
315                        }
316                    }
317                }
318            }
319        }
320
321        // Save credentials if applicable. Note that we want to allow empty
322        // passwords (see https://github.com/vufind-org/vufind/pull/532), but
323        // we also want to be careful not to replace a non-blank password with a
324        // blank one in case the auth mechanism fails to provide a password on
325        // an occasion after the user has manually stored one. (For discussion,
326        // see https://github.com/vufind-org/vufind/pull/612). Note that in the
327        // (unlikely) scenario that a password can actually change from non-blank
328        // to blank, additional work may need to be done here.
329        if (!empty($catUsername = $user->getCatUsername())) {
330            $this->ilsAuthenticator->setUserCatalogCredentials(
331                $user,
332                $catUsername,
333                empty($catPassword) ? $this->ilsAuthenticator->getCatPasswordForUser($user) : $catPassword
334            );
335        }
336
337        // Update the user in the database, then return it to the caller:
338        $this->getUserService()->persistEntity($user);
339        return $user;
340    }
341}