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