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