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