xref: /dokuwiki/lib/plugins/authldap/auth.php (revision 5a3e856355becfc2dd158af204080dc0fb58879b)
1<?php
2
3/**
4 * LDAP authentication backend
5 *
6 * @license   GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author    Andreas Gohr <andi@splitbrain.org>
8 * @author    Chris Smith <chris@jalakaic.co.uk>
9 * @author    Jan Schumann <js@schumann-it.com>
10 */
11class auth_plugin_authldap extends DokuWiki_Auth_Plugin
12{
13    /* @var resource $con holds the LDAP connection */
14    protected $con = null;
15
16    /* @var int $bound What type of connection does already exist? */
17    protected $bound = 0; // 0: anonymous, 1: user, 2: superuser
18
19    /* @var array $users User data cache */
20    protected $users = null;
21
22    /* @var array $pattern User filter pattern */
23    protected $pattern = null;
24
25    /**
26     * Constructor
27     */
28    public function __construct()
29    {
30        parent::__construct();
31
32        // ldap extension is needed
33        if (!function_exists('ldap_connect')) {
34            $this->debug("LDAP err: PHP LDAP extension not found.", -1, __LINE__, __FILE__);
35            $this->success = false;
36            return;
37        }
38
39        // Add the capabilities to change the password
40        $this->cando['modPass'] = $this->getConf('modPass');
41    }
42
43    /**
44     * Check user+password
45     *
46     * Checks if the given user exists and the given
47     * plaintext password is correct by trying to bind
48     * to the LDAP server
49     *
50     * @param string $user
51     * @param string $pass
52     * @return  bool
53     * @author  Andreas Gohr <andi@splitbrain.org>
54     */
55    public function checkPass($user, $pass)
56    {
57        // reject empty password
58        if (empty($pass)) return false;
59        if (!$this->openLDAP()) return false;
60
61        // indirect user bind
62        if ($this->getConf('binddn') && $this->getConf('bindpw')) {
63            // use superuser credentials
64            if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) {
65                $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
66                return false;
67            }
68            $this->bound = 2;
69        } elseif ($this->getConf('binddn') &&
70            $this->getConf('usertree') &&
71            $this->getConf('userfilter')
72        ) {
73            // special bind string
74            $dn = $this->makeFilter(
75                $this->getConf('binddn'),
76                array('user' => $user, 'server' => $this->getConf('server'))
77            );
78        } elseif (strpos($this->getConf('usertree'), '%{user}')) {
79            // direct user bind
80            $dn = $this->makeFilter(
81                $this->getConf('usertree'),
82                array('user' => $user, 'server' => $this->getConf('server'))
83            );
84        } else {
85            // Anonymous bind
86            if (!@ldap_bind($this->con)) {
87                msg("LDAP: can not bind anonymously", -1);
88                $this->debug('LDAP anonymous bind: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
89                return false;
90            }
91        }
92
93        // Try to bind to with the dn if we have one.
94        if (!empty($dn)) {
95            // User/Password bind
96            if (!@ldap_bind($this->con, $dn, $pass)) {
97                $this->debug("LDAP: bind with $dn failed", -1, __LINE__, __FILE__);
98                $this->debug('LDAP user dn bind: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
99                return false;
100            }
101            $this->bound = 1;
102            return true;
103        } else {
104            // See if we can find the user
105            $info = $this->fetchUserData($user, true);
106            if (empty($info['dn'])) {
107                return false;
108            } else {
109                $dn = $info['dn'];
110            }
111
112            // Try to bind with the dn provided
113            if (!@ldap_bind($this->con, $dn, $pass)) {
114                $this->debug("LDAP: bind with $dn failed", -1, __LINE__, __FILE__);
115                $this->debug('LDAP user bind: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
116                return false;
117            }
118            $this->bound = 1;
119            return true;
120        }
121    }
122
123    /**
124     * Return user info
125     *
126     * Returns info about the given user needs to contain
127     * at least these fields:
128     *
129     * name string  full name of the user
130     * mail string  email addres of the user
131     * grps array   list of groups the user is in
132     *
133     * This LDAP specific function returns the following
134     * addional fields:
135     *
136     * dn     string  distinguished name (DN)
137     * uid    string  Posix User ID
138     * inbind bool    for internal use - avoid loop in binding
139     *
140     * @param string $user
141     * @param bool $requireGroups (optional) - ignored, groups are always supplied by this plugin
142     * @return  array containing user data or false
143     * @author  <evaldas.auryla@pheur.org>
144     * @author  Stephane Chazelas <stephane.chazelas@emerson.com>
145     * @author  Steffen Schoch <schoch@dsb.net>
146     *
147     * @author  Andreas Gohr <andi@splitbrain.org>
148     * @author  Trouble
149     * @author  Dan Allen <dan.j.allen@gmail.com>
150     */
151    public function getUserData($user, $requireGroups = true)
152    {
153        return $this->fetchUserData($user);
154    }
155
156    /**
157     * @param string $user
158     * @param bool $inbind authldap specific, true if in bind phase
159     * @return  array containing user data or false
160     */
161    protected function fetchUserData($user, $inbind = false)
162    {
163        global $conf;
164        if (!$this->openLDAP()) return array();
165
166        // force superuser bind if wanted and not bound as superuser yet
167        if ($this->getConf('binddn') && $this->getConf('bindpw') && $this->bound < 2) {
168            // use superuser credentials
169            if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) {
170                $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
171                return array();
172            }
173            $this->bound = 2;
174        } elseif ($this->bound == 0 && !$inbind) {
175            // in some cases getUserData is called outside the authentication workflow
176            // eg. for sending email notification on subscribed pages. This data might not
177            // be accessible anonymously, so we try to rebind the current user here
178            list($loginuser, $loginsticky, $loginpass) = auth_getCookie();
179            if ($loginuser && $loginpass) {
180                $loginpass = auth_decrypt($loginpass, auth_cookiesalt(!$loginsticky, true));
181                $this->checkPass($loginuser, $loginpass);
182            }
183        }
184
185        $info = array();
186        $info['user'] = $user;
187        $this->debug('LDAP user to find: ' . hsc($info['user']), 0, __LINE__, __FILE__);
188
189        $info['server'] = $this->getConf('server');
190        $this->debug('LDAP Server: ' . hsc($info['server']), 0, __LINE__, __FILE__);
191
192        //get info for given user
193        $base = $this->makeFilter($this->getConf('usertree'), $info);
194        if ($this->getConf('userfilter')) {
195            $filter = $this->makeFilter($this->getConf('userfilter'), $info);
196        } else {
197            $filter = "(ObjectClass=*)";
198        }
199
200        $this->debug('LDAP Filter: ' . hsc($filter), 0, __LINE__, __FILE__);
201
202        $this->debug('LDAP user search: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
203        $this->debug('LDAP search at: ' . hsc($base . ' ' . $filter), 0, __LINE__, __FILE__);
204        $sr = $this->ldapSearch($this->con, $base, $filter, $this->getConf('userscope'), $this->getConf('attributes'));
205
206
207        $result = @ldap_get_entries($this->con, $sr);
208
209        // if result is not an array
210        if (!is_array($result)) {
211            // no objects found
212            $this->debug('LDAP search returned non-array result: ' . hsc(print($result)), -1, __LINE__, __FILE__);
213            return array();
214        }
215
216        // Don't accept more or less than one response
217        if ($result['count'] != 1) {
218            $this->debug(
219                'LDAP search returned ' . hsc($result['count']) . ' results while it should return 1!',
220                -1,
221                __LINE__,
222                __FILE__
223            );
224            //for($i = 0; $i < $result["count"]; $i++) {
225            //$this->_debug('result: '.hsc(print_r($result[$i])), 0, __LINE__, __FILE__);
226            //}
227            return array();
228        }
229
230        $this->debug('LDAP search found single result !', 0, __LINE__, __FILE__);
231
232        $user_result = $result[0];
233        ldap_free_result($sr);
234
235        // general user info
236        $info['dn'] = $user_result['dn'];
237        $info['gid'] = $user_result['gidnumber'][0];
238        $info['mail'] = $user_result['mail'][0];
239        $info['name'] = $user_result['cn'][0];
240        $info['grps'] = array();
241
242        // overwrite if other attribs are specified.
243        if (is_array($this->getConf('mapping'))) {
244            foreach ($this->getConf('mapping') as $localkey => $key) {
245                if (is_array($key)) {
246                    // use regexp to clean up user_result
247                    // $key = array($key=>$regexp), only handles the first key-value
248                    $regexp = current($key);
249                    $key = key($key);
250                    if ($user_result[$key]) foreach ($user_result[$key] as $grpkey => $grp) {
251                        if ($grpkey !== 'count' && preg_match($regexp, $grp, $match)) {
252                            if ($localkey == 'grps') {
253                                $info[$localkey][] = $match[1];
254                            } else {
255                                $info[$localkey] = $match[1];
256                            }
257                        }
258                    }
259                } else {
260                    $info[$localkey] = $user_result[$key][0];
261                }
262            }
263        }
264        $user_result = array_merge($info, $user_result);
265
266        //get groups for given user if grouptree is given
267        if ($this->getConf('grouptree') || $this->getConf('groupfilter')) {
268            $base = $this->makeFilter($this->getConf('grouptree'), $user_result);
269            $filter = $this->makeFilter($this->getConf('groupfilter'), $user_result);
270            $sr = $this->ldapSearch(
271                $this->con,
272                $base,
273                $filter,
274                $this->getConf('groupscope'),
275                array($this->getConf('groupkey'))
276            );
277            $this->debug('LDAP group search: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
278            $this->debug('LDAP search at: ' . hsc($base . ' ' . $filter), 0, __LINE__, __FILE__);
279
280            if (!$sr) {
281                msg("LDAP: Reading group memberships failed", -1);
282                return array();
283            }
284            $result = ldap_get_entries($this->con, $sr);
285            ldap_free_result($sr);
286
287            if (is_array($result)) foreach ($result as $grp) {
288                if (!empty($grp[$this->getConf('groupkey')])) {
289                    $group = $grp[$this->getConf('groupkey')];
290                    if (is_array($group)) {
291                        $group = $group[0];
292                    } else {
293                        $this->debug('groupkey did not return a detailled result', 0, __LINE__, __FILE__);
294                    }
295                    if ($group === '') continue;
296
297                    $this->debug('LDAP usergroup: ' . hsc($group), 0, __LINE__, __FILE__);
298                    $info['grps'][] = $group;
299                }
300            }
301        }
302
303        // always add the default group to the list of groups
304        if (!$info['grps'] or !in_array($conf['defaultgroup'], $info['grps'])) {
305            $info['grps'][] = $conf['defaultgroup'];
306        }
307        return $info;
308    }
309
310    /**
311     * Definition of the function modifyUser in order to modify the password
312     *
313     * @param string $user nick of the user to be changed
314     * @param array $changes array of field/value pairs to be changed (password will be clear text)
315     * @return  bool   true on success, false on error
316     */
317    public function modifyUser($user, $changes)
318    {
319
320        // open the connection to the ldap
321        if (!$this->openLDAP()) {
322            $this->debug('LDAP cannot connect: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
323            return false;
324        }
325
326        // find the information about the user, in particular the "dn"
327        $info = $this->getUserData($user, true);
328        if (empty($info['dn'])) {
329            $this->debug('LDAP cannot find your user dn', 0, __LINE__, __FILE__);
330            return false;
331        }
332        $dn = $info['dn'];
333
334        // find the old password of the user
335        list($loginuser, $loginsticky, $loginpass) = auth_getCookie();
336        if ($loginuser !== null) { // the user is currently logged in
337            $secret = auth_cookiesalt(!$loginsticky, true);
338            $pass = auth_decrypt($loginpass, $secret);
339
340            // bind with the ldap
341            if (!@ldap_bind($this->con, $dn, $pass)) {
342                $this->debug(
343                    'LDAP user bind failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)),
344                    0,
345                    __LINE__,
346                    __FILE__
347                );
348                return false;
349            }
350        } elseif ($this->getConf('binddn') && $this->getConf('bindpw')) {
351            // we are changing the password on behalf of the user (eg: forgotten password)
352            // bind with the superuser ldap
353            if (!@ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')))) {
354                $this->debug('LDAP bind as superuser: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
355                return false;
356            }
357        } else {
358            return false; // no otherway
359        }
360
361        // Generate the salted hashed password for LDAP
362        $phash = new \dokuwiki\PassHash();
363        $hash = $phash->hash_ssha($changes['pass']);
364
365        // change the password
366        if (!@ldap_mod_replace($this->con, $dn, array('userpassword' => $hash))) {
367            $this->debug(
368                'LDAP mod replace failed: ' . hsc($dn) . ': ' . hsc(ldap_error($this->con)),
369                0,
370                __LINE__,
371                __FILE__
372            );
373            return false;
374        }
375
376        return true;
377    }
378
379    /**
380     * Most values in LDAP are case-insensitive
381     *
382     * @return bool
383     */
384    public function isCaseSensitive()
385    {
386        return false;
387    }
388
389    /**
390     * Bulk retrieval of user data
391     *
392     * @param int $start index of first user to be returned
393     * @param int $limit max number of users to be returned
394     * @param array $filter array of field/pattern pairs, null for no filter
395     * @return  array of userinfo (refer getUserData for internal userinfo details)
396     * @author  Dominik Eckelmann <dokuwiki@cosmocode.de>
397     */
398    public function retrieveUsers($start = 0, $limit = 0, $filter = array())
399    {
400        if (!$this->openLDAP()) return array();
401
402        if (is_null($this->users)) {
403            // Perform the search and grab all their details
404            if ($this->getConf('userfilter')) {
405                $all_filter = str_replace('%{user}', '*', $this->getConf('userfilter'));
406            } else {
407                $all_filter = "(ObjectClass=*)";
408            }
409            $sr = ldap_search($this->con, $this->getConf('usertree'), $all_filter);
410            $entries = ldap_get_entries($this->con, $sr);
411            $users_array = array();
412            $userkey = $this->getConf('userkey');
413            for ($i = 0; $i < $entries["count"]; $i++) {
414                array_push($users_array, $entries[$i][$userkey][0]);
415            }
416            asort($users_array);
417            $result = $users_array;
418            if (!$result) return array();
419            $this->users = array_fill_keys($result, false);
420        }
421        $i = 0;
422        $count = 0;
423        $this->constructPattern($filter);
424        $result = array();
425
426        foreach ($this->users as $user => &$info) {
427            if ($i++ < $start) {
428                continue;
429            }
430            if ($info === false) {
431                $info = $this->getUserData($user);
432            }
433            if ($this->filter($user, $info)) {
434                $result[$user] = $info;
435                if (($limit > 0) && (++$count >= $limit)) break;
436            }
437        }
438        return $result;
439    }
440
441    /**
442     * Make LDAP filter strings.
443     *
444     * Used by auth_getUserData to make the filter
445     * strings for grouptree and groupfilter
446     *
447     * @param string $filter ldap search filter with placeholders
448     * @param array $placeholders placeholders to fill in
449     * @return  string
450     * @author  Troels Liebe Bentsen <tlb@rapanden.dk>
451     */
452    protected function makeFilter($filter, $placeholders)
453    {
454        preg_match_all("/%{([^}]+)/", $filter, $matches, PREG_PATTERN_ORDER);
455        //replace each match
456        foreach ($matches[1] as $match) {
457            //take first element if array
458            if (is_array($placeholders[$match])) {
459                $value = $placeholders[$match][0];
460            } else {
461                $value = $placeholders[$match];
462            }
463            $value = $this->filterEscape($value);
464            $filter = str_replace('%{' . $match . '}', $value, $filter);
465        }
466        return $filter;
467    }
468
469    /**
470     * return true if $user + $info match $filter criteria, false otherwise
471     *
472     * @param string $user the user's login name
473     * @param array $info the user's userinfo array
474     * @return bool
475     * @author Chris Smith <chris@jalakai.co.uk>
476     *
477     */
478    protected function filter($user, $info)
479    {
480        foreach ($this->pattern as $item => $pattern) {
481            if ($item == 'user') {
482                if (!preg_match($pattern, $user)) return false;
483            } elseif ($item == 'grps') {
484                if (!count(preg_grep($pattern, $info['grps']))) return false;
485            } else {
486                if (!preg_match($pattern, $info[$item])) return false;
487            }
488        }
489        return true;
490    }
491
492    /**
493     * Set the filter pattern
494     *
495     * @param $filter
496     * @return void
497     * @author Chris Smith <chris@jalakai.co.uk>
498     *
499     */
500    protected function constructPattern($filter)
501    {
502        $this->pattern = array();
503        foreach ($filter as $item => $pattern) {
504            $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters
505        }
506    }
507
508    /**
509     * Escape a string to be used in a LDAP filter
510     *
511     * Ported from Perl's Net::LDAP::Util escape_filter_value
512     *
513     * @param string $string
514     * @return string
515     * @author Andreas Gohr
516     */
517    protected function filterEscape($string)
518    {
519        // see https://github.com/adldap/adLDAP/issues/22
520        return preg_replace_callback(
521            '/([\x00-\x1F\*\(\)\\\\])/',
522            function ($matches) {
523                return "\\" . join("", unpack("H2", $matches[1]));
524            },
525            $string
526        );
527    }
528
529    /**
530     * Opens a connection to the configured LDAP server and sets the wanted
531     * option on the connection
532     *
533     * @author  Andreas Gohr <andi@splitbrain.org>
534     */
535    protected function openLDAP()
536    {
537        if ($this->con) return true; // connection already established
538
539        if ($this->getConf('debug')) {
540            ldap_set_option(null, LDAP_OPT_DEBUG_LEVEL, 7);
541        }
542
543        $this->bound = 0;
544
545        $port = $this->getConf('port');
546        $bound = false;
547        $servers = explode(',', $this->getConf('server'));
548        foreach ($servers as $server) {
549            $server = trim($server);
550            $this->con = @ldap_connect($server, $port);
551            if (!$this->con) {
552                continue;
553            }
554
555            /*
556             * When OpenLDAP 2.x.x is used, ldap_connect() will always return a resource as it does
557             * not actually connect but just initializes the connecting parameters. The actual
558             * connect happens with the next calls to ldap_* funcs, usually with ldap_bind().
559             *
560             * So we should try to bind to server in order to check its availability.
561             */
562
563            //set protocol version and dependend options
564            if ($this->getConf('version')) {
565                if (!@ldap_set_option(
566                    $this->con,
567                    LDAP_OPT_PROTOCOL_VERSION,
568                    $this->getConf('version')
569                )
570                ) {
571                    msg('Setting LDAP Protocol version ' . $this->getConf('version') . ' failed', -1);
572                    $this->debug('LDAP version set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
573                } else {
574                    //use TLS (needs version 3)
575                    if ($this->getConf('starttls')) {
576                        if (!@ldap_start_tls($this->con)) {
577                            msg('Starting TLS failed', -1);
578                            $this->debug('LDAP TLS set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
579                        }
580                    }
581                    // needs version 3
582                    if ($this->getConf('referrals') > -1) {
583                        if (!@ldap_set_option(
584                            $this->con,
585                            LDAP_OPT_REFERRALS,
586                            $this->getConf('referrals')
587                        )
588                        ) {
589                            msg('Setting LDAP referrals failed', -1);
590                            $this->debug('LDAP referal set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
591                        }
592                    }
593                }
594            }
595
596            //set deref mode
597            if ($this->getConf('deref')) {
598                if (!@ldap_set_option($this->con, LDAP_OPT_DEREF, $this->getConf('deref'))) {
599                    msg('Setting LDAP Deref mode ' . $this->getConf('deref') . ' failed', -1);
600                    $this->debug('LDAP deref set: ' . hsc(ldap_error($this->con)), 0, __LINE__, __FILE__);
601                }
602            }
603            /* As of PHP 5.3.0 we can set timeout to speedup skipping of invalid servers */
604            if (defined('LDAP_OPT_NETWORK_TIMEOUT')) {
605                ldap_set_option($this->con, LDAP_OPT_NETWORK_TIMEOUT, 1);
606            }
607
608            if ($this->getConf('binddn') && $this->getConf('bindpw')) {
609                $bound = @ldap_bind($this->con, $this->getConf('binddn'), conf_decodeString($this->getConf('bindpw')));
610                $this->bound = 2;
611            } else {
612                $bound = @ldap_bind($this->con);
613            }
614            if ($bound) {
615                break;
616            }
617        }
618
619        if (!$bound) {
620            msg("LDAP: couldn't connect to LDAP server", -1);
621            $this->debug(ldap_error($this->con), 0, __LINE__, __FILE__);
622            return false;
623        }
624
625        $this->cando['getUsers'] = true;
626        return true;
627    }
628
629    /**
630     * Wraps around ldap_search, ldap_list or ldap_read depending on $scope
631     *
632     * @param resource $link_identifier
633     * @param string $base_dn
634     * @param string $filter
635     * @param string $scope can be 'base', 'one' or 'sub'
636     * @param null|array $attributes
637     * @param int $attrsonly
638     * @param int $sizelimit
639     * @return resource
640     * @author Andreas Gohr <andi@splitbrain.org>
641     */
642    protected function ldapSearch(
643        $link_identifier,
644        $base_dn,
645        $filter,
646        $scope = 'sub',
647        $attributes = null,
648        $attrsonly = 0,
649        $sizelimit = 0
650    )
651    {
652        if (is_null($attributes)) $attributes = array();
653
654        if ($scope == 'base') {
655            return @ldap_read(
656                $link_identifier,
657                $base_dn,
658                $filter,
659                $attributes,
660                $attrsonly,
661                $sizelimit
662            );
663        } elseif ($scope == 'one') {
664            return @ldap_list(
665                $link_identifier,
666                $base_dn,
667                $filter,
668                $attributes,
669                $attrsonly,
670                $sizelimit
671            );
672        } else {
673            return @ldap_search(
674                $link_identifier,
675                $base_dn,
676                $filter,
677                $attributes,
678                $attrsonly,
679                $sizelimit
680            );
681        }
682    }
683
684    /**
685     * Wrapper around msg() but outputs only when debug is enabled
686     *
687     * @param string $message
688     * @param int $err
689     * @param int $line
690     * @param string $file
691     * @return void
692     */
693    protected function debug($message, $err, $line, $file)
694    {
695        if (!$this->getConf('debug')) return;
696        msg($message, $err, $line, $file);
697    }
698}
699