changeset 5208:aaa64c647e12

mod_http_oauth2: Add authentication, consent and error pages
author Matthew Wild <mwild1@gmail.com>
date Mon, 06 Mar 2023 09:46:58 +0000
parents c72e3b0914e8
children 942f8a2f722d
files mod_http_oauth2/html/consent.html mod_http_oauth2/html/error.html mod_http_oauth2/html/login.html mod_http_oauth2/html/style.css mod_http_oauth2/mod_http_oauth2.lua
diffstat 5 files changed, 360 insertions(+), 38 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/consent.html	Mon Mar 06 09:46:58 2023 +0000
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>{site_name} - Authorize {client.client_name}</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+	<main>
+	{state.error&<div class="error">
+		<p>{state.error}</p>
+	</div>}
+
+	<h1>{site_name}<h1>
+	<h2>Authorize new application</h2>
+	<p>A new application wants to connect to your account.</p>
+	<dl>
+		<dt>Name</dt>
+		<dd>{client.client_name}</dd>
+		<dt>Website</dt>
+		<dd><a href="{client.client_uri}">{client.client_uri}</a></dd>
+
+		{client.tos_uri&
+		<dt>Terms of Service</dt>
+		<dd><a href="{client.tos_uri}">View terms</a></dd>}
+
+		{client.policy_uri&
+		<dt>Policy</dt>
+		<dd><a href="{client.policy_uri}">View policy</a></dd>}
+	</dl>
+
+	<p>To allow <em>{client.client_name}</em> to access your account
+	   <em>{state.user.username}@{state.user.host}</em> and associated data,
+	   select 'Allow'. Otherwise, select 'Deny'.
+	</p>
+
+	<form method="post">
+		<input type="hidden" name="user_token" value="{state.user.token}">
+		<button type="submit" name="consent" value="denied">Deny</button>
+		<button type="submit" name="consent" value="granted">Allow</button>
+	</form>
+	</main>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/error.html	Mon Mar 06 09:46:58 2023 +0000
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>{site_name} - Error</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+	<main>
+	<h1>{site_name}<h1>
+	<h2>Authentication error</h2>
+	<p>There was a problem with the authentication request. If you were trying to sign in to a
+	   third-party application, you may want to report this issue to the developers.</p>
+	<div class="error">
+		<p>{error.text}</p>
+	</div>
+	</main>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/login.html	Mon Mar 06 09:46:58 2023 +0000
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>{site_name} - Sign in</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+	<main>
+	<h1>{site_name}<h1>
+	<h2>Sign in</h2>
+	<p>Sign in to your account to continue.</p>
+	{state.error&<div class="error">
+		<p>{state.error}</p>
+	</div>}
+	<form method="post">
+		<input type="text" name="username" placeholder="Username" aria-label="Username" required><br/>
+		<input type="password" name="password" placeholder="Password" aria-label="Password" autocomplete="current-password" required><br/>
+		<input type="submit" value="Sign in">
+	</form>
+	</main>
+</body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/style.css	Mon Mar 06 09:46:58 2023 +0000
@@ -0,0 +1,101 @@
+body
+{
+	text-align:center;
+	background-color:#f8f8f8;
+	font-family:sans-serif
+}
+
+h1
+{
+	font-size:xx-large;
+}
+
+p
+{
+	font-size:large;
+}
+
+.error
+{
+	margin: 0.75em;
+	background-color: #f8d7da;
+	color: #842029;
+	border: solid 1px #f5c2c7;
+}
+
+input {
+	margin: 0.3rem;
+	padding: 0.2rem;
+	line-height: 1.5rem;
+	font-size: 110%;
+}
+h1, h2 {
+	text-align: left;
+}
+
+main {
+	max-width: 600px;
+	padding: 0 1.5em 1.5em 1.5em;
+}
+
+dt
+{
+	font-weight: bold;
+	margin: 0.5em 0 0 0;
+}
+
+dd
+{
+	margin: 0;
+}
+
+button, input[type=submit]
+{
+	padding: 0.5rem;
+	margin: 0.75rem;
+}
+
+@media(prefers-color-scheme:dark)
+{
+	body
+	{
+		background-color:#161616;
+		color:#eee;
+	}
+
+	.error {
+		color: #f8d7da;
+		background-color: #842029;
+	}
+
+}
+
+@media(min-width: 768px)
+{
+	body
+	{
+		margin-top: 3em;
+	}
+
+	main
+	{
+		margin-left: auto;
+		margin-right: auto;
+		border: solid 1px #1e1e1e;
+		background-color: #efefef;
+	}
+
+	@media(prefers-color-scheme:dark)
+	{
+		body
+		{
+			background-color: #4e4e4e;
+		}
+
+		main
+		{
+			color: #fff;
+			background-color: #1f1f1f;
+		}
+	}
+}
--- a/mod_http_oauth2/mod_http_oauth2.lua	Mon Mar 06 09:40:17 2023 +0000
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Mon Mar 06 09:46:58 2023 +0000
@@ -9,10 +9,54 @@
 local uuid = require "util.uuid";
 local encodings = require "util.encodings";
 local base64 = encodings.base64;
+local random = require "util.random";
 local schema = require "util.jsonschema";
 local jwt = require"util.jwt";
 local it = require "util.iterators";
 local array = require "util.array";
+local st = require "util.stanza";
+
+local function read_file(base_path, fn, required)
+	local f, err = io.open(base_path .. "/" .. fn);
+	if not f then
+		module:log(required and "error" or "debug", "Unable to load template file: %s", err);
+		if required then
+			return error("Failed to load templates");
+		end
+		return nil;
+	end
+	local data = assert(f:read("*a"));
+	assert(f:close());
+	return data;
+end
+
+local template_path = module:get_option_path("oauth2_template_path", "html");
+local templates = {
+	login = read_file(template_path, "login.html", true);
+	consent = read_file(template_path, "consent.html", true);
+	error = read_file(template_path, "error.html", true);
+	css = read_file(template_path, "style.css");
+	js = read_file(template_path, "script.js");
+};
+
+local site_name = module:get_option_string("site_name", module.host);
+
+local _render_html = require"util.interpolation".new("%b{}", st.xml_escape);
+local function render_page(template, data, sensitive)
+	data = data or {};
+	data.site_name = site_name;
+	local resp = {
+		code = 200;
+		headers = {
+			["Content-Type"] = "text/html; charset=utf-8";
+			["Content-Security-Policy"] = "default-src 'self'";
+			["X-Frame-Options"] = "DENY";
+			["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private";
+		};
+		body = _render_html(template, data);
+	};
+	return resp;
+end
 
 local tokens = module:depends("tokenauth");
 
@@ -119,17 +163,7 @@
 	return json.encode(new_access_token(granted_jid, granted_scopes, nil));
 end
 
--- TODO response_type_handlers have some common boilerplate code, refactor?
-
-function response_type_handlers.code(params, granted_jid)
-	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
-
-	local ok, client = jwt_verify(params.client_id);
-
-	if not ok then
-		return oauth_error("invalid_client", "incorrect credentials");
-	end
-
+function response_type_handlers.code(client, params, granted_jid)
 	local request_username, request_host = jid.split(granted_jid);
 	local granted_scopes = filter_scopes(request_username, request_host, params.scope);
 
@@ -178,15 +212,7 @@
 end
 
 -- Implicit flow
-function response_type_handlers.token(params, granted_jid)
-	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
-
-	local client = jwt_verify(params.client_id);
-
-	if not client then
-		return oauth_error("invalid_client", "incorrect credentials");
-	end
-
+function response_type_handlers.token(client, params, granted_jid)
 	local request_username, request_host = jid.split(granted_jid);
 	local granted_scopes = filter_scopes(request_username, request_host, params.scope);
 	local token_info = new_access_token(granted_jid, granted_scopes, nil);
@@ -238,6 +264,60 @@
 	return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil));
 end
 
+-- Used to issue/verify short-lived tokens for the authorization process below
+local new_user_token, verify_user_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 });
+
+-- From the given request, figure out if the user is authenticated and has granted consent yet
+-- As this requires multiple steps (seek credentials, seek consent), we have a lot of state to
+-- carry around across requests. We also need to protect against CSRF and session mix-up attacks
+-- (e.g. the user may have multiple concurrent flows in progress, session cookies aren't unique
+--  to one of them).
+-- Our strategy here is to preserve the original query string (containing the authz request), and
+-- encode the rest of the flow in form POSTs.
+local function get_auth_state(request)
+	local form = request.method == "POST"
+	         and request.body
+	         and #request.body > 0
+	         and request.headers.content_type == "application/x-www-form-urlencoded"
+	         and http.formdecode(request.body);
+
+	if not form then return {}; end
+
+	if not form.user_token then
+		-- First step: login
+		local username = encodings.stringprep.nodeprep(form.username);
+		local password = encodings.stringprep.saslprep(form.password);
+		if not (username and password) or not usermanager.test_password(username, module.host, password) then
+			return {
+				error = "Invalid username/password";
+			};
+		end
+		return {
+			user = {
+				username = username;
+				host = module.host;
+				token = new_user_token({ username = username, host = module.host });
+			};
+		};
+	elseif form.user_token and form.consent then
+		-- Second step: consent
+		local ok, user = verify_user_token(form.user_token);
+		if not ok then
+			return {
+				error = user == "token-expired" and "Session expired - try again" or nil;
+			};
+		end
+
+		user.token = form.user_token;
+		return {
+			user = user;
+			consent = form.consent == "granted";
+		};
+	end
+
+	return {};
+end
+
 local function check_credentials(request, allow_token)
 	local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$");
 
@@ -290,6 +370,32 @@
 	check_credentials = function () return false end
 end
 
+-- OAuth errors should be returned to the client if possible, i.e. by
+-- appending the error information to the redirect_uri and sending the
+-- redirect to the user-agent. In some cases we can't do this, e.g. if
+-- the redirect_uri is missing or invalid. In those cases, we render an
+-- error directly to the user-agent.
+local function error_response(request, err)
+	local q = request.url.query and http.formdecode(request.url.query);
+	local redirect_uri = q and q.redirect_uri;
+	if not redirect_uri or not redirect_uri:match("^https://") then
+		module:log("warn", "Missing or invalid redirect_uri <%s>, rendering error to user-agent", redirect_uri or "");
+		return render_page(templates.error, { error = err });
+	end
+	local redirect_query = url.parse(redirect_uri);
+	local sep = redirect_query and "&" or "?";
+	redirect_uri = redirect_uri
+		.. sep .. http.formencode(err.extra.oauth2_response)
+		.. "&" .. http.formencode({ state = q.state, iss = get_issuer() });
+	module:log("warn", "Sending error response to client via redirect to %s", redirect_uri);
+	return {
+		status_code = 302;
+		headers = {
+			location = redirect_uri;
+		};
+	};
+end
+
 local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password"})
 for handler_type in pairs(grant_type_handlers) do
 	if not allowed_grant_type_handlers:contains(handler_type) then
@@ -309,42 +415,53 @@
 	event.response.headers.content_type = "application/json";
 	local params = http.formdecode(event.request.body);
 	if not params then
-		return oauth_error("invalid_request");
+		return error_response(event.request, oauth_error("invalid_request"));
 	end
 	local grant_type = params.grant_type
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return oauth_error("unsupported_grant_type");
+		return error_response(event.request, oauth_error("unsupported_grant_type"));
 	end
 	return grant_handler(params);
 end
 
 local function handle_authorization_request(event)
-	local request, response = event.request, event.response;
-	if not request.headers.authorization then
-		response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
-		return 401;
-	end
-	local user = check_credentials(request);
-	if not user then
-		return 401;
-	end
-	-- TODO ask user for consent here
+	local request = event.request;
+
 	if not request.url.query then
-		response.headers.content_type = "application/json";
-		return oauth_error("invalid_request");
+		return error_response(request, oauth_error("invalid_request"));
 	end
 	local params = http.formdecode(request.url.query);
 	if not params then
-		return oauth_error("invalid_request");
+		return error_response(request, oauth_error("invalid_request"));
+	end
+
+	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
+
+	local ok, client = jwt_verify(params.client_id);
+
+	if not ok then
+		return oauth_error("invalid_client", "incorrect credentials");
 	end
+
+	local auth_state = get_auth_state(request);
+	if not auth_state.user then
+		-- Render login page
+		return render_page(templates.login, { state = auth_state, client = client });
+	elseif auth_state.consent == nil then
+		-- Render consent page
+		return render_page(templates.consent, { state = auth_state, client = client }, true);
+	elseif not auth_state.consent then
+		-- Notify client of rejection
+		return error_response(request, oauth_error("access_denied"));
+	end
+
 	local response_type = params.response_type;
 	local response_handler = response_type_handlers[response_type];
 	if not response_handler then
-		response.headers.content_type = "application/json";
-		return oauth_error("unsupported_response_type");
+		return error_response(request, oauth_error("unsupported_response_type"));
 	end
-	return response_handler(params, jid.join(user, module.host));
+	return response_handler(client, params, jid.join(auth_state.user.username, module.host));
 end
 
 local function handle_revocation_request(event)
@@ -452,8 +569,23 @@
 	route = {
 		["POST /token"] = handle_token_grant;
 		["GET /authorize"] = handle_authorization_request;
+		["POST /authorize"] = handle_authorization_request;
 		["POST /revoke"] = handle_revocation_request;
 		["POST /register"] = handle_register_request;
+
+		-- Optional static content for templates
+		["GET /style.css"] = templates.css and {
+			headers = {
+				["Content-Type"] = "text/css";
+			};
+			body = _render_html(templates.css, module:get_option("oauth2_template_style"));
+		} or nil;
+		["GET /script.js"] = templates.js and {
+			headers = {
+				["Content-Type"] = "text/javascript";
+			};
+			body = templates.js;
+		} or nil;
 	};
 });