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