diff --git a/mod_email/mod_email.lua b/mod_email/mod_email.lua new file mode 100644 index 0000000..5af0259 --- /dev/null +++ b/mod_email/mod_email.lua @@ -0,0 +1,49 @@ +module:set_global(); + +local moduleapi = require "core.moduleapi"; + +local smtp = require"socket.smtp"; + +local config = module:get_option("smtp", { origin = "prosody", exec = "sendmail" }); + +local function send_email(to, headers, content) + if type(headers) == "string" then -- subject + headers = { + Subject = headers; + From = config.origin; + }; + end + headers.To = to; + if not headers["Content-Type"] then + headers["Content-Type"] = 'text/plain; charset="utf-8"'; + end + local message = smtp.message{ + headers = headers; + body = content; + }; + + if config.exec then + local pipe = io.popen(config.exec .. + " '"..to:gsub("'", "'\\''").."'", "w"); + + for str in message do + pipe:write(str); + end + + return pipe:close(); + end + + return smtp.send({ + user = config.user; password = config.password; + server = config.server; port = config.port; + domain = config.domain; + + from = config.origin; rcpt = to; + source = message; + }); +end + +assert(not moduleapi.send_email, "another email module is already loaded"); +function moduleapi:send_email(email) --luacheck: ignore 212/self + return send_email(email.to, email.headers or email.subject, email.body); +end diff --git a/mod_email_pass/README.markdown b/mod_email_pass/README.markdown new file mode 100644 index 0000000..7cda2ab --- /dev/null +++ b/mod_email_pass/README.markdown @@ -0,0 +1,49 @@ +--- +labels: +- 'Stage-Beta' +... + +Introduction +============ + +This module aims to help in the procedure of user password restoration. +To start the restoration, the user must go to an URL provided by this +module, fill the JID and email and submit the request. + +The module will generate a token valid for 24h and send an email with a +specially crafted url to the vCard email address. If the user goes to +this url, will be able to change his password. + +Usage +===== + +Simply add "email\_pass" to your modules\_enabled list and copy files +"**mod\_email\_pass.lua**" and "**vcard.lib.lua**" to prosody modules +folder. This module need to that **https\_host** or **http\_host** must +be configured. This parameter is necessary to construct the URL that has +been sended to the user. + +This module only send emails to the vCard user email address, then the +user must set this address in order to be capable of do the restoration. + +Configuration +============= + + --------------- ------------------------------------------------------------ + smtp\_server The Host/ip of your SMTP server + smtp\_port Port used by your SMTP server. Default 25 + smtp\_ssl Use of SMTP SSL legacy (No STARTTLS) + smtp\_user Username used to do SMTP auth + smtp\_pass Password used to do SMTP auth + smtp\_address EMail address that will be apears as From in mails + msg\_subject Subject used for messages/mails + msg\_body Message send when password has been changed + url\_path Path where the module will be visible. Default /resetpass/ + --------------- ------------------------------------------------------------ + +Compatibility +============= + + ----- ------- + 0.9 Works + ----- ------- diff --git a/mod_email_pass/mod_email_pass.lua b/mod_email_pass/mod_email_pass.lua new file mode 100644 index 0000000..a188736 --- /dev/null +++ b/mod_email_pass/mod_email_pass.lua @@ -0,0 +1,365 @@ +local dm_load = require "util.datamanager".load; +local st = require "util.stanza"; +local nodeprep = require "util.encodings".stringprep.nodeprep; +local usermanager = require "core.usermanager"; +local http = require "net.http"; +local vcard = module:require "vcard"; +local datetime = require "util.datetime"; +local timer = require "util.timer"; +local jidutil = require "util.jid"; + +-- SMTP related params. Readed from config +local os_time = os.time; +local smtp = require "socket.smtp"; +local smtp_server = module:get_option_string("smtp_server", "localhost"); +local smtp_port = module:get_option_string("smtp_port", "25"); +local smtp_ssl = module:get_option_boolean("smtp_ssl", false); +local smtp_user = module:get_option_string("smtp_username"); +local smtp_pass = module:get_option_string("smtp_password"); +local smtp_address = module:get_option("smtp_from") or ((smtp_user or "no-responder").."@"..(smtp_server or module.host)); +local mail_subject = module:get_option_string("msg_subject") +local mail_body = module:get_option_string("msg_body"); +local url_path = module:get_option_string("url_path", "/resetpass"); + + +-- This table has the tokens submited by the server +tokens_mails = {}; +tokens_expiration = {}; + +-- URL +local https_host = module:get_option_string("https_host"); +local http_host = module:get_option_string("http_host"); +local https_port = module:get_option("https_ports", { 443 }); +local http_port = module:get_option("http_ports", { 80 }); + +local timer_repeat = 120; -- repeat after 120 secs + +function enablessl() + local sock = socket.tcp() + return setmetatable({ + connect = function(_, host, port) + local r, e = sock:connect(host, port) + if not r then return r, e end + sock = ssl.wrap(sock, {mode='client', protocol='tlsv1'}) + return sock:dohandshake() + end + }, { + __index = function(t,n) + return function(_, ...) + return sock[n](sock, ...) + end + end + }) +end + +function template(data) + -- Like util.template, but deals with plain text + return { apply = function(values) return (data:gsub("{([^}]+)}", values)); end } +end + +local function get_template(name, extension) + local fh = assert(module:load_resource("templates/"..name..extension)); + local data = assert(fh:read("*a")); + fh:close(); + return template(data); +end + +local function render(template, data) + return tostring(template.apply(data)); +end + +function send_email(address, smtp_address, message_text, subject) + local rcpt = "<"..address..">"; + + local mesgt = { + headers = { + to = address; + subject = subject or ("Jabber password reset "..jid_bare(from_address)); + }; + body = message_text; + }; + local ok, err = nil; + + if not smtp_ssl then + ok, err = smtp.send{ from = smtp_address, rcpt = rcpt, source = smtp.message(mesgt), + server = smtp_server, user = smtp_user, password = smtp_pass, port = 25 }; + else + ok, err = smtp.send{ from = smtp_address, rcpt = rcpt, source = smtp.message(mesgt), + server = smtp_server, user = smtp_user, password = smtp_pass, port = smtp_port, create = enablessl }; + end + + if not ok then + module:log("error", "Failed to deliver to %s: %s", tostring(address), tostring(err)); + return; + end + return true; +end + +local vCard_mt = { + __index = function(t, k) + if type(k) ~= "string" then return nil end + for i=1,#t do + local t_i = rawget(t, i); + if t_i and t_i.name == k then + rawset(t, k, t_i); + return t_i; + end + end + end +}; + +local function get_user_vcard(user, host) + local vCard = dm_load(user, host or base_host, "vcard"); + if vCard then + vCard = st.deserialize(vCard); + vCard = vcard.from_xep54(vCard); + return setmetatable(vCard, vCard_mt); + end +end + +local changepass_tpl = get_template("changepass",".html"); +local sendmail_success_tpl = get_template("sendmailok",".html"); +local reset_success_tpl = get_template("resetok",".html"); +local token_tpl = get_template("token",".html"); + +function generate_page(event, display_options) + local request = event.request; + + return render(changepass_tpl, { + path = request.path; hostname = module.host; + notice = display_options and display_options.register_error or ""; + }) +end + +function generate_token_page(event, display_options) + local request = event.request; + + return render(token_tpl, { + path = request.path; hostname = module.host; + token = request.url.query; + notice = display_options and display_options.register_error or ""; + }) +end + +function generateToken(address) + math.randomseed(os.time()) + length = 16 + if length < 1 then return nil end + local array = {} + for i = 1, length, 2 do + array[i] = string.char(math.random(48,57)) + array[i+1] = string.char(math.random(97,122)) + end + local token = table.concat(array); + if not tokens_mails[token] then + + tokens_mails[token] = address; + tokens_expiration[token] = os.time(); + return token + else + module:log("error", "Reset password token collision: '%s'", token); + return generateToken(address) + end +end + +function isExpired(token) + if not tokens_expiration[token] then + return nil; + end + if os.difftime(os.time(), tokens_expiration[token]) < 86400 then -- 86400 secs == 24h + -- token is valid yet + return nil; + else + -- token invalid, we can create a fresh one. + return true; + end +end + +-- Expire tokens +expireTokens = function() + for token,value in pairs(tokens_mails) do + if isExpired(token) then + module:log("info","Expiring password reset request from user '%s', not used.", tokens_mails[token]); + tokens_mails[token] = nil; + tokens_expiration[token] = nil; + end + end + return timer_repeat; +end + +-- Check if a user has a active token not used yet. +function hasTokenActive(address) + for token,value in pairs(tokens_mails) do + if address == value and not isExpired(token) then + return token; + end + end + return nil; +end + +function generateUrl(token) + local url; + + if https_host then + url = "https://" .. https_host; + else + url = "http://" .. http_host; + end + + if https_port then + url = url .. ":" .. https_port[1]; + else + url = url .. ":" .. http_port[1]; + end + + url = url .. url_path .. "token.html?" .. token; + + return url; +end + +function sendMessage(jid, subject, message) + local msg = st.message({ from = module.host; to = jid; }): + tag("subject"):text(subject):up(): + tag("body"):text(message); + module:send(msg); +end + +function send_token_mail(form, origin) + local prepped_username = nodeprep(form.username); + local prepped_mail = form.email; + local jid = prepped_username .. "@" .. module.host; + + if not prepped_username then + return nil, "El usuario contiene caracteres incorrectos"; + end + if #prepped_username == 0 then + return nil, "El campo usuario está vacio"; + end + if not usermanager.user_exists(prepped_username, module.host) then + return nil, "El usuario NO existe"; + end + + if #prepped_mail == 0 then + return nil, "El campo email está vacio"; + end + + local vcarduser = get_user_vcard(prepped_username, module.host); + + if not vcarduser then + return nil, "User has not vCard"; + else + if not vcarduser.EMAIL then + return nil, "Esa cuente no tiene ningún email configurado en su vCard"; + end + + email = string.lower(vcarduser.EMAIL[1]); + + if email ~= string.lower(prepped_mail) then + return nil, "Dirección eMail incorrecta"; + end + + -- Check if has already a valid token, not used yet. + if hasTokenActive(jid) then + local valid_until = tokens_expiration[hasTokenActive(jid)] + 86400; + return nil, "Ya tienes una petición de restablecimiento de clave válida hasta: " .. datetime.date(valid_until) .. " " .. datetime.time(valid_until); + end + + local url_token = generateToken(jid); + local url = generateUrl(url_token); + local email_body = render(get_template("sendtoken",".mail"), {jid = jid, url = url} ); + + module:log("info", "Sending password reset mail to user %s", jid); + send_email(email, smtp_address, email_body, mail_subject); + return "ok"; + end + +end + +function reset_password_with_token(form, origin) + local token = form.token; + local password = form.newpassword; + + if not token then + return nil, "El Token es inválido"; + end + if not tokens_mails[token] then + return nil, "El Token no existe o ya fué usado"; + end + if not password then + return nil, "La campo clave no puede estar vacio"; + end + if #password < 5 then + return nil, "La clave debe tener una longitud de al menos 5 caracteres"; + end + local jid = tokens_mails[token]; + local user, host, resource = jidutil.split(jid); + + usermanager.set_password(user, password, host); + module:log("info", "Password changed with token for user %s", jid); + tokens_mails[token] = nil; + tokens_expiration[token] = nil; + sendMessage(jid, mail_subject, mail_body); + return "ok"; +end + +function generate_success(event, form) + return render(sendmail_success_tpl, { jid = nodeprep(form.username).."@"..module.host }); +end + +function generate_register_response(event, form, ok, err) + local message; + if ok then + return generate_success(event, form); + else + return generate_page(event, { register_error = err }); + end +end + +function handle_form_token(event) + local request, response = event.request, event.response; + local form = http.formdecode(request.body); + + local token_ok, token_err = send_token_mail(form, request); + response:send(generate_register_response(event, form, token_ok, token_err)); + + return true; -- Leave connection open until we respond above +end + +function generate_reset_success(event, form) + return render(reset_success_tpl, { }); +end + +function generate_reset_response(event, form, ok, err) + local message; + if ok then + return generate_reset_success(event, form); + else + return generate_token_page(event, { register_error = err }); + end +end + +function handle_form_reset(event) + local request, response = event.request, event.response; + local form = http.formdecode(request.body); + + local reset_ok, reset_err = reset_password_with_token(form, request); + response:send(generate_reset_response(event, form, reset_ok, reset_err)); + + return true; -- Leave connection open until we respond above + +end + +timer.add_task(timer_repeat, expireTokens); + +module:provides("http", { + default_path = url_path; + route = { + ["GET /style.css"] = render(get_template("style",".css"), {}); + ["GET /token.html"] = generate_token_page; + ["GET /"] = generate_page; + ["POST /token.html"] = handle_form_reset; + ["POST /"] = handle_form_token; + }; +}); + + diff --git a/mod_email_pass/templates/changepass.html b/mod_email_pass/templates/changepass.html new file mode 100644 index 0000000..b3f87bb --- /dev/null +++ b/mod_email_pass/templates/changepass.html @@ -0,0 +1,36 @@ + + + + + + Reseteo de la clave de tu cuenta Jabber + + +
+

Reseteo de la clave de tu cuenta Jabber

+
+

{notice}

+ + @{hostname} +
+ + +
+ + +
+
+

+ Al pulsar sobre el botón, se enviará a la dirección de correo que figura + en tu vCard un enlace en el que deberás entrar.
+

+
+ + + diff --git a/mod_email_pass/templates/resetok.html b/mod_email_pass/templates/resetok.html new file mode 100644 index 0000000..ceeb8b7 --- /dev/null +++ b/mod_email_pass/templates/resetok.html @@ -0,0 +1,15 @@ + + + + + + + Clave reseteada! + + +
+

Tu clave ha sido cambiada correctamente. Ya puedes iniciar sesión con ella.

+
+ + + diff --git a/mod_email_pass/templates/sendmailok.html b/mod_email_pass/templates/sendmailok.html new file mode 100644 index 0000000..2c3be4c --- /dev/null +++ b/mod_email_pass/templates/sendmailok.html @@ -0,0 +1,15 @@ + + + + + + + Enlace enviado! + + +
+

Acabamos de enviarte un email con un enlace que tendrás que visitar.

+
+ + + diff --git a/mod_email_pass/templates/sendtoken.mail b/mod_email_pass/templates/sendtoken.mail new file mode 100644 index 0000000..75193b7 --- /dev/null +++ b/mod_email_pass/templates/sendtoken.mail @@ -0,0 +1,14 @@ +Hola: + +Si has recibido este email es porque has solicitado el reseteo de la +clave de tu cuenta Jabber/XMPP {jid} + +Para proceder con el cambio de clave, haz click en el siguiente enlace: + +{url} + +Si no has solicitado resetear tu clave, ignora este mensaje. + +Atentamente, el equipo de mijabber.es + + diff --git a/mod_email_pass/templates/style.css b/mod_email_pass/templates/style.css new file mode 100644 index 0000000..aabd0e6 --- /dev/null +++ b/mod_email_pass/templates/style.css @@ -0,0 +1,109 @@ +body{ + font-family:"Lucida Grande", "Lucida Sans Unicode", Verdana, Arial, Helvetica, sans-serif; + font-size:12px; +} + +p, h1, form, button{border:0; margin:0; padding:0;} +.spacer{clear:both; height:1px;} + +/* ----------- My Form ----------- */ +.formulario{ + margin:0 auto; + width:500px; + padding:14px; +} +/* ----------- stylized ----------- */ +#estilo { + border:solid 2px #b7ddf2; + background:#ebf4fb; +} + +#estilo h1 { + font-size:14px; + font-weight:bold; + margin-bottom:8px; +} + +#estilo p { + font-size:11px; + color:#666666; + margin-bottom:20px; + border-bottom:solid 1px #b7ddf2; + padding-bottom:10px; +} + +#estilo p.error { + font-size:12px; + font-weight:bold; + color:red; + margin-bottom:20px; + border-bottom:solid 1px #b7ddf2; + padding-bottom:10px; +} + +#estilo label{ + display:block; + font-weight:bold; + text-align:right; + width:140px; + float:left; +} + +#estilo .small{ + color:#666666; + display:block; + font-size:11px; + font-weight:normal; + text-align:right; + width:140px; +} + +#estilo input{ + float:left; + font-size:12px; + padding:4px 2px; + border:solid 1px #aacfe4; + width:200px; + margin:2px 0 20px 10px; +} + +.button { + -moz-box-shadow:inset 0px 1px 0px 0px #cae3fc; + -webkit-box-shadow:inset 0px 1px 0px 0px #cae3fc; + box-shadow:inset 0px 1px 0px 0px #cae3fc; + background-color:#79bbff; + -webkit-border-top-left-radius:18px; + -moz-border-radius-topleft:18px; + border-top-left-radius:18px; + -webkit-border-top-right-radius:18px; + -moz-border-radius-topright:18px; + border-top-right-radius:18px; + -webkit-border-bottom-right-radius:18px; + -moz-border-radius-bottomright:18px; + border-bottom-right-radius:18px; + -webkit-border-bottom-left-radius:18px; + -moz-border-radius-bottomleft:18px; + border-bottom-left-radius:18px; + text-indent:0; + border:1px solid #469df5; + display:inline-block; + color:#ffffff; + font-family:Arial; + font-size:15px; + font-weight:bold; + font-style:normal; + height:40px; + line-height:40px; + width:100px; + text-decoration:none; + text-align:center; + text-shadow:1px 1px 0px #287ace; +} +.button:hover { + background-color:#4197ee; +} +.button:active { + position:relative; + top:1px; +} + diff --git a/mod_email_pass/templates/token.html b/mod_email_pass/templates/token.html new file mode 100644 index 0000000..c1fed05 --- /dev/null +++ b/mod_email_pass/templates/token.html @@ -0,0 +1,30 @@ + + + + + + Reseto de la clave de tu cuenta Jabber + + +
+

Reseteo de la clave de tu cuenta Jabber

+
+

{notice}

+ + +
+ + + +
+ +
+
+
+ + + diff --git a/mod_email_pass/vcard.lib.lua b/mod_email_pass/vcard.lib.lua new file mode 100644 index 0000000..2406023 --- /dev/null +++ b/mod_email_pass/vcard.lib.lua @@ -0,0 +1,464 @@ +-- Copyright (C) 2011-2012 Kim Alvefur +-- +-- This project is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. +-- + +-- TODO +-- Fix folding. + +local st = require "util.stanza"; +local t_insert, t_concat = table.insert, table.concat; +local type = type; +local next, pairs, ipairs = next, pairs, ipairs; + +local from_text, to_text, from_xep54, to_xep54; + +local line_sep = "\n"; + +local vCard_dtd; -- See end of file + +local function fold_line() + error "Not implemented" --TODO +end +local function unfold_line() + error "Not implemented" + -- gsub("\r?\n[ \t]([^\r\n])", "%1"); +end + +local function vCard_esc(s) + return s:gsub("[,:;\\]", "\\%1"):gsub("\n","\\n"); +end + +local function vCard_unesc(s) + return s:gsub("\\?[\\nt:;,]", { + ["\\\\"] = "\\", + ["\\n"] = "\n", + ["\\r"] = "\r", + ["\\t"] = "\t", + ["\\:"] = ":", -- FIXME Shouldn't need to espace : in values, just params + ["\\;"] = ";", + ["\\,"] = ",", + [":"] = "\29", + [";"] = "\30", + [","] = "\31", + }); +end + +local function item_to_xep54(item) + local t = st.stanza(item.name, { xmlns = "vcard-temp" }); + + local prop_def = vCard_dtd[item.name]; + if prop_def == "text" then + t:text(item[1]); + elseif type(prop_def) == "table" then + if prop_def.types and item.TYPE then + if type(item.TYPE) == "table" then + for _,v in pairs(prop_def.types) do + for _,typ in pairs(item.TYPE) do + if typ:upper() == v then + t:tag(v):up(); + break; + end + end + end + else + t:tag(item.TYPE:upper()):up(); + end + end + + if prop_def.props then + for _,v in pairs(prop_def.props) do + if item[v] then + t:tag(v):up(); + end + end + end + + if prop_def.value then + t:tag(prop_def.value):text(item[1]):up(); + elseif prop_def.values then + local prop_def_values = prop_def.values; + local repeat_last = prop_def_values.behaviour == "repeat-last" and prop_def_values[#prop_def_values]; + for i=1,#item do + t:tag(prop_def.values[i] or repeat_last):text(item[i]):up(); + end + end + end + + return t; +end + +local function vcard_to_xep54(vCard) + local t = st.stanza("vCard", { xmlns = "vcard-temp" }); + for i=1,#vCard do + t:add_child(item_to_xep54(vCard[i])); + end + return t; +end + +function to_xep54(vCards) + if not vCards[1] or vCards[1].name then + return vcard_to_xep54(vCards) + else + local t = st.stanza("xCard", { xmlns = "vcard-temp" }); + for i=1,#vCards do + t:add_child(vcard_to_xep54(vCards[i])); + end + return t; + end +end + +function from_text(data) + data = data -- unfold and remove empty lines + :gsub("\r\n","\n") + :gsub("\n ", "") + :gsub("\n\n+","\n"); + local vCards = {}; + local c; -- current item + for line in data:gmatch("[^\n]+") do + local line = vCard_unesc(line); + local name, params, value = line:match("^([-%a]+)(\30?[^\29]*)\29(.*)$"); + value = value:gsub("\29",":"); + if #params > 0 then + local _params = {}; + for k,isval,v in params:gmatch("\30([^=]+)(=?)([^\30]*)") do + k = k:upper(); + local _vt = {}; + for _p in v:gmatch("[^\31]+") do + _vt[#_vt+1]=_p + _vt[_p]=true; + end + if isval == "=" then + _params[k]=_vt; + else + _params[k]=true; + end + end + params = _params; + end + if name == "BEGIN" and value == "VCARD" then + c = {}; + vCards[#vCards+1] = c; + elseif name == "END" and value == "VCARD" then + c = nil; + elseif vCard_dtd[name] then + local dtd = vCard_dtd[name]; + local p = { name = name }; + c[#c+1]=p; + --c[name]=p; + local up = c; + c = p; + if dtd.types then + for _, t in ipairs(dtd.types) do + local t = t:lower(); + if ( params.TYPE and params.TYPE[t] == true) + or params[t] == true then + c.TYPE=t; + end + end + end + if dtd.props then + for _, p in ipairs(dtd.props) do + if params[p] then + if params[p] == true then + c[p]=true; + else + for _, prop in ipairs(params[p]) do + c[p]=prop; + end + end + end + end + end + if dtd == "text" or dtd.value then + t_insert(c, value); + elseif dtd.values then + local value = "\30"..value; + for p in value:gmatch("\30([^\30]*)") do + t_insert(c, p); + end + end + c = up; + end + end + return vCards; +end + +local function item_to_text(item) + local value = {}; + for i=1,#item do + value[i] = vCard_esc(item[i]); + end + value = t_concat(value, ";"); + + local params = ""; + for k,v in pairs(item) do + if type(k) == "string" and k ~= "name" then + params = params .. (";%s=%s"):format(k, type(v) == "table" and t_concat(v,",") or v); + end + end + + return ("%s%s:%s"):format(item.name, params, value) +end + +local function vcard_to_text(vcard) + local t={}; + t_insert(t, "BEGIN:VCARD") + for i=1,#vcard do + t_insert(t, item_to_text(vcard[i])); + end + t_insert(t, "END:VCARD") + return t_concat(t, line_sep); +end + +function to_text(vCards) + if vCards[1] and vCards[1].name then + return vcard_to_text(vCards) + else + local t = {}; + for i=1,#vCards do + t[i]=vcard_to_text(vCards[i]); + end + return t_concat(t, line_sep); + end +end + +local function from_xep54_item(item) + local prop_name = item.name; + local prop_def = vCard_dtd[prop_name]; + + local prop = { name = prop_name }; + + if prop_def == "text" then + prop[1] = item:get_text(); + elseif type(prop_def) == "table" then + if prop_def.value then --single item + prop[1] = item:get_child_text(prop_def.value) or ""; + elseif prop_def.values then --array + local value_names = prop_def.values; + if value_names.behaviour == "repeat-last" then + for i=1,#item.tags do + t_insert(prop, item.tags[i]:get_text() or ""); + end + else + for i=1,#value_names do + t_insert(prop, item:get_child_text(value_names[i]) or ""); + end + end + elseif prop_def.names then + local names = prop_def.names; + for i=1,#names do + if item:get_child(names[i]) then + prop[1] = names[i]; + break; + end + end + end + + if prop_def.props_verbatim then + for k,v in pairs(prop_def.props_verbatim) do + prop[k] = v; + end + end + + if prop_def.types then + local types = prop_def.types; + prop.TYPE = {}; + for i=1,#types do + if item:get_child(types[i]) then + t_insert(prop.TYPE, types[i]:lower()); + end + end + if #prop.TYPE == 0 then + prop.TYPE = nil; + end + end + + -- A key-value pair, within a key-value pair? + if prop_def.props then + local params = prop_def.props; + for i=1,#params do + local name = params[i] + local data = item:get_child_text(name); + if data then + prop[name] = prop[name] or {}; + t_insert(prop[name], data); + end + end + end + else + return nil + end + + return prop; +end + +local function from_xep54_vCard(vCard) + local tags = vCard.tags; + local t = {}; + for i=1,#tags do + t_insert(t, from_xep54_item(tags[i])); + end + return t +end + +function from_xep54(vCard) + if vCard.attr.xmlns ~= "vcard-temp" then + return nil, "wrong-xmlns"; + end + if vCard.name == "xCard" then -- A collection of vCards + local t = {}; + local vCards = vCard.tags; + for i=1,#vCards do + t[i] = from_xep54_vCard(vCards[i]); + end + return t + elseif vCard.name == "vCard" then -- A single vCard + return from_xep54_vCard(vCard) + end +end + +-- This was adapted from http://xmpp.org/extensions/xep-0054.html#dtd +vCard_dtd = { + VERSION = "text", --MUST be 3.0, so parsing is redundant + FN = "text", + N = { + values = { + "FAMILY", + "GIVEN", + "MIDDLE", + "PREFIX", + "SUFFIX", + }, + }, + NICKNAME = "text", + PHOTO = { + props_verbatim = { ENCODING = { "b" } }, + props = { "TYPE" }, + value = "BINVAL", --{ "EXTVAL", }, + }, + BDAY = "text", + ADR = { + types = { + "HOME", + "WORK", + "POSTAL", + "PARCEL", + "DOM", + "INTL", + "PREF", + }, + values = { + "POBOX", + "EXTADD", + "STREET", + "LOCALITY", + "REGION", + "PCODE", + "CTRY", + } + }, + LABEL = { + types = { + "HOME", + "WORK", + "POSTAL", + "PARCEL", + "DOM", + "INTL", + "PREF", + }, + value = "LINE", + }, + TEL = { + types = { + "HOME", + "WORK", + "VOICE", + "FAX", + "PAGER", + "MSG", + "CELL", + "VIDEO", + "BBS", + "MODEM", + "ISDN", + "PCS", + "PREF", + }, + value = "NUMBER", + }, + EMAIL = { + types = { + "HOME", + "WORK", + "INTERNET", + "PREF", + "X400", + }, + value = "USERID", + }, + JABBERID = "text", + MAILER = "text", + TZ = "text", + GEO = { + values = { + "LAT", + "LON", + }, + }, + TITLE = "text", + ROLE = "text", + LOGO = "copy of PHOTO", + AGENT = "text", + ORG = { + values = { + behaviour = "repeat-last", + "ORGNAME", + "ORGUNIT", + } + }, + CATEGORIES = { + values = "KEYWORD", + }, + NOTE = "text", + PRODID = "text", + REV = "text", + SORTSTRING = "text", + SOUND = "copy of PHOTO", + UID = "text", + URL = "text", + CLASS = { + names = { -- The item.name is the value if it's one of these. + "PUBLIC", + "PRIVATE", + "CONFIDENTIAL", + }, + }, + KEY = { + props = { "TYPE" }, + value = "CRED", + }, + DESC = "text", +}; +vCard_dtd.LOGO = vCard_dtd.PHOTO; +vCard_dtd.SOUND = vCard_dtd.PHOTO; + +return { + from_text = from_text; + to_text = to_text; + + from_xep54 = from_xep54; + to_xep54 = to_xep54; + + -- COMPAT: + lua_to_text = to_text; + lua_to_xep54 = to_xep54; + + text_to_lua = from_text; + text_to_xep54 = function (...) return to_xep54(from_text(...)); end; + + xep54_to_lua = from_xep54; + xep54_to_text = function (...) return to_text(from_xep54(...)) end; +}; diff --git a/mod_register_web/README.markdown b/mod_register_web/README.markdown new file mode 100644 index 0000000..d6a9aed --- /dev/null +++ b/mod_register_web/README.markdown @@ -0,0 +1,67 @@ +--- +labels: +- 'Stage-Alpha' +summary: A web interface to register user accounts +rockspec: + build: + copy_directories: + - templates +... + +Introduction +------------ + +There are various reasons to prefer web registration instead of +"in-band" account registration over XMPP. For example the lack of +CAPTCHA support in clients and servers. + +Details +------- + +mod\_register\_web has Prosody serve a web page where users can sign up +for an account. It implements reCAPTCHA to prevent automated sign-ups +(from bots, etc.). + +Configuration +------------- + +The module is served on Prosody's default HTTP ports at the path +`/register_web`. More details on configuring HTTP modules in Prosody can +be found in our [HTTP documentation](http://prosody.im/doc/http). + +To configure the CAPTCHA you need to supply a 'captcha\_options' option: + + captcha_options = { + recaptcha_private_key = "12345"; + recaptcha_public_key = "78901"; + } + +The keys for reCAPTCHA are available in your reCAPTCHA account, visit +[reCAPTCHA](https://developers.google.com/recaptcha/) for more info. + +If no reCaptcha options are set, a simple built in captcha is used. + +Customization +------------- + +Copy the files in mod_register_web/templates/ to a new directory. Edit them, +and set `register_web_template = "/path/to/your/custom-templates"` in your +config file. + +Compatibility +------------- + + ----- -------------- + 0.10 Works + 0.9 Works + 0.8 Doesn't work + ----- -------------- + +Todo +---- + +Different CAPTCHA implementation support + +Collection of additional data, such as email address + +The module kept simple! diff --git a/mod_register_web/mod_register_web.lua b/mod_register_web/mod_register_web.lua new file mode 100644 index 0000000..df59b87 --- /dev/null +++ b/mod_register_web/mod_register_web.lua @@ -0,0 +1,244 @@ +local captcha_options = module:get_option("captcha_options", {}); +local nodeprep = require "util.encodings".stringprep.nodeprep; +local usermanager = require "core.usermanager"; +local datamanager = require "util.datamanager"; +local http = require "net.http"; +local path_sep = package.config:sub(1,1); +local json = require "util.json".decode; +local t_concat = table.concat; + +pcall(function () + module:depends("register_limits"); +end); + +module:depends"http"; + +local extra_fields = { + nick = true; name = true; first = true; last = true; email = true; + address = true; city = true; state = true; zip = true; + phone = true; url = true; date = true; +} + +local template_path = module:get_option_string("register_web_template", "templates"); +function template(data) + -- Like util.template, but deals with plain text + return { apply = function(values) return (data:gsub("{([^}]+)}", values)); end } +end + +local function get_template(name) + local fh = assert(module:load_resource(template_path..path_sep..name..".html")); + local data = assert(fh:read("*a")); + fh:close(); + return template(data); +end + +local function render(template, data) + return tostring(template.apply(data)); +end + +local register_tpl = get_template "register"; +local success_tpl = get_template "success"; + +-- COMPAT `or request.conn:ip()` + +if next(captcha_options) ~= nil then + local provider = captcha_options.provider; + if provider == nil or provider == "recaptcha" then + local recaptcha_tpl = get_template "recaptcha"; + + function generate_captcha(display_options) + return recaptcha_tpl.apply(setmetatable({ + recaptcha_display_error = display_options and display_options.recaptcha_error + and ("&error="..display_options.recaptcha_error) or ""; + }, { + __index = function (_, k) + if captcha_options[k] then return captcha_options[k]; end + module:log("error", "Missing parameter from captcha_options: %s", k); + end + })); + end + function verify_captcha(request, form, callback) + http.request("https://www.google.com/recaptcha/api/siteverify", { + body = http.formencode { + secret = captcha_options.recaptcha_private_key; + remoteip = request.ip or request.conn:ip(); + response = form["g-recaptcha-response"]; + }; + }, function (verify_result, code) + local result = json(verify_result); + if not result then + module:log("warn", "Unable to decode response from recaptcha: [%d] %s", code, verify_result); + callback(false, "Captcha API error"); + elseif result.success == true then + callback(true); + else + callback(false, t_concat(result["error-codes"])); + end + end); + end + elseif provider == "hcaptcha" then + local captcha_tpl = get_template "hcaptcha"; + + function generate_captcha(display_options) + return captcha_tpl.apply(setmetatable({ + captcha_display_error = display_options and display_options.captcha_error + and ("&error="..display_options.captcha_error) or ""; + }, { + __index = function (_, k) + if captcha_options[k] then return captcha_options[k]; end + module:log("error", "Missing parameter from captcha_options: %s", k); + end + })); + end + function verify_captcha(request, form, callback) + http.request("https://hcaptcha.com/siteverify", { + body = http.formencode { + secret = captcha_options.captcha_private_key; + remoteip = request.ip or request.conn:ip(); + response = form["h-captcha-response"]; + }; + }, function (verify_result, code) + local result = json(verify_result); + if not result then + module:log("warn", "Unable to decode response from hcaptcha: [%d] %s", code, verify_result); + callback(false, "Captcha API error"); + elseif result.success == true then + callback(true); + else + callback(false, t_concat(result["error-codes"])); + end + end); + end + end +else + module:log("debug", "No captcha options set, using fallback captcha") + local random = math.random; + local hmac_sha1 = require "util.hashes".hmac_sha1; + local secret = require "util.uuid".generate() + local ops = { '+', '-' }; + local captcha_tpl = get_template "simplecaptcha"; + function generate_captcha() + local op = ops[random(1, #ops)]; + local x, y = random(1, 9) + repeat + y = random(1, 9); + until x ~= y; + local answer; + if op == '+' then + answer = x + y; + elseif op == '-' then + if x < y then + -- Avoid negative numbers + x, y = y, x; + end + answer = x - y; + end + local challenge = hmac_sha1(secret, answer, true); + return captcha_tpl.apply { + op = op, x = x, y = y, challenge = challenge; + }; + end + function verify_captcha(request, form, callback) + if hmac_sha1(secret, form.captcha_reply or "", true) == form.captcha_challenge then + callback(true); + else + callback(false, "Captcha verification failed"); + end + end +end + +function generate_page(event, display_options) + local request, response = event.request, event.response; + + response.headers.content_type = "text/html; charset=utf-8"; + return render(register_tpl, { + path = request.path; hostname = module.host; + notice = display_options and display_options.register_error or ""; + captcha = generate_captcha(display_options); + }) +end + +function register_user(form, origin) + local username = form.username; + local password = form.password; + local confirm_password = form.confirm_password; + local jid = nil; + form.username, form.password, form.confirm_password = nil, nil, nil; + + local prepped_username = nodeprep(username, true); + if not prepped_username then + return nil, "Username contains forbidden characters"; + end + if #prepped_username == 0 then + return nil, "The username field was empty"; + end + if usermanager.user_exists(prepped_username, module.host) then + return nil, "Username already taken"; + end + local registering = { username = prepped_username , host = module.host, additional = form, ip = origin.ip or origin.conn:ip(), allowed = true } + module:fire_event("user-registering", registering); + if not registering.allowed then + return nil, registering.reason or "Registration not allowed"; + end + if confirm_password ~= password then + return nil, "Passwords don't match"; + end + local ok, err = usermanager.create_user(prepped_username, password, module.host); + if ok then + jid = prepped_username.."@"..module.host + local extra_data = {}; + for field in pairs(extra_fields) do + local field_value = form[field]; + if field_value and #field_value > 0 then + extra_data[field] = field_value; + end + end + if next(extra_data) ~= nil then + datamanager.store(prepped_username, module.host, "account_details", extra_data); + end + module:fire_event("user-registered", { + username = prepped_username, + host = module.host, + source = module.name, + ip = origin.ip or origin.conn:ip(), + }); + end + return jid, err; +end + +function generate_success(event, jid) + return render(success_tpl, { jid = jid }); +end + +function generate_register_response(event, jid, err) + event.response.headers.content_type = "text/html; charset=utf-8"; + if jid then + return generate_success(event, jid); + else + return generate_page(event, { register_error = err }); + end +end + +function handle_form(event) + local request, response = event.request, event.response; + local form = http.formdecode(request.body); + verify_captcha(request, form, function (ok, err) + if ok then + local jid, register_err = register_user(form, request); + response:send(generate_register_response(event, jid, register_err)); + else + response:send(generate_page(event, { register_error = err })); + end + end); + return true; -- Leave connection open until we respond above +end + +module:provides("http", { + title = module:get_option_string("register_web_title", "Account Registration"); + route = { + GET = generate_page; + ["GET /"] = generate_page; + POST = handle_form; + ["POST /"] = handle_form; + }; +}); diff --git a/mod_register_web/templates/hcaptcha.html b/mod_register_web/templates/hcaptcha.html new file mode 100644 index 0000000..b9756a4 --- /dev/null +++ b/mod_register_web/templates/hcaptcha.html @@ -0,0 +1,6 @@ + + + +
+ + diff --git a/mod_register_web/templates/recaptcha.html b/mod_register_web/templates/recaptcha.html new file mode 100644 index 0000000..7853245 --- /dev/null +++ b/mod_register_web/templates/recaptcha.html @@ -0,0 +1,26 @@ + + + +
+ + + diff --git a/mod_register_web/templates/register.html b/mod_register_web/templates/register.html new file mode 100644 index 0000000..287e392 --- /dev/null +++ b/mod_register_web/templates/register.html @@ -0,0 +1,33 @@ + + + + + XMPP Account Registration + + +

XMPP Account Registration

+
+

{notice}

+ + + + + + + + + + + + + + + {captcha} + + + + +
Username:@{hostname}
Password:
Confirm Password:
+
+ + diff --git a/mod_register_web/templates/simplecaptcha.html b/mod_register_web/templates/simplecaptcha.html new file mode 100644 index 0000000..ad2122b --- /dev/null +++ b/mod_register_web/templates/simplecaptcha.html @@ -0,0 +1,5 @@ + + What is {x} {op} {y}? + + + diff --git a/mod_register_web/templates/success.html b/mod_register_web/templates/success.html new file mode 100644 index 0000000..1f4c11e --- /dev/null +++ b/mod_register_web/templates/success.html @@ -0,0 +1,13 @@ + + + + + Registration succeeded! + + +

Registration succeeded!

+

Your account is

+
{jid}
+

- happy chatting!

+ +