# HG changeset patch # User Trần H. Trung # Date 1690371941 -25200 # Node ID 89b6d0e09b86517ab8d1f7a49444b6e7ebbb19b8 # Parent f7410850941f7573de4558bbb5e1b1d9be85e253# Parent 81042c2a235a508ad330446b08e69ea8edb1a880 Merge upstream. diff -r f7410850941f -r 89b6d0e09b86 .editorconfig --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/.editorconfig Wed Jul 26 18:45:41 2023 +0700 @@ -0,0 +1,34 @@ +# https://editorconfig.org/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 150 + +[*.json] +# json_pp -json_opt canonical,pretty +indent_size = 3 +indent_style = space + +[{README,COPYING,CONTRIBUTING,TODO}{,.markdown,.md}] +# pandoc -s -t markdown +indent_size = 4 +indent_style = space + +[*.py] +indent_size = 4 +indent_style = space + +[*.{xml,svg}] +# xmllint --nsclean --encode UTF-8 --noent --format - +indent_size = 2 +indent_style = space + +[*.yaml] +indent_size = 2 +indent_style = space diff -r f7410850941f -r 89b6d0e09b86 mod_client_management/README.md --- a/mod_client_management/README.md Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_client_management/README.md Wed Jul 26 18:45:41 2023 +0700 @@ -35,6 +35,12 @@ prosodyctl shell user clients user@example.com ``` +To revoke access from particular client: + +```shell +prosodyctl shell user revoke_client user@example.com grant/xxxxx +``` + ## Compatibility Requires Prosody trunk (as of 2023-03-29). Not compatible with Prosody 0.12 diff -r f7410850941f -r 89b6d0e09b86 mod_client_management/mod_client_management.lua --- a/mod_client_management/mod_client_management.lua Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_client_management/mod_client_management.lua Wed Jul 26 18:45:41 2023 +0700 @@ -278,6 +278,17 @@ return active_clients; end +local function user_agent_tostring(user_agent) + if user_agent then + if user_agent.software then + if user_agent.software_version then + return user_agent.software .. "/" .. user_agent.software_version; + end + return user_agent.software; + end + end +end + function revoke_client_access(username, client_selector) if client_selector then local c_type, c_id = client_selector:match("^(%w+)/(.+)$"); @@ -311,6 +322,13 @@ local ok = tokenauth.revoke_grant(username, c_id); if not ok then return nil, "internal-server-error"; end return true; + elseif c_type == "software" then + local active_clients = get_active_clients(username); + for _, client in ipairs(active_clients) do + if client.user_agent and client.user_agent.software == c_id or user_agent_tostring(client.user_agent) == c_id then + return revoke_client_access(username, client.id); + end + end end end @@ -420,13 +438,12 @@ end local colspec = { + { title = "ID"; key = "id"; width = "1p" }; { title = "Software"; key = "user_agent"; width = "1p"; - mapper = function(user_agent) - return user_agent and user_agent.software; - end; + mapper = user_agent_tostring; }; { title = "Last seen"; @@ -434,7 +451,7 @@ 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); + return last_seen and os.date(os.difftime(os.time(), last_seen) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen); end; }; { @@ -458,4 +475,18 @@ print(string.rep("-", self.session.width)); return true, ("%d clients"):format(#clients); end + + function console_env.user:revoke_client(user_jid, selector) -- luacheck: ignore 212/self + 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 revoked, err = revocation_errors.coerce(mod.revoke_client_access(username, selector)); + if not revoked then + return false, err.text or err; + end + return true, "Client access revoked"; + end end); diff -r f7410850941f -r 89b6d0e09b86 mod_default_bookmarks/README.markdown --- a/mod_default_bookmarks/README.markdown Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_default_bookmarks/README.markdown Wed Jul 26 18:45:41 2023 +0700 @@ -31,13 +31,15 @@ Then add a list of the default rooms you want: - default_bookmarks = { - { jid = "room@conference.example.com", name = "The Room" }; - -- Specifying a password is supported: - { jid = "secret-room@conference.example.com", name = "A Secret Room", password = "secret" }; - -- You can also use this compact syntax: - "yetanother@conference.example.com"; -- this will get "yetanother" as name - }; +``` lua +default_bookmarks = { + { jid = "room@conference.example.com"; name = "The Room"; autojoin = true }; + -- Specifying a password is supported: + { jid = "secret-room@conference.example.com"; name = "A Secret Room"; password = "secret"; autojoin = true }; + -- You can also use this compact syntax: + "yetanother@conference.example.com"; -- this will get "yetanother" as name +}; +``` Compatibility ------------- diff -r f7410850941f -r 89b6d0e09b86 mod_http_muc_log/mod_http_muc_log.lua --- a/mod_http_muc_log/mod_http_muc_log.lua Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_http_muc_log/mod_http_muc_log.lua Wed Jul 26 18:45:41 2023 +0700 @@ -294,10 +294,16 @@ local function logs_page(event, path) local request, response = event.request, event.response; - local room, date = path:match("^([^/]+)/([^/]*)/?$"); - if not room then + -- /room --> 303 /room/ + -- /room/ --> calendar view + -- /room/yyyy-mm-dd --> logs view + -- /room/yyyy-mm-dd/* --> 404 + local room, date = path:match("^([^/]+)/([^/]*)$"); + if not room and not path:find"/" then response.headers.location = url.build({ path = path .. "/" }); return 303; + elseif not room then + return 404; end room = nodeprep(room); if not room then diff -r f7410850941f -r 89b6d0e09b86 mod_http_oauth2/README.markdown --- a/mod_http_oauth2/README.markdown Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_http_oauth2/README.markdown Wed Jul 26 18:45:41 2023 +0700 @@ -51,6 +51,7 @@ - [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html) - [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) +- [RFC 8628: OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628) - [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html) - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) - [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) (_partial, e.g. missing JWKS_) @@ -99,8 +100,8 @@ The defaults are recommended. ```lua -oauth2_access_token_ttl = 86400 -- 24 hours -oauth2_refresh_token_ttl = nil -- unlimited unless revoked by the user +oauth2_access_token_ttl = 3600 -- one hour +oauth2_refresh_token_ttl = 604800 -- one week ``` ### Dynamic client registration @@ -211,7 +212,8 @@ ### Supported flows - Authorization Code grant, optionally with Proof Key for Code Exchange -- Resource owner password grant +- Device Authorization Grant +- Resource owner password grant *(likely to be phased out in the future)* - Implicit flow *(disabled by default)* - Refresh Token grants @@ -222,6 +224,7 @@ -- These examples reflect the defaults allowed_oauth2_grant_types = { "authorization_code"; -- authorization code grant + "device_code"; "password"; -- resource owner password grant } diff -r f7410850941f -r 89b6d0e09b86 mod_http_oauth2/html/device.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_http_oauth2/html/device.html Wed Jul 26 18:45:41 2023 +0700 @@ -0,0 +1,30 @@ + + + + + +{site_name} - Authorize{client&d} Device + + + +
+

{site_name}

+
+ Device Authorization + {error&
+

{error.text}

+
} +{client& +

Authorization completed. You can go back to + {client.client_name}.

} +{client~ +

Enter the code to continue.

+
+ + +
} +
+
+ + + diff -r f7410850941f -r 89b6d0e09b86 mod_http_oauth2/mod_http_oauth2.lua --- a/mod_http_oauth2/mod_http_oauth2.lua Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_http_oauth2/mod_http_oauth2.lua Wed Jul 26 18:45:41 2023 +0700 @@ -68,6 +68,7 @@ login = read_file(template_path, "login.html", true); consent = read_file(template_path, "consent.html", true); oob = read_file(template_path, "oob.html", true); + device = read_file(template_path, "device.html", true); error = read_file(template_path, "error.html", true); css = read_file(template_path, "style.css"); js = read_file(template_path, "script.js"); @@ -100,8 +101,8 @@ local tokens = module:depends("tokenauth"); -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); +local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 3600); +local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", 604800); -- Used to derive client_secret from client_id, set to enable stateless dynamic registration. local registration_key = module:get_option_string("oauth2_registration_key"); @@ -120,6 +121,8 @@ sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options); end +local new_device_token, verify_device_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 }); + -- verify and prepare client structure local function check_client(client_id) if not verify_client then @@ -213,9 +216,8 @@ return code_expired(code) end); --- 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() +-- Clear out unredeemed codes so they don't linger in memory. +module:daily("Clear expired authorization codes", function() local k, code = codes:tail(); while code and code_expired(code) do codes:set(k, nil); @@ -231,6 +233,7 @@ -- code to the user for them to copy-paste into the client, which can then -- continue as if it received it via redirect. local oob_uri = "urn:ietf:wg:oauth:2.0:oob"; +local device_uri = "urn:ietf:params:oauth:grant-type:device_code"; local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" }); @@ -266,18 +269,23 @@ 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; end + if refresh_token_info then + -- out with the old refresh tokens + local ok, err = tokens.revoke_token(refresh_token_info.token); + if not ok then + module:log("error", "Could not revoke refresh token: %s", err); + return 500; + end + end + -- in with the new refresh token + local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, nil, "oauth2-refresh"); + if role == "xmpp" then -- Special scope meaning the users default role. local user_default_role = usermanager.get_user_role(jid.node(token_jid), module.host); @@ -317,6 +325,15 @@ -- When only a single URI is registered, that's the default return client.redirect_uris[1]; end + if query_redirect_uri == device_uri and client.grant_types then + for _, grant_type in ipairs(client.grant_types) do + if grant_type == device_uri then + return query_redirect_uri; + end + end + -- Tried to use device authorization flow without registering it. + return; + 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 @@ -342,6 +359,13 @@ local response_type_handlers = {}; local verifier_transforms = {}; +function grant_type_handlers.implicit() + -- Placeholder to make discovery work correctly. + -- Access tokens are delivered via redirect when using the implict flow, not + -- via the token endpoint, so how did you get here? + return oauth_error("invalid_request"); +end + function grant_type_handlers.password(params) local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)")); local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'")); @@ -371,7 +395,14 @@ end local code = id.medium(); - local ok = codes:set(params.client_id .. "#" .. code, { + if params.redirect_uri == device_uri then + local is_device, device_state = verify_device_token(params.state); + if is_device then + -- reconstruct the device_code + code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code)); + end + end + local ok = codes:set("authorization_code:" .. params.client_id .. "#" .. code, { expires = os.time() + 600; granted_jid = granted_jid; granted_scopes = granted_scopes; @@ -387,6 +418,8 @@ local redirect_uri = get_redirect_uri(client, params.redirect_uri); if redirect_uri == oob_uri then return render_page(templates.oob, { client = client; authorization_code = code }, true); + elseif redirect_uri == device_uri then + return render_page(templates.device, { client = client }, true); elseif not redirect_uri then return oauth_error("invalid_redirect_uri"); end @@ -462,12 +495,12 @@ module:log("debug", "client_secret mismatch"); return oauth_error("invalid_client", "incorrect credentials"); end - local code, err = codes:get(params.client_id .. "#" .. params.code); + local code, err = codes:get("authorization_code:" .. 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 -- TODO if a second attempt *is* made, revoke any tokens issued - codes:set(params.client_id .. "#" .. params.code, nil); + codes:set("authorization_code:" .. 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"); @@ -530,6 +563,34 @@ return json.encode(new_access_token(refresh_token_info.jid, role, new_scopes, client, nil, refresh_token_info)); end +grant_type_handlers[device_uri] = function(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.device_code then return oauth_error("invalid_request", "missing 'device_code'"); end + + local client = check_client(params.client_id); + if not client then + return oauth_error("invalid_client", "incorrect credentials"); + end + + 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 = codes:get("device_code:" .. params.device_code); + if type(code) ~= "table" or code_expired(code) then + return oauth_error("expired_token"); + elseif code.error then + return code.error; + elseif not code.granted_jid then + return oauth_error("authorization_pending"); + end + codes:set("device_code:" .. params.device_code, nil); + + return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token)); +end + -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients function verifier_transforms.plain(code_verifier) @@ -688,7 +749,14 @@ "authorization_code"; "password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used. "refresh_token"; + device_uri; }) +if allowed_grant_type_handlers:contains("device_code") then + -- expand short form because that URI is long + module:log("debug", "Expanding %q to %q in '%s'", "device_code", device_uri, "allowed_oauth2_grant_types"); + allowed_grant_type_handlers:remove("device_code"); + allowed_grant_type_handlers:add(device_uri); +end 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); @@ -727,7 +795,7 @@ event.response.headers.pragma = "no-cache"; local params = strict_formdecode(event.request.body); if not params then - return oauth_error("invalid_request"); + return oauth_error("invalid_request", "Could not parse request body as 'application/x-www-form-urlencoded'"); end if credentials and credentials.type == "basic" then @@ -739,7 +807,7 @@ local grant_type = params.grant_type local grant_handler = grant_type_handlers[grant_type]; if not grant_handler then - return oauth_error("invalid_request"); + return oauth_error("invalid_request", "No such grant type."); end return grant_handler(params); end @@ -820,6 +888,16 @@ return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true); elseif not auth_state.consent then -- Notify client of rejection + if redirect_uri == device_uri then + local is_device, device_state = verify_device_token(params.state); + if is_device then + local device_code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code)); + local code = codes:get("device_code:" .. params.client_id .. "#" .. device_code); + code.error = oauth_error("access_denied"); + code.expires = os.time() + 60; + codes:set("device_code:" .. params.client_id .. "#" .. device_code, code); + end + end return error_response(request, redirect_uri, oauth_error("access_denied")); end -- else auth_state.consent == true @@ -856,6 +934,113 @@ return ret; end +local function handle_device_authorization_request(event) + local request = event.request; + + local credentials = get_request_credentials(request); + + local params = strict_formdecode(request.body); + if not params then + return render_error(oauth_error("invalid_request", "Invalid query parameters")); + end + + if credentials and credentials.type == "basic" then + -- client_secret_basic converted internally to client_secret_post + params.client_id = http.urldecode(credentials.username); + local client_secret = http.urldecode(credentials.password); + + if not verify_client_secret(params.client_id, client_secret) then + module:log("debug", "client_secret mismatch"); + return oauth_error("invalid_client", "incorrect credentials"); + end + else + return 401; + end + + local client = check_client(params.client_id); + + if not client then + return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter")); + end + + if not set.new(client.grant_types):contains(device_uri) then + return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant")); + end + + local requested_scopes = parse_scopes(params.scope or ""); + if client.scope then + local client_scopes = set.new(parse_scopes(client.scope)); + requested_scopes:filter(function(scope) + return client_scopes:contains(scope); + end); + end + + -- TODO better code generator, this one should be easy to type from a + -- screen onto a phone + local user_code = (id.tiny() .. "-" .. id.tiny()):upper(); + local collisions = 0; + while codes:get("authorization_code:" .. device_uri .. "#" .. user_code) do + collisions = collisions + 1; + if collisions > 10 then + return oauth_error("temporarily_unavailable"); + end + user_code = (id.tiny() .. "-" .. id.tiny()):upper(); + end + -- device code should be derivable after consent but not guessable by the user + local device_code = b64url(hashes.hmac_sha256(verification_key, user_code)); + local verification_uri = module:http_url() .. "/device"; + local verification_uri_complete = verification_uri .. "?" .. http.formencode({ user_code = user_code }); + + local dc_ok = codes:set("device_code:" .. params.client_id .. "#" .. device_code, { expires = os.time() + 1200 }); + local uc_ok = codes:set("user_code:" .. user_code, + { user_code = user_code; expires = os.time() + 600; client_id = params.client_id; + scope = requested_scopes:concat(" ") }); + if not dc_ok or not uc_ok then + return oauth_error("temporarily_unavailable"); + end + + return { + headers = { content_type = "application/json"; cache_control = "no-store"; pragma = "no-cache" }; + body = json.encode { + device_code = device_code; + user_code = user_code; + verification_uri = verification_uri; + verification_uri_complete = verification_uri_complete; + expires_in = 600; + interval = 5; + }; + } +end + +local function handle_device_verification_request(event) + local request = event.request; + local params = strict_formdecode(request.url.query); + if not params or not params.user_code then + return render_page(templates.device, { client = false }); + end + + local device_info = codes:get("user_code:" .. params.user_code); + if not device_info or code_expired(device_info) or not codes:set("user_code:" .. params.user_code, nil) then + return render_page(templates.device, { + client = false; + error = oauth_error("expired_token", "Incorrect or expired code"); + }); + end + + return { + status_code = 303; + headers = { + location = module:http_url() .. "/authorize" .. "?" .. http.formencode({ + client_id = device_info.client_id; + redirect_uri = device_uri; + response_type = "code"; + scope = device_info.scope; + state = new_device_token({ user_code = params.user_code }); + }); + }; + } +end + local function handle_revocation_request(event) local request, response = event.request, event.response; response.headers.cache_control = "no-store"; @@ -886,6 +1071,7 @@ end local registration_schema = { + title = "OAuth 2.0 Dynamic Client Registration Protocol"; type = "object"; required = { -- These are shown to users in the template @@ -895,13 +1081,21 @@ "redirect_uris"; }; properties = { - redirect_uris = { type = "array"; minItems = 1; uniqueItems = true; items = { type = "string"; format = "uri" } }; + redirect_uris = { + title = "List of Redirect URIs"; + type = "array"; + minItems = 1; + uniqueItems = true; + items = { title = "Redirect URI"; type = "string"; format = "uri" }; + }; token_endpoint_auth_method = { + title = "Token Endpoint Authentication Method"; type = "string"; enum = { "none"; "client_secret_post"; "client_secret_basic" }; default = "client_secret_basic"; }; grant_types = { + title = "Grant Types"; type = "array"; minItems = 1; uniqueItems = true; @@ -915,27 +1109,87 @@ "refresh_token"; "urn:ietf:params:oauth:grant-type:jwt-bearer"; "urn:ietf:params:oauth:grant-type:saml2-bearer"; + device_uri; }; }; default = { "authorization_code" }; }; - application_type = { type = "string"; enum = { "native"; "web" }; default = "web" }; + application_type = { + title = "Application Type"; + description = "Determines which kinds of redirect URIs the client may register. \z + The value 'web' limits the client to https:// URLs with the same hostname as in 'client_uri' \z + while the value 'native' allows either loopback http:// URLs or application specific URIs."; + type = "string"; + enum = { "native"; "web" }; + default = "web"; + }; response_types = { + title = "Response Types"; type = "array"; minItems = 1; uniqueItems = true; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" }; }; - client_name = { type = "string" }; - client_uri = { type = "string"; format = "uri"; pattern = "^https:" }; - logo_uri = { type = "string"; format = "uri"; pattern = "^https:" }; - scope = { type = "string" }; - contacts = { type = "array"; minItems = 1; items = { type = "string"; format = "email" } }; - tos_uri = { type = "string"; format = "uri"; pattern = "^https:" }; - policy_uri = { type = "string"; format = "uri"; pattern = "^https:" }; - software_id = { type = "string"; format = "uuid" }; - software_version = { type = "string" }; + client_name = { + title = "Client Name"; + description = "Human-readable name of the client, presented to the user in the consent dialog."; + type = "string"; + }; + client_uri = { + title = "Client URL"; + description = "Should be an link to a page with information about the client."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + logo_uri = { + title = "Logo URL"; + description = "URL to the clients logotype (not currently used)."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + scope = { + title = "Scopes"; + description = "Space-separated list of scopes the client promises to restrict itself to."; + type = "string"; + }; + contacts = { + title = "Contact Addresses"; + description = "Addresses, typically email or URLs where the client developers can be contacted."; + type = "array"; + minItems = 1; + items = { type = "string"; format = "email" }; + }; + tos_uri = { + title = "Terms of Service URL"; + description = "Link to Terms of Service for the client, presented to the user in the consent dialog. \z + MUST be a https:// URL with hostname matching that of 'client_uri'."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + policy_uri = { + title = "Privacy Policy URL"; + description = "Link to a Privacy Policy for the client. MUST be a https:// URL with hostname matching that of 'client_uri'."; + type = "string"; + format = "uri"; + pattern = "^https:"; + }; + software_id = { + title = "Software ID"; + description = "Unique identifier for the client software, common for all instances. Typically an UUID."; + type = "string"; + format = "uuid"; + }; + software_version = { + title = "Software Version"; + description = "Version of the client software being registered. \z + E.g. to allow revoking all related tokens in the event of a security incident."; + type = "string"; + example = "2.3.1"; + }; }; } @@ -1069,6 +1323,8 @@ module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled") handle_authorization_request = nil handle_register_request = nil + handle_device_authorization_request = nil + handle_device_verification_request = nil end local function handle_userinfo_request(event) @@ -1130,6 +1386,10 @@ -- Step 1. Create OAuth client ["POST /register"] = handle_register_request; + -- Device flow + ["POST /device"] = handle_device_authorization_request; + ["GET /device"] = handle_device_verification_request; + -- Step 2. User-facing login and consent view ["GET /authorize"] = handle_authorization_request; ["POST /authorize"] = handle_authorization_request; @@ -1203,11 +1463,9 @@ 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" }); + device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device"; 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"; - }); + grant_types_supported = array(it.keys(grant_type_handlers)); 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"); diff -r f7410850941f -r 89b6d0e09b86 mod_muc_block_pm/README.markdown --- a/mod_muc_block_pm/README.markdown Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_muc_block_pm/README.markdown Wed Jul 26 18:45:41 2023 +0700 @@ -1,12 +1,11 @@ --- -summary: Prevent unaffiliated MUC participants from sending PMs +summary: Prevent MUC participants from sending PMs --- # Introduction -This module prevents unaffiliated users from sending private messages in -chat rooms, unless someone with an affiliation (member, admin etc) -messages them first. +This module prevents *participants* from sending private messages to +anyone except *moderators*. # Configuration @@ -23,6 +22,5 @@ Branch State -------- ----------------- - 0.9 Works - 0.10 Should work - 0.11 Should work + 0.11 Will **not** work + 0.12 Should work diff -r f7410850941f -r 89b6d0e09b86 mod_muc_block_pm/mod_muc_block_pm.lua --- a/mod_muc_block_pm/mod_muc_block_pm.lua Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_muc_block_pm/mod_muc_block_pm.lua Wed Jul 26 18:45:41 2023 +0700 @@ -1,29 +1,26 @@ -local bare_jid = require"util.jid".bare; -local st = require"util.stanza"; +local st = require "util.stanza"; + +module:hook("muc-disco#info", function(event) + table.insert(event.form, { name = "muc#roomconfig_allowpm"; value = "moderators" }); +end); --- Support both old and new MUC code -local mod_muc = module:depends"muc"; -local rooms = rawget(mod_muc, "rooms"); -local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or - function (jid) - return rooms[jid]; +module:hook("muc-private-message", function(event) + local stanza, room = event.stanza, event.room; + local from_occupant = room:get_occupant_by_nick(stanza.attr.from); + + if from_occupant and from_occupant.role == "moderator" then + return -- moderators may message anyone end -module:hook("message/full", function(event) - local stanza, origin = event.stanza, event.origin; - if stanza.attr.type == "error" then - return + local to_occupant = room:get_occupant_by_nick(stanza.attr.to) + if to_occupant and to_occupant.role == "moderator" then + return -- messaging moderators is ok end - local to, from = stanza.attr.to, stanza.attr.from; - local room = get_room_from_jid(bare_jid(to)); - local to_occupant = room and room._occupants[to]; - local from_occupant = room and room._occupants[room._jid_nick[from]] - if not ( to_occupant and from_occupant ) then return end - if from_occupant.affiliation then - to_occupant._pm_block_override = true; - elseif not from_occupant._pm_block_override then - origin.send(st.error_reply(stanza, "cancel", "not-authorized", "Private messages are disabled")); - return true; + if to_occupant.bare_jid == from_occupant.bare_jid then + return -- to yourself is okay, used by some clients to sync read state in public channels end + + room:route_to_occupant(from_occupant, st.error_reply(stanza, "cancel", "policy-violation", "Private messages are disabled", room.jid)) + return false; end, 1); diff -r f7410850941f -r 89b6d0e09b86 mod_muc_limits/README.markdown --- a/mod_muc_limits/README.markdown Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_muc_limits/README.markdown Wed Jul 26 18:45:41 2023 +0700 @@ -30,18 +30,22 @@ Add the module to the MUC host (not the global modules\_enabled): - Component "conference.example.com" "muc" - modules_enabled = { "muc_limits" } +```lua +Component "conference.example.com" "muc" + modules_enabled = { "muc_limits" } +``` You can define (globally or per-MUC component) the following options: - Name Default value Description - --------------------- --------------- -------------------------------------------------- - muc_event_rate 0.5 The maximum number of events per second. - muc_burst_factor 6 Allow temporary bursts of this multiple. - muc_max_nick_length 23 The maximum allowed length of user nicknames - muc_max_char_count 5664 The maximum allowed number of bytes in a message - muc_max_line_count 23 The maximum allowed number of lines in a message + Name Default value Description + --------------------------- --------------- ---------------------------------------------------------- + muc_event_rate 0.5 The maximum number of events per second. + muc_burst_factor 6 Allow temporary bursts of this multiple. + muc_max_nick_length 23 The maximum allowed length of user nicknames + muc_max_char_count 5664 The maximum allowed number of bytes in a message + muc_max_line_count 23 The maximum allowed number of lines in a message + muc_limit_base_cost 1 Base cost of sending a stanza + muc_line_count_multiplier 0.1 Additional cost of each newline in the body of a message For more understanding of how these values are used, see the algorithm section below. @@ -68,15 +72,7 @@ Compatibility ============= - ------- ------------------ + ------- ------- trunk Works 0.11 Works - 0.10 Works - 0.9 Works - 0.8 Doesn't work[^1] - ------- ------------------ - -[^1]: This module can be made to work in 0.8 (and *maybe* previous - versions) of Prosody by copying the new - [util.throttle](http://hg.prosody.im/trunk/raw-file/fc8a22936b3c/util/throttle.lua) - into your Prosody source directory (into the util/ subdirectory). + ------- ------- diff -r f7410850941f -r 89b6d0e09b86 mod_muc_limits/mod_muc_limits.lua --- a/mod_muc_limits/mod_muc_limits.lua Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_muc_limits/mod_muc_limits.lua Wed Jul 26 18:45:41 2023 +0700 @@ -15,6 +15,8 @@ local max_nick_length = module:get_option_number("muc_max_nick_length", 23); -- Default chosen through scientific methods local max_line_count = module:get_option_number("muc_max_line_count", 23); -- Default chosen through s/scientific methods/copy and paste/ local max_char_count = module:get_option_number("muc_max_char_count", 5664); -- Default chosen by multiplying a number by 23 +local base_cost = math.max(module:get_option_number("muc_limit_base_cost", 1), 0); +local line_multiplier = math.max(module:get_option_number("muc_line_count_multiplier", 0.1), 0); local join_only = module:get_option_boolean("muc_limit_joins_only", false); local dropped_count = 0; @@ -49,7 +51,7 @@ throttle = new_throttle(period*burst, burst); room.throttle = throttle; end - local cost = 1; + local cost = base_cost; local body = stanza:get_child_text("body"); if body then -- TODO calculate a text diagonal cross-section or some mathemagical @@ -65,7 +67,7 @@ :tag("x", { xmlns = xmlns_muc; })); return true; end - cost = cost + body_lines; + cost = cost + (body_lines * line_multiplier); end if not throttle:poll(cost) then module:log("debug", "Dropping stanza for %s@%s from %s, over rate limit", dest_room, dest_host, from_jid); diff -r f7410850941f -r 89b6d0e09b86 mod_muc_moderation/mod_muc_moderation.lua --- a/mod_muc_moderation/mod_muc_moderation.lua Wed Jul 26 18:43:45 2023 +0700 +++ b/mod_muc_moderation/mod_muc_moderation.lua Wed Jul 26 18:45:41 2023 +0700 @@ -27,6 +27,7 @@ -- Namespaces local xmlns_fasten = "urn:xmpp:fasten:0"; local xmlns_moderate = "urn:xmpp:message-moderate:0"; +local xmlns_occupant_id = "urn:xmpp:occupant-id:0"; local xmlns_retract = "urn:xmpp:message-retract:0"; -- Discovering support @@ -95,11 +96,31 @@ announcement:text_tag("reason", reason); end + local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id); + if room.get_occupant_id and moderated_occupant_id then + announcement:add_direct_child(moderated_occupant_id); + end + + local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick); + if room.get_occupant_id then + -- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here + announcement:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) })) + end + if muc_log_archive.set and retract then local tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id }) :tag("moderated", { xmlns = xmlns_moderate, by = actor_nick }) :tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up(); + if room.get_occupant_id then + tombstone:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) })) + + if moderated_occupant_id then + -- Copy occupant id from moderated message + tombstone:add_child(moderated_occupant_id); + end + end + if reason then tombstone:text_tag("reason", reason); end