view mod_sasl2_fast/mod_sasl2_fast.lua @ 5448:9d542e86e19a

mod_http_oauth2: Allow requesting a subset of scopes on token refresh This enables clients to request access tokens with fewer permissions than the grant they were given, reducing impact of token leak. Clients could e.g. request access tokens with some privileges and immediately revoke them after use, or other strategies.
author Kim Alvefur <zash@zash.se>
date Thu, 11 May 2023 21:40:09 +0200
parents b10a7082b3c3
children 563c2c70cb9f
line wrap: on
line source

local usermanager = require "core.usermanager";

local sasl = require "util.sasl";
local dt = require "util.datetime";
local id = require "util.id";
local jid = require "util.jid";
local st = require "util.stanza";
local now = require "util.time".now;
local hash = require "util.hashes";

module:depends("sasl2");

-- Tokens expire after 21 days by default
local fast_token_ttl = module:get_option_number("sasl2_fast_token_ttl", 86400*21);
-- Tokens are automatically rotated daily
local fast_token_min_ttl = module:get_option_number("sasl2_fast_token_min_ttl", 86400);

local xmlns_fast = "urn:xmpp:fast:0";
local xmlns_sasl2 = "urn:xmpp:sasl:2";

local token_store = module:open_store("fast_tokens", "map");

local log = module._log;

local function make_token(username, client_id, mechanism)
	local new_token = "secret-token:fast-"..id.long();
	local key = hash.sha256(client_id, true).."-new";
	local issued_at = now();
	local token_info = {
		mechanism = mechanism;
		secret = new_token;
		issued_at = issued_at;
		expires_at = issued_at + fast_token_ttl;
	};
	if not token_store:set(username, key, token_info) then
		return nil;
	end
	return token_info;
end

local function new_token_tester(hmac_f)
	return function (mechanism, username, client_id, token_hash, cb_data, invalidate)
		local account_info = usermanager.get_account_info(username, module.host);
		local last_password_change = account_info and account_info.password_updated;
		local tried_current_token = false;
		local key = hash.sha256(client_id, true).."-new";
		local token;
		repeat
			log("debug", "Looking for %s token %s/%s", mechanism, username, key);
			token = token_store:get(username, key);
			if token and token.mechanism == mechanism then
				local expected_hash = hmac_f(token.secret, "Initiator"..cb_data);
				if hash.equals(expected_hash, token_hash) then
					local current_time = now();
					if token.expires_at < current_time then
						log("debug", "Token found, but it has expired (%ds ago). Cleaning up...", current_time - token.expires_at);
						token_store:set(username, key, nil);
						return nil, "credentials-expired";
					elseif last_password_change and token.issued_at < last_password_change then
						log("debug", "Token found, but issued prior to password change (%ds ago). Cleaning up...",
							current_time - last_password_change
						);
						token_store:set(username, key, nil);
						return nil, "credentials-expired";
					end
					if not tried_current_token and not invalidate then
						-- The new token is becoming the current token
						token_store:set_keys(username, {
							[key] = token_store.remove;
							[key:sub(1, -5).."-cur"] = token;
						});
					end
					local rotation_needed;
					if invalidate then
						token_store:set(username, key, nil);
					elseif current_time - token.issued_at > fast_token_min_ttl then
						log("debug", "FAST token due for rotation (age: %d)", current_time - token.issued_at);
						rotation_needed = true;
					end
					return true, username, hmac_f(token.secret, "Responder"..cb_data), rotation_needed;
				end
			end
			if not tried_current_token then
				log("debug", "Trying next token...");
				-- Try again with the current token instead
				tried_current_token = true;
				key = key:sub(1, -5).."-cur";
			else
				log("debug", "No matching %s token found for %s/%s", mechanism, username, key);
				return nil;
			end
		until false;
	end
end

function get_sasl_handler()
	local token_auth_profile = {
		ht_sha_256 = new_token_tester(hash.hmac_sha256);
	};
	local handler = sasl.new(module.host, token_auth_profile);
	handler.fast = true;
	return handler;
end

-- Advertise FAST to connecting clients
module:hook("advertise-sasl-features", function (event)
	local session = event.origin;
	local username = session.username;
	if not username then
		username = jid.node(event.stream.from);
		if not username then return; end
	end
	local sasl_handler = get_sasl_handler(username);
	if not sasl_handler then return; end
	sasl_handler.fast_auth = true; -- For informational purposes
	-- Copy channel binding info from primary SASL handler
	sasl_handler.profile.cb = session.sasl_handler.profile.cb;
	sasl_handler.userdata = session.sasl_handler.userdata;
	-- Store this handler, in case we later want to use it for authenticating
	session.fast_sasl_handler = sasl_handler;
	local fast = st.stanza("fast", { xmlns = xmlns_fast });
	for mech in pairs(sasl_handler:mechanisms()) do
		fast:text_tag("mechanism", mech);
	end
	event.features:add_child(fast);
end);

-- Process any FAST elements in <authenticate/>
module:hook_tag(xmlns_sasl2, "authenticate", function (session, auth)
	-- Cache action for future processing (after auth success)
	local fast_auth = auth:get_child("fast", xmlns_fast);
	if fast_auth then
		-- Client says it is using FAST auth, so set our SASL handler
		local fast_sasl_handler = session.fast_sasl_handler;
		local client_id = auth:get_child_attr("user-agent", nil, "id");
		if fast_sasl_handler and client_id then
			session.log("debug", "Client is authenticating using FAST");
			fast_sasl_handler.client_id = client_id;
			fast_sasl_handler.profile.cb = session.sasl_handler.profile.cb;
			fast_sasl_handler.userdata = session.sasl_handler.userdata;
			local invalidate = fast_auth.attr.invalidate;
			fast_sasl_handler.invalidate = invalidate == "1" or invalidate == "true";
			-- Set our SASL handler as the session's SASL handler
			session.sasl_handler = fast_sasl_handler;
		else
			session.log("warn", "Client asked to auth via FAST, but SASL handler or client id missing");
			local failure = st.stanza("failure", { xmlns = xmlns_sasl2 })
				:tag("malformed-request"):up()
				:text_tag("text", "FAST is not available on this stream");
			session.send(failure);
			return true;
		end
	end
	session.fast_sasl_handler = nil;
	local fast_token_request = auth:get_child("request-token", xmlns_fast);
	if fast_token_request then
		local mech = fast_token_request.attr.mechanism;
		session.log("debug", "Client requested new FAST token for %s", mech);
		session.fast_token_request = {
			mechanism = mech;
		};
	end
end, 100);

-- Process post-success (new token generation, etc.)
module:hook("sasl2/c2s/success", function (event)
	local session = event.session;

	local token_request = session.fast_token_request;
	local client_id = session.client_id;
	local sasl_handler = session.sasl_handler;
	if token_request or (sasl_handler.fast and sasl_handler.rotation_needed) then
		if not client_id then
			session.log("warn", "FAST token requested, but missing client id");
			return;
		end
		local mechanism = token_request and token_request.mechanism or session.sasl_handler.selected;
		local token_info = make_token(session.username, client_id, mechanism)
		if token_info then
			session.log("debug", "Provided new FAST token to client");
			event.success:tag("token", {
				xmlns = xmlns_fast;
				expiry = dt.datetime(token_info.expires_at);
				token = token_info.secret;
			}):up();
		end
	end
end, 75);

-- HT-* mechanisms

local function new_ht_mechanism(mechanism_name, backend_profile_name, cb_name)
	return function (sasl_handler, message)
		local backend = sasl_handler.profile[backend_profile_name];
		local authc_username, token_hash = message:match("^([^%z]+)%z(.+)$");
		if not authc_username then
			return "failure", "malformed-request";
		end
		local cb_data = cb_name and sasl_handler.profile.cb[cb_name](sasl_handler) or "";
		local ok, authz_username, response, rotation_needed = backend(
			mechanism_name,
			authc_username,
			sasl_handler.client_id,
			token_hash,
			cb_data,
			sasl_handler.invalidate
		);
		if not ok then
			-- authz_username is error condition
			return "failure", authz_username or "not-authorized";
		end
		sasl_handler.username = authz_username;
		sasl_handler.rotation_needed = rotation_needed;
		return "success", response;
	end
end

local function register_ht_mechanism(name, backend_profile_name, cb_name)
	return sasl.registerMechanism(name, { backend_profile_name }, new_ht_mechanism(
		name,
		backend_profile_name,
		cb_name
	),
	cb_name and { cb_name } or nil);
end

register_ht_mechanism("HT-SHA-256-NONE", "ht_sha_256", nil);
register_ht_mechanism("HT-SHA-256-UNIQ", "ht_sha_256", "tls-unique");
register_ht_mechanism("HT-SHA-256-ENDP", "ht_sha_256", "tls-server-end-point");
register_ht_mechanism("HT-SHA-256-EXPR", "ht_sha_256", "tls-exporter");

-- Public API

--luacheck: ignore 131
function is_client_fast(username, client_id, last_password_change)
	local client_id_hash = hash.sha256(client_id, true);
	local curr_time = now();
	local cur = token_store:get(username, client_id_hash.."-cur");
	if cur and cur.expires_at >= curr_time and (not last_password_change or last_password_change < cur.issued_at) then
		return true;
	end
	local new = token_store:get(username, client_id_hash.."-new");
	if new and new.expires_at >= curr_time and (not last_password_change or last_password_change < new.issued_at) then
		return true;
	end
	return false;
end

function revoke_fast_tokens(username, client_id)
	local client_id_hash = hash.sha256(client_id, true);
	local cur_ok = token_store:set(username, client_id_hash.."-cur", nil);
	local new_ok = token_store:set(username, client_id_hash.."-new", nil);
	return cur_ok and new_ok;
end