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