source: subversion/branches/devel-addressbook/program/include/rcube_ldap.php @ 4341

Last change on this file since 4341 was 4341, checked in by thomasb, 2 years ago

Implement new config option 'fieldmap' for ldap address books

  • Property svn:eol-style set to native
  • Property svn:keywords set to Id
File size: 21.4 KB
Line 
1<?php
2/*
3 +-----------------------------------------------------------------------+
4 | program/include/rcube_ldap.php                                        |
5 |                                                                       |
6 | This file is part of the Roundcube Webmail client                     |
7 | Copyright (C) 2006-2010, Roundcube Dev. - Switzerland                 |
8 | Licensed under the GNU GPL                                            |
9 |                                                                       |
10 | PURPOSE:                                                              |
11 |   Interface to an LDAP address directory                              |
12 |                                                                       |
13 +-----------------------------------------------------------------------+
14 | Author: Thomas Bruederli <roundcube@gmail.com>                        |
15 +-----------------------------------------------------------------------+
16
17 $Id$
18
19*/
20
21
22/**
23 * Model class to access an LDAP address directory
24 *
25 * @package Addressbook
26 */
27class rcube_ldap extends rcube_addressbook
28{
29  var $conn;
30  var $prop = array();
31  var $fieldmap = array();
32
33  var $filter = '';
34  var $result = null;
35  var $ldap_result = null;
36  var $sort_col = '';
37  var $mail_domain = '';
38  var $debug = false;
39
40  /** public properties */
41  var $primary_key = 'ID';
42  var $readonly = true;
43  var $list_page = 1;
44  var $page_size = 10;
45  var $ready = false;
46
47
48  /**
49   * Object constructor
50   *
51   * @param array       LDAP connection properties
52   * @param boolean     Enables debug mode
53   * @param string      Current user mail domain name
54   * @param integer User-ID
55   */
56  function __construct($p, $debug=false, $mail_domain=NULL)
57  {
58    $this->prop = $p;
59
60    // fieldmap property is given
61    if (is_array($p['fieldmap'])) {
62      foreach ($p['fieldmap'] as $rf => $lf)
63          $this->fieldmap[$rf] = $this->_attr_name(strtolower($lf));
64    }
65    else {
66      // read deprecated *_field properties to remain backwards compatible
67      foreach ($p as $prop => $value)
68        if (preg_match('/^(.+)_field$/', $prop, $matches))
69          $this->fieldmap[$matches[1]] = $this->_attr_name(strtolower($value));
70    }
71   
72    // use fieldmap to advertise supported coltypes to the application
73    $this->coltypes = array_keys($this->fieldmap);
74    if ($this->fieldmap['street'] && $this->fieldmap['locality'])
75      $this->coltypes[] = 'address';
76
77    // make sure 'required_fields' is an array
78    if (!is_array($this->prop['required_fields']))
79      $this->prop['required_fields'] = (array) $this->prop['required_fields'];
80
81    foreach ($this->prop['required_fields'] as $key => $val)
82      $this->prop['required_fields'][$key] = $this->_attr_name(strtolower($val));
83
84    $this->sort_col = $p['sort'];
85    $this->debug = $debug;
86    $this->mail_domain = $mail_domain;
87
88    $this->connect();
89  }
90
91
92  /**
93   * Establish a connection to the LDAP server
94   */
95  function connect()
96  {
97    global $RCMAIL;
98   
99    if (!function_exists('ldap_connect'))
100      raise_error(array('code' => 100, 'type' => 'ldap',
101        'file' => __FILE__, 'line' => __LINE__,
102        'message' => "No ldap support in this installation of PHP"), true);
103
104    if (is_resource($this->conn))
105      return true;
106
107    if (!is_array($this->prop['hosts']))
108      $this->prop['hosts'] = array($this->prop['hosts']);
109
110    if (empty($this->prop['ldap_version']))
111      $this->prop['ldap_version'] = 3;
112
113    foreach ($this->prop['hosts'] as $host)
114    {
115      $host = idn_to_ascii(rcube_parse_host($host));
116      $this->_debug("C: Connect [$host".($this->prop['port'] ? ':'.$this->prop['port'] : '')."]");
117
118      if ($lc = @ldap_connect($host, $this->prop['port']))
119      {
120        if ($this->prop['use_tls']===true)
121          if (!ldap_start_tls($lc))
122            continue;
123
124        $this->_debug("S: OK");
125
126        ldap_set_option($lc, LDAP_OPT_PROTOCOL_VERSION, $this->prop['ldap_version']);
127        $this->prop['host'] = $host;
128        $this->conn = $lc;
129        break;
130      }
131      $this->_debug("S: NOT OK");
132    }
133   
134    if (is_resource($this->conn))
135    {
136      $this->ready = true;
137
138      // User specific access, generate the proper values to use.
139      if ($this->prop['user_specific']) {
140        // No password set, use the session password
141        if (empty($this->prop['bind_pass'])) {
142          $this->prop['bind_pass'] = $RCMAIL->decrypt($_SESSION['password']);
143        }
144
145        // Get the pieces needed for variable replacement.
146        $fu = $RCMAIL->user->get_username();
147        list($u, $d) = explode('@', $fu);
148        $dc = 'dc='.strtr($d, array('.' => ',dc=')); // hierarchal domain string
149
150        $replaces = array('%dc' => $dc, '%d' => $d, '%fu' => $fu, '%u' => $u);
151
152        if ($this->prop['search_base_dn'] && $this->prop['search_filter']) {
153          // Search for the dn to use to authenticate
154          $this->prop['search_base_dn'] = strtr($this->prop['search_base_dn'], $replaces);
155          $this->prop['search_filter'] = strtr($this->prop['search_filter'], $replaces);
156
157          $this->_debug("S: searching with base {$this->prop['search_base_dn']} for {$this->prop['search_filter']}");
158
159          $res = ldap_search($this->conn, $this->prop['search_base_dn'], $this->prop['search_filter'], array('uid'));
160          if ($res && ($entry = ldap_first_entry($this->conn, $res))) {
161            $bind_dn = ldap_get_dn($this->conn, $entry);
162
163            $this->_debug("S: search returned dn: $bind_dn");
164
165            if ($bind_dn) {
166              $this->prop['bind_dn'] = $bind_dn;
167              $dn = ldap_explode_dn($bind_dn, 1);
168              $replaces['%dn'] = $dn[0];
169            }
170          }
171        }
172        // Replace the bind_dn and base_dn variables.
173        $this->prop['bind_dn'] = strtr($this->prop['bind_dn'], $replaces);
174        $this->prop['base_dn'] = strtr($this->prop['base_dn'], $replaces);
175      }
176
177      if (!empty($this->prop['bind_dn']) && !empty($this->prop['bind_pass']))
178        $this->ready = $this->bind($this->prop['bind_dn'], $this->prop['bind_pass']);
179    }
180    else
181      raise_error(array('code' => 100, 'type' => 'ldap',
182        'file' => __FILE__, 'line' => __LINE__,
183        'message' => "Could not connect to any LDAP server, last tried $host:{$this->prop[port]}"), true);
184
185    // See if the directory is writeable.
186    if ($this->prop['writable']) {
187      $this->readonly = false;
188    } // end if
189
190  }
191
192
193  /**
194   * Bind connection with DN and password
195   *
196   * @param string Bind DN
197   * @param string Bind password
198   * @return boolean True on success, False on error
199   */
200  function bind($dn, $pass)
201  {
202    if (!$this->conn) {
203      return false;
204    }
205   
206    $this->_debug("C: Bind [dn: $dn] [pass: $pass]");
207   
208    if (@ldap_bind($this->conn, $dn, $pass)) {
209      $this->_debug("S: OK");
210      return true;
211    }
212
213    $this->_debug("S: ".ldap_error($this->conn));
214
215    raise_error(array(
216        'code' => ldap_errno($this->conn), 'type' => 'ldap',
217        'file' => __FILE__, 'line' => __LINE__,
218        'message' => "Bind failed for dn=$dn: ".ldap_error($this->conn)),
219        true);
220
221    return false;
222  }
223
224
225  /**
226   * Close connection to LDAP server
227   */
228  function close()
229  {
230    if ($this->conn)
231    {
232      $this->_debug("C: Close");
233      ldap_unbind($this->conn);
234      $this->conn = null;
235    }
236  }
237
238
239  /**
240   * Set internal list page
241   *
242   * @param  number  Page number to list
243   * @access public
244   */
245  function set_page($page)
246  {
247    $this->list_page = (int)$page;
248  }
249
250
251  /**
252   * Set internal page size
253   *
254   * @param  number  Number of messages to display on one page
255   * @access public
256   */
257  function set_pagesize($size)
258  {
259    $this->page_size = (int)$size;
260  }
261
262
263  /**
264   * Save a search string for future listings
265   *
266   * @param string Filter string
267   */
268  function set_search_set($filter)
269  {
270    $this->filter = $filter;
271  }
272 
273 
274  /**
275   * Getter for saved search properties
276   *
277   * @return mixed Search properties used by this class
278   */
279  function get_search_set()
280  {
281    return $this->filter;
282  }
283
284
285  /**
286   * Reset all saved results and search parameters
287   */
288  function reset()
289  {
290    $this->result = null;
291    $this->ldap_result = null;
292    $this->filter = '';
293  }
294 
295 
296  /**
297   * List the current set of contact records
298   *
299   * @param  array  List of cols to show
300   * @param  int    Only return this number of records
301   * @return array  Indexed list of contact records, each a hash array
302   */
303  function list_records($cols=null, $subset=0)
304  {
305    // add general filter to query
306    if (!empty($this->prop['filter']) && empty($this->filter))
307    {
308      $filter = $this->prop['filter'];
309      $this->set_search_set($filter);
310    }
311
312    // exec LDAP search if no result resource is stored
313    if ($this->conn && !$this->ldap_result)
314      $this->_exec_search();
315   
316    // count contacts for this user
317    $this->result = $this->count();
318
319    // we have a search result resource
320    if ($this->ldap_result && $this->result->count > 0)
321    {
322      if ($this->sort_col && $this->prop['scope'] !== 'base')
323        ldap_sort($this->conn, $this->ldap_result, $this->sort_col);
324
325      $start_row = $subset < 0 ? $this->result->first + $this->page_size + $subset : $this->result->first;
326      $last_row = $this->result->first + $this->page_size;
327      $last_row = $subset != 0 ? $start_row + abs($subset) : $last_row;
328
329      $entries = ldap_get_entries($this->conn, $this->ldap_result);
330      for ($i = $start_row; $i < min($entries['count'], $last_row); $i++)
331        $this->result->add($this->_ldap2result($entries[$i]));
332    }
333
334    return $this->result;
335  }
336
337
338  /**
339   * Search contacts
340   *
341   * @param array   List of fields to search in
342   * @param string  Search value
343   * @param boolean True for strict, False for partial (fuzzy) matching
344   * @param boolean True if results are requested, False if count only
345   * @param boolean (Not used)
346   * @param array   List of fields that cannot be empty
347   * @return array  Indexed list of contact records and 'count' value
348   */
349  function search($fields, $value, $strict=false, $select=true, $nocount=false, $required=array())
350  {
351    // special treatment for ID-based search
352    if ($fields == 'ID' || $fields == $this->primary_key)
353    {
354      $ids = explode(',', $value);
355      $result = new rcube_result_set();
356      foreach ($ids as $id)
357        if ($rec = $this->get_record($id, true))
358        {
359          $result->add($rec);
360          $result->count++;
361        }
362     
363      return $result;
364    }
365   
366    $filter = '(|';
367    $wc = !$strict && $this->prop['fuzzy_search'] ? '*' : '';
368    if (is_array($this->prop['search_fields']))
369    {
370      foreach ($this->prop['search_fields'] as $k => $field)
371        $filter .= "($field=$wc" . rcube_ldap::quote_string($value) . "$wc)";
372    }
373    else
374    {
375      foreach ((array)$fields as $field)
376        if ($f = $this->_map_field($field))
377          $filter .= "($f=$wc" . rcube_ldap::quote_string($value) . "$wc)";
378    }
379    $filter .= ')';
380
381    // add required (non empty) fields filter
382    $req_filter = '';
383    foreach ((array)$required as $field)
384      if ($f = $this->_map_field($field))
385        $req_filter .= "($f=*)";
386
387    if (!empty($req_filter))
388      $filter = '(&' . $req_filter . $filter . ')';
389
390    // avoid double-wildcard if $value is empty
391    $filter = preg_replace('/\*+/', '*', $filter);
392
393    // add general filter to query
394    if (!empty($this->prop['filter']))
395      $filter = '(&(' . preg_replace('/^\(|\)$/', '', $this->prop['filter']) . ')' . $filter . ')';
396
397    // set filter string and execute search
398    $this->set_search_set($filter);
399    $this->_exec_search();
400   
401    if ($select)
402      $this->list_records();
403    else
404      $this->result = $this->count();
405   
406    return $this->result; 
407  }
408
409
410  /**
411   * Count number of available contacts in database
412   *
413   * @return object rcube_result_set Resultset with values for 'count' and 'first'
414   */
415  function count()
416  {
417    $count = 0;
418    if ($this->conn && $this->ldap_result) {
419      $count = ldap_count_entries($this->conn, $this->ldap_result);
420    } // end if
421    elseif ($this->conn) {
422      // We have a connection but no result set, attempt to get one.
423      if (empty($this->filter)) {
424        // The filter is not set, set it.
425        $this->filter = $this->prop['filter'];
426      } // end if
427      $this->_exec_search();
428      if ($this->ldap_result) {
429        $count = ldap_count_entries($this->conn, $this->ldap_result);
430      } // end if
431    } // end else
432
433    return new rcube_result_set($count, ($this->list_page-1) * $this->page_size);
434  }
435
436
437  /**
438   * Return the last result set
439   *
440   * @return object rcube_result_set Current resultset or NULL if nothing selected yet
441   */
442  function get_result()
443  {
444    return $this->result;
445  }
446 
447 
448  /**
449   * Get a specific contact record
450   *
451   * @param mixed   Record identifier
452   * @param boolean Return as associative array
453   * @return mixed  Hash array or rcube_result_set with all record fields
454   */
455  function get_record($dn, $assoc=false)
456  {
457    $res = null;
458    if ($this->conn && $dn)
459    {
460      $dn = base64_decode($dn);
461
462      $this->_debug("C: Read [dn: $dn] [(objectclass=*)]");
463   
464      if ($this->ldap_result = @ldap_read($this->conn, $dn, '(objectclass=*)', array_values($this->fieldmap)))
465        $entry = ldap_first_entry($this->conn, $this->ldap_result);
466      else
467        $this->_debug("S: ".ldap_error($this->conn));
468
469      if ($entry && ($rec = ldap_get_attributes($this->conn, $entry)))
470      {
471        $this->_debug("S: OK");
472
473        $rec = array_change_key_case($rec, CASE_LOWER);
474
475        // Add in the dn for the entry.
476        $rec['dn'] = $dn;
477        $res = $this->_ldap2result($rec);
478        $this->result = new rcube_result_set(1);
479        $this->result->add($res);
480      }
481    }
482
483    return $assoc ? $res : $this->result;
484  }
485 
486 
487  /**
488   * Create a new contact record
489   *
490   * @param array    Hash array with save data
491   * @return encoded record ID on success, False on error
492   */
493  function insert($save_cols)
494  {
495    // Map out the column names to their LDAP ones to build the new entry.
496    $newentry = array();
497    $newentry['objectClass'] = $this->prop['LDAP_Object_Classes'];
498    foreach ($save_cols as $col => $val) {
499      $fld = $this->_map_field($col);
500      if ($fld && $val) {
501        // The field does exist, add it to the entry.
502        $newentry[$fld] = $val;
503      } // end if
504    } // end foreach
505
506    // Verify that the required fields are set.
507    // We know that the email address is required as a default of rcube, so
508    // we will default its value into any unfilled required fields.
509    foreach ($this->prop['required_fields'] as $fld) {
510      if (!isset($newentry[$fld])) {
511        $newentry[$fld] = $newentry[$this->_map_field('email')];
512      } // end if
513    } // end foreach
514
515    // Build the new entries DN.
516    $dn = $this->prop['LDAP_rdn'].'='.rcube_ldap::quote_string($newentry[$this->prop['LDAP_rdn']], true)
517      .','.$this->prop['base_dn'];
518
519    $this->_debug("C: Add [dn: $dn]: ".print_r($newentry, true));
520
521    $res = ldap_add($this->conn, $dn, $newentry);
522    if ($res === FALSE) {
523      $this->_debug("S: ".ldap_error($this->conn));
524      return false;
525    } // end if
526
527    $this->_debug("S: OK");
528
529    return base64_encode($dn);
530  }
531 
532 
533  /**
534   * Update a specific contact record
535   *
536   * @param mixed Record identifier
537   * @param array Hash array with save data
538   * @return boolean True on success, False on error
539   */
540  function update($id, $save_cols)
541  {
542    $record = $this->get_record($id, true);
543    $result = $this->get_result();
544    $record = $result->first();
545
546    $newdata = array();
547    $replacedata = array();
548    $deletedata = array();
549    foreach ($save_cols as $col => $val) {
550      $fld = $this->_map_field($col);
551      if ($fld) {
552        // The field does exist compare it to the ldap record.
553        if ($record[$col] != $val) {
554          // Changed, but find out how.
555          if (!isset($record[$col])) {
556            // Field was not set prior, need to add it.
557            $newdata[$fld] = $val;
558          } // end if
559          elseif ($val == '') {
560            // Field supplied is empty, verify that it is not required.
561            if (!in_array($fld, $this->prop['required_fields'])) {
562              // It is not, safe to clear.
563              $deletedata[$fld] = $record[$col];
564            } // end if
565          } // end elseif
566          else {
567            // The data was modified, save it out.
568            $replacedata[$fld] = $val;
569          } // end else
570        } // end if
571      } // end if
572    } // end foreach
573
574    $dn = base64_decode($id);
575
576    // Update the entry as required.
577    if (!empty($deletedata)) {
578      // Delete the fields.
579      $this->_debug("C: Delete [dn: $dn]: ".print_r($deletedata, true));
580      if (!ldap_mod_del($this->conn, $dn, $deletedata)) {
581        $this->_debug("S: ".ldap_error($this->conn));
582        return false;
583      }
584      $this->_debug("S: OK");
585    } // end if
586
587    if (!empty($replacedata)) {
588      // Handle RDN change
589      if ($replacedata[$this->prop['LDAP_rdn']]) {
590        $newdn = $this->prop['LDAP_rdn'].'='
591          .rcube_ldap::quote_string($replacedata[$this->prop['LDAP_rdn']], true)
592          .','.$this->prop['base_dn']; 
593        if ($dn != $newdn) {
594          $newrdn = $this->prop['LDAP_rdn'].'='
595            .rcube_ldap::quote_string($replacedata[$this->prop['LDAP_rdn']], true);
596          unset($replacedata[$this->prop['LDAP_rdn']]);
597        }
598      }
599      // Replace the fields.
600      if (!empty($replacedata)) {
601        $this->_debug("C: Replace [dn: $dn]: ".print_r($replacedata, true));
602        if (!ldap_mod_replace($this->conn, $dn, $replacedata)) {
603          $this->_debug("S: ".ldap_error($this->conn));
604          return false;
605        }
606        $this->_debug("S: OK");
607      } // end if
608    } // end if
609
610    if (!empty($newdata)) {
611      // Add the fields.
612      $this->_debug("C: Add [dn: $dn]: ".print_r($newdata, true));
613      if (!ldap_mod_add($this->conn, $dn, $newdata)) {
614        $this->_debug("S: ".ldap_error($this->conn));
615        return false;
616      }
617      $this->_debug("S: OK");
618    } // end if
619
620    // Handle RDN change
621    if (!empty($newrdn)) {
622      $this->_debug("C: Rename [dn: $dn] [dn: $newrdn]");
623      if (@ldap_rename($this->conn, $dn, $newrdn, NULL, TRUE)) {
624        $this->_debug("S: ".ldap_error($this->conn));
625        return base64_encode($newdn);
626      }
627      $this->_debug("S: OK");
628    }
629
630    return true;
631  }
632 
633 
634  /**
635   * Mark one or more contact records as deleted
636   *
637   * @param array  Record identifiers
638   * @return boolean True on success, False on error
639   */
640  function delete($ids)
641  {
642    if (!is_array($ids)) {
643      // Not an array, break apart the encoded DNs.
644      $dns = explode(',', $ids);
645    } // end if
646
647    foreach ($dns as $id) {
648      $dn = base64_decode($id);
649      $this->_debug("C: Delete [dn: $dn]");
650      // Delete the record.
651      $res = ldap_delete($this->conn, $dn);
652      if ($res === FALSE) {
653        $this->_debug("S: ".ldap_error($this->conn));
654        return false;
655      } // end if
656      $this->_debug("S: OK");
657    } // end foreach
658
659    return count($dns);
660  }
661
662
663  /**
664   * Execute the LDAP search based on the stored credentials
665   *
666   * @access private
667   */
668  private function _exec_search()
669  {
670    if ($this->ready)
671    {
672      $filter = $this->filter ? $this->filter : '(objectclass=*)';
673      $function = $this->prop['scope'] == 'sub' ? 'ldap_search' : ($this->prop['scope'] == 'base' ? 'ldap_read' : 'ldap_list');
674
675      $this->_debug("C: Search [".$filter."]");
676
677      if ($this->ldap_result = @$function($this->conn, $this->prop['base_dn'], $filter,
678          array_values($this->fieldmap), 0, (int) $this->prop['sizelimit'], (int) $this->prop['timelimit'])
679      ) {
680        $this->_debug("S: ".ldap_count_entries($this->conn, $this->ldap_result)." record(s)");
681        return true;
682      } else
683        $this->_debug("S: ".ldap_error($this->conn));
684    }
685   
686    return false;
687  }
688 
689 
690  /**
691   * @access private
692   */
693  private function _ldap2result($rec)
694  {
695    $out = array();
696   
697    if ($rec['dn'])
698      $out[$this->primary_key] = base64_encode($rec['dn']);
699   
700    foreach ($this->fieldmap as $rf => $lf)
701    {
702      for ($i=0; $i < $rec[$lf]['count']; $i++) {
703        if (!($value = $rec[$lf][$i]))
704          continue;
705        if ($rf == 'email' && $this->mail_domain && !strpos($value, '@'))
706          $out[$rf][] = sprintf('%s@%s', $value, $this->mail_domain);
707        else if (in_array($rf, array('street','zipcode','locality','country','region')))
708          $out['address'][$i][$rf] = $value;
709        else if ($rec[$lf]['count'] > 1)
710          $out[$rf][] = $value;
711        else
712          $out[$rf] = $value;
713      }
714    }
715   
716    return $out;
717  }
718 
719 
720  /**
721   * @access private
722   */
723  private function _map_field($field)
724  {
725    return $this->fieldmap[$field];
726  }
727 
728 
729  /**
730   * @access private
731   */
732  private function _attr_name($name)
733  {
734    // list of known attribute aliases
735    $aliases = array(
736      'gn' => 'givenname',
737      'rfc822mailbox' => 'email',
738      'userid' => 'uid',
739      'emailaddress' => 'email',
740      'pkcs9email' => 'email',
741    );
742    return isset($aliases[$name]) ? $aliases[$name] : $name;
743  }
744
745
746  /**
747   * @access private
748   */
749  private function _debug($str)
750  {
751    if ($this->debug)
752      write_log('ldap', $str);
753  }
754 
755
756  /**
757   * @static
758   */
759  function quote_string($str, $dn=false)
760  {
761    if ($dn)
762      $replace = array(','=>'\2c', '='=>'\3d', '+'=>'\2b', '<'=>'\3c',
763        '>'=>'\3e', ';'=>'\3b', '\\'=>'\5c', '"'=>'\22', '#'=>'\23');
764    else
765      $replace = array('*'=>'\2a', '('=>'\28', ')'=>'\29', '\\'=>'\5c',
766        '/'=>'\2f');
767
768    return strtr($str, $replace);
769  }
770
771}
772
Note: See TracBrowser for help on using the repository browser.