view mod_auth_phpbb3/mod_auth_phpbb3.lua @ 6299:5cf5ee23b361

mod_voipms: New Module to send and receive SMS/MMS via VoIP.ms APIs. diff --git a/mod_voipms/README.md b/mod_voipms/README.md new file mode 100644 --- /dev/null +++ b/mod_voipms/README.md @@ -0,0 +1,51 @@ +--- +labels: +- 'Stage-Alpha' +- 'Type-Web' +summary: Send and receive SMS/MMS via VoIP.ms APIs. +rockspec: + build: + modules: + mod_voipms: mod_voipms.lua +... + +Introduction +============ + +This is a Prosody module to map JIDs to DIDs on VoIP.ms and support sending/receiving SMS/MMS. + +Configuration +============= + +| option | type | default | +|-----------------------|--------|---------| +| voipms\_api\_username | string | nil | +| voipms\_api\_password | string | nil | +| voipms\_query\_key | string | nil | +| voipms\_jid\_map | table | nil | +``` +VirtualHost "sms.example.com" +modules_enabled = { + "voipms"; +} +voipms_api_username = john@example.com -- E-mail registered with VoIP.ms +voipms_api_password = abcd1234 -- API password configured in VoIP.ms +voipms_query_key = some_query_key -- query param 'key' part of your URL callback +voipms_jid_map = { + ["your_jid@your_domain.com"] = "+1234567890" +} +``` + +HTTP +==== + +The module is served on Prosody's default HTTP ports at the path /voipms. More details on configuring HTTP modules in Prosody can be found in the HTTP documentation. + +VoIP.ms Webhook URL +=================== + +This module receives the VoIP.ms Webhook URL (POST) at the /voipms endpoint. It uses the sendSMS/sendMMS GET methods against the VoIP.ms APIs. This is an example webhook to use in VoIP.ms: + +``` +https://sms.example.com/voipms?key=some_query_key +``` diff --git a/mod_voipms/mod_voipms.lua b/mod_voipms/mod_voipms.lua new file mode 100644 --- /dev/null +++ b/mod_voipms/mod_voipms.lua @@ -0,0 +1,166 @@ +local http = require "net.http" +local json = require "util.json" +local st = require "util.stanza" + +local api_username = module:get_option_string("voipms_api_username") +local api_password = module:get_option_string("voipms_api_password") +local query_key = module:get_option("voipms_query_key") +local jid_map = module:get_option("voipms_jid_map") or {} +local rest_endpoint = "https://voip.ms/api/v1/rest.php" + +if not api_username or not api_password or not query_key then + module:log("error", "Missing required config values (voipms_api_username, voipms_api_password, voipms_query_key)") + return +end + +module:depends("http") + +local function normalize_number(num) + if not num then return nil end + if num:sub(1, 1) ~= "+" then + return "+1" .. num + end + return num +end + +local function extract_query_key(event) + return (event.request.url.query or ""):match("key=([^&]+)") +end + +module:provides("http", { + route = { + ["POST"] = function(event) + local req = event.request + local body = req.body or "" + + if extract_query_key(event) ~= query_key then + module:log("warn", "Unauthorized webhook: missing or invalid key") + return { status_code = 403 } + end + + local json_payload, err = json.decode(body) + if not json_payload then + module:log("warn", "Invalid JSON: %s", err or "unknown error") + return { status_code = 400 } + end + + local payload = json_payload.data and json_payload.data.payload + if not payload then + module:log("warn", "Missing payload in JSON") + return { status_code = 400 } + end + + local from = payload.from and payload.from.phone_number + local to_list = payload.to or {} + local to = #to_list > 0 and to_list[1].phone_number or nil + + if not from or not to then + module:log("warn", "Missing phone numbers (from: %s, to: %s)", tostring(from), tostring(to)) + return { status_code = 400 } + end + + local normalized_to = normalize_number(to) + local target_jid = nil + + for jid, did in pairs(jid_map) do + if normalize_number(did) == normalized_to then + target_jid = jid + break + end + end + + if not target_jid then + module:log("warn", "No JID mapping for DID %s", normalized_to) + return { status_code = 404 } + end + + local normalized_from = normalize_number(from) + local message_text = payload.text or "" + + local message = st.message({ + from = normalized_from .. "@" .. module.host, + to = target_jid, + type = "chat" + }):tag("body"):text(message_text):up() + + if payload.media and #payload.media > 0 then + for _, media_item in ipairs(payload.media) do + if media_item.url then + message_text = message_text .. "\n" .. media_item.url + message:tag("x", { xmlns = "jabber:x:oob" }) + :tag("url"):text(media_item.url):up():up() + end + end + end + + module:send(message) + module:log("info", "Delivered SMS from %s to %s", normalized_from, target_jid) + + return { status_code = 204 } + end + } +}) + +module:hook("message/bare", function(event) + local stanza = event.stanza + if stanza.attr.type ~= "chat" then return end + + local from_jid = stanza.attr.from + local to_jid = stanza.attr.to + local body = stanza:get_child_text("body") + if not body or body == "" then return end + + local from_number = jid_map[from_jid] + if not from_number then + module:log("warn", "No DID mapping for JID %s", from_jid) + return + end + + local to_number = to_jid:match("^([^@]+)") + if not to_number then + module:log("warn", "Malformed JID in message.to: %s", to_jid) + return + end + + to_number = normalize_number(to_number) + + local media_urls = {} + for line in body:gmatch("[^\r\n]+") do + if line:match("^https?://") then + table.insert(media_urls, line) + end + end + + local method = (#media_urls > 0) and "sendMMS" or "sendSMS" + local query = { + api_username = api_username, + api_password = api_password, + method = method, + did = from_number, + dst = to_number, + message = body + } + + if method == "sendMMS" then + for i, url in ipairs(media_urls) do + query["media_url[" .. (i - 1) .. "]"] = url + end + end + + local query_str = http.formencode(query) + + http.request(rest_endpoint .. "?" .. query_str, { + method = "GET"; + }, function(response_body, code) + if code == 200 then + local resp, err = json.decode(response_body) + if not resp or resp.status ~= "success" then + module:log("error", "Failed to send %s: %s", method, err or (resp and resp.status) or "unknown") + else + module:log("info", "Sent %s from %s to %s", method, from_number, to_number) + end + else + module:log("error", "HTTP error sending %s: code %s", method, tostring(code)) + end + end) +end)
author Chaz <menel@snikket.de>
date Mon, 21 Jul 2025 23:57:37 +0200
parents 28d99ffa3c06
children c400892603ce
line wrap: on
line source

-- phpbb3 authentication backend for Prosody
--
-- Copyright (C) 2011 Waqas Hussain
--

local log = require "util.logger".init("auth_sql");
local new_sasl = require "util.sasl".new;
local nodeprep = require "util.encodings".stringprep.nodeprep;
local saslprep = require "util.encodings".stringprep.saslprep;
local DBI = require "DBI"
local md5 = require "util.hashes".md5;
local uuid_gen = require "util.uuid".generate;
local have_bcrypt, bcrypt = pcall(require, "bcrypt"); -- available from luarocks

local connection;
local params = module:get_option("sql");

local resolve_relative_path = require "core.configmanager".resolve_relative_path;

local function test_connection()
	if not connection then return nil; end
	if connection:ping() then
		return true;
	else
		module:log("debug", "Database connection closed");
		connection = nil;
	end
end
local function connect()
	if not test_connection() then
		prosody.unlock_globals();
		local dbh, err = DBI.Connect(
			params.driver, params.database,
			params.username, params.password,
			params.host, params.port
		);
		prosody.lock_globals();
		if not dbh then
			module:log("debug", "Database connection failed: %s", tostring(err));
			return nil, err;
		end
		module:log("debug", "Successfully connected to database");
		dbh:autocommit(true); -- don't run in transaction
		connection = dbh;
		return connection;
	end
end

do -- process options to get a db connection
	params = params or { driver = "SQLite3" };

	if params.driver == "SQLite3" then
		params.database = resolve_relative_path(prosody.paths.data or ".", params.database or "prosody.sqlite");
	end

	assert(params.driver and params.database, "Both the SQL driver and the database need to be specified");

	assert(connect());
end

local function getsql(sql, ...)
	if params.driver == "PostgreSQL" then
		sql = sql:gsub("`", "\"");
	end
	if not test_connection() then connect(); end
	-- do prepared statement stuff
	local stmt, err = connection:prepare(sql);
	if not stmt and not test_connection() then error("connection failed"); end
	if not stmt then module:log("error", "QUERY FAILED: %s %s", err, debug.traceback()); return nil, err; end
	-- run query
	local ok, err = stmt:execute(...);
	if not ok and not test_connection() then error("connection failed"); end
	if not ok then return nil, err; end

	return stmt;
end
local function setsql(sql, ...)
	local stmt, err = getsql(sql, ...);
	if not stmt then return stmt, err; end
	return stmt:affected();
end

local function get_password(username)
	local stmt, err = getsql("SELECT `user_password` FROM `phpbb_users` WHERE `username_clean`=?", username);
	if stmt then
		for row in stmt:rows(true) do
			return row.user_password;
		end
	end
end
local function check_sessionids(username, session_id)
	-- TODO add session expiration and auto-login check
	local stmt, err = getsql("SELECT phpbb_sessions.session_id FROM phpbb_sessions INNER JOIN phpbb_users ON phpbb_users.user_id = phpbb_sessions.session_user_id WHERE phpbb_users.username_clean =?", username);
	if stmt then
		for row in stmt:rows(true) do
			-- if row.session_id == session_id then return true; end

			-- workaround for possible LuaDBI bug
			-- The session_id returned by the sql statement has an additional zero at the end. But that is not in the database.
			if row.session_id == session_id or row.session_id == session_id.."0" then return true; end
		end
	end
end


local itoa64 = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
local function hashEncode64(input, count)
	local output = "";
	local i, value = 0, 0;

	while true do
		value = input:byte(i+1)
		i = i+1;
		local idx = value % 0x40 + 1;
		output = output .. itoa64:sub(idx, idx);

		if i < count then
			value = value + input:byte(i+1) * 256;
		end
		local _ = value % (2^6);
		local idx = ((value - _) / (2^6)) % 0x40 + 1
		output = output .. itoa64:sub(idx, idx);

		if i >= count then break; end
		i = i+1;

		if i < count then
			value = value + input:byte(i+1) * 256 * 256;
		end
		local _ = value % (2^12);
		local idx = ((value - _) / (2^12)) % 0x40 + 1
		output = output .. itoa64:sub(idx, idx);

		if i >= count then break; end
		i = i+1;

		local _ = value % (2^18);
		local idx = ((value - _) / (2^18)) % 0x40 + 1
		output = output .. itoa64:sub(idx, idx);

		if not(i < count) then break; end
	end
	return output;
end
local function hashCryptPrivate(password, genSalt)
	local output = "*";
	if not genSalt:match("^%$H%$") then return output; end

	local count_log2 = itoa64:find(genSalt:sub(4,4)) - 1;
	if count_log2 < 7 or count_log2 > 30 then return output; end

	local count = 2 ^ count_log2;
	local salt = genSalt:sub(5, 12);

	if #salt ~= 8 then return output; end

	local hash = md5(salt..password);

	while true do
		hash = md5(hash..password);
		if not(count > 1) then break; end
		count = count-1;
	end

	output = genSalt:sub(1, 12);
	output = output .. hashEncode64(hash, 16);

	return output;
end
local function hashGensaltPrivate(input)
	local iteration_count_log2 = 6;
	local output = "$H$";
	local idx = math.min(iteration_count_log2 + 5, 30) + 1;
	output = output .. itoa64:sub(idx, idx);
	output = output .. hashEncode64(input, 6);
	return output;
end
local function phpbbCheckHash(password, hash)
	if #hash == 32 then return hash == md5(password, true); end -- legacy PHPBB2 hash
	if #hash == 34 then return hashCryptPrivate(password, hash) == hash; end
	if #hash == 60 and have_bcrypt then return bcrypt.verify(password, hash); end
	module:log("error", "Unsupported hash: %s", hash);
	return false;
end
local function phpbbCreateHash(password)
	local random = uuid_gen():sub(-6);
	local salt = hashGensaltPrivate(random);
	local hash = hashCryptPrivate(password, salt);
	if #hash == 34 then return hash; end
	return md5(password, true);
end


provider = {};

function provider.test_password(username, password)
	local hash = get_password(username);
	return hash and phpbbCheckHash(password, hash);
end
function provider.user_exists(username)
	module:log("debug", "test user %s existence", username);
	return get_password(username) and true;
end

function provider.get_password(username)
	return nil, "Getting password is not supported.";
end
function provider.set_password(username, password)
	local hash = phpbbCreateHash(password);
	local stmt, err = setsql("UPDATE `phpbb_users` SET `user_password`=? WHERE `username_clean`=?", hash, username);
	return stmt and true, err;
end
function provider.create_user(username, password)
	return nil, "Account creation/modification not supported.";
end

local escapes = {
	[" "] = "\\20";
	['"'] = "\\22";
	["&"] = "\\26";
	["'"] = "\\27";
	["/"] = "\\2f";
	[":"] = "\\3a";
	["<"] = "\\3c";
	[">"] = "\\3e";
	["@"] = "\\40";
	["\\"] = "\\5c";
};
local unescapes = {};
for k,v in pairs(escapes) do unescapes[v] = k; end
local function jid_escape(s) return s and (s:gsub(".", escapes)); end
local function jid_unescape(s) return s and (s:gsub("\\%x%x", unescapes)); end

function provider.get_sasl_handler()
	local sasl = {};
	function sasl:clean_clone() return provider.get_sasl_handler(); end
	function sasl:mechanisms() return { PLAIN = true; }; end
	function sasl:select(mechanism)
		if not self.selected and mechanism == "PLAIN" then
			self.selected = mechanism;
			return true;
		end
	end
	function sasl:process(message)
		if not message then return "failure", "malformed-request"; end
		local authorization, authentication, password = message:match("^([^%z]*)%z([^%z]+)%z([^%z]+)");
		if not authorization then return "failure", "malformed-request"; end
		authentication = saslprep(authentication);
		password = saslprep(password);
		if (not password) or (password == "") or (not authentication) or (authentication == "") then
			return "failure", "malformed-request", "Invalid username or password.";
		end
		local function test(authentication)
			local prepped = nodeprep(authentication);
			local normalized = jid_unescape(prepped);
			return normalized and provider.test_password(normalized, password) and prepped;
		end
		local username = test(authentication) or test(jid_escape(authentication));
		if not username and params.sessionid_as_password then
			local function test(authentication)
				local prepped = nodeprep(authentication);
				local normalized = jid_unescape(prepped);
				return normalized and check_sessionids(normalized, password) and prepped;
			end
			username = test(authentication) or test(jid_escape(authentication));
		end
		if username then
			self.username = username;
			return "success";
		end
		return "failure", "not-authorized", "Unable to authorize you with the authentication credentials you've sent.";
	end
	return sasl;
end

module:provides("auth", provider);