Changeset

2956:d0ca211e1b0e

New HMAC token authentication module for Prosody.
author JC Brand <jc@opkode.com>
date Tue, 27 Mar 2018 10:48:04 +0200
parents 2938:f000ba14d531
children 2957:0f813e22e3fa
files mod_auth_token/README.md mod_auth_token/mock.lua mod_auth_token/mod_auth_token.lua mod_auth_token/mod_sasl_token.lua mod_auth_token/test_token_auth.lua mod_auth_token/token_auth_utils.lib.lua
diffstat 6 files changed, 457 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_token/README.md	Tue Mar 27 10:48:04 2018 +0200
@@ -0,0 +1,48 @@
+# mod_auth_token
+
+This module enables Prosody to authenticate time-based one-time-pin (TOTP) HMAC tokens.
+
+This is an alternative to "external authentication" which avoids the need to
+make a blocking HTTP call to the external authentication service (usually a web application backend).
+
+Instead, the application generates the HMAC token, which is then sent to
+Prosody via the XMPP client and Prosody verifies the authenticity of this
+token.
+
+If the token is verified, then the user is authenticated.
+
+## How to generate the token
+
+You'll need a shared OTP_SEED value for generating time-based one-time-pin
+values and a shared private key for signing the HMAC token.
+
+You can generate the OTP_SEED value with Python, like so:
+
+    >>> import pyotp
+    >>> pyotp.random_base32()
+    u'XVGR73KMZH2M4XMY'
+
+and the shared secret key as follows:
+
+    >>> import pyotp
+    >>> pyotp.random_base32(length=32)
+    u'JYXEX4IQOEYFYQ2S3MC5P4ZT4SDHYEA7'
+
+These values then need to go into your Prosody.cfg file:
+
+token_secret = "JYXEX4IQOEYFYQ2S3MC5P4ZT4SDHYEA7"
+otp_seed = "XVGR73KMZH2M4XMY"
+
+The application that generates the tokens also needs access to these values.
+
+For an example on how to generate a token, take a look at the `generate_token`
+function in the `test_token_auth.lua` file inside this directory.
+
+## Custom SASL auth
+
+This module depends on a custom SASL auth mechanism called X-TOKEN and which
+is provided by the file `mod_sasl_token.lua`.
+
+Prosody doesn't automatically pick up this file, so you'll need to update your
+configuration file's `plugin_paths` to link to this subdirectory (for example
+to `/usr/lib/prosody-modules/mod_auth_token/`).
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_token/mock.lua	Tue Mar 27 10:48:04 2018 +0200
@@ -0,0 +1,134 @@
+-- Source code taken from https://github.com/britzl/deftest
+-- Released under the MIT License. Copyright (c) 2009-2012 Norman Clarke.
+
+--- Provides the ability to mock any module.
+
+-- @usage
+--
+-- mock.mock(sys)
+--
+-- -- specifying return values
+-- sys.get_sys_info.returns({my_data})
+-- ...
+-- local sys_info = sys.get_sys_info() -- will be my_data
+-- assert(sys.get_sys_info.calls == 1) -- call counting
+-- ...
+-- local sys_info = sys.get_sys_info() -- original response as we are now out of mocked answers
+-- assert(sys.get_sys_info.calls == 2) -- call counting
+-- ...
+--
+-- -- specifying a replacement function
+-- sys.get_sys_info.replace(function () return my_data end)
+--
+-- ...
+-- local sys_info = sys.get_sys_info() -- will be my_data
+-- assert(sys.get_sys_info.calls == 3) -- call counting
+-- ...
+-- local sys_info = sys.get_sys_info() -- will still be my_data
+-- assert(sys.get_sys_info.calls == 4) -- call counting
+-- ...
+--
+-- -- cleaning up
+-- mock.unmock(sys) -- restore the sys library again
+
+local mock = {}
+
+--- Mock the specified module.
+-- Mocking the module extends the functions it contains with the ability to have their logic overridden.
+-- @param module module to mock
+-- @usage
+--
+-- -- mock module x
+-- mock.mock(x)
+--
+-- -- make x.f return 1, 2 then the original value
+-- x.f.returns({1, 2})
+-- print(x.f()) -- prints 1
+--
+-- -- make x.f return 1 forever
+-- x.f.replace(function () return 1 end)
+-- while true do print(x.f()) end -- prints 1 forever
+--
+-- -- counting calls
+-- assert(x.f.calls > 0)
+--
+-- -- return to original state of module x
+-- mock.unmock(x)
+--
+function mock.mock(module)
+	assert(module, "You must provide a module to mock")
+	for k,v in pairs(module) do
+		if type(v) == "function" then
+			local mock_fn = {
+				calls = 0,
+				answers = {},
+				repl_fn = nil,
+				orig_fn = v,
+				params = {}
+			}
+			function mock_fn.returns(...)
+				local arg_length = select("#", ...)
+				assert(arg_length > 0, "You must provide some answers")
+				local args = { ... }
+				if arg_length == 1 then
+					mock_fn.answers = args[1]
+				else
+					mock_fn.answers = args
+				end
+			end
+			function mock_fn.always_returns(answer)
+				mock_fn.repl_fn = function()
+					return answer
+				end
+			end
+			function mock_fn.replace(repl_fn)
+				mock_fn.repl_fn = repl_fn
+			end
+			function mock_fn.original(...)
+				return mock_fn.orig_fn(...)
+			end
+			function mock_fn.restore()
+				mock_fn.repl_fn = nil
+			end
+			local mt = {
+				__call = function (mock_fn, ...)
+					mock_fn.calls = mock_fn.calls + 1
+					local arg = {...}
+
+					if #arg > 0 then
+						for i=1,#arg do
+							mock_fn.params[i] = arg[i]
+						end
+					end
+
+					if mock_fn.answers[1] then
+						local result = mock_fn.answers[1]
+						table.remove(mock_fn.answers, 1)
+						return result
+					elseif mock_fn.repl_fn then
+						return mock_fn.repl_fn(...)
+					else
+						return v(...)
+					end
+				end
+			}
+			setmetatable(mock_fn, mt)
+			module[k] = mock_fn
+		end
+	end
+end
+
+--- Remove the mocking capabilities from a module.
+-- @param module module to remove mocking from
+function mock.unmock(module)
+	assert(module, "You must provide a module to unmock")
+	for k,v in pairs(module) do
+		if type(v) == "table" then
+			if v.orig_fn then
+				module[k] = v.orig_fn
+			end
+		end
+	end
+end
+
+return mock
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_token/mod_auth_token.lua	Tue Mar 27 10:48:04 2018 +0200
@@ -0,0 +1,59 @@
+-- Copyright (C) 2018 Minddistrict
+--
+-- This file is MIT/X11 licensed.
+--
+
+local host = module.host;
+local log = module._log;
+local new_sasl = require "util.sasl".new;
+local verify_token = module:require "token_auth_utils".verify_token;
+
+local provider = {};
+
+
+function provider.test_password(username, password, realm)
+	log("debug", "Testing signed OTP for user %s at host %s", username, host);
+	return verify_token(
+		username,
+		password,
+		realm,
+		module:get_option_string("otp_seed"),
+		module:get_option_string("token_secret"),
+		log
+	);
+end
+
+function provider.users()
+	return function()
+		return nil;
+	end
+end
+
+function provider.set_password(username, password)
+	return nil, "Changing passwords not supported";
+end
+
+function provider.user_exists(username)
+	return true;
+end
+
+function provider.create_user(username, password)
+	return nil, "User creation not supported";
+end
+
+function provider.delete_user(username)
+	return nil , "User deletion not supported";
+end
+
+function provider.get_sasl_handler()
+	local supported_mechanisms = {};
+	supported_mechanisms["X-TOKEN"] = true;
+	return new_sasl(host, {
+		token = function(sasl, username, password, realm)
+			return provider.test_password(username, password, realm), true;
+		end,
+        mechanisms = supported_mechanisms
+	});
+end
+
+module:provides("auth", provider);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_token/mod_sasl_token.lua	Tue Mar 27 10:48:04 2018 +0200
@@ -0,0 +1,54 @@
+-- Copyright (C) 2018 Minddistrict
+--
+-- This file is MIT/X11 licensed.
+--
+
+local s_match = string.match;
+local registerMechanism = require "util.sasl".registerMechanism;
+local saslprep = require "util.encodings".stringprep.saslprep;
+local nodeprep = require "util.encodings".stringprep.nodeprep;
+local log = require "util.logger".init("sasl");
+local _ENV = nil;
+
+
+local function token_auth(self, message)
+	if not message then
+		return "failure", "malformed-request";
+	end
+
+	local authorization, authentication, password = s_match(message, "^([^%z]*)%z([^%z]+)%z([^%z]+)");
+
+	if not authorization then
+		return "failure", "malformed-request";
+	end
+
+	-- SASLprep password and authentication
+	authentication = saslprep(authentication);
+	password = saslprep(password);
+
+	if (not password) or (password == "") or (not authentication) or (authentication == "") then
+		log("debug", "Username or password violates SASLprep.");
+		return "failure", "malformed-request", "Invalid username or password.";
+	end
+
+	local _nodeprep = self.profile.nodeprep;
+	if _nodeprep ~= false then
+		authentication = (_nodeprep or nodeprep)(authentication);
+		if not authentication or authentication == "" then
+			return "failure", "malformed-request", "Invalid username or password."
+		end
+	end
+
+	local correct, state = false, false;
+    correct, state = self.profile.token(self, authentication, password, self.realm);
+
+	self.username = authentication
+	if state == false then
+		return "failure", "account-disabled";
+	elseif state == nil or not correct then
+		return "failure", "not-authorized", "Unable to authorize you with the authentication credentials you've sent.";
+	end
+	return "success";
+end
+
+registerMechanism("X-TOKEN", {"token"}, token_auth);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_token/test_token_auth.lua	Tue Mar 27 10:48:04 2018 +0200
@@ -0,0 +1,95 @@
+local base64 = require "util.encodings".base64;
+local hmac = require "openssl.hmac";
+local luatz = require "luatz";
+local luaunit = require "luaunit";
+local uuid = require "uuid";
+local otp = require "otp";
+local mock = require "mock";
+local pkey = require "openssl.pkey";
+local token_utils = dofile("token_auth_utils.lib.lua");
+
+math.randomseed(os.time())
+
+local OTP_SEED = 'E3W374VRSFO4NVKE';
+
+
+function generate_token(jid, key)
+	local nonce = '';
+	for i=1,32 do
+		nonce = nonce..math.random(9);
+	end
+	local utc_time_table = luatz.gmtime(luatz.time());
+	local totp = otp.new_totp_from_key(
+		OTP_SEED,
+		token_utils.OTP_DIGITS,
+		token_utils.OTP_INTERVAL
+	):generate(0, utc_time_table);
+
+	local hmac_ctx = hmac.new(key, token_utils.DIGEST_TYPE)
+	local signature = hmac_ctx:final(totp..nonce..jid)
+	return totp..nonce..' '..base64.encode(signature)
+end
+
+
+function test_token_verification()
+	-- Test verification of a valid token
+	local key = uuid();
+	local result = token_utils.verify_token(
+		'root',
+		generate_token('root@localhost', key),
+		'localhost',
+		OTP_SEED,
+		key
+	)
+	luaunit.assert_is(result, true)
+end
+
+
+function test_token_is_valid_only_once()
+	local key = uuid();
+	local token = generate_token('root@localhost', key);
+	local result = token_utils.verify_token(
+		'root',
+		token,
+		'localhost',
+		OTP_SEED,
+		key
+	)
+	luaunit.assert_is(result, true)
+
+	result = token_utils.verify_token(
+		'root',
+		token,
+		'localhost',
+		OTP_SEED,
+		key
+	)
+	luaunit.assert_is(result, false)
+end
+
+
+function test_token_expiration()
+	-- Test that a token expires after (at most) the configured interval plus
+	-- any amount of deviations.
+	local key = uuid();
+	local token = generate_token('root@localhost', key);
+	-- Wait two ticks of the interval window and then check that the token is
+	-- no longer valid.
+	mock.mock(os);
+	os.time.replace(function ()
+		return os.time.original() +
+			(token_utils.OTP_INTERVAL + 
+				(token_utils.OTP_DEVIATION * token_utils.OTP_INTERVAL));
+	end)
+	result = token_utils.verify_token(
+		'root',
+		token,
+		'localhost',
+		OTP_SEED,
+		key
+	)
+	mock.unmock(os);
+	luaunit.assert_is(result, false)
+end
+
+os.exit(luaunit.LuaUnit.run())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_auth_token/token_auth_utils.lib.lua	Tue Mar 27 10:48:04 2018 +0200
@@ -0,0 +1,67 @@
+local base64 = require "util.encodings".base64;
+local digest = require "openssl.digest";
+local hmac = require "openssl.hmac";
+local luatz = require "luatz";
+local otp = require "otp";
+
+local DIGEST_TYPE = "SHA256";
+local OTP_DEVIATION = 1;
+local OTP_DIGITS = 8;
+local OTP_INTERVAL = 30;
+
+local nonce_cache = {};
+
+function check_nonce(jid, otp, nonce)
+	-- We cache all nonces used per OTP, to ensure that a token cannot be used
+	-- more than once.
+	--
+	-- We assume that the OTP is valid in the current time window. This is the
+	-- case because we only call check_nonce *after* the OTP has been verified.
+	--
+	-- We only store one OTP per JID, so if a new OTP comes in, we wipe the
+	-- previous OTP and its cached nonces.
+	if nonce_cache[jid] == nil or nonce_cache[jid][otp] == nil then
+		nonce_cache[jid] = {}
+		nonce_cache[jid][otp] = {}
+		nonce_cache[jid][otp][nonce] = true
+		return true;
+	end
+	if nonce_cache[jid][otp][nonce] == true then
+		return false;
+	else
+		nonce_cache[jid][otp][nonce] = true;
+		return true;
+	end
+end
+
+
+function verify_token(username, password, realm, otp_seed, token_secret, log)
+	local totp = otp.new_totp_from_key(otp_seed, OTP_DIGITS, OTP_INTERVAL)
+	local token = string.match(password, "(%d+) ")
+	local otp = token:sub(1,8)
+	local nonce = token:sub(9)
+	local signature = base64.decode(string.match(password, " (.+)"))
+	local jid = username.."@"..realm
+
+	if totp:verify(otp, OTP_DEVIATION, luatz.gmtime(luatz.time())) then
+		-- log("debug", "**** THE OTP WAS VERIFIED ****** ");
+		local hmac_ctx = hmac.new(token_secret, DIGEST_TYPE)
+		if signature == hmac_ctx:final(otp..nonce..jid) then
+			-- log("debug", "**** THE KEY WAS VERIFIED ****** ");
+			if check_nonce(jid, otp, nonce) then
+				-- log("debug", "**** THE NONCE WAS VERIFIED ****** ");
+				return true;
+			end
+		end
+	end
+	-- log("debug", "**** VERIFICATION FAILED ****** ");
+	return false;
+end
+
+return {
+	OTP_DEVIATION = OTP_DIGITS,
+	OTP_DIGITS = OTP_DIGITS,
+	OTP_INTERVAL = OTP_INTERVAL,
+	DIGEST_TYPE = DIGEST_TYPE,
+	verify_token = verify_token;
+}