# HG changeset patch # User JC Brand # Date 1522140484 -7200 # Node ID d0ca211e1b0e5271bf1dd60f846e422a7f9c037f # Parent f000ba14d531abf78bd0024cf571eb168999837a New HMAC token authentication module for Prosody. diff -r f000ba14d531 -r d0ca211e1b0e mod_auth_token/README.md --- /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/`). diff -r f000ba14d531 -r d0ca211e1b0e mod_auth_token/mock.lua --- /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 diff -r f000ba14d531 -r d0ca211e1b0e mod_auth_token/mod_auth_token.lua --- /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); diff -r f000ba14d531 -r d0ca211e1b0e mod_auth_token/mod_sasl_token.lua --- /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); diff -r f000ba14d531 -r d0ca211e1b0e mod_auth_token/test_token_auth.lua --- /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()) diff -r f000ba14d531 -r d0ca211e1b0e mod_auth_token/token_auth_utils.lib.lua --- /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; +}