aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGES1
-rw-r--r--plugins/mod_invites.lua339
2 files changed, 340 insertions, 0 deletions
diff --git a/CHANGES b/CHANGES
index f6d77f35..8f50f598 100644
--- a/CHANGES
+++ b/CHANGES
@@ -17,6 +17,7 @@ TRUNK
- 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
diff --git a/plugins/mod_invites.lua b/plugins/mod_invites.lua
new file mode 100644
index 00000000..8933b2bd
--- /dev/null
+++ b/plugins/mod_invites.lua
@@ -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