Changeset

12142:87532eebd0b8

mod_invites: Import from prosdy-modules@5fc306239db3
author Kim Alvefur <zash@zash.se>
date Mon, 27 Dec 2021 20:46:34 +0100
parents 12141:3ac801630b4b
children 12143:51b7ade94d50
files CHANGES plugins/mod_invites.lua
diffstat 2 files changed, 340 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES	Fri Dec 31 14:14:03 2021 +0100
+++ b/CHANGES	Mon Dec 27 20:46:34 2021 +0100
@@ -17,6 +17,7 @@
 -   mod_admin_shell: New home of the Console admin interface
 -   mod_admin_socket: Enable secure connections to the Console
 -   mod_tombstones: Prevent registration of deleted accounts
+-   mod_invites: Create and manage invites
 
 ### Security and authentication
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_invites.lua	Mon Dec 27 20:46:34 2021 +0100
@@ -0,0 +1,339 @@
+local id = require "util.id";
+local it = require "util.iterators";
+local url = require "socket.url";
+local jid_node = require "util.jid".node;
+local jid_split = require "util.jid".split;
+
+local default_ttl = module:get_option_number("invite_expiry", 86400 * 7);
+
+local token_storage;
+if prosody.process_type == "prosody" or prosody.shutdown then
+	token_storage = module:open_store("invite_token", "map");
+end
+
+local function get_uri(action, jid, token, params) --> string
+	return url.build({
+			scheme = "xmpp",
+			path = jid,
+			query = action..";preauth="..token..(params and (";"..params) or ""),
+		});
+end
+
+local function create_invite(invite_action, invite_jid, allow_registration, additional_data, ttl, reusable)
+	local token = id.medium();
+
+	local created_at = os.time();
+	local expires = created_at + (ttl or default_ttl);
+
+	local invite_params = (invite_action == "roster" and allow_registration) and "ibr=y" or nil;
+
+	local invite = {
+		type = invite_action;
+		jid = invite_jid;
+
+		token = token;
+		allow_registration = allow_registration;
+		additional_data = additional_data;
+
+		uri = get_uri(invite_action, invite_jid, token, invite_params);
+
+		created_at = created_at;
+		expires = expires;
+
+		reusable = reusable;
+	};
+
+	module:fire_event("invite-created", invite);
+
+	if allow_registration then
+		local ok, err = token_storage:set(nil, token, invite);
+		if not ok then
+			module:log("warn", "Failed to store account invite: %s", err);
+			return nil, "internal-server-error";
+		end
+	end
+
+	if invite_action == "roster" then
+		local username = jid_node(invite_jid);
+		local ok, err = token_storage:set(username, token, expires);
+		if not ok then
+			module:log("warn", "Failed to store subscription invite: %s", err);
+			return nil, "internal-server-error";
+		end
+	end
+
+	return invite;
+end
+
+-- Create invitation to register an account (optionally restricted to the specified username)
+function create_account(account_username, additional_data, ttl) --luacheck: ignore 131/create_account
+	local jid = account_username and (account_username.."@"..module.host) or module.host;
+	return create_invite("register", jid, true, additional_data, ttl);
+end
+
+-- Create invitation to reset the password for an account
+function create_account_reset(account_username, ttl) --luacheck: ignore 131/create_account_reset
+	return create_account(account_username, { allow_reset = account_username }, ttl or 86400);
+end
+
+-- Create invitation to become a contact of a local user
+function create_contact(username, allow_registration, additional_data, ttl) --luacheck: ignore 131/create_contact
+	return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl);
+end
+
+-- Create invitation to register an account and join a user group
+-- If explicit ttl is passed, invite is valid for multiple signups
+-- during that time period
+function create_group(group_ids, additional_data, ttl) --luacheck: ignore 131/create_group
+	local merged_additional_data = {
+		groups = group_ids;
+	};
+	if additional_data then
+		for k, v in pairs(additional_data) do
+			merged_additional_data[k] = v;
+		end
+	end
+	return create_invite("register", module.host, true, merged_additional_data, ttl, not not ttl);
+end
+
+-- Iterates pending (non-expired, unused) invites that allow registration
+function pending_account_invites() --luacheck: ignore 131/pending_account_invites
+	local store = module:open_store("invite_token");
+	local now = os.time();
+	local function is_valid_invite(_, invite)
+		return invite.expires > now;
+	end
+	return it.filter(is_valid_invite, pairs(store:get(nil) or {}));
+end
+
+function get_account_invite_info(token) --luacheck: ignore 131/get_account_invite_info
+	if not token then
+		return nil, "no-token";
+	end
+
+	-- Fetch from host store (account invite)
+	local token_info = token_storage:get(nil, token);
+	if not token_info then
+		return nil, "token-invalid";
+	elseif os.time() > token_info.expires then
+		return nil, "token-expired";
+	end
+
+	return token_info;
+end
+
+function delete_account_invite(token) --luacheck: ignore 131/delete_account_invite
+	if not token then
+		return nil, "no-token";
+	end
+
+	return token_storage:set(nil, token, nil);
+end
+
+local valid_invite_methods = {};
+local valid_invite_mt = { __index = valid_invite_methods };
+
+function valid_invite_methods:use()
+	if self.reusable then
+		return true;
+	end
+
+	if self.username then
+		-- Also remove the contact invite if present, on the
+		-- assumption that they now have a mutual subscription
+		token_storage:set(self.username, self.token, nil);
+	end
+	token_storage:set(nil, self.token, nil);
+
+	return true;
+end
+
+-- Get a validated invite (or nil, err). Must call :use() on the
+-- returned invite after it is actually successfully used
+-- For "roster" invites, the username of the local user (who issued
+-- the invite) must be passed.
+-- If no username is passed, but the registration is a roster invite
+-- from a local user, the "inviter" field of the returned invite will
+-- be set to their username.
+function get(token, username)
+	if not token then
+		return nil, "no-token";
+	end
+
+	local valid_until, inviter;
+
+	-- Fetch from host store (account invite)
+	local token_info = token_storage:get(nil, token);
+
+	if username then -- token being used for subscription
+		-- Fetch from user store (subscription invite)
+		valid_until = token_storage:get(username, token);
+	else -- token being used for account creation
+		valid_until = token_info and token_info.expires;
+		if token_info and token_info.type == "roster" then
+			username = jid_node(token_info.jid);
+			inviter = username;
+		end
+	end
+
+	if not valid_until then
+		module:log("debug", "Got unknown token: %s", token);
+		return nil, "token-invalid";
+	elseif os.time() > valid_until then
+		module:log("debug", "Got expired token: %s", token);
+		return nil, "token-expired";
+	end
+
+	return setmetatable({
+		token = token;
+		username = username;
+		inviter = inviter;
+		type = token_info and token_info.type or "roster";
+		uri = token_info and token_info.uri or get_uri("roster", username.."@"..module.host, token);
+		additional_data = token_info and token_info.additional_data or nil;
+		reusable = token_info.reusable;
+	}, valid_invite_mt);
+end
+
+function use(token) --luacheck: ignore 131/use
+	local invite = get(token);
+	return invite and invite:use();
+end
+
+--- shell command
+do
+	-- Since the console is global this overwrites the command for
+	-- each host it's loaded on, but this should be fine.
+
+	local get_module = require "core.modulemanager".get_module;
+
+	local console_env = module:shared("/*/admin_shell/env");
+
+	-- luacheck: ignore 212/self
+	console_env.invite = {};
+	function console_env.invite:create_account(user_jid)
+		local username, host = jid_split(user_jid);
+		local mod_invites, err = get_module(host, "invites");
+		if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
+		local invite, err = mod_invites.create_account(username);
+		if not invite then return nil, err; end
+		return true, invite.uri;
+	end
+
+	function console_env.invite:create_contact(user_jid, allow_registration)
+		local username, host = jid_split(user_jid);
+		local mod_invites, err = get_module(host, "invites");
+		if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
+		local invite, err = mod_invites.create_contact(username, allow_registration);
+		if not invite then return nil, err; end
+		return true, invite.uri;
+	end
+end
+
+--- prosodyctl command
+function module.command(arg)
+	if #arg < 2 or arg[1] ~= "generate" then
+		print("usage: prosodyctl mod_"..module.name.." generate example.com");
+		return 2;
+	end
+	table.remove(arg, 1); -- pop command
+
+	local sm = require "core.storagemanager";
+	local mm = require "core.modulemanager";
+
+	local host = arg[1];
+	assert(hosts[host], "Host "..tostring(host).." does not exist");
+	sm.initialize_host(host);
+	table.remove(arg, 1); -- pop host
+	module.host = host; --luacheck: ignore 122/module
+	token_storage = module:open_store("invite_token", "map");
+
+	-- Load mod_invites
+	local invites = module:depends("invites");
+	local invites_page_module = module:get_option_string("invites_page_module", "invites_page");
+	if mm.get_modules_for_host(host):contains(invites_page_module) then
+		module:depends(invites_page_module);
+	end
+
+	local allow_reset;
+	local roles;
+	local groups = {};
+
+	while #arg > 0 do
+		local value = arg[1];
+		table.remove(arg, 1);
+		if value == "--help" then
+			print("usage: prosodyctl mod_"..module.name.." generate DOMAIN --reset USERNAME")
+			print("usage: prosodyctl mod_"..module.name.." generate DOMAIN [--admin] [--role ROLE] [--group GROUPID]...")
+			print()
+			print("This command has two modes: password reset and new account.")
+			print("If --reset is given, the command operates in password reset mode and in new account mode otherwise.")
+			print()
+			print("required arguments in password reset mode:")
+			print()
+			print("    --reset USERNAME  Generate a password reset link for the given USERNAME.")
+			print()
+			print("optional arguments in new account mode:")
+			print()
+			print("    --admin           Make the new user privileged")
+			print("                      Equivalent to --role prosody:admin")
+			print("    --role ROLE       Grant the given ROLE to the new user")
+			print("    --group GROUPID   Add the user to the group with the given ID")
+			print("                      Can be specified multiple times")
+			print()
+			print("--role and --admin override each other; the last one wins")
+			print("--group can be specified multiple times; the user will be added to all groups.")
+			print()
+			print("--reset and the other options cannot be mixed.")
+			return 2
+		elseif value == "--reset" then
+			local nodeprep = require "util.encodings".stringprep.nodeprep;
+			local username = nodeprep(arg[1])
+			table.remove(arg, 1);
+			if not username then
+				print("Please supply a valid username to generate a reset link for");
+				return 2;
+			end
+			allow_reset = username;
+		elseif value == "--admin" then
+			roles = { ["prosody:admin"] = true };
+		elseif value == "--role" then
+			local rolename = arg[1];
+			if not rolename then
+				print("Please supply a role name");
+				return 2;
+			end
+			roles = { [rolename] = true };
+			table.remove(arg, 1);
+		elseif value == "--group" or value == "-g" then
+			local groupid = arg[1];
+			if not groupid then
+				print("Please supply a group ID")
+				return 2;
+			end
+			table.insert(groups, groupid);
+			table.remove(arg, 1);
+		else
+			print("unexpected argument: "..value)
+		end
+	end
+
+	local invite;
+	if allow_reset then
+		if roles then
+			print("--role/--admin and --reset are mutually exclusive")
+			return 2;
+		end
+		if #groups > 0 then
+			print("--group and --reset are mutually exclusive")
+		end
+		invite = assert(invites.create_account_reset(allow_reset));
+	else
+		invite = assert(invites.create_account(nil, {
+			roles = roles,
+			groups = groups
+		}));
+	end
+
+	print(invite.landing_page or invite.uri);
+end