changeset 4093:a2116f5a7c8f

mod_invites_register_web: New module to allow web registration with an invite token
author Matthew Wild <mwild1@gmail.com>
date Fri, 11 Sep 2020 13:51:54 +0100
parents 2b6918714792
children dd00a2b9927c
files mod_invites_register_web/README.markdown mod_invites_register_web/html/register.html mod_invites_register_web/html/register_error.html mod_invites_register_web/html/register_success.html mod_invites_register_web/html/register_success_setup.html mod_invites_register_web/mod_invites_register_web.lua
diffstat 6 files changed, 523 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_register_web/README.markdown	Fri Sep 11 13:51:54 2020 +0100
@@ -0,0 +1,34 @@
+---
+labels:
+- 'Stage-Beta'
+summary: 'Register accounts via the web using invite tokens'
+...
+
+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_register_web implements a web-based registration form that
+validates invite tokens. It also supports guiding the user through client
+download and configuration via mod_register_apps.
+
+There is no specific configuration for this module (though it uses the
+optional `site_name` to override the displayed site name.
+
+This module depends on mod_invites_page solely for the case where an invalid
+invite token is received - it will redirect to mod_invites_page so that an
+appropriate error can be served to the user.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_register_web/html/register.html	Fri Sep 11 13:51:54 2020 +0100
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>{site_name}</title>
+	<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
+	<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+	<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+	<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+	<link rel="manifest" href="/site.webmanifest">
+	<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
+	<meta name="msapplication-TileColor" content="#fbd308">
+	<meta name="theme-color" content="#fbd308">
+</head>
+<body>
+	<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
+	<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
+		<div class="card rounded-lg shadow">
+			<h1 class="card-header rounded-lg rounded-lg">
+				Register on {site_name}<br/>
+			</h1>
+			<div class="card-body" >
+				<p>{site_name} is part of XMPP, a secure and decentralized messaging network. To begin
+				chatting {app&using {app.name} }you need to first register an account.</p>
+
+				<p>Creating an account will allow to communicate with {inviter&{inviter} and }other
+				people on {site_name} and other services on the XMPP network.</p>
+
+				{app&{app.supports_preauth_uri&
+				<div class="alert alert-info">
+					<p>If you already have {app.name} installed,
+					we recommend that you continue the account creation process using the app
+					by clicking on the button below:</p>
+
+					<h6 class="text-center">{app.name} already installed?</h6>
+
+					<div class="text-center">
+						<a href="{uri}"><button class="btn btn-secondary btn-sm">Open the app</button></a><br/>
+						<small class="text-muted">This button works only if you have the app installed already!</small>
+					</div>
+					<br/>
+				</div>
+				}}
+
+				<h5 class="card-title">Create an account</h5>
+
+				{message&<div class="alert {msg_class?alert-info}" role="alert">
+				  {message}
+				</div>}
+
+				<form method="post">
+					<div class="form-group form-row">
+						<label for="user" class="col-md-4 col-lg-12 col-form-label">Username:</label>
+						<div class="col-md-8 col-lg-12">
+							<div class="input-group">
+								<input
+								       type="text" name="user" class="form-control" aria-describedby="usernameHelp"
+								       required autofocus minlength="1" maxlength="30" length="30"
+								>
+								<div class="input-group-append">
+									<span class="input-group-text">@{domain}</span>
+								</div>
+							</div>
+							<small id="usernameHelp" class="d-block form-text text-muted">Choose a username, this will become the first part of your new chat address.</small>
+						</div>
+					</div>
+					<div class="form-group form-row">
+						<label for="password" class="col-md-4 col-lg-12 col-form-label">Password:</label>
+						<div class="col-md-8 col-lg-12">
+							<input type="password" name="password" class="form-control" aria-describedby="passwordHelp"
+							       autocomplete="new-password"
+							>
+							<small id="passwordHelp" class="form-text text-muted">Enter a secure password that you do not use anywhere else.</small>
+						</div>
+					</div>
+					<div class="form-group form-row">
+						<input type="hidden" name="token" value="{token}">
+						{app&<input type="hidden" name="app_id" value="{app.id}">}
+						<button type="submit" class="btn btn-primary btn-lg">Submit</button>
+					</div>
+				</form>
+			</div>
+		</div>
+	</div>
+	<script src="/share/jquery/jquery.min.js"></script>
+	<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_register_web/html/register_error.html	Fri Sep 11 13:51:54 2020 +0100
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>Invite to {site_name}</title>
+	<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
+	<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+	<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+	<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+	<link rel="manifest" href="/site.webmanifest">
+	<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
+	<meta name="msapplication-TileColor" content="#fbd308">
+	<meta name="theme-color" content="#fbd308">
+</head>
+<body>
+	<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
+	<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
+		<div class="card rounded-lg shadow">
+			<h1 class="card-header rounded-lg rounded-lg">
+				Invite to {site_name}<br/>
+			</h1>
+			<div class="card-body" >
+				<h5 class="card-title">Registration error</h5>
+
+				<p>{message?Sorry, there was a problem registering your account.}</p>
+			</div>
+		</div>
+	</div>
+	<script src="/share/jquery/jquery.min.js"></script>
+	<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_register_web/html/register_success.html	Fri Sep 11 13:51:54 2020 +0100
@@ -0,0 +1,80 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>{site_name}</title>
+	<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
+	<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+	<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+	<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+	<link rel="manifest" href="/site.webmanifest">
+	<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
+	<meta name="msapplication-TileColor" content="#fbd308">
+	<meta name="theme-color" content="#fbd308">
+
+	<script>
+		function toggle_password(e) {
+			var button = e.target;
+			var input = button.parentNode.parentNode.querySelector("input");
+			switch(input.attributes.type.value) {
+			case "password":
+				input.attributes.type.value = "text";
+				button.innerText = "Hide";
+				break;
+			case "text":
+				input.attributes.type.value = "password";
+				button.innerText = "Show";
+				break;
+			}
+		  }
+	</script>
+</head>
+<body>
+	<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
+	<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
+		<div class="card rounded-lg shadow">
+			<h1 class="card-header rounded-lg rounded-lg">
+				{site_name}<br/>
+			</h1>
+			<div class="card-body">
+				<h5 class="card-title">Congratulations!</h5>
+
+				<p>You have created an account on {site_name}.</p>
+
+				<p>To start chatting, you need to enter your new account
+				credentials into your chosen XMPP software.</p>
+
+				<p>As a final reminder, your account details are shown below:</p>
+
+				<form class="account-details col-12 col-lg-6 mx-auto">
+					<div class="form-group form-row">
+						<label for="user" class="col-md-4 col-lg-12 col-form-label">Chat address (JID):</label>
+						<div class="col-md-8 col-lg-12">
+							<input type="text" class="form-control-plaintext" readonly value="{username}@{domain}">
+						</div>
+					</div>
+					{password&
+					<div class="form-group form-row">
+						<label for="password" class="col-md-4 col-lg-12 col-form-label">Password:</label>
+						<div class="col-md-8 col-lg-12">
+							<div class="input-group">
+								<input type="password" readonly class="form-control" value="{password}">
+								<div class="input-group-append">
+									<button class="btn btn-outline-secondary" type="button" onclick="toggle_password(event)">Show</button>
+								</div>
+							</div>
+						</div>
+					</div>
+					}
+				</form>
+
+				<p>Your password is stored encrypted on the server and will not be accessible after you close
+				this page. Keep it safe and never share it with anyone.</p>
+			</div>
+		</div>
+	</div>
+	<script src="/share/jquery/jquery.min.js"></script>
+	<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_register_web/html/register_success_setup.html	Fri Sep 11 13:51:54 2020 +0100
@@ -0,0 +1,104 @@
+<!DOCTYPE html>
+<html>
+<head>
+	<meta charset="utf-8">
+	<meta name="viewport" content="width=device-width, initial-scale=1.0">
+	<title>{site_name}</title>
+	<link rel="stylesheet" href="/share/bootstrap4/css/bootstrap.min.css">
+	<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
+	<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
+	<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
+	<link rel="manifest" href="/site.webmanifest">
+	<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
+	<meta name="msapplication-TileColor" content="#fbd308">
+	<meta name="theme-color" content="#fbd308">
+
+	<script>
+		function toggle_password(e) {
+			var button = e.target;
+			var input = button.parentNode.parentNode.querySelector("input");
+			switch(input.attributes.type.value) {
+			case "password":
+				input.attributes.type.value = "text";
+				button.innerText = "Hide";
+				break;
+			case "text":
+				input.attributes.type.value = "password";
+				button.innerText = "Show";
+				break;
+			}
+		  }
+	</script>
+</head>
+<body>
+	<div id="background" class="fixed-top overflow-hidden" aria-role="none presentation"></div>
+	<div id="form" class="container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 mt-2 mt-md-5">
+		<div class="card rounded-lg shadow">
+			<h1 class="card-header rounded-lg rounded-lg">
+				{site_name}<br/>
+			</h1>
+			<div class="card-body">
+				<h5 class="card-title">Congratulations!</h5>
+
+				<p>You have created an account on {site_name}!</p>
+
+				<p>You can now set up {app.name} and connect it to your new account.</p>
+
+				<h5>Step 1: Download and install {app.name}</h5>
+
+				<p>{app.download.text?Download and install {app.name} below:}</p>
+
+				<div class="ml-5 mb-3">
+					{app.download.buttons#
+						{item.image&
+							<a href="{item.url}" {item.target&target="{item.target}"} rel="noopener">
+								<img src="{item.image}" {item.alttext&alt="{item.alttext}"}>
+							</a>
+						}
+						{item.text&
+							<a href="{item.url}" {item.target&target="{item.target}"} rel="noopener">
+								<button class="btn btn-primary">
+									{item.text}
+								</button>
+							</a>
+						}
+					}
+				</div>
+
+				<h5>Step 2: Connect {app.name} to your new account</h5>
+
+				<p>{app.setup.text?Launch {app.name} and sign in using your account credentials.}</p>
+
+				<p>As a final reminder, your account details are shown below:</p>
+
+				<form class="account-details col-12 col-lg-6 mx-auto">
+					<div class="form-group form-row">
+						<label for="user" class="col-md-4 col-lg-12 col-form-label font-weight-bold">Chat address (JID):</label>
+						<div class="col-md-8 col-lg-12">
+							<input type="text" class="form-control-plaintext" readonly value="{username}@{domain}">
+						</div>
+					</div>
+					{password&
+					<div class="form-group form-row">
+						<label for="password" class="col-md-4 col-lg-12 col-form-label font-weight-bold">Password:</label>
+						<div class="col-md-8 col-lg-12">
+							<div class="input-group">
+								<input type="password" readonly class="form-control" value="{password}">
+								<div class="input-group-append">
+									<button class="btn btn-outline-secondary" type="button" onclick="toggle_password(event)">Show</button>
+								</div>
+							</div>
+						</div>
+					</div>
+					}
+				</form>
+
+				<p>Your password is stored encrypted at {site_name} and will not be accessible after you close
+				this page. Keep it safe and never share it with anyone.</p>
+			</div>
+		</div>
+	</div>
+	<script src="/share/jquery/jquery.min.js"></script>
+	<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_register_web/mod_invites_register_web.lua	Fri Sep 11 13:51:54 2020 +0100
@@ -0,0 +1,183 @@
+local id = require "util.id";
+local http_formdecode = require "net.http".formdecode;
+local usermanager = require "core.usermanager";
+local nodeprep = require "util.encodings".stringprep.nodeprep;
+local st = require "util.stanza";
+local url_escape = require "util.http".urlencode;
+local render_html_template = require"util.interpolation".new("%b{}", st.xml_escape, {
+	urlescape = url_escape;
+});
+
+
+module:depends("register_apps");
+
+local site_name = module:get_option_string("site_name", module.host);
+local site_apps = module:shared("register_apps/apps");
+
+module:depends("http");
+module:depends("easy_invite");
+local invites = module:depends("invites");
+local invites_page = module:depends("invites_page");
+
+function serve_register_page(event)
+	local register_page_template = assert(module:load_resource("html/register.html")):read("*a");
+
+	local query_params = http_formdecode(event.request.url.query);
+
+	local invite = invites.get(query_params.t);
+	if not invite then
+		return {
+			status_code = 303;
+			headers = {
+				["Location"] = invites.module:http_url().."?"..event.request.url.query;
+			};
+		};
+	end
+
+	local invite_page = render_html_template(register_page_template, {
+		site_name = site_name;
+		token = invite.token;
+		domain = module.host;
+		uri = invite.uri;
+		type = invite.type;
+		jid = invite.jid;
+		inviter = invite.inviter;
+		app = query_params.c and site_apps[query_params.c];
+	});
+	return invite_page;
+end
+
+function handle_register_form(event)
+	local request, response = event.request, event.response;
+	local form_data = http_formdecode(request.body);
+	local user, password, token = form_data["user"], form_data["password"], form_data["token"];
+	local app_id = form_data["app_id"];
+
+	local register_page_template = assert(module:load_resource("html/register.html")):read("*a");
+	local error_template = assert(module:load_resource("html/register_error.html")):read("*a");
+
+	local invite = invites.get(token);
+	if not invite then
+		return {
+			status_code = 303;
+			headers = {
+				["Location"] = invites_page.module:http_url().."?"..event.request.url.query;
+			};
+		};
+	end
+
+	response.headers.content_type = "text/html; charset=utf-8";
+
+	if not user or #user == 0 or not password or #password == 0 or not token then
+		return render_html_template(register_page_template, {
+			site_name = site_name;
+			token = invite.token;
+			domain = module.host;
+			uri = invite.uri;
+			type = invite.type;
+			jid = invite.jid;
+
+			msg_class = "alert-warning";
+			message = "Please fill in all fields.";
+		});
+	end
+
+	-- Shamelessly copied from mod_register_web.
+	local prepped_username = nodeprep(user);
+
+	if not prepped_username or #prepped_username == 0 then
+		return render_html_template(register_page_template, {
+			site_name = site_name;
+			token = invite.token;
+			domain = module.host;
+			uri = invite.uri;
+			type = invite.type;
+			jid = invite.jid;
+
+			msg_class = "alert-warning";
+			message = "This username contains invalid characters.";
+		});
+	end
+
+	if usermanager.user_exists(prepped_username, module.host) then
+		return render_html_template(register_page_template, {
+			site_name = site_name;
+			token = invite.token;
+			domain = module.host;
+			uri = invite.uri;
+			type = invite.type;
+			jid = invite.jid;
+
+			msg_class = "alert-warning";
+			message = "This username is already in use.";
+		});
+	end
+
+	local registering = {
+		validated_invite = invite;
+		username = prepped_username;
+		host = module.host;
+		allowed = true;
+	};
+
+	module:fire_event("user-registering", registering);
+
+	if not registering.allowed then
+		return render_html_template(error_template, {
+			site_name = site_name;
+			msg_class = "alert-danger";
+			message = registering.reason or "Registration is not allowed.";
+		});
+	end
+
+	local ok, err = usermanager.create_user(prepped_username, password, module.host);
+
+	if ok then
+		module:fire_event("user-registered", {
+			username = prepped_username;
+			host = module.host;
+			source = "mod_"..module.name;
+			validated_invite = invite;
+		});
+
+		local app_info = site_apps[app_id];
+
+		local success_template;
+		if app_info then
+			-- If recognised app, we serve a page that includes setup instructions
+			success_template = assert(module:load_resource("html/register_success_setup.html")):read("*a");
+		else
+			success_template = assert(module:load_resource("html/register_success.html")):read("*a");
+		end
+
+		-- Due to the credentials being served here, ensure that
+		-- the browser or any intermediary does not cache the page
+		event.response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate";
+		event.response.headers["Pragma"] = "no-cache";
+		event.response.headers["Expires"] = "0";
+
+		return render_html_template(success_template, {
+			site_name = site_name;
+			username = prepped_username;
+			domain = module.host;
+			password = password;
+			app = app_info;
+		});
+	else
+		local err_id = id.short();
+		module:log("warn", "Registration failed (%s): %s", err_id, tostring(err));
+		return render_html_template(error_template, {
+			site_name = site_name;
+			msg_class = "alert-danger";
+			message = ("An unknown error has occurred (%s)"):format(err_id);
+		});
+	end
+end
+
+module:provides("http", {
+	default_path = "register";
+	route = {
+		["GET"] = serve_register_page;
+		["POST"] = handle_register_form;
+	};
+});