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