changeset 4115:165ade4ce97b

mod_invites_api: New module to create new invites over HTTP
author Matthew Wild <mwild1@gmail.com>
date Sun, 13 Sep 2020 11:05:19 +0100
parents 4656a64e59be
children 05d0a249326a
files mod_invites_api/README.markdown mod_invites_api/mod_invites_api.lua
diffstat 2 files changed, 237 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_api/README.markdown	Sun Sep 13 11:05:19 2020 +0100
@@ -0,0 +1,113 @@
+---
+labels:
+- 'Stage-Beta'
+summary: 'Authenticated HTTP API to create invites'
+rockspec:
+  dependencies:
+  - mod_invites
+...
+
+Introduction
+============
+
+This module is part of the suite of modules that implement invite-based
+account registration for Prosody. The other modules are:
+
+- mod_invites
+- mod_invites_adhoc
+- mod_invites_page
+- mod_invites_register
+- mod_invites_register_web
+- mod_register_apps
+
+For details and a full overview, start with the mod_invites documentation.
+
+Details
+=======
+
+mod_invites_api provides an authenticated HTTP API to create invites
+using mod_invites.
+
+You can use the command-line to create and manage API keys.
+
+Configuration
+=============
+
+There are no specific configuration options for this module.
+
+All the usual [HTTP configuration options](https://prosody.im/doc/http)
+can be used to configure this module.
+
+API usage
+=========
+
+Step 1: Create an API key, with an optional name to help you remember what
+it is for
+
+```
+$ prosodyctl mod_invites_api create example.com "My test key"
+```
+
+**Tip:** Remember to put quotes around your key name if it contains spaces.
+
+The command will print out a key:
+
+```
+HTwALnKL/73UUylA-2ZJbu9x1XMATuIbjWpip8ow1
+```
+
+Step 2: Make a HTTP request to Prosody, containing the key
+
+```
+$ curl -v https://example.com:5281/invites_api?key=HTwALnKL/73UUylA-2ZJbu9x1XMATuIbjWpip8ow1
+```
+
+Prosody will respond with a HTTP status code "201 Created" to indicate
+creation of the invite, and per HTTP's usual rules, the URL of the created
+invite page will be in the `Location` header:
+
+```
+< HTTP/1.1 201 Created
+< Access-Control-Max-Age: 7200
+< Connection: Keep-Alive
+< Access-Control-Allow-Origin: *
+< Date: Sun, 13 Sep 2020 09:50:19 GMT
+< Access-Control-Allow-Headers: Content-Type
+< Access-Control-Allow-Methods: OPTIONS, GET
+< Content-Length: 0
+< Location: https://example.com/invite?c-vhJjyB5Pb4HpAf
+```
+
+Sometimes for convenience, you may want to just visit the URL in the
+browser. Append `&redirect=true` to the URL, and instead Prosody will
+return a `303 See Other` response code, which will tell the browser to
+redirect straight to the newly-created invite. This is super handy in a
+bookmark :)
+
+If using the API programmatically, it is recommended to put the key in
+the `Authorization` header if possible. This is quite simple:
+
+```
+Authorization: Bearer HTwALnKL/73UUylA-2ZJbu9x1XMATuIbjWpip8ow1
+```
+
+Key management
+==============
+
+At any time you can view authorized keys using:
+
+```
+prosodyctl mod_invites_api list example.com
+```
+
+This will list out the id of each key, and the name if set:
+
+```
+HTwALnKL	My test key
+```
+
+You can revoke a key by passing this key id to the 'delete` sub-command:
+
+```
+prosodyctl mod_invites_api delete example.com HTwALnKL
+```
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_api/mod_invites_api.lua	Sun Sep 13 11:05:19 2020 +0100
@@ -0,0 +1,124 @@
+local http_formdecode = require "net.http".formdecode;
+
+local api_key_store;
+local invites;
+-- COMPAT: workaround to avoid executing inside prosodyctl
+if prosody.shutdown then
+	module:depends("http");
+	api_key_store = module:open_store("invite_api_keys", "map");
+	invites = module:depends("invites");
+end
+
+local function get_api_user(request, params)
+	local combined_key;
+
+	local auth_header = request.headers.authorization;
+
+	if not auth_header then
+		params = params or http_formdecode(request.url.query);
+		combined_key = params.key;
+	else
+		local auth_type, value = auth_header:match("^(%S+)%s(%S+)$");
+		if auth_type ~= "Bearer" then
+			return;
+		end
+		combined_key = value;
+	end
+
+	if not combined_key then
+		return;
+	end
+
+	local key_id, key_token = combined_key:match("^([^/]+)/(.+)$");
+
+	if not key_id then
+		return;
+	end
+
+	local api_user = api_key_store:get(nil, key_id);
+
+	if not api_user or api_user.token ~= key_token then
+		return;
+	end
+
+	-- TODO: key expiry, rate limiting, etc.
+	return api_user;
+end
+
+function handle_request(event)
+	local query_params = http_formdecode(event.request.url.query);
+
+	local api_user = get_api_user(event.request, query_params);
+
+	if not api_user then
+		return 403;
+	end
+
+	local invite = invites.create_account(nil, { source = "api/token/"..api_user.id });
+	if not invite then
+		return 500;
+	end
+
+	event.response.headers.Location = invite.landing_page or invite.uri;
+
+	if query_params.redirect then
+		return 303;
+	end
+	return 201;
+end
+
+if invites then
+	module:provides("http", {
+		route = {
+			["GET"] = handle_request;
+		};
+	});
+end
+
+function module.command(arg)
+	if #arg < 2 then
+		print("Usage:");
+		print("");
+		print(" prosodyctl mod_"..module.name.." create NAME");
+		print(" prosodyctl mod_"..module.name.." delete KEY_ID");
+		print(" prosodyctl mod_"..module.name.." list");
+		print("");
+	end
+
+	local command = table.remove(arg, 1);
+
+	local host = table.remove(arg, 1);
+	if not prosody.hosts[host] then
+		print("Error: please supply a valid host");
+		return 1;
+	end
+	require "core.storagemanager".initialize_host(host);
+	module.host = host; --luacheck: ignore 122/module
+	api_key_store = module:open_store("invite_api_keys", "map");
+
+	if command == "create" then
+		local id = require "util.id".short();
+		local token = require "util.id".long();
+		api_key_store:set(nil, id, {
+			id = id;
+			token = token;
+			name = arg[1];
+			created_at = os.time();
+		});
+		print(id.."/"..token);
+	elseif command == "delete" then
+		local id = table.remove(arg, 1);
+		if not api_key_store:get(nil, id) then
+			print("Error: key not found");
+			return 1;
+		end
+		api_key_store:set(nil, id, nil);
+	elseif command == "list" then
+		local api_key_store_kv = module:open_store("invite_api_keys");
+		for key_id, key_info in pairs(api_key_store_kv:get(nil)) do
+			print(key_id, key_info.name or "<unknown>");
+		end
+	else
+		print("Unknown command - "..command);
+	end
+end