1<?php
2/**
3 * Plain CAS authentication plugin
4 *
5 * @licence   GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author    Fabian Bircher
7 * @version   0.0.2
8 */
9
10// must be run within Dokuwiki
11if(!defined('DOKU_INC')) die();
12
13// Look for the phpCAS library in different places.
14if (!class_exists('phpCAS')) {
15  $phpcas_paths = [
16    DOKU_INC . 'vendor/jasig/phpcas/CAS.php',
17    DOKU_INC . 'phpCAS/CAS.php',
18    DOKU_PLUGIN . 'phpCAS/CAS.php',
19    DOKU_PLUGIN . 'authplaincas/phpCAS/CAS.php',
20  ];
21  foreach ($phpcas_paths as $file) {
22    if (file_exists($file)) {
23      require_once $file;
24      continue;
25    }
26  }
27}
28
29
30class auth_plugin_authplaincas extends DokuWiki_Auth_Plugin {
31  /** @var array user cache */
32  protected $users = null;
33
34  /** @var array filter pattern */
35  protected $_pattern = array();
36
37  var $_options = array();
38  var $_userInfo = array();
39
40  var $casuserfile = null;
41  var $localuserfile = NULL;
42
43  /**
44   * Constructor
45   *
46   * Carry out sanity checks to ensure the object is
47   * able to operate. Set capabilities.
48   *
49   * @author     Fabian Bircher <fabian@esn.org>
50   */
51  public function __construct() {
52    parent::__construct();
53    global $config_cascade;
54    global $conf;
55
56    // Show an error message instead of a php error when third party conditions
57    // are not met.
58    if (!class_exists('phpCAS')) {
59      msg("CAS err: phpCAS class not found.",-1);
60      $this->success = false;
61      return;
62    }
63    if(!function_exists('curl_init')) {
64      msg("CAS err: CURL php extension not found.",-1);
65      $this->success = false;
66      return;
67    }
68
69    // allow the preloading to configure other user files
70    if( isset($config_cascade['plaincasauth.users']) && isset($config_cascade['plaincasauth.users']['default']) ) {
71      $this->casuserfile = $config_cascade['plaincasauth.users']['default'];
72    }
73    else {
74      $this->casuserfile = DOKU_CONF . 'users.auth.plaincas.php';
75    }
76    $this->localuserfile = $config_cascade['plainauth.users']['default'];
77
78    // check the state of the file with the users and attempt to create it.
79    if (!@is_readable($this->casuserfile)) {
80      if(! fopen($this->casuserfile, 'w') ) {
81        msg("plainCAS: The CAS users file could not be opened.", -1);
82        $this->success = false;
83      }
84      elseif(!@is_readable($this->casuserfile)){
85        $this->success = false;
86      }
87      else{
88        $this->success = true;
89      }
90      // die( "bitch!" );
91    }
92    if ($this->success) {
93      // the users are not managable through the wiki
94      $this->cando['addUser']      = false;
95      $this->cando['delUser']      = true;
96      $this->cando['modLogin']     = false; //keep this false as CAS name is constant
97      $this->cando['modPass']      = false;
98      $this->cando['modName']      = false;
99      $this->cando['modMail']      = false;
100      $this->cando['modGroups']    = false;
101      $this->cando['getUsers']     = true;
102      $this->cando['getUserCount'] = true;
103
104      $this->cando['external'] = true;
105      $this->cando['login'] = true;
106      $this->cando['logout'] = true;
107      $this->cando['logoff'] = true;
108
109      // The default options which need to be set in the settins file.
110      $defaults = array(
111        // 'server' => 'galaxy.esn.org',
112        // 'rootcas' => '/cas',
113        // 'port' => '443',
114        // 'autologin' => false,
115        // 'handlelogoutrequest' => true,
116        // 'handlelogoutrequestTrustedHosts' => "galaxy.esn.org",
117        // 'caslogout' => false,
118        // 'minimalgroups' => NULL,
119        // 'customgroups' => false,
120        'logFile' => NULL,
121        'cert' => NULL,
122        'cacert' => NULL,
123        'debug' => false,
124        'settings_file' => DOKU_CONF . 'plaincas.settings.php',
125
126        'defaultgroup' => $conf['defaultgroup'],
127        'superuser' => $conf['superuser'],
128
129      );
130      $this->_options = (array) $conf['plugin']['authplaincas'] + $defaults;
131
132      // Options are set in the configuration and have a proper default value there.
133      $this->_options['server'] = $this->getConf('server');
134      $this->_options['rootcas'] = $this->getConf('rootcas');
135      $this->_options['port'] = $this->getConf('port');
136      $this->_options['samlValidate'] = $this->getConf('samlValidate');
137      $this->_options['handlelogoutrequest'] = $this->getConf('handlelogoutrequest');
138      $this->_options['handlelogoutrequestTrustedHosts'] = $this->getConf('handlelogoutrequestTrustedHosts');
139      $this->_options['minimalgroups'] = $this->getConf('minimalgroups');
140      $this->_options['localusers'] = $this->getConf('localusers');
141      // $this->_options['defaultgroup'] = $this->getConf('defaultgroup');
142      // $this->_options['superuser'] = $this->getConf('superuser');
143
144      // Configure support for autologin (gateway mode) and redirecting on logout for CAS server single-logout
145      if (preg_match("#(bot)|(slurp)|(netvibes)#i", $_SERVER['HTTP_USER_AGENT'])) {
146        // bots (like search engine indexers) should never be given 302 redirects
147        $this->_options['autologin'] = false;
148      } else {
149        // otherwise, fall back to the individual configuration parameters "autologin" and "caslogout"
150        $this->_options['autologin'] = $this->getConf('autologin');
151      }
152
153      // no local users at the moment
154      $this->_options['localusers'] = false;
155
156      if($this->_options['localusers'] && !@is_readable($this->localuserfile)) {
157        msg("plainCAS: The local users file is not readable.", -1);
158        $this->success = false;
159      }
160
161      if($this->_getOption("logFile")){ phpCAS::setDebug($this->_getOption("logFile"));}
162      //If $conf['auth']['cas']['logFile'] exist we start phpCAS in debug mode
163
164      $server_version  = CAS_VERSION_2_0;
165      if($this->_getOption("samlValidate")) {
166          $server_version = SAML_VERSION_1_1;
167      }
168      phpCAS::client($server_version, $this->_getOption('server'), (int) $this->_getOption('port'), $this->_getOption('rootcas'), false);
169      //False avoids phpCAS from taking care of sessions messing with the session ID. As Dokuwiki introduced new requirements to the session ID, logins will otherwise fail. This causes some PHP warnings on logout so should be updated once supported by phpCAS
170
171      // when using autologin (gateway mode), how often will autologin be attempted
172      if ($this->getConf('autologinonce', false)) {
173        // cache a failed autologin attempt "forever" until the current
174        // anonymous session expires or the user clicks the login button
175        phpCAS::setCacheTimesForAuthRecheck(-1);
176      } else {
177        // retry autologin every pageview, but cache a failed gateway attempt 1
178        // time, to avoid a second gateway attempt on the indexer.php page
179        // asset on the same pageview
180        phpCAS::setCacheTimesForAuthRecheck(1);
181      }
182
183      if($this->_getOption('cert')) {
184        phpCAS::setCasServerCert($this->_getOption('cert'));
185      }
186      elseif($this->_getOption('cacert')) {
187        phpCAS::setCasServerCACert($this->_getOption('cacert'));
188      }
189      else {
190        phpCAS::setNoCasServerValidation();
191      }
192
193      if($this->_getOption('handlelogoutrequest')) {
194        phpCAS::handleLogoutRequests(true, $this->_getOption('handlelogoutrequestTrustedHosts'));
195      }
196      else {
197        phpCAS::handleLogoutRequests(false);
198      }
199
200      if (@is_readable($this->_getOption('settings_file'))) {
201        include_once($this->_getOption('settings_file'));
202      }
203      else {
204        include_once(DOKU_PLUGIN . 'authplaincas/plaincas.settings.php');
205      }
206
207    }
208    //
209  }
210
211  function _getOption ($optionName)
212  {
213    if (isset($this->_options[$optionName])) {
214      switch( $optionName ){
215        case 'minimalgroups':
216        case 'superusers':
217          if (!$this->_options[$optionName]) {
218            return null;
219          }
220        case 'handlelogoutrequestTrustedHosts':
221          $arr = explode(',', $this->_options[$optionName]);
222          foreach( $arr as $key => $item ){
223            $arr[$key] = trim($item);
224          }
225          return $arr;
226          break;
227        default:
228          return $this->_options[$optionName];
229      }
230    }
231    return NULL;
232  }
233
234  /**
235   * Inherited canDo function, may be useful for localusers
236   *
237   * @param string $cap
238   * @return bool
239   */
240  public function canDo($cap) {
241    // We might need to do something to redefine the capabilities for local users
242    return parent::canDo($cap);
243  }
244
245  public function logIn() {
246    global $QUERY;
247    $login_url = DOKU_URL . 'doku.php?id=' . $QUERY;
248    phpCAS::setFixedServiceURL($login_url);
249    phpCAS::forceAuthentication();
250  }
251
252  public function logOff() {
253    global $QUERY;
254
255    if($this->_getOption('handlelogoutrequest')) { // dokuwiki + cas logout
256      @session_start();
257      session_destroy();
258      $logout_url = DOKU_URL . 'doku.php?id=' . $QUERY;
259      //hide warnings of not initalized session, cas session is killed anyway
260      @phpCAS::logoutWithRedirectService($logout_url);
261    }
262    else { // dokuwiki logout only
263      @session_start();
264      session_destroy();
265      unset($_SESSION['phpCAS']);
266    }
267  }
268
269function trustExternal ($user,$pass,$sticky=false)
270  {
271    global $USERINFO;
272    $sticky ? $sticky = true : $sticky = false; //sanity check
273
274    if (phpCAS::isAuthenticated() || ( $this->_getOption('autologin') && phpCAS::checkAuthentication() )) {
275
276      $remoteUser = phpCAS::getUser();
277      $this->_userInfo = $this->getUserData($remoteUser);
278      // msg(print_r($this->_userInfo,true) . __LINE__);
279
280      // Create the user if he doesn't exist
281      if ($this->_userInfo === false) {
282        $attributes = plaincas_user_attributes(phpCAS::getAttributes());
283        $this->_userInfo = array(
284          'uid' => $remoteUser,
285          'name' => $attributes['name'],
286          'mail' => $attributes['mail']
287        );
288
289        $this->_assembleGroups($remoteUser);
290        $this->_saveUserGroup();
291        $this->_saveUserInfo();
292
293        // msg(print_r($this->_userInfo,true) . __LINE__);
294
295        $USERINFO = $this->_userInfo;
296        $_SESSION[DOKU_COOKIE]['auth']['user'] = $USERINFO['uid'];
297        $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
298        $_SERVER['REMOTE_USER'] = $USERINFO['uid'];
299        return true;
300
301      // User exists, check for updates
302      } else {
303        $this->_userInfo['uid'] = $remoteUser;
304        $this->_assembleGroups($remoteUser);
305
306        $attributes = plaincas_user_attributes(phpCAS::getAttributes());
307
308        if ($this->_userInfo['grps'] != $this->_userInfo['tmp_grps'] ||
309            $attributes['name'] !== $this->_userInfo['name'] ||
310            $attributes['mail'] !== $this->_userInfo['mail']
311            ) {
312          //msg("new roles, email, or name");
313          $this->deleteUsers(array($remoteUser));
314          $this->_userInfo = array(
315            'uid' => $remoteUser,
316            'name' => $attributes['name'],
317            'mail' => $attributes['mail']
318          );
319          $this->_assembleGroups($remoteUser);
320          $this->_saveUserGroup();
321          $this->_saveUserInfo();
322        }
323
324        $USERINFO = $this->_userInfo;
325        $_SESSION[DOKU_COOKIE]['auth']['user'] = $USERINFO['uid'];
326        $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
327        $_SERVER['REMOTE_USER'] = $USERINFO['uid'];
328
329        return true;
330      }
331
332    }
333    // else{
334    // }
335
336    return false;
337  }
338
339
340  function _assembleGroups($remoteUser) {
341
342    $this->_userInfo['tmp_grps'] = array();
343
344    if (NULL !== $this->_getOption('defaultgroup')) {
345      $this->_addUserGroup($this->_getOption('defaultgroup'));
346    }
347
348    if ((NULL !== $this->_getOption('superusers')) &&
349          is_array($this->_getOption('superusers')) &&
350          in_array($remoteUser, $this->_getOption('superusers'))) {
351
352      $this->_addUserGroup($this->_getOption('admingroup'));
353    }
354
355    $this->_setCASGroups();
356    $this->_setCustomGroups($remoteUser);
357  }
358
359
360  function _setCASGroups ()
361  {
362    if( phpCAS::checkAuthentication() ) {
363      $attributes = plaincas_pattern_attributes(phpCAS::getAttributes());
364      if (!is_array($attributes)) {
365        $attributes = array($attributes);
366      }
367      $patterns = plaincas_group_patterns();
368      if (!empty($patterns)) {
369        foreach ($patterns as $role => $pattern) {
370          foreach ($attributes as $attribute) {
371            // An invalid pattern will generate a php warning and will not be considered.
372            if (preg_match($pattern, $attribute)) {
373              $this->_addUserGroup($role);
374            }
375          }
376        }
377      }
378      else {
379        foreach ($attributes as $attribute) {
380          // Add all attributes as groups
381          $this->_addUserGroup($attribute);
382        }
383      }
384    }
385  }
386
387
388  function _setCustomGroups ($userId)
389  {
390    // assert existence of function for backwards compatibility
391    if (!function_exists('plaincas_custom_groups')) {
392      return;
393    }
394    $customGroups = plaincas_custom_groups();
395
396    if (! is_array($customGroups) || empty($customGroups)) {
397      return;
398    }
399
400    foreach ($customGroups as $groupName => $groupMembers) {
401      if (! is_array($groupMembers) || empty($groupMembers)) {
402        continue;
403      }
404      if (in_array($userId, $groupMembers)) {
405        $this->_addUserGroup($groupName);
406      }
407    }
408
409  }
410
411
412  function _addUserGroup ($groupName)
413  {
414    if (! isset($this->_userInfo['tmp_grps'])) {
415      $this->_userInfo['tmp_grps'] = array();
416    }
417    if( !in_array(trim($groupName), $this->_userInfo['tmp_grps'])) {
418      $this->_userInfo['tmp_grps'][] = trim($groupName);
419    }
420
421  }
422
423  function _saveUserGroup()
424  {
425    $this->_userInfo['grps'] = $this->_userInfo['tmp_grps'];
426  }
427
428  function _minimalGroupCheck() {
429    $groups = $this->_getOption('minimalgroups');
430    if( ! $groups || empty($groups) ) {
431      return true;
432    }
433    elseif (count( array_intersect( $this->_userInfo['grps'], $groups  ) )) {
434      return true;
435    }
436    else {
437      return false;
438    }
439
440  }
441
442  function _saveUserInfo ()
443  {
444    $save = true;
445    if(!$this->_minimalGroupCheck()) {
446      $save = false;
447      $this->_userInfo['grps'] = array();
448      $this->_userInfo['tmp_grps'] = array();
449    }
450    global $USERINFO;
451
452    $USERINFO = $this->_userInfo;
453    $_SESSION[DOKU_COOKIE]['auth']['user'] = $USERINFO['uid'];
454    $_SESSION[DOKU_COOKIE]['auth']['info'] = $USERINFO;
455
456    // Despite setting the user into the session, DokuWiki still uses hard-coded REMOTE_USER variable
457    $_SERVER['REMOTE_USER'] = $USERINFO['uid'];
458
459    // user mustn't already exist
460    if ($this->getUserData($USERINFO['uid']) === false && $save) {
461      // prepare user line
462      $groups = join(',',$USERINFO['grps']);
463      $userline = join(':',array($USERINFO['uid'], $USERINFO['name'], $USERINFO['mail'], $groups))."\n";
464
465      if (io_saveFile($this->casuserfile,$userline,true)) {
466        $this->users[$USERINFO['uid']] = compact('name','mail','grps');
467      }else{
468        msg('The '.$this->casuserfile.' file is not writable. Please inform the Wiki-Admin',-1);
469      }
470    }
471    $this->_log($this->_userInfo);
472  }
473
474
475  function _log ($value)
476  {
477    if ($this->_getOption('debug')) {
478      error_log(print_r($value, true));
479      var_dump($value);
480    }
481  }
482
483  /**
484   * Modify user data
485   *
486   * @author  Chris Smith <chris@jalakai.co.uk>
487   * @param   $user      nick of the user to be changed
488   * @param   $changes   array of field/value pairs to be changed (password will be clear text)
489   * @return  bool
490   */
491  function modifyUser($user, $changes) {
492    global $conf;
493
494    // sanity checks, user must already exist and there must be something to change
495    if (($userinfo = $this->getUserData($user)) === false) return false;
496//      if (!(count($changes) == 1 and isset($changes['grps']))) return false;
497    if (!is_array($changes) || !count($changes)) return true;
498
499    foreach ($changes as $field => $value) {
500      $userinfo[$field] = $value;
501    }
502
503    $groups = join(',',$userinfo['grps']);
504    $userline = join(':',array($user, $userinfo['name'], $userinfo['mail'], $groups))."\n";
505
506    if (!$this->deleteUsers(array($user))) {
507      msg('Unable to modify user data. Please inform the Wiki-Admin',-1);
508      return false;
509    }
510
511    if (!io_saveFile($this->casuserfile,$userline,true)) {
512      msg('There was an error modifying the user data. Please inform the Wiki-Admin.',-1);
513      return false;
514    }
515
516    $this->users[$user] = $userinfo;
517    return true;
518  }
519
520  /**
521   *  Remove one or more users from the list of registered users
522   *
523   *  @author  Christopher Smith <chris@jalakai.co.uk>
524   *  @param   array  $users   array of users to be deleted
525   *  @return  int             the number of users deleted
526   */
527  function deleteUsers($users) {
528    if (!is_array($users) || empty($users)) return 0;
529
530    if ($this->users === null) $this->_loadUserData();
531
532    $deleted = array();
533    foreach ($users as $user) {
534      if (isset($this->users[$user])) $deleted[] = preg_quote($user,'/');
535    }
536
537    if (empty($deleted)) return 0;
538
539    $pattern = '/^('.join('|',$deleted).'):/';
540
541    if (io_deleteFromFile($this->casuserfile,$pattern,true)) {
542      foreach ($deleted as $user) unset($this->users[$user]);
543      return count($deleted);
544    }
545
546    // problem deleting, reload the user list and count the difference
547    $count = count($this->users);
548    $this->_loadUserData();
549    $count -= count($this->users);
550    return $count;
551  }
552
553
554  /**
555   * Return user info
556   *
557   * Returns info about the given user needs to contain
558   * at least these fields:
559   *
560   * name string  full name of the user
561   * mail string  email addres of the user
562   * grps array   list of groups the user is in
563   *
564   * @author  Andreas Gohr <andi@splitbrain.org>
565   */
566  function getUserData($user, $requireGroups=true) {
567    if($this->users === null) $this->_loadUserData();
568    return isset($this->users[$user]) ? $this->users[$user] : false;
569  }
570
571  /**
572   * Load all user data
573   *
574   * loads the user file into a datastructure
575   *
576   * @author  Andreas Gohr <andi@splitbrain.org>
577   * @author  Martin Kos <martin@kos.li>
578   */
579  function _loadUserData(){
580    $this->users = array();
581
582    if(!@file_exists($this->casuserfile)) return;
583
584    $lines = file($this->casuserfile);
585    foreach($lines as $line){
586      $line = preg_replace('/#.*$/','',$line); //ignore comments
587      $line = trim($line);
588      if(empty($line)) continue;
589
590      $row    = explode(":",$line,5);
591      $groups = explode(",",$row[3]);
592      // msg(print_r($row,true). __LINE__);
593
594      $this->users[$row[0]]['name'] = $row[1];
595      $this->users[$row[0]]['mail'] = $row[2];
596      $this->users[$row[0]]['grps'] = $groups;
597    }
598  }
599
600
601  /**
602   * Return a count of the number of user which meet $filter criteria
603   *
604   * @author  Chris Smith <chris@jalakai.co.uk>
605   */
606  function getUserCount($filter=array()) {
607
608    if($this->users === null) $this->_loadUserData();
609
610    if (!count($filter)) return count($this->users);
611
612    $count = 0;
613    $this->_constructPattern($filter);
614
615    foreach ($this->users as $user => $info) {
616      $count += $this->_filter($user, $info);
617    }
618
619    return $count;
620  }
621
622  /**
623   * Bulk retrieval of user data
624   *
625   * @author  Chris Smith <chris@jalakai.co.uk>
626   * @param   start     index of first user to be returned
627   * @param   limit     max number of users to be returned
628   * @param   filter    array of field/pattern pairs
629   * @return  array of userinfo (refer getUserData for internal userinfo details)
630   */
631  function retrieveUsers($start=0,$limit=0,$filter=array()) {
632    if ($this->users === null) $this->_loadUserData();
633
634    ksort($this->users);
635
636    $i = 0;
637    $count = 0;
638    $out = array();
639    $this->_constructPattern($filter);
640
641    foreach ($this->users as $user => $info) {
642      if ($this->_filter($user, $info)) {
643        if ($i >= $start) {
644          $out[$user] = $info;
645          $count++;
646          if (($limit > 0) && ($count >= $limit)) break;
647        }
648        $i++;
649      }
650    }
651
652    return $out;
653  }
654
655  function cleanUser($user) {
656    $user = str_replace('@', '_', $user);
657    $user = str_replace(':', '_', $user);
658    return $user;
659  }
660
661  function cleanGroup($group) {
662    return $group;
663  }
664
665  /**
666   * return 1 if $user + $info match $filter criteria, 0 otherwise
667   *
668   * @author   Chris Smith <chris@jalakai.co.uk>
669   */
670  function _filter($user, $info) {
671    // FIXME
672    foreach ($this->_pattern as $item => $pattern) {
673      if ($item == 'user') {
674        if (!preg_match($pattern, $user)) return 0;
675      } else if ($item == 'grps') {
676        if (!count(preg_grep($pattern, $info['grps']))) return 0;
677      } else {
678        if (!preg_match($pattern, $info[$item])) return 0;
679      }
680    }
681    return 1;
682  }
683
684  function _constructPattern($filter) {
685    $this->_pattern = array();
686    foreach ($filter as $item => $pattern) {
687//        $this->_pattern[$item] = '/'.preg_quote($pattern,"/").'/i';          // don't allow regex characters
688      $this->_pattern[$item] = '/'.str_replace('/','\/',$pattern).'/i';    // allow regex characters
689    }
690  }
691
692}
693