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 $data = $this->selectUser($user); 168 if ($data == false) return false; 169 170 if (isset($data['hash'])) unset($data['hash']); 171 if (isset($data['clean'])) unset($data['clean']); 172 173 if ($requireGroups) { 174 $data['grps'] = $this->selectUserGroups($data); 175 if ($data['grps'] === false) return false; 176 } 177 178 return $data; 179 } 180 181 /** 182 * Create a new User [implement only where required/possible] 183 * 184 * Returns false if the user already exists, null when an error 185 * occurred and true if everything went well. 186 * 187 * The new user HAS TO be added to the default group by this 188 * function! 189 * 190 * Set addUser capability when implemented 191 * 192 * @param string $user 193 * @param string $clear 194 * @param string $name 195 * @param string $mail 196 * @param null|array $grps 197 * @return bool|null 198 */ 199 public function createUser($user, $clear, $name, $mail, $grps = null) 200 { 201 global $conf; 202 203 if (($info = $this->getUserData($user, false)) !== false) { 204 msg($this->getLang('userexists'), -1); 205 return false; // user already exists 206 } 207 208 // prepare data 209 if ($grps == null) $grps = []; 210 array_unshift($grps, $conf['defaultgroup']); 211 $grps = array_unique($grps); 212 $hash = auth_cryptPassword($clear); 213 $userdata = ['user' => $user, 'clear' => $clear, 'hash' => $hash, 'name' => $name, 'mail' => $mail]; 214 215 // action protected by transaction 216 $this->pdo->beginTransaction(); 217 { 218 // insert the user 219 $ok = $this->query($this->getConf('insert-user'), $userdata); 220 if ($ok === false) goto FAIL; 221 $userdata = $this->getUserData($user, false); 222 if ($userdata === false) goto FAIL; 223 224 // create all groups that do not exist, the refetch the groups 225 $allgroups = $this->selectGroups(); 226 foreach ($grps as $group) { 227 if (!isset($allgroups[$group])) { 228 $ok = $this->addGroup($group); 229 if ($ok === false) goto FAIL; 230 } 231 } 232 $allgroups = $this->selectGroups(); 233 234 // add user to the groups 235 foreach ($grps as $group) { 236 $ok = $this->joinGroup($userdata, $allgroups[$group]); 237 if ($ok === false) goto FAIL; 238 } 239 } 240 $this->pdo->commit(); 241 return true; 242 243 // something went wrong, rollback 244 FAIL: 245 $this->pdo->rollBack(); 246 $this->debugMsg('Transaction rolled back', 0, __LINE__); 247 msg($this->getLang('writefail'), -1); 248 return null; // return error 249 } 250 251 /** 252 * Modify user data 253 * 254 * @param string $user nick of the user to be changed 255 * @param array $changes array of field/value pairs to be changed (password will be clear text) 256 * @return bool 257 */ 258 public function modifyUser($user, $changes) 259 { 260 // secure everything in transaction 261 $this->pdo->beginTransaction(); 262 { 263 $olddata = $this->getUserData($user); 264 $oldgroups = $olddata['grps']; 265 unset($olddata['grps']); 266 267 // changing the user name? 268 if (isset($changes['user'])) { 269 if ($this->getUserData($changes['user'], false)) goto FAIL; 270 $params = $olddata; 271 $params['newlogin'] = $changes['user']; 272 273 $ok = $this->query($this->getConf('update-user-login'), $params); 274 if ($ok === false) goto FAIL; 275 } 276 277 // changing the password? 278 if (isset($changes['pass'])) { 279 $params = $olddata; 280 $params['clear'] = $changes['pass']; 281 $params['hash'] = auth_cryptPassword($changes['pass']); 282 283 $ok = $this->query($this->getConf('update-user-pass'), $params); 284 if ($ok === false) goto FAIL; 285 } 286 287 // changing info? 288 if (isset($changes['mail']) || isset($changes['name'])) { 289 $params = $olddata; 290 if (isset($changes['mail'])) $params['mail'] = $changes['mail']; 291 if (isset($changes['name'])) $params['name'] = $changes['name']; 292 293 $ok = $this->query($this->getConf('update-user-info'), $params); 294 if ($ok === false) goto FAIL; 295 } 296 297 // changing groups? 298 if (isset($changes['grps'])) { 299 $allgroups = $this->selectGroups(); 300 301 // remove membership for previous groups 302 foreach ($oldgroups as $group) { 303 if (!in_array($group, $changes['grps']) && isset($allgroups[$group])) { 304 $ok = $this->leaveGroup($olddata, $allgroups[$group]); 305 if ($ok === false) goto FAIL; 306 } 307 } 308 309 // create all new groups that are missing 310 $added = 0; 311 foreach ($changes['grps'] as $group) { 312 if (!isset($allgroups[$group])) { 313 $ok = $this->addGroup($group); 314 if ($ok === false) goto FAIL; 315 $added++; 316 } 317 } 318 // reload group info 319 if ($added > 0) $allgroups = $this->selectGroups(); 320 321 // add membership for new groups 322 foreach ($changes['grps'] as $group) { 323 if (!in_array($group, $oldgroups)) { 324 $ok = $this->joinGroup($olddata, $allgroups[$group]); 325 if ($ok === false) goto FAIL; 326 } 327 } 328 } 329 330 } 331 $this->pdo->commit(); 332 return true; 333 334 // something went wrong, rollback 335 FAIL: 336 $this->pdo->rollBack(); 337 $this->debugMsg('Transaction rolled back', 0, __LINE__); 338 msg($this->getLang('writefail'), -1); 339 return false; // return error 340 } 341 342 /** 343 * Delete one or more users 344 * 345 * Set delUser capability when implemented 346 * 347 * @param array $users 348 * @return int number of users deleted 349 */ 350 public function deleteUsers($users) 351 { 352 $count = 0; 353 foreach ($users as $user) { 354 if ($this->deleteUser($user)) $count++; 355 } 356 return $count; 357 } 358 359 /** 360 * Bulk retrieval of user data [implement only where required/possible] 361 * 362 * Set getUsers capability when implemented 363 * 364 * @param int $start index of first user to be returned 365 * @param int $limit max number of users to be returned 366 * @param array $filter array of field/pattern pairs, null for no filter 367 * @return array list of userinfo (refer getUserData for internal userinfo details) 368 */ 369 public function retrieveUsers($start = 0, $limit = -1, $filter = null) 370 { 371 if ($limit < 0) $limit = 10000; // we don't support no limit 372 if (is_null($filter)) $filter = []; 373 374 if (isset($filter['grps'])) $filter['group'] = $filter['grps']; 375 foreach (['user', 'name', 'mail', 'group'] as $key) { 376 if (!isset($filter[$key])) { 377 $filter[$key] = '%'; 378 } else { 379 $filter[$key] = '%' . $filter[$key] . '%'; 380 } 381 } 382 $filter['start'] = (int)$start; 383 $filter['end'] = (int)$start + $limit; 384 $filter['limit'] = (int)$limit; 385 386 $result = $this->query($this->getConf('list-users'), $filter); 387 if (!$result) return []; 388 $users = []; 389 if (is_array($result)) { 390 foreach ($result as $row) { 391 if (!isset($row['user'])) { 392 $this->debugMsg("list-users statement did not return 'user' attribute", -1, __LINE__); 393 return []; 394 } 395 $users[] = $this->getUserData($row['user']); 396 } 397 } else { 398 $this->debugMsg("list-users statement did not return a list of result", -1, __LINE__); 399 } 400 return $users; 401 } 402 403 /** 404 * Return a count of the number of user which meet $filter criteria 405 * 406 * @param array $filter array of field/pattern pairs, empty array for no filter 407 * @return int 408 */ 409 public function getUserCount($filter = []) 410 { 411 if (is_null($filter)) $filter = []; 412 413 if (isset($filter['grps'])) $filter['group'] = $filter['grps']; 414 foreach (['user', 'name', 'mail', 'group'] as $key) { 415 if (!isset($filter[$key])) { 416 $filter[$key] = '%'; 417 } else { 418 $filter[$key] = '%' . $filter[$key] . '%'; 419 } 420 } 421 422 $result = $this->query($this->getConf('count-users'), $filter); 423 if (!$result || !isset($result[0]['count'])) { 424 $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__); 425 } 426 return (int)$result[0]['count']; 427 } 428 429 /** 430 * Create a new group with the given name 431 * 432 * @param string $group 433 * @return bool 434 */ 435 public function addGroup($group) 436 { 437 $sql = $this->getConf('insert-group'); 438 439 $result = $this->query($sql, [':group' => $group]); 440 $this->clearGroupCache(); 441 if ($result === false) return false; 442 return true; 443 } 444 445 /** 446 * Retrieve groups 447 * 448 * Set getGroups capability when implemented 449 * 450 * @param int $start 451 * @param int $limit 452 * @return array 453 */ 454 public function retrieveGroups($start = 0, $limit = 0) 455 { 456 $groups = array_keys($this->selectGroups()); 457 if ($groups === false) return []; 458 459 if (!$limit) { 460 return array_splice($groups, $start); 461 } else { 462 return array_splice($groups, $start, $limit); 463 } 464 } 465 466 /** 467 * Select data of a specified user 468 * 469 * @param string $user the user name 470 * @return bool|array user data, false on error 471 */ 472 protected function selectUser($user) 473 { 474 $sql = $this->getConf('select-user'); 475 476 $result = $this->query($sql, [':user' => $user]); 477 if (!$result) return false; 478 479 if (count($result) > 1) { 480 $this->debugMsg('Found more than one matching user', -1, __LINE__); 481 return false; 482 } 483 484 $data = array_shift($result); 485 $dataok = true; 486 487 if (!isset($data['user'])) { 488 $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__); 489 $dataok = false; 490 } 491 if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(['check-pass'])) { 492 $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__); 493 $dataok = false; 494 } 495 if (!isset($data['name'])) { 496 $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__); 497 $dataok = false; 498 } 499 if (!isset($data['mail'])) { 500 $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__); 501 $dataok = false; 502 } 503 504 if (!$dataok) return false; 505 return $data; 506 } 507 508 /** 509 * Delete a user after removing all their group memberships 510 * 511 * @param string $user 512 * @return bool true when the user was deleted 513 */ 514 protected function deleteUser($user) 515 { 516 $this->pdo->beginTransaction(); 517 { 518 $userdata = $this->getUserData($user); 519 if ($userdata === false) goto FAIL; 520 $allgroups = $this->selectGroups(); 521 522 // remove group memberships (ignore errors) 523 foreach ($userdata['grps'] as $group) { 524 if (isset($allgroups[$group])) { 525 $this->leaveGroup($userdata, $allgroups[$group]); 526 } 527 } 528 529 $ok = $this->query($this->getConf('delete-user'), $userdata); 530 if ($ok === false) goto FAIL; 531 } 532 $this->pdo->commit(); 533 return true; 534 535 FAIL: 536 $this->pdo->rollBack(); 537 return false; 538 } 539 540 /** 541 * Select all groups of a user 542 * 543 * @param array $userdata The userdata as returned by _selectUser() 544 * @return array|bool list of group names, false on error 545 */ 546 protected function selectUserGroups($userdata) 547 { 548 global $conf; 549 $sql = $this->getConf('select-user-groups'); 550 $result = $this->query($sql, $userdata); 551 if ($result === false) return false; 552 553 $groups = [$conf['defaultgroup']]; // always add default config 554 if (is_array($result)) { 555 foreach ($result as $row) { 556 if (!isset($row['group'])) { 557 $this->debugMsg("No 'group' field returned in select-user-groups statement", -1, __LINE__); 558 return false; 559 } 560 $groups[] = $row['group']; 561 } 562 } else { 563 $this->debugMsg("select-user-groups statement did not return a list of result", -1, __LINE__); 564 } 565 566 $groups = array_unique($groups); 567 Sort::sort($groups); 568 return $groups; 569 } 570 571 /** 572 * Select all available groups 573 * 574 * @return array|bool list of all available groups and their properties 575 */ 576 protected function selectGroups() 577 { 578 if ($this->groupcache) return $this->groupcache; 579 580 $sql = $this->getConf('select-groups'); 581 $result = $this->query($sql); 582 if ($result === false) return false; 583 584 $groups = []; 585 if (is_array($result)) { 586 foreach ($result as $row) { 587 if (!isset($row['group'])) { 588 $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__); 589 return false; 590 } 591 592 // relayout result with group name as key 593 $group = $row['group']; 594 $groups[$group] = $row; 595 } 596 } else { 597 $this->debugMsg("select-groups statement did not return a list of result", -1, __LINE__); 598 } 599 600 Sort::ksort($groups); 601 return $groups; 602 } 603 604 /** 605 * Remove all entries from the group cache 606 */ 607 protected function clearGroupCache() 608 { 609 $this->groupcache = null; 610 } 611 612 /** 613 * Adds the user to the group 614 * 615 * @param array $userdata all the user data 616 * @param array $groupdata all the group data 617 * @return bool 618 */ 619 protected function joinGroup($userdata, $groupdata) 620 { 621 $data = array_merge($userdata, $groupdata); 622 $sql = $this->getConf('join-group'); 623 $result = $this->query($sql, $data); 624 if ($result === false) return false; 625 return true; 626 } 627 628 /** 629 * Removes the user from the group 630 * 631 * @param array $userdata all the user data 632 * @param array $groupdata all the group data 633 * @return bool 634 */ 635 protected function leaveGroup($userdata, $groupdata) 636 { 637 $data = array_merge($userdata, $groupdata); 638 $sql = $this->getConf('leave-group'); 639 $result = $this->query($sql, $data); 640 if ($result === false) return false; 641 return true; 642 } 643 644 /** 645 * Executes a query 646 * 647 * @param string $sql The SQL statement to execute 648 * @param array $arguments Named parameters to be used in the statement 649 * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error 650 */ 651 protected function query($sql, $arguments = []) 652 { 653 $sql = trim($sql); 654 if (empty($sql)) { 655 $this->debugMsg('No SQL query given', -1, __LINE__); 656 return false; 657 } 658 659 // execute 660 $params = []; 661 $sth = $this->pdo->prepare($sql); 662 $result = false; 663 try { 664 // prepare parameters - we only use those that exist in the SQL 665 foreach ($arguments as $key => $value) { 666 if (is_array($value)) continue; 667 if (is_object($value)) continue; 668 if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed 669 if (strpos($sql, (string) $key) === false) continue; // skip if parameter is missing 670 671 if (is_int($value)) { 672 $sth->bindValue($key, $value, PDO::PARAM_INT); 673 } else { 674 $sth->bindValue($key, $value); 675 } 676 $params[$key] = $value; //remember for debugging 677 } 678 679 $sth->execute(); 680 // only report last line's result 681 $hasnextrowset = true; 682 $currentsql = $sql; 683 while ($hasnextrowset) { 684 if (str_starts_with(strtolower($currentsql), 'select')) { 685 $result = $sth->fetchAll(); 686 } else { 687 $result = $sth->rowCount(); 688 } 689 $semi_pos = strpos($currentsql, ';'); 690 if ($semi_pos) { 691 $currentsql = trim(substr($currentsql, $semi_pos + 1)); 692 } 693 try { 694 $hasnextrowset = $sth->nextRowset(); // run next rowset 695 } catch (PDOException $rowset_e) { 696 $hasnextrowset = false; // driver does not support multi-rowset, should be executed in one time 697 } 698 } 699 } catch (Exception $e) { 700 // report the caller's line 701 $trace = debug_backtrace(); 702 $line = $trace[0]['line']; 703 $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST')); 704 $this->debugMsg($e, -1, $line); 705 $this->debugMsg("SQL: <pre>$dsql</pre>", -1, $line); 706 } 707 $sth->closeCursor(); 708 709 return $result; 710 } 711 712 /** 713 * Wrapper around msg() but outputs only when debug is enabled 714 * 715 * @param string|Exception $message 716 * @param int $err 717 * @param int $line 718 */ 719 protected function debugMsg($message, $err = 0, $line = 0) 720 { 721 if (!$this->getConf('debug')) return; 722 if (is_a($message, 'Exception')) { 723 $err = -1; 724 $msg = $message->getMessage(); 725 if (!$line) $line = $message->getLine(); 726 } else { 727 $msg = $message; 728 } 729 730 if (defined('DOKU_UNITTEST')) { 731 printf("\n%s, %s:%d\n", $msg, __FILE__, $line); 732 } else { 733 msg('authpdo: ' . $msg, $err, $line, __FILE__); 734 } 735 } 736 737 /** 738 * Check if the given config strings are set 739 * 740 * @param string[] $keys 741 * @return bool 742 * @author Matthias Grimm <matthiasgrimm@users.sourceforge.net> 743 * 744 */ 745 protected function checkConfig($keys) 746 { 747 foreach ($keys as $key) { 748 $params = explode(':', $key); 749 $key = array_shift($params); 750 $sql = trim($this->getConf($key)); 751 752 // check if sql is set 753 if (!$sql) return false; 754 // check if needed params are there 755 foreach ($params as $param) { 756 if (strpos($sql, ":$param") === false) return false; 757 } 758 } 759 760 return true; 761 } 762 763 /** 764 * create an approximation of the SQL string with parameters replaced 765 * 766 * @param string $sql 767 * @param array $params 768 * @param bool $htmlescape Should the result be escaped for output in HTML? 769 * @return string 770 */ 771 protected function debugSQL($sql, $params, $htmlescape = true) 772 { 773 foreach ($params as $key => $val) { 774 if (is_int($val)) { 775 $val = $this->pdo->quote($val, PDO::PARAM_INT); 776 } elseif (is_bool($val)) { 777 $val = $this->pdo->quote($val, PDO::PARAM_BOOL); 778 } elseif (is_null($val)) { 779 $val = 'NULL'; 780 } else { 781 $val = $this->pdo->quote($val); 782 } 783 $sql = str_replace($key, $val, $sql); 784 } 785 if ($htmlescape) $sql = hsc($sql); 786 return $sql; 787 } 788} 789 790// vim:ts=4:sw=4:et: 791