xref: /dokuwiki/lib/plugins/authldap/auth.php (revision 93a7873eb0646b5712d75b031cd8b8b143968ba2)
1<?php
2// must be run within Dokuwiki
3if(!defined('DOKU_INC')) die();
4
5/**
6 * LDAP authentication backend
7 *
8 * @license   GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author    Andreas Gohr <andi@splitbrain.org>
10 * @author    Chris Smith <chris@jalakaic.co.uk>
11 * @author    Jan Schumann <js@schumann-it.com>
12 */
13class auth_plugin_authldap extends DokuWiki_Auth_Plugin {
14    var $cnf = null;
15    var $con = null;
16    var $bound = 0; // 0: anonymous, 1: user, 2: superuser
17
18    /**
19     * Constructor
20     */
21    function __construct(){
22        global $conf;
23        $this->cnf = $conf['auth']['ldap'];
24
25        // ldap extension is needed
26        if(!function_exists('ldap_connect')) {
27            if ($this->cnf['debug'])
28                msg("LDAP err: PHP LDAP extension not found.",-1,__LINE__,__FILE__);
29            $this->success = false;
30            return;
31        }
32
33        if(empty($this->cnf['groupkey']))   $this->cnf['groupkey']   = 'cn';
34        if(empty($this->cnf['userscope']))  $this->cnf['userscope']  = 'sub';
35        if(empty($this->cnf['groupscope'])) $this->cnf['groupscope'] = 'sub';
36
37        // auth_ldap currently just handles authentication, so no
38        // capabilities are set
39    }
40
41    /**
42     * Check user+password
43     *
44     * Checks if the given user exists and the given
45     * plaintext password is correct by trying to bind
46     * to the LDAP server
47     *
48     * @author  Andreas Gohr <andi@splitbrain.org>
49     * @return  bool
50     */
51    function checkPass($user,$pass){
52        // reject empty password
53        if(empty($pass)) return false;
54        if(!$this->_openLDAP()) return false;
55
56        // indirect user bind
57        if($this->cnf['binddn'] && $this->cnf['bindpw']){
58            // use superuser credentials
59            if(!@ldap_bind($this->con,$this->cnf['binddn'],$this->cnf['bindpw'])){
60                if($this->cnf['debug'])
61                    msg('LDAP bind as superuser: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
62                return false;
63            }
64            $this->bound = 2;
65        }else if($this->cnf['binddn'] &&
66                 $this->cnf['usertree'] &&
67                 $this->cnf['userfilter']) {
68            // special bind string
69            $dn = $this->_makeFilter($this->cnf['binddn'],
70                                     array('user'=>$user,'server'=>$this->cnf['server']));
71
72        }else if(strpos($this->cnf['usertree'], '%{user}')) {
73            // direct user bind
74            $dn = $this->_makeFilter($this->cnf['usertree'],
75                                     array('user'=>$user,'server'=>$this->cnf['server']));
76
77        }else{
78            // Anonymous bind
79            if(!@ldap_bind($this->con)){
80                msg("LDAP: can not bind anonymously",-1);
81                if($this->cnf['debug'])
82                    msg('LDAP anonymous bind: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
83                return false;
84            }
85        }
86
87        // Try to bind to with the dn if we have one.
88        if(!empty($dn)) {
89            // User/Password bind
90            if(!@ldap_bind($this->con,$dn,$pass)){
91                if($this->cnf['debug']){
92                    msg("LDAP: bind with $dn failed", -1,__LINE__,__FILE__);
93                    msg('LDAP user dn bind: '.htmlspecialchars(ldap_error($this->con)),0);
94                }
95                return false;
96            }
97            $this->bound = 1;
98            return true;
99        }else{
100            // See if we can find the user
101            $info = $this->getUserData($user,true);
102            if(empty($info['dn'])) {
103                return false;
104            } else {
105                $dn = $info['dn'];
106            }
107
108            // Try to bind with the dn provided
109            if(!@ldap_bind($this->con,$dn,$pass)){
110                if($this->cnf['debug']){
111                    msg("LDAP: bind with $dn failed", -1,__LINE__,__FILE__);
112                    msg('LDAP user bind: '.htmlspecialchars(ldap_error($this->con)),0);
113                }
114                return false;
115            }
116            $this->bound = 1;
117            return true;
118        }
119
120        return false;
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     * @author  Andreas Gohr <andi@splitbrain.org>
141     * @author  Trouble
142     * @author  Dan Allen <dan.j.allen@gmail.com>
143     * @author  <evaldas.auryla@pheur.org>
144     * @author  Stephane Chazelas <stephane.chazelas@emerson.com>
145     * @return  array containing user data or false
146     */
147    function getUserData($user,$inbind=false) {
148        global $conf;
149        if(!$this->_openLDAP()) return false;
150
151        // force superuser bind if wanted and not bound as superuser yet
152        if($this->cnf['binddn'] && $this->cnf['bindpw'] && $this->bound < 2){
153            // use superuser credentials
154            if(!@ldap_bind($this->con,$this->cnf['binddn'],$this->cnf['bindpw'])){
155                if($this->cnf['debug'])
156                    msg('LDAP bind as superuser: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
157                return false;
158            }
159            $this->bound = 2;
160        }elseif($this->bound == 0 && !$inbind) {
161            // in some cases getUserData is called outside the authentication workflow
162            // eg. for sending email notification on subscribed pages. This data might not
163            // be accessible anonymously, so we try to rebind the current user here
164            list($loginuser,$loginsticky,$loginpass) = auth_getCookie();
165            if($loginuser && $loginpass){
166                $loginpass = PMA_blowfish_decrypt($loginpass, auth_cookiesalt(!$loginsticky));
167                $this->checkPass($loginuser, $loginpass);
168            }
169        }
170
171        $info['user']   = $user;
172        $info['server'] = $this->cnf['server'];
173
174        //get info for given user
175        $base = $this->_makeFilter($this->cnf['usertree'], $info);
176        if(!empty($this->cnf['userfilter'])) {
177            $filter = $this->_makeFilter($this->cnf['userfilter'], $info);
178        } else {
179            $filter = "(ObjectClass=*)";
180        }
181
182        $sr     = $this->_ldapsearch($this->con, $base, $filter, $this->cnf['userscope']);
183        $result = @ldap_get_entries($this->con, $sr);
184        if($this->cnf['debug']){
185            msg('LDAP user search: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
186            msg('LDAP search at: '.htmlspecialchars($base.' '.$filter),0,__LINE__,__FILE__);
187        }
188
189        // Don't accept more or less than one response
190        if(!is_array($result) || $result['count'] != 1){
191            return false; //user not found
192        }
193
194        $user_result = $result[0];
195        ldap_free_result($sr);
196
197        // general user info
198        $info['dn']   = $user_result['dn'];
199        $info['gid']  = $user_result['gidnumber'][0];
200        $info['mail'] = $user_result['mail'][0];
201        $info['name'] = $user_result['cn'][0];
202        $info['grps'] = array();
203
204        // overwrite if other attribs are specified.
205        if(is_array($this->cnf['mapping'])){
206            foreach($this->cnf['mapping'] as $localkey => $key) {
207                if(is_array($key)) {
208                    // use regexp to clean up user_result
209                    list($key, $regexp) = each($key);
210                    if($user_result[$key]) foreach($user_result[$key] as $grp){
211                        if (preg_match($regexp,$grp,$match)) {
212                            if($localkey == 'grps') {
213                                $info[$localkey][] = $match[1];
214                            } else {
215                                $info[$localkey] = $match[1];
216                            }
217                        }
218                    }
219                } else {
220                    $info[$localkey] = $user_result[$key][0];
221                }
222            }
223        }
224        $user_result = array_merge($info,$user_result);
225
226        //get groups for given user if grouptree is given
227        if ($this->cnf['grouptree'] || $this->cnf['groupfilter']) {
228            $base   = $this->_makeFilter($this->cnf['grouptree'], $user_result);
229            $filter = $this->_makeFilter($this->cnf['groupfilter'], $user_result);
230            $sr = $this->_ldapsearch($this->con, $base, $filter, $this->cnf['groupscope'], array($this->cnf['groupkey']));
231            if($this->cnf['debug']){
232                msg('LDAP group search: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
233                msg('LDAP search at: '.htmlspecialchars($base.' '.$filter),0,__LINE__,__FILE__);
234            }
235            if(!$sr){
236                msg("LDAP: Reading group memberships failed",-1);
237                return false;
238            }
239            $result = ldap_get_entries($this->con, $sr);
240            ldap_free_result($sr);
241
242            if(is_array($result)) foreach($result as $grp){
243                if(!empty($grp[$this->cnf['groupkey']][0])){
244                    if($this->cnf['debug'])
245                        msg('LDAP usergroup: '.htmlspecialchars($grp[$this->cnf['groupkey']][0]),0,__LINE__,__FILE__);
246                    $info['grps'][] = $grp[$this->cnf['groupkey']][0];
247                }
248            }
249        }
250
251        // always add the default group to the list of groups
252        if(!in_array($conf['defaultgroup'],$info['grps'])){
253            $info['grps'][] = $conf['defaultgroup'];
254        }
255        return $info;
256    }
257
258    /**
259     * Most values in LDAP are case-insensitive
260     */
261    function isCaseSensitive(){
262        return false;
263    }
264
265    /**
266     * Bulk retrieval of user data
267     *
268     * @author  Dominik Eckelmann <dokuwiki@cosmocode.de>
269     * @param   start     index of first user to be returned
270     * @param   limit     max number of users to be returned
271     * @param   filter    array of field/pattern pairs, null for no filter
272     * @return  array of userinfo (refer getUserData for internal userinfo details)
273     */
274    function retrieveUsers($start=0,$limit=-1,$filter=array()) {
275        if(!$this->_openLDAP()) return false;
276
277        if (!isset($this->users)) {
278            // Perform the search and grab all their details
279            if(!empty($this->cnf['userfilter'])) {
280                $all_filter = str_replace('%{user}', '*', $this->cnf['userfilter']);
281            } else {
282                $all_filter = "(ObjectClass=*)";
283            }
284            $sr=ldap_search($this->con,$this->cnf['usertree'],$all_filter);
285            $entries = ldap_get_entries($this->con, $sr);
286            $users_array = array();
287            for ($i=0; $i<$entries["count"]; $i++){
288                array_push($users_array, $entries[$i]["uid"][0]);
289            }
290            asort($users_array);
291            $result = $users_array;
292            if (!$result) return array();
293            $this->users = array_fill_keys($result, false);
294        }
295        $i = 0;
296        $count = 0;
297        $this->_constructPattern($filter);
298        $result = array();
299
300        foreach ($this->users as $user => &$info) {
301            if ($i++ < $start) {
302                continue;
303            }
304            if ($info === false) {
305                $info = $this->getUserData($user);
306            }
307            if ($this->_filter($user, $info)) {
308                $result[$user] = $info;
309                if (($limit >= 0) && (++$count >= $limit)) break;
310            }
311        }
312        return $result;
313    }
314
315    /**
316     * Make LDAP filter strings.
317     *
318     * Used by auth_getUserData to make the filter
319     * strings for grouptree and groupfilter
320     *
321     * filter      string  ldap search filter with placeholders
322     * placeholders array   array with the placeholders
323     *
324     * @author  Troels Liebe Bentsen <tlb@rapanden.dk>
325     * @return  string
326     */
327    function _makeFilter($filter, $placeholders) {
328        preg_match_all("/%{([^}]+)/", $filter, $matches, PREG_PATTERN_ORDER);
329        //replace each match
330        foreach ($matches[1] as $match) {
331            //take first element if array
332            if(is_array($placeholders[$match])) {
333                $value = $placeholders[$match][0];
334            } else {
335                $value = $placeholders[$match];
336            }
337            $value = $this->_filterEscape($value);
338            $filter = str_replace('%{'.$match.'}', $value, $filter);
339        }
340        return $filter;
341    }
342
343    /**
344     * return 1 if $user + $info match $filter criteria, 0 otherwise
345     *
346     * @author   Chris Smith <chris@jalakai.co.uk>
347     */
348    function _filter($user, $info) {
349        foreach ($this->_pattern as $item => $pattern) {
350            if ($item == 'user') {
351                if (!preg_match($pattern, $user)) return 0;
352            } else if ($item == 'grps') {
353                if (!count(preg_grep($pattern, $info['grps']))) return 0;
354            } else {
355                if (!preg_match($pattern, $info[$item])) return 0;
356            }
357        }
358        return 1;
359    }
360
361    function _constructPattern($filter) {
362        $this->_pattern = array();
363        foreach ($filter as $item => $pattern) {
364            $this->_pattern[$item] = '/'.str_replace('/','\/',$pattern).'/i';    // allow regex characters
365        }
366    }
367
368    /**
369     * Escape a string to be used in a LDAP filter
370     *
371     * Ported from Perl's Net::LDAP::Util escape_filter_value
372     *
373     * @author Andreas Gohr
374     */
375    function _filterEscape($string){
376        return preg_replace('/([\x00-\x1F\*\(\)\\\\])/e',
377                            '"\\\\\".join("",unpack("H2","$1"))',
378                            $string);
379    }
380
381    /**
382     * Opens a connection to the configured LDAP server and sets the wanted
383     * option on the connection
384     *
385     * @author  Andreas Gohr <andi@splitbrain.org>
386     */
387    function _openLDAP(){
388        if($this->con) return true; // connection already established
389
390        $this->bound = 0;
391
392        $port = ($this->cnf['port']) ? $this->cnf['port'] : 389;
393        $bound = false;
394        $servers = explode(',', $this->cnf['server']);
395        foreach ($servers as $server) {
396            $server = trim($server);
397            $this->con = @ldap_connect($server, $port);
398            if (!$this->con) {
399                continue;
400            }
401
402            /*
403             * When OpenLDAP 2.x.x is used, ldap_connect() will always return a resource as it does
404             * not actually connect but just initializes the connecting parameters. The actual
405             * connect happens with the next calls to ldap_* funcs, usually with ldap_bind().
406             *
407             * So we should try to bind to server in order to check its availability.
408             */
409
410            //set protocol version and dependend options
411            if($this->cnf['version']){
412                if(!@ldap_set_option($this->con, LDAP_OPT_PROTOCOL_VERSION,
413                                     $this->cnf['version'])){
414                    msg('Setting LDAP Protocol version '.$this->cnf['version'].' failed',-1);
415                    if($this->cnf['debug'])
416                        msg('LDAP version set: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
417                }else{
418                    //use TLS (needs version 3)
419                    if($this->cnf['starttls']) {
420                        if (!@ldap_start_tls($this->con)){
421                            msg('Starting TLS failed',-1);
422                            if($this->cnf['debug'])
423                                msg('LDAP TLS set: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
424                        }
425                    }
426                    // needs version 3
427                    if(isset($this->cnf['referrals'])) {
428                        if(!@ldap_set_option($this->con, LDAP_OPT_REFERRALS,
429                           $this->cnf['referrals'])){
430                            msg('Setting LDAP referrals to off failed',-1);
431                            if($this->cnf['debug'])
432                                msg('LDAP referal set: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
433                        }
434                    }
435                }
436            }
437
438            //set deref mode
439            if($this->cnf['deref']){
440                if(!@ldap_set_option($this->con, LDAP_OPT_DEREF, $this->cnf['deref'])){
441                    msg('Setting LDAP Deref mode '.$this->cnf['deref'].' failed',-1);
442                    if($this->cnf['debug'])
443                        msg('LDAP deref set: '.htmlspecialchars(ldap_error($this->con)),0,__LINE__,__FILE__);
444                }
445            }
446            /* As of PHP 5.3.0 we can set timeout to speedup skipping of invalid servers */
447            if (defined('LDAP_OPT_NETWORK_TIMEOUT')) {
448                ldap_set_option($this->con, LDAP_OPT_NETWORK_TIMEOUT, 1);
449            }
450            $bound = @ldap_bind($this->con);
451            if ($bound) {
452                break;
453            }
454        }
455
456        if(!$bound) {
457            msg("LDAP: couldn't connect to LDAP server",-1);
458            return false;
459        }
460
461
462        $this->canDo['getUsers'] = true;
463        return true;
464    }
465
466    /**
467     * Wraps around ldap_search, ldap_list or ldap_read depending on $scope
468     *
469     * @param  $scope string - can be 'base', 'one' or 'sub'
470     * @author Andreas Gohr <andi@splitbrain.org>
471     */
472    function _ldapsearch($link_identifier, $base_dn, $filter, $scope='sub', $attributes=null,
473                         $attrsonly=0, $sizelimit=0, $timelimit=0, $deref=LDAP_DEREF_NEVER){
474        if(is_null($attributes)) $attributes = array();
475
476        if($scope == 'base'){
477            return @ldap_read($link_identifier, $base_dn, $filter, $attributes,
478                             $attrsonly, $sizelimit, $timelimit, $deref);
479        }elseif($scope == 'one'){
480            return @ldap_list($link_identifier, $base_dn, $filter, $attributes,
481                             $attrsonly, $sizelimit, $timelimit, $deref);
482        }else{
483            return @ldap_search($link_identifier, $base_dn, $filter, $attributes,
484                                $attrsonly, $sizelimit, $timelimit, $deref);
485        }
486    }
487}
488