commit 1f4fb5915c2f4e17bc07e5de29c3c11ac90c2470 Author: Auke Kok Date: Sun Jan 7 15:15:47 2018 -0800 Initial commit. diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..0f06564 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,11 @@ +unused_args = false +allow_defined_top = true + +read_globals = { + "DIR_DELIM", + "minetest", + "dump", + "SecureRandom", + "fsc" +} + diff --git a/depends.txt b/depends.txt new file mode 100644 index 0000000..b75ab62 --- /dev/null +++ b/depends.txt @@ -0,0 +1 @@ +fsc diff --git a/description.txt b/description.txt new file mode 100644 index 0000000..ca7cd13 --- /dev/null +++ b/description.txt @@ -0,0 +1 @@ +Adds server and player email confirmation to add an extra layer of security. diff --git a/init.lua b/init.lua new file mode 100644 index 0000000..73ba41c --- /dev/null +++ b/init.lua @@ -0,0 +1,487 @@ + +--[[ + +mt2fa server side mod + +--]] + +-- allocate API stuff + +local S = minetest.settings +assert(S) + +local req_reg = S:get_bool("mt2fa.require_registration") or false +local req_auth = S:get_bool("mt2fa.require_authentication") or false +local reg_api = S:get("mt2fa.api_server") or "https://mt2fa.foo-projects.org/mt2fa" +local reg_id = S:get("mt2fa.server_id") --nil when first starting server +local reg_grace = S:get("mt2fa.grace") or 300 -- period before enforcement starts + +minetest.log("verbose", "mt2fa: requiring: registration(" .. tostring(req_reg) .. "), " .. + "authentication(" .. tostring(req_auth) .. ")") + +local http = minetest.request_http_api() +assert(http, "HTTP API unavailable. Please add `mt2fa` to secure.trusted_mods in minetest.conf!") + +local message = { + REG = "Registration request sent.", + AUTH = "Authentication request sent.", + ACCT = "Verifying account information, please wait.", + SERVER = "Server registration request sent.", + SERVERIP = "Server IP change request sent.", +} + +local forms = { + email = + "size[10,8]".. + "label[1,1;" .. + "Please type your email address. You will need to be\n" .. + "able to receive email and click on a link that is sent\n" .. + "to you at the address. If you lose the email address,\n" .. + "you may lose all access to this server.]" .. + "button_exit[6,7;3,1;OK;OK]".. + "field[1,6;8,1;email;email address;]", + + register = + "size[10,8]".. + "label[1,1;" .. + "This server allows you to register your account using\n" .. + "2-factor authentication and link your account to an\n" .. + "email address. This allows you to recover your account\n" .. + "on this server in case you forget your password. It also\n" .. + "may prevent unauthorized access to your account on this\n" .. + "server or any other server that uses this service. This\n" .. + "process is voluntary. If you decide to opt out, you can\n" .. + "at any time later opt in.]" .. + "button_exit[1,7;3,1;nothanks;No thanks]".. + "button_exit[6,7;3,1;register;Register]", +} + +local logins = {} +local privs = {} + +-- +-- loop our waiting message. If the player presses `escape` it will just restart it +-- as long as our context is valid. If the network code callback is reached, we +-- invalidate the context and abort this wait screen. +-- +local function do_wait(player, context) + fsc.show(player:get_player_name(), + "size[10,8]label[1,1;" .. context.message .. "]", + context, + function(p, fields, c) + minetest.after(0.1, do_wait, p, c) + return true + end) +end + +local function do_msg(player, context) + fsc.show(player:get_player_name(), + "size[10,8]label[1,1;" .. context.message .. "]" .. + "button_exit[6,7;3,1;OK;OK]", + context, + function(p, fields, c) + return true + end) +end + +local function send_request(request_type, player, context) + minetest.log("action", "mt2fa: sending " .. request_type .. " for " .. player:get_player_name()) + -- initial player message may be needed + if message[request_type] then + context.message = message[request_type] + end + do_wait(player, context) + -- perform the request + print("POST:", request_type, reg_id, dump(context)) + http.fetch({ + url = reg_api, + post_data = minetest.write_json({ + request_type = request_type, + player = player:get_player_name(), + server_id = reg_id, + email = context.email, + cookie = context.cookie, + server_data = context.server_data, + }), + timeout = 15, + }, function(res) + assert(res.completed) + print(request_type, dump(res)) + if not res.succeeded then + -- major problem, retry + minetest.log("error", "mt2fa: server replied with an error code") + do_wait(player, context) + minetest.after(15, send_request, request_type, player, context) + return + end + local data = minetest.parse_json(res.data) + if not data then + -- major problem, retry + minetest.log("error", "mt2fa: failed to parse JSON from mt2fa server") + do_wait(player, context) + minetest.after(15, send_request, request_type, player, context) + return + end + + minetest.log("action", "mt2fa: received " .. data.result .. " for " .. player:get_player_name()) + + -- update user message + context.message = data.info + + -- process result + if request_type == "REG" then + if data.result == "REGPEND" then + context.cookie = data.data.Cookie + minetest.after(10, send_request, "REGSTAT", player, context) + elseif data.result == "REGOK" then + -- record email address for this user in a user attribute, permanently + player:set_attribute("mt2fa.registered", "1") + -- signal success to the player + fsc.show(player:get_player_name(), + "size[10,8]label[1,1;" .. context.message .. "]" .. + "button_exit[6,7;3,1;OK;OK]", + context, + function(p, fields, c) + -- proceed to authentication if needed + if req_auth or p:get_attribute("mt2fa.auth_required") then + send_request("AUTH", p, c) + else + send_request("ACCT", p, c) + end + return true + end) + else + -- display error, retry new email? + do_wait(player, context) + minetest.after(15, send_request, request_type, player, context) + return + end + elseif request_type == "REGSTAT" then + if data.result == "REGPEND" then + minetest.after(10, send_request, "REGSTAT", player, context) + do_wait(player, context) + elseif data.result == "REGOK" then + -- record email address for this user in a user attribute, permanently + player:set_attribute("mt2fa.registered", "1") + -- signal success to the player + fsc.show(player:get_player_name(), + "size[10,8]label[1,1;" .. context.message .. "]" .. + "button_exit[6,7;3,1;OK;OK]", + context, + function(p, fields, c) + -- proceed to authentication if needed + if req_auth or p:get_attribute("mt2fa.auth_required") then + send_request("AUTH", p, c) + else + send_request("ACCT", p, c) + end + return true + end) + + else + -- error, retry? + do_wait(player, context) + minetest.after(15, send_request, request_type, player, context) + return + end + elseif request_type == "SERVER" then + if data.result == "SERVERPEND" then + context.cookie = data.data.Cookie + minetest.after(10, send_request, "SERVERSTAT", player, context) + else + -- error, retry? + do_wait(player, context) + minetest.after(15, send_request, request_type, player, context) + return + end + elseif request_type == "SERVERSTAT" then + if data.result == "SERVEROK" then + -- signal success to the player + S:set("mt2fa.server_id", data.data.Server_id) + reg_id = data.data.Server_id + do_msg(player, context) + elseif data.result == "SERVERPEND" then + minetest.after(10, send_request, "SERVERSTAT", player, context) + do_wait(player, context) + else + -- unknown data error + do_msg(player, context) + return + end + elseif request_type == "SERVERIP" then + if data.result == "SERVERIPPEND" then + context.cookie = data.data.Cookie + minetest.after(10, send_request, "SERVERIPSTAT", player, context) + else + -- error, retry? + do_wait(player, context) + minetest.after(15, send_request, request_type, player, context) + return + end + elseif request_type == "SERVERIPSTAT" then + if data.result == "SERVERIPOK" then + -- signal success to the player + do_msg(player, context) + elseif data.result == "SERVERIPPEND" then + minetest.after(10, send_request, "SERVERIPSTAT", player, context) + do_wait(player, context) + else + -- unknown data error + do_msg(player, context) + return + end + elseif request_type == "ACCT" then + if data.result == "ACCTOK" then + if data.auth_required == "1" then + -- do auth + send_request("AUTH", player, context) + end + do_msg(player, context) -- maybe just a chat msg here? + else + do_msg(player, context) + end + elseif request_type == "AUTH" then + if data.result == "AUTHPEND" then + context.cookie = data.data.Cookie + -- no need to send this right away so fast + minetest.after(10, send_request, "AUTHSTAT", player, context) + else + do_msg(player, context) + end + elseif request_type == "AUTHSTAT" then + if data.result == "AUTHOK" then + local name = player:get_player_name() + minetest.close_formspec(player:get_player_name(), "") + minetest.chat_send_player(name, context.message) + -- mark successful + minetest.set_player_privs(name, privs[name]) + logins[name] = nil + privs[name] = nil + elseif data.result == "AUTHPEND" then + minetest.after(10, send_request, "AUTHSTAT", player, context) + else + do_msg(player, context) + -- boot player? + end + else + assert("we sent an incorrect request, our bad") + end + end) +end + +-- join/leave stuff + +minetest.after(0, function() + -- + -- warn through log entries that registration has not been completed yet + -- + if not reg_id then + minetest.log("error", "mt2fa: This server needs to be registered with a mt2fa server in order to" .. + "allow players without the \"server\" priv to log on. To register, use `/mt2fa register ` " .. + "to start the registation process.") + end +end) + +minetest.register_on_joinplayer(function(player) + -- + -- if uninitialized, reject non-admin players + -- + if not reg_id then + if minetest.check_player_privs(player, {server = true}) then + minetest.chat_send_player(player:get_player_name(), + "This server needs to be registered with a mt2fa server in order to allow " .. + "players without the \"server\" priv to log on. To register, use `/mt2fa register ` " .. + "to start the registration process.") + return + else + minetest.kick_player(player:get_player_name(), + "This server is not properly initialized yet. Come back later.") + return + end + end + + -- + -- Player registration and Authentication + -- + + -- start the process + local name = player:get_player_name() + logins[name] = os.time() + reg_grace + privs[name] = minetest.get_player_privs(name) + minetest.set_player_privs(name, {}) + + local reg = player:get_attribute("mt2fa.registered") + if req_reg or player:get_attribute("mt2fa.auth_required") == "1" then + if reg ~= "1" then + -- ask for email + fsc.show(name, forms.email, {}, + function(p, fields, c) + if not fields.email then + fields.quit = nil + return + --FIXME reopen + end + send_request("REG", p, { + required = true, + email = fields.email, + }) + end + ) + else + -- already registered + send_request("AUTH", player, {required = true}) + end + else + -- offer to register + fsc.show(name, forms.register, {required = false}, + function(p, fields, c) + if not fields.register then + return true + end + -- ask for email + fsc.show(name, forms.email, c, + function(pl, f, co) + if not f.email then + return + end + send_request("REG", pl, {email = f.email}) + end + ) + return true + end) + + if req_auth then + if reg == "1" then + -- authentication must be performed + send_request("AUTH", player, {required = true}) + end + else + if reg == "1" then + -- check if the user account on the server has + -- been marked as `require 2fa authentication` + send_request("ACCT", player, {}) + end + end + end +end) + +minetest.register_on_leaveplayer(function(player) + -- cleanup stuff + local name = player:get_player_name() + logins[name] = nil + if privs[name] then + minetest.set_player_privs(name, privs[name]) + privs[name] = nil + end +end) + +local function do_grace() + minetest.after(7, do_grace) + + local t = os.time() + for k, v in pairs(logins) do + if v > t then + minetest.kick_player(k, "You did not authenticate or register within the allowed time limit.") + end + end +end +minetest.after(reg_grace, do_grace) + +local function do_updates(first) + minetest.after(15 * 60, do_updates, false) + + if not reg_id then + return + end + + local post_data = { + request_type = "UPDATES", + server_id = reg_id, + } + if first then + post_data.server_data = { + owner = S:get("name"), + name = S:get("server_name"), + address = S:get("server_address"), + url = S:get("server_url"), + announce = S:get("server_announce"), + announce_url = S:get("serverlist_url"), + } + end + + http.fetch({ + url = reg_api, + post_data = minetest.write_json(post_data), + timeout = 15, + }, function(res) + assert(res.completed) + print(dump(res)) + if not res.succeeded then + -- major problem, retry + minetest.log("error", "mt2fa: server replied with an error code") + return + end + local data = minetest.parse_json(res.data) + if not data then + -- major problem, retry + minetest.log("error", "mt2fa: failed to parse JSON from mt2fa server") + return + end + + minetest.log("action", "mt2fa: received " .. data.result) + end) +end +minetest.after(3, do_updates, true) +-- +-- admin/server command +-- +minetest.register_chatcommand("mt2fa", { + params = "mt2fa server", + description = "Administrate 2-factor authentication services", + privs = {server = true}, + func = function(name, param) + if param:sub(1,9) == "register " then + if reg_id then + return false, "This server already has a server ID - you have already registered it." + end + local email = param:sub(10) + local player = minetest.get_player_by_name(name) + assert(player) + send_request("SERVER", player, { + email = email, + server_data = { + owner = S:get("name"), + name = S:get("server_name"), + address = S:get("server_address"), + url = S:get("server_url"), + announce = S:get("server_announce"), + announce_url = S:get("serverlist_url"), + }, + }) + elseif param:sub(1,9) == "ipchange " then + local email = param:sub(10) + local player = minetest.get_player_by_name(name) + assert(player) + send_request("SERVERIP", player, { + email = email, + server_data = { + owner = S:get("name"), + name = S:get("server_name"), + address = S:get("server_address"), + url = S:get("server_url"), + announce = S:get("server_announce"), + announce_url = S:get("serverlist_url"), + }, + }) + else + return true, "Usage: /mt2fa [register|ipchange] " + end + end +}) + +-- +-- TODO: add cli commands for server operator to obtain and renew mt2fa tokens +-- for the server. +-- + +-- TODO: cli for modifying auth_required for local players diff --git a/mod.conf b/mod.conf new file mode 100644 index 0000000..3f6a658 --- /dev/null +++ b/mod.conf @@ -0,0 +1 @@ +name = mt2fa diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..ea22a47 --- /dev/null +++ b/readme.md @@ -0,0 +1,70 @@ + +## mt2fa - authenticate using your email + +Enables server to allow users to add an extra layer of security and verify +their identity using their email address. + +## License + + (C) 2018 Auke Kok + + Permission to use, copy, modify, and/or distribute this software + for any purpose with or without fee is hereby granted, provided + that the above copyright notice and this permission notice appear + in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR +BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. + +## chatcommands + +These commands require the `server` privilege. + + `/mt2fa register ` + + Required. Until registration is complete, only players with the `server` + priv will be able to log in. This command will perform a `server` + registration that will need confirmation. + + `/mt2fa ipchange ` + + If the server changes IP address, this will need confirmation. The email + must be the same as the original registration address. This action will + need confirmation. Until the ipchange is confirmed, no other actions will + be allowed by the mt2fa server. + +## settings + + `mt2fa.require_registration bool false` + + All player must register if set to `true` + + `mt2fa.require_authentication bool false` + + All players must authenticate if set to `true` + + `mt2fa.api_server string https://mt2fa.foo-projects.org/mt2fa` + + Adress to the mt2fa API server. + + `mt2fa.grace int 300` + + How long (seconds) a player can take to perform required authentication + and/or registration. + +## player attributes + + `mt2fa.registered int nil` + + This player has previously registered. + + `mt2fa.auth_required int nil` + + This player must authenticate. +