# HG changeset patch # User mrDoctorWho # Date 1396108584 -25200 # Node ID 985bfc6e8cad13b8f98eb6167d888228da8cf137 # Parent a573d64968e99f6bcefbdc5c5e9a272779ded1aa mod_captcha_registration: initial commit diff -r a573d64968e9 -r 985bfc6e8cad mod_captcha_registration/FiraSans-Regular.ttf Binary file mod_captcha_registration/FiraSans-Regular.ttf has changed diff -r a573d64968e9 -r 985bfc6e8cad mod_captcha_registration/install.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_captcha_registration/install.lua Sat Mar 29 22:56:24 2014 +0700 @@ -0,0 +1,36 @@ +-- simple installer for mod_register with dependicies + +files = {"util/dataforms.lua", "modules/mod_register.lua", "FiraSans-Regular.ttf"} + +default_path = "/usr/lib/prosody" + + +function exists(name) + if type(name) ~= "string" then return false end + return os.rename(name, name) and true or false +end + +function copy_file(name, target) + local file = io.open(name) + local data = file:read("*all") + file:close() + local file = io.open(target, "w") + file:write(data) + file:close() +end + +function copy_files(path) + for index = 1, #files do + local filename = files[index] + os.remove(default_path.."/"..filename) + copy_file(filename, default_path.."/"..filename) + print("copied: "..default_path.."/"..filename) + end +end + +if not exists(default_path) then + io.write("\nEnter prosody path [/usr/lib/prosody]: ") + path = io.read("*line") +end + +copy_files(path or default_path) \ No newline at end of file diff -r a573d64968e9 -r 985bfc6e8cad mod_captcha_registration/modules/mod_register.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_captcha_registration/modules/mod_register.lua Sat Mar 29 22:56:24 2014 +0700 @@ -0,0 +1,389 @@ +-- Prosody IM +-- Copyright (C) 2008-2010 Matthew Wild +-- Copyright (C) 2008-2010 Waqas Hussain +-- Modifications copyright (C) 2014 mrDoctorWho +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + + +local st = require "util.stanza"; +local dataform_new = require "util.dataforms".new; +local usermanager_user_exists = require "core.usermanager".user_exists; +local usermanager_create_user = require "core.usermanager".create_user; +local usermanager_set_password = require "core.usermanager".set_password; +local usermanager_delete_user = require "core.usermanager".delete_user; +local os_time = os.time; +local nodeprep = require "util.encodings".stringprep.nodeprep; +local jid_bare = require "util.jid".bare; +local timer = require "util.timer"; + + +local math = require "math"; +local captcha = require "captcha"; + + + +local base64 = require "util.encodings".base64.encode; +local sha1 = require "util.hashes".sha1; + + +local captcha_ids = {}; + +local config = module:get_option("captcha_config") or {}; + + +local compat = module:get_option_boolean("registration_compat", true); +local allow_registration = module:get_option_boolean("allow_registration", false); +local additional_fields = module:get_option("additional_registration_fields", {}); + +local account_details = module:open_store("account_details"); + +local field_map = { + username = { name = "username", type = "text-single", label = "Username", required = true }; + password = { name = "password", type = "text-private", label = "Password", required = true }; + nick = { name = "nick", type = "text-single", label = "Nickname" }; + name = { name = "name", type = "text-single", label = "Full Name" }; + first = { name = "first", type = "text-single", label = "Given Name" }; + last = { name = "last", type = "text-single", label = "Family Name" }; + email = { name = "email", type = "text-single", label = "Email" }; + address = { name = "address", type = "text-single", label = "Street" }; + city = { name = "city", type = "text-single", label = "City" }; + state = { name = "state", type = "text-single", label = "State" }; + zip = { name = "zip", type = "text-single", label = "Postal code" }; + phone = { name = "phone", type = "text-single", label = "Telephone number" }; + url = { name = "url", type = "text-single", label = "Webpage" }; + date = { name = "date", type = "text-single", label = "Birth date" }; + + -- something new + formtype = { name = "FORM_TYPE", type = "hidden"}; + captcha_text = { name = "captcha_text", type = "fixed", label = "Warning: "}; + captcha_psi = { name = "captchahidden", type = "hidden" }; -- Don't know exactly why, but it exists in ejabberd register form + captcha_url = { name = "url", type = "text-single", label = "Captcha url"}; + from = { name = "from", type = "hidden" }; + captcha_challenge = { name = "challenge", type = "hidden" }; + sid = { name = "sid", type = "hidden" }; + ocr = { name = "ocr", label = "Enter shown text", required = true, type = "media" } +}; + +local registration_form = dataform_new{ + + field_map.formtype; + field_map.username; + field_map.password; + field_map.captcha_text; +-- field_map.captcha_psi; -- Maybe later, i really have no idea why it used in ejabberd reg form + field_map.captcha_url; + field_map.from; + field_map.captcha_challenge; + field_map.sid; + field_map.ocr; +}; + + +function delete_captcha(cid) + os.remove(string.format("%s/%s.png", config.dir, cid)) + captcha_ids[cid] = nil; +end + +for _, field in ipairs(additional_fields) do + if type(field) == "table" then + registration_form[#registration_form + 1] = field; + else + if field:match("%+$") then + field = field:sub(1, #field - 1); + field_map[field].required = true; + end + + registration_form[#registration_form + 1] = field_map[field]; + registration_query:tag(field):up(); + end +end + +module:add_feature("jabber:iq:register"); + +local register_stream_feature = st.stanza("register", {xmlns="http://jabber.org/features/iq-register"}):up(); +module:hook("stream-features", function(event) + local session, features = event.origin, event.features; + + -- Advertise registration to unauthorized clients only. + if not(allow_registration) or session.type ~= "c2s_unauthed" then + return + end + + features:add_child(register_stream_feature); +end); + +local function handle_registration_stanza(event) + local session, stanza = event.origin, event.stanza; + + local query = stanza.tags[1]; + if stanza.attr.type == "get" then + local reply = st.reply(stanza); + reply:tag("query", {xmlns = "jabber:iq:register"}) + :tag("registered"):up() + :tag("username"):text(session.username):up() + :tag("password"):up(); + session.send(reply); + else -- stanza.attr.type == "set" + if query.tags[1] and query.tags[1].name == "remove" then + local username, host = session.username, session.host; + + local old_session_close = session.close; + session.close = function(session, ...) + session.send(st.reply(stanza)); + return old_session_close(session, ...); + end + + local ok, err = usermanager_delete_user(username, host); + + if not ok then + module:log("debug", "Removing user account %s@%s failed: %s", username, host, err); + session.close = old_session_close; + session.send(st.error_reply(stanza, "cancel", "service-unavailable", err)); + return true; + end + + module:log("info", "User removed their account: %s@%s", username, host); + module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session }); + else + local username = nodeprep(query:get_child("username"):get_text()); + local password = query:get_child("password"):get_text(); + if username and password then + if username == session.username then + if usermanager_set_password(username, password, session.host) then + session.send(st.reply(stanza)); + else + -- TODO unable to write file, file may be locked, etc, what's the correct error? + session.send(st.error_reply(stanza, "wait", "internal-server-error")); + end + else + session.send(st.error_reply(stanza, "modify", "bad-request")); + end + else + session.send(st.error_reply(stanza, "modify", "bad-request")); + end + end + end + return true; +end + +module:hook("iq/self/jabber:iq:register:query", handle_registration_stanza); +if compat then + module:hook("iq/host/jabber:iq:register:query", function (event) + local session, stanza = event.origin, event.stanza; + if session.type == "c2s" and jid_bare(stanza.attr.to) == session.host then + return handle_registration_stanza(event); + end + end); +end + +local function parse_response(query) + local form = query:get_child("x", "jabber:x:data"); + if form then + return registration_form:data(form); + else + local data = {}; + local errors = {}; + for _, field in ipairs(registration_form) do + local name, required = field.name, field.required; + if field_map[name] then + data[name] = query:get_child_text(name); + if (not data[name] or #data[name] == 0) and required then + errors[name] = "Required value missing"; + end + end + end + if next(errors) then + return data, errors; + end + return data; + end +end + +local recent_ips = {}; +local min_seconds_between_registrations = module:get_option("min_seconds_between_registrations"); +local whitelist_only = module:get_option("whitelist_registration_only"); +local whitelisted_ips = module:get_option("registration_whitelist") or { "127.0.0.1" }; +local blacklisted_ips = module:get_option("registration_blacklist") or {}; + +for _, ip in ipairs(whitelisted_ips) do whitelisted_ips[ip] = true; end +for _, ip in ipairs(blacklisted_ips) do blacklisted_ips[ip] = true; end + + +local function get_file(name) + local file = io.open(name, "rb") + local data = file:read("*all") + file:close() + return data +end + + +local function get_captcha() + local cap = captcha.new(); + math.randomseed(os_time()); + local cid = tostring(math.random(1000, 90000)); -- random cid used for cap name + cap:font(config.font); + cap:scribble(); + captcha_ids[cid] = cap:write(string.format("%s/%s.png", config.dir, cid)):lower(); + timer.add_task(config.timeout, function() delete_captcha(cid) end); -- Add new function to use arguments. Is there any other way in lua? Or it even works? + return cid +end + + + +module:hook("stanza/iq/jabber:iq:register:query", function(event) + local session, stanza = event.origin, event.stanza; + + if not(allow_registration) or session.type ~= "c2s_unauthed" then + session.send(st.error_reply(stanza, "cancel", "service-unavailable")); + else + local query = stanza.tags[1]; + if stanza.attr.type == "get" then + local reply = st.reply(stanza):query("jabber:iq:register"); + -- TODO: Move this in standalone function + local challenge = get_captcha() + local captcha_data = get_file(config.dir.."/"..challenge..".png") + local captcha_sha = sha1(captcha_data, true) -- omg + local captcha_base64 = base64(captcha_data) -- lol wut + xml = registration_form:form(({FORM_TYPE = "urn:xmpp:captcha", + from = session.host, + ocr = {{ + type = "image/png", + uri = string.format("cid:sha1+%s@bob.xmpp.org", captcha_sha) + }}; + url = string.format("http://%s:5280/%s/%s", session.host, config.web_path, challenge); + captcha_text = "If you can't see an image, follow link below"; + challenge = challenge; + sid = "1"; + })); + + data = st.stanza("data", + {xmlns = "urn:xmpp:bob", + cid = string.format("sha1+%s@bob.xmpp.org", captcha_sha), + type = "image/png", + ["max-age"] = config.timeout}) + :text(captcha_base64); + + reply = reply:add_child(xml); + reply = reply:add_child(data); + session.send(reply); + + elseif stanza.attr.type == "set" then + if query.tags[1] and query.tags[1].name == "remove" then + session.send(st.error_reply(stanza, "auth", "registration-required")); + else + local data, errors = parse_response(query); + if errors then + session.send(st.error_reply(stanza, "modify", "not-acceptable")); + else + -- Check that the user is not blacklisted or registering too often + if not session.ip then + module:log("debug", "User's IP not known; can't apply blacklist/whitelist"); + elseif blacklisted_ips[session.ip] or (whitelist_only and not whitelisted_ips[session.ip]) then + session.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not allowed to register an account.")); + return true; + elseif min_seconds_between_registrations and not whitelisted_ips[session.ip] then + if not recent_ips[session.ip] then + recent_ips[session.ip] = { time = os_time(), count = 1 }; + else + local ip = recent_ips[session.ip]; + ip.count = ip.count + 1; + + if os_time() - ip.time < min_seconds_between_registrations then + ip.time = os_time(); + session.send(st.error_reply(stanza, "wait", "not-acceptable")); + return true; + end + ip.time = os_time(); + end + end + local host = module.host; + local ocr = data.ocr:lower(); + local challenge = data.challenge; + local username, password = nodeprep(data.username), data.password; + data.username, data.password = nil, nil; + + if challenge == nil or captcha_ids[challenge] == nil then + session.send(st.error_reply(stanza, "modify", "not-acceptable", "Captcha id is invalid or it has expired")); + delete_captcha(challenge); + return true; + elseif ocr ~= captcha_ids[challenge] then + session.send(st.error_reply(stanza, "modify", "not-acceptable", "Invalid captcha text")); + delete_captcha(challenge); + return true; + end + if not username or username == "" then + session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is invalid.")); + delete_captcha(challenge); + return true; + end + local user = { username = username , host = host, allowed = true } + module:fire_event("user-registering", user); + if not user.allowed then + delete_captcha(challenge); + session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is forbidden.")); + elseif usermanager_user_exists(username, host) then + delete_captcha(challenge) + session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists.")); + else + -- TODO unable to write file, file may be locked, etc, what's the correct error? + local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk."); + if usermanager_create_user(username, password, host) then + if next(data) and not account_details:set(username, data) then + delete_captcha(challenge); + usermanager_delete_user(username, host); + session.send(error_reply); + return true; + end + session.send(st.reply(stanza)); -- user created! + module:log("info", "User account created: %s@%s", username, host); + module:fire_event("user-registered", { + username = username, host = host, source = "mod_register", + session = session }); + else + delete_captcha(challenge); + session.send(error_reply); + end + end + end + end + end + end + return true; +end); + +function string:split(sep) + local sep, fields = sep or ":", {} + local pattern = string.format("([^%s]+)", sep) + self:gsub(pattern, function(c) fields[#fields+1] = c end) + return fields +end + + +function handle_http_request(event) + local request = event.request; + local path = request.path; + local cid = path:split("/")[2]; + if cid == nil or captcha_ids[cid] == nil then + return nil; + end + request.response = { + status_code = 200; + headers = { + content_type = "image/png" + }; + body = get_file(string.format("%s/%s.png", config.dir, cid)); + }; + return request.response; + + +end; + +module:provides("http", { + default_path = "/"..config.web_path; + route = { + ["GET /*"] = handle_http_request; + }; +}); \ No newline at end of file diff -r a573d64968e9 -r 985bfc6e8cad mod_captcha_registration/util/dataforms.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_captcha_registration/util/dataforms.lua Sat Mar 29 22:56:24 2014 +0700 @@ -0,0 +1,256 @@ +-- 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. +-- + +local setmetatable = setmetatable; +local pairs, ipairs = pairs, ipairs; +local tostring, type, next = tostring, type, next; +local t_concat = table.concat; +local st = require "util.stanza"; +local jid_prep = require "util.jid".prep; + +module "dataforms" + +local xmlns_forms = 'jabber:x:data'; + +local form_t = {}; +local form_mt = { __index = form_t }; + +function new(layout) + return setmetatable(layout, form_mt); +end + +function form_t.form(layout, data, formtype) + local form = st.stanza("x", { xmlns = xmlns_forms, type = formtype or "form" }); + if layout.title then + form:tag("title"):text(layout.title):up(); + end + if layout.instructions then + form:tag("instructions"):text(layout.instructions):up(); + end + for n, field in ipairs(layout) do + local field_type = field.type or "text-single"; + -- Add field tag + form:tag("field", { type = field_type, var = field.name, label = field.label }); + + local value = (data and data[field.name]) or field.value; + + if value then + -- Add value, depending on type + if field_type == "hidden" then + if type(value) == "table" then + -- Assume an XML snippet + form:tag("value") + :add_child(value) + :up(); + else + form:tag("value"):text(tostring(value)):up(); + end + elseif field_type == "boolean" then + form:tag("value"):text((value and "1") or "0"):up(); + elseif field_type == "fixed" then + form:tag("value"):text(value):up(); + elseif field_type == "jid-multi" then + for _, jid in ipairs(value) do + form:tag("value"):text(jid):up(); + end + elseif field_type == "jid-single" then + form:tag("value"):text(value):up(); + elseif field_type == "text-single" or field_type == "text-private" then + form:tag("value"):text(value):up(); + elseif field_type == "text-multi" then + -- Split into multiple tags, one for each line + for line in value:gmatch("([^\r\n]+)\r?\n*") do + form:tag("value"):text(line):up(); + end + elseif field_type == "list-single" then + local has_default = false; + for _, val in ipairs(value) do + if type(val) == "table" then + form:tag("option", { label = val.label }):tag("value"):text(val.value):up():up(); + if val.default and (not has_default) then + form:tag("value"):text(val.value):up(); + has_default = true; + end + else + form:tag("option", { label= val }):tag("value"):text(tostring(val)):up():up(); + end + end + elseif field_type == "list-multi" then + for _, val in ipairs(value) do + if type(val) == "table" then + form:tag("option", { label = val.label }):tag("value"):text(val.value):up():up(); + if val.default then + form:tag("value"):text(val.value):up(); + end + else + form:tag("option", { label= val }):tag("value"):text(tostring(val)):up():up(); + end + end + elseif field_type == "media" then + form:tag("media", { xmlns = "urn:xmpp:media-element" }); + for _, val in ipairs(value) do + form:tag("uri", { type = val.type }):text(val.uri):up() + end + form:up(); + end + end + + if field.required then + form:tag("required"):up(); + end + + -- Jump back up to list of fields + form:up(); + end + return form; +end + +local field_readers = {}; + +function form_t.data(layout, stanza) + local data = {}; + local errors = {}; + + for _, field in ipairs(layout) do + local tag; + for field_tag in stanza:childtags() do + if field.name == field_tag.attr.var then + tag = field_tag; + break; + end + end + + if not tag then + if field.required then + errors[field.name] = "Required value missing"; + end + else + local reader = field_readers[field.type]; + if reader then + data[field.name], errors[field.name] = reader(tag, field.required); + end + end + end + if next(errors) then + return data, errors; + end + return data; +end + +field_readers["text-single"] = + function (field_tag, required) + local data = field_tag:get_child_text("value"); + if data and #data > 0 then + return data + elseif required then + return nil, "Required value missing"; + end + end + +field_readers["text-private"] = + field_readers["text-single"]; + +field_readers["jid-single"] = + function (field_tag, required) + local raw_data = field_tag:get_child_text("value") + local data = jid_prep(raw_data); + if data and #data > 0 then + return data + elseif raw_data then + return nil, "Invalid JID: " .. raw_data; + elseif required then + return nil, "Required value missing"; + end + end + +field_readers["jid-multi"] = + function (field_tag, required) + local result = {}; + local err = {}; + for value_tag in field_tag:childtags("value") do + local raw_value = value_tag:get_text(); + local value = jid_prep(raw_value); + result[#result+1] = value; + if raw_value and not value then + err[#err+1] = ("Invalid JID: " .. raw_value); + end + end + if #result > 0 then + return result, (#err > 0 and t_concat(err, "\n") or nil); + elseif required then + return nil, "Required value missing"; + end + end + +field_readers["list-multi"] = + function (field_tag, required) + local result = {}; + for value in field_tag:childtags("value") do + result[#result+1] = value:get_text(); + end + if #result > 0 then + return result; + elseif required then + return nil, "Required value missing"; + end + end + +field_readers["text-multi"] = + function (field_tag, required) + local data, err = field_readers["list-multi"](field_tag, required); + if data then + data = t_concat(data, "\n"); + end + return data, err; + end + +field_readers["list-single"] = + field_readers["text-single"]; + +local boolean_values = { + ["1"] = true, ["true"] = true, + ["0"] = false, ["false"] = false, +}; + +field_readers["boolean"] = + function (field_tag, required) + local raw_value = field_tag:get_child_text("value"); + local value = boolean_values[raw_value ~= nil and raw_value]; + if value ~= nil then + return value; + elseif raw_value then + return nil, "Invalid boolean representation"; + elseif required then + return nil, "Required value missing"; + end + end + +field_readers["hidden"] = + function (field_tag) + return field_tag:get_child_text("value"); + end + +field_readers["media"] = field_readers["text-single"] + +return _M; + + +--[=[ + +Layout: +{ + + title = "MUC Configuration", + instructions = [[Use this form to configure options for this MUC room.]], + + { name = "FORM_TYPE", type = "hidden", required = true }; + { name = "field-name", type = "field-type", required = false }; +} + + +--]=]