1<?php
2
3/**
4 * Authentication backend using Shibboleth
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Dominique Launay <dominique.launay AT cru.fr>
7 * @author     Olivier Salaun <olivier.salaun AT cru.fr>
8 * @author     Yoann Lecuyer <yoann.lecuyer AT cru.fr>
9 * Version: 1.2
10 * last modified: 2009-11-27
11 *
12 * Work based on :
13 *    ssl authentication backend:
14 *       @author     Dominique Launay <dominique.launay AT cru.fr>
15 *    Sympa Soap server authentication backend:
16 *       @author     David Pepin<sympa-authors AT cru.fr>
17 *
18 * The nusoap library is required
19 *
20 **/
21
22
23define('DOKU_AUTH', dirname(__FILE__));
24require_once(DOKU_AUTH.'/basic.class.php');
25define('AUTH_USERFILE',DOKU_CONF.'users.auth.php');
26
27class auth_shibboleth extends auth_basic {
28  var $users = null;
29  var $sympaDefaultGroup = null;
30  var $sympaSoapService = null;
31  var $soap_client = null;
32  var $log_file = null;
33
34  /**
35   *
36   * Constructor
37   *
38   * @author  Yoann Lecuyer <yoann.lecuyer AT cru.fr>
39   *
40   **/
41  // This function is run for every web access to the wiki
42  // Most of the Shibboleth authentication code is run here because we never run the do_login action
43  // With Shibboleth, dokuwiki never collects user password
44  function auth_shibboleth() {
45    global $conf;
46    //msg('auth_shibboleth');
47    $this->cando['external'] = true;
48    //$this->cando['getGroups']    = true;
49
50
51    if (method_exists($this, 'auth_basic')){
52      parent::auth_basic();
53    }
54    // check if the server configuration has correctly been done
55    if (!isset($conf['plugin']['shibbolethauth']['shibbolethEmailAttribute']) || empty($conf['plugin']['shibbolethauth']['shibbolethEmailAttribute'])) {
56      $this->success = false;
57      return;
58    }
59    if (isset($conf['plugin']['shibbolethauth']['useSympa']) && $conf['plugin']['shibbolethauth']['useSympa'] == 1 && empty($conf['plugin']['shibbolethauth']['sympaSoapService'])) {
60      $this->success = false;
61      return;
62    }
63    if(!isset($conf['plugin']['shibbolethauth']['shibbolethLoginURL']) || empty($conf['plugin']['shibbolethauth']['shibbolethLoginURL'])) {
64      $this->success = false;
65      return;
66    }
67    if(!isset($conf['plugin']['shibbolethauth']['shibbolethLogoutURL']) || empty($conf['plugin']['shibbolethauth']['shibbolethLogoutURL'])) {
68      $this->success = false;
69      return;
70    }
71
72    if (isset($conf['defaultgroup'])) {
73      $this->sympaDefaultGroup = $conf['defaultgroup'];
74    }
75
76
77    $this->success = true;
78    return;
79  }
80
81  /**
82   * Do all authentication [ OPTIONAL ]
83   *
84   * Set $this->cando['external'] = true when implemented
85   *
86   * If this function is implemented it will be used to
87   * authenticate a user - all other DokuWiki internals
88   * will not be used for authenticating, thus
89   * implementing the functions below becomes optional.
90   *
91   * The function can be used to authenticate against third
92   * party cookies or Apache auth mechanisms and replaces
93   * the auth_login() function
94   *
95   * The function will be called with or without a set
96   * username. If the Username is given it was called
97   * from the login form and the given credentials might
98   * need to be checked. If no username was given it
99   * the function needs to check if the user is logged in
100   * by other means (cookie, environment).
101   *
102   * The function needs to set some globals needed by
103   * DokuWiki like auth_login() does.
104   *
105   * @see auth_login()
106   * @author  Andreas Gohr <andi@splitbrain.org>
107   * @author  Olivier Salaün <olivier.salaun AT cru.fr>
108   * @author  Dominique Launay <dominique.launay AT cru.fr>
109   *
110   * @param   string  $user    Username
111   * @param   string  $pass    Cleartext Password
112   * @param   bool    $sticky  Cookie should not expire
113   * @return  bool             true on successful auth
114   */
115  function trustExternal($user,$pass,$sticky=false){
116    //msg('trust_external');
117    // some example:
118
119    global $USERINFO;
120    global $conf;
121    $sticky ? $sticky = true : $sticky = false; //sanity check
122    // do the checking here
123    //see if we got cookie
124    if(isset($_SESSION['DW_SHIB'])){
125      // case logout
126      if($_REQUEST['do'] == 'logout'){
127	unset($_SESSION['DW_SHIB']);
128	unset($_SESSION[DOKU_COOKIE]['auth']['user']);
129	unset($_SESSION[DOKU_COOKIE]['auth']['pass']);
130	unset($_SESSION[DOKU_COOKIE]['auth']['info']);
131	error_log('shibbolethauth : authenticated user redirected for logout to '.$conf['plugin']['shibbolethauth']['shibbolethLogoutURL']);
132	header("Location: ".$conf['plugin']['shibbolethauth']['shibbolethLogoutURL']."?return=".urlencode($_SERVER['HTTP_REFERER']));
133	exit;
134      }else{
135	// fill vars with cookie vars
136	$_SESSION[DOKU_COOKIE]['auth']['user'] = $user = $_SESSION['DW_SHIB']['user'];
137	$_SESSION[DOKU_COOKIE]['auth']['pass'] = $pass = $_SESSION['DW_SHIB']['pass'];
138       	$_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO = $_SESSION['DW_SHIB']['info'];
139	$_SERVER['REMOTE_USER'] =  $_SESSION['DW_SHIB']['user'];
140	return true;
141      }
142    }else{
143      // save the Email done by a Shibboleth attribute if this attribute is set
144      // we need to check if the identityProvider attribute is set, otherwise we can't distinguish
145      // normal unauthenticated access from authenticated access with a missing email address
146      if(isset($_SERVER[$conf['plugin']['shibbolethauth']['shibbolethEmailAttribute']]) && $_SERVER[$conf['plugin']['shibbolethauth']['shibbolethEmailAttribute']]!=""){
147	// if user wants to log out
148	if($_REQUEST['do'] == 'logout'){
149	  unset($_SESSION['DW_SHIB']);
150	  error_log('shibbolethauth : authenticated user redirected for logout to '.$conf['plugin']['shibbolethauth']['shibbolethLogoutURL']);
151	  header("Location: ".$conf['plugin']['shibbolethauth']['shibbolethLogoutURL']."?return=".urlencode($_SERVER['HTTP_REFERER']));
152	  exit;
153	}else{
154	  //attributes set fill convenient variables
155	  $USERINFO['name'] = $user = $_SERVER[$conf['plugin']['shibbolethauth']['shibbolethEmailAttribute']];
156	  $USERINFO['mail'] = $mail = $_SERVER[$conf['plugin']['shibbolethauth']['shibbolethEmailAttribute']];
157
158
159	  // load the groups of users contained on the file users.auth.php
160	  if($this->users === null) $this->_loadUserData();
161	  // groups the person belongs to
162	  $grps = array();
163	  // Test if $user is an email or not. If it is not, it is an user id
164	  if(strpbrk($user, '@') != false){
165	    // We are in a Shibboteth context, and we get the datas for the autorization
166	    // Test if we use the autorisation with Sympa server
167	    $sympaIsOK = true;
168	    if(isset($conf['plugin']['shibbolethauth']['useSympa']) && $conf['plugin']['shibbolethauth']['useSympa'] == 1){
169	      // Get the groups list on the Sympa server
170	      //create soap client
171	      try{
172		$this->soap_client = new SoapClient($conf['plugin']['shibbolethauth']['sympaSoapService']);
173	      }catch(SoapFault $fault){
174		$sympaIsOK =  false;
175		error_log("shibbolethauth : unable to establish a SOAP connection with ".$conf['plugin']['shibbolethauth']['sympaSoapService']);
176		msg("No valid SOAP server",0);
177	      }
178	      // get the groups list
179	      if($sympaIsOK){
180		try{
181		  $res = $this->soap_client->authenticateRemoteAppAndRun($conf['plugin']['shibbolethauth']['sympaApplicationId'],$conf['plugin']['shibbolethauth']['sympaApplicationPwd'], 'USER_EMAIL='.$user,'complexWhich');
182		}catch(SoapFault $fault){
183		  $sympaIsOK =  false;
184		  msg("dokuwiki groups only",0);
185		  error_log("shibbolethauth : unable to authenticate on ".$conf['plugin']['shibbolethauth']['sympaSoapService']);
186		}
187		if($sympaIsOK){
188		  if (isset($res) && gettype($res) == 'array') {
189		    foreach ($res as $list) {
190		      if (empty($grps[0])) {
191			$grps[0] = $list->listAddress;
192		      }else{
193			array_unshift($grps,$list->listAddress);
194		      }
195		      if($list->isOwner == 'true'){
196			$listrequest = preg_replace('/@/','-request@',$list->listAddress);
197			array_unshift($grps,$listrequest);
198		      }
199		      if ($list->isEditor == 'true') {
200			$listeditor = preg_replace('/@/','-editor@',$list->listAddress);
201			array_unshift($grps,$listeditor);
202		      }
203		    }
204		  }
205		}
206	      }
207	    }
208	    // Collect the DokuWiki's data with the Sympa's data (if we are in Sympa context)
209	    $users = $this->setUserGroups($mail,$grps);
210	  }
211	  // set the globals if authed
212	  $USERINFO['grps'] = $users['grps'];
213	  $_SERVER['REMOTE_USER'] = $user;
214	  $_SESSION[DOKU_COOKIE]['auth']['user'] = $user;
215	  $_SESSION[DOKU_COOKIE]['auth']['pass'] = $user;
216	  $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
217	  $_SESSION['DW_SHIB']['user']= $user;
218	  $_SESSION['DW_SHIB']['pass']= $user;
219	  $_SESSION['DW_SHIB']['info'] = $USERINFO;
220	  error_log('shibbolethauth : authenticated user');
221	  return true;
222	}
223	// if this attribute is not set, we log off the system
224      }else{
225	if($_REQUEST['do'] == 'login'){
226	  error_log('shibbolethauth: redirect user for login to '.$conf['plugin']['shibbolethauth']['shibbolethLoginURL']);
227	  header("Location: ".$conf['plugin']['shibbolethauth']['shibbolethLoginURL']."?target=".urlencode($_SERVER['HTTP_REFERER']));
228	  exit;
229	}else{
230	  error_log('shibbolethauth: no email address to log');
231	  unset($_SESSION[DOKU_COOKIE]['auth']['user']);
232	  unset($_SESSION[DOKU_COOKIE]['auth']['pass']);
233	  unset($_SESSION[DOKU_COOKIE]['auth']['info']);
234	  return false;
235	}
236      }
237    }
238  }
239
240
241  /**
242   * Checks if the Email attibute of Shibboleth is correctly set
243   *
244   * @author  Yoann Lecuyer <yoann.lecuyer AT cru.fr>
245   *
246   **/
247
248  function checkPass($user,$pass){
249  }
250
251  /**
252   * Return user info
253   *
254   * Accessing to sympa is done through the nusoap library
255   * for the autorization part
256   *
257   * Reading the user's group on DokuWiki's files
258   * for the user manager part
259   *
260   * The returned field are :
261   *
262   * name string  full name of the user
263   * mail string  email addres of the user
264   * groups array   list of groups the user is in
265   *
266   * @author  Yoann Lecuyer <yoann.lecuyer AT cru.fr>
267   *
268   **/
269
270  function getUserData( $user ) {
271    $userData = false;
272    $userData['name'] = $user;
273    $userData['mail'] = $user;
274    $userData['grps'] = $this->getUserGroups($user);
275    return $userData;
276  }
277
278  /**
279   * Return user groups
280   *
281   **/
282
283  function getUserGroups($user) {
284    global $conf;
285    $mail=$user;
286    // load the groups of users contained on the file users.auth.php
287    if($this->users === null) $this->_loadUserData();
288    // groups the person belongs to
289    $grps = array();
290    // Test if $user is an email or not. If it is not, it is an user id
291    if(strpbrk($user, '@') != false){
292      // We are in a Shibboteth context, and we get the datas for the autorization
293      // Test if we use the autorisation with Sympa server
294      $sympaIsOK = true;
295      if(isset($conf['plugin']['shibbolethauth']['useSympa']) && $conf['plugin']['shibbolethauth']['useSympa'] == 1){
296	// Get the groups list on the Sympa server
297	//create soap client
298	try{
299	  $this->soap_client = new SoapClient($conf['plugin']['shibbolethauth']['sympaSoapService']);
300	}catch(SoapFault $fault){
301	  $sympaIsOK =  false;
302	  error_log("shibbolethauth : unable to establish a SOAP connection with ".$conf['plugin']['shibbolethauth']['sympaSoapService']);
303	}
304	// get the groups list
305	if($sympaIsOK){
306	  try{
307	    $res = $this->soap_client->authenticateRemoteAppAndRun($conf['plugin']['shibbolethauth']['sympaApplicationId'],$conf['plugin']['shibbolethauth']['sympaApplicationPwd'], 'USER_EMAIL='.$user,'complexWhich');
308	  }catch(SoapFault $fault){
309	    $sympaIsOK =  false;
310	    msg("dokuwiki groups only",0);
311	    error_log("shibbolethauth : unable to authenticate on ".$conf['plugin']['shibbolethauth']['sympaSoapService']);
312	  }
313	  if($sympaIsOK){
314	  if (isset($res) && gettype($res) == 'array') {
315	      foreach ($res as $list) {
316		if (empty($grps[0])) {
317		  $grps[0] = $list->listAddress;
318		}else{
319		  array_unshift($grps,$list->listAddress);
320		}
321		if($list->isOwner == 'true'){
322		  $listrequest = preg_replace('/@/','-request@',$list->listAddress);
323		  array_unshift($grps,$listrequest);
324		}
325		if ($list->isEditor == 'true') {
326		  $listeditor = preg_replace('/@/','-editor@',$list->listAddress);
327		  array_unshift($grps,$listeditor);
328		}
329	      }
330	    }
331	  }
332	}
333      }
334
335      // Collect the DokuWiki's data with the Sympa's data (if we are in Sympa context)
336      $users = $this->setUserGroups($mail,$grps);
337    }
338
339
340    // set the globals if authed
341    return $users['grps'];
342  }
343
344
345  /**
346   * Set the DokuWiki's groups and the Sympa's groups together
347   *
348   * @author  Yoann Lecuyer <yoann.lecuyer AT cru.fr>
349   *
350   **/
351
352  function setUserGroups($mail,$grps){
353    foreach ($this->users as $user){
354      if($user['mail'] == $mail){
355        // Add or set the groups of the user
356        if(!empty($grps[0])){
357          foreach($grps as $group){
358            array_unshift($user['grps'],$group);
359          }
360        }
361        // Verify if the table contains the sympaDefaultGroup value
362        $defaultgroupset = false;
363        foreach ($user['grps'] as $group){
364          if($group == $this->sympaDefaultGroup) $defaultgroupset = true;
365        }
366        // Put the sympaDefaultGroup value if it is not on the table
367        if(!$defaultgroupset) array_unshift($user['grps'],$this->sympaDefaultGroup);
368        return $user;
369      }
370    }
371    // the mail is not on the table of DokuWiki's users, we create an other user
372
373    // Verify if the table contains the sympaDefaultGroup value
374    $defaultgrpset = false;
375    foreach ($grps as $grp){
376      if($grp == $this->sympaDefaultGroup) $defaultgrpset = true;
377    }
378    // Put the sympaDefaultGroup value if it is not on the table
379    if(!$defaultgrpset) array_unshift($grps,$this->sympaDefaultGroup);
380
381    $this->users[$mail]['name'] = $mail;
382    $this->users[$mail]['mail'] = $mail;
383    $this->users[$mail]['grps'] = $grps;
384    return $this->users[$mail];
385  }
386
387  /**
388   * Logoff user :
389   *
390   * everything that was about the session is unset
391   *
392   * @author  Yoann Lecuyer <yoann.lecuyer AT cru.fr>
393   *
394   **/
395
396  function logOff() {
397    if(isset($_SESSION[DOKU_COOKIE]['auth']['info'])) {
398      unset($_SESSION[DOKU_COOKIE]['auth']['info']);
399    }
400    unset($this->soap_client);
401    unset($_REQUEST['u']);
402    return;
403  }
404
405  function server() {
406    return($this->sympaSoapService);
407  }
408
409  /**
410   * Create a new User
411   *
412   * Returns false if the user already exists, null when an error
413   * occured and true if everything went well.
414   *
415   * The new user will be added to the default group by this
416   * function if grps are not specified (default behaviour).
417   *
418   * @author  Andreas Gohr <andi@splitbrain.org>
419   * @author  Chris Smith <chris@jalakai.co.uk>
420   */
421  function createUser($user,$pwd,$name,$mail,$grps=null){
422    global $conf;
423
424    // user mustn't already exist
425    if ($this->getUserData($user) !== false) return false;
426
427    $pass = auth_cryptPassword($pwd);
428    $name = $user;
429    // set default group if no groups specified
430    if (!is_array($grps)) $grps = array($conf['defaultgroup']);
431
432    // prepare user line
433    $groups = join(',',$grps);
434    $userline = join(':',array($user,$pass,$name,$mail,$groups))."\n";
435
436    if (io_saveFile(AUTH_USERFILE,$userline,true)) {
437      $this->users[$user] = compact('pass','name','mail','grps');
438      return $pass;
439    }
440
441    msg('The '.AUTH_USERFILE.' file is not writable. Please inform the Wiki-Admin',-1);
442    return null;
443  }
444
445  /**
446   * Modify user data
447   *
448   * @author  Chris Smith <chris@jalakai.co.uk>
449   * @param   $user      nick of the user to be changed
450   * @param   $changes   array of field/value pairs to be changed (password will be clear text)
451   * @return  bool
452   */
453
454  function modifyUser($user, $changes) {
455    global $conf;
456    global $ACT;
457    global $INFO;
458
459    // sanity checks, user must already exist and there must be something to change
460    if (($userinfo = $this->getUserData($user)) === false) return false;
461    if (!is_array($changes) || !count($changes)) return true;
462
463    // update userinfo with new data, remembering to encrypt any password
464    $newuser = $user;
465    foreach ($changes as $field => $value) {
466      if ($field == 'user') {
467        $newuser = $value;
468        continue;
469      }
470      if ($field == 'pass') $value = auth_cryptPassword($value);
471      $userinfo[$field] = $value;
472    }
473
474    $groups = join(',',$userinfo['grps']);
475    $userline = join(':',array($newuser, $userinfo['pass'], $userinfo['name'], $userinfo['mail'], $groups))."\n";
476
477    if (!$this->deleteUsers(array($user))) {
478      msg('Unable to modify user data. Please inform the Wiki-Admin',-1);
479      return false;
480    }
481
482    if (!io_saveFile(AUTH_USERFILE,$userline,true)) {
483      msg('There was an error modifying your user data. You should register again.',-1);
484      // FIXME, user has been deleted but not recreated, should force a logout and redirect to login page
485      $ACT == 'register';
486      return false;
487    }
488
489    $this->users[$newuser] = $userinfo;
490    return true;
491  }
492
493  /**
494   *  Remove one or more users from the list of registered users
495   *
496   *  @author  Christopher Smith <chris@jalakai.co.uk>
497   *  @param   array  $users   array of users to be deleted
498   *  @return  int             the number of users deleted
499   */
500
501  function deleteUsers($users) {
502
503    if (!is_array($users) || empty($users)) return 0;
504
505    if ($this->users === null) $this->_loadUserData();
506
507    $deleted = array();
508    foreach ($users as $user) {
509      if (isset($this->users[$user])) $deleted[] = preg_quote($user,'/');
510    }
511
512    if (empty($deleted)) return 0;
513
514    $pattern = '/^('.join('|',$deleted).'):/';
515
516    if (io_deleteFromFile(AUTH_USERFILE,$pattern,true)) {
517      foreach ($deleted as $user) unset($this->users[$user]);
518      return count($deleted);
519    }
520
521    // problem deleting, reload the user list and count the difference
522    $count = count($this->users());
523    $this->_loadUserData();
524    $count -= $count($this->users());
525    return $count;
526  }
527
528  /**
529   * Return a count of the number of user which meet $filter criteria
530   *
531   * @author  Chris Smith <chris@jalakai.co.uk>
532   */
533
534  function getUserCount($filter=array()) {
535
536    if($this->users === null) $this->_loadUserData();
537
538    if (!count($filter)) return count($this->users);
539
540    $count = 0;
541    $this->_constructPattern($filter);
542
543    foreach ($this->users as $user => $info) {
544      $count += $this->_filter($user, $info);
545    }
546
547    return $count;
548  }
549
550  /**
551   * Bulk retrieval of user data
552   *
553   * @author  Chris Smith <chris@jalakai.co.uk>
554   * @param   start     index of first user to be returned
555   * @param   limit     max number of users to be returned
556   * @param   filter    array of field/pattern pairs
557   * @return  array of userinfo (refer getUserData for internal userinfo details)
558   */
559  function retrieveUsers($start=0,$limit=0,$filter=array()) {
560
561    if ($this->users === null) $this->_loadUserData();
562
563    ksort($this->users);
564
565    $i = 0;
566    $count = 0;
567    $out = array();
568    $this->_constructPattern($filter);
569
570    foreach ($this->users as $user => $info) {
571      if ($this->_filter($user, $info)) {
572        if ($i >= $start) {
573          $out[$user] = $info;
574          $count++;
575          if (($limit > 0) && ($count >= $limit)) break;
576        }
577        $i++;
578      }
579    }
580
581    return $out;
582  }
583
584  /**
585   * Load all user data
586   *
587   * loads the user file into a datastructure
588   *
589   * @author  Andreas Gohr <andi@splitbrain.org>
590   */
591
592  function _loadUserData(){
593    $this->users = array();
594
595    if(!@file_exists(AUTH_USERFILE)) return;
596
597    $lines = file(AUTH_USERFILE);
598    foreach($lines as $line){
599      $line = preg_replace('/#.*$/','',$line); //ignore comments
600      $line = trim($line);
601      if(empty($line)) continue;
602
603      $row    = split(":",$line,5);
604      $groups = split(",",$row[4]);
605      $this->users[$row[0]]['pass'] = $row[1];
606      $this->users[$row[0]]['name'] = urldecode($row[2]);
607      $this->users[$row[0]]['mail'] = $row[3];
608      $this->users[$row[0]]['grps'] = $groups;
609    }
610  }
611
612  /**
613   * return 1 if $user + $info match $filter criteria, 0 otherwise
614   *
615   * @author   Chris Smith <chris@jalakai.co.uk>
616   */
617
618  function _filter($user, $info) {
619    // FIXME
620    foreach ($this->_pattern as $item => $pattern) {
621      if ($item == 'user') {
622        if (!preg_match($pattern, $user)) return 0;
623      } else if ($item == 'grps') {
624        if (!count(preg_grep($pattern, $info['grps']))) return 0;
625      } else {
626        if (!preg_match($pattern, $info[$item])) return 0;
627      }
628    }
629    return 1;
630  }
631
632  function _constructPattern($filter) {
633    $this->_pattern = array();
634    foreach ($filter as $item => $pattern) {
635//      $this->_pattern[$item] = '/'.preg_quote($pattern,"/").'/';          // don't allow regex characters
636      $this->_pattern[$item] = '/'.str_replace('/','\/',$pattern).'/';    // allow regex characters
637    }
638  }
639}
640