Software /
code /
prosody
File
plugins/mod_saslauth.lua @ 12642:9061f9621330
Switch to a new role-based authorization framework, removing is_admin()
We began moving away from simple "is this user an admin?" permission checks
before 0.12, with the introduction of mod_authz_internal and the ability to
dynamically change the roles of individual users.
The approach in 0.12 still had various limitations however, and apart from
the introduction of roles other than "admin" and the ability to pull that info
from storage, not much actually changed.
This new framework shakes things up a lot, though aims to maintain the same
functionality and behaviour on the surface for a default Prosody
configuration. That is, if you don't take advantage of any of the new
features, you shouldn't notice any change.
The biggest change visible to developers is that usermanager.is_admin() (and
the auth provider is_admin() method) have been removed. Gone. Completely.
Permission checks should now be performed using a new module API method:
module:may(action_name, context)
This method accepts an action name, followed by either a JID (string) or
(preferably) a table containing 'origin'/'session' and 'stanza' fields (e.g.
the standard object passed to most events). It will return true if the action
should be permitted, or false/nil otherwise.
Modules should no longer perform permission checks based on the role name.
E.g. a lot of code previously checked if the user's role was prosody:admin
before permitting some action. Since many roles might now exist with similar
permissions, and the permissions of prosody:admin may be redefined
dynamically, it is no longer suitable to use this method for permission
checks. Use module:may().
If you start an action name with ':' (recommended) then the current module's
name will automatically be used as a prefix.
To define a new permission, use the new module API:
module:default_permission(role_name, action_name)
module:default_permissions(role_name, { action_name[, action_name...] })
This grants the specified role permission to execute the named action(s) by
default. This may be overridden via other mechanisms external to your module.
The built-in roles that developers should use are:
- prosody:user (normal user)
- prosody:admin (host admin)
- prosody:operator (global admin)
The new prosody:operator role is intended for server-wide actions (such as
shutting down Prosody).
Finally, all usage of is_admin() in modules has been fixed by this commit.
Some of these changes were trickier than others, but no change is expected to
break existing deployments.
EXCEPT: mod_auth_ldap no longer supports the ldap_admin_filter option. It's
very possible nobody is using this, but if someone is then we can later update
it to pull roles from LDAP somehow.
author | Matthew Wild <mwild1@gmail.com> |
---|---|
date | Wed, 15 Jun 2022 12:15:01 +0100 |
parent | 12641:e9865b0cfb89 |
child | 12718:27a4a7e64831 |
line wrap: on
line source
-- Prosody IM -- Copyright (C) 2008-2010 Matthew Wild -- Copyright (C) 2008-2010 Waqas Hussain -- -- This project is MIT/X11 licensed. Please see the -- COPYING file in the source package for more information. -- -- luacheck: ignore 431/log local st = require "util.stanza"; local sm_bind_resource = require "core.sessionmanager".bind_resource; local sm_make_authenticated = require "core.sessionmanager".make_authenticated; local base64 = require "util.encodings".base64; local set = require "util.set"; local errors = require "util.error"; local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler; local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true)); local allow_unencrypted_plain_auth = module:get_option_boolean("allow_unencrypted_plain_auth", false) local insecure_mechanisms = module:get_option_set("insecure_sasl_mechanisms", allow_unencrypted_plain_auth and {} or {"PLAIN", "LOGIN"}); local disabled_mechanisms = module:get_option_set("disable_sasl_mechanisms", { "DIGEST-MD5" }); local log = module._log; local xmlns_sasl ='urn:ietf:params:xml:ns:xmpp-sasl'; local xmlns_bind ='urn:ietf:params:xml:ns:xmpp-bind'; local function build_reply(status, ret, err_msg) local reply = st.stanza(status, {xmlns = xmlns_sasl}); if status == "failure" then reply:tag(ret):up(); if err_msg then reply:tag("text"):text(err_msg); end elseif status == "challenge" or status == "success" then if ret == "" then reply:text("=") elseif ret then reply:text(base64.encode(ret)); end else module:log("error", "Unknown sasl status: %s", status); end return reply; end local function handle_status(session, status, ret, err_msg) if not session.sasl_handler then return "failure", "temporary-auth-failure", "Connection gone"; end if status == "failure" then module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg }); session.sasl_handler = session.sasl_handler:clean_clone(); elseif status == "success" then local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.role); if ok then module:fire_event("authentication-success", { session = session }); session.sasl_handler = nil; session:reset_stream(); else module:log("warn", "SASL succeeded but username was invalid"); module:fire_event("authentication-failure", { session = session, condition = "not-authorized", text = err }); session.sasl_handler = session.sasl_handler:clean_clone(); return "failure", "not-authorized", "User authenticated successfully, but username was invalid"; end end return status, ret, err_msg; end local function sasl_process_cdata(session, stanza) local text = stanza[1]; if text then text = base64.decode(text); if not text then session.sasl_handler = nil; session.send(build_reply("failure", "incorrect-encoding")); return true; end end local status, ret, err_msg = session.sasl_handler:process(text); status, ret, err_msg = handle_status(session, status, ret, err_msg); local s = build_reply(status, ret, err_msg); session.send(s); return true; end module:hook_tag(xmlns_sasl, "success", function (session) if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end module:log("debug", "SASL EXTERNAL with %s succeeded", session.to_host); session.external_auth = "succeeded" session:reset_stream(); session:open_stream(session.from_host, session.to_host); module:fire_event("s2s-authenticated", { session = session, host = session.to_host, mechanism = "EXTERNAL" }); return true; end) module:hook_tag(xmlns_sasl, "failure", function (session, stanza) if session.type ~= "s2sout_unauthed" or session.external_auth ~= "attempting" then return; end local text = stanza:get_child_text("text"); local condition = "unknown-condition"; for child in stanza:childtags() do if child.name ~= "text" then condition = child.name; break; end end local err = errors.new({ -- TODO type = what? text = text, condition = condition, }, { session = session, stanza = stanza, }); module:log("info", "SASL EXTERNAL with %s failed: %s", session.to_host, err); session.external_auth = "failed" session.external_auth_failure_reason = err; end, 500) module:hook_tag(xmlns_sasl, "failure", function (session, stanza) -- luacheck: ignore 212/stanza session.log("debug", "No fallback from SASL EXTERNAL failure, giving up"); session:close(nil, session.external_auth_failure_reason, errors.new({ type = "wait", condition = "remote-server-timeout", text = "Could not authenticate to remote server", }, { session = session, sasl_failure = session.external_auth_failure_reason, })); return true; end, 90) module:hook_tag("http://etherx.jabber.org/streams", "features", function (session, stanza) if session.type ~= "s2sout_unauthed" or not session.secure then return; end local mechanisms = stanza:get_child("mechanisms", xmlns_sasl) if mechanisms then for mech in mechanisms:childtags() do if mech[1] == "EXTERNAL" then module:log("debug", "Initiating SASL EXTERNAL with %s", session.to_host); local reply = st.stanza("auth", {xmlns = xmlns_sasl, mechanism = "EXTERNAL"}); reply:text(base64.encode(session.from_host)) session.sends2s(reply) session.external_auth = "attempting" return true end end end end, 150); local function s2s_external_auth(session, stanza) if session.external_auth ~= "offered" then return end -- Unexpected request local mechanism = stanza.attr.mechanism; if mechanism ~= "EXTERNAL" then session.sends2s(build_reply("failure", "invalid-mechanism")); return true; end if not session.secure then session.sends2s(build_reply("failure", "encryption-required")); return true; end local text = stanza[1]; if not text then session.sends2s(build_reply("failure", "malformed-request")); return true; end text = base64.decode(text); if not text then session.sends2s(build_reply("failure", "incorrect-encoding")); return true; end -- The text value is either "" or equals session.from_host if not ( text == "" or text == session.from_host ) then session.sends2s(build_reply("failure", "invalid-authzid")); return true; end -- We've already verified the external cert identity before offering EXTERNAL if session.cert_chain_status ~= "valid" or session.cert_identity_status ~= "valid" then session.sends2s(build_reply("failure", "not-authorized")); session:close(); return true; end -- Success! session.external_auth = "succeeded"; session.sends2s(build_reply("success")); module:log("info", "Accepting SASL EXTERNAL identity from %s", session.from_host); module:fire_event("s2s-authenticated", { session = session, host = session.from_host, mechanism = mechanism }); session:reset_stream(); return true; end module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:auth", function(event) local session, stanza = event.origin, event.stanza; if session.type == "s2sin_unauthed" then return s2s_external_auth(session, stanza) end if session.type ~= "c2s_unauthed" or module:get_host_type() ~= "local" then return; end if session.sasl_handler and session.sasl_handler.selected then session.sasl_handler = nil; -- allow starting a new SASL negotiation before completing an old one end if not session.sasl_handler then session.sasl_handler = usermanager_get_sasl_handler(module.host, session); end local mechanism = stanza.attr.mechanism; if not session.secure and (secure_auth_only or insecure_mechanisms:contains(mechanism)) then session.send(build_reply("failure", "encryption-required")); return true; elseif disabled_mechanisms:contains(mechanism) then session.send(build_reply("failure", "invalid-mechanism")); return true; end local valid_mechanism = session.sasl_handler:select(mechanism); if not valid_mechanism then session.send(build_reply("failure", "invalid-mechanism")); return true; end return sasl_process_cdata(session, stanza); end); module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:response", function(event) local session = event.origin; if not(session.sasl_handler and session.sasl_handler.selected) then session.send(build_reply("failure", "not-authorized", "Out of order SASL element")); return true; end return sasl_process_cdata(session, event.stanza); end); module:hook("stanza/urn:ietf:params:xml:ns:xmpp-sasl:abort", function(event) local session = event.origin; session.sasl_handler = nil; session.send(build_reply("failure", "aborted")); return true; end); local function tls_unique(self) return self.userdata["tls-unique"]:ssl_peerfinished(); end local function tls_exporter(conn) if not conn.ssl_exportkeyingmaterial then return end return conn:ssl_exportkeyingmaterial("EXPORTER-Channel-Binding", 32, ""); end local function sasl_tls_exporter(self) return tls_exporter(self.userdata["tls-exporter"]); end local mechanisms_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-sasl' }; local bind_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-bind' }; local xmpp_session_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-session' }; module:hook("stream-features", function(event) local origin, features = event.origin, event.features; local log = origin.log or log; if not origin.username then if secure_auth_only and not origin.secure then log("debug", "Not offering authentication on insecure connection"); return; end local sasl_handler = usermanager_get_sasl_handler(module.host, origin) origin.sasl_handler = sasl_handler; local channel_bindings = set.new() if origin.encrypted then -- check whether LuaSec has the nifty binding to the function needed for tls-unique -- FIXME: would be nice to have this check only once and not for every socket if sasl_handler.add_cb_handler then local info = origin.conn:ssl_info(); if info and info.protocol == "TLSv1.3" then log("debug", "Channel binding 'tls-unique' undefined in context of TLS 1.3"); if tls_exporter(origin.conn) then log("debug", "Channel binding 'tls-exporter' supported"); sasl_handler:add_cb_handler("tls-exporter", sasl_tls_exporter); channel_bindings:add("tls-exporter"); end elseif origin.conn.ssl_peerfinished and origin.conn:ssl_peerfinished() then log("debug", "Channel binding 'tls-unique' supported"); sasl_handler:add_cb_handler("tls-unique", tls_unique); channel_bindings:add("tls-unique"); else log("debug", "Channel binding 'tls-unique' not supported (by LuaSec?)"); end sasl_handler["userdata"] = { ["tls-unique"] = origin.conn; ["tls-exporter"] = origin.conn; }; else log("debug", "Channel binding not supported by SASL handler"); end end local mechanisms = st.stanza("mechanisms", mechanisms_attr); local sasl_mechanisms = sasl_handler:mechanisms() local available_mechanisms = set.new(); for mechanism in pairs(sasl_mechanisms) do available_mechanisms:add(mechanism); end log("debug", "SASL mechanisms supported by handler: %s", available_mechanisms); local usable_mechanisms = available_mechanisms - disabled_mechanisms; local available_disabled = set.intersection(available_mechanisms, disabled_mechanisms); if not available_disabled:empty() then log("debug", "Not offering disabled mechanisms: %s", available_disabled); end local available_insecure = set.intersection(available_mechanisms, insecure_mechanisms); if not origin.secure and not available_insecure:empty() then log("debug", "Session is not secure, not offering insecure mechanisms: %s", available_insecure); usable_mechanisms = usable_mechanisms - insecure_mechanisms; end if not usable_mechanisms:empty() then log("debug", "Offering usable mechanisms: %s", usable_mechanisms); for mechanism in usable_mechanisms do mechanisms:tag("mechanism"):text(mechanism):up(); end if not channel_bindings:empty() then -- XXX XEP-0440 is Experimental mechanisms:tag("sasl-channel-binding", {xmlns='urn:xmpp:sasl-cb:0'}) for channel_binding in channel_bindings do mechanisms:tag("channel-binding", {type=channel_binding}):up() end mechanisms:up(); end features:add_child(mechanisms); return; end local authmod = module:get_option_string("authentication", "internal_hashed"); if available_mechanisms:empty() then log("warn", "No available SASL mechanisms, verify that the configured authentication module '%s' is loaded and configured correctly", authmod); return; end if not origin.secure and not available_insecure:empty() then if not available_disabled:empty() then log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s) or disabled (%s)", authmod, available_insecure, available_disabled); else log("warn", "All SASL mechanisms provided by authentication module '%s' are forbidden on insecure connections (%s)", authmod, available_insecure); end elseif not available_disabled:empty() then log("warn", "All SASL mechanisms provided by authentication module '%s' are disabled (%s)", authmod, available_disabled); end else features:tag("bind", bind_attr):tag("required"):up():up(); features:tag("session", xmpp_session_attr):tag("optional"):up():up(); end end); module:hook("s2s-stream-features", function(event) local origin, features = event.origin, event.features; if origin.secure and origin.type == "s2sin_unauthed" then -- Offer EXTERNAL only if both chain and identity is valid. if origin.cert_chain_status == "valid" and origin.cert_identity_status == "valid" then module:log("debug", "Offering SASL EXTERNAL"); origin.external_auth = "offered" features:tag("mechanisms", { xmlns = xmlns_sasl }) :tag("mechanism"):text("EXTERNAL") :up():up(); end end end); module:hook("stanza/iq/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event) local origin, stanza = event.origin, event.stanza; local resource; if stanza.attr.type == "set" then local bind = stanza.tags[1]; resource = bind:get_child("resource"); resource = resource and #resource.tags == 0 and resource[1] or nil; end local success, err_type, err, err_msg = sm_bind_resource(origin, resource); if success then origin.send(st.reply(stanza) :tag("bind", { xmlns = xmlns_bind }) :tag("jid"):text(origin.full_jid)); origin.log("debug", "Resource bound: %s", origin.full_jid); else origin.send(st.error_reply(stanza, err_type, err, err_msg)); origin.log("debug", "Resource bind failed: %s", err_msg or err); end return true; end); local function handle_legacy_session(event) event.origin.send(st.reply(event.stanza)); return true; end module:hook("iq/self/urn:ietf:params:xml:ns:xmpp-session:session", handle_legacy_session); module:hook("iq/host/urn:ietf:params:xml:ns:xmpp-session:session", handle_legacy_session);