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