From 9990ce7d749f08c497953934d3298214af5dbc42 Mon Sep 17 00:00:00 2001 From: ItsOnlyBinary Date: Mon, 1 Mar 2021 13:44:52 +0000 Subject: [PATCH] Add jitsi-meet modules and README.md --- README.md | 9 +- jitsi-meet-modules/README.md | 116 +++++ jitsi-meet-modules/mod_auth_kiwi_token.lua | 149 ++++++ .../mod_kiwi_muc_role_from_jwt.lua | 92 ++++ .../mod_kiwi_presence_identity.lua | 25 + .../mod_kiwi_token_verification.lua | 118 +++++ jitsi-meet-modules/token/util_kiwi.lib.lua | 486 ++++++++++++++++++ jitsi-meet-modules/util_kiwi.lib.lua | 347 +++++++++++++ 8 files changed, 1340 insertions(+), 2 deletions(-) create mode 100644 jitsi-meet-modules/README.md create mode 100644 jitsi-meet-modules/mod_auth_kiwi_token.lua create mode 100644 jitsi-meet-modules/mod_kiwi_muc_role_from_jwt.lua create mode 100644 jitsi-meet-modules/mod_kiwi_presence_identity.lua create mode 100644 jitsi-meet-modules/mod_kiwi_token_verification.lua create mode 100644 jitsi-meet-modules/token/util_kiwi.lib.lua create mode 100644 jitsi-meet-modules/util_kiwi.lib.lua diff --git a/README.md b/README.md index 9ba7b51..917f20e 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Add the plugin javascript file to your kiwiirc `config.json` and configure the s ### Security note! By default, this plugin uses Jisti's public servers. It should be noted that by using these public servers, your conference calls are not secure in that anybody can join them if they can guess the room name. -Note that the "secure" option enables JWT authentication, but will not work on Jitsi's public server. +Note that the "secure" option enables JWT authentication, but will not work on Jitsi's public server. For more information see [Running your own conference server](#Running-your-own-conference-server) ### Extra configuration Jitsi Meet supports extra configuration to customise its interface and functions. You can configure these via the optional `interfaceConfigOverwrite` and `configOverwrite` config options. @@ -103,7 +103,12 @@ You may also choose to hide the conference call icon in either channels or priva } ``` ### Running your own conference server -Running your own conference server allows you to secure your conference rooms. We make use of the Jitsi Meet server to handle the conference calls, the installation steps can be found here: https://github.com/jitsi/jitsi-meet/blob/master/doc/quick-install.md +Running your own conference server allows you to secure your conference rooms. We make use of the Jitsi Meet server to handle the conference calls, the installation steps can be found here: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-quickstart + +If docker is your preferred method then see here: https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker + +For installing and configuring jitsi to support [IRCv3 EXTJWT](https://github.com/ircv3/ircv3-specifications/pull/341) documentation can can be found here: https://github.com/kiwiirc/plugin-conference/blob/master/jitsi-meet-modules/README.md + ## License diff --git a/jitsi-meet-modules/README.md b/jitsi-meet-modules/README.md new file mode 100644 index 0000000..80af261 --- /dev/null +++ b/jitsi-meet-modules/README.md @@ -0,0 +1,116 @@ +# Jitsi Meet Kiwi IRC auth plugin + +## About + +This repository contains modified copy of Jitsi Meet's Prosody token authentication modules for use with [kiwiirc/plugin-conference]. + +The purpose of these files is to mirror the access control model and user identity from the IRC environment to the Jitsi conference room. + +This auth method is necessary when using `{ "conference.secure": true }` in your KiwiIRC client config. + +___ + +## Docker install + +Follow the [jitsi documentation](https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-docker) for setting up jitsi docker + +After setting up docker copy the contents of `plugins/` to `~/.jitsi-meet-cfg/prosody/prosody-plugins-custom` + +### Editing the docker .env file + +Uncomment and set the following settings + +``` +ENABLE_WELCOME_PAGE=0 + +ENABLE_AUTH=1 + +ENABLE_GUESTS=0 + +AUTH_TYPE=jwt + +JWT_APP_ID= + +JWT_APP_SECRET= + +XMPP_MUC_MODULES=kiwi_muc_role_from_jwt,kiwi_presence_identity +``` + +The following vars will require adding: + +``` +ENABLE_P2P=false + +ENABLE_AUTO_OWNER=false + +JWT_AUTH_TYPE=kiwi_token + +JWT_TOKEN_AUTH_MODULE=kiwi_token_verification +``` + +___ + +## Native install + +Before proceeding, you'll need to complete the installation of the Jitsi Meet backend. Using the apt repository mentioned in [Jitsi Meet's instructions](https://jitsi.github.io/handbook/docs/devops-guide/devops-guide-quickstart), install the `jitsi-meet` and `jitsi-meet-tokens` packages. e.g. `apt-get install jitsi-meet jitsi-meet-tokens`. + +> A few hints: +> +> - On Ubuntu 16.04 `jitsi-meet-tokens` currently has a dependency on `prosody-trunk`, rather than the `prosody` package available in the main repositories. See [Prosody's documentation] to add their apt repository as a package source on your system. +> - The Jitsi Meet packaging may have issues on Ubuntu 18.04. +> - Use an interactive shell when installing `jitsi-meet` because the packages will ask questions via debconf during installation and errors will occur if no debconf frontend is available. +> - Install `nginx` **before** `jitsi-meet` if you want the `jitsi-meet` package to automatically create an nginx site configuration for you. +> - On debian there is an issue building the dependencies at install time due to a difference in libssl packages. As a workaround, do `sudo apt-get install apt-transport-https libssl1.0-dev luarocks git && sudo luarocks install luacrypto` before trying to install the jitsi packages. `libssl1.0-dev` is needed to build luacrypto, but it will get uninstalled and replaced with `libssl-dev` due to the dependencies specified by jitsi packages later on. +> - On Ubuntu 20.04 / Debian 10 `liblua5.2-dev` package is required before installing `jitsi-meet-tokens`. +> - To restart jitsi services after editing configs you can use `sudo systemctl restart prosody.service jicofo.service jitsi-videobridge2.service`. + +### Post install + +After installing the required packages copy the contents of `plugins/` to `/usr/share/jitsi-meet-kiwi` + +### Configuring + +In `/etc/prosody/conf.d/.cfg.lua`: + +1. Edit `plugin_paths` to add the newly created `/usr/share/jitsi-meet-kiwi` directory. + +2. At the top level of the config, add these two lines, replacing `` with the appropriate value for your installation: + +```lua +jitsi_meet_domain = ""; +jitsi_meet_focus_hostname = "auth."; +``` + +3. `authentication = "kiwi_token"` + +4. `app_secret` (referred to as `application secret` during the interactive debconf prompts) needs to match the secret set in your webircgateway config. + +5. `app_id` (`application ID` in debconf) must match the hostname in the upstream section of your webircgateway config **as well as** the server hostname that the KiwiIRC *client* uses (i.e. `startupOptions.server` in the client `config.json`) + +6. Remove `"token_verification"` and add `"kiwi_token_verification"; "kiwi_muc_role_from_jwt"; "kiwi_presence_identity";` to `modules_enabled` in `Component "conference." "muc"`. + +7. If you're hosting Jitsi on a separate hostname from KiwiIRC, you will need to either add + +```lua +cross_domain_bosh = true; +``` + +at the top level of the config or manually add CORS headers in your nginx config. + +### Jicofo SIP Communicator properties + +8. Open `/etc/jitsi/jicofo/sip-communicator.properties` and add the following line: + +```ini +org.jitsi.jicofo.DISABLE_AUTO_OWNER=True +``` + +### Jitsi Meet config + +9. You may also want to disable P2P connectivity in the videobridge's config file at `/etc/jitsi/meet/-config.js`. + +```js +p2p: { enabled: false } +``` + +See `/usr/share/doc/jitsi-meet-web-config/config.js` for an example of the configuration format. diff --git a/jitsi-meet-modules/mod_auth_kiwi_token.lua b/jitsi-meet-modules/mod_auth_kiwi_token.lua new file mode 100644 index 0000000..e46cb25 --- /dev/null +++ b/jitsi-meet-modules/mod_auth_kiwi_token.lua @@ -0,0 +1,149 @@ +-- Token authentication +-- Copyright (C) 2015 Atlassian + +local formdecode = require "util.http".formdecode; +local generate_uuid = require "util.uuid".generate; +local new_sasl = require "util.sasl".new; +local sasl = require "util.sasl"; +local token_util = module:require "token/util_kiwi".new(module); +local sessions = prosody.full_sessions; + +module:log("info", "kiwiirc patch active: prosody-plugins/mod_auth_kiwi_token.lua"); + +-- no token configuration +if token_util == nil then + return; +end + +-- define auth provider +local provider = {}; + +local host = module.host; + +-- Extract 'token' param from URL when session is created +function init_session(event) + local session, request = event.session, event.request; + local query = request.url.query; + + if query ~= nil then + local params = formdecode(query); + + -- The following fields are filled in the session, by extracting them + -- from the query and no validation is beeing done. + -- After validating auth_token will be cleaned in case of error and few + -- other fields will be extracted from the token and set in the session + + session.auth_token = query and params.token or nil; + -- previd is used together with https://modules.prosody.im/mod_smacks.html + -- the param is used to find resumed session and re-use anonymous(random) user id + -- (see get_username_from_token) + session.previd = query and params.previd or nil; + + -- The room name and optional prefix from the web query + session.jitsi_web_query_room = params.room; + session.jitsi_web_query_prefix = params.prefix or ""; + + -- Deprecated, you should use jitsi_web_query_room and jitsi_web_query_prefix + session.jitsi_bosh_query_room = session.jitsi_web_query_room; + session.jitsi_bosh_query_prefix = session.jitsi_web_query_prefix; + end +end + +module:hook_global("bosh-session", init_session); +module:hook_global("websocket-session", init_session); + +function provider.test_password(username, password) + return nil, "Password based auth not supported"; +end + +function provider.get_password(username) + return nil; +end + +function provider.set_password(username, password) + return nil, "Set password not supported"; +end + +function provider.user_exists(username) + return nil; +end + +function provider.create_user(username, password) + return nil; +end + +function provider.delete_user(username) + return nil; +end + +function provider.get_sasl_handler(session) + + local function get_username_from_token(self, message) + + -- retrieve custom public key from server and save it on the session + local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session); + if pre_event_result ~= nil and pre_event_result.res == false then + log("warn", + "Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason); + session.auth_token = nil; + return pre_event_result.res, pre_event_result.error, pre_event_result.reason; + end + + local res, error, reason = token_util:process_and_verify_token(session); + if res == false then + log("warn", + "Error verifying token err:%s, reason:%s", error, reason); + session.auth_token = nil; + return res, error, reason; + end + + local customUsername + = prosody.events.fire_event("pre-jitsi-authentication", session); + + if (customUsername) then + self.username = customUsername; + elseif (session.previd ~= nil) then + for _, session1 in pairs(sessions) do + if (session1.resumption_token == session.previd) then + self.username = session1.username; + break; + end + end + else + self.username = message; + end + + local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session); + if post_event_result ~= nil and post_event_result.res == false then + log("warn", + "Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason); + session.auth_token = nil; + return post_event_result.res, post_event_result.error, post_event_result.reason; + end + + return res; + end + + return new_sasl(host, { anonymous = get_username_from_token }); +end + +module:provides("auth", provider); + +local function anonymous(self, message) + + local username = generate_uuid(); + + -- This calls the handler created in 'provider.get_sasl_handler(session)' + local result, err, msg = self.profile.anonymous(self, username, self.realm); + + if result == true then + if (self.username == nil) then + self.username = username; + end + return "success"; + else + return "failure", err, msg; + end +end + +sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous); diff --git a/jitsi-meet-modules/mod_kiwi_muc_role_from_jwt.lua b/jitsi-meet-modules/mod_kiwi_muc_role_from_jwt.lua new file mode 100644 index 0000000..d070790 --- /dev/null +++ b/jitsi-meet-modules/mod_kiwi_muc_role_from_jwt.lua @@ -0,0 +1,92 @@ + + +local muc_service = module:depends("muc"); +local room_mt = muc_service.room_mt; + +--local occupant = muc_service.occupant_mt; +module:set_global(); +--module:depends("c2s"); +--local sessions = module:shared("c2s/sessions"); +local sessions = module:shared("sessions"); + +local muc_util = module:require "muc/util"; +local valid_affiliations = muc_util.valid_affiliations; + +local jid_bare = require "util.jid".bare; +local jid_split = require "util.jid".split; + +-- package.path = '?.lua;' .. package.path +-- local inspect = require('/usr/share/jitsi-meet/prosody-plugins/inspect'); + +module:log("info", "kiwiirc patch active: prosody-plugins/mod_kiwi_muc_role_from_jwt.lua"); + +function len(t) + local n = 0; + for _ in pairs(t) do + n = n + 1 + end + return n +end + +local seen={} + +function dump(t,i) + module:log("debug", "dump table size: " .. len(t)); + seen[t]=true + local s={} + local n=0 + for k in pairs(t) do + n=n+1 s[n]=k + end + if pcall(function () table.sort(s); end) then + table.sort(s) + end + for k,v in ipairs(s) do + module:log("debug", "dump: " .. tostring(i) .. tostring(v)) + v=t[v] + if type(v)=="table" and not seen[v] then + dump(v,i.."\t") + end + end +end + +local jitsi_meet_focus_hostname = module:get_option("jitsi_meet_focus_hostname", os.getenv("XMPP_AUTH_DOMAIN")); + +room_mt.get_affiliation = function(room, jid) + --module:log("debug", "--- entered get_affiliation: jid=" .. jid); + --module:log("debug", debug.traceback()); +-- dump(_G,""); + -- dump(_G.full_sessions, ""); + --module:log("debug", "sessions - " .. inspect(prosody.full_sessions)); + local bare_jid = jid_bare(jid); +-- module:log("debug", "jid : " .. session); + --local sess = occupant:get_presence(jid); + --module.log("debug", "sess - " .. sess); + local affiliation = nil; + + -- TODO: don't allow earlier sessions to override affiliation claim from later ones + for conn, session in pairs(prosody.full_sessions) do + --module:log("debug", "test session: " .. jid .. " - " .. session.full_jid); + if jid_bare(session.full_jid) == bare_jid then + --module:log("debug", "found session - " .. inspect(session)); + --module:log("debug", "found session - " .. session.full_jid); + if valid_affiliations[session.jitsi_meet_room_affiliation] ~= nil then + affiliation = session.jitsi_meet_room_affiliation; + -- module:log("debug", "setting affil - " .. affiliation); + end + end + end + local default_focus_affil = "owner"; + local node, host, resource = jid_split(jid); + if host == jitsi_meet_focus_hostname then + module:log("debug", "affil (focus): " .. default_focus_affil .. " jid: " .. jid); + return default_focus_affil; + end + local default_affil = "member"; + if affiliation == nil then + module:log("debug", "affil (default): " .. default_affil .. " jid: " .. jid); + return default_affil; + end + module:log("debug", "affil: " .. affiliation .. " jid: " .. jid); + return affiliation; +end diff --git a/jitsi-meet-modules/mod_kiwi_presence_identity.lua b/jitsi-meet-modules/mod_kiwi_presence_identity.lua new file mode 100644 index 0000000..c1a00f3 --- /dev/null +++ b/jitsi-meet-modules/mod_kiwi_presence_identity.lua @@ -0,0 +1,25 @@ +local stanza = require "util.stanza"; +local update_presence_identity = module:require "util_kiwi".update_presence_identity; + +module:log("info", "kiwiirc patch active: prosody-plugins/mod_kiwi_presence_identity.lua"); + +-- For all received presence messages, if the jitsi_meet_context_(user|group) +-- values are set in the session, then insert them into the presence messages +-- for that session. +function on_message(event) + if event and event["stanza"] then + if event.origin and event.origin.jitsi_meet_context_user then + + update_presence_identity( + event.stanza, + event.origin.jitsi_meet_context_user, + event.origin.jitsi_meet_context_group + ); + + end + end +end + +module:hook("pre-presence/bare", on_message); +module:hook("pre-presence/full", on_message); +module:hook("presence/full", on_message); -- this is the only one that fires now? diff --git a/jitsi-meet-modules/mod_kiwi_token_verification.lua b/jitsi-meet-modules/mod_kiwi_token_verification.lua new file mode 100644 index 0000000..16257ce --- /dev/null +++ b/jitsi-meet-modules/mod_kiwi_token_verification.lua @@ -0,0 +1,118 @@ +-- Token authentication +-- Copyright (C) 2015 Atlassian + +local log = module._log; +local host = module.host; +local st = require "util.stanza"; +local um_is_admin = require "core.usermanager".is_admin; + +module:log("info", "kiwiirc patch active: prosody-plugins/mod_kiwi_token_verification.lua"); + +local function is_admin(jid) + return um_is_admin(jid, host); +end + +local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")(); +if parentHostName == nil then + log("error", "Failed to start - unable to get parent hostname"); + return; +end + +local parentCtx = module:context(parentHostName); +if parentCtx == nil then + log("error", + "Failed to start - unable to get parent context for host: %s", + tostring(parentHostName)); + return; +end + +local token_util = module:require "token/util_kiwi".new(parentCtx); + +-- no token configuration +if token_util == nil then + return; +end + +log("debug", + "%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s", + tostring(host), tostring(token_util.appId), tostring(token_util.appSecret), + tostring(token_util.allowEmptyToken)); + +-- option to disable room modification (sending muc config form) for guest that do not provide token +local require_token_for_moderation; +local function load_config() + require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation"); +end +load_config(); + +-- verify user and whether he is allowed to join a room based on the token information +local function verify_user(session, stanza) + log("debug", "Session token: %s, session room: %s", + tostring(session.auth_token), + tostring(session.jitsi_meet_room)); + + -- token not required for admin users + local user_jid = stanza.attr.from; + if is_admin(user_jid) then + log("debug", "Token not required from admin user: %s", user_jid); + return true; + end + + log("debug", + "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); + if not token_util:verify_room(session, stanza.attr.to) then + log("error", "Token %s not allowed to join: %s", + tostring(session.auth_token), tostring(stanza.attr.to)); + session.send( + st.error_reply( + stanza, "cancel", "not-allowed", "Room and token mismatched")); + return false; -- we need to just return non nil + end + log("debug", + "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); + return true; +end + +module:hook("muc-room-pre-create", function(event) + local origin, stanza = event.origin, event.stanza; + log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); + if not verify_user(origin, stanza) then + return true; -- Returning any value other than nil will halt processing of the event + end +end); + +module:hook("muc-occupant-pre-join", function(event) + local origin, room, stanza = event.origin, event.room, event.stanza; + log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); + if not verify_user(origin, stanza) then + return true; -- Returning any value other than nil will halt processing of the event + end +end); + +for event_name, method in pairs { + -- Normal room interactions + ["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; + -- Host room + ["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ; +} do + module:hook(event_name, function (event) + local session, stanza = event.origin, event.stanza; + + -- if we do not require token we pass it through(default behaviour) + -- or the request is coming from admin (focus) + if not require_token_for_moderation or is_admin(stanza.attr.from) then + return; + end + + -- jitsi_meet_room is set after the token had been verified + if not session.auth_token or not session.jitsi_meet_room then + session.send( + st.error_reply( + stanza, "cancel", "not-allowed", "Room modification disabled for guests")); + return true; + end + + end, -1); -- the default prosody hook is on -2 +end + +module:hook_global('config-reloaded', load_config); diff --git a/jitsi-meet-modules/token/util_kiwi.lib.lua b/jitsi-meet-modules/token/util_kiwi.lib.lua new file mode 100644 index 0000000..ec33fbb --- /dev/null +++ b/jitsi-meet-modules/token/util_kiwi.lib.lua @@ -0,0 +1,486 @@ +-- Token authentication +-- Copyright (C) 2015 Atlassian + +local basexx = require "basexx"; +local have_async, async = pcall(require, "util.async"); +local hex = require "util.hex"; +local jwt = require "luajwtjitsi"; +local jid = require "util.jid"; +local json_safe = require "cjson.safe"; +local path = require "util.paths"; +local sha256 = require "util.hashes".sha256; +local main_util = module:require "util_kiwi"; +local http_get_with_retry = main_util.http_get_with_retry; +local extract_subdomain = main_util.extract_subdomain; + +local nr_retries = 3; + +-- TODO: Figure out a less arbitrary default cache size. +local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128); + +local jitsi_meet_domain = module:get_option("jitsi_meet_domain", os.getenv("XMPP_DOMAIN")); +module:log("info", "kiwiirc patch active: prosody-plugins/token/util_kiwi.lib.lua"); + +local Util = {} +Util.__index = Util + +--- Constructs util class for token verifications. +-- Constructor that uses the passed module to extract all the +-- needed configurations. +-- If confuguration is missing returns nil +-- @param module the module in which options to check for configs. +-- @return the new instance or nil +function Util.new(module) + local self = setmetatable({}, Util) + + self.appId = module:get_option_string("app_id"); + self.appSecret = module:get_option_string("app_secret"); + self.asapKeyServer = module:get_option_string("asap_key_server"); + self.allowEmptyToken = module:get_option_boolean("allow_empty_token"); + + self.cache = require"util.cache".new(cacheSize); + + --[[ + Multidomain can be supported in some deployments. In these deployments + there is a virtual conference muc, which address contains the subdomain + to use. Those deployments are accessible + by URL https://domain/subdomain. + Then the address of the room will be: + roomName@conference.subdomain.domain. This is like a virtual address + where there is only one muc configured by default with address: + conference.domain and the actual presentation of the room in that muc + component is [subdomain]roomName@conference.domain. + These setups relay on configuration 'muc_domain_base' which holds + the main domain and we use it to substract subdomains from the + virtual addresses. + The following confgurations are for multidomain setups and domain name + verification: + --]] + + -- optional parameter for custom muc component prefix, + -- defaults to "conference" + self.muc_domain_prefix = module:get_option_string( + "muc_mapper_domain_prefix", "conference"); + -- domain base, which is the main domain used in the deployment, + -- the main VirtualHost for the deployment + self.muc_domain_base = module:get_option_string("muc_mapper_domain_base"); + -- The "real" MUC domain that we are proxying to + if self.muc_domain_base then + self.muc_domain = module:get_option_string( + "muc_mapper_domain", + self.muc_domain_prefix.."."..self.muc_domain_base); + end + -- whether domain name verification is enabled, by default it is disabled + self.enableDomainVerification = module:get_option_boolean( + "enable_domain_verification", false); + + if self.allowEmptyToken == true then + module:log("warn", "WARNING - empty tokens allowed"); + end + + if self.appId == nil then + module:log("error", "'app_id' must not be empty"); + return nil; + end + + if self.appSecret == nil and self.asapKeyServer == nil then + module:log("error", "'app_secret' or 'asap_key_server' must be specified"); + return nil; + end + + --array of accepted issuers: by default only includes our appId + self.acceptedIssuers = module:get_option_array('asap_accepted_issuers',{self.appId}) + + --array of accepted audiences: by default only includes our appId + self.acceptedAudiences = module:get_option_array('asap_accepted_audiences',{'*'}) + + self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true); + + if self.asapKeyServer and not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + return self +end + +function Util:set_asap_key_server(asapKeyServer) + self.asapKeyServer = asapKeyServer; +end + +function Util:set_asap_accepted_issuers(acceptedIssuers) + self.acceptedIssuers = acceptedIssuers; +end + +function Util:set_asap_accepted_audiences(acceptedAudiences) + self.acceptedAudiences = acceptedAudiences; +end + +function Util:set_asap_require_room_claim(checkRoom) + self.requireRoomClaim = checkRoom; +end + +function Util:clear_asap_cache() + self.cache = require"util.cache".new(cacheSize); +end + +--- Returns the public key by keyID +-- @param keyId the key ID to request +-- @return the public key (the content of requested resource) or nil +function Util:get_public_key(keyId) + local content = self.cache:get(keyId); + if content == nil then + -- If the key is not found in the cache. + module:log("debug", "Cache miss for key: "..keyId); + local keyurl = path.join(self.asapKeyServer, hex.to(sha256(keyId))..'.pem'); + module:log("debug", "Fetching public key from: "..keyurl); + content = http_get_with_retry(keyurl, nr_retries); + if content ~= nil then + self.cache:set(keyId, content); + end + return content; + else + -- If the key is in the cache, use it. + module:log("debug", "Cache hit for key: "..keyId); + return content; + end +end + +--- Verifies issuer part of token +-- @param 'issClaim' claim from the token to verify +-- @param 'acceptedIssuers' list of issuers to check +-- @return nil and error string or true for accepted claim +function Util:verify_issuer(issClaim, acceptedIssuers) + if not acceptedIssuers then + acceptedIssuers = self.acceptedIssuers + end + module:log("debug", "verify_issuer claim: %s against accepted: %s", issClaim, acceptedIssuers); + for i, iss in ipairs(acceptedIssuers) do + if iss == '*' then + -- "*" indicates to accept any issuer in the claims so return success + return true; + end + if issClaim == iss then + -- claim matches an accepted issuer so return success + return true; + end + end + -- if issClaim not found in acceptedIssuers, fail claim + return nil, "Invalid issuer ('iss' claim)"; +end + +--- Verifies audience part of token +-- @param 'audClaim' claim from the token to verify +-- @return nil and error string or true for accepted claim +function Util:verify_audience(audClaim) + module:log("debug", "verify_audience claim: %s against accepted: %s", audClaim, self.acceptedAudiences); + for i, aud in ipairs(self.acceptedAudiences) do + if aud == '*' then + -- "*" indicates to accept any audience in the claims so return success + return true; + end + if audClaim == aud then + -- claim matches an accepted audience so return success + return true; + end + end + -- if audClaim not found in acceptedAudiences, fail claim + return nil, "Invalid audience ('aud' claim)"; +end + +--- Verifies token +-- @param token the token to verify +-- @param secret the secret to use to verify token +-- @param acceptedIssuers the list of accepted issuers to check +-- @return nil and error or the extracted claims from the token +function Util:verify_token(token, secret, acceptedIssuers) + local claims, err = jwt.decode(token, secret, true); + if claims == nil then + return nil, err; + end + + claims["context"] = nil; + + local alg = claims["alg"]; + if alg ~= nil and (alg == "none" or alg == "") then + return nil, "'alg' claim must not be empty"; + end + + local issClaim = claims["iss"]; + if issClaim == nil then + return nil, "'iss' claim is missing"; + end + + --check the issuer against the accepted list + local subClaim = claims["sub"]; + if subClaim == nil then + claims["sub"] = jitsi_meet_domain; + end + + if self.requireRoomClaim then + local roomClaim = claims["channel"]; + if roomClaim == nil then + return nil, "'channel' claim is missing"; + end + + local encRoom = issClaim .. "/" .. roomClaim; + claims["room"] = encRoom:gsub('.', function (c) return string.format('%02X', string.byte(c)) end):lower(); + module:log("debug", "room encoded from " .. encRoom .. " as " .. claims.room); + end + + local nickClaim = claims["nick"]; + if nickClaim == nil then + return nil, "'nick' claim is missing"; + end + + local audClaim = claims["aud"]; + if audClaim == nil then + audClaim = "kiwiClientID"; + claims["aud"] = audClaim; + end + --check the audience against the accepted list + local audCheck, audCheckErr = self:verify_audience(audClaim); + if audCheck == nil then + return nil, audCheckErr; + end + + return claims; +end + +--- Verifies token and process needed values to be stored in the session. +-- Token is obtained from session.auth_token. +-- Stores in session the following values: +-- session.jitsi_meet_room - the room name value from the token +-- session.jitsi_meet_domain - the domain name value from the token +-- session.jitsi_meet_context_user - the user details from the token +-- session.jitsi_meet_context_group - the group value from the token +-- session.jitsi_meet_context_features - the features value from the token +-- @param session the current session +-- @param acceptedIssuers optional list of accepted issuers to check +-- @return false and error +function Util:process_and_verify_token(session, acceptedIssuers) + if not acceptedIssuers then + acceptedIssuers = self.acceptedIssuers; + end + + if session.auth_token == nil then + if self.allowEmptyToken then + return true; + else + return false, "not-allowed", "token required"; + end + end + + local pubKey; + if session.public_key then + module:log("debug","Public key was found on the session"); + pubKey = session.public_key; + elseif self.asapKeyServer and session.auth_token ~= nil then + local dotFirst = session.auth_token:find("%."); + if not dotFirst then return nil, "Invalid token" end + local header, err = json_safe.decode(basexx.from_url64(session.auth_token:sub(1,dotFirst-1))); + if err then + return false, "not-allowed", "bad token format"; + end + local kid = header["kid"]; + if kid == nil then + return false, "not-allowed", "'kid' claim is missing"; + end + pubKey = self:get_public_key(kid); + if pubKey == nil then + return false, "not-allowed", "could not obtain public key"; + end + end + + -- now verify the whole token + local claims, msg; + if self.asapKeyServer then + claims, msg = self:verify_token(session.auth_token, pubKey, acceptedIssuers); + else + claims, msg = self:verify_token(session.auth_token, self.appSecret, acceptedIssuers); + end + if claims ~= nil then + -- Binds room name to the session which is later checked on MUC join + session.jitsi_meet_room = claims["room"]; + session.jitsi_meet_joined = claims["joined"]; + -- Binds domain name to the session + session.jitsi_meet_domain = claims["sub"]; + session.jitsi_meet_issuer = claims["iss"]; + + affil = {}; + affil.owner = false; + affil.admin = false; + affil.member = false; + affil.none = true; + affil.outcast = false; + + if (claims.modes ~= nil and type(claims.modes) == "table") then + for i, mode in ipairs(claims.modes) do + if mode == "o" then affil.owner = true + -- elseif mode == "%" then affil.member = true + -- elseif mode == "+" then affil.member = true + end + end + end + + if affil.owner then affil.final = "owner" + elseif affil.admin then affil.final = "admin" + elseif affil.member then affil.final = "member" + elseif affil.none then affil.final = "none" + else affil.final = "outcast" + end + --module:log("error", "---- Benz log msg - " .. affil.final .. " from " .. inspect(claims)); + --module:log("error", debug.traceback()); + session.jitsi_meet_room_affiliation = affil.final; + + local contextUser = {}; + contextUser["name"] = claims["nick"]; + session.jitsi_meet_context_user = contextUser; + + -- Binds the user details to the session if available + if claims["context"] ~= nil then + if claims["context"]["user"] ~= nil then + session.jitsi_meet_context_user = claims["context"]["user"]; + end + + if claims["context"]["group"] ~= nil then + -- Binds any group details to the session + session.jitsi_meet_context_group = claims["context"]["group"]; + end + + if claims["context"]["features"] ~= nil then + -- Binds any features details to the session + session.jitsi_meet_context_features = claims["context"]["features"]; + end + end + return true; + else + return false, "not-allowed", msg; + end +end + +--- Verifies room name and domain if necesarry. +-- Checks configs and if necessary checks the room name extracted from +-- room_address against the one saved in the session when token was verified. +-- Also verifies domain name from token against the domain in the room_address, +-- if enableDomainVerification is enabled. +-- @param session the current session +-- @param room_address the whole room address as received +-- @return returns true in case room was verified or there is no need to verify +-- it and returns false in case verification was processed +-- and was not successful +function Util:verify_room(session, room_address) + if self.allowEmptyToken and session.auth_token == nil then + module:log( + "debug", + "Skipped room token verification - empty tokens are allowed"); + return true; + end + + -- extract room name using all chars, except the not allowed ones + local room,_,_ = jid.split(room_address); + if room == nil then + log("error", + "Unable to get name of the MUC room ? to: %s", room_address); + return true; + end + + -- allow anonymous users because apparently that's how jicofo works. hopefully something else actually enforces access control elsewhere??? + if not session.jitsi_meet_issuer then + module:log("debug", "allowing anonymous session"); + return true + end + + -- handle auth for one-on-one query conversations + local decoded_room_name = hex.from(room); + module:log("debug", "decoded room name: " .. decoded_room_name); + local server_address, first_query_partner, second_query_partner = decoded_room_name:match("^([^/]+)/query[-]([^#]+)#([^#]+)$"); + local query_auth = first_query_partner ~= nil; + if query_auth then + local client_nick = session.jitsi_meet_context_user ~= nil and session.jitsi_meet_context_user.name; + local participant = client_nick == first_query_partner or client_nick == second_query_partner; + local same_server = server_address == session.jitsi_meet_issuer; + -- return participant and same_server; + if participant and same_server then + return true; + else + module:log("debug", "query auth failed. continuing..."); + end + end + + local auth_room = session.jitsi_meet_room; + if auth_room and not session.jitsi_meet_joined then + module:log("debug", "ignoring room claim without joined claim"); + auth_room = nil + end + if not self.enableDomainVerification then + -- if auth_room is missing, this means user is anonymous (no token for + -- its domain) we let it through, jicofo is verifying creation domain + if auth_room and room ~= string.lower(auth_room) and auth_room ~= '*' or query_auth then + return false; + end + + return true; + end + + local room_address_to_verify = jid.bare(room_address); + local room_node = jid.node(room_address); + -- parses bare room address, for multidomain expected format is: + -- [subdomain]roomName@conference.domain + local target_subdomain, target_room = extract_subdomain(room_node); + + -- if we have '*' as room name in token, this means all rooms are allowed + -- so we will use the actual name of the room when constructing strings + -- to verify subdomains and domains to simplify checks + local room_to_check; + if auth_room == '*' then + -- authorized for accessing any room assign to room_to_check the actual + -- room name + if target_room ~= nil then + -- we are in multidomain mode and we were able to extract room name + room_to_check = target_room; + else + -- no target_room, room_address_to_verify does not contain subdomain + -- so we get just the node which is the room name + room_to_check = room_node; + end + else + -- no wildcard, so check room against authorized room in token + room_to_check = auth_room; + end + + local auth_domain = session.jitsi_meet_domain; + local subdomain_to_check; + if target_subdomain then + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = target_subdomain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = auth_domain; + end + -- from this point we depend on muc_domain_base, + -- deny access if option is missing + if not self.muc_domain_base then + module:log("warn", "No 'muc_domain_base' option set, denying access!"); + return false; + end + + return room_address_to_verify == jid.join( + "["..string.lower(subdomain_to_check).."]"..string.lower(room_to_check), self.muc_domain); + else + if auth_domain == '*' then + -- check for wildcard in JWT claim, allow access if found + subdomain_to_check = self.muc_domain; + else + -- no wildcard in JWT claim, so check subdomain against sub in token + subdomain_to_check = self.muc_domain_prefix.."."..auth_domain; + end + -- we do not have a domain part (multidomain is not enabled) + -- verify with info from the token + return room_address_to_verify == jid.join( + string.lower(room_to_check), string.lower(subdomain_to_check)); + end +end + +return Util; diff --git a/jitsi-meet-modules/util_kiwi.lib.lua b/jitsi-meet-modules/util_kiwi.lib.lua new file mode 100644 index 0000000..83d796a --- /dev/null +++ b/jitsi-meet-modules/util_kiwi.lib.lua @@ -0,0 +1,347 @@ +local jid = require "util.jid"; +local timer = require "util.timer"; +local http = require "net.http"; + +module:log("info", "kiwiirc patch active: prosody-plugins/util_kiwi.lib.lua"); + +local http_timeout = 30; +local have_async, async = pcall(require, "util.async"); +local http_headers = { + ["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")" +}; + +local muc_domain_prefix + = module:get_option_string("muc_mapper_domain_prefix", "conference"); + +-- defaults to module.host, the module that uses the utility +local muc_domain_base + = module:get_option_string("muc_mapper_domain_base", module.host); + +-- The "real" MUC domain that we are proxying to +local muc_domain = module:get_option_string( + "muc_mapper_domain", muc_domain_prefix.."."..muc_domain_base); + +local escaped_muc_domain_base = muc_domain_base:gsub("%p", "%%%1"); +local escaped_muc_domain_prefix = muc_domain_prefix:gsub("%p", "%%%1"); +-- The pattern used to extract the target subdomain +-- (e.g. extract 'foo' from 'conference.foo.example.com') +local target_subdomain_pattern + = "^"..escaped_muc_domain_prefix..".([^%.]+)%."..escaped_muc_domain_base; + +-- table to store all incoming iqs without roomname in it, like discoinfo to the muc compoent +local roomless_iqs = {}; + +-- Utility function to split room JID to include room name and subdomain +-- (e.g. from room1@conference.foo.example.com/res returns (room1, example.com, res, foo)) +local function room_jid_split_subdomain(room_jid) + local node, host, resource = jid.split(room_jid); + + -- optimization, skip matching if there is no subdomain or it is not the muc component address at all + if host == muc_domain or not starts_with(host, muc_domain_prefix) then + return node, host, resource; + end + + local target_subdomain = host and host:match(target_subdomain_pattern); + return node, host, resource, target_subdomain +end + +--- Utility function to check and convert a room JID from +--- virtual room1@conference.foo.example.com to real [foo]room1@conference.example.com +-- @param room_jid the room jid to match and rewrite if needed +-- @param stanza the stanza +-- @return returns room jid [foo]room1@conference.example.com when it has subdomain +-- otherwise room1@conference.example.com(the room_jid value untouched) +local function room_jid_match_rewrite(room_jid, stanza) + local node, _, resource, target_subdomain = room_jid_split_subdomain(room_jid); + if not target_subdomain then + -- module:log("debug", "No need to rewrite out 'to' %s", room_jid); + return room_jid; + end + -- Ok, rewrite room_jid address to new format + local new_node, new_host, new_resource; + if node then + new_node, new_host, new_resource = "["..target_subdomain.."]"..node, muc_domain, resource; + else + -- module:log("debug", "No room name provided so rewriting only host 'to' %s", room_jid); + new_host, new_resource = muc_domain, resource; + + if (stanza and stanza.attr and stanza.attr.id) then + roomless_iqs[stanza.attr.id] = stanza.attr.to; + end + end + room_jid = jid.join(new_node, new_host, new_resource); + -- module:log("debug", "Rewrote to %s", room_jid); + return room_jid +end + +-- Utility function to check and convert a room JID from real [foo]room1@muc.example.com to virtual room1@muc.foo.example.com +local function internal_room_jid_match_rewrite(room_jid, stanza) + local node, host, resource = jid.split(room_jid); + if host ~= muc_domain or not node then + -- module:log("debug", "No need to rewrite %s (not from the MUC host)", room_jid); + + if (stanza and stanza.attr and stanza.attr.id and roomless_iqs[stanza.attr.id]) then + local result = roomless_iqs[stanza.attr.id]; + roomless_iqs[stanza.attr.id] = nil; + return result; + end + + return room_jid; + end + + local target_subdomain, target_node = extract_subdomain(node); + if not (target_node and target_subdomain) then + -- module:log("debug", "Not rewriting... unexpected node format: %s", node); + return room_jid; + end + + -- Ok, rewrite room_jid address to pretty format + local new_node, new_host, new_resource = target_node, muc_domain_prefix..".".. target_subdomain.."."..muc_domain_base, resource; + room_jid = jid.join(new_node, new_host, new_resource); + -- module:log("debug", "Rewrote to %s", room_jid); + return room_jid +end + +--- Finds and returns room by its jid +-- @param room_jid the room jid to search in the muc component +-- @return returns room if found or nil +function get_room_from_jid(room_jid) + local _, host = jid.split(room_jid); + local component = hosts[host]; + if component then + local muc = component.modules.muc + if muc and rawget(muc,"rooms") then + -- We're running 0.9.x or 0.10 (old MUC API) + return muc.rooms[room_jid]; + elseif muc and rawget(muc,"get_room_from_jid") then + -- We're running >0.10 (new MUC API) + return muc.get_room_from_jid(room_jid); + else + return + end + end +end + +function async_handler_wrapper(event, handler) + if not have_async then + module:log("error", "requires a version of Prosody with util.async"); + return nil; + end + + local runner = async.runner; + + -- Grab a local response so that we can send the http response when + -- the handler is done. + local response = event.response; + local async_func = runner( + function (event) + local result = handler(event) + + -- If there is a status code in the result from the + -- wrapped handler then add it to the response. + if tonumber(result.status_code) ~= nil then + response.status_code = result.status_code + end + + -- If there are headers in the result from the + -- wrapped handler then add them to the response. + if result.headers ~= nil then + response.headers = result.headers + end + + -- Send the response to the waiting http client with + -- or without the body from the wrapped handler. + if result.body ~= nil then + response:send(result.body) + else + response:send(); + end + end + ) + async_func:run(event) + -- return true to keep the client http connection open. + return true; +end + +--- Updates presence stanza, by adding identity node +-- @param stanza the presence stanza +-- @param user the user to which presence we are updating identity +-- @param group the group of the user to which presence we are updating identity +-- @param creator_user the user who created the user which presence we +-- are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +-- @param creator_group the group of the user who created the user which +-- presence we are updating (this is the poltergeist case, where a user creates +-- a poltergeist), optional. +function update_presence_identity( + stanza, user, group, creator_user, creator_group) + + -- First remove any 'identity' element if it already + -- exists, so it cannot be spoofed by a client + stanza:maptags( + function(tag) + for k, v in pairs(tag) do + if k == "name" and v == "identity" then + return nil + end + -- Also remove the nick element + if k == "name" and v == "nick" then + return nil + end + end + return tag + end + ) + module:log("debug", + "Presence after previous identity stripped: %s", tostring(stanza)); + + -- Override nick + stanza:tag("nick", {xmlns='http://jabber.org/protocol/nick'}):text(user.name):up(); + + stanza:tag("identity"):tag("user"); + for k, v in pairs(user) do + v = tostring(v) + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the group information if it is present + if group then + stanza:tag("group"):text(group):up(); + end + + -- Add the creator user information if it is present + if creator_user then + stanza:tag("creator_user"); + for k, v in pairs(creator_user) do + stanza:tag(k):text(v):up(); + end + stanza:up(); + + -- Add the creator group information if it is present + if creator_group then + stanza:tag("creator_group"):text(creator_group):up(); + end + stanza:up(); + end + + module:log("debug", + "Presence with identity inserted %s", tostring(stanza)) +end + +-- Utility function to check whether feature is present and enabled. Allow +-- a feature if there are features present in the session(coming from +-- the token) and the value of the feature is true. +-- If features is not present in the token we skip feature detection and allow +-- everything. +function is_feature_allowed(session, feature) + if (session.jitsi_meet_context_features == nil + or session.jitsi_meet_context_features[feature] == "true" or session.jitsi_meet_context_features[feature] == true) then + return true; + else + return false; + end +end + +--- Extracts the subdomain and room name from internal jid node [foo]room1 +-- @return subdomain(optional, if extracted or nil), the room name +function extract_subdomain(room_node) + -- optimization, skip matching if there is no subdomain, no [subdomain] part in the beginning of the node + if not starts_with(room_node, '[') then + return nil,room_node; + end + + return room_node:match("^%[([^%]]+)%](.+)$"); +end + +function starts_with(str, start) + return str:sub(1, #start) == start +end + +-- healthcheck rooms in jicofo starts with a string '__jicofo-health-check' +function is_healthcheck_room(room_jid) + if starts_with(room_jid, "__jicofo-health-check") then + return true; + end + + return false; +end + +--- Utility function to make an http get request and +--- retry @param retry number of times +-- @param url endpoint to be called +-- @param retry nr of retries, if retry is +-- nil there will be no retries +-- @returns result of the http call or nil if +-- the external call failed after the last retry +function http_get_with_retry(url, retry) + local content, code; + local timeout_occurred; + local wait, done = async.waiter(); + local function cb(content_, code_, response_, request_) + if timeout_occurred == nil then + code = code_; + if code == 200 or code == 204 then + module:log("debug", "External call was successful, content %s", content_); + content = content_ + else + module:log("warn", "Error on public key request: Code %s, Content %s", + code_, content_); + end + done(); + else + module:log("warn", "External call reply delivered after timeout from: %s", url); + end + end + + local function call_http() + return http.request(url, { + headers = http_headers or {}, + method = "GET" + }, cb); + end + + local request = call_http(); + + local function cancel() + -- TODO: This check is racey. Not likely to be a problem, but we should + -- still stick a mutex on content / code at some point. + if code == nil then + timeout_occurred = true; + module:log("warn", "Timeout %s seconds making the external call to: %s", http_timeout, url); + -- no longer present in prosody 0.11, so check before calling + if http.destroy_request ~= nil then + http.destroy_request(request); + end + if retry == nil then + module:log("debug", "External call failed and retry policy is not set"); + done(); + elseif retry ~= nil and retry < 1 then + module:log("debug", "External call failed after retry") + done(); + else + module:log("debug", "External call failed, retry nr %s", retry) + retry = retry - 1; + request = call_http() + return http_timeout; + end + end + end + timer.add_task(http_timeout, cancel); + wait(); + + return content; +end + +return { + extract_subdomain = extract_subdomain; + is_feature_allowed = is_feature_allowed; + is_healthcheck_room = is_healthcheck_room; + get_room_from_jid = get_room_from_jid; + async_handler_wrapper = async_handler_wrapper; + room_jid_match_rewrite = room_jid_match_rewrite; + room_jid_split_subdomain = room_jid_split_subdomain; + internal_room_jid_match_rewrite = internal_room_jid_match_rewrite; + update_presence_identity = update_presence_identity; + http_get_with_retry = http_get_with_retry; +};