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