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 ( 223 !io_replaceInFile( 224 $config_cascade['plainauth.users']['default'], 225 '/^' . preg_quote($user, '/') . ':/', 226 $userline, 227 true 228 ) 229 ) { 230 msg('There was an error modifying your user data. You may need to register again.', -1); 231 // FIXME, io functions should be fail-safe so existing data isn't lost 232 $ACT = 'register'; 233 return false; 234 } 235 236 if (isset($this->users[$user])) unset($this->users[$user]); 237 $this->users[$newuser] = $userinfo; 238 return true; 239 } 240 241 /** 242 * Remove one or more users from the list of registered users 243 * 244 * @author Christopher Smith <chris@jalakai.co.uk> 245 * @param array $users array of users to be deleted 246 * @return int the number of users deleted 247 */ 248 public function deleteUsers($users) 249 { 250 global $config_cascade; 251 252 if (!is_array($users) || $users === []) return 0; 253 254 if ($this->users === null) $this->loadUserData(); 255 256 $deleted = []; 257 foreach ($users as $user) { 258 // don't delete protected users 259 if (!empty($this->users[$user]['protected'])) { 260 msg(sprintf($this->getLang('protected'), hsc($user)), -1); 261 continue; 262 } 263 if (isset($this->users[$user])) $deleted[] = preg_quote($user, '/'); 264 } 265 266 if ($deleted === []) return 0; 267 268 $pattern = '/^(' . implode('|', $deleted) . '):/'; 269 if (!io_deleteFromFile($config_cascade['plainauth.users']['default'], $pattern, true)) { 270 msg($this->getLang('writefail'), -1); 271 return 0; 272 } 273 274 // reload the user list and count the difference 275 $count = count($this->users); 276 $this->loadUserData(); 277 $count -= count($this->users); 278 return $count; 279 } 280 281 /** 282 * Return a count of the number of user which meet $filter criteria 283 * 284 * @author Chris Smith <chris@jalakai.co.uk> 285 * 286 * @param array $filter 287 * @return int 288 */ 289 public function getUserCount($filter = []) 290 { 291 292 if ($this->users === null) $this->loadUserData(); 293 294 if ($filter === []) return count($this->users); 295 296 $count = 0; 297 $this->constructPattern($filter); 298 299 foreach ($this->users as $user => $info) { 300 $count += $this->filter($user, $info); 301 } 302 303 return $count; 304 } 305 306 /** 307 * Bulk retrieval of user data 308 * 309 * @author Chris Smith <chris@jalakai.co.uk> 310 * 311 * @param int $start index of first user to be returned 312 * @param int $limit max number of users to be returned 313 * @param array $filter array of field/pattern pairs 314 * @return array userinfo (refer getUserData for internal userinfo details) 315 */ 316 public function retrieveUsers($start = 0, $limit = 0, $filter = []) 317 { 318 319 if ($this->users === null) $this->loadUserData(); 320 321 Sort::ksort($this->users); 322 323 $i = 0; 324 $count = 0; 325 $out = []; 326 $this->constructPattern($filter); 327 328 foreach ($this->users as $user => $info) { 329 if ($this->filter($user, $info)) { 330 if ($i >= $start) { 331 $out[$user] = $info; 332 $count++; 333 if (($limit > 0) && ($count >= $limit)) break; 334 } 335 $i++; 336 } 337 } 338 339 return $out; 340 } 341 342 /** 343 * Retrieves groups. 344 * Loads complete user data into memory before searching for groups. 345 * 346 * @param int $start index of first group to be returned 347 * @param int $limit max number of groups to be returned 348 * @return array 349 */ 350 public function retrieveGroups($start = 0, $limit = 0) 351 { 352 $groups = []; 353 354 if ($this->users === null) $this->loadUserData(); 355 foreach ($this->users as $info) { 356 $groups = array_merge($groups, array_diff($info['grps'], $groups)); 357 } 358 Sort::ksort($groups); 359 360 if ($limit > 0) { 361 return array_splice($groups, $start, $limit); 362 } 363 return array_splice($groups, $start); 364 } 365 366 /** 367 * Only valid pageid's (no namespaces) for usernames 368 * 369 * @param string $user 370 * @return string 371 */ 372 public function cleanUser($user) 373 { 374 global $conf; 375 376 return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $user)); 377 } 378 379 /** 380 * Only valid pageid's (no namespaces) for groupnames 381 * 382 * @param string $group 383 * @return string 384 */ 385 public function cleanGroup($group) 386 { 387 global $conf; 388 389 return cleanID(str_replace([':', '/', ';'], $conf['sepchar'], $group)); 390 } 391 392 /** 393 * Load all user data 394 * 395 * loads the user file into a datastructure 396 * 397 * @author Andreas Gohr <andi@splitbrain.org> 398 */ 399 protected function loadUserData() 400 { 401 global $config_cascade; 402 403 $this->users = $this->readUserFile($config_cascade['plainauth.users']['default']); 404 405 // support protected users 406 if (!empty($config_cascade['plainauth.users']['protected'])) { 407 $protected = $this->readUserFile($config_cascade['plainauth.users']['protected']); 408 foreach (array_keys($protected) as $key) { 409 $protected[$key]['protected'] = true; 410 } 411 $this->users = array_merge($this->users, $protected); 412 } 413 } 414 415 /** 416 * Read user data from given file 417 * 418 * ignores non existing files 419 * 420 * @param string $file the file to load data from 421 * @return array 422 */ 423 protected function readUserFile($file) 424 { 425 $users = []; 426 if (!file_exists($file)) return $users; 427 428 $lines = file($file); 429 foreach ($lines as $line) { 430 $line = preg_replace('/(?<!\\\\)#.*$/', '', $line); //ignore comments (unless escaped) 431 $line = trim($line); 432 if (empty($line)) continue; 433 434 $row = $this->splitUserData($line); 435 $row = str_replace('\\:', ':', $row); 436 $row = str_replace('\\\\', '\\', $row); 437 $row = str_replace('\\#', '#', $row); 438 439 $groups = array_values(array_filter(explode(",", $row[4]))); 440 441 $users[$row[0]]['pass'] = $row[1]; 442 $users[$row[0]]['name'] = urldecode($row[2]); 443 $users[$row[0]]['mail'] = $row[3]; 444 $users[$row[0]]['grps'] = $groups; 445 } 446 return $users; 447 } 448 449 /** 450 * Get the user line split into it's parts 451 * 452 * @param string $line 453 * @return string[] 454 */ 455 protected function splitUserData($line) 456 { 457 $data = preg_split('/(?<![^\\\\]\\\\)\:/', $line, 5); // allow for : escaped as \: 458 if (count($data) < 5) { 459 $data = array_pad($data, 5, ''); 460 Logger::error('User line with less than 5 fields. Possibly corruption in your user file', $data); 461 } 462 return $data; 463 } 464 465 /** 466 * return true if $user + $info match $filter criteria, false otherwise 467 * 468 * @author Chris Smith <chris@jalakai.co.uk> 469 * 470 * @param string $user User login 471 * @param array $info User's userinfo array 472 * @return bool 473 */ 474 protected function filter($user, $info) 475 { 476 foreach ($this->pattern as $item => $pattern) { 477 if ($item == 'user') { 478 if (!preg_match($pattern, $user)) return false; 479 } elseif ($item == 'grps') { 480 if (!count(preg_grep($pattern, $info['grps']))) return false; 481 } elseif (!preg_match($pattern, $info[$item])) { 482 return false; 483 } 484 } 485 return true; 486 } 487 488 /** 489 * construct a filter pattern 490 * 491 * @param array $filter 492 */ 493 protected function constructPattern($filter) 494 { 495 $this->pattern = []; 496 foreach ($filter as $item => $pattern) { 497 $this->pattern[$item] = '/' . str_replace('/', '\/', $pattern) . '/i'; // allow regex characters 498 } 499 } 500} 501