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