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