Software /
code /
prosody-modules
Changeset
5673:0eb2d5ea2428
merge
author | Stephen Paul Weber <singpolyma@singpolyma.net> |
---|---|
date | Sat, 06 May 2023 19:40:23 -0500 |
parents | 5672:2c69577b28c2 (current diff) 5422:72f23107beb4 (diff) |
children | 5674:b40750891bee |
files | |
diffstat | 61 files changed, 3144 insertions(+), 315 deletions(-) [+] |
line wrap: on
line diff
--- a/.luacheckrc Wed Feb 22 22:47:45 2023 -0500 +++ b/.luacheckrc Sat May 06 19:40:23 2023 -0500 @@ -59,6 +59,7 @@ "module.may", "module.measure", "module.metric", + "module.once", "module.open_store", "module.provides", "module.remove_item",
--- a/mod_adhoc_oauth2_client/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_adhoc_oauth2_client/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -2,21 +2,20 @@ labels: - Stage-Alpha summary: 'Create OAuth2 clients via ad-hoc command' +rockspec: + dependencies: + - mod_http_oauth2 ... Introduction ============ -Allows creating OAuth2 clients for use with [mod_http_oauth2]. Otherwise -a work-in-progress intended for developers only! - -Configuration -============= - -**TODO** +[Ad-Hoc command][XEP-0050] interface to +[dynamic OAuth2 registration](https://oauth.net/2/dynamic-client-registration/) +provided by [mod_http_oauth2]. Compatibility ============= -Probably Prosody trunk. +Same as [mod_http_oauth2]
--- a/mod_adhoc_oauth2_client/mod_adhoc_oauth2_client.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_adhoc_oauth2_client/mod_adhoc_oauth2_client.lua Sat May 06 19:40:23 2023 -0500 @@ -1,22 +1,20 @@ local adhoc = require "util.adhoc"; local dataforms = require "util.dataforms"; -local errors = require "util.error"; -local hashes = require "util.hashes"; -local id = require "util.id"; -local jid = require "util.jid"; -local base64 = require"util.encodings".base64; -local clients = module:open_store("oauth2_clients", "map"); - -local iteration_count = module:get_option_number("oauth2_client_iteration_count", 10000); -local pepper = module:get_option_string("oauth2_client_pepper", ""); +local mod_http_oauth2 = module:depends"http_oauth2"; local new_client = dataforms.new({ title = "Create OAuth2 client"; - {var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#create"}; - {name = "name"; type = "text-single"; label = "Client name"; required = true}; - {name = "description"; type = "text-multi"; label = "Description"}; - {name = "info_url"; type = "text-single"; label = "Informative URL"; desc = "Link to information about your client"; datatype = "xs:anyURI"}; + { var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#create" }; + { name = "client_name"; type = "text-single"; label = "Client name"; required = true }; + { + name = "client_uri"; + type = "text-single"; + label = "Informative URL"; + desc = "Link to information about your client. MUST be https URI."; + datatype = "xs:anyURI"; + required = true; + }; { name = "redirect_uri"; type = "text-single"; @@ -30,9 +28,9 @@ local client_created = dataforms.new({ title = "New OAuth2 client created"; instructions = "Save these details, they will not be shown again"; - {var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#created"}; - {name = "client_id"; type = "text-single"; label = "Client ID"}; - {name = "client_secret"; type = "text-single"; label = "Client secret"}; + { var = "FORM_TYPE"; type = "hidden"; value = "urn:uuid:ff0d55ed-2187-4ee0-820a-ab633a911c14#created" }; + { name = "client_id"; type = "text-single"; label = "Client ID" }; + { name = "client_secret"; type = "text-single"; label = "Client secret" }; }) local function create_client(client, formerr, data) @@ -41,23 +39,15 @@ for field, err in pairs(formerr) do table.insert(errmsg, field .. ": " .. err); end return {status = "error"; error = {message = table.concat(errmsg, "\n")}}; end - - local creator = jid.split(data.from); - local client_uid = id.short(); - local client_id = jid.join(creator, module.host, client_uid); - local client_secret = id.long(); - local salt = id.medium(); - local i = iteration_count; + client.redirect_uris = { client.redirect_uri }; + client.redirect_uri = nil; - client.secret_hash = base64.encode(hashes.pbkdf2_hmac_sha256(client_secret, salt .. pepper, i)); - client.iteration_count = i; - client.salt = salt; + local client_metadata, err = mod_http_oauth2.create_client(client); + if err then return { status = "error"; error = err }; end - local ok, err = errors.coerce(clients:set(creator, client_uid, client)); - module:log("info", "OAuth2 client %q created by %s", client_id, data.from); - if not ok then return {status = "canceled"; error = {message = err}}; end + module:log("info", "OAuth2 client %q %q created by %s", client.name, client.info_uri, data.from); - return {status = "completed"; result = {layout = client_created; values = {client_id = client_id; client_secret = client_secret}}}; + return { status = "completed"; result = { layout = client_created; values = client_metadata } }; end local handler = adhoc.new_simple_form(new_client, create_client);
--- a/mod_audit/README.md Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_audit/README.md Sat May 06 19:40:23 2023 -0500 @@ -25,3 +25,27 @@ allowed to store the data for the amount of time these modules will store it. Note that it is currently not possible to store different event types with different expiration times. + +## Viewing the log + +You can view the log using prosodyctl. This works even when Prosody is not +running. + +For example, to view the full audit log for example.com: + +```shell +prosodyctl mod_audit example.com +``` + +To view only host-wide events (those not attached to a specific user account), +use the `--global` option (or use `--no-global` to hide such events): + +```shell +prosodyctl mod_audit --global example.com +``` + +To narrow results to a specific user, specify their JID: + +```shell +prosodyctl mod_audit user@example.com +```
--- a/mod_audit/mod_audit.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_audit/mod_audit.lua Sat May 06 19:40:23 2023 -0500 @@ -1,14 +1,34 @@ module:set_global(); -local audit_log_limit = module:get_option_number("audit_log_limit", 10000); -local cleanup_after = module:get_option_string("audit_log_expires_after", "2w"); - local time_now = os.time; +local parse_duration = require "util.human.io".parse_duration; +local ip = require "util.ip"; local st = require "util.stanza"; local moduleapi = require "core.moduleapi"; local host_wide_user = "@"; +local cleanup_after = module:get_option_string("audit_log_expires_after", "28d"); +if cleanup_after == "never" then + cleanup_after = nil; +else + cleanup_after = parse_duration(cleanup_after); +end + +local attach_ips = module:get_option_boolean("audit_log_ips", true); +local attach_ipv4_prefix = module:get_option_number("audit_log_ipv4_prefix", nil); +local attach_ipv6_prefix = module:get_option_number("audit_log_ipv6_prefix", nil); + +local have_geoip, geoip = pcall(require, "geoip.country"); +local attach_location = have_geoip and module:get_option_boolean("audit_log_location", true); + +local geoip4_country, geoip6_country; +if have_geoip and attach_location then + geoip4_country = geoip.open(module:get_option_string("geoip_ipv4_country", "/usr/share/GeoIP/GeoIP.dat")); + geoip6_country = geoip.open(module:get_option_string("geoip_ipv6_country", "/usr/share/GeoIP/GeoIPv6.dat")); +end + + local stores = {}; local function get_store(self, host) @@ -23,6 +43,34 @@ setmetatable(stores, { __index = get_store }); +local function prune_audit_log(host) + local before = os.time() - cleanup_after; + module:context(host):log("debug", "Pruning audit log for entries older than %s", os.date("%Y-%m-%d %R:%S", before)); + local ok, err = stores[host]:delete(nil, { ["end"] = before }); + if not ok then + module:context(host):log("error", "Unable to prune audit log: %s", err); + return; + end + local sum = tonumber(ok); + if sum then + module:context(host):log("debug", "Pruned %d expired audit log entries", sum); + return sum > 0; + end + module:context(host):log("debug", "Pruned expired audit log entries"); + return true; +end + +local function get_ip_network(ip_addr) + local _ip = ip.new_ip(ip_addr); + local proto = _ip.proto; + local network; + if proto == "IPv4" and attach_ipv4_prefix then + network = ip.truncate(_ip, attach_ipv4_prefix).normal.."/"..attach_ipv4_prefix; + elseif proto == "IPv6" and attach_ipv6_prefix then + network = ip.truncate(_ip, attach_ipv6_prefix).normal.."/"..attach_ipv6_prefix; + end + return network; +end local function session_extra(session) local attr = { @@ -35,8 +83,22 @@ attr.type = session.type; end local stanza = st.stanza("session", attr); - if session.ip then - stanza:text_tag("remote-ip", session.ip); + if attach_ips and session.ip then + local remote_ip, network = session.ip; + if attach_ipv4_prefix or attach_ipv6_prefix then + network = get_ip_network(remote_ip); + end + stanza:text_tag("remote-ip", network or remote_ip); + end + if attach_location and session.ip then + local remote_ip = ip.new(session.ip); + local geoip_country = ip.proto == "IPv6" and geoip6_country or geoip4_country; + stanza:tag("location", { + country = geoip_country:query_by_addr(remote_ip.normal); + }):up(); + end + if session.client_id then + stanza:text_tag("client", session.client_id); end return stanza end @@ -55,7 +117,7 @@ attr.user = user_key; end local stanza = st.stanza("audit-event", attr); - if extra ~= nil then + if extra then if extra.session then local child = session_extra(extra.session); if child then @@ -63,7 +125,7 @@ end end if extra.custom then - for _, child in extra.custom do + for _, child in ipairs(extra.custom) do if not st.is_stanza(child) then error("all extra.custom items must be stanzas") end @@ -72,15 +134,155 @@ end end - local id, err = stores[host]:append(nil, nil, stanza, time_now(), user_key); - if err then - module:log("error", "failed to persist audit event: %s", err); - return + local store = stores[host]; + local id, err = store:append(nil, nil, stanza, extra and extra.timestamp or time_now(), user_key); + if not id then + if err == "quota-limit" then + local limit = store.caps and store.caps.quota or 1000; + local truncate_to = math.floor(limit * 0.99); + if type(cleanup_after) == "number" then + module:log("debug", "Audit log has reached quota - forcing prune"); + if prune_audit_log(host) then + -- Retry append + id, err = store:append(nil, nil, stanza, extra and extra.timestamp or time_now(), user_key); + end + end + if not id and (store.caps and store.caps.truncate) then + module:log("debug", "Audit log has reached quota - truncating"); + local truncated = store:delete(nil, { + truncate = truncate_to; + }); + if truncated then + -- Retry append + id, err = store:append(nil, nil, stanza, extra and extra.timestamp or time_now(), user_key); + end + end + end + if not id then + module:log("error", "Failed to persist audit event: %s", err); + return; + end else - module:log("debug", "persisted audit event %s as %s", stanza:top_tag(), id); + module:log("debug", "Persisted audit event %s as %s", stanza:top_tag(), id); end end function moduleapi.audit(module, user, event_type, extra) audit(module.host, user, "mod_" .. module:get_name(), event_type, extra); end + +function module.command(arg_) + local jid = require "util.jid"; + local arg = require "util.argparse".parse(arg_, { + value_params = { "limit" }; + }); + + for k, v in pairs(arg) do print("U", k, v) end + local query_user, host = jid.prepped_split(arg[1]); + + if arg.prune then + local sm = require "core.storagemanager"; + if host then + sm.initialize_host(host); + prune_audit_log(host); + else + for _host in pairs(prosody.hosts) do + sm.initialize_host(_host); + prune_audit_log(_host); + end + end + return; + end + + if not host then + print("EE: Please supply the host for which you want to show events"); + return 1; + elseif not prosody.hosts[host] then + print("EE: Unknown host: "..host); + return 1; + end + + require "core.storagemanager".initialize_host(host); + local store = stores[host]; + local c = 0; + + if arg.global then + if query_user then + print("WW: Specifying a user account is incompatible with --global. Showing only global events."); + end + query_user = "@"; + end + + local results, err = store:find(nil, { + with = query_user; + limit = arg.limit and tonumber(arg.limit) or nil; + reverse = true; + }) + if not results then + print("EE: Failed to query audit log: "..tostring(err)); + return 1; + end + + local colspec = { + { title = "Date", key = "when", width = 19, mapper = function (when) return os.date("%Y-%m-%d %R:%S", when); end }; + { title = "Source", key = "source", width = "2p" }; + { title = "Event", key = "event_type", width = "2p" }; + }; + + if arg.show_user ~= false and (not arg.global and not query_user) or arg.show_user then + table.insert(colspec, { + title = "User", key = "username", width = "2p", + mapper = function (user) + if user == "@" then return ""; end + if user:sub(-#host-1, -1) == ("@"..host) then + return (user:gsub("@.+$", "")); + end + end; + }); + end + if arg.show_ip ~= false and (not arg.global and attach_ips) or arg.show_ip then + table.insert(colspec, { + title = "IP", key = "ip", width = "2p"; + }); + end + if arg.show_location ~= false and (not arg.global and attach_location) or arg.show_location then + table.insert(colspec, { + title = "Location", key = "country", width = 2; + }); + end + + if arg.show_note then + table.insert(colspec, { + title = "Note", key = "note", width = "2p"; + }); + end + + local row, width = require "util.human.io".table(colspec); + + print(string.rep("-", width)); + print(row()); + print(string.rep("-", width)); + for _, entry, when, user in results do + if arg.global ~= false or user ~= "@" then + c = c + 1; + print(row({ + when = when; + source = entry.attr.source; + event_type = entry.attr.type:gsub("%-", " "); + username = user; + ip = entry:get_child_text("remote-ip"); + location = entry:find("location@country"); + note = entry:get_child_text("note"); + })); + end + end + print(string.rep("-", width)); + print(("%d records displayed"):format(c)); +end + +function module.add_host(host_module) + host_module:depends("cron"); + host_module:daily("Prune audit logs", function () + prune_audit_log(host_module.host); + end); +end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_audit_status/README.md Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,29 @@ +--- +summary: Log server status changes to audit log +rockspec: {} +... + +This module records server status (start, stop, crash) to the audit log +maintained by [mod_audit]. + +## Configuration + +There is a single option, `audit_status_heartbeat_interval` which specifies +the interval at which the "server is running" heartbeat should be updated (it +is stored in Prosody's configured storage backend). + +To detect crashes, Prosody periodically updates this value at the specified +interval. A low value will update more frequently, which causes additional I/O +for Prosody. A high value will give less accurate timestamps for "server +crashed" events in the audit log. + +The default value is 60 (seconds). + +```lua +audit_status_heartbeat_interval = 60 +``` + +## Compatibility + +This module requires Prosody trunk (as of April 2023). It is not compatible +with 0.12.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_audit_status/mod_audit_status.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,35 @@ +module:depends("audit"); + +local st = require "util.stanza"; + +-- Suppress warnings about module:audit() +-- luacheck: ignore 143/module + +local heartbeat_interval = module:get_option_number("audit_status_heartbeat_interval", 60); + +local store = module:open_store(nil, "keyval+"); + +module:hook_global("server-started", function () + local recorded_status = store:get(); + if recorded_status and recorded_status.status == "started" then + module:audit(nil, "server-crashed", { timestamp = recorded_status.heartbeat }); + end + module:audit(nil, "server-started"); + store:set_key(nil, "status", "started"); +end); + +module:hook_global("server-stopped", function () + module:audit(nil, "server-stopped", { + custom = { + prosody.shutdown_reason and st.stanza("note"):text(prosody.shutdown_reason); + }; + }); + store:set_key(nil, "status", "stopped"); +end); + +if heartbeat_interval then + module:add_timer(0, function () + store:set_key(nil, "heartbeat", os.time()); + return heartbeat_interval; + end); +end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_auth_oauth_external/README.md Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,95 @@ +--- +summary: Authenticate against an external OAuth 2 IdP +labels: +- Stage-Alpha +--- + +This module provides external authentication via an external [AOuth +2](https://datatracker.ietf.org/doc/html/rfc7628) authorization server +and supports the [SASL OAUTHBEARER authentication][rfc7628] +mechanism as well as PLAIN for legacy clients (this is all of them). + +# How it works + +Clients retrieve tokens somehow, then show them to Prosody, which asks +the Authorization server to validate them, returning info about the user +back to Prosody. + +Alternatively for legacy clients, Prosody receives the users username +and password and retrieves a token itself, then proceeds as above. + +# Configuration + +## Example + +```lua +-- authentication = "oauth_external" + +oauth_external_discovery_url = "https//auth.example.com/auth/realms/TheRealm/.well-known/openid-configuration" +oauth_external_token_endpoint = "https//auth.example.com/auth/realms/TheRealm/protocol/openid-connect/token" +oauth_external_validation_endpoint = "https//auth.example.com/auth/realms/TheRealm/protocol/openid-connect/userinfo" +oauth_external_username_field = "xmpp_username" +``` + + +## Common + +`oauth_external_issuer` +: Optional URL string representing the Authorization server identity. + +`oauth_external_discovery_url` +: Optional URL string pointing to [OAuth 2.0 Authorization Server + Metadata](https://oauth.net/2/authorization-server-metadata/). Lets + clients discover where they should retrieve access tokens from if + they don't have one yet. Default based on `oauth_external_issuer` is + set, otherwise empty. + +`oauth_external_validation_endpoint` +: URL string. The token validation endpoint, should validate the token + and return a JSON structure containing the username of the user + logging in the field specified by `oauth_external_username_field`. + Commonly the [OpenID `UserInfo` + endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) + +`oauth_external_username_field` +: String. Default is `"preferred_username"`. Field in the JSON + structure returned by the validation endpoint that contains the XMPP + localpart. + +## For SASL PLAIN + +`oauth_external_resource_owner_password` +: Boolean. Defaults to `true`. Whether to allow the *insecure* + [resource owner password + grant](https://oauth.net/2/grant-types/password/) and SASL PLAIN. + +`oauth_external_token_endpoint` +: URL string. OAuth 2 [Token + Endpoint](https://www.rfc-editor.org/rfc/rfc6749#section-3.2) used + to retrieve token in order to then retrieve the username. + +`oauth_external_client_id` +: String. Client ID used to identify Prosody during the resource owner + password grant. + +# Compatibility + +## Prosody + + Version Status + --------- --------------- + trunk works + 0.12.x does not work + 0.11.x does not work + +## Identity Provider + +Tested with + +- [KeyCloak](https://www.keycloak.org/) + +# Future work + +- Automatically discover endpoints from Discovery URL +- Configurable input username mapping (e.g. user → user@host). +- [SCRAM over HTTP?!][rfc7804]
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_auth_oauth_external/mod_auth_oauth_external.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,98 @@ +local http = require "net.http"; +local async = require "util.async"; +local json = require "util.json"; +local sasl = require "util.sasl"; + +local issuer_identity = module:get_option_string("oauth_external_issuer"); +local oidc_discovery_url = module:get_option_string("oauth_external_discovery_url", + issuer_identity and issuer_identity .. "/.well-known/oauth-authorization-server" or nil); +local validation_endpoint = module:get_option_string("oauth_external_validation_endpoint"); +local token_endpoint = module:get_option_string("oauth_external_token_endpoint"); + +local username_field = module:get_option_string("oauth_external_username_field", "preferred_username"); +local allow_plain = module:get_option_boolean("oauth_external_resource_owner_password", true); + +-- XXX Hold up, does whatever done here even need any of these things? Are we +-- the OAuth client? Is the XMPP client the OAuth client? What are we??? +local client_id = module:get_option_string("oauth_external_client_id"); +-- TODO -- local client_secret = module:get_option_string("oauth_external_client_secret"); + +--[[ More or less required endpoints +digraph "oauth endpoints" { +issuer -> discovery -> { registration validation } +registration -> { client_id client_secret } +{ client_id client_secret validation } -> required +} +--]] + +local host = module.host; +local provider = {}; + +function provider.get_sasl_handler() + local profile = {}; + profile.http_client = http.default; -- TODO configurable + local extra = { oidc_discovery_url = oidc_discovery_url }; + if token_endpoint and allow_plain then + local map_username = function (username, _realm) return username; end; --jid.join; -- TODO configurable + function profile:plain_test(username, password, realm) + local tok, err = async.wait_for(self.profile.http_client:request(token_endpoint, { + headers = { ["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"; ["Accept"] = "application/json" }; + body = http.formencode({ + grant_type = "password"; + client_id = client_id; + username = map_username(username, realm); + password = password; + scope = "openid"; + }); + })) + if err or not (tok.code >= 200 and tok.code < 300) then + return false, nil; + end + local token_resp = json.decode(tok.body); + if not token_resp or string.lower(token_resp.token_type or "") ~= "bearer" then + return false, nil; + end + local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, + { headers = { ["Authorization"] = "Bearer " .. token_resp.access_token; ["Accept"] = "application/json" } })); + if err then + return false, nil; + end + if not (ret.code >= 200 and ret.code < 300) then + return false, nil; + end + local response = json.decode(ret.body); + if type(response) ~= "table" or (response[username_field]) ~= username then + return false, nil, nil; + end + if response.jid then + self.username, self.realm, self.resource = jid.prepped_split(response.jid, true); + end + self.role = response.role; + self.token_info = response; + return true, true; + end + end + function profile:oauthbearer(token) + if token == "" then + return false, nil, extra; + end + + local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, + { headers = { ["Authorization"] = "Bearer " .. token; ["Accept"] = "application/json" } })); + if err then + return false, nil, extra; + end + local response = ret and json.decode(ret.body); + if not (ret.code >= 200 and ret.code < 300) then + return false, nil, response or extra; + end + if type(response) ~= "table" or type(response[username_field]) ~= "string" then + return false, nil, nil; + end + + return response[username_field], true, response; + end + return sasl.new(host, profile); +end + +module:provides("auth", provider);
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_authz_delegate/README.md Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,24 @@ +--- +summary: Authorization delegation +rockspec: {} +... + +This module allows delegating authorization questions (role assignment and +role policies) to another host within prosody. + +The primary use of this is for a group of virtual hosts to use a common +authorization database, for example to allow a MUC component to grant +administrative access to an admin on a corresponding user virtual host. + +## Configuration + +The following example will make all role assignments for local and remote JIDs +from domain.example effective on groups.domain.example: + +``` +VirtualHost "domain.example" + +Component "groups.domain.example" "muc" + authorization = "delegate" + authz_delegate_to = "domain.example" +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_authz_delegate/mod_authz_delegate.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,96 @@ +local target_host = assert(module:get_option("authz_delegate_to")); +local this_host = module:get_host(); + +local array = require"util.array"; +local jid_split = import("prosody.util.jid", "split"); + +local hosts = prosody.hosts; + +function get_jids_with_role(role) --luacheck: ignore 212/role + return nil +end + +function get_user_role(user) + -- this is called where the JID belongs to the host this module is loaded on + -- that means we have to delegate that to get_jid_role with an appropriately composed JID + return hosts[target_host].authz.get_jid_role(user .. "@" .. this_host) +end + +function set_user_role(user, role_name) --luacheck: ignore 212/user 212/role_name + -- no roles for entities on this host. + return false, "cannot set user role on delegation target" +end + +function get_user_secondary_roles(user) --luacheck: ignore 212/user + -- no roles for entities on this host. + return {} +end + +function add_user_secondary_role(user, role_name) --luacheck: ignore 212/user 212/role_name + -- no roles for entities on this host. + return nil, "cannot set user role on delegation target" +end + +function remove_user_secondary_role(user, role_name) --luacheck: ignore 212/user 212/role_name + -- no roles for entities on this host. + return nil, "cannot set user role on delegation target" +end + +function user_can_assume_role(user, role_name) --luacheck: ignore 212/user 212/role_name + -- no roles for entities on this host. + return false +end + +function get_jid_role(jid) + local user, host = jid_split(jid); + if host == target_host then + return hosts[target_host].authz.get_user_role(user); + end + return hosts[target_host].authz.get_jid_role(jid); +end + +function set_jid_role(jid) --luacheck: ignore 212/jid + -- TODO: figure out if there are actually legitimate uses for this... + return nil, "cannot set jid role on delegation target" +end + +local default_permission_queue = array{}; + +function add_default_permission(role_name, action, policy) + -- NOTE: we always record default permissions, because the delegated-to + -- host may be re-activated. + default_permission_queue:push({ + role_name = role_name, + action = action, + policy = policy, + }); + local target_host_object = hosts[target_host]; + local authz = target_host_object and target_host_object.authz; + if not authz then + module:log("debug", "queueing add_default_permission call for later, %s is not active yet", target_host); + return; + end + return authz.add_default_permission(role_name, action, policy) +end + +function get_role_by_name(role_name) + return hosts[target_host].authz.get_role_by_name(role_name) +end + +function get_all_roles() + return hosts[target_host].authz.get_all_roles() +end + +module:hook_global("host-activated", function(host) + if host == target_host then + local authz = hosts[target_host].authz; + module:log("debug", "replaying %d queued permission changes", #default_permission_queue); + assert(authz); + -- replay default permission changes, if any + for i, item in ipairs(default_permission_queue) do + authz.add_default_permission(item.role_name, item.action, item.policy); + end + -- NOTE: we do not clear that array here -- in case the target_host is + -- re-activated + end +end, -10000)
--- a/mod_block_registrations/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_block_registrations/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -19,11 +19,11 @@ You can then set some options to configure your desired policy: - Option Default Description - -------------------------------- --------------- ------------------------------------------------------------------------------------------------------------------------------------------------- - block\_registrations\_users `{ "admin" }` A list of reserved usernames - block\_registrations\_matching `{ }` A list of [Lua patterns](http://www.lua.org/manual/5.1/manual.html#5.4.1) matching reserved usernames (slower than block\_registrations\_users) - block\_registrations\_require `nil` A pattern that registered user accounts MUST match to be allowed + Option Default Description + ------------------------------ ------------------- ----------------------------------------------------------------------------------------------------------------------------------------------- + block_registrations_users *See source code* A list of reserved usernames + block_registrations_matching `{ }` A list of [Lua patterns](http://www.lua.org/manual/5.1/manual.html#5.4.1) matching reserved usernames (slower than block_registrations_users) + block_registrations_require `nil` A pattern that registered user accounts MUST match to be allowed Some examples: @@ -36,9 +36,7 @@ Compatibility ============= - ----- ------------- - 0.9 Works - 0.8 Should work - ----- ------------- - - + ------ ------- + 0.12 Works + 0.11 Work + ------ -------
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_client_management/README.md Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,146 @@ +--- +labels: +- Stage-Beta +summary: "Manage clients with access to your account" +rockspec: + dependencies: + - mod_sasl2_fast +--- + +This module allows a user to identify what currently has access to their +account. + +This module depends on [mod_sasl2_fast] and mod_tokenauth (bundled with +Prosody). Both will be automatically loaded if this module is loaded. + +## Configuration + +| Name | Description | Default | +|---------------------------|--------------------------------------------------------|-----------------| +| enforce_client_ids | Only allow SASL2-compatible clients | `false` | + +When `enforce_client_ids` is not enabled, the client listing may be less accurate due to legacy clients, +which can only be tracked by their resource, which is public information, not necessarily unique to a +client instance, and is also exposed to other XMPP entities the user communicates with. + +When `enforce_client_ids` is enabled, clients that don't support SASL2 and provide a client id will be +denied access. + +## Shell usage + +You can use this module via the Prosody shell. For example, to list a user's +clients: + +```shell +prosodyctl shell user clients user@example.com +``` + +## Compatibility + +Requires Prosody trunk (as of 2023-03-29). Not compatible with Prosody 0.12 +and earlier. + +## Developers + +### Protocol + +#### Listing clients + +To list clients that have access to the user's account, send the following +stanza: + +```xml +<iq id="p" type="get"> + <list xmlns="xmpp:prosody.im/protocol/manage-clients"/> +</iq> +``` + +The server will respond with a list of clients: + +```xml +<iq id="p" to="mattj-gajim@auth2.superxmpp.com/gajim.UYJKBHKT" type="result" xmlns="jabber:client"> + <clients xmlns="xmpp:prosody.im/protocol/manage-clients"> + <client connected="true" id="client/zeiP41HLglIu" type="session"> + <first-seen>2023-04-06T14:26:08Z</first-seen> + <last-seen>2023-04-06T14:37:25Z</last-seen> + <auth> + <password/> + </auth> + <user-agent> + <software>Gajim</software> + <uri>https://gajim.org/</uri> + <device>Juliet's laptop</device> + </user-agent> + </client> + <client connected="false" id="grant/HjEEr45_LQr" type="access"> + <first-seen>2023-03-27T15:16:09Z</first-seen> + <last-seen>2023-03-27T15:37:24Z</last-seen> + <user-agent> + <software>REST client</software> + </user-agent> + </client> + </clients> +</iq> +``` + +On the `<client/>` tag most things are self-explanatory. The following attributes +are defined: + +- 'connected': a boolean that reflects whether this client has an active session +on the server (i.e. this includes connected and "hibernating" sessions). +- 'id': an opaque reference for the client, which can be used to revoke access. +- 'type': either `"session"` if this client is known to have an active or inactive + client session on the server, or "access" if no session has been established (e.g. + it may have been granted access to the account, but only used non-XMPP APIs or + never logged in). + +The `<first-seen/>` and `<last-seen/>` elements contain timestamps that reflect +when a client was first granted access to the user's account, and when it most +recently used that access. For active sessions, it may reflect the current +time or the time of the last login. + +The `<user-agent/>` element contains information about the client software. It +may contain any of three optional child elements, each containing text content: + +- `<software/>` - the name of the software +- `<uri/>` - a URI/URL for the client, such as a homepage +- `<device/>` - a human-readable identifier/name for the device where the client + runs + +The `<auth/>` element lists the known authentication methods that the client +has used to gain access to the account. The following elements are defined: + +- `<password/>` - the client has presented a valid password +- `<grant/>` - the client has a valid authorization grant (e.g. via OAuth) +- `<fast/>` - the client has active FAST tokens + +#### Revoking access + +To revoke a client's access, send a `<revoke/>` element with an 'id' attribute +containing one of the client ids fetched from the list: + +```xml +<iq id="p" type="set"> + <revoke xmlns="xmpp:prosody.im/protocol/manage-clients" id="grant/HjEEr45_LQr" /> +</iq> +``` + +The server will respond with an empty result if the revocation succeeds: + +```xml +<iq id="p" type="result" /> +``` + +If the client has previously authenticated with a password, there is no way to +revoke access except by changing the user's password. If you request +revocation of such a client, the server will respond with a 'service-unavailable' +error, with the 'password-reset-required' application error: + +```xml +<iq id="p" type="error"> + <error type="cancel"> + <service-unavailable xmlns="xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'"> + <password-reset-required xmlns="xmpp:prosody.im/protocol/manage-clients"/> + </error> +</iq> +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_client_management/mod_client_management.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,459 @@ +local modulemanager = require "core.modulemanager"; +local usermanager = require "core.usermanager"; + +local array = require "util.array"; +local dt = require "util.datetime"; +local id = require "util.id"; +local it = require "util.iterators"; +local jid = require "util.jid"; +local st = require "util.stanza"; + +local strict = module:get_option_boolean("enforce_client_ids", false); + +module:default_permission("prosody:user", ":list-clients"); +module:default_permission("prosody:user", ":manage-clients"); + +local tokenauth = module:depends("tokenauth"); +local mod_fast = module:depends("sasl2_fast"); + +local client_store = assert(module:open_store("clients", "keyval+")); +--[[{ + id = id; + first_seen = + last_seen = + user_agent = { + name = + os = + } +--}]] + +local xmlns_sasl2 = "urn:xmpp:sasl:2"; + +local function get_user_agent(sasl_handler, token_info) + local sasl_agent = sasl_handler and sasl_handler.user_agent; + local token_agent = token_info and token_info.data and token_info.data.oauth2_client; + if not (sasl_agent or token_agent) then return; end + return { + software = sasl_agent and sasl_agent.software or token_agent and token_agent.name or nil; + uri = token_agent and token_agent.uri or nil; + device = sasl_agent and sasl_agent.device or nil; + }; +end + +module:hook("sasl2/c2s/success", function (event) + local session = event.session; + local username, client_id = session.username, session.client_id; + local mechanism = session.sasl_handler.selected; + local token_info = session.sasl_handler.token_info; + local token_id = token_info and token_info.id or nil; + + local now = os.time(); + if client_id then -- SASL2, have client identifier + local is_new_client; + + local client_state = client_store:get_key(username, client_id); + if not client_state then + is_new_client = true; + client_state = { + id = client_id; + first_seen = now; + user_agent = get_user_agent(session.sasl_handler, token_info); + full_jid = nil; + last_seen = nil; + mechanisms = {}; + }; + end + -- Update state + client_state.full_jid = session.full_jid; + client_state.last_seen = now; + client_state.mechanisms[mechanism] = now; + if session.sasl_handler.fast_auth then + client_state.fast_auth = now; + end + if token_id then + client_state.auth_token_id = token_id; + end + -- Store updated state + client_store:set_key(username, client_id, client_state); + + if is_new_client then + module:fire_event("client_management/new-client", { client = client_state }); + end + end +end); + +local function find_client_by_resource(username, resource) + local full_jid = jid.join(username, module.host, resource); + local clients = client_store:get(username); + if not clients then return; end + + for _, client_state in pairs(clients) do + if client_state.full_jid == full_jid then + return client_state; + end + end +end + +module:hook("resource-bind", function (event) + local session = event.session; + if session.client_id then return; end + local is_new_client; + local client_state = find_client_by_resource(event.session.username, event.session.resource); + local now = os.time(); + if not client_state then + is_new_client = true; + client_state = { + id = id.short(); + first_seen = now; + user_agent = nil; + full_jid = nil; + last_seen = nil; + mechanisms = {}; + legacy = true; + }; + end + + -- Update state + local legacy_info = session.client_management_info; + client_state.full_jid = session.full_jid; + client_state.last_seen = now; + client_state.mechanisms[legacy_info.mechanism] = now; + if legacy_info.fast_auth then + client_state.fast_auth = now; + end + + local token_id = legacy_info.token_info and legacy_info.token_info.id; + if token_id then + client_state.auth_token_id = token_id; + end + + -- Store updated state + client_store:set_key(session.username, client_state.id, client_state); + + if is_new_client then + module:fire_event("client_management/new-client", { client = client_state }); + end +end); + +if strict then + module:hook_tag(xmlns_sasl2, "authenticate", function (session, auth) + local user_agent = auth:get_child("user-agent"); + if not user_agent or not user_agent.attr.id then + local failure = st.stanza("failure", { xmlns = xmlns_sasl2 }) + :tag("malformed-request", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl" }):up() + :text_tag("text", "Client identifier required but not supplied"); + session.send(failure); + return true; + end + end, 500); + + if modulemanager.get_modules_for_host(module.host):contains("saslauth") then + module:log("error", "mod_saslauth is enabled, but enforce_client_ids is enabled and will prevent it from working"); + end + + module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function (event) + -- Block legacy SASL, if for some reason it is being used (either mod_saslauth is loaded, + -- or clients try it without advertisement) + module:log("warn", "Blocking legacy SASL authentication because enforce_client_ids is enabled"); + local failure = st.stanza("failure", { xmlns = xmlns_sasl2 }) + :tag("malformed-request", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl" }):up() + :text_tag("text", "Legacy SASL authentication is not available on this server"); + event.session.send(failure); + return true; + end); +else + -- Legacy client compat code + module:hook("authentication-success", function (event) + local session = event.session; + if session.client_id then return; end -- SASL2 client + + local sasl_handler = session.sasl_handler; + session.client_management_info = { + mechanism = sasl_handler.selected; + token_info = sasl_handler.token_info; + fast_auth = sasl_handler.fast_auth; + }; + end); +end + +local function is_password_mechanism(mech_name) + if mech_name == "OAUTHBEARER" then return false; end + if mech_name:match("^HT%-") then return false; end + return true; +end + +local function is_client_active(client) + local username, host = jid.split(client.full_jid); + local account_info = usermanager.get_account_info(username, host); + local last_password_change = account_info and account_info.password_updated; + + local status = {}; + + -- Check for an active token grant that has been previously used by this client + if client.auth_token_id then + local grant = tokenauth.get_grant_info(client.auth_token_id); + if grant then + status.grant = grant; + end + end + + -- Check for active FAST tokens + if client.fast_auth then + if mod_fast.is_client_fast(username, client.id, last_password_change) then + status.fast = client.fast_auth; + end + end + + -- Client has access if any password-based SASL mechanisms have been used since last password change + for mech, mech_last_used in pairs(client.mechanisms) do + if is_password_mechanism(mech) and (not last_password_change or mech_last_used >= last_password_change) then + status.password = mech_last_used; + end + end + + if prosody.full_sessions[client.full_jid] then + status.connected = true; + end + + if next(status) == nil then + return nil; + end + return status; +end + +-- Public API +--luacheck: ignore 131 +function get_active_clients(username) + local clients = client_store:get(username); + local active_clients = {}; + local used_grants = {}; + + -- Go through known clients, check whether they could possibly log in + for client_id, client in pairs(clients or {}) do --luacheck: ignore 213/client_id + local active = is_client_active(client); + if active then + client.type = "session"; + client.id = "client/"..client.id; + client.active = active; + table.insert(active_clients, client); + if active.grant then + used_grants[active.grant.id] = true; + end + end + end + + -- Next, account for any grants that have been issued, but never actually logged in + for grant_id, grant in pairs(tokenauth.get_user_grants(username) or {}) do + if not used_grants[grant_id] then -- exclude grants already accounted for + table.insert(active_clients, { + id = "grant/"..grant_id; + type = "access"; + first_seen = grant.created; + last_seen = grant.accessed; + active = { + grant = grant; + }; + user_agent = get_user_agent(nil, grant); + }); + end + end + + table.sort(active_clients, function (a, b) + if a.last_seen and b.last_seen then + return a.last_seen < b.last_seen; + elseif not (a.last_seen or b.last_seen) then + if a.first_seen and b.first_seen then + return a.first_seen < b.first_seen; + end + elseif b.last_seen then + return true; + elseif a.last_seen then + return false; + end + return a.id < b.id; + end); + + return active_clients; +end + +function revoke_client_access(username, client_selector) + if client_selector then + local c_type, c_id = client_selector:match("^(%w+)/(.+)$"); + if c_type == "client" then + local client = client_store:get_key(username, c_id); + if not client then + return nil, "item-not-found"; + end + local status = is_client_active(client); + if status.connected then + local ok, err = prosody.full_sessions[client.full_jid]:close(); + if not ok then return ok, err; end + end + if status.fast then + local ok = mod_fast.revoke_fast_tokens(username, client.id); + if not ok then return nil, "internal-server-error"; end + end + if status.grant then + local ok = tokenauth.revoke_grant(username, status.grant.id); + if not ok then return nil, "internal-server-error"; end + end + if status.password then + return nil, "password-reset-required"; + end + return true; + elseif c_type == "grant" then + local grant = tokenauth.get_grant_info(username, c_id); + if not grant then + return nil, "item-not-found"; + end + local ok = tokenauth.revoke_grant(username, c_id); + if not ok then return nil, "internal-server-error"; end + return true; + end + end + + return nil, "item-not-found"; +end + +-- Protocol + +local xmlns_manage_clients = "xmpp:prosody.im/protocol/manage-clients"; + +module:hook("iq-get/self/xmpp:prosody.im/protocol/manage-clients:list", function (event) + local origin, stanza = event.origin, event.stanza; + + if not module:may(":list-clients", event) then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + + local reply = st.reply(stanza) + :tag("clients", { xmlns = xmlns_manage_clients }); + + local active_clients = get_active_clients(event.origin.username); + for _, client in ipairs(active_clients) do + local auth_type = st.stanza("auth"); + if client.active then + if client.active.password then + auth_type:text_tag("password"); + end + if client.active.grant then + auth_type:text_tag("bearer-token"); + end + if client.active.fast then + auth_type:text_tag("fast"); + end + end + + local user_agent = st.stanza("user-agent"); + if client.user_agent then + if client.user_agent.software then + user_agent:text_tag("software", client.user_agent.software); + end + if client.user_agent.device then + user_agent:text_tag("device", client.user_agent.device); + end + if client.user_agent.uri then + user_agent:text_tag("uri", client.user_agent.uri); + end + end + + local connected = client.active and client.active.connected; + reply:tag("client", { id = client.id, connected = connected and "true" or "false", type = client.type }) + :text_tag("first-seen", dt.datetime(client.first_seen)) + :text_tag("last-seen", dt.datetime(client.last_seen)) + :add_child(auth_type) + :add_child(user_agent) + :up(); + end + reply:up(); + + origin.send(reply); + return true; +end); + +local revocation_errors = require "util.error".init(module.name, xmlns_manage_clients, { + ["item-not-found"] = { "cancel", "item-not-found", "Client not found" }; + ["internal-server-error"] = { "wait", "internal-server-error", "Unable to revoke client access" }; + ["password-reset-required"] = { "cancel", "service-unavailable", "Password reset required", "password-reset-required" }; +}); + +module:hook("iq-set/self/xmpp:prosody.im/protocol/manage-clients:revoke", function (event) + local origin, stanza = event.origin, event.stanza; + + if not module:may(":manage-clients", event) then + origin.send(st.error_reply(stanza, "auth", "forbidden")); + return true; + end + + local client_id = stanza.tags[1].attr.id; + + local ok, err = revocation_errors.coerce(revoke_client_access(origin.username, client_id)); + if not ok then + origin.send(st.error_reply(stanza, err)); + return true; + end + + origin.send(st.reply(stanza)); + return true; +end); + + +-- Command + +module:once(function () + local console_env = module:shared("/*/admin_shell/env"); + if not console_env.user then return; end -- admin_shell probably not loaded + + function console_env.user:clients(user_jid) + local username, host = jid.split(user_jid); + local mod = prosody.hosts[host] and prosody.hosts[host].modules.client_management; + if not mod then + return false, ("Host does not exist on this server, or does not have mod_client_management loaded"); + end + + local clients = mod.get_active_clients(username); + if not clients or #clients == 0 then + return true, "No clients associated with this account"; + end + + local colspec = { + { + title = "Software"; + key = "user_agent"; + width = "1p"; + mapper = function(user_agent) + return user_agent and user_agent.software; + end; + }; + { + title = "Last seen"; + key = "last_seen"; + width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S")); + align = "right"; + mapper = function(last_seen) + return os.date(os.difftime(os.time(), last_seen) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen); + end; + }; + { + title = "Authentication"; + key = "active"; + width = "2p"; + mapper = function(active) + return array.collect(it.keys(active)):sort():concat(", "); + end; + }; + }; + + local row = require "util.human.io".table(colspec, self.session.width); + + local print = self.session.print; + print(row()); + print(string.rep("-", self.session.width)); + for _, client in ipairs(clients) do + print(row(client)); + end + print(string.rep("-", self.session.width)); + return true, ("%d clients"):format(#clients); + end +end);
--- a/mod_cloud_notify/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_cloud_notify/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -93,6 +93,9 @@ Compatibility ============= +**Note:** This module should be used with Lua 5.2 and higher. Using it with +Lua 5.1 may cause push notifications to not be sent to some clients. + ------ ----------------------------------------------------------------------------- trunk Works 0.12 Works
--- a/mod_cloud_notify/mod_cloud_notify.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_cloud_notify/mod_cloud_notify.lua Sat May 06 19:40:23 2023 -0500 @@ -28,6 +28,10 @@ local id2node = {}; local id2identifier = {}; +if _VERSION:match("5%.1") then + module:log("warn", "This module may behave incorrectly on Lua 5.1. It is recommended to upgrade to a newer Lua version."); +end + -- For keeping state across reloads while caching reads -- This uses util.cache for caching the most recent devices and removing all old devices when max_push_devices is reached local push_store = (function()
--- a/mod_cloud_notify_filters/mod_cloud_notify_filters.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_cloud_notify_filters/mod_cloud_notify_filters.lua Sat May 06 19:40:23 2023 -0500 @@ -28,7 +28,12 @@ if filter_muted then local muted_jids = {}; for item in filter_muted:childtags("item") do - muted_jids[jid.prep(item.attr.jid)] = true; + local room_jid = jid.prep(item.attr.jid); + if not room_jid then + module:log("warn", "Skipping invalid JID: <%s>", room_jid); + else + muted_jids[room_jid] = true; + end end event.push_info.muted_jids = muted_jids; end @@ -37,10 +42,15 @@ if filter_groupchat then local groupchat_rules = {}; for item in filter_groupchat:childtags("room") do - groupchat_rules[jid.prep(item.attr.jid)] = { - when = item.attr.allow; - nick = item.attr.nick; - }; + local room_jid = jid.prep(item.attr.jid); + if not room_jid then + module:log("warn", "Skipping invalid JID: <%s>", item.attr.jid); + else + groupchat_rules[room_jid] = { + when = item.attr.allow; + nick = item.attr.nick; + }; + end end event.push_info.groupchat_rules = groupchat_rules; end
--- a/mod_conversejs/mod_conversejs.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_conversejs/mod_conversejs.lua Sat May 06 19:40:23 2023 -0500 @@ -79,9 +79,9 @@ end end -local user_options = module:get_option("conversejs_options"); +local function get_converse_options() + local user_options = module:get_option("conversejs_options"); -local function get_converse_options() local allow_registration = module:get_option_boolean("allow_registration", false); local converse_options = { -- Auto-detected connection endpoints
--- a/mod_firewall/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_firewall/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -605,9 +605,9 @@ ### Route modification -The most common actions modify the stanza's route in some way. Currently -the first matching rule to do so will halt further processing of actions -and rules (this may change in the future). +The following common actions modify the stanza's route in some way. These +rules will halt further processing of the stanza - no further actions will be +executed, and no further rules will be checked. Action Description ----------------------- --------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -615,15 +615,41 @@ `DROP.` Stop executing actions and rules on this stanza, and discard it. `DEFAULT.` Stop executing actions and rules on this stanza, prevent any other scripts/modules from handling it, to trigger the appropriate default "unhandled stanza" behaviour. Do not use in custom chains (it is treated as PASS). `REDIRECT=jid` Redirect the stanza to the given JID. - `REPLY=text` Reply to the stanza (assumed to be a message) with the given text. `BOUNCE.` Bounce the stanza with the default error (usually service-unavailable) `BOUNCE=error` Bounce the stanza with the given error (MUST be a defined XMPP stanza error, see [RFC6120](http://xmpp.org/rfcs/rfc6120.html#stanzas-error-conditions). `BOUNCE=error (text)` As above, but include the supplied human-readable text with a description of the error - `COPY=jid` Make a copy of the stanza and send the copy to the specified JID. The copied stanza flows through Prosody's routing code, and as such is affected by firewall rules. Be careful to avoid loops. - `FORWARD=jid` Forward a copy of the stanza to the given JID (using XEP-0297). The stanza will be sent from the current host's JID. **Note:** It is incorrect behaviour to reply to an 'error' stanza with another error, so BOUNCE will simply act the same as 'DROP' for stanzas that should not be bounced (error stanzas and iq results). +### Replying and forwarding + +These actions cause a new stanza to be generated and sent somewhere. +Processing of the original stanza will continue beyond these actions. + + Action Description + ------------------------ --------------------------------------------------------------------------------------------------------------------------------------------------------- + `REPLY=text` Reply to the stanza (assumed to be a message) with the given text. + `COPY=jid` Make a copy of the stanza and send the copy to the specified JID. The copied stanza flows through Prosody's routing code, and as such is affected by firewall rules. Be careful to avoid loops. + `FORWARD=jid` Forward a copy of the stanza to the given JID (using XEP-0297). The stanza will be sent from the current host's JID. + +### Reporting + + Action Description + ------------------------ --------------------------------------------------------------------------------------------------------------------------------------------------------- + `REPORT=jid reason text` Forwards the full stanza to `jid` with a XEP-0377 abuse report attached. + +Only the `jid` is mandatory. The `reason` parameter should be either `abuse`, `spam` or a custom URI. If not specified, it defaults to `abuse`. +After the reason, some human-readable text may be included to explain the report. + +Example: + +``` +KIND: message +TO: honeypot@example.com +REPORT TO=antispam.example.com spam Caught by the honeypot! +DROP. +``` + ### Stanza modification These actions make it possible to modify the content and structure of a
--- a/mod_firewall/actions.lib.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_firewall/actions.lib.lua Sat May 06 19:40:23 2023 -0500 @@ -241,4 +241,22 @@ { "rostermanager", "core_post_stanza", "st", "split_to", "bare_to", "bare_from" }; end +function action_handlers.REPORT_TO(spec) + local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$"); + if reason == "spam" then + reason = "urn:xmpp:reporting:spam"; + elseif reason == "abuse" or not reason then + reason = "urn:xmpp:reporting:abuse"; + end + local code = [[ + local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" }); + local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up(); + newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q }) + do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end + newstanza:up(); + core_post_stanza(session, newstanza); + ]]; + return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" }; +end + return action_handlers;
--- a/mod_firewall/definitions.lib.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_firewall/definitions.lib.lua Sat May 06 19:40:23 2023 -0500 @@ -197,7 +197,11 @@ -- TODO Invent some custom schema for this? Needed for just a set of strings? pubsubitemid = { init = function(self, pubsub_spec, opts) - local service_addr, node = pubsub_spec:match("^([^/]*)/(.*)"); + local service_addr, node = pubsub_spec:match("^pubsubitemid:([^/]*)/(.*)"); + if not service_addr then + module:log("warn", "Invalid list specification (expected 'pubsubitemid:<service>/<node>', got: '%s')", pubsub_spec); + return; + end module:depends("pubsub_subscription"); module:add_item("pubsub-subscription", { service = service_addr;
--- a/mod_firewall/mod_firewall.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_firewall/mod_firewall.lua Sat May 06 19:40:23 2023 -0500 @@ -558,8 +558,12 @@ local function fire_event(name, data) return module:fire_event(name, data); end + local init_ok, initialized_chunk = pcall(chunk); + if not init_ok then + return nil, "Error initializing compiled rules: "..initialized_chunk; + end return function (pass_return) - return chunk()(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues + return initialized_chunk(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues end end
--- a/mod_http_admin_api/mod_http_admin_api.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_http_admin_api/mod_http_admin_api.lua Sat May 06 19:40:23 2023 -0500 @@ -1,5 +1,6 @@ local usermanager = require "core.usermanager"; +local jid = require "util.jid"; local it = require "util.iterators"; local json = require "util.json"; local st = require "util.stanza"; @@ -48,6 +49,9 @@ event.response.headers.authorization = www_authenticate_header; return false, 401; end + -- FIXME this should probably live in mod_tokenauth or similar + session.type = "c2s"; + session.full_jid = jid.join(session.username, session.host, session.resource); event.session = session; if not module:may(":access-admin-api", event) then return false, 403;
--- a/mod_http_admin_api/openapi.yaml Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_http_admin_api/openapi.yaml Sat May 06 19:40:23 2023 -0500 @@ -482,6 +482,21 @@ type: string description: Display name of the user nullable: true + role: + type: string + description: Primary role of the user + nullable: true + secondary_roles: + type: array + description: List of additional roles assigned to the user + items: + type: string + roles: + type: array + description: List of roles assigned to the user (Legacy) + deprecated: true + items: + type: string email: type: string description: Optional email address for the user (NYI) @@ -780,10 +795,10 @@ - since properties: since: - type: float + type: number description: The metric epoch as UNIX timestamp value: - type: float + type: number description: Seconds of CPU time used since the metric epoch c2s: type: integer
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_debug/mod_http_debug.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,26 @@ +local json = require "util.json" + +module:depends("http") +module:provides("http", { + route = { + GET = function(event) + local request = event.request; + return { + status_code = 200; + headers = { + content_type = "application/json", + }, + body = json.encode { + body = request.body; + headers = request.headers; + httpversion = request.httpversion; + ip = request.ip; + method = request.method; + path = request.path; + secure = request.secure; + url = request.url; + } + } + end; + } + })
--- a/mod_http_oauth2/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_http_oauth2/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -2,19 +2,176 @@ labels: - Stage-Alpha summary: 'OAuth2 API' +rockspec: + build: + copy_directories: + - html ... -Introduction -============ +## Introduction + +This module implements an [OAuth2](https://oauth.net/2/)/[OpenID Connect +(OIDC)](https://openid.net/connect/) provider HTTP frontend on top of +Prosody's usual internal authentication backend. + +OAuth and OIDC are web standards that allow you to provide clients and +third-party applications limited access to your account, without sharing your +password with them. + +With this module deployed, software that supports OAuth can obtain "access +tokens" from Prosody which can then be used to connect to XMPP accounts using +the 'OAUTHBEARER' SASL mechanism or via non-XMPP interfaces such as [mod_rest]. + +Although this module has been around for some time, it has recently been +significantly extended and largely rewritten to support OAuth/OIDC more fully. + +As of April 2023, it should be considered **alpha** stage. It works, we have +tested it, but it has not yet seen wider review, testing and deployment. At +this stage we recommend it for experimental and test deployments only. For +specific information, see the [deployment notes section](#deployment-notes) +below. + +Known client implementations: + +- [example shell script for mod_rest](https://hg.prosody.im/prosody-modules/file/tip/mod_rest/example/rest.sh) +- *(we need you!)* + +Support for OAUTHBEARER has been added to the Lua XMPP library, [verse](https://code.matthewwild.co.uk/verse). +If you know of additional implementations, or are motivated to work on one, +please let us know! We'd be happy to help (e.g. by providing a test server). + +## Standards support + +Notable supported standards: -This module is a work-in-progress intended for developers only! +- [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749) +- [RFC 7009: OAuth 2.0 Token Revocation](https://www.rfc-editor.org/rfc/rfc7009) +- [RFC 7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth](https://www.rfc-editor.org/rfc/rfc7628) +- [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://www.rfc-editor.org/rfc/rfc7636) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +- [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) & [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html) +- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) + +## Configuration + +### Interface + +The module presents a web page to users to allow them to authenticate when +a client requests access. Built-in pages are provided, but you may also theme +or entirely override them. + +This module honours the 'site_name' configuration option that is also used by +a number of other modules: + +```lua +site_name = "My XMPP Server" +``` + +To provide custom templates, specify the path to the template directory: + +```lua +oauth2_template_path = "/etc/prosody/custom-oauth2-templates" +``` + +Some templates support additional variables, that can be provided by the +'oauth2_template_style' option: + +```lua +oauth2_template_style = { + background_colour = "#ffffff"; +} +``` + +### Token parameters + +The following options configure the lifetime of tokens issued by the module. +The defaults are recommended. + +```lua +oauth2_access_token_ttl = 86400 -- 24 hours +oauth2_refresh_token_ttl = nil -- unlimited unless revoked by the user +``` -Configuration -============= +### Dynamic client registration + +To allow users to connect any compatible software, you should enable dynamic +client registration. + +Dynamic client registration can be enabled by configuring a JWT key. Algorithm +defaults to *HS256* lifetime defaults to forever. + +```lua +oauth2_registration_key = "securely generated JWT key here" +oauth2_registration_algorithm = "HS256" +oauth2_registration_ttl = nil -- unlimited by default +``` + +### Supported flows + +Various flows can be disabled and enabled with +`allowed_oauth2_grant_types` and `allowed_oauth2_response_types`: -None currently. +```lua +allowed_oauth2_grant_types = { + "authorization_code"; -- authorization code grant + "password"; -- resource owner password grant +} + +allowed_oauth2_response_types = { + "code"; -- authorization code flow + -- "token"; -- implicit flow disabled by default +} +``` + +The [Proof Key for Code Exchange][RFC 7636] mitigation method can be +made required: + +```lua +oauth2_require_code_challenge = true +``` + +Further, individual challenge methods can be enabled or disabled: -Compatibility -============= +```lua +allowed_oauth2_code_challenge_methods = { + "plain"; -- the insecure one + "S256"; +} +``` + +### Policy documents + +Links to Terms of Service and Service Policy documents can be advertised +for use by OAuth clients: + +```lua +oauth2_terms_url = "https://example.com/terms-of-service.html" +oauth2_policy_url = "https://example.com/service-policy.pdf" +``` + +## Deployment notes + +### Access management -Requires Prosody 0.12+ or trunk. +This module does not provide an interface for users to manage what they have +granted access to their account! (e.g. to view and revoke clients they have +previously authorized). It is recommended to join this module with +mod_client_management to provide such access. However, at the time of writing, +no XMPP clients currently support the protocol used by that module. We plan to +work on additional interfaces in the future. + +### Scopes + +OAuth supports "scopes" as a way to grant clients limited access. + +There are currently no standard scopes defined for XMPP. This is something +that we intend to change, e.g. by definitions provided in a future XEP. This +means that clients you authorize currently have unrestricted access to your +account (including the ability to change your password and lock you out!). So, +for now, while using OAuth clients can prevent leaking your password to them, +it is not currently suitable for connecting untrusted clients to your account. + +## Compatibility + +Requires Prosody trunk (April 2023), **not** compatible with Prosody 0.12 or +earlier.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_oauth2/html/consent.html Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,53 @@ +<!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> + <fieldset> + <legend>Authorize new application</legend> + <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"> + <details><summary>Requested permissions</summary>{scopes# + <input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked><label class="scope" for="scope_{idx}">{item}</label>}{roles& + <select name="role">{roles# + <option value="{item.name}"{item.selected& selected}>{item.name}</option>} + </select>} + </details> + <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> + </fieldset> + </main> +</body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_oauth2/html/error.html Sat May 06 19:40:23 2023 -0500 @@ -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 Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,26 @@ +<!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> + <fieldset> + <legend>Sign in</legend> + <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 autofocus><br/> + <input type="password" name="password" placeholder="Password" aria-label="Password" autocomplete="current-password" required><br/> + <input type="submit" value="Sign in"> + </form> + </fieldset> + </main> +</body> +</html>
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_oauth2/html/style.css Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,95 @@ +body +{ + margin-top:14%; + text-align:center; + background-color:#f8f8f8; + font-family:sans-serif +} + +h1 +{ + font-size:xx-large; +} + +legend { + font-size:x-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; + } + + + :link + { + color: #6197df; + } + + :visited + { + color: #9a61df; + } +} + +@media(min-width: 768px) +{ + main + { + margin-left: auto; + margin-right: auto; + } + +}
--- a/mod_http_oauth2/mod_http_oauth2.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_http_oauth2/mod_http_oauth2.lua Sat May 06 19:40:23 2023 -0500 @@ -6,51 +6,175 @@ local usermanager = require "core.usermanager"; local errors = require "util.error"; local url = require "socket.url"; -local uuid = require "util.uuid"; +local id = require "util.id"; local encodings = require "util.encodings"; local base64 = encodings.base64; +local random = require "util.random"; +local schema = require "util.jsonschema"; +local set = require "util.set"; +local jwt = require"util.jwt"; +local it = require "util.iterators"; +local array = require "util.array"; +local st = require "util.stanza"; + +local function b64url(s) + return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" })) +end + +local function tmap(t) + return function(k) + return t[k]; + end +end + +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 = { + status_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"); -local clients = module:open_store("oauth2_clients", "map"); +local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 86400); +local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", nil); + +-- Used to derive client_secret from client_id, set to enable stateless dynamic registration. +local registration_key = module:get_option_string("oauth2_registration_key"); +local registration_algo = module:get_option_string("oauth2_registration_algorithm", "HS256"); +local registration_ttl = module:get_option("oauth2_registration_ttl", nil); +local registration_options = module:get_option("oauth2_registration_options", + { default_ttl = registration_ttl; accept_expired = not registration_ttl }); + +local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false); -local function filter_scopes(username, host, requested_scope_string) - if host ~= module.host then - return usermanager.get_jid_role(username.."@"..host, module.host).name; - end +local verification_key; +local jwt_sign, jwt_verify; +if registration_key then + -- Tie it to the host if global + verification_key = hashes.hmac_sha256(registration_key, module.host); + jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options); +end + +local function parse_scopes(scope_string) + return array(scope_string:gmatch("%S+")); +end - if requested_scope_string then -- Specific role requested - -- TODO: The requested scope string is technically a space-delimited list - -- of scopes, but for simplicity we're mapping this slot to role names. - if usermanager.user_can_assume_role(username, module.host, requested_scope_string) then - return requested_scope_string; +local openid_claims = set.new({ "openid", "profile"; "email"; "address"; "phone" }); + +local function split_scopes(scope_list) + local claims, roles, unknown = array(), array(), array(); + local all_roles = usermanager.get_all_roles(module.host); + for _, scope in ipairs(scope_list) do + if openid_claims:contains(scope) then + claims:push(scope); + elseif all_roles[scope] then + roles:push(scope); + else + unknown:push(scope); end end + return claims, roles, unknown; +end +local function can_assume_role(username, requested_role) + return usermanager.user_can_assume_role(username, module.host, requested_role); +end + +local function select_role(username, requested_roles) + if requested_roles then + for _, requested_role in ipairs(requested_roles) do + if can_assume_role(username, requested_role) then + return requested_role; + end + end + end + -- otherwise the default role return usermanager.get_user_role(username, module.host).name; end -local function code_expires_in(code) - return os.difftime(os.time(), code.issued); +local function filter_scopes(username, requested_scope_string) + local granted_scopes, requested_roles; + + if requested_scope_string then -- Specific role(s) requested + granted_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string)); + else + granted_scopes = array(); + end + + local selected_role = select_role(username, requested_roles); + granted_scopes:push(selected_role); + + return granted_scopes:concat(" "), selected_role; end -local function code_expired(code) - return code_expires_in(code) > 120; +local function code_expires_in(code) --> number, seconds until code expires + return os.difftime(code.expires, os.time()); +end + +local function code_expired(code) --> boolean, true: has expired, false: still valid + return code_expires_in(code) < 0; end local codes = cache.new(10000, function (_, code) return code_expired(code) end); -module:add_timer(900, function() +-- Periodically clear out unredeemed codes. Does not need to be exact, expired +-- codes are rejected if tried. Mostly just to keep memory usage in check. +module:hourly("Clear expired authorization codes", function() local k, code = codes:tail(); while code and code_expired(code) do codes:set(k, nil); k, code = codes:tail(); end - return code and code_expires_in(code) + 1 or 900; end) +local function get_issuer() + return (module:http_url(nil, "/"):gsub("/$", "")); +end + +local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); +local function is_secure_redirect(uri) + local u = url.parse(uri); + return u.scheme ~= "http" or loopbacks:contains(u.host); +end + local function oauth_error(err_name, err_desc) return errors.new({ type = "modify"; @@ -61,19 +185,69 @@ }); end -local function new_access_token(token_jid, scope, ttl) - local token = tokens.create_jid_token(token_jid, token_jid, scope, ttl); +-- client_id / client_metadata are pretty large, filter out a subset of +-- properties that are deemed useful e.g. in case tokens issued to a certain +-- client needs to be revoked +local function client_subset(client) + return { name = client.client_name; uri = client.client_uri; id = client.software_id; version = client.software_version }; +end + +local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info) + local token_data = { oauth2_scopes = scope_string, oauth2_client = nil }; + if client then + token_data.oauth2_client = client_subset(client); + end + if next(token_data) == nil then + token_data = nil; + end + + local refresh_token; + local grant = refresh_token_info and refresh_token_info.grant; + if not grant then + -- No existing grant, create one + grant = tokens.create_grant(token_jid, token_jid, default_refresh_ttl, token_data); + -- Create refresh token for the grant if desired + refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant, nil, nil, "oauth2-refresh"); + else + -- Grant exists, reuse existing refresh token + refresh_token = refresh_token_info.token; + + refresh_token_info.grant = nil; -- Prevent reference loop + end + + local access_token, access_token_info = tokens.create_token(token_jid, grant, role, default_access_ttl, "oauth2"); + + local expires_at = access_token_info.expires; return { token_type = "bearer"; - access_token = token; - expires_in = ttl; - scope = scope; - -- TODO: include refresh_token when implemented + access_token = access_token; + expires_in = expires_at and (expires_at - os.time()) or nil; + scope = scope_string; + id_token = id_token; + refresh_token = refresh_token or nil; }; end +local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string + if not query_redirect_uri then + if #client.redirect_uris ~= 1 then + -- Client registered multiple URIs, it needs specify which one to use + return; + end + -- When only a single URI is registered, that's the default + return client.redirect_uris[1]; + end + -- Verify the client-provided URI matches one previously registered + for _, redirect_uri in ipairs(client.redirect_uris) do + if query_redirect_uri == redirect_uri then + return redirect_uri + end + end +end + local grant_type_handlers = {}; local response_type_handlers = {}; +local verifier_transforms = {}; function grant_type_handlers.password(params) local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); @@ -88,57 +262,99 @@ end local granted_jid = jid.join(request_username, request_host, request_resource); - local granted_scopes = filter_scopes(request_username, request_host, params.scope); - return json.encode(new_access_token(granted_jid, granted_scopes, nil)); + local granted_scopes, granted_role = filter_scopes(request_username, params.scope); + return json.encode(new_access_token(granted_jid, granted_role, granted_scopes, nil)); end -function response_type_handlers.code(params, granted_jid) - if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end - if not params.redirect_uri then return oauth_error("invalid_request", "missing 'redirect_uri'"); end +function response_type_handlers.code(client, params, granted_jid, id_token) + local request_username, request_host = jid.split(granted_jid); + if not request_host or request_host ~= module.host then + return oauth_error("invalid_request", "invalid JID"); + end + local granted_scopes, granted_role = filter_scopes(request_username, params.scope); - local client_owner, client_host, client_id = jid.prepped_split(params.client_id); - if client_host ~= module.host then - return oauth_error("invalid_client", "incorrect credentials"); - end - local client, err = clients:get(client_owner, client_id); - if err then error(err); end - if not client then - return oauth_error("invalid_client", "incorrect credentials"); + if pkce_required and not params.code_challenge then + return oauth_error("invalid_request", "PKCE required"); end - local granted_scopes = filter_scopes(client_owner, client_host, params.scope); - - local code = uuid.generate(); + local code = id.medium(); local ok = codes:set(params.client_id .. "#" .. code, { - issued = os.time(); + expires = os.time() + 600; granted_jid = granted_jid; granted_scopes = granted_scopes; + granted_role = granted_role; + challenge = params.code_challenge; + challenge_method = params.code_challenge_method; + id_token = id_token; }); if not ok then return {status_code = 429}; end - local redirect = url.parse(params.redirect_uri); + local redirect_uri = get_redirect_uri(client, params.redirect_uri); + if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" then + -- TODO some nicer template page + -- mod_http_errors will set content-type to text/html if it catches this + -- event, if not text/plain is kept for the fallback text. + local response = { status_code = 200; headers = { content_type = "text/plain" } } + response.body = module:context("*"):fire_event("http-message", { + response = response; + title = "Your authorization code"; + message = "Here's your authorization code, copy and paste it into " .. (client.client_name or "your client"); + extra = code; + }) or ("Here's your authorization code:\n%s\n"):format(code); + return response; + elseif not redirect_uri then + return 400; + end + + local redirect = url.parse(redirect_uri); + local query = http.formdecode(redirect.query or ""); if type(query) ~= "table" then query = {}; end - table.insert(query, { name = "code", value = code }) + table.insert(query, { name = "code", value = code }); + table.insert(query, { name = "iss", value = get_issuer() }); if params.state then table.insert(query, { name = "state", value = params.state }); end redirect.query = http.formencode(query); return { - status_code = 302; + status_code = 303; headers = { location = url.build(redirect); }; } end -local pepper = module:get_option_string("oauth2_client_pepper", ""); +-- Implicit flow +function response_type_handlers.token(client, params, granted_jid) + local request_username, request_host = jid.split(granted_jid); + if not request_host or request_host ~= module.host then + return oauth_error("invalid_request", "invalid JID"); + end + local granted_scopes, granted_role = filter_scopes(request_username, params.scope); + local token_info = new_access_token(granted_jid, granted_role, granted_scopes, client, nil); + + local redirect = url.parse(get_redirect_uri(client, params.redirect_uri)); + if not redirect then return 400; end + token_info.state = params.state; + redirect.fragment = http.formencode(token_info); -local function verify_secret(stored, salt, i, secret) - return base64.decode(stored) == hashes.pbkdf2_hmac_sha256(secret, salt .. pepper, i); + return { + status_code = 303; + headers = { + location = url.build(redirect); + }; + } +end + +local function make_client_secret(client_id) --> client_secret + return hashes.hmac_sha256(verification_key, client_id, true); +end + +local function verify_client_secret(client_id, client_secret) + return hashes.equals(make_client_secret(client_id), client_secret); end function grant_type_handlers.authorization_code(params) @@ -149,49 +365,157 @@ return oauth_error("invalid_scope", "unknown scope requested"); end - local client_owner, client_host, client_id = jid.prepped_split(params.client_id); - if client_host ~= module.host then - module:log("debug", "%q ~= %q", client_host, module.host); + local client_ok, client = jwt_verify(params.client_id); + if not client_ok then return oauth_error("invalid_client", "incorrect credentials"); end - local client, err = clients:get(client_owner, client_id); - if err then error(err); end - if not client or not verify_secret(client.secret_hash, client.salt, client.iteration_count, params.client_secret) then + + if not verify_client_secret(params.client_id, params.client_secret) then module:log("debug", "client_secret mismatch"); return oauth_error("invalid_client", "incorrect credentials"); end local code, err = codes:get(params.client_id .. "#" .. params.code); if err then error(err); end + -- MUST NOT use the authorization code more than once, so remove it to + -- prevent a second attempted use + codes:set(params.client_id .. "#" .. params.code, nil); if not code or type(code) ~= "table" or code_expired(code) then module:log("debug", "authorization_code invalid or expired: %q", code); return oauth_error("invalid_client", "incorrect credentials"); end - assert(codes:set(client_owner, client_id .. "#" .. params.code, nil)); + + -- TODO Decide if the code should be removed or not when PKCE fails + local transform = verifier_transforms[code.challenge_method or "plain"]; + if not transform then + return oauth_error("invalid_request", "unknown challenge transform method"); + elseif transform(params.code_verifier) ~= code.challenge then + return oauth_error("invalid_grant", "incorrect credentials"); + end + + return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token)); +end + +function grant_type_handlers.refresh_token(params) + if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end + if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end + if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); end + + local client_ok, client = jwt_verify(params.client_id); + if not client_ok then + return oauth_error("invalid_client", "incorrect credentials"); + end - return json.encode(new_access_token(code.granted_jid, code.granted_scopes, nil)); + if not verify_client_secret(params.client_id, params.client_secret) then + module:log("debug", "client_secret mismatch"); + return oauth_error("invalid_client", "incorrect credentials"); + end + + local refresh_token_info = tokens.get_token_info(params.refresh_token); + if not refresh_token_info or refresh_token_info.purpose ~= "oauth2-refresh" then + return oauth_error("invalid_grant", "invalid refresh token"); + end + + -- new_access_token() requires the actual token + refresh_token_info.token = params.refresh_token; + + return json.encode(new_access_token( + refresh_token_info.jid, refresh_token_info.role, refresh_token_info.grant.data.oauth2_scopes, client, nil, refresh_token_info + )); +end + +-- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients + +function verifier_transforms.plain(code_verifier) + -- code_challenge = code_verifier + return code_verifier; +end + +function verifier_transforms.S256(code_verifier) + -- code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + return code_verifier and b64url(hashes.sha256(code_verifier)); end -local function check_credentials(request, allow_token) +-- 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 ~= "" + and request.headers.content_type == "application/x-www-form-urlencoded" + and http.formdecode(request.body); + + if type(form) ~= "table" 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 + + local scope = array():append(form):filter(function(field) + return field.name == "scope" or field.name == "role"; + end):pluck("value"):concat(" "); + + user.token = form.user_token; + return { + user = user; + scope = scope; + consent = form.consent == "granted"; + }; + end + + return {}; +end + +local function get_request_credentials(request) + if not request.headers.authorization then return; end + local auth_type, auth_data = string.match(request.headers.authorization, "^(%S+)%s(.+)$"); if auth_type == "Basic" then local creds = base64.decode(auth_data); - if not creds then return false; end + if not creds then return; end local username, password = string.match(creds, "^([^:]+):(.*)$"); - if not username then return false; end - username, password = encodings.stringprep.nodeprep(username), encodings.stringprep.saslprep(password); - if not username then return false; end - if not usermanager.test_password(username, module.host, password) then - return false; - end - return username; - elseif auth_type == "Bearer" and allow_token then - local token_info = tokens.get_token_info(auth_data); - if not token_info or not token_info.session or token_info.session.host ~= module.host then - return false; - end - return token_info.session.username; + if not username then return; end + return { + type = "basic"; + username = username; + password = password; + }; + elseif auth_type == "Bearer" then + return { + type = "bearer"; + bearer_token = auth_data; + }; end + return nil; end @@ -210,7 +534,7 @@ end if request_password == component_secret then local granted_jid = jid.join(request_username, request_host, request_resource); - return json.encode(new_access_token(granted_jid, nil, nil)); + return json.encode(new_access_token(granted_jid, nil, nil, nil)); end return oauth_error("invalid_grant", "incorrect credentials"); end @@ -218,69 +542,178 @@ -- TODO How would this make sense with components? -- Have an admin authenticate maybe? response_type_handlers.code = nil; + response_type_handlers.token = nil; grant_type_handlers.authorization_code = nil; - 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 is_secure_redirect(redirect_uri) 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.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 = 303; + headers = { + location = redirect_uri; + }; + }; +end + +local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "refresh_token"}) +for handler_type in pairs(grant_type_handlers) do + if not allowed_grant_type_handlers:contains(handler_type) then + module:log("debug", "Grant type %q disabled", handler_type); + grant_type_handlers[handler_type] = nil; + else + module:log("debug", "Grant type %q enabled", handler_type); + end +end + +-- "token" aka implicit flow is considered insecure +local allowed_response_type_handlers = module:get_option_set("allowed_oauth2_response_types", {"code"}) +for handler_type in pairs(response_type_handlers) do + if not allowed_response_type_handlers:contains(handler_type) then + module:log("debug", "Response type %q disabled", handler_type); + response_type_handlers[handler_type] = nil; + else + module:log("debug", "Response type %q enabled", handler_type); + end +end + +local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "plain"; "S256" }) +for handler_type in pairs(verifier_transforms) do + if not allowed_challenge_methods:contains(handler_type) then + module:log("debug", "Challenge method %q disabled", handler_type); + verifier_transforms[handler_type] = nil; + else + module:log("debug", "Challenge method %q enabled", handler_type); + end end function handle_token_grant(event) + local credentials = get_request_credentials(event.request); + 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 + + if credentials and credentials.type == "basic" then + -- client_secret_basic converted internally to client_secret_post + params.client_id = http.urldecode(credentials.username); + params.client_secret = http.urldecode(credentials.password); + 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 client_response_types = set.new(array(client.response_types or { "code" })); + client_response_types = set.intersection(client_response_types, allowed_response_type_handlers); + if not client_response_types:contains(params.response_type) then + return oauth_error("invalid_client", "response_type not allowed"); 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 + local scopes, requested_roles = split_scopes(parse_scopes(params.scope or "")); + local default_role = select_role(auth_state.user.username, requested_roles); + local roles = array(it.values(usermanager.get_all_roles(module.host))):filter(function(role) + return can_assume_role(auth_state.user.username, role.name); + end):sort(function(a, b) + return (a.priority or 0) < (b.priority or 0) + end):map(function(role) + return { name = role.name; selected = role.name == default_role }; + end); + if not roles[2] then + -- Only one role to choose from, might as well skip the selector + roles = nil; + end + return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes; roles = roles }, true); + elseif not auth_state.consent then + -- Notify client of rejection + return error_response(request, oauth_error("access_denied")); + end + -- else auth_state.consent == true + + params.scope = auth_state.scope; + + local user_jid = jid.join(auth_state.user.username, module.host); + local client_secret = make_client_secret(params.client_id); + local id_token_signer = jwt.new_signer("HS256", client_secret); + local id_token = id_token_signer({ + iss = get_issuer(); + sub = url.build({ scheme = "xmpp"; path = user_jid }); + aud = params.client_id; + nonce = params.nonce; + }); 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, user_jid, id_token); end local function handle_revocation_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; - elseif request.headers.content_type ~= "application/x-www-form-urlencoded" - or not request.body or request.body == "" then - return 400; - end - local user = check_credentials(request, true); - if not user then - return 401; + if request.headers.authorization then + local credentials = get_request_credentials(request); + if not credentials or credentials.type ~= "basic" then + response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name); + return 401; + end + -- OAuth "client" credentials + if not verify_client_secret(credentials.username, credentials.password) then + return 401; + end end - local form_data = http.formdecode(event.request.body); + local form_data = http.formdecode(event.request.body or ""); if not form_data or not form_data.token then - return 400; + response.headers.accept = "application/x-www-form-urlencoded"; + return 415; end local ok, err = tokens.revoke_token(form_data.token); if not ok then @@ -290,12 +723,268 @@ return 200; end +local registration_schema = { + type = "object"; + required = { + -- These are shown to users in the template + "client_name"; + "client_uri"; + -- We need at least one redirect URI for things to work + "redirect_uris"; + }; + properties = { + redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } }; + token_endpoint_auth_method = { + type = "string"; + enum = { "none"; "client_secret_post"; "client_secret_basic" }; + default = "client_secret_basic"; + }; + grant_types = { + type = "array"; + items = { + type = "string"; + enum = { + "authorization_code"; + "implicit"; + "password"; + "client_credentials"; + "refresh_token"; + "urn:ietf:params:oauth:grant-type:jwt-bearer"; + "urn:ietf:params:oauth:grant-type:saml2-bearer"; + }; + }; + default = { "authorization_code" }; + }; + application_type = { type = "string"; enum = { "native"; "web" }; default = "web" }; + response_types = { type = "array"; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" } }; + client_name = { type = "string" }; + client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + scope = { type = "string" }; + contacts = { type = "array"; items = { type = "string"; format = "email" } }; + tos_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + policy_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + jwks_uri = { type = "string"; format = "uri"; luaPattern = "^https:" }; + jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" }; + software_id = { type = "string"; format = "uuid" }; + software_version = { type = "string" }; + }; + luaPatternProperties = { + -- Localized versions of descriptive properties and URIs + ["^client_name#"] = { description = "Localized version of 'client_name'"; type = "string" }; + ["^[a-z_]+_uri#"] = { type = "string"; format = "uri"; luaPattern = "^https:" }; + }; +} + +local function redirect_uri_allowed(redirect_uri, client_uri, app_type) + local uri = url.parse(redirect_uri); + if app_type == "native" then + return uri.scheme == "http" and loopbacks:contains(uri.host) or uri.scheme ~= "https"; + elseif app_type == "web" then + return uri.scheme == "https" and uri.host == client_uri.host; + end +end + +function create_client(client_metadata) + if not schema.validate(registration_schema, client_metadata) then + return nil, oauth_error("invalid_request", "Failed schema validation."); + end + + -- Fill in default values + for propname, propspec in pairs(registration_schema.properties) do + if client_metadata[propname] == nil and type(propspec) == "table" and propspec.default ~= nil then + client_metadata[propname] = propspec.default; + end + end + + local client_uri = url.parse(client_metadata.client_uri); + if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then + return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri"); + end + + for _, redirect_uri in ipairs(client_metadata.redirect_uris) do + if not redirect_uri_allowed(redirect_uri, client_uri, client_metadata.application_type) then + return nil, oauth_error("invalid_redirect_uri", "Invalid, insecure or inappropriate redirect URI."); + end + end + + for field, prop_schema in pairs(registration_schema.properties) do + if field ~= "client_uri" and prop_schema.format == "uri" and client_metadata[field] then + if not redirect_uri_allowed(client_metadata[field], client_uri, "web") then + return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI"); + end + end + end + + for k, v in pairs(client_metadata) do + local base_k = k:match"^([^#]+)#" or k; + if not registration_schema.properties[base_k] or k:find"^client_uri#" then + -- Ignore and strip unknown extra properties + client_metadata[k] = nil; + elseif k:find"_uri#" then + -- Localized URIs should be secure too + if not redirect_uri_allowed(v, client_uri, "web") then + return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI"); + end + end + end + + local grant_types = set.new(client_metadata.grant_types); + local response_types = set.new(client_metadata.response_types); + + if grant_types:contains("authorization_code") and not response_types:contains("code") then + return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'"); + elseif grant_types:contains("implicit") and not response_types:contains("token") then + return nil, oauth_error("invalid_client_metadata", "Inconsistency between 'grant_types' and 'response_types'"); + end + + if set.intersection(grant_types, allowed_grant_type_handlers):empty() then + return nil, oauth_error("invalid_client_metadata", "No allowed 'grant_types' specified"); + elseif set.intersection(response_types, allowed_response_type_handlers):empty() then + return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified"); + end + + -- Ensure each signed client_id JWT is unique, short ID and issued at + -- timestamp should be sufficient to rule out brute force attacks + client_metadata.nonce = id.short(); + + -- Do we want to keep everything? + local client_id = jwt_sign(client_metadata); + + client_metadata.client_id = client_id; + client_metadata.client_id_issued_at = os.time(); + + if client_metadata.token_endpoint_auth_method ~= "none" then + local client_secret = make_client_secret(client_id); + client_metadata.client_secret = client_secret; + client_metadata.client_secret_expires_at = 0; + + if not registration_options.accept_expired then + client_metadata.client_secret_expires_at = client_metadata.client_id_issued_at + (registration_options.default_ttl or 3600); + end + end + + return client_metadata; +end + +local function handle_register_request(event) + local request = event.request; + local client_metadata, err = json.decode(request.body); + if err then + return oauth_error("invalid_request", "Invalid JSON"); + end + + local response, err = create_client(client_metadata); + if err then return err end + + return { + status_code = 201; + headers = { content_type = "application/json" }; + body = json.encode(response); + }; +end + +if not registration_key then + module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled") + handle_authorization_request = nil + handle_register_request = nil +end + +local function handle_userinfo_request(event) + local request = event.request; + local credentials = get_request_credentials(request); + if not credentials or not credentials.bearer_token then + module:log("debug", "Missing credentials for UserInfo endpoint: %q", credentials) + return 401; + end + local token_info,err = tokens.get_token_info(credentials.bearer_token); + if not token_info then + module:log("debug", "UserInfo query failed token validation: %s", err) + return 403; + end + local scopes = set.new() + if type(token_info.grant.data) == "table" and type(token_info.grant.data.oauth2_scopes) == "string" then + scopes:add_list(parse_scopes(token_info.grant.data.oauth2_scopes)); + else + module:log("debug", "token_info = %q", token_info) + end + + if not scopes:contains("openid") then + module:log("debug", "Missing the 'openid' scope in %q", scopes) + -- The 'openid' scope is required for access to this endpoint. + return 403; + end + + local user_info = { + iss = get_issuer(); + sub = url.build({ scheme = "xmpp"; path = token_info.jid }); + } + + local token_claims = set.intersection(openid_claims, scopes); + token_claims:remove("openid"); -- that's "iss" and "sub" above + if not token_claims:empty() then + -- Another module can do that + module:fire_event("token/userinfo", { + token = token_info; + claims = token_claims; + username = jid.split(token_info.jid); + userinfo = user_info; + }); + end + + return { + status_code = 200; + headers = { content_type = "application/json" }; + body = json.encode(user_info); + }; +end + module:depends("http"); module:provides("http", { route = { - ["POST /token"] = handle_token_grant; + -- OAuth 2.0 in 5 simple steps! + -- This is the normal 'authorization_code' flow. + + -- Step 1. Create OAuth client + ["POST /register"] = handle_register_request; + + -- Step 2. User-facing login and consent view ["GET /authorize"] = handle_authorization_request; + ["POST /authorize"] = handle_authorization_request; + + -- Step 3. User is redirected to the 'redirect_uri' along with an + -- authorization code. In the insecure 'implicit' flow, the access token + -- is delivered here. + + -- Step 4. Retrieve access token using the code. + ["POST /token"] = handle_token_grant; + + -- Step 4 is later repeated using the refresh token to get new access tokens. + + -- Step 5. Revoke token (access or refresh) ["POST /revoke"] = handle_revocation_request; + + -- OpenID + ["GET /userinfo"] = handle_userinfo_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; + + -- Some convenient fallback handlers + ["GET /register"] = { headers = { content_type = "application/schema+json" }; body = json.encode(registration_schema) }; + ["GET /token"] = function() return 405; end; + ["GET /revoke"] = function() return 405; end; }; }); @@ -310,3 +999,41 @@ event.response.status_code = event.error.code or 400; return json.encode(oauth2_response); end, 5); + +-- OIDC Discovery + +module:provides("http", { + name = "oauth2-discovery"; + default_path = "/.well-known/oauth-authorization-server"; + route = { + ["GET"] = { + headers = { content_type = "application/json" }; + body = json.encode { + -- RFC 8414: OAuth 2.0 Authorization Server Metadata + issuer = get_issuer(); + authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil; + token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil; + jwks_uri = nil; -- TODO? + registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil; + scopes_supported = usermanager.get_all_roles and array(it.keys(usermanager.get_all_roles(module.host))):append(array(openid_claims:items())); + response_types_supported = array(it.keys(response_type_handlers)); + token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" }); + op_policy_uri = module:get_option_string("oauth2_policy_url", nil); + op_tos_uri = module:get_option_string("oauth2_terms_url", nil); + revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil; + revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" }); + code_challenge_methods_supported = array(it.keys(verifier_transforms)); + grant_types_supported = array(it.keys(response_type_handlers)):map(tmap { token = "implicit"; code = "authorization_code" }); + response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" }); + authorization_response_iss_parameter_supported = true; + service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html"); + + -- OpenID + userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil; + id_token_signing_alg_values_supported = { "HS256" }; + }; + }; + }; +}); + +module:shared("tokenauth/oauthbearer_config").oidc_discovery_url = module:http_url("oauth2-discovery", "/.well-known/oauth-authorization-server");
--- a/mod_inotify_reload/mod_inotify_reload.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_inotify_reload/mod_inotify_reload.lua Sat May 06 19:40:23 2023 -0500 @@ -12,31 +12,19 @@ local watches = {}; local watch_ids = {}; --- Fake socket object around inotify -local inh_conn = { - getfd = function () return inh:fileno(); end; - dirty = function (self) return false; end; - settimeout = function () end; - send = function (_, d) return #d, 0; end; - close = function () end; - receive = function () - local events = inh:read(); - for _, event in ipairs(events) do - local mod = watches[watch_ids[event.wd]]; - if mod then - local host, name = mod.host, mod.name; - module:log("debug", "Reloading changed module mod_%s on %s", name, host); - modulemanager.reload(host, name); - else - module:log("warn", "no watch for %d", event.wd); - end +require"net.server".watchfd(inh:fileno(), function() + local events = inh:read(); + for _, event in ipairs(events) do + local mod = watches[watch_ids[event.wd]]; + if mod then + local host, name = mod.host, mod.name; + module:log("debug", "Reloading changed module mod_%s on %s", name, host); + modulemanager.reload(host, name); + else + module:log("warn", "no watch for %d", event.wd); end - return ""; end -}; -require "net.server".wrapclient(inh_conn, "inotify", inh:fileno(), { - onincoming = function () end, ondisconnect = function () end -}, "*a"); +end); function watch_module(name, host, path) local id, err = inh:addwatch(path, inotify.IN_CLOSE_WRITE);
--- a/mod_isolate_host/mod_isolate_host.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_isolate_host/mod_isolate_host.lua Sat May 06 19:40:23 2023 -0500 @@ -22,6 +22,14 @@ except_domains:add(to_host); return; end + if origin.type == "local" then + -- this is code-generated, which means that set_session_isolation_flag has never triggered. + -- we need to check explicitly. + if not is_jid_isolated(jid_bare(event.stanza.attr.from)) then + module:log("debug", "server-generated stanza from %s is allowed, as the jid is not isolated", event.stanza.attr.from); + return; + end + end module:log("warn", "Forbidding stanza from %s to %s", stanza.attr.from or origin.full_jid, stanza.attr.to); origin.send(st.error_reply(stanza, "auth", "forbidden", "Communication with "..to_host.." is not available")); return true; @@ -36,13 +44,21 @@ module:default_permission("prosody:admin", "xmpp:federate"); -function check_user_isolated(event) +function is_jid_isolated(bare_jid) + if except_users:contains(bare_jid) or module:may("xmpp:federate", bare_jid) then + return false; + else + return true; + end +end + +function set_session_isolation_flag(event) local session = event.session; local bare_jid = jid_bare(session.full_jid); - if module:may("xmpp:federate", event) or except_users:contains(bare_jid) then + if not is_jid_isolated(bare_jid) then session.no_host_isolation = true; end module:log("debug", "%s is %sisolated", session.full_jid or "[?]", session.no_host_isolation and "" or "not "); end -module:hook("resource-bind", check_user_isolated); +module:hook("resource-bind", set_session_isolation_flag);
--- a/mod_listusers/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_listusers/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -1,3 +1,13 @@ +--- +labels: +- 'Stage-Obsolete' +--- + +::: {.alert .alert-warning} +As of Prosody 0.12.x, `prosodyctl shell user list HOST` can be used +instead of this module. +::: + This module adds a command to `prosodyctl` for listing users. ``` sh
--- a/mod_log_ringbuffer/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_log_ringbuffer/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -34,7 +34,7 @@ error = "/var/log/prosody/prosody.err"; -- Log debug and higher to a 2MB buffer - { level = "debug", to = "ringbuffer", size = 1024*1024*2, filename = "debug-logs-{pid}-{count}.log", signal = "SIGUSR2" }; + { to = "ringbuffer", size = 1024*1024*2, filename_template = "debug-logs-{pid}-{count}.log", signal = "SIGUSR2" }; } ``` @@ -43,8 +43,8 @@ `to` : Set this to `"ringbuffer"`. -`level` -: The minimum log level to capture, e.g. `"debug"`. +`levels` +: The log levels to capture, e.g. `{ min = "debug" }`. By default, all levels are captured. `size` : The size, in bytes, of the buffer. When the buffer fills, @@ -109,7 +109,6 @@ { to = "ringbuffer"; - level = "debug"; filename_template = "{paths.data}/traceback-{pid}-{count}.log"; event = "debug_traceback/triggered"; };
--- a/mod_muc_http_defaults/mod_muc_http_defaults.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_muc_http_defaults/mod_muc_http_defaults.lua Sat May 06 19:40:23 2023 -0500 @@ -89,7 +89,7 @@ if type(config.description) == "string" then room:set_description(config.description); end if type(config.language) == "string" then room:set_language(config.language); end if type(config.password) == "string" then room:set_password(config.password); end - if type(config.subject) == "string" then room:set_subject(config.subject); end + if type(config.subject) == "string" then room:set_subject(room.jid, config.subject); end if type(config.public) == "boolean" then room:set_public(config.public); end if type(config.members_only) == "boolean" then room:set_members_only(config.members_only); end
--- a/mod_muc_rtbl/mod_muc_rtbl.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_muc_rtbl/mod_muc_rtbl.lua Sat May 06 19:40:23 2023 -0500 @@ -38,6 +38,13 @@ module:log("debug", "Retracted hash: %s", hash); banned_hashes[hash] = nil; end; + + purge = function() + module:log("debug", "Purge all hashes"); + for hash in pairs(banned_hashes) do + banned_hashes[hash] = nil; + end + end; }); function request_list() @@ -146,7 +153,7 @@ module:hook("muc-private-message", function(event) local occupant = event.room:get_occupant_by_nick(event.stanza.attr.from); - local affiliation = event.room:get_affiliation(event.occupant.bare_jid); + local affiliation = event.room:get_affiliation(occupant.bare_jid); if affiliation and affiliation ~= "none" then -- Skip check for affiliated users return;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_oidc_userinfo_vcard4/README.md Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,19 @@ +--- +summary: OIDC UserInfo profile details from vcard4 +labels: +- Stage-Alpha +rockspec: + dependencies: + - mod_http_oauth2 +--- + +This module extracts profile details from the user's [vcard4][XEP-0292] +and provides them in the [UserInfo] endpoint of [mod_http_oauth2] to +clients the user grants authorization. + +Whether this is really needed is unclear at this point. When logging in +with an XMPP client, it could fetch the actual vcard4 to retrieve these +details, so the UserInfo details would probably primarily be useful to +other OAuth 2 and OIDC clients. + +[UserInfo]: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,81 @@ +-- Provide OpenID UserInfo data to mod_http_oauth2 +-- Alternatively, separate module for the whole HTTP endpoint? +-- +local nodeprep = require "util.encodings".stringprep.nodeprep; + +local mod_pep = module:depends "pep"; + +local gender_map = { M = "male"; F = "female"; O = "other"; N = "nnot applicable"; U = "unknown" } + +module:hook("token/userinfo", function(event) + local pep_service = mod_pep.get_pep_service(event.username); + + local vcard4 = select(3, pep_service:get_last_item("urn:xmpp:vcard4", true)); + + local userinfo = event.userinfo; + vcard4 = vcard4 and vcard4:get_child("vcard", "urn:ietf:params:xml:ns:vcard-4.0"); + if vcard4 and event.claims:contains("profile") then + userinfo.name = vcard4:find("fn/text#"); + userinfo.family_name = vcard4:find("n/surname#"); + userinfo.given_name = vcard4:find("n/given#"); + userinfo.middle_name = vcard4:find("n/additional#"); + + userinfo.nickname = vcard4:find("nickname/text#"); + if not userinfo.nickname then + local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", true); + if ok and nick_item then + userinfo.nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick"); + end + end + + userinfo.preferred_username = event.username; + + -- profile -- page? not their website + -- picture -- mod_http_pep_avatar? + userinfo.website = vcard4:find("url/uri#"); + userinfo.birthdate = vcard4:find("bday/date#"); + userinfo.zoneinfo = vcard4:find("tz/text#"); + userinfo.locale = vcard4:find("lang/language-tag#"); + + userinfo.gender = gender_map[vcard4:find("gender/sex#")] or vcard4:find("gender/text#"); + + -- updated_at -- we don't keep a vcard change timestamp? + end + + if not userinfo.nickname and event.claims:contains("profile") then + local ok, _, nick_item = pep_service:get_last_item("http://jabber.org/protocol/nick", true); + if ok and nick_item then + userinfo.nickname = nick_item:get_child_text("nick", "http://jabber.org/protocol/nick"); + end + end + + if vcard4 and event.claims:contains("email") then + userinfo.email = vcard4:find("email/text#") + if userinfo.email then + userinfo.email_verified = false; + end + end + + if vcard4 and event.claims:contains("address") then + local adr = vcard4:get_child("adr"); + if adr then + userinfo.address = { + formatted = nil; + street_address = adr:get_child_text("street"); + locality = adr:get_child_text("locality"); + region = adr:get_child_text("region"); + postal_code = adr:get_child_text("code"); + country = adr:get_child_text("country"); + } + end + end + + if vcard4 and event.claims:contains("phone") then + userinfo.phone = vcard4:find("tel/text#") + if userinfo.phone then + userinfo.phone_number_verified = false; + end + end + + +end, 10);
--- a/mod_prometheus/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_prometheus/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -15,10 +15,12 @@ [prometheusconf]: https://prometheus.io/docs/instrumenting/exposition_formats/ +::: {.alert .alert-info} **Note:** For use with Prosody trunk (0.12) we recommend the bundled [mod_http_openmetrics](https://prosody.im/doc/modules/mod_http_openmetrics) instead. This module (mod_prometheus) will continue to be available in the community repository for use with older Prosody versions. +::: Configuration =============
--- a/mod_pubsub_feeds/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_pubsub_feeds/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -3,7 +3,7 @@ rockspec: build: modules: - pubsub_feeds.feeds: feeds.lib.lua + mod_pubsub_feeds.feeds: feeds.lib.lua --- # Introduction
--- a/mod_register_apps/mod_register_apps.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_register_apps/mod_register_apps.lua Sat May 06 19:40:23 2023 -0500 @@ -1,7 +1,7 @@ -- luacheck: ignore 631 module:depends("http"); local http_files -if prosody.process_type == "prosody" then +if prosody.process_type then -- Prosody >= 0.12 http_files = require "net.http.files"; else
--- a/mod_remote_roster/mod_remote_roster.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_remote_roster/mod_remote_roster.lua Sat May 06 19:40:23 2023 -0500 @@ -19,6 +19,7 @@ local rm_roster_push = require "core.rostermanager".roster_push; local user_exists = require "core.usermanager".user_exists; local add_task = require "util.timer".add_task; +local new_id = require "util.id".short; module:hook("iq-get/bare/jabber:iq:roster:query", function(event) local origin, stanza = event.origin, event.stanza; @@ -138,7 +139,7 @@ if roster then local item = roster[jid]; local contact_node, contact_host = jid_split(jid); - local stanza = st.iq({ type="set", from=node.."@"..host, to=contact_host }):query("jabber:iq:roster"); + local stanza = st.iq({ type="set", from=node.."@"..host, to=contact_host, id = new_id() }):query("jabber:iq:roster"); if item then stanza:tag("item", { jid = jid, subscription = item.subscription, name = item.name, ask = item.ask }); for group in pairs(item.groups) do
--- a/mod_rest/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_rest/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -38,9 +38,9 @@ ## OAuth2 -[mod_http_oauth2] can be used to grant bearer tokens which are -accepted by mod_rest. Tokens can be passed to `curl` like -`--oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K` as in some examples below. +[mod_http_oauth2] can be used to grant bearer tokens which are accepted +by mod_rest. Tokens can be passed to `curl` like `--oauth2-bearer +dmVyeSBzZWNyZXQgdG9rZW4K` instead of using `--user`. ## Sending stanzas @@ -62,7 +62,7 @@ ``` {.sh} curl https://prosody.example:5281/rest \ - --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \ + --user username \ -H 'Content-Type: application/json' \ --data-binary '{ "body" : "Hello!", @@ -81,7 +81,7 @@ ``` curl https://prosody.example:5281/rest/message/chat/john@example.com \ - --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \ + --user username \ -H 'Content-Type: text/plain' \ --data-binary 'Hello John!' ``` @@ -93,7 +93,7 @@ ``` {.sh} curl https://prosody.example:5281/rest \ - --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \ + --user username \ -H 'Content-Type: application/xmpp+xml' \ --data-binary '<iq type="get" to="example.net"> <ping xmlns="urn:xmpp:ping"/> @@ -111,7 +111,7 @@ ``` curl https://prosody.example:5281/rest/version/example.com \ - --oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K \ + --user username \ -H 'Accept: application/json' ```
--- a/mod_rest/apidemo.lib.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_rest/apidemo.lib.lua Sat May 06 19:40:23 2023 -0500 @@ -27,11 +27,13 @@ do local f = module:load_resource("res/openapi.yaml"); + local openapi = f:read("*a"); + openapi = openapi:gsub("https://example%.com/oauth2", module:http_url("oauth2")); _M.schema = { headers = { content_type = "text/x-yaml"; }; - body = f:read("*a"); + body = openapi; } f:close(); end
--- a/mod_rest/example/prosody_oauth.py Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_rest/example/prosody_oauth.py Sat May 06 19:40:23 2023 -0500 @@ -1,27 +1,45 @@ -from oauthlib.oauth2 import LegacyApplicationClient from requests_oauthlib import OAuth2Session - - -class ProsodyRestClient(LegacyApplicationClient): - pass +import requests class ProsodyRestSession(OAuth2Session): - def __init__(self, base_url=None, token_url=None, rest_url=None, *args, **kwargs): - if base_url and not token_url: - token_url = base_url + "/oauth2/token" - if base_url and not rest_url: - rest_url = base_url + "/rest" - self._prosody_rest_url = rest_url - self._prosody_token_url = token_url + def __init__( + self, base_url, client_name, client_uri, redirect_uri, *args, **kwargs + ): + self.base_url = base_url + discovery_url = base_url + "/.well-known/oauth-authorization-server" - super().__init__(client=ProsodyRestClient(*args, **kwargs)) + meta = requests.get(discovery_url).json() + reg = requests.post( + meta["registration_endpoint"], + json={ + "client_name": client_name, + "client_uri": client_uri, + "redirect_uris": [redirect_uri], + }, + ).json() + + super().__init__(client_id=reg["client_id"], *args, **kwargs) + + self.meta = meta + self.client_secret = reg["client_secret"] + self.client_id = reg["client_id"] + + def authorization_url(self, *args, **kwargs): + return super().authorization_url( + self.meta["authorization_endpoint"], *args, **kwargs + ) def fetch_token(self, *args, **kwargs): - return super().fetch_token(token_url=self._prosody_token_url, *args, **kwargs) + return super().fetch_token( + token_url=self.meta["token_endpoint"], + client_secret=self.client_secret, + *args, + **kwargs + ) def xmpp(self, json=None, *args, **kwargs): - return self.post(self._prosody_rest_url, json=json, *args, **kwargs) + return self.post(self.base_url + "/rest", json=json, *args, **kwargs) if __name__ == "__main__": @@ -30,8 +48,16 @@ # from prosody_oauth import ProsodyRestSession from getpass import getpass - p = ProsodyRestSession(base_url=input("Base URL: "), client_id="app") - - p.fetch_token(username=input("XMPP Address: "), password=getpass("Password: ")) + p = ProsodyRestSession( + input("Base URL: "), + "Prosody mod_rest OAuth 2 example", + "https://modules.prosody.im/mod_rest", + "urn:ietf:wg:oauth:2.0:oob", + ) + + print("Open the following URL in a browser and login:") + print(p.authorization_url()[0]) + + p.fetch_token(code=getpass("Paste Authorization code: ")) print(p.xmpp(json={"disco": True, "to": "jabber.org"}).json())
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_rest/example/rest.sh Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,130 @@ +#!/bin/bash -eu + +# Copyright (c) Kim Alvefur +# This file is MIT/X11 licensed. + +# Dependencies: +# - https://httpie.io/ +# - https://github.com/stedolan/jq +# - some sort of XDG 'open' command + +# Settings +HOST="" +DOMAIN="" + +AUTH_METHOD="session-read-only" +AUTH_ID="rest" + +if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" ]; then + # Config file can contain the above settings + source "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" +fi + +if [[ $# == 0 ]]; then + echo "${0##*/} [-h HOST] [-u USER|--login] [/path] kind=(message|presence|iq) ...." + # Last arguments are handed to HTTPie, so refer to its docs for further details + exit 0 +fi + +if [[ "$1" == "-h" ]]; then + HOST="$2" + shift 2 +elif [ -z "${HOST:-}" ]; then + HOST="$(hostname)" +fi + +if [[ "$HOST" != *.* ]]; then + # Assumes subdomain of your DOMAIN + if [ -z "${DOMAIN:-}" ]; then + DOMAIN="$(hostname -d)" + fi + if [[ "$HOST" == *:* ]]; then + HOST="${HOST%:*}.$DOMAIN:${HOST#*:}" + else + HOST="$HOST.$DOMAIN" + fi +fi + +if [[ "$1" == "-u" ]]; then + # -u username + AUTH_METHOD="auth" + AUTH_ID="$2" + shift 2 +elif [[ "$1" == "-rw" ]]; then + # To e.g. save Accept headers to the session + AUTH_METHOD="session" + shift 1 +fi + +if [[ "$1" == "--login" ]]; then + shift 1 + + # Check cache for OAuth client + if [ -f "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" ]; then + source "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" + fi + + OAUTH_META="$(http --check-status --json "https://$HOST/.well-known/oauth-authorization-server" Accept:application/json)" + AUTHORIZATION_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.authorization_endpoint')" + TOKEN_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.token_endpoint')" + + if [ -z "${OAUTH_CLIENT_INFO:-}" ]; then + # Register a new OAuth client + REGISTRATION_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.registration_endpoint')" + OAUTH_CLIENT_INFO="$(http --check-status "$REGISTRATION_ENDPOINT" Content-Type:application/json Accept:application/json client_name=rest.sh client_uri="https://modules.prosody.im/mod_rest" application_type=native software_id=0bdb0eb9-18e8-43af-a7f6-bd26613374c0 redirect_uris:='["urn:ietf:wg:oauth:2.0:oob"]')" + mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}/rest/" + typeset -p OAUTH_CLIENT_INFO >> "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" + fi + + CLIENT_ID="$(echo "$OAUTH_CLIENT_INFO" | jq -e -r '.client_id')" + CLIENT_SECRET="$(echo "$OAUTH_CLIENT_INFO" | jq -e -r '.client_secret')" + + if [ -n "${REFRESH_TOKEN:-}" ]; then + TOKEN_RESPONSE="$(http --check-status --form "$TOKEN_ENDPOINT" 'grant_type=refresh_token' "client_id=$CLIENT_ID" "client_secret=$CLIENT_SECRET" "refresh_token=$REFRESH_TOKEN")" + ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')" + if [ "$ACCESS_TOKEN" == "null" ]; then + ACCESS_TOKEN="" + fi + fi + + if [ -z "${ACCESS_TOKEN:-}" ]; then + CODE_CHALLENGE="$(head -c 33 /dev/urandom | base64 | tr /+ _-)" + open "$AUTHORIZATION_ENDPOINT?response_type=code&client_id=$CLIENT_ID&code_challenge=$CODE_CHALLENGE&scope=openid+prosody:user" + read -p "Paste authorization code: " -s -r AUTHORIZATION_CODE + + TOKEN_RESPONSE="$(http --check-status --form "$TOKEN_ENDPOINT" 'grant_type=authorization_code' "client_id=$CLIENT_ID" "client_secret=$CLIENT_SECRET" "code=$AUTHORIZATION_CODE" code_verifier="$CODE_CHALLENGE")" + ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -e -r '.access_token')" + REFRESH_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')" + + if [ "$REFRESH_TOKEN" != "null" ]; then + # FIXME Better type check would be nice, but nobody should ever have the + # string "null" as a legitimate refresh token... + typeset -p REFRESH_TOKEN >> "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" + fi + + if [ -n "${COLORTERM:-}" ]; then + echo -ne '\e[1K\e[G' + else + echo + fi + fi + + USERINFO_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.userinfo_endpoint')" + http --check-status -b --session rest "$USERINFO_ENDPOINT" "Authorization:Bearer $ACCESS_TOKEN" Accept:application/json >&2 + AUTH_METHOD="session-read-only" + AUTH_ID="rest" +fi + +if [[ $# == 0 ]]; then + # Just login? + exit 0 +fi + +# For e.g /disco/example.com and such GET queries +GET_PATH="" +if [[ "$1" == /* ]]; then + GET_PATH="$1" + shift 1 +fi + +http --check-status -p b "--$AUTH_METHOD" "$AUTH_ID" "https://$HOST/rest$GET_PATH" "$@"
--- a/mod_rest/mod_rest.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_rest/mod_rest.lua Sat May 06 19:40:23 2023 -0500 @@ -390,7 +390,10 @@ module:hook(archive_event_name, archive_handler, 1); end - local p = module:send_iq(payload, origin):next( + local iq_timeout = tonumber(request.headers.prosody_rest_timeout) or module:get_option_number("rest_iq_timeout", 60*2); + iq_timeout = math.min(iq_timeout, module:get_option_number("rest_iq_max_timeout", 300)); + + local p = module:send_iq(payload, origin, iq_timeout):next( function (result) module:log("debug", "Sending[rest]: %s", result.stanza:top_tag()); response.headers.content_type = send_type;
--- a/mod_rest/res/openapi.yaml Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_rest/res/openapi.yaml Sat May 06 19:40:23 2023 -0500 @@ -21,6 +21,7 @@ security: - basic: [] - token: [] + - oauth2: [] requestBody: $ref: '#/components/requestBodies/common' responses: @@ -37,6 +38,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/kind' - $ref: '#/components/parameters/type' @@ -55,6 +57,7 @@ security: - basic: [] - token: [] + - oauth2: [] requestBody: $ref: '#/components/requestBodies/common' responses: @@ -69,6 +72,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -91,6 +95,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -112,6 +117,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -126,6 +132,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -140,6 +147,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' - name: type @@ -160,6 +168,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' - name: with @@ -211,6 +220,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -225,6 +235,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -239,6 +250,7 @@ security: - basic: [] - token: [] + - oauth2: [] parameters: - $ref: '#/components/parameters/to' responses: @@ -1411,6 +1423,18 @@ description: Use JID as username. scheme: Basic type: http + oauth2: + description: Needs mod_http_oauth2 + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth2/authorize + tokenUrl: https://example.com/oauth2/token + scopes: + prosody:restricted: Restricted account + prosody:user: Regular user privileges + prosody:admin: Administrator privileges + prosody:operator: Server operator privileges requestBodies: common:
--- a/mod_s2s_blacklist/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_s2s_blacklist/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -2,6 +2,11 @@ level. ``` {.lua} +modules_enabled = { + -- other modules -- + "s2s_blacklist", + +} s2s_blacklist = { "proxy.eu.jabber.org", }
--- a/mod_s2s_whitelist/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_s2s_whitelist/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -2,6 +2,11 @@ whitelist. ``` {.lua} +modules_enabled = { + -- other modules -- + "s2s_whitelist", + +} s2s_whitelist = { "example.org", }
--- a/mod_sasl2/mod_sasl2.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_sasl2/mod_sasl2.lua Sat May 06 19:40:23 2023 -0500 @@ -121,6 +121,7 @@ end module:hook("sasl2/c2s/failure", function (event) + module:fire_event("authentication-failure", event); local session, condition, text = event.session, event.message, event.error_text; local failure = st.stanza("failure", { xmlns = xmlns_sasl2 }) :tag(condition, { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl" }):up(); @@ -165,6 +166,7 @@ end, -1000); module:hook("sasl2/c2s/success", function (event) + module:fire_event("authentication-success", event); local session = event.session; local features = st.stanza("stream:features"); module:fire_event("stream-features", { origin = session, features = features }); @@ -206,6 +208,10 @@ local user_agent = auth:get_child("user-agent"); if user_agent then session.client_id = user_agent.attr.id; + sasl_handler.user_agent = { + software = user_agent:get_child_text("software"); + device = user_agent:get_child_text("device"); + }; end local initial = auth:get_child_text("initial-response"); return process_cdata(session, initial);
--- a/mod_sasl2_bind2/mod_sasl2_bind2.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_sasl2_bind2/mod_sasl2_bind2.lua Sat May 06 19:40:23 2023 -0500 @@ -24,13 +24,15 @@ -- Helper to actually bind a resource to a session local function do_bind(session, bind_request) - local resource; + local resource = session.sasl_handler.resource; - local client_name_tag = bind_request:get_child_text("tag"); - if client_name_tag then - local client_id = session.client_id; - local tag_suffix = client_id and base64.encode(sha1(client_id):sub(1, 9)) or id.medium(); - resource = ("%s~%s"):format(client_name_tag, tag_suffix); + if not resource then + local client_name_tag = bind_request:get_child_text("tag"); + if client_name_tag then + local client_id = session.client_id; + local tag_suffix = client_id and base64.encode(sha1(client_id):sub(1, 9)) or id.medium(); + resource = ("%s~%s"):format(client_name_tag, tag_suffix); + end end local success, err_type, err, err_msg = sm_bind_resource(session, resource);
--- a/mod_sasl2_fast/mod_sasl2_fast.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_sasl2_fast/mod_sasl2_fast.lua Sat May 06 19:40:23 2023 -0500 @@ -1,3 +1,5 @@ +local usermanager = require "core.usermanager"; + local sasl = require "util.sasl"; local dt = require "util.datetime"; local id = require "util.id"; @@ -38,6 +40,8 @@ local function new_token_tester(hmac_f) return function (mechanism, username, client_id, token_hash, cb_data, invalidate) + local account_info = usermanager.get_account_info(username, module.host); + local last_password_change = account_info and account_info.password_updated; local tried_current_token = false; local key = hash.sha256(client_id, true).."-new"; local token; @@ -52,12 +56,18 @@ log("debug", "Token found, but it has expired (%ds ago). Cleaning up...", current_time - token.expires_at); token_store:set(username, key, nil); return nil, "credentials-expired"; + elseif last_password_change and token.issued_at < last_password_change then + log("debug", "Token found, but issued prior to password change (%ds ago). Cleaning up...", + current_time - last_password_change + ); + token_store:set(username, key, nil); + return nil, "credentials-expired"; end if not tried_current_token and not invalidate then -- The new token is becoming the current token token_store:set_keys(username, { [key] = token_store.remove; - [key:sub(1, -4).."-cur"] = token; + [key:sub(1, -5).."-cur"] = token; }); end local rotation_needed; @@ -74,7 +84,7 @@ log("debug", "Trying next token..."); -- Try again with the current token instead tried_current_token = true; - key = key:sub(1, -4).."-cur"; + key = key:sub(1, -5).."-cur"; else log("debug", "No matching %s token found for %s/%s", mechanism, username, key); return nil; @@ -102,6 +112,7 @@ end local sasl_handler = get_sasl_handler(username); if not sasl_handler then return; end + sasl_handler.fast_auth = true; -- For informational purposes -- Copy channel binding info from primary SASL handler sasl_handler.profile.cb = session.sasl_handler.profile.cb; sasl_handler.userdata = session.sasl_handler.userdata; @@ -217,3 +228,27 @@ register_ht_mechanism("HT-SHA-256-UNIQ", "ht_sha_256", "tls-unique"); register_ht_mechanism("HT-SHA-256-ENDP", "ht_sha_256", "tls-server-end-point"); register_ht_mechanism("HT-SHA-256-EXPR", "ht_sha_256", "tls-exporter"); + +-- Public API + +--luacheck: ignore 131 +function is_client_fast(username, client_id, last_password_change) + local client_id_hash = hash.sha256(client_id, true); + local curr_time = now(); + local cur = token_store:get(username, client_id_hash.."-cur"); + if cur and cur.expires_at >= curr_time and (not last_password_change or last_password_change < cur.issued_at) then + return true; + end + local new = token_store:get(username, client_id_hash.."-new"); + if new and new.expires_at >= curr_time and (not last_password_change or last_password_change < new.issued_at) then + return true; + end + return false; +end + +function revoke_fast_tokens(username, client_id) + local client_id_hash = hash.sha256(client_id, true); + local cur_ok = token_store:set(username, client_id_hash.."-cur", nil); + local new_ok = token_store:set(username, client_id_hash.."-new", nil); + return cur_ok and new_ok; +end
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_service_outage_status/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,19 @@ +This module allows advertising a machine-readable document were outages, +planned or otherwise, may be reported. + +See [XEP-0455: Service Outage Status] for further details, including +the format of the outage status document. + +```lua +modules_enabled = { + -- other modules + "service_outage_status", +} + +outage_status_urls = { + "https://uptime.example.net/status.json", +} +``` + +The outage status document should be hosted on a separate server to +ensure availability even if the XMPP server is unreachable.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_service_outage_status/mod_service_outage_status.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,9 @@ +local dataforms = require "util.dataforms"; + +local form_layout = dataforms.new({ + { type = "hidden"; var = "FORM_TYPE"; value = "urn:xmpp:sos:0" }; + { type = "list-multi"; name = "addrs"; var = "external-status-addresses" }; +}); + +local addresses = module:get_option_array("outage_status_urls"); +module:add_extension(form_layout:form({ addrs = addresses }, "result"));
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_spam_report_forwarder/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,48 @@ +--- +labels: +- 'Stage-Beta' +summary: 'Forward spam/abuse reports to a JID' +--- + +This module forwards spam/abuse reports (e.g. those submitted by users via +XEP-0377 via mod_spam_reporting) to one or more JIDs. + +## Configuration + +Install and enable the module the same as any other. + +There is a single option, `spam_report_destinations` which accepts a list of +JIDs to send reports to. + +For example: + +```lua +modules_enabled = { + --- + "spam_reporting"; + "spam_report_forwarder"; + --- +} + +spam_report_destinations = { "antispam.example.com" } +``` + +## Protocol + +This section is intended for developers. + +XEP-0377 assumes the report is embedded within another protocol such as +XEP-0191, and doesn't specify a format for communicating "standalone" reports. +This module transmits them inside a `<message>` stanza, and adds a `<jid/>` +element (borrowed from XEP-0268): + +```xml +<message from="prosody.example" to="destination.example"> + <report xmlns="urn:xmpp:reporting:1" reason="urn:xmpp:reporting:spam"> + <jid xmlns="urn:xmpp:jid:0">spammer@bad.example</jid> + <text> + Never came trouble to my house like this. + </text> + </report> +</message> +```
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_spam_report_forwarder/mod_spam_report_forwarder.lua Sat May 06 19:40:23 2023 -0500 @@ -0,0 +1,21 @@ +local st = require "util.stanza"; + +local destinations = module:get_option_set("spam_report_destinations", {}); + +function forward_report(event) + local report = st.clone(event.report); + report:text_tag("jid", event.jid, { xmlns = "urn:xmpp:jid:0" }); + + local message = st.message({ from = module.host }) + :add_child(report); + + for destination in destinations do + local m = st.clone(message); + m.attr.to = destination; + module:send(m); + end +end + +module:hook("spam_reporting/abuse-report", forward_report, -1); +module:hook("spam_reporting/spam-report", forward_report, -1); +module:hook("spam_reporting/unknown-report", forward_report, -1);
--- a/mod_strict_https/README.markdown Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_strict_https/README.markdown Sat May 06 19:40:23 2023 -0500 @@ -1,33 +1,37 @@ --- -labels: summary: HTTP Strict Transport Security -... +--- -Introduction -============ +# Introduction -This module implements [HTTP Strict Transport -Security](https://tools.ietf.org/html/rfc6797) and responds to all -non-HTTPS requests with a `301 Moved Permanently` redirect to the HTTPS -equivalent of the path. +This module implements [RFC 6797: HTTP Strict Transport Security] and +responds to all non-HTTPS requests with a `301 Moved Permanently` +redirect to the HTTPS equivalent of the path. -Configuration -============= +# Configuration Add the module to the `modules_enabled` list and optionally configure the specific header sent. - modules_enabled = { - ... - "strict_https"; - } - hsts_header = "max-age=31556952" +``` lua +modules_enabled = { + ... + "strict_https"; +} +hsts_header = "max-age=31556952" +``` + +If the redirect from `http://` to `https://` causes trouble with +internal use of HTTP APIs it can be disabled: -Compatibility -============= +``` lua +hsts_redirect = false +``` + +# Compatibility - ------- -------------- - trunk Works - 0.9 Works - 0.8 Doesn't work - ------- -------------- + ------- ------------- + trunk Should work + 0.12 Should work + 0.11 Should work + ------- -------------
--- a/mod_strict_https/mod_strict_https.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_strict_https/mod_strict_https.lua Sat May 06 19:40:23 2023 -0500 @@ -1,44 +1,23 @@ -- HTTP Strict Transport Security --- https://tools.ietf.org/html/rfc6797 +-- https://www.rfc-editor.org/info/rfc6797 module:set_global(); local http_server = require "net.http.server"; local hsts_header = module:get_option_string("hsts_header", "max-age=31556952"); -- This means "Don't even try to access without HTTPS for a year" - -local _old_send_response; -local _old_fire_event; - -local modules = {}; +local redirect = module:get_option_boolean("hsts_redirect", true); -function module.load() - _old_send_response = http_server.send_response; - function http_server.send_response(response, body) - response.headers.strict_transport_security = hsts_header; - return _old_send_response(response, body); - end - - _old_fire_event = http_server._events.fire_event; - function http_server._events.fire_event(event, payload) - local request = payload.request; - local host = event:match("^[A-Z]+ ([^/]+)"); - local module = modules[host]; - if module and not request.secure then - payload.response.headers.location = module:http_url(request.path); +module:wrap_object_event(http_server._events, false, function(handlers, event_name, event_data) + local request, response = event_data.request, event_data.response; + if request and response then + if request.secure then + response.headers.strict_transport_security = hsts_header; + elseif redirect then + -- This won't get the port number right + response.headers.location = "https://" .. request.host .. request.path .. (request.query and "?" .. request.query or ""); return 301; end - return _old_fire_event(event, payload); end -end -function module.unload() - http_server.send_response = _old_send_response; - http_server._events.fire_event = _old_fire_event; -end -function module.add_host(module) - local http_host = module:get_option_string("http_host", module.host); - modules[http_host] = module; - function module.unload() - modules[http_host] = nil; - end -end + return handlers(event_name, event_data); +end);
--- a/mod_vcard_muc/mod_vcard_muc.lua Wed Feb 22 22:47:45 2023 -0500 +++ b/mod_vcard_muc/mod_vcard_muc.lua Sat May 06 19:40:23 2023 -0500 @@ -76,7 +76,7 @@ session.send(st.error_reply(stanza, "cancel", "item-not-found")); end else - if from_affiliation == "owner" then + if from_affiliation == "owner" or (module.may and module:may("muc:automatic-ownership", from)) then if vcards:set(room_node, st.preserialize(stanza.tags[1])) then session.send(st.reply(stanza):tag("vCard", { xmlns = "vcard-temp" })); broadcast_presence(room, nil)