1<?php 2/** 3 * DokuWiki Plugin authyubikey (Auth Component) 4 * Plaintext authentication backend combined with Yubico's OTP 5 * 6 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 7 * @author Dirk Scheer <dirk@scheernet.de> 8 */ 9 10// This lib is developed by Yubico. 11// Take a look at https://developers.yubico.com/php-yubico/ 12require_once 'lib/Yubico.php'; 13 14// must be run within Dokuwiki 15if(!defined('DOKU_INC')) die(); 16 17/* 18 * Class auth_plugin_authyubikey simply extends the basic 19 * auth_plugin_authplain class definition by Andreas Gohr 20 * <andi@splitbrain.org>. 21 */ 22class auth_plugin_authyubikey extends auth_plugin_authplain { 23 /** 24 * Constructor 25 * 26 * Carry out sanity checks to ensure the object is 27 * able to operate. Set capabilities. 28 * 29 * @author Dirk Scheer <dirk@scheernet.de> 30 */ 31 public function __construct() { 32 parent::__construct(); 33 } 34 35 /** 36 * Check user+password 37 * 38 * Checks if the given user exists and the given 39 * plaintext password is correct 40 * 41 * @author Dirk Scheer <dirk@scheernet.de> 42 * @param string $user 43 * @param string $pass 44 * @return bool 45 */ 46 public function checkPass($user, $pass) { 47 global $INPUT; 48 global $config; 49 50 /* Get all defined users with their attributes */ 51 $userinfo = $this->getUserData($user); 52 if($userinfo === false) return false; 53 54 /* Check the given password */ 55 if(auth_verifyPassword($pass, $this->users[$user]['pass']) === false) return false; 56 57 /* If this function is called in another context as the login form; 58 * then checking of the password is enough. 59 * (I hope, this is not a security risc!!!) 60 */ 61 if($INPUT->str('do') !== 'login') { 62 return true; 63 } 64 65 /* Get the yubikey IDs of the user. If the user has no IDs, 66 * no further checking is needed for this user. 67 */ 68 $yubikeys = $this->users[$user]['yubi']; 69 if(count($yubikeys) === 0) return true; 70 71 /* Get the one-time password, the user has entered 72 * in the login form. From this OTP we have to extract the 73 * first 12 bytes. These bytes build the ID of the key, which 74 * is stored in the yubikey-mapping file. 75 */ 76 $otp = $INPUT->str('otp'); 77 $yid = substr($otp, 0, 12); 78 if(in_array($yid, $yubikeys) === false) return false; 79 80 /* A corresponding Yubikey ID was found, so we will check 81 * finally the entered OTP against the servers of Yubico. 82 */ 83 $yubi = new Auth_Yubico($this->getConf('yubico_client_id'), $this->getConf('yubico_secret_key')); 84 $auth = $yubi->verify($otp); 85 return (PEAR::isError($auth) ? false : true); 86 } 87 88 /** 89 * Modify user data 90 * 91 * @author Dirk Scheer <dirk@scheernet.de> 92 * @author Chris Smith <chris@jalakai.co.uk> 93 * @param string $user nick of the user to be changed 94 * @param array $changes array of field/value pairs to be changed (password will be clear text) 95 * @return bool 96 */ 97 public function modifyUser($user, $changes) { 98 global $ACT; 99 global $INPUT; 100 global $conf; 101 global $config_cascade; 102 103 // sanity checks, user must already exist and there must be something to change 104 if(($userinfo = $this->getUserData($user)) === false) return false; 105 if(!is_array($changes) || !count($changes)) return true; 106 107 // update userinfo with new data, remembering to encrypt any password 108 $newuser = $user; 109 foreach($changes as $field => $value) { 110 if($field == 'user') { 111 $newuser = $value; 112 continue; 113 } 114 if($field == 'pass') $value = auth_cryptPassword($value); 115 $userinfo[$field] = $value; 116 } 117 118 // Check all entered Yubikeys 119 $yubi = new Auth_Yubico($this->getConf('yubico_client_id'), $this->getConf('yubico_secret_key')); 120 $errors = array(); 121 $userinfo['yubi'] = array(); 122 for($i=0; $i < intval($this->getConf('yubico_maxkeys')); $i++) { 123 $otp = $INPUT->str('yubikeyid'.$i); 124 if($otp !== '') { 125 if($otp == $this->users[$user]['yubi'][$i]) { 126 array_push($userinfo['yubi'], substr($otp, 0, 12)); 127 } 128 else { 129 $auth = $yubi->verify($otp); 130 if(PEAR::isError($auth) && $auth != 'REPLAYED_OTP') { 131 if($this->getConf('yubico_maxkeys') == 1) { 132 array_push($errors, sprintf($this->getLang('yubikeyiderr'), substr($otp, 0, 12), $auth)); 133 } 134 else { 135 array_push($errors, sprintf($this->getLang('yubikeyidserr'), $i+1, substr($otp, 0, 12), $auth)); 136 } 137 } 138 else { 139 array_push($userinfo['yubi'], substr($otp, 0, 12)); 140 } 141 } 142 } 143 } 144 if(count($errors) > 0) { 145 foreach($errors as $error) { 146 $errtext .= $error . '<BR>'; 147 } 148 msg($errtext, -1); 149 return false; 150 } 151 152 $userline = $this->_createUserLine($newuser, $userinfo['pass'], $userinfo['name'], $userinfo['mail'], $userinfo['grps']); 153 154 if(!$this->deleteUsers(array($user))) { 155 msg($this->getLang('yubikeymodifyerr1'), -1); 156 return false; 157 } 158 159 if(!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) { 160 msg($this->getLang('yubikeymodifyerr2'), -1); 161 // FIXME, user has been deleted but not recreated, should force a logout and redirect to login page 162 $ACT = 'register'; 163 return false; 164 } 165 166 $yubiline = ''; 167 foreach($userinfo['yubi'] as $yubi) { 168 $yubiline .= $newuser . ':' . $yubi . "\n"; 169 } 170 if(!io_saveFile(DOKU_CONF . 'users.yubikeys.php', $yubiline, true)) { 171 msg($this->getLang('yubikeymodifyerr3'), -1); 172 return false; 173 } 174 175 $this->users[$newuser] = $userinfo; 176 return true; 177 } 178 179 /** 180 * Remove one or more users from the list of registered users 181 * 182 * @author Dirk Scheer <dirk@scheernet.de> 183 * @author Christopher Smith <chris@jalakai.co.uk> 184 * @param array $users array of users to be deleted 185 * @return int the number of users deleted 186 */ 187 public function deleteUsers($users) { 188 global $config_cascade; 189 190 if(!is_array($users) || empty($users)) return 0; 191 192 if($this->users === null) $this->_loadUserData(); 193 194 $deleted = array(); 195 foreach($users as $user) { 196 if(isset($this->users[$user])) $deleted[] = preg_quote($user, '/'); 197 } 198 199 if(empty($deleted)) return 0; 200 201 $pattern = '/^('.join('|', $deleted).'):/'; 202 io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true); 203 io_deleteFromFile(DOKU_CONF . 'users.yubikeys.php', $pattern, true); 204 205 // reload the user list and count the difference 206 $count = count($this->users); 207 $this->_loadUserData(); 208 $count -= count($this->users); 209 return $count; 210 } 211 212 /** 213 * Load all user data 214 * 215 * loads the user file into a datastructure 216 * 217 * @author Dirk Scheer <dirk@scheernet.de> 218 * @author Andreas Gohr <andi@splitbrain.org> 219 */ 220 protected function _loadUserData() { 221 global $config_cascade; 222 223 $this->users = array(); 224 225 if(!@file_exists($config_cascade['plainauth.users']['default'])) return; 226 227 $lines = file($config_cascade['plainauth.users']['default']); 228 foreach($lines as $line) { 229 $line = preg_replace('/#.*$/', '', $line); //ignore comments 230 $line = trim($line); 231 if(empty($line)) continue; 232 233 /* NB: preg_split can be deprecated/replaced with str_getcsv once dokuwiki is min php 5.3 */ 234 $row = $this->_splitUserData($line); 235 $row = str_replace('\\:', ':', $row); 236 $row = str_replace('\\\\', '\\', $row); 237 238 $groups = array_values(array_filter(explode(",", $row[4]))); 239 240 $this->users[$row[0]]['pass'] = $row[1]; 241 $this->users[$row[0]]['name'] = urldecode($row[2]); 242 $this->users[$row[0]]['mail'] = $row[3]; 243 $this->users[$row[0]]['grps'] = $groups; 244 $this->users[$row[0]]['yubi'] = array(); 245 } 246 247 /* Read the mapping table for Yubikeys */ 248 $lines = file(DOKU_CONF . 'users.yubikeys.php'); 249 foreach($lines as $line) { 250 $line = preg_replace('/#.*$/', '', $line); //ignore comments 251 $line = trim($line); 252 if(empty($line)) continue; 253 254 list($user, $yubikey) = explode(':', $line); 255 if(isset($this->users[$user])) { 256 array_push($this->users[$user]['yubi'], $yubikey); 257 } 258 } 259 } 260} 261