Software /
code /
prosody-modules
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 | 5207:c72e3b0914e8 |
children | 5209: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; }; });