*/ /** * Class auth_plugin_authpdo */ class auth_plugin_authpdo extends AuthPlugin { /** @var PDO */ protected $pdo; /** @var null|array The list of all groups */ protected $groupcache; /** * Constructor. */ public function __construct() { parent::__construct(); // for compatibility if (!class_exists('PDO')) { $this->debugMsg('PDO extension for PHP not found.', -1, __LINE__); $this->success = false; return; } if (!$this->getConf('dsn')) { $this->debugMsg('No DSN specified', -1, __LINE__); $this->success = false; return; } try { $this->pdo = new PDO( $this->getConf('dsn'), $this->getConf('user'), conf_decodeString($this->getConf('pass')), [ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // always fetch as array PDO::ATTR_EMULATE_PREPARES => true, // emulating prepares allows us to reuse param names PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // we want exceptions, not error codes ] ); } catch (PDOException $e) { $this->debugMsg($e); msg($this->getLang('connectfail'), -1); $this->success = false; return; } // can Users be created? $this->cando['addUser'] = $this->checkConfig( ['select-user', 'select-user-groups', 'select-groups', 'insert-user', 'insert-group', 'join-group'] ); // can Users be deleted? $this->cando['delUser'] = $this->checkConfig( ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'delete-user'] ); // can login names be changed? $this->cando['modLogin'] = $this->checkConfig( ['select-user', 'select-user-groups', 'update-user-login'] ); // can passwords be changed? $this->cando['modPass'] = $this->checkConfig( ['select-user', 'select-user-groups', 'update-user-pass'] ); // can real names be changed? $this->cando['modName'] = $this->checkConfig( ['select-user', 'select-user-groups', 'update-user-info:name'] ); // can real email be changed? $this->cando['modMail'] = $this->checkConfig( ['select-user', 'select-user-groups', 'update-user-info:mail'] ); // can groups be changed? $this->cando['modGroups'] = $this->checkConfig( ['select-user', 'select-user-groups', 'select-groups', 'leave-group', 'join-group', 'insert-group'] ); // can a filtered list of users be retrieved? $this->cando['getUsers'] = $this->checkConfig( ['list-users'] ); // can the number of users be retrieved? $this->cando['getUserCount'] = $this->checkConfig( ['count-users'] ); // can a list of available groups be retrieved? $this->cando['getGroups'] = $this->checkConfig( ['select-groups'] ); $this->success = true; } /** * Check user+password * * @param string $user the user name * @param string $pass the clear text password * @return bool */ public function checkPass($user, $pass) { $userdata = $this->selectUser($user); if ($userdata == false) return false; // password checking done in SQL? if ($this->checkConfig(['check-pass'])) { $userdata['clear'] = $pass; $userdata['hash'] = auth_cryptPassword($pass); $result = $this->query($this->getConf('check-pass'), $userdata); if ($result === false) return false; return (count($result) == 1); } // we do password checking on our own if (isset($userdata['hash'])) { // hashed password $passhash = new PassHash(); return $passhash->verify_hash($pass, $userdata['hash']); } else { // clear text password in the database O_o return ($pass === $userdata['clear']); } } /** * Return user info * * Returns info about the given user needs to contain * at least these fields: * * name string full name of the user * mail string email addres of the user * grps array list of groups the user is in * * @param string $user the user name * @param bool $requireGroups whether or not the returned data must include groups * @return array|bool containing user data or false */ public function getUserData($user, $requireGroups = true) { $data = $this->selectUser($user); if ($data == false) return false; if (isset($data['hash'])) unset($data['hash']); if (isset($data['clean'])) unset($data['clean']); if ($requireGroups) { $data['grps'] = $this->selectUserGroups($data); if ($data['grps'] === false) return false; } return $data; } /** * Create a new User [implement only where required/possible] * * Returns false if the user already exists, null when an error * occurred and true if everything went well. * * The new user HAS TO be added to the default group by this * function! * * Set addUser capability when implemented * * @param string $user * @param string $clear * @param string $name * @param string $mail * @param null|array $grps * @return bool|null */ public function createUser($user, $clear, $name, $mail, $grps = null) { global $conf; if (($info = $this->getUserData($user, false)) !== false) { msg($this->getLang('userexists'), -1); return false; // user already exists } // prepare data if ($grps == null) $grps = []; array_unshift($grps, $conf['defaultgroup']); $grps = array_unique($grps); $hash = auth_cryptPassword($clear); $userdata = ['user' => $user, 'clear' => $clear, 'hash' => $hash, 'name' => $name, 'mail' => $mail]; // action protected by transaction $this->pdo->beginTransaction(); { // insert the user $ok = $this->query($this->getConf('insert-user'), $userdata); if ($ok === false) goto FAIL; $userdata = $this->getUserData($user, false); if ($userdata === false) goto FAIL; // create all groups that do not exist, the refetch the groups $allgroups = $this->selectGroups(); foreach ($grps as $group) { if (!isset($allgroups[$group])) { $ok = $this->addGroup($group); if ($ok === false) goto FAIL; } } $allgroups = $this->selectGroups(); // add user to the groups foreach ($grps as $group) { $ok = $this->joinGroup($userdata, $allgroups[$group]); if ($ok === false) goto FAIL; } } $this->pdo->commit(); return true; // something went wrong, rollback FAIL: $this->pdo->rollBack(); $this->debugMsg('Transaction rolled back', 0, __LINE__); msg($this->getLang('writefail'), -1); return null; // return error } /** * Modify user data * * @param string $user nick of the user to be changed * @param array $changes array of field/value pairs to be changed (password will be clear text) * @return bool */ public function modifyUser($user, $changes) { // secure everything in transaction $this->pdo->beginTransaction(); { $olddata = $this->getUserData($user); $oldgroups = $olddata['grps']; unset($olddata['grps']); // changing the user name? if (isset($changes['user'])) { if ($this->getUserData($changes['user'], false)) goto FAIL; $params = $olddata; $params['newlogin'] = $changes['user']; $ok = $this->query($this->getConf('update-user-login'), $params); if ($ok === false) goto FAIL; } // changing the password? if (isset($changes['pass'])) { $params = $olddata; $params['clear'] = $changes['pass']; $params['hash'] = auth_cryptPassword($changes['pass']); $ok = $this->query($this->getConf('update-user-pass'), $params); if ($ok === false) goto FAIL; } // changing info? if (isset($changes['mail']) || isset($changes['name'])) { $params = $olddata; if (isset($changes['mail'])) $params['mail'] = $changes['mail']; if (isset($changes['name'])) $params['name'] = $changes['name']; $ok = $this->query($this->getConf('update-user-info'), $params); if ($ok === false) goto FAIL; } // changing groups? if (isset($changes['grps'])) { $allgroups = $this->selectGroups(); // remove membership for previous groups foreach ($oldgroups as $group) { if (!in_array($group, $changes['grps']) && isset($allgroups[$group])) { $ok = $this->leaveGroup($olddata, $allgroups[$group]); if ($ok === false) goto FAIL; } } // create all new groups that are missing $added = 0; foreach ($changes['grps'] as $group) { if (!isset($allgroups[$group])) { $ok = $this->addGroup($group); if ($ok === false) goto FAIL; $added++; } } // reload group info if ($added > 0) $allgroups = $this->selectGroups(); // add membership for new groups foreach ($changes['grps'] as $group) { if (!in_array($group, $oldgroups)) { $ok = $this->joinGroup($olddata, $allgroups[$group]); if ($ok === false) goto FAIL; } } } } $this->pdo->commit(); return true; // something went wrong, rollback FAIL: $this->pdo->rollBack(); $this->debugMsg('Transaction rolled back', 0, __LINE__); msg($this->getLang('writefail'), -1); return false; // return error } /** * Delete one or more users * * Set delUser capability when implemented * * @param array $users * @return int number of users deleted */ public function deleteUsers($users) { $count = 0; foreach ($users as $user) { if ($this->deleteUser($user)) $count++; } return $count; } /** * Bulk retrieval of user data [implement only where required/possible] * * Set getUsers capability when implemented * * @param int $start index of first user to be returned * @param int $limit max number of users to be returned * @param array $filter array of field/pattern pairs, null for no filter * @return array list of userinfo (refer getUserData for internal userinfo details) */ public function retrieveUsers($start = 0, $limit = -1, $filter = null) { if ($limit < 0) $limit = 10000; // we don't support no limit if (is_null($filter)) $filter = []; if (isset($filter['grps'])) $filter['group'] = $filter['grps']; foreach (['user', 'name', 'mail', 'group'] as $key) { if (!isset($filter[$key])) { $filter[$key] = '%'; } else { $filter[$key] = '%' . $filter[$key] . '%'; } } $filter['start'] = (int)$start; $filter['end'] = (int)$start + $limit; $filter['limit'] = (int)$limit; $result = $this->query($this->getConf('list-users'), $filter); if (!$result) return []; $users = []; if (is_array($result)) { foreach ($result as $row) { if (!isset($row['user'])) { $this->debugMsg("list-users statement did not return 'user' attribute", -1, __LINE__); return []; } $users[] = $this->getUserData($row['user']); } } else { $this->debugMsg("list-users statement did not return a list of result", -1, __LINE__); } return $users; } /** * Return a count of the number of user which meet $filter criteria * * @param array $filter array of field/pattern pairs, empty array for no filter * @return int */ public function getUserCount($filter = []) { if (is_null($filter)) $filter = []; if (isset($filter['grps'])) $filter['group'] = $filter['grps']; foreach (['user', 'name', 'mail', 'group'] as $key) { if (!isset($filter[$key])) { $filter[$key] = '%'; } else { $filter[$key] = '%' . $filter[$key] . '%'; } } $result = $this->query($this->getConf('count-users'), $filter); if (!$result || !isset($result[0]['count'])) { $this->debugMsg("Statement did not return 'count' attribute", -1, __LINE__); } return (int)$result[0]['count']; } /** * Create a new group with the given name * * @param string $group * @return bool */ public function addGroup($group) { $sql = $this->getConf('insert-group'); $result = $this->query($sql, [':group' => $group]); $this->clearGroupCache(); if ($result === false) return false; return true; } /** * Retrieve groups * * Set getGroups capability when implemented * * @param int $start * @param int $limit * @return array */ public function retrieveGroups($start = 0, $limit = 0) { $groups = array_keys($this->selectGroups()); if ($groups === false) return []; if (!$limit) { return array_splice($groups, $start); } else { return array_splice($groups, $start, $limit); } } /** * Select data of a specified user * * @param string $user the user name * @return bool|array user data, false on error */ protected function selectUser($user) { $sql = $this->getConf('select-user'); $result = $this->query($sql, [':user' => $user]); if (!$result) return false; if (count($result) > 1) { $this->debugMsg('Found more than one matching user', -1, __LINE__); return false; } $data = array_shift($result); $dataok = true; if (!isset($data['user'])) { $this->debugMsg("Statement did not return 'user' attribute", -1, __LINE__); $dataok = false; } if (!isset($data['hash']) && !isset($data['clear']) && !$this->checkConfig(['check-pass'])) { $this->debugMsg("Statement did not return 'clear' or 'hash' attribute", -1, __LINE__); $dataok = false; } if (!isset($data['name'])) { $this->debugMsg("Statement did not return 'name' attribute", -1, __LINE__); $dataok = false; } if (!isset($data['mail'])) { $this->debugMsg("Statement did not return 'mail' attribute", -1, __LINE__); $dataok = false; } if (!$dataok) return false; return $data; } /** * Delete a user after removing all their group memberships * * @param string $user * @return bool true when the user was deleted */ protected function deleteUser($user) { $this->pdo->beginTransaction(); { $userdata = $this->getUserData($user); if ($userdata === false) goto FAIL; $allgroups = $this->selectGroups(); // remove group memberships (ignore errors) foreach ($userdata['grps'] as $group) { if (isset($allgroups[$group])) { $this->leaveGroup($userdata, $allgroups[$group]); } } $ok = $this->query($this->getConf('delete-user'), $userdata); if ($ok === false) goto FAIL; } $this->pdo->commit(); return true; FAIL: $this->pdo->rollBack(); return false; } /** * Select all groups of a user * * @param array $userdata The userdata as returned by _selectUser() * @return array|bool list of group names, false on error */ protected function selectUserGroups($userdata) { global $conf; $sql = $this->getConf('select-user-groups'); $result = $this->query($sql, $userdata); if ($result === false) return false; $groups = [$conf['defaultgroup']]; // always add default config if (is_array($result)) { foreach ($result as $row) { if (!isset($row['group'])) { $this->debugMsg("No 'group' field returned in select-user-groups statement", -1, __LINE__); return false; } $groups[] = $row['group']; } } else { $this->debugMsg("select-user-groups statement did not return a list of result", -1, __LINE__); } $groups = array_unique($groups); Sort::sort($groups); return $groups; } /** * Select all available groups * * @return array|bool list of all available groups and their properties */ protected function selectGroups() { if ($this->groupcache) return $this->groupcache; $sql = $this->getConf('select-groups'); $result = $this->query($sql); if ($result === false) return false; $groups = []; if (is_array($result)) { foreach ($result as $row) { if (!isset($row['group'])) { $this->debugMsg("No 'group' field returned from select-groups statement", -1, __LINE__); return false; } // relayout result with group name as key $group = $row['group']; $groups[$group] = $row; } } else { $this->debugMsg("select-groups statement did not return a list of result", -1, __LINE__); } Sort::ksort($groups); return $groups; } /** * Remove all entries from the group cache */ protected function clearGroupCache() { $this->groupcache = null; } /** * Adds the user to the group * * @param array $userdata all the user data * @param array $groupdata all the group data * @return bool */ protected function joinGroup($userdata, $groupdata) { $data = array_merge($userdata, $groupdata); $sql = $this->getConf('join-group'); $result = $this->query($sql, $data); if ($result === false) return false; return true; } /** * Removes the user from the group * * @param array $userdata all the user data * @param array $groupdata all the group data * @return bool */ protected function leaveGroup($userdata, $groupdata) { $data = array_merge($userdata, $groupdata); $sql = $this->getConf('leave-group'); $result = $this->query($sql, $data); if ($result === false) return false; return true; } /** * Executes a query * * @param string $sql The SQL statement to execute * @param array $arguments Named parameters to be used in the statement * @return array|int|bool The result as associative array for SELECTs, affected rows for others, false on error */ protected function query($sql, $arguments = []) { $sql = trim($sql); if (empty($sql)) { $this->debugMsg('No SQL query given', -1, __LINE__); return false; } // execute $params = []; $sth = $this->pdo->prepare($sql); $result = false; try { // prepare parameters - we only use those that exist in the SQL foreach ($arguments as $key => $value) { if (is_array($value)) continue; if (is_object($value)) continue; if ($key[0] != ':') $key = ":$key"; // prefix with colon if needed if (strpos($sql, (string) $key) === false) continue; // skip if parameter is missing if (is_int($value)) { $sth->bindValue($key, $value, PDO::PARAM_INT); } else { $sth->bindValue($key, $value); } $params[$key] = $value; //remember for debugging } $sth->execute(); // only report last line's result $hasnextrowset = true; $currentsql = $sql; while ($hasnextrowset) { if (str_starts_with(strtolower($currentsql), 'select')) { $result = $sth->fetchAll(); } else { $result = $sth->rowCount(); } $semi_pos = strpos($currentsql, ';'); if ($semi_pos) { $currentsql = trim(substr($currentsql, $semi_pos + 1)); } try { $hasnextrowset = $sth->nextRowset(); // run next rowset } catch (PDOException $rowset_e) { $hasnextrowset = false; // driver does not support multi-rowset, should be executed in one time } } } catch (Exception $e) { // report the caller's line $trace = debug_backtrace(); $line = $trace[0]['line']; $dsql = $this->debugSQL($sql, $params, !defined('DOKU_UNITTEST')); $this->debugMsg($e, -1, $line); $this->debugMsg("SQL:
$dsql
", -1, $line); } $sth->closeCursor(); return $result; } /** * Wrapper around msg() but outputs only when debug is enabled * * @param string|Exception $message * @param int $err * @param int $line */ protected function debugMsg($message, $err = 0, $line = 0) { if (!$this->getConf('debug')) return; if (is_a($message, 'Exception')) { $err = -1; $msg = $message->getMessage(); if (!$line) $line = $message->getLine(); } else { $msg = $message; } if (defined('DOKU_UNITTEST')) { printf("\n%s, %s:%d\n", $msg, __FILE__, $line); } else { msg('authpdo: ' . $msg, $err, $line, __FILE__); } } /** * Check if the given config strings are set * * @param string[] $keys * @return bool * @author Matthias Grimm * */ protected function checkConfig($keys) { foreach ($keys as $key) { $params = explode(':', $key); $key = array_shift($params); $sql = trim($this->getConf($key)); // check if sql is set if (!$sql) return false; // check if needed params are there foreach ($params as $param) { if (strpos($sql, ":$param") === false) return false; } } return true; } /** * create an approximation of the SQL string with parameters replaced * * @param string $sql * @param array $params * @param bool $htmlescape Should the result be escaped for output in HTML? * @return string */ protected function debugSQL($sql, $params, $htmlescape = true) { foreach ($params as $key => $val) { if (is_int($val)) { $val = $this->pdo->quote($val, PDO::PARAM_INT); } elseif (is_bool($val)) { $val = $this->pdo->quote($val, PDO::PARAM_BOOL); } elseif (is_null($val)) { $val = 'NULL'; } else { $val = $this->pdo->quote($val); } $sql = str_replace($key, $val, $sql); } if ($htmlescape) $sql = hsc($sql); return $sql; } } // vim:ts=4:sw=4:et: