* add UserHelper methods to manipulate user database (add, modify, delete)

* expose said methods via CLI (update.php)
 * fix several invocations of deprecated functions
 * set stricter type hints on several method arguments
This commit is contained in:
Andrew Dolgov 2022-06-10 13:39:00 +03:00
parent 2975c7297b
commit cf1eaeedf3
No known key found for this signature in database
GPG Key ID: 1A56B4FA25D4AF2A
8 changed files with 275 additions and 30 deletions

View File

@ -68,9 +68,9 @@ class Debug {
} }
/** /**
* @param int $level Debug::LOG_* * @param Debug::LOG_* $level
*/ */
public static function set_loglevel(int $level): void { public static function set_loglevel($level): void {
self::$loglevel = $level; self::$loglevel = $level;
} }
@ -82,7 +82,21 @@ class Debug {
} }
/** /**
* @param int $level Debug::LOG_* * @param int $level integer loglevel value
* @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise
*/
public static function map_loglevel(int $level) : int {
if (in_array($level, self::ALL_LOG_LEVELS)) {
/** @phpstan-ignore-next-line */
return $level;
} else {
user_error("Passed invalid debug log level: $level", E_USER_WARNING);
return self::LOG_DISABLED;
}
}
/**
* @param Debug::LOG_* $level log level
*/ */
public static function log(string $message, int $level = Debug::LOG_NORMAL): bool { public static function log(string $message, int $level = Debug::LOG_NORMAL): bool {

View File

@ -665,7 +665,7 @@ class Feeds extends Handler_Protected {
} }
Debug::set_enabled(true); Debug::set_enabled(true);
Debug::set_loglevel($xdebug); Debug::set_loglevel(Debug::map_loglevel($xdebug));
$feed_id = (int)$_REQUEST["feed_id"]; $feed_id = (int)$_REQUEST["feed_id"];
$do_update = ($_REQUEST["action"] ?? "") == "do_update"; $do_update = ($_REQUEST["action"] ?? "") == "do_update";

View File

@ -76,7 +76,7 @@ class Handler_Public extends Handler {
"/public.php?op=rss&id=$feed&key=" . "/public.php?op=rss&id=$feed&key=" .
Feeds::_get_access_key($feed, false, $owner_uid); Feeds::_get_access_key($feed, false, $owner_uid);
if (!$feed_site_url) $feed_site_url = get_self_url_prefix(); if (!$feed_site_url) $feed_site_url = Config::get_self_url();
if ($format == 'atom') { if ($format == 'atom') {
$tpl = new Templator(); $tpl = new Templator();
@ -87,7 +87,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('VERSION', Config::get_version(), true); $tpl->setVariable('VERSION', Config::get_version(), true);
$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true); $tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
$tpl->setVariable('SELF_URL', htmlspecialchars(get_self_url_prefix()), true); $tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true);
while ($line = $result->fetch()) { while ($line = $result->fetch()) {
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...')); $line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
@ -134,7 +134,7 @@ class Handler_Public extends Handler {
$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true); $tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : get_self_url_prefix()), true); $tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true);
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true); $tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
foreach ($line["tags"] as $tag) { foreach ($line["tags"] as $tag) {
@ -312,7 +312,7 @@ class Handler_Public extends Handler {
$login, $user_id); $login, $user_id);
if (!$redirect_url) if (!$redirect_url)
$redirect_url = get_self_url_prefix() . "/index.php"; $redirect_url = Config::get_self_url() . "/index.php";
header("Location: " . $redirect_url); header("Location: " . $redirect_url);
} else { } else {
@ -389,11 +389,11 @@ class Handler_Public extends Handler {
if (UserHelper::authenticate($login, $password)) { if (UserHelper::authenticate($login, $password)) {
$_POST["password"] = ""; $_POST["password"] = "";
if (get_schema_version() >= 120) { if (Config::get_schema_version() >= 120) {
$_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]); $_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
} }
$_SESSION["ref_schema_version"] = get_schema_version(); $_SESSION["ref_schema_version"] = Config::get_schema_version();
$_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false); $_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false);
$_SESSION["safe_mode"] = $safe_mode; $_SESSION["safe_mode"] = $safe_mode;
@ -563,7 +563,7 @@ class Handler_Public extends Handler {
print_notice("Password reset instructions are being sent to your email address."); print_notice("Password reset instructions are being sent to your email address.");
$resetpass_token = sha1(get_random_bytes(128)); $resetpass_token = sha1(get_random_bytes(128));
$resetpass_link = get_self_url_prefix() . "/public.php?op=forgotpass&hash=" . $resetpass_token . $resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
"&login=" . urlencode($login); "&login=" . urlencode($login);
$tpl = new Templator(); $tpl = new Templator();

View File

@ -230,7 +230,7 @@ class Prefs {
} }
} }
if (get_schema_version() >= 141) { if (Config::get_schema_version() >= 141) {
// fill in any overrides from the database // fill in any overrides from the database
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 $sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2
WHERE owner_uid = :uid AND WHERE owner_uid = :uid AND
@ -265,7 +265,7 @@ class Prefs {
if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) { if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) {
$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id); $cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
return Config::cast_to($cached_value, $type_hint); return Config::cast_to($cached_value, $type_hint);
} else if (get_schema_version() >= 141) { } else if (Config::get_schema_version() >= 141) {
$sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2 $sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2
WHERE pref_name = :name AND owner_uid = :uid AND WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))"); (profile = :profile OR (:profile IS NULL AND profile IS NULL))");
@ -390,7 +390,7 @@ class Prefs {
} }
function migrate(int $owner_uid, ?int $profile_id): void { function migrate(int $owner_uid, ?int $profile_id): void {
if (get_schema_version() < 141) if (Config::get_schema_version() < 141)
return; return;
if (!$profile_id) $profile_id = null; if (!$profile_id) $profile_id = null;

View File

@ -17,6 +17,15 @@ class UserHelper {
self::HASH_ALGO_SHA1 self::HASH_ALGO_SHA1
]; ];
const ACCESS_LEVELS = [
self::ACCESS_LEVEL_DISABLED,
self::ACCESS_LEVEL_READONLY,
self::ACCESS_LEVEL_USER,
self::ACCESS_LEVEL_POWERUSER,
self::ACCESS_LEVEL_ADMIN,
self::ACCESS_LEVEL_KEEP_CURRENT
];
/** forbidden to login */ /** forbidden to login */
const ACCESS_LEVEL_DISABLED = -2; const ACCESS_LEVEL_DISABLED = -2;
@ -32,6 +41,23 @@ class UserHelper {
/** has administrator permissions */ /** has administrator permissions */
const ACCESS_LEVEL_ADMIN = 10; const ACCESS_LEVEL_ADMIN = 10;
/** used by self::user_modify() to keep current access level */
const ACCESS_LEVEL_KEEP_CURRENT = -1024;
/**
* @param int $level integer loglevel value
* @return UserHelper::ACCESS_LEVEL_* if valid, warn and return ACCESS_LEVEL_KEEP_CURRENT otherwise
*/
public static function map_access_level(int $level) : int {
if (in_array($level, self::ACCESS_LEVELS)) {
/** @phpstan-ignore-next-line */
return $level;
} else {
user_error("Passed invalid user access level: $level", E_USER_WARNING);
return self::ACCESS_LEVEL_KEEP_CURRENT;
}
}
static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null): bool { static function authenticate(string $login = null, string $password = null, bool $check_only = false, string $service = null): bool {
if (!Config::get(Config::SINGLE_USER_MODE)) { if (!Config::get(Config::SINGLE_USER_MODE)) {
$user_id = false; $user_id = false;
@ -133,7 +159,7 @@ class UserHelper {
if (empty($_SESSION["uid"])) { if (empty($_SESSION["uid"])) {
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) { if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
$_SESSION["ref_schema_version"] = get_schema_version(); $_SESSION["ref_schema_version"] = Config::get_schema_version();
} else { } else {
self::authenticate(null, null, true); self::authenticate(null, null, true);
} }
@ -217,6 +243,7 @@ class UserHelper {
return substr(bin2hex(get_random_bytes(125)), 0, 250); return substr(bin2hex(get_random_bytes(125)), 0, 250);
} }
/** TODO: this should invoke UserHelper::user_modify() */
static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void { static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void {
$user = ORM::for_table('ttrss_users')->find_one($uid); $user = ORM::for_table('ttrss_users')->find_one($uid);
@ -380,4 +407,89 @@ class UserHelper {
else else
return false; return false;
} }
/**
* @param string $login Login for new user (case-insensitive)
* @param string $password Password for new user (may not be blank)
* @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user
* @return bool true if user has been created
*/
static function user_add(string $login, string $password, int $access_level) : bool {
$login = clean($login);
if ($login &&
$password &&
!self::find_user_by_login($login) &&
self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) {
$user = ORM::for_table('ttrss_users')->create();
$user->salt = self::get_salt();
$user->login = mb_strtolower($login);
$user->pwd_hash = self::hash_password($password, $user->salt);
$user->access_level = $access_level;
$user->created = Db::NOW();
return $user->save();
}
return false;
}
/**
* @param int $uid User ID to modify
* @param string $new_password set password to this value if its not blank
* @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT)
* @return bool true if user record has been saved
*
* NOTE: $access_level is of mixed type because of intellephense
*/
static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool {
$user = ORM::for_table('ttrss_users')->find_one($uid);
if ($user) {
if ($new_password != '') {
$new_salt = self::get_salt();
$pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]);
$user->pwd_hash = $pwd_hash;
$user->salt = $new_salt;
}
if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) {
$user->access_level = (int)$access_level;
}
return $user->save();
}
return false;
}
/**
* @param int $uid user ID to delete (this won't delete built-in admin user with UID 1)
* @return bool true if user has been deleted
*/
static function user_delete(int $uid) : bool {
if ($uid != 1) {
$user = ORM::for_table('ttrss_users')->find_one($uid);
if ($user) {
// TODO: is it still necessary to split those queries?
ORM::for_table('ttrss_tags')
->where('owner_uid', $uid)
->delete_many();
ORM::for_table('ttrss_feeds')
->where('owner_uid', $uid)
->delete_many();
return $user->delete();
}
}
return false;
}
} }

View File

@ -18,14 +18,14 @@ class Auth_Internal extends Auth_Base {
$otp = (int) ($_REQUEST["otp"] ?? 0); $otp = (int) ($_REQUEST["otp"] ?? 0);
// don't bother with null/null logins for auth_external etc // don't bother with null/null logins for auth_external etc
if ($login && get_schema_version() > 96) { if ($login && Config::get_schema_version() > 96) {
$user_id = UserHelper::find_user_by_login($login); $user_id = UserHelper::find_user_by_login($login);
if ($user_id && UserHelper::is_otp_enabled($user_id)) { if ($user_id && UserHelper::is_otp_enabled($user_id)) {
// only allow app passwords for service logins if OTP is enabled // only allow app passwords for service logins if OTP is enabled
if ($service && get_schema_version() > 138) { if ($service && Config::get_schema_version() > 138) {
return $this->check_app_password($login, $password, $service); return $this->check_app_password($login, $password, $service);
} }
@ -106,7 +106,7 @@ class Auth_Internal extends Auth_Base {
// service logins: check app passwords first but allow regular password // service logins: check app passwords first but allow regular password
// as a fallback if OTP is not enabled // as a fallback if OTP is not enabled
if ($service && get_schema_version() > 138) { if ($service && Config::get_schema_version() > 138) {
$user_id = $this->check_app_password($login, $password, $service); $user_id = $this->check_app_password($login, $password, $service);
if ($user_id) if ($user_id)
@ -119,7 +119,7 @@ class Auth_Internal extends Auth_Base {
->find_one(); ->find_one();
if ($user) { if ($user) {
if (get_schema_version() >= 145) { if (Config::get_schema_version() >= 145) {
if ($user->last_auth_attempt) { if ($user->last_auth_attempt) {
$last_auth_attempt = strtotime($user->last_auth_attempt); $last_auth_attempt = strtotime($user->last_auth_attempt);
@ -145,7 +145,7 @@ class Auth_Internal extends Auth_Base {
if ($auth_result) { if ($auth_result) {
return $auth_result; return $auth_result;
} else { } else {
if (get_schema_version() >= 145) { if (Config::get_schema_version() >= 145) {
$user->last_auth_attempt = Db::NOW(); $user->last_auth_attempt = Db::NOW();
$user->save(); $user->save();
} }
@ -176,7 +176,7 @@ class Auth_Internal extends Auth_Base {
list ($pwd_algo, $raw_hash) = explode(":", $pwd_hash, 2); list ($pwd_algo, $raw_hash) = explode(":", $pwd_hash, 2);
// check app password only if service is specified // check app password only if service is specified
if ($service && get_schema_version() > 138) { if ($service && Config::get_schema_version() > 138) {
return $this->check_app_password($login, $password, $service); return $this->check_app_password($login, $password, $service);
} }

View File

@ -99,8 +99,12 @@
"opml-export:" => ["USER:FILE", "export OPML of USER to FILE"], "opml-export:" => ["USER:FILE", "export OPML of USER to FILE"],
"opml-import:" => ["USER:FILE", "import OPML for USER from FILE"], "opml-import:" => ["USER:FILE", "import OPML for USER from FILE"],
"user-list" => "list all users", "user-list" => "list all users",
# "user-add:" => ["USER[:PASSWORD]", "add USER, optionally without prompting for PASSWORD"], "user-add:" => ["USER[:PASSWORD[:ACCESS_LEVEL=0]]", "add USER, prompts for password if unset"],
# "user-remove:" => ["USERNAME", "remove specified user"], "user-remove:" => ["USERNAME", "remove USER"],
"user-set-password:" => ["USER:PASSWORD", "sets PASSWORD of specified USER"],
"user-set-access-level:" => ["USER:LEVEL", "sets access LEVEL of specified USER"],
"user-exists:" => ["USER", "returns 0 if specified USER exists in the database"],
"force-yes" => "assume 'yes' to all queries",
"help" => "", "help" => "",
]; ];
@ -150,7 +154,7 @@
Debug::set_enabled(true); Debug::set_enabled(true);
if (isset($options["log-level"])) { if (isset($options["log-level"])) {
Debug::set_loglevel((int)$options["log-level"]); Debug::set_loglevel(Debug::map_loglevel((int)$options["log-level"]));
} }
if (isset($options["log"])) { if (isset($options["log"])) {
@ -159,7 +163,7 @@
Debug::log("Logging to " . $options["log"]); Debug::log("Logging to " . $options["log"]);
} else { } else {
if (isset($options['quiet'])) { if (isset($options['quiet'])) {
Debug::set_loglevel(Debug::$LOG_DISABLED); Debug::set_loglevel(Debug::LOG_DISABLED);
} }
} }
@ -265,7 +269,7 @@
if (isset($options["update-schema"])) { if (isset($options["update-schema"])) {
if (Config::is_migration_needed()) { if (Config::is_migration_needed()) {
if ($options["update-schema"] != "force-yes") { if (!isset($options['force-yes']) || $options["update-schema"] != "force-yes") {
Debug::log("Type 'yes' to continue."); Debug::log("Type 'yes' to continue.");
if (read_stdin() != 'yes') if (read_stdin() != 'yes')
@ -275,7 +279,7 @@
} }
if (!isset($options["log-level"])) { if (!isset($options["log-level"])) {
Debug::set_loglevel(Debug::$LOG_VERBOSE); Debug::set_loglevel(Debug::LOG_VERBOSE);
} }
$migrations = Config::get_migrations(); $migrations = Config::get_migrations();
@ -352,7 +356,7 @@
if (isset($options["force-refetch"])) $_REQUEST["force_refetch"] = true; if (isset($options["force-refetch"])) $_REQUEST["force_refetch"] = true;
if (isset($options["force-rehash"])) $_REQUEST["force_rehash"] = true; if (isset($options["force-rehash"])) $_REQUEST["force_rehash"] = true;
Debug::set_loglevel(Debug::$LOG_EXTENDED); Debug::set_loglevel(Debug::LOG_EXTENDED);
$rc = RSSUtils::update_rss_feed($feed) != false ? 0 : 1; $rc = RSSUtils::update_rss_feed($feed) != false ? 0 : 1;
@ -407,6 +411,121 @@
} }
if (isset($options["user-add"])) {
list ($login, $password, $access_level) = explode(":", $options["user-add"], 3);
$uid = UserHelper::find_user_by_login($login);
if ($uid) {
Debug::log("Error: User already exists: $login");
exit(1);
}
if (!$access_level)
$access_level = UserHelper::ACCESS_LEVEL_USER;
if (!in_array($access_level, UserHelper::ACCESS_LEVELS)) {
Debug::log("Error: Invalid access level value: $access_level");
exit(1);
}
if (!$password) {
Debug::log("Please enter password for user $login: ");
$password = read_stdin();
if (!$password) {
Debug::log("Error: password may not be blank.");
exit(1);
}
}
Debug::log("Adding user $login with access level $access_level...");
if (UserHelper::user_add($login, $password, $access_level)) {
Debug::log("Success.");
} else {
Debug::log("Operation failed, check the logs for more information.");
}
}
if (isset($options["user-set-password"])) {
list ($login, $password) = explode(":", $options["user-set-password"], 2);
$uid = UserHelper::find_user_by_login($login);
if (!$uid) {
Debug::log("Error: User not found: $login");
exit(1);
}
Debug::log("Changing password of user $login...");
if (UserHelper::user_modify($uid, $password)) {
Debug::log("Success.");
} else {
Debug::log("Operation failed, check the logs for more information.");
}
}
if (isset($options["user-set-access-level"])) {
list ($login, $access_level) = explode(":", $options["user-set-access-level"], 2);
$uid = UserHelper::find_user_by_login($login);
if (!$uid) {
Debug::log("Error: User not found: $login");
exit(1);
}
if (!in_array($access_level, UserHelper::ACCESS_LEVELS)) {
Debug::log("Error: Invalid access level value: $access_level");
exit(1);
}
Debug::log("Changing access level of user $login...");
if (UserHelper::user_modify($uid, '', UserHelper::map_access_level((int)$access_level))) {
Debug::log("Success.");
} else {
Debug::log("Operation failed, check the logs for more information.");
}
}
if (isset($options["user-remove"])) {
$login = $options["user-remove"];
$uid = UserHelper::find_user_by_login($login);
if (!$uid) {
Debug::log("Error: User not found: $login");
exit(1);
}
if (!isset($options['force-yes'])) {
Debug::log("About to remove user $login. Type 'yes' to continue.");
if (read_stdin() != 'yes')
exit(1);
}
Debug::log("Removing user $login...");
if (UserHelper::user_delete($uid)) {
Debug::log("Success.");
} else {
Debug::log("Operation failed, check the logs for more information.");
}
}
if (isset($options["user-exists"])) {
$login = $options["user-exists"];
if (UserHelper::find_user_by_login($login))
exit(0);
else
exit(1);
}
PluginHost::getInstance()->run_commands($options); PluginHost::getInstance()->run_commands($options);
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/$lock_filename")) if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/$lock_filename"))

View File

@ -146,7 +146,7 @@
Debug::set_enabled(true); Debug::set_enabled(true);
if (isset($options["log-level"])) { if (isset($options["log-level"])) {
Debug::set_loglevel((int)$options["log-level"]); Debug::set_loglevel(Debug::map_loglevel((int)$options["log-level"]));
} }
if (isset($options["log"])) { if (isset($options["log"])) {
@ -155,7 +155,7 @@
Debug::log("Logging to " . $options["log"]); Debug::log("Logging to " . $options["log"]);
} else { } else {
if (isset($options['quiet'])) { if (isset($options['quiet'])) {
Debug::set_loglevel(Debug::$LOG_DISABLED); Debug::set_loglevel(Debug::LOG_DISABLED);
} }
} }