You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

788 lines
24 KiB

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