1<?php 2 3use dokuwiki\Extension\AuthPlugin; 4use dokuwiki\PassHash; 5use dokuwiki\Utf8\Sort; 6 7/** 8 * DokuWiki Plugin authpdo (Auth Component) 9 * 10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 11 * @author Andreas Gohr <andi@splitbrain.org> 12 */ 13 14/** 15 * Class auth_plugin_authpdo 16 */ 17class auth_plugin_authpdo extends AuthPlugin 18{ 19 /** @var PDO */ 20 protected $pdo; 21 22 /** @var null|array The list of all groups */ 23 protected $groupcache; 24 25 /** 26 * Constructor. 27 */ 28 public function __construct() 29 { 30 parent::__construct(); // for compatibility 31 32 if (!class_exists('PDO')) { 33 $this->debugMsg('PDO extension for PHP not found.', -1, __LINE__); 34 $this->success = false; 35 return; 36 } 37 38 if (!$this->getConf('dsn')) { 39 $this->debugMsg('No DSN specified', -1, __LINE__); 40 $this->success = false; 41 return; 42 } 43 44 try { 45 $this->pdo = new PDO( 46 $this->getConf('dsn'), 47 $this->getConf('user'), 48 conf_decodeString($this->getConf('pass')), 49 [ 50 PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // always fetch as array 51 PDO::ATTR_EMULATE_PREPARES => true, // emulating prepares allows us to reuse param names 52 PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // we want exceptions, not error codes 53 ] 54 ); 55 } catch (PDOException $e) { 56 $this->debugMsg($e); 57 msg($this->getLang('connectfail'), -1); 58 $this->success = false; 59 return; 60 } 61 62 // can Users be created? 63 $this->cando['addUser'] = $this->checkConfig( 64 ['select-user', 'select-user-groups', 'select-groups', 'insert-user', 'insert-group', 'join-group'] 65 ); 66 67 // can Users be deleted? 68 $this->cando['delUser'] = $this->checkConfig( 69 ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'delete-user'] 70 ); 71 72 // can login names be changed? 73 $this->cando['modLogin'] = $this->checkConfig( 74 ['select-user', 'select-user-groups', 'update-user-login'] 75 ); 76 77 // can passwords be changed? 78 $this->cando['modPass'] = $this->checkConfig( 79 ['select-user', 'select-user-groups', 'update-user-pass'] 80 ); 81 82 // can real names be changed? 83 $this->cando['modName'] = $this->checkConfig( 84 ['select-user', 'select-user-groups', 'update-user-info:name'] 85 ); 86 87 // can real email be changed? 88 $this->cando['modMail'] = $this->checkConfig( 89 ['select-user', 'select-user-groups', 'update-user-info:mail'] 90 ); 91 92 // can groups be changed? 93 $this->cando['modGroups'] = $this->checkConfig( 94 ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'join-group', 'insert-group'] 95 ); 96 97 // can a filtered list of users be retrieved? 98 $this->cando['getUsers'] = $this->checkConfig( 99 ['list-users'] 100 ); 101 102 // can the number of users be retrieved? 103 $this->cando['getUserCount'] = $this->checkConfig( 104 ['count-users'] 105 ); 106 107 // can a list of available groups be retrieved? 108 $this->cando['getGroups'] = $this->checkConfig( 109 ['select-groups'] 110 ); 111 112 $this->success = true; 113 } 114 115 /** 116 * Check user+password 117 * 118 * @param string $user the user name 119 * @param string $pass the clear text password 120 * @return bool 121 */ 122 public function checkPass($user, $pass) 123 { 124 125 $userdata = $this->selectUser($user); 126 if ($userdata === false) { 127 auth_cryptPassword('dummy'); // run a crypt op to prevent timing attacks 128 return false; 129 } 130 131 // password checking done in SQL? 132 if ($this->checkConfig(['check-pass'])) { 133 $userdata['clear'] = $pass; 134 $userdata['hash'] = auth_cryptPassword($pass); 135 $result = $this->query($this->getConf('check-pass'), $userdata); 136 if ($result === false) return false; 137 return (count($result) == 1); 138 } 139 140 // we do password checking on our own 141 if (isset($userdata['hash'])) { 142 // hashed password 143 $passhash = new PassHash(); 144 return $passhash->verify_hash($pass, $userdata['hash']); 145 } else { 146 // clear text password in the database O_o 147 return ($pass === $userdata['clear']); 148 } 149 } 150 151 /** 152 * Return user info 153 * 154 * Returns info about the given user needs to contain 155 * at least these fields: 156 * 157 * name string full name of the user 158 * mail string email addres of the user 159 * grps array list of groups the user is in 160 * 161 * @param string $user the user name 162 * @param bool $requireGroups whether or not the returned data must include groups 163 * @return array|bool containing user data or false 164 */ 165 public function getUserData($user, $requireGroups = true) 166 { 167 if (!is_string($user)) return false; 168 $data = $this->selectUser($user); 169 if ($data == false) return false; 170 171 if (isset($data['hash'])) unset($data['hash']); 172 if (isset($data['clean'])) unset($data['clean']); 173 174 if ($requireGroups) { 175 $data['grps'] = $this->selectUserGroups($data); 176 if ($data['grps'] === false) return false; 177 } 178 179 return $data; 180 } 181 182 /** 183 * Create a new User [implement only where required/possible] 184 * 185 * Returns false if the user already exists, null when an error 186 * occurred and true if everything went well. 187 * 188 * The new user HAS TO be added to the default group by this 189 * function! 190 * 191 * Set addUser capability when implemented 192 * 193 * @param string $user 194 * @param string $clear 195 * @param string $name 196 * @param string $mail 197 * @param null|array $grps 198 * @return bool|null 199 */ 200 public function createUser($user, $clear, $name, $mail, $grps = null) 201 { 202 global $conf; 203 204 if (($info = $this->getUserData($user, false)) !== false) { 205 msg($this->getLang('userexists'), -1); 206 return false; // user already exists 207 } 208 209 // prepare data 210 if ($grps == null) $grps = []; 211 array_unshift($grps, $conf['defaultgroup']); 212 $grps = array_unique($grps); 213 $hash = auth_cryptPassword($clear); 214 $userdata = ['user' => $user, 'clear' => $clear, 'hash' => $hash, 'name' => $name, 'mail' => $mail]; 215 216 // action protected by transaction 217 $this->pdo->beginTransaction(); 218 { 219 // insert the user 220 $ok = $this->query($this->getConf('insert-user'), $userdata); 221 if ($ok === false) goto FAIL; 222 $userdata = $this->getUserData($user, false); 223 if ($userdata === false) goto FAIL; 224 225 // create all groups that do not exist, the refetch the groups 226 $allgroups = $this->selectGroups(); 227 foreach ($grps as $group) { 228 if (!isset($allgroups[$group])) { 229 $ok = $this->addGroup($group); 230 if ($ok === false) goto FAIL; 231 } 232 } 233 $allgroups = $this->selectGroups(); 234 235 // add user to the groups 236 foreach ($grps as $group) { 237 $ok = $this->joinGroup($userdata, $allgroups[$group]); 238 if ($ok === false) goto FAIL; 239 } 240 } 241 $this->pdo->commit(); 242 return true; 243 244 // something went wrong, rollback 245 FAIL: 246 $this->pdo->rollBack(); 247 $this->debugMsg('Transaction rolled back', 0, __LINE__); 248 msg($this->getLang('writefail'), -1); 249 return null; // return error 250 } 251 252 /** 253 * Modify user data 254 * 255 * @param string $user nick of the user to be changed 256 * @param array $changes array of field/value pairs to be changed (password will be clear text) 257 * @return bool 258 */ 259 public function modifyUser($user, $changes) 260 { 261 // secure everything in transaction 262 $this->pdo->beginTransaction(); 263 { 264 $olddata = $this->getUserData($user); 265 $oldgroups = $olddata['grps']; 266 unset($olddata['grps']); 267 268 // changing the user name? 269 if (isset($changes['user'])) { 270 if ($this->getUserData($changes['user'], false)) goto FAIL; 271 $params = $olddata; 272 $params['newlogin'] = $changes['user']; 273 274 $ok = $this->query($this->getConf('update-user-login'), $params); 275 if ($ok === false) goto FAIL; 276 } 277 278 // changing the password? 279 if (isset($changes['pass'])) { 280 $params = $olddata; 281 $params['clear'] = $changes['pass']; 282 $params['hash'] = auth_cryptPassword($changes['pass']); 283 284 $ok = $this->query($this->getConf('update-user-pass'), $params); 285 if ($ok === false) goto FAIL; 286 } 287 288 // changing info? 289 if (isset($changes['mail']) || isset($changes['name'])) { 290 $params = $olddata; 291 if (isset($changes['mail'])) $params['mail'] = $changes['mail']; 292 if (isset($changes['name'])) $params['name'] = $changes['name']; 293 294 $ok = $this->query($this->getConf('update-user-info'), $params); 295 if ($ok === false) goto FAIL; 296 } 297 298 // changing groups? 299 if (isset($changes['grps'])) { 300 $allgroups = $this->selectGroups(); 301 302 // remove membership for previous groups 303 foreach ($oldgroups as $group) { 304 if (!in_array($group, $changes['grps']) && isset($allgroups[$group])) { 305 $ok = $this->leaveGroup($olddata, $allgroups[$group]); 306 if ($ok === false) goto FAIL; 307 } 308 } 309 310 // create all new groups that are missing 311 $added = 0; 312 foreach ($changes['grps'] as $group) { 313 if (!isset($allgroups[$group])) { 314 $ok = $this->addGroup($group); 315 if ($ok === false) goto FAIL; 316 $added++; 317 } 318 } 319 // reload group info 320 if ($added > 0) $allgroups = $this->selectGroups(); 321 322 // add membership for new groups 323 foreach ($changes['grps'] as $group) { 324 if (!in_array($group, $oldgroups)) { 325 $ok = $this->joinGroup($olddata, $allgroups[$group]); 326 if ($ok === false) goto FAIL; 327 } 328 } 329 } 330 331 } 332 $this->pdo->commit(); 333 return true; 334 335 // something went wrong, rollback 336 FAIL: 337 $this->pdo->rollBack(); 338 $this->debugMsg('Transaction rolled back', 0, __LINE__); 339 msg($this->getLang('writefail'), -1); 340 return false; // return error 341 } 342 343 /** 344 * Delete one or more users 345 * 346 * Set delUser capability when implemented 347 * 348 * @param array $users 349 * @return int number of users deleted 350 */ 351 public function deleteUsers($users) 352 { 353 $count = 0; 354 foreach ($users as $user) { 355 if ($this->deleteUser($user)) $count++; 356 } 357 return $count; 358 } 359 360 /** 361 * Bulk retrieval of user data [implement only where required/possible] 362 * 363 * Set getUsers capability when implemented 364 * 365 * @param int $start index of first user to be returned 366 * @param int $limit max number of users to be returned 367 * @param array $filter array of field/pattern pairs, null for no filter 368 * @return array list of userinfo (refer getUserData for internal userinfo details) 369 */ 370 public function retrieveUsers($start = 0, $limit = -1, $filter = null) 371 { 372 if ($limit < 0) $limit = 10000; // we don't support no limit 373 if (is_null($filter)) $filter = []; 374 375 if (isset($filter['grps'])) $filter['group'] = $filter['grps']; 376 foreach (['user', 'name', 'mail', 'group'] as $key) { 377 if (!isset($filter[$key])) { 378 $filter[$key] = '%'; 379 } else { 380 $filter[$key] = '%' . $filter[$key] . '%'; 381 } 382 } 383 $filter['start'] = (int)$start; 384 $filter['end'] = (int)$start + $limit; 385 $filter['limit'] = (int)$limit; 386 387 $result = $this->query($this->getConf('list-users'), $filter); 388 if (!$result) return []; 389 $users = []; 390 if (is_array($result)) { 391 foreach ($result as $row) { 392 if (!isset($row['user'])) { 393 $this->debugMsg("list-users statement did not return 'user' attribute", -1, __LINE__); 394 return []; 395 } 396 $users[] = $this->getUserData($row['user']); 397 } 398 } else { 399 $this->debugMsg("list-users statement did not return a list of result", -1, __LINE__); 400 } 401 return $users; 402 } 403 404 /** 405 * Return a count of the number of user which meet $filter criteria 406 * 407 * @param array $filter array of field/pattern pairs, empty array for no filter 408 * @return int 409 */ 410 public function getUserCount($filter = []) 411 { 412 if (is_null($filter)) $filter = []; 413 414 if (isset($filter['grps'])) $filter['group'] = $filter['grps']; 415 foreach (['user', 'name', 'mail', 'group'] as $key) { 416 if (!isset($filter[$key])) { 417 $filter[$key] = '%'; 418 } else { 419 $filter[$key] = '%' . $filter[$key] . '%'; 420 } 421 } 422 423 $result = $this->query($this->getConf('count-users'), $filter); 424 if (!$result || !isset($result[0]['count'])) { 425 $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__); 426 } 427 return (int)$result[0]['count']; 428 } 429 430 /** 431 * Create a new group with the given name 432 * 433 * @param string $group 434 * @return bool 435 */ 436 public function addGroup($group) 437 { 438 $sql = $this->getConf('insert-group'); 439 440 $result = $this->query($sql, [':group' => $group]); 441 $this->clearGroupCache(); 442 if ($result === false) return false; 443 return true; 444 } 445 446 /** 447 * Retrieve groups 448 * 449 * Set getGroups capability when implemented 450 * 451 * @param int $start 452 * @param int $limit 453 * @return array 454 */ 455 public function retrieveGroups($start = 0, $limit = 0) 456 { 457 $groups = array_keys($this->selectGroups()); 458 if ($groups === false) return []; 459 460 if (!$limit) { 461 return array_splice($groups, $start); 462 } else { 463 return array_splice($groups, $start, $limit); 464 } 465 } 466 467 /** 468 * Select data of a specified user 469 * 470 * @param string $user the user name 471 * @return bool|array user data, false on error 472 */ 473 protected function selectUser($user) 474 { 475 $sql = $this->getConf('select-user'); 476 477 $result = $this->query($sql, [':user' => $user]); 478 if (!$result) return false; 479 480 if (count($result) > 1) { 481 $this->debugMsg('Found more than one matching user', -1, __LINE__); 482 return false; 483 } 484 485 $data = array_shift($result); 486 $dataok = true; 487 488 if (!isset($data['user'])) { 489 $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__); 490 $dataok = false; 491 } 492 if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(['check-pass'])) { 493 $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__); 494 $dataok = false; 495 } 496 if (!isset($data['name'])) { 497 $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__); 498 $dataok = false; 499 } 500 if (!isset($data['mail'])) { 501 $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__); 502 $dataok = false; 503 } 504 505 if (!$dataok) return false; 506 return $data; 507 } 508 509 /** 510 * Delete a user after removing all their group memberships 511 * 512 * @param string $user 513 * @return bool true when the user was deleted 514 */ 515 protected function deleteUser($user) 516 { 517 $this->pdo->beginTransaction(); 518 { 519 $userdata = $this->getUserData($user); 520 if ($userdata === false) goto FAIL; 521 $allgroups = $this->selectGroups(); 522 523 // remove group memberships (ignore errors) 524 foreach ($userdata['grps'] as $group) { 525 if (isset($allgroups[$group])) { 526 $this->leaveGroup($userdata, $allgroups[$group]); 527 } 528 } 529 530 $ok = $this->query($this->getConf('delete-user'), $userdata); 531 if ($ok === false) goto FAIL; 532 } 533 $this->pdo->commit(); 534 return true; 535 536 FAIL: 537 $this->pdo->rollBack(); 538 return false; 539 } 540 541 /** 542 * Select all groups of a user 543 * 544 * @param array $userdata The userdata as returned by _selectUser() 545 * @return array|bool list of group names, false on error 546 */ 547 protected function selectUserGroups($userdata) 548 { 549 global $conf; 550 $sql = $this->getConf('select-user-groups'); 551 $result = $this->query($sql, $userdata); 552 if ($result === false) return false; 553 554 $groups = [$conf['defaultgroup']]; // always add default config 555 if (is_array($result)) { 556 foreach ($result as $row) { 557 if (!isset($row['group'])) { 558 $this->debugMsg("No 'group' field returned in select-user-groups statement", -1, __LINE__); 559 return false; 560 } 561 $groups[] = $row['group']; 562 } 563 } else { 564 $this->debugMsg("select-user-groups statement did not return a list of result", -1, __LINE__); 565 } 566 567 $groups = array_unique($groups); 568 Sort::sort($groups); 569 return $groups; 570 } 571 572 /** 573 * Select all available groups 574 * 575 * @return array|bool list of all available groups and their properties 576 */ 577 protected function selectGroups() 578 { 579 if ($this->groupcache) return $this->groupcache; 580 581 $sql = $this->getConf('select-groups'); 582 $result = $this->query($sql); 583 if ($result === false) return false; 584 585 $groups = []; 586 if (is_array($result)) { 587 foreach ($result as $row) { 588 if (!isset($row['group'])) { 589 $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__); 590 return false; 591 } 592 593 // relayout result with group name as key 594 $group = $row['group']; 595 $groups[$group] = $row; 596 } 597 } else { 598 $this->debugMsg("select-groups statement did not return a list of result", -1, __LINE__); 599 } 600 601 Sort::ksort($groups); 602 return $groups; 603 } 604 605 /** 606 * Remove all entries from the group cache 607 */ 608 protected function clearGroupCache() 609 { 610 $this->groupcache = null; 611 } 612 613 /** 614 * Adds the user to the group 615 * 616 * @param array $userdata all the user data 617 * @param array $groupdata all the group data 618 * @return bool 619 */ 620 protected function joinGroup($userdata, $groupdata) 621 { 622 $data = array_merge($userdata, $groupdata); 623 $sql = $this->getConf('join-group'); 624 $result = $this->query($sql, $data); 625 if ($result === false) return false; 626 return true; 627 } 628 629 /** 630 * Removes the user from the group 631 * 632 * @param array $userdata all the user data 633 * @param array $groupdata all the group data 634 * @return bool 635 */ 636 protected function leaveGroup($userdata, $groupdata) 637 { 638 $data = array_merge($userdata, $groupdata); 639 $sql = $this->getConf('leave-group'); 640 $result = $this->query($sql, $data); 641 if ($result === false) return false; 642 return true; 643 } 644 645 /** 646 * Executes a query 647 * 648 * @param string $sql The SQL statement to execute 649 * @param array $arguments Named parameters to be used in the statement 650 * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error 651 */ 652 protected function query($sql, $arguments = []) 653 { 654 $sql = trim($sql); 655 if (empty($sql)) { 656 $this->debugMsg('No SQL query given', -1, __LINE__); 657 return false; 658 } 659 660 // execute 661 $params = []; 662 $sth = $this->pdo->prepare($sql); 663 $result = false; 664 try { 665 // prepare parameters - we only use those that exist in the SQL 666 foreach ($arguments as $key => $value) { 667 if (is_array($value)) continue; 668 if (is_object($value)) continue; 669 if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed 670 if (!str_contains($sql, (string) $key)) continue; // skip if parameter is missing 671 672 if (is_int($value)) { 673 $sth->bindValue($key, $value, PDO::PARAM_INT); 674 } else { 675 $sth->bindValue($key, $value); 676 } 677 $params[$key] = $value; //remember for debugging 678 } 679 680 $sth->execute(); 681 // only report last line's result 682 $hasnextrowset = true; 683 $currentsql = $sql; 684 while ($hasnextrowset) { 685 if (str_starts_with(strtolower($currentsql), 'select')) { 686 $result = $sth->fetchAll(); 687 } else { 688 $result = $sth->rowCount(); 689 } 690 $semi_pos = strpos($currentsql, ';'); 691 if ($semi_pos) { 692 $currentsql = trim(substr($currentsql, $semi_pos + 1)); 693 } 694 try { 695 $hasnextrowset = $sth->nextRowset(); // run next rowset 696 } catch (PDOException) { 697 $hasnextrowset = false; // driver does not support multi-rowset, should be executed in one time 698 } 699 } 700 } catch (Exception $e) { 701 // report the caller's line 702 $trace = debug_backtrace(); 703 $line = $trace[0]['line']; 704 $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST')); 705 $this->debugMsg($e, -1, $line); 706 $this->debugMsg("SQL: <pre>$dsql</pre>", -1, $line); 707 } 708 $sth->closeCursor(); 709 710 return $result; 711 } 712 713 /** 714 * Wrapper around msg() but outputs only when debug is enabled 715 * 716 * @param string|Exception $message 717 * @param int $err 718 * @param int $line 719 */ 720 protected function debugMsg($message, $err = 0, $line = 0) 721 { 722 if (!$this->getConf('debug')) return; 723 if (is_a($message, 'Exception')) { 724 $err = -1; 725 $msg = $message->getMessage(); 726 if (!$line) $line = $message->getLine(); 727 } else { 728 $msg = $message; 729 } 730 731 if (defined('DOKU_UNITTEST')) { 732 printf("\n%s, %s:%d\n", $msg, __FILE__, $line); 733 } else { 734 msg('authpdo: ' . $msg, $err, $line, __FILE__); 735 } 736 } 737 738 /** 739 * Check if the given config strings are set 740 * 741 * @param string[] $keys 742 * @return bool 743 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 744 * 745 */ 746 protected function checkConfig($keys) 747 { 748 foreach ($keys as $key) { 749 $params = explode(':', $key); 750 $key = array_shift($params); 751 $sql = trim($this->getConf($key)); 752 753 // check if sql is set 754 if (!$sql) return false; 755 // check if needed params are there 756 foreach ($params as $param) { 757 if (!str_contains($sql, ":$param")) return false; 758 } 759 } 760 761 return true; 762 } 763 764 /** 765 * create an approximation of the SQL string with parameters replaced 766 * 767 * @param string $sql 768 * @param array $params 769 * @param bool $htmlescape Should the result be escaped for output in HTML? 770 * @return string 771 */ 772 protected function debugSQL($sql, $params, $htmlescape = true) 773 { 774 foreach ($params as $key => $val) { 775 if (is_int($val)) { 776 $val = $this->pdo->quote($val, PDO::PARAM_INT); 777 } elseif (is_bool($val)) { 778 $val = $this->pdo->quote($val, PDO::PARAM_BOOL); 779 } elseif (is_null($val)) { 780 $val = 'NULL'; 781 } else { 782 $val = $this->pdo->quote($val); 783 } 784 $sql = str_replace($key, $val, $sql); 785 } 786 if ($htmlescape) $sql = hsc($sql); 787 return $sql; 788 } 789} 790 791// vim:ts=4:sw=4:et: 792