-- Enforces JWT token room-claim verification at MUC join/create time. Loaded -- on the MUC component. For each join or room-create, calls -- token_util:verify_room() to check that the token's room claim matches the -- target room JID. Admins and domains listed in token_verification_allowlist -- are exempt. Anonymous users (no token) are allowed through. When -- token_verification_require_token_for_moderation is set, also blocks room -- config IQs (e.g. granting moderator status) from unauthenticated users. -- Records the verified conference on the session (jitsi_meet_verified_room) and -- answers the 'jitsi-verify-session-rooms' event, so that a session whose JWT -- claims are refreshed after the join (mod_auth_token, on stream resumption) is -- re-checked against it. -- Token authentication -- Copyright (C) 2021-present 8x8, Inc. local log = module._log; local host = module.host; local st = require "util.stanza"; local jid_split = require 'util.jid'.split; local jid_bare = require 'util.jid'.bare; local util = module:require 'util'; local is_admin = util.is_admin; local DEBUG = false; local measure_success = module:measure('success', 'counter'); local measure_fail = module:measure('fail', 'counter'); local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")(); if parentHostName == nil then module:log("error", "Failed to start - unable to get parent hostname"); return; end local parentCtx = module:context(parentHostName); if parentCtx == nil then module:log("error", "Failed to start - unable to get parent context for host: %s", tostring(parentHostName)); return; end local token_util = module:require "token/util".new(parentCtx); -- no token configuration if token_util == nil then return; end module: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; -- option to allow domains to skip token verification local allowlist; local function load_config() require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation"); allowlist = module:get_option_set('token_verification_allowlist', {}); 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) if DEBUG then module:log("debug", "Session token: %s, session room: %s", tostring(session.auth_token), tostring(session.jitsi_meet_room)); end -- token not required for admin users local user_jid = stanza.attr.from; if is_admin(user_jid) then if DEBUG then module:log("debug", "Token not required from admin user: %s", user_jid); end return true; end -- token not required for users matching allow list local user_bare_jid = jid_bare(user_jid); local _, user_domain = jid_split(user_jid); -- allowlist for participants, jigasi (sip & transcriber), jibri (recorder & sip) if allowlist:contains(user_domain) or allowlist:contains(user_bare_jid) -- allow main participants in visitor mode or session.type == 's2sin' then if DEBUG then module:log("debug", "Token not required from user in allow list: %s", user_jid); end return true; end if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end local res, err, reason = token_util:verify_room(session, stanza.attr.to); if not res then if not err and not reason then reason = 'Room and token mismatched'; end module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s', tostring(session.auth_token), tostring(stanza.attr.to), err, reason); local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason); if err then response:tag(err, { xmlns = 'http://jitsi.org/jitmeet' }); end session.send(response); return false; -- we need to just return non nil end if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end -- Remember the conference this session was verified for, so claims that are -- refreshed later (mod_auth_token, on stream resumption) can be checked -- against it without going through a join again. session.jitsi_meet_verified_room = jid_bare(stanza.attr.to); return true; end module:hook("muc-room-pre-create", function(event) local origin, stanza = event.origin, event.stanza; if DEBUG then module:log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); end if not verify_user(origin, stanza) then measure_fail(1); return true; -- Returning any value other than nil will halt processing of the event end measure_success(1); end, 99); module:hook("muc-occupant-pre-join", function(event) local origin, room, stanza = event.origin, event.room, event.stanza; if DEBUG then module:log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); end if not verify_user(origin, stanza) then measure_fail(1); return true; -- Returning any value other than nil will halt processing of the event end measure_success(1); end, 99); -- Verifies the token claims currently on a session against the conference it -- was verified for on join. Fired by mod_auth_token when a connection resuming -- a hibernating session (XEP-0198) presents a different token, which refreshes -- the claims of the live session: the hooks above only run at join time, so -- this applies the same room check to a session that is already in a room. -- Answering with res=false makes mod_auth_token keep the claims that were -- verified on join. module:hook_global('jitsi-verify-session-rooms', function(event) local session = event.session; local room_jid = session.jitsi_meet_verified_room; if not room_jid then return; end -- Being an occupant is deliberately not required: a session that moved into -- a breakout room has left the main room it joined, and that main room is -- still the conference its claims have to cover. Only a room that is gone -- (everyone left while the session was hibernating) is skipped. if not prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return; end local res, err, reason = token_util:verify_room(session, room_jid); if not res then measure_fail(1); return { res = false; room = room_jid; error = err; reason = reason; }; end measure_success(1); 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);