1<?php 2 3use dokuwiki\Extension\AuthPlugin; 4use dokuwiki\Logger; 5use dokuwiki\Utf8\Sort; 6 7/** 8 * Plaintext authentication backend 9 * 10 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 11 * @author Andreas Gohr <andi@splitbrain.org> 12 * @author Chris Smith <chris@jalakai.co.uk> 13 * @author Jan Schumann <js@schumann-it.com> 14 */ 15class auth_plugin_authplain extends AuthPlugin 16{ 17 /** @var array user cache */ 18 protected $users; 19 20 /** @var array filter pattern */ 21 protected $pattern = []; 22 23 /** @var bool safe version of preg_split */ 24 protected $pregsplit_safe = false; 25 26 /** 27 * Constructor 28 * 29 * Carry out sanity checks to ensure the object is 30 * able to operate. Set capabilities. 31 * 32 * @author Christopher Smith <chris@jalakai.co.uk> 33 */ 34 public function __construct() 35 { 36 parent::__construct(); 37 global $config_cascade; 38 39 if (!@is_readable($config_cascade['plainauth.users']['default'])) { 40 $this->success = false; 41 } else { 42 if (@is_writable($config_cascade['plainauth.users']['default'])) { 43 $this->cando['addUser'] = true; 44 $this->cando['delUser'] = true; 45 $this->cando['modLogin'] = true; 46 $this->cando['modPass'] = true; 47 $this->cando['modName'] = true; 48 $this->cando['modMail'] = true; 49 $this->cando['modGroups'] = true; 50 } 51 $this->cando['getUsers'] = true; 52 $this->cando['getUserCount'] = true; 53 $this->cando['getGroups'] = true; 54 } 55 } 56 57 /** 58 * Check user+password 59 * 60 * Checks if the given user exists and the given 61 * plaintext password is correct 62 * 63 * @author Andreas Gohr <andi@splitbrain.org> 64 * @param string $user 65 * @param string $pass 66 * @return bool 67 */ 68 public function checkPass($user, $pass) 69 { 70 $userinfo = $this->getUserData($user); 71 if ($userinfo === false) return false; 72 73 return auth_verifyPassword($pass, $this->users[$user]['pass']); 74 } 75 76 /** 77 * Return user info 78 * 79 * Returns info about the given user needs to contain 80 * at least these fields: 81 * 82 * name string full name of the user 83 * mail string email addres of the user 84 * grps array list of groups the user is in 85 * 86 * @author Andreas Gohr <andi@splitbrain.org> 87 * @param string $user 88 * @param bool $requireGroups (optional) ignored by this plugin, grps info always supplied 89 * @return array|false 90 */ 91 public function getUserData($user, $requireGroups = true) 92 { 93 if ($this->users === null) $this->loadUserData(); 94 return $this->users[$user] ?? false; 95 } 96 97 /** 98 * Creates a string suitable for saving as a line 99 * in the file database 100 * (delimiters escaped, etc.) 101 * 102 * @param string $user 103 * @param string $pass 104 * @param string $name 105 * @param string $mail 106 * @param array $grps list of groups the user is in 107 * @return string 108 */ 109 protected function createUserLine($user, $pass, $name, $mail, $grps) 110 { 111 $groups = implode(',', $grps); 112 $userline = [$user, $pass, $name, $mail, $groups]; 113 $userline = str_replace('\\', '\\\\', $userline); // escape \ as \\ 114 $userline = str_replace(':', '\\:', $userline); // escape : as \: 115 $userline = str_replace('#', '\\#', $userline); // escape # as \ 116 $userline = implode(':', $userline) . "\n"; 117 return $userline; 118 } 119 120 /** 121 * Create a new User 122 * 123 * Returns false if the user already exists, null when an error 124 * occurred and true if everything went well. 125 * 126 * The new user will be added to the default group by this 127 * function if grps are not specified (default behaviour). 128 * 129 * @author Andreas Gohr <andi@splitbrain.org> 130 * @author Chris Smith <chris@jalakai.co.uk> 131 * 132 * @param string $user 133 * @param string $pwd 134 * @param string $name 135 * @param string $mail 136 * @param array $grps 137 * @return bool|null|string 138 */ 139 public function createUser($user, $pwd, $name, $mail, $grps = null) 140 { 141 global $conf; 142 global $config_cascade; 143 144 // user mustn't already exist 145 if ($this->getUserData($user) !== false) { 146 msg($this->getLang('userexists'), -1); 147 return false; 148 } 149 150 $pass = auth_cryptPassword($pwd); 151 152 // set default group if no groups specified 153 if (!is_array($grps)) $grps = [$conf['defaultgroup']]; 154 155 // prepare user line 156 $userline = $this->createUserLine($user, $pass, $name, $mail, $grps); 157 158 if (!io_saveFile($config_cascade['plainauth.users']['default'], $userline, true)) { 159 msg($this->getLang('writefail'), -1); 160 return null; 161 } 162 163 $this->users[$user] = [ 164 'pass' => $pass, 165 'name' => $name, 166 'mail' => $mail, 167 'grps' => $grps 168 ]; 169 return $pwd; 170 } 171 172 /** 173 * Modify user data 174 * 175 * @author Chris Smith <chris@jalakai.co.uk> 176 * @param string $user nick of the user to be changed 177 * @param array $changes array of field/value pairs to be changed (password will be clear text) 178 * @return bool 179 */ 180 public function modifyUser($user, $changes) 181 { 182 global $ACT; 183 global $config_cascade; 184 185 // sanity checks, user must already exist and there must be something to change 186 if (($userinfo = $this->getUserData($user)) === false) { 187 msg($this->getLang('usernotexists'), -1); 188 return false; 189 } 190 191 // don't modify protected users 192 if (!empty($userinfo['protected'])) { 193 msg(sprintf($this->getLang('protected'), hsc($user)), -1); 194 return false; 195 } 196 197 if (!is_array($changes) || $changes === []) return true; 198 199 // update userinfo with new data, remembering to encrypt any password 200 $newuser = $user; 201 foreach ($changes as $field => $value) { 202 if ($field == 'user') { 203 $newuser = $value; 204 continue; 205 } 206 if ($field == 'pass') $value = auth_cryptPassword($value); 207 $userinfo[$field] = $value; 208 } 209 210 $userline = $this->createUserLine( 211 $newuser, 212 $userinfo['pass'], 213 $userinfo['name'], 214 $userinfo['mail'], 215 $userinfo['grps'] 216 ); 217 218 if (!io_replaceInFile($config_cascade['plainauth.users']['default'], '/^' . $user . ':/', $userline, true)) { 219 msg('There was an error modifying your user data. You may need to register again.', -1); 220 // FIXME, io functions should be fail-safe so existing data isn't lost 221 $ACT = 'register'; 222 return false; 223 } 224 225 if (isset($this->users[$user])) unset($this->users[$user]); 226 $this->users[$newuser] = $userinfo; 227 return true; 228 } 229 230 /** 231 * Remove one or more users from the list of registered users 232 * 233 * @author Christopher Smith <chris@jalakai.co.uk> 234 * @param array $users array of users to be deleted 235 * @return int the number of users deleted 236 */ 237 public function deleteUsers($users) 238 { 239 global $config_cascade; 240 241 if (!is_array($users) || $users === []) return 0; 242 243 if ($this->users === null) $this->loadUserData(); 244 245 $deleted = []; 246 foreach ($users as $user) { 247 // don't delete protected users 248 if (!empty($this->users[$user]['protected'])) { 249 msg(sprintf($this->getLang('protected'), hsc($user)), -1); 250 continue; 251 } 252 if (isset($this->users[$user])) $deleted[] = preg_quote($user, '/'); 253 } 254 255 if ($deleted === []) return 0; 256 257 $pattern = '/^(' . implode('|', $deleted) . '):/'; 258 if (!io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true)) { 259 msg($this->getLang('writefail'), -1); 260 return 0; 261 } 262 263 // reload the user list and count the difference 264 $count = count($this->users); 265 $this->loadUserData(); 266 $count -= count($this->users); 267 return $count; 268 } 269 270 /** 271 * Return a count of the number of user which meet $filter criteria 272 * 273 * @author Chris Smith <chris@jalakai.co.uk> 274 * 275 * @param array $filter 276 * @return int 277 */ 278 public function getUserCount($filter = []) 279 { 280 281 if ($this->users === null) $this->loadUserData(); 282 283 if ($filter === []) return count($this->users); 284 285 $count = 0; 286 $this->constructPattern($filter); 287 288 foreach ($this->users as $user => $info) { 289 $count += $this->filter($user, $info); 290 } 291 292 return $count; 293 } 294 295 /** 296 * Bulk retrieval of user data 297 * 298 * @author Chris Smith <chris@jalakai.co.uk> 299 * 300 * @param int $start index of first user to be returned 301 * @param int $limit max number of users to be returned 302 * @param array $filter array of field/pattern pairs 303 * @return array userinfo (refer getUserData for internal userinfo details) 304 */ 305 public function retrieveUsers($start = 0, $limit = 0, $filter = []) 306 { 307 308 if ($this->users === null) $this->loadUserData(); 309 310 Sort::ksort($this->users); 311 312 $i = 0; 313 $count = 0; 314 $out = []; 315 $this->constructPattern($filter); 316 317 foreach ($this->users as $user => $info) { 318 if ($this->filter($user, $info)) { 319 if ($i >= $start) { 320 $out[$user] = $info; 321 $count++; 322 if (($limit > 0) && ($count >= $limit)) break; 323 } 324 $i++; 325 } 326 } 327 328 return $out; 329 } 330 331 /** 332 * Retrieves groups. 333 * Loads complete user data into memory before searching for groups. 334 * 335 * @param int $start index of first group to be returned 336 * @param int $limit max number of groups to be returned 337 * @return array 338 */ 339 public function retrieveGroups($start = 0, $limit = 0) 340 { 341 $groups = []; 342 343 if ($this->users === null) $this->loadUserData(); 344 foreach ($this->users as $info) { 345 $groups = array_merge($groups, array_diff($info['grps'], $groups)); 346 } 347 Sort::ksort($groups); 348 349 if ($limit > 0) { 350 return array_splice($groups, $start, $limit); 351 } 352 return array_splice($groups, $start); 353 } 354 355 /** 356 * Only valid pageid's (no namespaces) for usernames 357 * 358 * @param string $user 359 * @return string 360 */ 361 public function cleanUser($user) 362 { 363 global $conf; 364 365 return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $user)); 366 } 367 368 /** 369 * Only valid pageid's (no namespaces) for groupnames 370 * 371 * @param string $group 372 * @return string 373 */ 374 public function cleanGroup($group) 375 { 376 global $conf; 377 378 return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $group)); 379 } 380 381 /** 382 * Load all user data 383 * 384 * loads the user file into a datastructure 385 * 386 * @author Andreas Gohr <andi@splitbrain.org> 387 */ 388 protected function loadUserData() 389 { 390 global $config_cascade; 391 392 $this->users = $this->readUserFile($config_cascade['plainauth.users']['default']); 393 394 // support protected users 395 if (!empty($config_cascade['plainauth.users']['protected'])) { 396 $protected = $this->readUserFile($config_cascade['plainauth.users']['protected']); 397 foreach (array_keys($protected) as $key) { 398 $protected[$key]['protected'] = true; 399 } 400 $this->users = array_merge($this->users, $protected); 401 } 402 } 403 404 /** 405 * Read user data from given file 406 * 407 * ignores non existing files 408 * 409 * @param string $file the file to load data from 410 * @return array 411 */ 412 protected function readUserFile($file) 413 { 414 $users = []; 415 if (!file_exists($file)) return $users; 416 417 $lines = file($file); 418 foreach ($lines as $line) { 419 $line = preg_replace('/(?<!\\\\)#.*$/', '', $line); //ignore comments (unless escaped) 420 $line = trim($line); 421 if (empty($line)) continue; 422 423 $row = $this->splitUserData($line); 424 $row = str_replace('\\:', ':', $row); 425 $row = str_replace('\\\\', '\\', $row); 426 $row = str_replace('\\#', '#', $row); 427 428 $groups = array_values(array_filter(explode(",", $row[4]))); 429 430 $users[$row[0]]['pass'] = $row[1]; 431 $users[$row[0]]['name'] = urldecode($row[2]); 432 $users[$row[0]]['mail'] = $row[3]; 433 $users[$row[0]]['grps'] = $groups; 434 } 435 return $users; 436 } 437 438 /** 439 * Get the user line split into it's parts 440 * 441 * @param string $line 442 * @return string[] 443 */ 444 protected function splitUserData($line) 445 { 446 $data = preg_split('/(?<![^\\\\]\\\\)\:/', $line, 5); // allow for : escaped as \: 447 if (count($data) < 5) { 448 $data = array_pad($data, 5, ''); 449 Logger::error('User line with less than 5 fields. Possibly corruption in your user file', $data); 450 } 451 return $data; 452 } 453 454 /** 455 * return true if $user + $info match $filter criteria, false otherwise 456 * 457 * @author Chris Smith <chris@jalakai.co.uk> 458 * 459 * @param string $user User login 460 * @param array $info User's userinfo array 461 * @return bool 462 */ 463 protected function filter($user, $info) 464 { 465 foreach ($this->pattern as $item => $pattern) { 466 if ($item == 'user') { 467 if (!preg_match($pattern, $user)) return false; 468 } elseif ($item == 'grps') { 469 if (!count(preg_grep($pattern, $info['grps']))) return false; 470 } elseif (!preg_match($pattern, $info[$item])) { 471 return false; 472 } 473 } 474 return true; 475 } 476 477 /** 478 * construct a filter pattern 479 * 480 * @param array $filter 481 */ 482 protected function constructPattern($filter) 483 { 484 $this->pattern = []; 485 foreach ($filter as $item => $pattern) { 486 $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters 487 } 488 } 489} 490