From 153398de84cac480fb18af5d0985017645aa5fb7 Mon Sep 17 00:00:00 2001 From: elite Date: Fri, 30 Jun 2017 21:42:59 -0400 Subject: [PATCH] add mysql_auth mod --- worldmods/mysql_auth/README.md | 123 ++++++++++ worldmods/mysql_auth/auth_txt_import.lua | 47 ++++ worldmods/mysql_auth/depends.txt | 2 + worldmods/mysql_auth/init.lua | 299 +++++++++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 worldmods/mysql_auth/README.md create mode 100644 worldmods/mysql_auth/auth_txt_import.lua create mode 100644 worldmods/mysql_auth/depends.txt create mode 100644 worldmods/mysql_auth/init.lua diff --git a/worldmods/mysql_auth/README.md b/worldmods/mysql_auth/README.md new file mode 100644 index 0000000..8de3bc4 --- /dev/null +++ b/worldmods/mysql_auth/README.md @@ -0,0 +1,123 @@ +# mysql_auth + +Plug Minetest's auth mechanism into a MySQL database. + +# Configuration + +First, if mod security is enabled (`secure.enable_security = true`), this mod must be added as +a trusted mod (in the `secure.trusted_mods` config entry). There is **no** other solution to +make it work under mod security. + +By default `mysql_auth` doesn't run in singleplayer. This can be overriden by setting +`mysql_auth.enable_singleplayer` to `true`. + +Configuration may be done as regular Minetest settings entries, or using a config file, allowing +for more configuration options; to do so specify the path as `mysql_auth.cfgfile`. This config +must contain a Lua table that can be read by `minetest.deserialize`, i.e. a regular table +definition follwing a `return` statement (see the example below). + +When using flat Minetest configuation entries, all the following option names must be prefixed +with `mysql_auth.`. When using a config file, entries are to be hierarchised as per the dot +separator. + +Values written next to option names are default values. + +## Database connection + +### Flat config file + +```lua +db.host = 'localhost' +db.user = nil -- MySQL connector defaults to current username +db.pass = nil -- Using password: NO +db.port = nil -- MySQL connector defaults to either 3306, or no port if using localhost/unix socket +db.db = nil -- <== Setting this is required +``` + +### Lua table config file + +Connection options are passed as a table through the `db.connopts` entry. +Its format must be the same as [LuaPower's MySQL module `mysql.connect(options_t)` function][mycn], +that is (all members are optional); + +```lua +connopts = { + host = ..., + user = ..., + pass = ..., + db = ..., + port = ..., + unix_socket = ..., + flags = { ... }, + options = { ... }, + attrs = { ... }, + -- Also key, cert, ca, cpath, cipher +} +``` + +## Auth table schema finetuning + +```lua +db.tables.auths.name = 'auths' +db.tables.auths.schema.userid = 'userid' +db.tables.auths.schema.userid_type = 'INT' +db.tables.auths.schema.username = 'username' +db.tables.auths.schema.username_type = 'VARCHAR(32)' +db.tables.auths.schema.password = 'password' +db.tables.auths.schema.password_type = 'VARCHAR(512)' +db.tables.auths.schema.privs = 'privs' +db.tables.auths.schema.privs_type = 'VARCHAR(512)' +db.tables.auths.schema.lastlogin = 'lastlogin' +db.tables.auths.schema.lastlogin_type = 'BIGINT' +``` + +The `_type` config entries are only used when creating an auth table, i.e. when +`db.tables.auths.name` doesn't exist. + +## Examples + +### Example 1 + +#### Using a Lua config file + +`minetest.conf`: +``` +mysql_auth.cfgfile = /srv/minetest/skyblock/mysql_auth_config +``` + +`/srv/minetest/skyblock/mysql_auth_config`: +```lua +return { + db = { + connopts = { + user = 'minetest', + pass = 'BQy77wK$Um6es3Bi($iZ*w3N', + db = 'minetest' + }, + tables = { + auths = { + name = 'skyblock_auths' + } + } + } +} +``` + +#### Using only Minetest config entries + +`minetest.conf`: +``` +mysql_auth.db.user = minetest +mysql_auth.db.pass = BQy77wK$Um6es3Bi($iZ*w3N +mysql_auth.db.db = minetest +mysql_auth.db.tables.auth.name = skyblock_auths +``` + +# License + +`mysql_auth` is licensed under [LGPLv3](https://www.gnu.org/licenses/lgpl.html). + +Using the Public Domain-licensed LuaPower `mysql` module. + + +[mycn]: https://luapower.com/mysql#mysql.connectoptions_t---conn diff --git a/worldmods/mysql_auth/auth_txt_import.lua b/worldmods/mysql_auth/auth_txt_import.lua new file mode 100644 index 0000000..d65b55d --- /dev/null +++ b/worldmods/mysql_auth/auth_txt_import.lua @@ -0,0 +1,47 @@ +local modname = minetest.get_current_modname() +local modpath = minetest.get_modpath(modname) + +local thismod = _G[modname] + +function thismod.import_auth_txt() + minetest.log('action', modname .. ": Importing auth.txt") + local auth_file_path = minetest.get_worldpath() .. '/auth.txt' + local create_auth_stmt = thismod.create_auth_stmt + local create_auth_params = thismod.create_auth_params + local conn = thismod.conn + local file, errmsg = io.open(auth_file_path, 'rb') + if not file then + minetest.log('action', modname .. ": " .. auth_file_path .. " could not be opened for reading" .. + "(" .. errmsg .. "); no auth entries imported") + return + end + conn:query('SET autocommit=0') + conn:query('START TRANSACTION') + 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 + minetest.log('warning', modname .. ": Invalid line in auth.txt, skipped: " .. dump(line)) + end + minetest.log('info', modname .. " importing player '"..name.."'") + create_auth_params:set(1, name) + create_auth_params:set(2, password) + create_auth_params:set(3, privilege_string) + create_auth_params:set(4, last_login) + local success, msg = pcall(create_auth_stmt.exec, create_auth_stmt) + if not success then + error(modname .. ": import failed: " .. msg) + end + if create_auth_stmt:affected_rows() ~= 1 then + error(modname .. ": create_auth failed: affected row count is " .. + create_auth_stmt:affected_rows() .. ", expected 1") + end + end + end + conn:query('COMMIT') + conn:query('SET autocommit=1') + io.close(file) + minetest.log('action', modname .. ": Finished importing auth.txt") +end diff --git a/worldmods/mysql_auth/depends.txt b/worldmods/mysql_auth/depends.txt new file mode 100644 index 0000000..2170244 --- /dev/null +++ b/worldmods/mysql_auth/depends.txt @@ -0,0 +1,2 @@ +mysql_base + diff --git a/worldmods/mysql_auth/init.lua b/worldmods/mysql_auth/init.lua new file mode 100644 index 0000000..5d85313 --- /dev/null +++ b/worldmods/mysql_auth/init.lua @@ -0,0 +1,299 @@ +local modname = minetest.get_current_modname() +local modpath = minetest.get_modpath(modname) + +local thismod = { + enabled = false +} +_G[modname] = thismod + +if not mysql_base.enabled then + minetest.log('action', modname .. ": mysql_base disabled, not loading mod") + return +end + +local singleplayer = minetest.is_singleplayer() -- Caching is OK since you can't open a game to +-- multiplayer unless you restart it. +if not minetest.setting_get(modname .. '.enable_singleplayer') and singleplayer then + minetest.log('action', modname .. ": Not adding auth handler because of singleplayer game") + return +end + +enabled = true + +do + local get = mysql_base.mkget(modname) + + local conn, dbname = mysql_base.conn, mysql_base.dbname + + local tables = {} + do -- Tables and schema settings + local t_auths = get('db.tables.auths') + if type(t_auths) == 'table' then + tables.auths = t_auths + else + tables.auths = {} + tables.auths.name = get('db.tables.auths.name') + tables.auths.schema = {} + local S = tables.auths.schema + S.userid = get('db.tables.auths.schema.userid') + S.username = get('db.tables.auths.schema.username') + S.password = get('db.tables.auths.schema.password') + S.privs = get('db.tables.auths.schema.privs') + S.lastlogin = get('db.tables.auths.schema.lastlogin') + S.userid_type = get('db.tables.auths.schema.userid_type') + S.username_type = get('db.tables.auths.schema.username_type') + S.password_type = get('db.tables.auths.schema.password_type') + S.privs_type = get('db.tables.auths.schema.privs_type') + S.lastlogin_type = get('db.tables.auths.schema.lastlogin_type') + end + + do -- Default values + tables.auths.name = tables.auths.name or 'auths' + tables.auths.schema = tables.auths.schema or {} + local S = tables.auths.schema + S.userid = S.userid or 'userid' + S.username = S.username or 'username' + S.password = S.password or 'password' + S.privs = S.privs or 'privs' + S.lastlogin = S.lastlogin or 'lastlogin' + + S.userid_type = S.userid_type or 'INT' + S.username_type = S.username_type or 'VARCHAR(32)' + S.password_type = S.password_type or 'VARCHAR(512)' + S.privs_type = S.privs_type or 'VARCHAR(512)' + S.lastlogin_type = S.lastlogin_type or 'BIGINT' + -- Note lastlogin doesn't use the TIMESTAMP type, which is 32-bit and therefore + -- subject to the year 2038 problem. + end + end + + local auth_table_created + -- Auth table existence check and setup + if not mysql_base.table_exists(tables.auths.name) then + -- Auth table doesn't exist, create it + local S = tables.auths.schema + conn:query('CREATE TABLE ' .. tables.auths.name .. ' (' .. + S.userid .. ' ' .. S.userid_type .. ' NOT NULL AUTO_INCREMENT,' .. + S.username .. ' ' .. S.username_type .. ' NOT NULL,' .. + S.password .. ' ' .. S.password_type .. ' NOT NULL,' .. + S.privs .. ' ' .. S.privs_type .. ' NOT NULL,' .. + S.lastlogin .. ' ' .. S.lastlogin_type .. ',' .. + 'PRIMARY KEY (' .. S.userid .. '),' .. + 'UNIQUE (' .. S.username .. ')' .. + ')') + minetest.log('action', modname .. " created table '" .. dbname .. "." .. tables.auths.name .. + "'") + auth_table_created = true + end + + local S = tables.auths.schema + local get_auth_stmt = conn:prepare('SELECT ' .. S.userid .. ',' .. S.password .. ',' .. S.privs .. + ',' .. S.lastlogin .. ' FROM ' .. tables.auths.name .. ' WHERE ' .. S.username .. '=?') + thismod.get_auth_stmt = get_auth_stmt + local get_auth_params = get_auth_stmt:bind_params({S.username_type}) + thismod.get_auth_params = get_auth_params + local get_auth_results = get_auth_stmt:bind_result({S.userid_type, S.password_type, S.privs_type, + S.lastlogin_type}) + thismod.get_auth_results = get_auth_results + + local create_auth_stmt = conn:prepare('INSERT INTO ' .. tables.auths.name .. '(' .. S.username .. + ',' .. S.password .. ',' .. S.privs .. ',' .. S.lastlogin .. ') VALUES (?,?,?,?)') + thismod.create_auth_stmt = create_auth_stmt + local create_auth_params = create_auth_stmt:bind_params({S.username_type, S.password_type, + S.privs_type, S.lastlogin_type}) + thismod.create_auth_params = create_auth_params + + local delete_auth_stmt = conn:prepare('DELETE FROM ' .. tables.auths.name .. ' WHERE ' .. + S.username .. '=?') + thismod.delete_auth_stmt = delete_auth_stmt + local delete_auth_params = delete_auth_stmt:bind_params({S.username_type}) + thismod.delete_auth_params = delete_auth_params + + local set_password_stmt = conn:prepare('UPDATE ' .. tables.auths.name .. ' SET ' .. S.password .. + '=? WHERE ' .. S.username .. '=?') + thismod.set_password_stmt = set_password_stmt + local set_password_params = set_password_stmt:bind_params({S.password_type, S.username_type}) + thismod.set_password_params = set_password_params + + local set_privileges_stmt = conn:prepare('UPDATE ' .. tables.auths.name .. ' SET ' .. S.privs .. + '=? WHERE ' .. S.username .. '=?') + thismod.set_privileges_stmt = set_privileges_stmt + local set_privileges_params = set_privileges_stmt:bind_params({S.privs_type, S.username_type}) + thismod.set_privileges_params = set_privileges_params + + local record_login_stmt = conn:prepare('UPDATE ' .. tables.auths.name .. ' SET ' .. + S.lastlogin .. '=? WHERE ' .. S.username .. '=?') + thismod.record_login_stmt = record_login_stmt + local record_login_params = record_login_stmt:bind_params({S.lastlogin_type, S.username_type}) + thismod.record_login_params = record_login_params + + local enumerate_auths_query = 'SELECT ' .. S.username .. ',' .. S.password .. ',' .. S.privs .. + ',' .. S.lastlogin .. ' FROM ' .. tables.auths.name + thismod.enumerate_auths_query = enumerate_auths_query + + if auth_table_created and get('import_auth_txt_on_table_create') ~= 'false' then + if not thismod.import_auth_txt then + dofile(modpath .. '/auth_txt_import.lua') + end + thismod.import_auth_txt() + end + + thismod.auth_handler = { + get_auth = function(name) + assert(type(name) == 'string') + get_auth_params:set(1, name) + local success, msg = pcall(get_auth_stmt.exec, get_auth_stmt) + if not success then + minetest.log('error', modname .. ": get_auth(" .. name .. ") failed: " .. msg) + return nil + end + get_auth_stmt:store_result() + if not get_auth_stmt:fetch() then + -- No such auth row exists + return nil + end + while get_auth_stmt:fetch() do + minetest.log('warning', modname .. ": get_auth(" .. name .. "): multiples lines were" .. + " returned") + end + local userid, password, privs_str, lastlogin = get_auth_results:get(1), + get_auth_results:get(2), get_auth_results:get(3), get_auth_results:get(4) + local admin = (name == minetest.setting_get("name")) + local privs + if singleplayer or admin then + privs = {} + -- If admin, grant all privs, if singleplayer, grant all privs w/ give_to_singleplayer + for priv, def in pairs(core.registered_privileges) do + if (singleplayer and def.give_to_singleplayer) or admin then + privs[priv] = true + end + end + if admin and not thismod.admin_get_auth_called then + thismod.admin_get_auth_called = true + thismod.auth_handler.set_privileges(name, privs) + end + else + privs = minetest.string_to_privs(privs_str) + end + return { + userid = userid, + password = password, + privileges = privs, + last_login = tonumber(lastlogin) + } + end, + create_auth = function(name, password, reason) + assert(type(name) == 'string') + assert(type(password) == 'string') + minetest.log('info', modname .. " creating player '"..name.."'" .. (reason or "")) + create_auth_params:set(1, name) + create_auth_params:set(2, password) + create_auth_params:set(3, minetest.setting_get("default_privs")) + create_auth_params:set(4, math.floor(os.time())) + local success, msg = pcall(create_auth_stmt.exec, create_auth_stmt) + if not success then + minetest.log('error', modname .. ": create_auth(" .. name .. ") failed: " .. msg) + return false + end + if create_auth_stmt:affected_rows() ~= 1 then + minetest.log('error', modname .. ": create_auth(" .. name .. ") failed: affected row" .. + " count is " .. create_auth_stmt:affected_rows() .. ", expected 1") + return false + end + return true + end, + delete_auth = function(name) + assert(type(name) == 'string') + minetest.log('info', modname .. " deleting player '"..name.."'") + delete_auth_params:set(1, name) + local success, msg = pcall(delete_auth_stmt.exec, delete_auth_stmt) + if not success then + minetest.log('error', modname .. ": delete_auth(" .. name .. ") failed: " .. msg) + return false + end + if delete_auth_stmt:affected_rows() ~= 1 then + minetest.log('error', modname .. ": delete_auth(" .. name .. ") failed: affected row" .. + " count is " .. delete_auth_stmt:affected_rows() .. ", expected 1") + return false + end + return true + end, + set_password = function(name, password) + assert(type(name) == 'string') + assert(type(password) == 'string') + if not thismod.auth_handler.get_auth(name) then + return thismod.auth_handler.create_auth(name, password, " because set_password was requested") + else + minetest.log('info', modname .. " setting password of player '" .. name .. "'") + set_password_params:set(1, password) + set_password_params:set(2, name) + local success, msg = pcall(set_password_stmt.exec, set_password_stmt) + if not success then + minetest.log('error', modname .. ": set_password(" .. name .. ") failed: " .. msg) + return false + end + if set_password_stmt:affected_rows() ~= 1 then + minetest.log('error', modname .. ": set_password(" .. name .. ") failed: affected row" .. + " count is " .. set_password_stmt:affected_rows() .. ", expected 1") + return false + end + return true + end + end, + set_privileges = function(name, privileges) + assert(type(name) == 'string') + assert(type(privileges) == 'table') + set_privileges_params:set(1, minetest.privs_to_string(privileges)) + set_privileges_params:set(2, name) + local success, msg = pcall(set_privileges_stmt.exec, set_privileges_stmt) + if not success then + minetest.log('error', modname .. ": set_privileges(" .. name .. ") failed: " .. msg) + return false + end + minetest.notify_authentication_modified(name) + return true + end, + reload = function() + return true + end, + record_login = function(name) + assert(type(name) == 'string') + record_login_params:set(1, math.floor(os.time())) + record_login_params:set(2, name) + local success, msg = pcall(record_login_stmt.exec, record_login_stmt) + if not success then + minetest.log('error', modname .. ": record_login(" .. name .. ") failed: " .. msg) + return false + end + if record_login_stmt:affected_rows() ~= 1 then + minetest.log('error', modname .. ": record_login(" .. name .. ") failed: affected row" .. + " count is " .. record_login_stmt:affected_rows() .. ", expected 1") + return false + end + return true + end, + enumerate_auths = function() + conn:query(enumerate_auths_query) + local res = conn:store_result() + return function() + local row = res:fetch('n') + if not row then + return nil + end + local username, password, privs_str, lastlogin = unpack(row) + return username, { + password = password, + privileges = minetest.string_to_privs(privs_str), + last_login = tonumber(lastlogin) + } + end + end + } +end + +minetest.register_authentication_handler(thismod.auth_handler) +minetest.log('action', modname .. ": Registered auth handler") + +mysql_base.register_on_shutdown(function() + thismod.get_auth_stmt:free_result() +end)