From: Ben Deutsch Date: Sun, 5 Aug 2018 11:13:38 +0000 (+0200) Subject: Replace auth.txt with SQLite auth database (#7279) X-Git-Tag: 5.0.0~268 X-Git-Url: https://git.librecmc.org/?a=commitdiff_plain;h=153fb211ac2342907eb766a79c1f41824f981ab5;p=oweals%2Fminetest.git Replace auth.txt with SQLite auth database (#7279) * Replace auth.txt with SQLite auth database --- diff --git a/build/android/jni/Android.mk b/build/android/jni/Android.mk index c8057a4dd..548e56ffe 100644 --- a/build/android/jni/Android.mk +++ b/build/android/jni/Android.mk @@ -251,6 +251,7 @@ LOCAL_SRC_FILES := \ jni/src/util/srp.cpp \ jni/src/util/timetaker.cpp \ jni/src/unittest/test.cpp \ + jni/src/unittest/test_authdatabase.cpp \ jni/src/unittest/test_collision.cpp \ jni/src/unittest/test_compression.cpp \ jni/src/unittest/test_connection.cpp \ @@ -331,6 +332,7 @@ LOCAL_SRC_FILES += \ jni/src/script/cpp_api/s_security.cpp \ jni/src/script/cpp_api/s_server.cpp \ jni/src/script/lua_api/l_areastore.cpp \ + jni/src/script/lua_api/l_auth.cpp \ jni/src/script/lua_api/l_base.cpp \ jni/src/script/lua_api/l_camera.cpp \ jni/src/script/lua_api/l_client.cpp \ diff --git a/builtin/game/auth.lua b/builtin/game/auth.lua index ad2f35a13..7aedfc82e 100644 --- a/builtin/game/auth.lua +++ b/builtin/game/auth.lua @@ -4,72 +4,22 @@ -- Builtin authentication handler -- -local auth_file_path = core.get_worldpath().."/auth.txt" -local auth_table = {} - -local function read_auth_file() - local newtable = {} - local file, errmsg = io.open(auth_file_path, 'rb') - if not file then - core.log("info", auth_file_path.." could not be opened for reading ("..errmsg.."); assuming new world") - return - end - for line in file:lines() do - if line ~= "" then - local fields = line:split(":", true) - local name, password, privilege_string, last_login = unpack(fields) - last_login = tonumber(last_login) - if not (name and password and privilege_string) then - error("Invalid line in auth.txt: "..dump(line)) - end - local privileges = core.string_to_privs(privilege_string) - newtable[name] = {password=password, privileges=privileges, last_login=last_login} - end - end - io.close(file) - auth_table = newtable - core.notify_authentication_modified() -end - -local function save_auth_file() - local newtable = {} - -- Check table for validness before attempting to save - for name, stuff in pairs(auth_table) do - assert(type(name) == "string") - assert(name ~= "") - assert(type(stuff) == "table") - assert(type(stuff.password) == "string") - assert(type(stuff.privileges) == "table") - assert(stuff.last_login == nil or type(stuff.last_login) == "number") - end - local content = {} - for name, stuff in pairs(auth_table) do - local priv_string = core.privs_to_string(stuff.privileges) - local parts = {name, stuff.password, priv_string, stuff.last_login or ""} - content[#content + 1] = table.concat(parts, ":") - end - if not core.safe_file_write(auth_file_path, table.concat(content, "\n")) then - error(auth_file_path.." could not be written to") - end -end - -read_auth_file() +-- Make the auth object private, deny access to mods +local core_auth = core.auth +core.auth = nil core.builtin_auth_handler = { get_auth = function(name) assert(type(name) == "string") - -- Figure out what password to use for a new player (singleplayer - -- always has an empty password, otherwise use default, which is - -- usually empty too) - local new_password_hash = "" - -- If not in authentication table, return nil - if not auth_table[name] then + local auth_entry = core_auth.read(name) + -- If no such auth found, return nil + if not auth_entry then return nil end -- Figure out what privileges the player should have. -- Take a copy of the privilege table local privileges = {} - for priv, _ in pairs(auth_table[name].privileges) do + for priv, _ in pairs(auth_entry.privileges) do privileges[priv] = true end -- If singleplayer, give all privileges except those marked as give_to_singleplayer = false @@ -89,85 +39,89 @@ core.builtin_auth_handler = { end -- All done return { - password = auth_table[name].password, + password = auth_entry.password, privileges = privileges, -- Is set to nil if unknown - last_login = auth_table[name].last_login, + last_login = auth_entry.last_login, } end, create_auth = function(name, password) assert(type(name) == "string") assert(type(password) == "string") core.log('info', "Built-in authentication handler adding player '"..name.."'") - auth_table[name] = { + return core_auth.create({ + name = name, password = password, privileges = core.string_to_privs(core.settings:get("default_privs")), last_login = os.time(), - } - save_auth_file() + }) end, delete_auth = function(name) assert(type(name) == "string") - if not auth_table[name] then + local auth_entry = core_auth.read(name) + if not auth_entry then return false end core.log('info', "Built-in authentication handler deleting player '"..name.."'") - auth_table[name] = nil - save_auth_file() - return true + return core_auth.delete(name) end, set_password = function(name, password) assert(type(name) == "string") assert(type(password) == "string") - if not auth_table[name] then + local auth_entry = core_auth.read(name) + if not auth_entry then core.builtin_auth_handler.create_auth(name, password) else core.log('info', "Built-in authentication handler setting password of player '"..name.."'") - auth_table[name].password = password - save_auth_file() + auth_entry.password = password + core_auth.save(auth_entry) end return true end, set_privileges = function(name, privileges) assert(type(name) == "string") assert(type(privileges) == "table") - if not auth_table[name] then - core.builtin_auth_handler.create_auth(name, + local auth_entry = core_auth.read(name) + if not auth_entry then + auth_entry = core.builtin_auth_handler.create_auth(name, core.get_password_hash(name, core.settings:get("default_password"))) end -- Run grant callbacks for priv, _ in pairs(privileges) do - if not auth_table[name].privileges[priv] then + if not auth_entry.privileges[priv] then core.run_priv_callbacks(name, priv, nil, "grant") end end -- Run revoke callbacks - for priv, _ in pairs(auth_table[name].privileges) do + for priv, _ in pairs(auth_entry.privileges) do if not privileges[priv] then core.run_priv_callbacks(name, priv, nil, "revoke") end end - auth_table[name].privileges = privileges + auth_entry.privileges = privileges + core_auth.save(auth_entry) core.notify_authentication_modified(name) - save_auth_file() end, reload = function() - read_auth_file() + core_auth.reload() return true end, record_login = function(name) assert(type(name) == "string") - assert(auth_table[name]).last_login = os.time() - save_auth_file() + local auth_entry = core_auth.read(name) + assert(auth_entry) + auth_entry.last_login = os.time() + core_auth.save(auth_entry) end, iterate = function() local names = {} - for k in pairs(auth_table) do - names[k] = true + local nameslist = core_auth.list_names() + for k,v in pairs(nameslist) do + names[v] = true end return pairs(names) end, @@ -177,12 +131,13 @@ core.register_on_prejoinplayer(function(name, ip) if core.registered_auth_handler ~= nil then return -- Don't do anything if custom auth handler registered end - if auth_table[name] ~= nil then + local auth_entry = core_auth.read(name) + if auth_entry ~= nil then return end local name_lower = name:lower() - for k in pairs(auth_table) do + for k in core.builtin_auth_handler.iterate() do if k:lower() == name_lower then return string.format("\nCannot create new player called '%s'. ".. "Another account called '%s' is already registered. ".. diff --git a/doc/world_format.txt b/doc/world_format.txt index 9b0a1ef07..c5d1d1be1 100644 --- a/doc/world_format.txt +++ b/doc/world_format.txt @@ -29,6 +29,7 @@ It can be copied over from an old world to a newly created world. World |-- auth.txt ----- Authentication data +|-- auth.sqlite -- Authentication data (SQLite alternative) |-- env_meta.txt - Environment metadata |-- ipban.txt ---- Banned ips/users |-- map_meta.txt - Map metadata @@ -62,6 +63,34 @@ Example lines: - Player "bar", no password, no privileges: bar:: +auth.sqlite +------------ +Contains authentification data as an SQLite database. This replaces auth.txt +above when auth_backend is set to "sqlite3" in world.mt . + +This database contains two tables "auth" and "user_privileges": + +CREATE TABLE `auth` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT, + `name` VARCHAR(32) UNIQUE, + `password` VARCHAR(512), + `last_login` INTEGER +); +CREATE TABLE `user_privileges` ( + `id` INTEGER, + `privilege` VARCHAR(32), + PRIMARY KEY (id, privilege) + CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES auth (id) ON DELETE CASCADE +); + +The "name" and "password" fields of the auth table are the same as the auth.txt +fields (with modern password hash). The "last_login" field is the last login +time as a unix time stamp. + +The "user_privileges" table contains one entry per privilege and player. +A player with "interact" and "shout" privileges will have two entries, one +with privilege="interact" and the second with privilege="shout". + env_meta.txt ------------- Simple global environment variables. @@ -107,6 +136,7 @@ Example content (added indentation and - explanations): readonly_backend = sqlite3 - optionally readonly seed DB (DB file _must_ be located in "readonly" subfolder) server_announce = false - whether the server is publicly announced or not load_mod_ = false - whether is to be loaded in this world + auth_backend = files - which DB backend to use for authentication data Player File Format =================== diff --git a/src/database/database-files.cpp b/src/database/database-files.cpp index 70de8c8d2..64eca394e 100644 --- a/src/database/database-files.cpp +++ b/src/database/database-files.cpp @@ -25,6 +25,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "settings.h" #include "porting.h" #include "filesys.h" +#include "util/string.h" // !!! WARNING !!! // This backend is intended to be used on Minetest 0.4.16 only for the transition backend @@ -177,3 +178,105 @@ void PlayerDatabaseFiles::listPlayers(std::vector &res) res.emplace_back(player.getName()); } } + +AuthDatabaseFiles::AuthDatabaseFiles(const std::string &savedir) : m_savedir(savedir) +{ + readAuthFile(); +} + +bool AuthDatabaseFiles::getAuth(const std::string &name, AuthEntry &res) +{ + const auto res_i = m_auth_list.find(name); + if (res_i == m_auth_list.end()) { + return false; + } + res = res_i->second; + return true; +} + +bool AuthDatabaseFiles::saveAuth(const AuthEntry &authEntry) +{ + m_auth_list[authEntry.name] = authEntry; + + // save entire file + return writeAuthFile(); +} + +bool AuthDatabaseFiles::createAuth(AuthEntry &authEntry) +{ + m_auth_list[authEntry.name] = authEntry; + + // save entire file + return writeAuthFile(); +} + +bool AuthDatabaseFiles::deleteAuth(const std::string &name) +{ + if (!m_auth_list.erase(name)) { + // did not delete anything -> hadn't existed + return false; + } + return writeAuthFile(); +} + +void AuthDatabaseFiles::listNames(std::vector &res) +{ + res.clear(); + res.reserve(m_auth_list.size()); + for (const auto &res_pair : m_auth_list) { + res.push_back(res_pair.first); + } +} + +void AuthDatabaseFiles::reload() +{ + readAuthFile(); +} + +bool AuthDatabaseFiles::readAuthFile() +{ + std::string path = m_savedir + DIR_DELIM + "auth.txt"; + std::ifstream file(path, std::ios::binary); + if (!file.good()) { + return false; + } + m_auth_list.clear(); + while (file.good()) { + std::string line; + std::getline(file, line); + std::vector parts = str_split(line, ':'); + if (parts.size() < 3) // also: empty line at end + continue; + const std::string &name = parts[0]; + const std::string &password = parts[1]; + std::vector privileges = str_split(parts[2], ','); + s64 last_login = parts.size() > 3 ? atol(parts[3].c_str()) : 0; + + m_auth_list[name] = { + 1, + name, + password, + privileges, + last_login, + }; + } + return true; +} + +bool AuthDatabaseFiles::writeAuthFile() +{ + std::string path = m_savedir + DIR_DELIM + "auth.txt"; + std::ostringstream output(std::ios_base::binary); + for (const auto &auth_i : m_auth_list) { + const AuthEntry &authEntry = auth_i.second; + output << authEntry.name << ":" << authEntry.password << ":"; + output << str_join(authEntry.privileges, ","); + output << ":" << authEntry.last_login; + output << std::endl; + } + if (!fs::safeWriteToFile(path, output.str())) { + infostream << "Failed to write " << path << std::endl; + return false; + } + return true; +} diff --git a/src/database/database-files.h b/src/database/database-files.h index f0824a304..218815cf7 100644 --- a/src/database/database-files.h +++ b/src/database/database-files.h @@ -24,6 +24,7 @@ with this program; if not, write to the Free Software Foundation, Inc., // for player files #include "database.h" +#include class PlayerDatabaseFiles : public PlayerDatabase { @@ -41,3 +42,23 @@ private: std::string m_savedir; }; + +class AuthDatabaseFiles : public AuthDatabase +{ +public: + AuthDatabaseFiles(const std::string &savedir); + virtual ~AuthDatabaseFiles() = default; + + virtual bool getAuth(const std::string &name, AuthEntry &res); + virtual bool saveAuth(const AuthEntry &authEntry); + virtual bool createAuth(AuthEntry &authEntry); + virtual bool deleteAuth(const std::string &name); + virtual void listNames(std::vector &res); + virtual void reload(); + +private: + std::unordered_map m_auth_list; + std::string m_savedir; + bool readAuthFile(); + bool writeAuthFile(); +}; diff --git a/src/database/database-sqlite3.cpp b/src/database/database-sqlite3.cpp index 76935ada4..97b0fd36a 100644 --- a/src/database/database-sqlite3.cpp +++ b/src/database/database-sqlite3.cpp @@ -606,3 +606,170 @@ void PlayerDatabaseSQLite3::listPlayers(std::vector &res) sqlite3_reset(m_stmt_player_list); } + +/* + * Auth database + */ + +AuthDatabaseSQLite3::AuthDatabaseSQLite3(const std::string &savedir) : + Database_SQLite3(savedir, "auth"), AuthDatabase() +{ +} + +AuthDatabaseSQLite3::~AuthDatabaseSQLite3() +{ + FINALIZE_STATEMENT(m_stmt_read) + FINALIZE_STATEMENT(m_stmt_write) + FINALIZE_STATEMENT(m_stmt_create) + FINALIZE_STATEMENT(m_stmt_delete) + FINALIZE_STATEMENT(m_stmt_list_names) + FINALIZE_STATEMENT(m_stmt_read_privs) + FINALIZE_STATEMENT(m_stmt_write_privs) + FINALIZE_STATEMENT(m_stmt_delete_privs) + FINALIZE_STATEMENT(m_stmt_last_insert_rowid) +} + +void AuthDatabaseSQLite3::createDatabase() +{ + assert(m_database); // Pre-condition + + SQLOK(sqlite3_exec(m_database, + "CREATE TABLE IF NOT EXISTS `auth` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT," + "`name` VARCHAR(32) UNIQUE," + "`password` VARCHAR(512)," + "`last_login` INTEGER" + ");", + NULL, NULL, NULL), + "Failed to create auth table"); + + SQLOK(sqlite3_exec(m_database, + "CREATE TABLE IF NOT EXISTS `user_privileges` (" + "`id` INTEGER," + "`privilege` VARCHAR(32)," + "PRIMARY KEY (id, privilege)" + "CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES auth (id) ON DELETE CASCADE" + ");", + NULL, NULL, NULL), + "Failed to create auth privileges table"); +} + +void AuthDatabaseSQLite3::initStatements() +{ + PREPARE_STATEMENT(read, "SELECT id, name, password, last_login FROM auth WHERE name = ?"); + PREPARE_STATEMENT(write, "UPDATE auth set name = ?, password = ?, last_login = ? WHERE id = ?"); + PREPARE_STATEMENT(create, "INSERT INTO auth (name, password, last_login) VALUES (?, ?, ?)"); + PREPARE_STATEMENT(delete, "DELETE FROM auth WHERE name = ?"); + + PREPARE_STATEMENT(list_names, "SELECT name FROM auth ORDER BY name DESC"); + + PREPARE_STATEMENT(read_privs, "SELECT privilege FROM user_privileges WHERE id = ?"); + PREPARE_STATEMENT(write_privs, "INSERT OR IGNORE INTO user_privileges (id, privilege) VALUES (?, ?)"); + PREPARE_STATEMENT(delete_privs, "DELETE FROM user_privileges WHERE id = ?"); + + PREPARE_STATEMENT(last_insert_rowid, "SELECT last_insert_rowid()"); +} + +bool AuthDatabaseSQLite3::getAuth(const std::string &name, AuthEntry &res) +{ + verifyDatabase(); + str_to_sqlite(m_stmt_read, 1, name); + if (sqlite3_step(m_stmt_read) != SQLITE_ROW) { + sqlite3_reset(m_stmt_read); + return false; + } + res.id = sqlite_to_uint(m_stmt_read, 0); + res.name = sqlite_to_string(m_stmt_read, 1); + res.password = sqlite_to_string(m_stmt_read, 2); + res.last_login = sqlite_to_int64(m_stmt_read, 3); + sqlite3_reset(m_stmt_read); + + int64_to_sqlite(m_stmt_read_privs, 1, res.id); + while (sqlite3_step(m_stmt_read_privs) == SQLITE_ROW) { + res.privileges.emplace_back(sqlite_to_string(m_stmt_read_privs, 0)); + } + sqlite3_reset(m_stmt_read_privs); + + return true; +} + +bool AuthDatabaseSQLite3::saveAuth(const AuthEntry &authEntry) +{ + beginSave(); + + str_to_sqlite(m_stmt_write, 1, authEntry.name); + str_to_sqlite(m_stmt_write, 2, authEntry.password); + int64_to_sqlite(m_stmt_write, 3, authEntry.last_login); + int64_to_sqlite(m_stmt_write, 4, authEntry.id); + sqlite3_vrfy(sqlite3_step(m_stmt_write), SQLITE_DONE); + sqlite3_reset(m_stmt_write); + + writePrivileges(authEntry); + + endSave(); + return true; +} + +bool AuthDatabaseSQLite3::createAuth(AuthEntry &authEntry) +{ + beginSave(); + + // id autoincrements + str_to_sqlite(m_stmt_create, 1, authEntry.name); + str_to_sqlite(m_stmt_create, 2, authEntry.password); + int64_to_sqlite(m_stmt_create, 3, authEntry.last_login); + sqlite3_vrfy(sqlite3_step(m_stmt_create), SQLITE_DONE); + sqlite3_reset(m_stmt_create); + + // obtain id and write back to original authEntry + sqlite3_step(m_stmt_last_insert_rowid); + authEntry.id = sqlite_to_uint(m_stmt_last_insert_rowid, 0); + sqlite3_reset(m_stmt_last_insert_rowid); + + writePrivileges(authEntry); + + endSave(); + return true; +} + +bool AuthDatabaseSQLite3::deleteAuth(const std::string &name) +{ + verifyDatabase(); + + str_to_sqlite(m_stmt_delete, 1, name); + sqlite3_vrfy(sqlite3_step(m_stmt_delete), SQLITE_DONE); + int changes = sqlite3_changes(m_database); + sqlite3_reset(m_stmt_delete); + + // privileges deleted by foreign key on delete cascade + + return changes > 0; +} + +void AuthDatabaseSQLite3::listNames(std::vector &res) +{ + verifyDatabase(); + + while (sqlite3_step(m_stmt_list_names) == SQLITE_ROW) { + res.push_back(sqlite_to_string(m_stmt_list_names, 0)); + } + sqlite3_reset(m_stmt_list_names); +} + +void AuthDatabaseSQLite3::reload() +{ + // noop for SQLite +} + +void AuthDatabaseSQLite3::writePrivileges(const AuthEntry &authEntry) +{ + int64_to_sqlite(m_stmt_delete_privs, 1, authEntry.id); + sqlite3_vrfy(sqlite3_step(m_stmt_delete_privs), SQLITE_DONE); + sqlite3_reset(m_stmt_delete_privs); + for (const std::string &privilege : authEntry.privileges) { + int64_to_sqlite(m_stmt_write_privs, 1, authEntry.id); + str_to_sqlite(m_stmt_write_privs, 2, privilege); + sqlite3_vrfy(sqlite3_step(m_stmt_write_privs), SQLITE_DONE); + sqlite3_reset(m_stmt_write_privs); + } +} diff --git a/src/database/database-sqlite3.h b/src/database/database-sqlite3.h index 8d9f91f21..d7202a918 100644 --- a/src/database/database-sqlite3.h +++ b/src/database/database-sqlite3.h @@ -85,6 +85,16 @@ protected: return (u32) sqlite3_column_int(s, iCol); } + inline s64 sqlite_to_int64(sqlite3_stmt *s, int iCol) + { + return (s64) sqlite3_column_int64(s, iCol); + } + + inline u64 sqlite_to_uint64(sqlite3_stmt *s, int iCol) + { + return (u64) sqlite3_column_int64(s, iCol); + } + inline float sqlite_to_float(sqlite3_stmt *s, int iCol) { return (float) sqlite3_column_double(s, iCol); @@ -191,3 +201,34 @@ private: sqlite3_stmt *m_stmt_player_metadata_remove = nullptr; sqlite3_stmt *m_stmt_player_metadata_add = nullptr; }; + +class AuthDatabaseSQLite3 : private Database_SQLite3, public AuthDatabase +{ +public: + AuthDatabaseSQLite3(const std::string &savedir); + virtual ~AuthDatabaseSQLite3(); + + virtual bool getAuth(const std::string &name, AuthEntry &res); + virtual bool saveAuth(const AuthEntry &authEntry); + virtual bool createAuth(AuthEntry &authEntry); + virtual bool deleteAuth(const std::string &name); + virtual void listNames(std::vector &res); + virtual void reload(); + +protected: + virtual void createDatabase(); + virtual void initStatements(); + +private: + virtual void writePrivileges(const AuthEntry &authEntry); + + sqlite3_stmt *m_stmt_read = nullptr; + sqlite3_stmt *m_stmt_write = nullptr; + sqlite3_stmt *m_stmt_create = nullptr; + sqlite3_stmt *m_stmt_delete = nullptr; + sqlite3_stmt *m_stmt_list_names = nullptr; + sqlite3_stmt *m_stmt_read_privs = nullptr; + sqlite3_stmt *m_stmt_write_privs = nullptr; + sqlite3_stmt *m_stmt_delete_privs = nullptr; + sqlite3_stmt *m_stmt_last_insert_rowid = nullptr; +}; diff --git a/src/database/database.h b/src/database/database.h index 9926c7b93..b7d551935 100644 --- a/src/database/database.h +++ b/src/database/database.h @@ -19,6 +19,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #pragma once +#include #include #include #include "irr_v3d.h" @@ -61,3 +62,25 @@ public: virtual bool removePlayer(const std::string &name) = 0; virtual void listPlayers(std::vector &res) = 0; }; + +struct AuthEntry +{ + u64 id; + std::string name; + std::string password; + std::vector privileges; + s64 last_login; +}; + +class AuthDatabase +{ +public: + virtual ~AuthDatabase() = default; + + virtual bool getAuth(const std::string &name, AuthEntry &res) = 0; + virtual bool saveAuth(const AuthEntry &authEntry) = 0; + virtual bool createAuth(AuthEntry &authEntry) = 0; + virtual bool deleteAuth(const std::string &name) = 0; + virtual void listNames(std::vector &res) = 0; + virtual void reload() = 0; +}; diff --git a/src/main.cpp b/src/main.cpp index 005e1acc7..e033ef63a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -289,6 +289,8 @@ static void set_allowed_options(OptionList *allowed_options) _("Migrate from current map backend to another (Only works when using minetestserver or with --server)")))); allowed_options->insert(std::make_pair("migrate-players", ValueSpec(VALUETYPE_STRING, _("Migrate from current players backend to another (Only works when using minetestserver or with --server)")))); + allowed_options->insert(std::make_pair("migrate-auth", ValueSpec(VALUETYPE_STRING, + _("Migrate from current auth backend to another (Only works when using minetestserver or with --server)")))); allowed_options->insert(std::make_pair("terminal", ValueSpec(VALUETYPE_FLAG, _("Feature an interactive terminal (Only works when using minetestserver or with --server)")))); #ifndef SERVER @@ -840,6 +842,9 @@ static bool run_dedicated_server(const GameParams &game_params, const Settings & if (cmd_args.exists("migrate-players")) return ServerEnvironment::migratePlayersDatabase(game_params, cmd_args); + if (cmd_args.exists("migrate-auth")) + return ServerEnvironment::migrateAuthDatabase(game_params, cmd_args); + if (cmd_args.exists("terminal")) { #if USE_CURSES bool name_ok = true; diff --git a/src/script/lua_api/CMakeLists.txt b/src/script/lua_api/CMakeLists.txt index 97c3786ec..32f6a2793 100644 --- a/src/script/lua_api/CMakeLists.txt +++ b/src/script/lua_api/CMakeLists.txt @@ -1,5 +1,6 @@ set(common_SCRIPT_LUA_API_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/l_areastore.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/l_auth.cpp ${CMAKE_CURRENT_SOURCE_DIR}/l_base.cpp ${CMAKE_CURRENT_SOURCE_DIR}/l_craft.cpp ${CMAKE_CURRENT_SOURCE_DIR}/l_env.cpp diff --git a/src/script/lua_api/l_auth.cpp b/src/script/lua_api/l_auth.cpp new file mode 100644 index 000000000..0fc57ba3a --- /dev/null +++ b/src/script/lua_api/l_auth.cpp @@ -0,0 +1,216 @@ +/* +Minetest +Copyright (C) 2018 bendeutsch, Ben Deutsch + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation; either version 2.1 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +*/ + +#include "lua_api/l_auth.h" +#include "lua_api/l_internal.h" +#include "common/c_converter.h" +#include "common/c_content.h" +#include "cpp_api/s_base.h" +#include "server.h" +#include "environment.h" +#include "database/database.h" +#include + +// common start: ensure auth db +AuthDatabase *ModApiAuth::getAuthDb(lua_State *L) +{ + ServerEnvironment *server_environment = + dynamic_cast(getEnv(L)); + if (!server_environment) + return nullptr; + return server_environment->getAuthDatabase(); +} + +void ModApiAuth::pushAuthEntry(lua_State *L, const AuthEntry &authEntry) +{ + lua_newtable(L); + int table = lua_gettop(L); + // id + lua_pushnumber(L, authEntry.id); + lua_setfield(L, table, "id"); + // name + lua_pushstring(L, authEntry.name.c_str()); + lua_setfield(L, table, "name"); + // password + lua_pushstring(L, authEntry.password.c_str()); + lua_setfield(L, table, "password"); + // privileges + lua_newtable(L); + int privtable = lua_gettop(L); + for (const std::string &privs : authEntry.privileges) { + lua_pushboolean(L, true); + lua_setfield(L, privtable, privs.c_str()); + } + lua_setfield(L, table, "privileges"); + // last_login + lua_pushnumber(L, authEntry.last_login); + lua_setfield(L, table, "last_login"); + + lua_pushvalue(L, table); +} + +// auth_read(name) +int ModApiAuth::l_auth_read(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + AuthDatabase *auth_db = getAuthDb(L); + if (!auth_db) + return 0; + AuthEntry authEntry; + const char *name = luaL_checkstring(L, 1); + bool success = auth_db->getAuth(std::string(name), authEntry); + if (!success) + return 0; + + pushAuthEntry(L, authEntry); + return 1; +} + +// auth_save(table) +int ModApiAuth::l_auth_save(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + AuthDatabase *auth_db = getAuthDb(L); + if (!auth_db) + return 0; + luaL_checktype(L, 1, LUA_TTABLE); + int table = 1; + AuthEntry authEntry; + bool success; + success = getintfield(L, table, "id", authEntry.id); + success = success && getstringfield(L, table, "name", authEntry.name); + success = success && getstringfield(L, table, "password", authEntry.password); + lua_getfield(L, table, "privileges"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + while (lua_next(L, -2)) { + authEntry.privileges.emplace_back( + lua_tostring(L, -2)); // the key, not the value + lua_pop(L, 1); + } + } else { + success = false; + } + lua_pop(L, 1); // the table + success = success && getintfield(L, table, "last_login", authEntry.last_login); + + if (!success) { + lua_pushboolean(L, false); + return 1; + } + + lua_pushboolean(L, auth_db->saveAuth(authEntry)); + return 1; +} + +// auth_create(table) +int ModApiAuth::l_auth_create(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + AuthDatabase *auth_db = getAuthDb(L); + if (!auth_db) + return 0; + luaL_checktype(L, 1, LUA_TTABLE); + int table = 1; + AuthEntry authEntry; + bool success; + // no meaningful id field, we assume + success = getstringfield(L, table, "name", authEntry.name); + success = success && getstringfield(L, table, "password", authEntry.password); + lua_getfield(L, table, "privileges"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + while (lua_next(L, -2)) { + authEntry.privileges.emplace_back( + lua_tostring(L, -2)); // the key, not the value + lua_pop(L, 1); + } + } else { + success = false; + } + lua_pop(L, 1); // the table + success = success && getintfield(L, table, "last_login", authEntry.last_login); + + if (!success) + return 0; + + if (auth_db->createAuth(authEntry)) { + pushAuthEntry(L, authEntry); + return 1; + } + + return 0; +} + +// auth_delete(name) +int ModApiAuth::l_auth_delete(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + AuthDatabase *auth_db = getAuthDb(L); + if (!auth_db) + return 0; + std::string name(luaL_checkstring(L, 1)); + lua_pushboolean(L, auth_db->deleteAuth(name)); + return 1; +} + +// auth_list_names() +int ModApiAuth::l_auth_list_names(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + AuthDatabase *auth_db = getAuthDb(L); + if (!auth_db) + return 0; + std::vector names; + auth_db->listNames(names); + lua_createtable(L, names.size(), 0); + int table = lua_gettop(L); + int i = 1; + for (const std::string &name : names) { + lua_pushstring(L, name.c_str()); + lua_rawseti(L, table, i++); + } + return 1; +} + +// auth_reload() +int ModApiAuth::l_auth_reload(lua_State *L) +{ + NO_MAP_LOCK_REQUIRED; + AuthDatabase *auth_db = getAuthDb(L); + if (auth_db) + auth_db->reload(); + return 0; +} + +void ModApiAuth::Initialize(lua_State *L, int top) +{ + + lua_newtable(L); + int auth_top = lua_gettop(L); + + registerFunction(L, "read", l_auth_read, auth_top); + registerFunction(L, "save", l_auth_save, auth_top); + registerFunction(L, "create", l_auth_create, auth_top); + registerFunction(L, "delete", l_auth_delete, auth_top); + registerFunction(L, "list_names", l_auth_list_names, auth_top); + registerFunction(L, "reload", l_auth_reload, auth_top); + + lua_setfield(L, top, "auth"); +} diff --git a/src/script/lua_api/l_auth.h b/src/script/lua_api/l_auth.h new file mode 100644 index 000000000..fb9a9875b --- /dev/null +++ b/src/script/lua_api/l_auth.h @@ -0,0 +1,54 @@ +/* +Minetest +Copyright (C) 2018 bendeutsch, Ben Deutsch + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation; either version 2.1 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +*/ + +#pragma once + +#include "lua_api/l_base.h" + +class AuthDatabase; +struct AuthEntry; + +class ModApiAuth : public ModApiBase +{ +private: + // auth_read(name) + static int l_auth_read(lua_State *L); + + // auth_save(table) + static int l_auth_save(lua_State *L); + + // auth_create(table) + static int l_auth_create(lua_State *L); + + // auth_delete(name) + static int l_auth_delete(lua_State *L); + + // auth_list_names() + static int l_auth_list_names(lua_State *L); + + // auth_reload() + static int l_auth_reload(lua_State *L); + + // helper for auth* methods + static AuthDatabase *getAuthDb(lua_State *L); + static void pushAuthEntry(lua_State *L, const AuthEntry &authEntry); + +public: + static void Initialize(lua_State *L, int top); +}; diff --git a/src/script/scripting_server.cpp b/src/script/scripting_server.cpp index 93b28b61b..2204c6884 100644 --- a/src/script/scripting_server.cpp +++ b/src/script/scripting_server.cpp @@ -23,6 +23,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "settings.h" #include "cpp_api/s_internal.h" #include "lua_api/l_areastore.h" +#include "lua_api/l_auth.h" #include "lua_api/l_base.h" #include "lua_api/l_craft.h" #include "lua_api/l_env.h" @@ -106,6 +107,7 @@ void ServerScripting::InitializeModApi(lua_State *L, int top) ModChannelRef::Register(L); // Initialize mod api modules + ModApiAuth::Initialize(L, top); ModApiCraft::Initialize(L, top); ModApiEnvMod::Initialize(L, top); ModApiInventory::Initialize(L, top); diff --git a/src/serverenvironment.cpp b/src/serverenvironment.cpp index 7d7eb4c08..3b2983825 100644 --- a/src/serverenvironment.cpp +++ b/src/serverenvironment.cpp @@ -414,6 +414,18 @@ ServerEnvironment::ServerEnvironment(ServerMap *map, std::string name; conf.getNoEx("player_backend", name); m_player_database = openPlayerDatabase(name, path_world, conf); + + std::string auth_name = "files"; + if (conf.exists("auth_backend")) { + conf.getNoEx("auth_backend", auth_name); + } else { + conf.set("auth_backend", "files"); + if (!conf.updateConfigFile(conf_path.c_str())) { + errorstream << "ServerEnvironment::ServerEnvironment(): " + << "Failed to update world.mt!" << std::endl; + } + } + m_auth_database = openAuthDatabase(auth_name, path_world, conf); } ServerEnvironment::~ServerEnvironment() @@ -439,6 +451,7 @@ ServerEnvironment::~ServerEnvironment() } delete m_player_database; + delete m_auth_database; } Map & ServerEnvironment::getMap() @@ -2274,3 +2287,91 @@ bool ServerEnvironment::migratePlayersDatabase(const GameParams &game_params, } return true; } + +AuthDatabase *ServerEnvironment::openAuthDatabase( + const std::string &name, const std::string &savedir, const Settings &conf) +{ + if (name == "sqlite3") + return new AuthDatabaseSQLite3(savedir); + + if (name == "files") + return new AuthDatabaseFiles(savedir); + + throw BaseException(std::string("Database backend ") + name + " not supported."); +} + +bool ServerEnvironment::migrateAuthDatabase( + const GameParams &game_params, const Settings &cmd_args) +{ + std::string migrate_to = cmd_args.get("migrate-auth"); + Settings world_mt; + std::string world_mt_path = game_params.world_path + DIR_DELIM + "world.mt"; + if (!world_mt.readConfigFile(world_mt_path.c_str())) { + errorstream << "Cannot read world.mt!" << std::endl; + return false; + } + + std::string backend = "files"; + if (world_mt.exists("auth_backend")) + backend = world_mt.get("auth_backend"); + else + warningstream << "No auth_backend found in world.mt, " + "assuming \"files\"." << std::endl; + + if (backend == migrate_to) { + errorstream << "Cannot migrate: new backend is same" + << " as the old one" << std::endl; + return false; + } + + try { + const std::unique_ptr srcdb(ServerEnvironment::openAuthDatabase( + backend, game_params.world_path, world_mt)); + const std::unique_ptr dstdb(ServerEnvironment::openAuthDatabase( + migrate_to, game_params.world_path, world_mt)); + + std::vector names_list; + srcdb->listNames(names_list); + for (const std::string &name : names_list) { + actionstream << "Migrating auth entry for " << name << std::endl; + bool success; + AuthEntry authEntry; + success = srcdb->getAuth(name, authEntry); + success = success && dstdb->createAuth(authEntry); + if (!success) + errorstream << "Failed to migrate " << name << std::endl; + } + + actionstream << "Successfully migrated " << names_list.size() + << " auth entries" << std::endl; + world_mt.set("auth_backend", migrate_to); + if (!world_mt.updateConfigFile(world_mt_path.c_str())) + errorstream << "Failed to update world.mt!" << std::endl; + else + actionstream << "world.mt updated" << std::endl; + + if (backend == "files") { + // special-case files migration: + // move auth.txt to auth.txt.bak if possible + std::string auth_txt_path = + game_params.world_path + DIR_DELIM + "auth.txt"; + std::string auth_bak_path = auth_txt_path + ".bak"; + if (!fs::PathExists(auth_bak_path)) + if (fs::Rename(auth_txt_path, auth_bak_path)) + actionstream << "Renamed auth.txt to auth.txt.bak" + << std::endl; + else + errorstream << "Could not rename auth.txt to " + "auth.txt.bak" << std::endl; + else + warningstream << "auth.txt.bak already exists, auth.txt " + "not renamed" << std::endl; + } + + } catch (BaseException &e) { + errorstream << "An error occured during migration: " << e.what() + << std::endl; + return false; + } + return true; +} diff --git a/src/serverenvironment.h b/src/serverenvironment.h index 225f788d9..b7a121adf 100644 --- a/src/serverenvironment.h +++ b/src/serverenvironment.h @@ -32,6 +32,7 @@ struct GameParams; class MapBlock; class RemotePlayer; class PlayerDatabase; +class AuthDatabase; class PlayerSAO; class ServerEnvironment; class ActiveBlockModifier; @@ -366,6 +367,10 @@ public: static bool migratePlayersDatabase(const GameParams &game_params, const Settings &cmd_args); + + AuthDatabase *getAuthDatabase() { return m_auth_database; } + static bool migrateAuthDatabase(const GameParams &game_params, + const Settings &cmd_args); private: /** @@ -375,6 +380,8 @@ private: static PlayerDatabase *openPlayerDatabase(const std::string &name, const std::string &savedir, const Settings &conf); + static AuthDatabase *openAuthDatabase(const std::string &name, + const std::string &savedir, const Settings &conf); /* Internal ActiveObject interface ------------------------------------------- @@ -467,6 +474,7 @@ private: std::vector m_players; PlayerDatabase *m_player_database = nullptr; + AuthDatabase *m_auth_database = nullptr; // Particles IntervalLimiter m_particle_management_interval; diff --git a/src/unittest/CMakeLists.txt b/src/unittest/CMakeLists.txt index 311204e32..3ffe1978e 100644 --- a/src/unittest/CMakeLists.txt +++ b/src/unittest/CMakeLists.txt @@ -1,5 +1,6 @@ set (UNITTEST_SRCS ${CMAKE_CURRENT_SOURCE_DIR}/test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/test_authdatabase.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_activeobject.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_areastore.cpp ${CMAKE_CURRENT_SOURCE_DIR}/test_ban.cpp diff --git a/src/unittest/test_authdatabase.cpp b/src/unittest/test_authdatabase.cpp new file mode 100644 index 000000000..1e8fd233d --- /dev/null +++ b/src/unittest/test_authdatabase.cpp @@ -0,0 +1,299 @@ +/* +Minetest +Copyright (C) 2018 bendeutsch, Ben Deutsch + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation; either version 2.1 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License along +with this program; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +*/ + +#include "test.h" + +#include +#include "database/database-files.h" +#include "database/database-sqlite3.h" +#include "util/string.h" +#include "filesys.h" + +namespace +{ +// Anonymous namespace to create classes that are only +// visible to this file +// +// These are helpers that return a *AuthDatabase and +// allow us to run the same tests on different databases and +// database acquisition strategies. + +class AuthDatabaseProvider +{ +public: + virtual ~AuthDatabaseProvider() = default; + virtual AuthDatabase *getAuthDatabase() = 0; +}; + +class FixedProvider : public AuthDatabaseProvider +{ +public: + FixedProvider(AuthDatabase *auth_db) : auth_db(auth_db){}; + virtual ~FixedProvider(){}; + virtual AuthDatabase *getAuthDatabase() { return auth_db; }; + +private: + AuthDatabase *auth_db; +}; + +class FilesProvider : public AuthDatabaseProvider +{ +public: + FilesProvider(const std::string &dir) : dir(dir){}; + virtual ~FilesProvider() { delete auth_db; }; + virtual AuthDatabase *getAuthDatabase() + { + delete auth_db; + auth_db = new AuthDatabaseFiles(dir); + return auth_db; + }; + +private: + std::string dir; + AuthDatabase *auth_db = nullptr; +}; + +class SQLite3Provider : public AuthDatabaseProvider +{ +public: + SQLite3Provider(const std::string &dir) : dir(dir){}; + virtual ~SQLite3Provider() { delete auth_db; }; + virtual AuthDatabase *getAuthDatabase() + { + delete auth_db; + auth_db = new AuthDatabaseSQLite3(dir); + return auth_db; + }; + +private: + std::string dir; + AuthDatabase *auth_db = nullptr; +}; +} + +class TestAuthDatabase : public TestBase +{ +public: + TestAuthDatabase() + { + TestManager::registerTestModule(this); + // fixed directory, for persistence + test_dir = getTestTempDirectory(); + } + const char *getName() { return "TestAuthDatabase"; } + + void runTests(IGameDef *gamedef); + void runTestsForCurrentDB(); + + void testRecallFail(); + void testCreate(); + void testRecall(); + void testChange(); + void testRecallChanged(); + void testChangePrivileges(); + void testRecallChangedPrivileges(); + void testListNames(); + void testDelete(); + +private: + std::string test_dir; + AuthDatabaseProvider *auth_provider; +}; + +static TestAuthDatabase g_test_instance; + +void TestAuthDatabase::runTests(IGameDef *gamedef) +{ + // Each set of tests is run twice for each database type: + // one where we reuse the same AuthDatabase object (to test local caching), + // and one where we create a new AuthDatabase object for each call + // (to test actual persistence). + + rawstream << "-------- Files database (same object)" << std::endl; + + AuthDatabase *auth_db = new AuthDatabaseFiles(test_dir); + auth_provider = new FixedProvider(auth_db); + + runTestsForCurrentDB(); + + delete auth_db; + delete auth_provider; + + // reset database + fs::DeleteSingleFileOrEmptyDirectory(test_dir + DIR_DELIM + "auth.txt"); + + rawstream << "-------- Files database (new objects)" << std::endl; + + auth_provider = new FilesProvider(test_dir); + + runTestsForCurrentDB(); + + delete auth_provider; + + rawstream << "-------- SQLite3 database (same object)" << std::endl; + + auth_db = new AuthDatabaseSQLite3(test_dir); + auth_provider = new FixedProvider(auth_db); + + runTestsForCurrentDB(); + + delete auth_db; + delete auth_provider; + + // reset database + fs::DeleteSingleFileOrEmptyDirectory(test_dir + DIR_DELIM + "auth.sqlite"); + + rawstream << "-------- SQLite3 database (new objects)" << std::endl; + + auth_provider = new SQLite3Provider(test_dir); + + runTestsForCurrentDB(); + + delete auth_provider; +} + +//////////////////////////////////////////////////////////////////////////////// + +void TestAuthDatabase::runTestsForCurrentDB() +{ + TEST(testRecallFail); + TEST(testCreate); + TEST(testRecall); + TEST(testChange); + TEST(testRecallChanged); + TEST(testChangePrivileges); + TEST(testRecallChangedPrivileges); + TEST(testListNames); + TEST(testDelete); + TEST(testRecallFail); +} + +void TestAuthDatabase::testRecallFail() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + // no such user yet + UASSERT(!auth_db->getAuth("TestName", authEntry)); +} + +void TestAuthDatabase::testCreate() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + authEntry.name = "TestName"; + authEntry.password = "TestPassword"; + authEntry.privileges.emplace_back("shout"); + authEntry.privileges.emplace_back("interact"); + authEntry.last_login = 1000; + UASSERT(auth_db->createAuth(authEntry)); +} + +void TestAuthDatabase::testRecall() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + UASSERT(auth_db->getAuth("TestName", authEntry)); + UASSERTEQ(std::string, authEntry.name, "TestName"); + UASSERTEQ(std::string, authEntry.password, "TestPassword"); + // the order of privileges is unimportant + std::sort(authEntry.privileges.begin(), authEntry.privileges.end()); + UASSERTEQ(std::string, str_join(authEntry.privileges, ","), "interact,shout"); +} + +void TestAuthDatabase::testChange() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + UASSERT(auth_db->getAuth("TestName", authEntry)); + authEntry.password = "NewPassword"; + authEntry.last_login = 1002; + UASSERT(auth_db->saveAuth(authEntry)); +} + +void TestAuthDatabase::testRecallChanged() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + UASSERT(auth_db->getAuth("TestName", authEntry)); + UASSERTEQ(std::string, authEntry.password, "NewPassword"); + // the order of privileges is unimportant + std::sort(authEntry.privileges.begin(), authEntry.privileges.end()); + UASSERTEQ(std::string, str_join(authEntry.privileges, ","), "interact,shout"); + UASSERTEQ(u64, authEntry.last_login, 1002); +} + +void TestAuthDatabase::testChangePrivileges() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + UASSERT(auth_db->getAuth("TestName", authEntry)); + authEntry.privileges.clear(); + authEntry.privileges.emplace_back("interact"); + authEntry.privileges.emplace_back("fly"); + authEntry.privileges.emplace_back("dig"); + UASSERT(auth_db->saveAuth(authEntry)); +} + +void TestAuthDatabase::testRecallChangedPrivileges() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + AuthEntry authEntry; + + UASSERT(auth_db->getAuth("TestName", authEntry)); + // the order of privileges is unimportant + std::sort(authEntry.privileges.begin(), authEntry.privileges.end()); + UASSERTEQ(std::string, str_join(authEntry.privileges, ","), "dig,fly,interact"); +} + +void TestAuthDatabase::testListNames() +{ + + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + std::vector list; + + AuthEntry authEntry; + + authEntry.name = "SecondName"; + authEntry.password = "SecondPassword"; + authEntry.privileges.emplace_back("shout"); + authEntry.privileges.emplace_back("interact"); + authEntry.last_login = 1003; + auth_db->createAuth(authEntry); + + auth_db->listNames(list); + // not necessarily sorted, so sort before comparing + std::sort(list.begin(), list.end()); + UASSERTEQ(std::string, str_join(list, ","), "SecondName,TestName"); +} + +void TestAuthDatabase::testDelete() +{ + AuthDatabase *auth_db = auth_provider->getAuthDatabase(); + + UASSERT(!auth_db->deleteAuth("NoSuchName")); + UASSERT(auth_db->deleteAuth("TestName")); + // second try, expect failure + UASSERT(!auth_db->deleteAuth("TestName")); +} diff --git a/src/unittest/test_utilities.cpp b/src/unittest/test_utilities.cpp index 315cbf0fc..6f4a01432 100644 --- a/src/unittest/test_utilities.cpp +++ b/src/unittest/test_utilities.cpp @@ -51,6 +51,7 @@ public: void testIsNumber(); void testIsPowerOfTwo(); void testMyround(); + void testStringJoin(); }; static TestUtilities g_test_instance; @@ -78,6 +79,7 @@ void TestUtilities::runTests(IGameDef *gamedef) TEST(testIsNumber); TEST(testIsPowerOfTwo); TEST(testMyround); + TEST(testStringJoin); } //////////////////////////////////////////////////////////////////////////////// @@ -328,3 +330,24 @@ void TestUtilities::testMyround() UASSERT(myround(-6.5f) == -7); } +void TestUtilities::testStringJoin() +{ + std::vector input; + UASSERT(str_join(input, ",") == ""); + + input.emplace_back("one"); + UASSERT(str_join(input, ",") == "one"); + + input.emplace_back("two"); + UASSERT(str_join(input, ",") == "one,two"); + + input.emplace_back("three"); + UASSERT(str_join(input, ",") == "one,two,three"); + + input[1] = ""; + UASSERT(str_join(input, ",") == "one,,three"); + + input[1] = "two"; + UASSERT(str_join(input, " and ") == "one and two and three"); +} + diff --git a/src/util/string.h b/src/util/string.h index 35b7cfa8a..ab9a4a6c8 100644 --- a/src/util/string.h +++ b/src/util/string.h @@ -704,3 +704,22 @@ inline const std::string duration_to_string(int sec) return ss.str(); } + +/** + * Joins a vector of strings by the string \p delimiter. + * + * @return A std::string + */ +inline std::string str_join(const std::vector &list, + const std::string &delimiter) +{ + std::ostringstream oss; + bool first = true; + for (const auto &part : list) { + if (!first) + oss << delimiter; + oss << part; + first = false; + } + return oss.str(); +}