diff --git a/priv/mod_invites/base_min.html b/priv/mod_invites/base_min.html index 7ae225583b2..dd4d6eda95c 100644 --- a/priv/mod_invites/base_min.html +++ b/priv/mod_invites/base_min.html @@ -24,6 +24,6 @@

{%block h1 %}{% blocktrans %}Invite to {{ site_name }}{% {% block qr_code %}{% endblock %} {% block extra_scripts %}{% endblock %} - + diff --git a/priv/mod_invites/index.html b/priv/mod_invites/index.html new file mode 100644 index 00000000000..4dee56d28dc --- /dev/null +++ b/priv/mod_invites/index.html @@ -0,0 +1,113 @@ +{% extends "base_min.html" %} + +{% block title %}{% if error %}{% trans "Form Error" %}{% else %}{% trans "Startpage" %}{% endif %} | {{ site_name }}{% endblock %} +{% block h1 %}{% if error %}{% trans "Form Error" %}{% else %}{% blocktrans %}Welcome to {{ site_name }}{% endblocktrans %}{% endif %}{% endblock %} + +{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 col-sm-offset-2 col-lg-6 col-lg-offset-3 my-3 mt-md-5{% endblock %} + +{% block content %} +

Create Account Invite

+ {% if token %} +
Congratulations, a new invite has been created! Forward this link to someone you want to invite to this service.
+
+
+ {% trans "Your Invite-Link:" %} {{ token }} +
+ +
+
+ + {% trans "You can transfer this invite to a mobile device by scanning the QR code below with its camera." %} +
+
+
+
+
+ + + {% else %} +

{% blocktrans %}This form allows you to create an invite that you can send to someone else, so they can create a new account at this service. Be aware that you need to have the necessary rights to do so, otherwise this command will fail.{% endblocktrans %}

+
+
+ +
+
+ +
+ @{{ domain }} +
+
+ {% if error.class == 'username' %}{{ error.text }}{% else %} + {% blocktrans %}Please provide a valid username!{% endblocktrans %}{% endif %} +
+
+ {% trans "Enter the username you use to log into this service." %} +
+
+
+ +
+ +
+ {% if error.class == 'password' %}{{ error.text }}{% else %} + {% blocktrans %}Please provide a password!{% endblocktrans %}{% endif %} +
+ {% trans "Enter the corresponding password." %} +
+
+
+
{% trans "Invite Options" %}
+
+
+ +
+
+ +
+ @{{ domain }} +
+
+ {% if error.class == 'account_name' %}{{ error.text }}{% endif %} +
+ {% trans "You can suggest a username for the account to be created with this invite." %} +
+
+
+
+ + + {% trans "If checked once an account is created using this invite, it will automatically be added to your contact list." %} +
+
+
+
+ + +
+
+ {% endif %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/priv/mod_invites/register.html b/priv/mod_invites/register.html index 1d5267f84c4..ac790a86526 100644 --- a/priv/mod_invites/register.html +++ b/priv/mod_invites/register.html @@ -24,12 +24,12 @@

{% trans "Create an account" %}

{%if error and error.class == 'undefined' %}{% endif %}
-
- -
+
+ +
@{{ domain }} @@ -42,10 +42,10 @@

{% trans "Create an account" %}

{% trans "Choose a username, this will become the first part of your new chat address." %}
-
- -
- + +
+
{% if error.class == 'password' %}{{ error.text }}{% else %} diff --git a/priv/mod_invites/static/invite.js b/priv/mod_invites/static/invite.js index a168d14f468..257e343d030 100644 --- a/priv/mod_invites/static/invite.js +++ b/priv/mod_invites/static/invite.js @@ -2,13 +2,20 @@ document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')); // If QR lib loaded ok, show QR button on desktop devices - if (window.QRCode) { + const qr_el = document.getElementById("qr-invite-page"); + if (window.QRCode && qr_el) { + let link = document.location.href; + const data_link = qr_el.getAttribute('data-link'); + if (data_link && data_link != '') + link = data_link; const qrcode_opts = { - text: document.location.href, + text: link, addQuietZone: true }; - new QRCode(document.getElementById("qr-invite-page"), qrcode_opts); - document.getElementById('qr-button-container').classList.add("d-md-block"); + new QRCode(qr_el, qrcode_opts); + const qr_button = document.getElementById('qr-button-container'); + if (qr_button) + qr_button.classList.add("d-md-block"); } const toggle_pw_button = document.getElementById('toggle-pw-button'); @@ -129,9 +136,9 @@ function toggle_password(e) { 'use strict'; window.addEventListener('load', function () { // Fetch all the forms we want to apply custom Bootstrap validation styles to - var forms = document.getElementsByClassName('needs-validation'); + const forms = document.getElementsByClassName('needs-validation'); // Loop over them and prevent submission - var validation = Array.prototype.filter.call(forms, function (form) { + const validation = Array.prototype.filter.call(forms, function (form) { form.addEventListener('submit', function (event) { if (form.checkValidity() === false) { event.preventDefault(); @@ -140,5 +147,28 @@ function toggle_password(e) { form.classList.add('was-validated'); }, false); }); + + const clipboard_btns = document.getElementsByClassName('clipboard'); + + Array.prototype.forEach.call(clipboard_btns, function(btn) { + btn.addEventListener('click', (e) => { + const span = btn.children[0]; + const oldVal = span.innerText; + Promise.resolve().then(() => { + return navigator.clipboard.writeText(btn.getAttribute('data-copy')); + }).then( + () => { + span.innerText = btn.getAttribute('data-text-copied'); + }, + () => { + span.innerText = btn.getAttribute('data-text-copy-failed'); + } + ).finally(() => { + window.setTimeout(() => { + span.innerText = oldVal; + }, 1000); +}); + }); + }); }, false); })(); diff --git a/src/mod_invites.erl b/src/mod_invites.erl index 24de85305fb..2c4ee5a1313 100644 --- a/src/mod_invites.erl +++ b/src/mod_invites.erl @@ -46,15 +46,16 @@ -export([cleanup_expired/0, expire_tokens/2, generate_invite/1, generate_invite/2, list_invites/1]). %% helpers --export([create_account_allowed/2, get_invite/2, get_invites_tree_t/2, get_max_invites/2, - is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, roster_add/2, - send_presence/3, set_invitee/3, set_invitee/5, token_uri/1, transaction/2, xdata_field/3]). +-export([create_account_allowed/2, create_account_invite/4, get_invite/2, get_invites_tree_t/2, + get_max_invites/2, is_create_allowed/2, is_expired/1, is_reserved/3, is_token_valid/2, + roster_add/2, send_presence/3, set_invitee/3, set_invitee/5, token_uri/1, transaction/2, + xdata_field/3]). %% ejabberd_http -export([process/2]). -ifdef(TEST). --export([create_roster_invite/2, create_account_invite/4, find_invites_tree_root_t/4, gen_invite/1, +-export([create_roster_invite/2, find_invites_tree_root_t/4, gen_invite/1, gen_invite/2, get_invites/2, get_invites_tree_as_root_t/2, is_token_valid/3]). -endif. @@ -132,7 +133,15 @@ mod_doc() -> ?T("In order to use the included landing page feature, you have to" " set `landing_page` to either `auto` or an URL template like " "`https://{{ host }}/invites/{{ invite.token }}` " - " if your server setup includes a so called reverse proxy."), + " if your server setup includes a so called reverse proxy. " + "Furthermore you need to connect this module as a handler of an `ejabberd_http` " + "listener as shown at the example below."), + "", + ?T("For convenience there's now also a start page included at wherever you 'mount' " + "`mod_invites` at the `ejabberd_http` handler. This start page will allow people to " + "generate invites by giving their username and password. As such the URL should be " + "protected by HTTPS. The main use-case is when people have only clients, that don't " + "support generating invites natively."), "", ?T("If you'd rather want to use an external service, set `landing_page` " "to something like " diff --git a/src/mod_invites_http.erl b/src/mod_invites_http.erl index 89209523a03..de6f8826ab6 100644 --- a/src/mod_invites_http.erl +++ b/src/mod_invites_http.erl @@ -26,16 +26,17 @@ -author('stefan@strigler.de'). --include("logger.hrl"). - -export([process/2, landing_page/2, tmpl_to_renderer/1]). +-import(translate, [translate/2]). + -ifdef(TEST). -export([apps_json/3]). -endif. -include_lib("xmpp/include/xmpp.hrl"). +-include("logger.hrl"). -include("ejabberd_http.hrl"). -include("mod_invites.hrl"). -include("translate.hrl"). @@ -43,6 +44,7 @@ -define(HTTP(Code, Headers, CT, Text), {Code, [{<<"Content-Type">>, CT} | Headers], Text}). -define(HTTP(Code, CT, Text), ?HTTP(Code, [], CT, Text)). -define(HTTP(Code, Text), ?HTTP(Code, <<"text/plain">>, Text)). +-define(HTTP_OK(Text), ?HTTP_OK([], Text)). -define(HTTP_OK(Headers, Text), ?HTTP(200, security_headers() ++ Headers, <<"text/html">>, Text)). -define(NOT_FOUND, ?HTTP(404, ?T("NOT FOUND"))). -define(NOT_FOUND(Text), ?HTTP(404, <<"text/html">>, Text)). @@ -56,7 +58,6 @@ {<<".js">>, <<"application/javascript">>}, {<<".png">>, <<"image/png">>}, {<<".svg">>, <<"image/svg+xml">>}]). - -define(STATIC, <<"static">>). -define(REGISTRATION, <<"registration">>). -define(STATIC_CTX, {static, <<"/", Base/binary, "/", ?STATIC/binary>>}). @@ -97,8 +98,7 @@ render_landing_page_url(Tmpl, Host, Invite) -> -spec process(LocalPath :: [binary()], #request{}) -> {HTTPCode :: integer(), [{binary(), binary()}], Page :: string()}. -process([?STATIC | StaticFile], #request{host = Host} = Request) -> - ?DEBUG("Static file requested ~p:~n~p", [StaticFile, Request]), +process([?STATIC | StaticFile], #request{host = Host} = _Request) -> try mod_invites_opt:templates_dir(Host) of TemplatesDir -> Filename = filename:join([TemplatesDir, "static" | StaticFile]), @@ -110,11 +110,14 @@ process([?STATIC | StaticFile], #request{host = Host} = Request) -> ?NOT_FOUND end catch - _:{module_not_loaded, mod_invites, Host} -> + _:{module_not_loaded, mod_invites, _Host} -> ?NOT_FOUND end; -process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) -> - ?DEBUG("Requested:~n~p", [Request]), +process([Token | _] = LocalPath, + #request{host = Host, + lang = Lang, + path = Path} = + Request) -> try mod_invites:is_token_valid(Host, Token) of true -> case mod_invites:get_invite(Host, Token) of @@ -127,15 +130,76 @@ process([Token | _] = LocalPath, #request{host = Host, lang = Lang} = Request) - ?NOT_FOUND(render(Host, Lang, <<"invite_invalid.html">>, - ctx(Request, LocalPath, Token))) + base_ctx(Host, Lang, Path, LocalPath))) catch _:not_found -> ?NOT_FOUND; _:{error, host_unknown} -> ?NOT_FOUND end; -process([], _Request) -> - ?NOT_FOUND. +process([] = LocalPath, + #request{method = 'POST', + q = Q, + path = Path, + host = Host, + lang = Lang, + headers = Headers}) -> + Username = proplists:get_value(<<"user">>, Q), + Password = proplists:get_value(<<"password">>, Q), + CSRFToken = proplists:get_value(<<"csrf_token">>, Q), + CookieVal = get_csrf_cookie(<<"gen-invite-id">>, Headers), + try {check_csrf(CookieVal, CSRFToken), + ejabberd_auth:check_password(Username, <<"plain">>, Host, Password), + mod_invites:create_account_allowed(Host, jid:make(Username, Host))} + of + {ok, true, ok} -> + AccountName = proplists:get_value(<<"account_name">>, Q, <<>>), + Subscribe = proplists:get_value(<<"subscribe">>, Q, <<"no">>) == <<"yes">>, + case mod_invites:create_account_invite(Host, {Username, Host}, AccountName, Subscribe) + of + #invite_token{} = Invite -> + Ctx = [{uri, mod_invites:token_uri(Invite)}, + {landing_page, landing_page(Host, Invite)}, + {token, Invite#invite_token.token} + | base_ctx(Host, Lang, Path, LocalPath)], + ?HTTP_OK(render(Host, Lang, <<"index.html">>, Ctx)); + {error, Reason} -> + Ctx = [{username, Username}, + {csrf_token, CSRFToken}, + {error, + [{text, reason_to_hr(Lang, Reason)}, {class, error_class(Reason)}]} + | base_ctx(Host, Lang, Path, LocalPath)], + render_bad_request(Host, true, <<"index.html">>, Ctx) + end; + {ok, true, {error, not_allowed}} -> + Ctx = [{username, Username}, + {csrf_token, CSRFToken}, + {error, + [{text, translate(Lang, ?T("User is not allowed to create invites"))}, + {class, username}]} + | base_ctx(Host, Lang, Path, LocalPath)], + render_bad_request(Host, true, <<"index.html">>, Ctx); + {ok, false, _} -> + Ctx = [{username, Username}, + {csrf_token, CSRFToken}, + {error, [{text, translate(Lang, ?T("Password invalid"))}, {class, password}]} + | base_ctx(Host, Lang, Path, LocalPath)], + render_bad_request(Host, true, <<"index.html">>, Ctx) + catch + _:no_match -> + ?BAD_REQUEST + end; +process([] = LocalPath, + #request{path = Path, + host = Host, + lang = Lang}) -> + CSRFCookie = gen_rand_id(), + Ctx = [{csrf_token, csrf_token(CSRFCookie)} | base_ctx(Host, Lang, Path, LocalPath)], + ?HTTP_OK(maybe_add_hsts_header(add_cookie_header([], + csrf_cookie_string(<<"gen-invite-id">>, + CSRFCookie)), + true), + render(Host, Lang, <<"index.html">>, Ctx)). process_valid_token([_Token, AppID, ?REGISTRATION] = LocalPath, #request{method = 'POST'} = Request, @@ -175,10 +239,12 @@ process_register_form(Invite, LocalPath) -> try app_ctx(Host, AppID, Lang, ctx(Invite, Request, LocalPath)) of AppCtx -> - Ctx = [{csrf_token, csrf_token(Invite#invite_token.token)}, maybe_add_username(Invite)] - ++ AppCtx, + CSRFCookie = gen_rand_id(), + Ctx = [{csrf_token, csrf_token(CSRFCookie)} | maybe_add_username(AppCtx, Invite)], Body = render_register_form(Request, Ctx), - Headers = maybe_add_hsts_header(Host, Invite), + Headers = + add_cookie_header(maybe_hsts_header(is_https_lp(Host, Invite)), + csrf_cookie_string(<<"register-id">>, CSRFCookie)), ?HTTP_OK(Headers, Body) catch _:not_found -> @@ -200,16 +266,19 @@ process_register_post(Invite, #request{host = Host, q = Q, lang = Lang, - ip = {Source, _}} = + path = Path, + ip = {Source, _}, + headers = Headers} = Request, LocalPath) -> Username = proplists:get_value(<<"user">>, Q), Password = proplists:get_value(<<"password">>, Q), - Token = Invite#invite_token.token, CSRFToken = proplists:get_value(<<"csrf_token">>, Q), + Token = Invite#invite_token.token, + CSRFCookie = get_csrf_cookie(<<"register-id">>, Headers), try {app_ctx(Host, AppID, Lang, ctx(Invite, Request, LocalPath)), ensure_same(Token, proplists:get_value(<<"token">>, Q)), - check_csrf(Token, CSRFToken)} + check_csrf(CSRFCookie, CSRFToken)} of {AppCtx, ok, ok} -> case mod_invites_register:try_register(Invite, Username, Host, Password, Source, Lang) @@ -229,16 +298,20 @@ process_register_post(Invite, case Type of T when T == cancel; T == modify -> Ctx = [{username, Username}, - {csrf_token, csrf_token(Invite#invite_token.token)}, + {csrf_token, CSRFToken}, {error, [{text, Msg}, {class, error_class(Reason)}]}] ++ AppCtx, Body = render_register_form(Request, Ctx), ?BAD_REQUEST(Body); _ -> render_bad_request(Host, - Invite, + is_https_lp(Host, Invite), <<"register_error.html">>, - [{message, Msg} | ctx(Request, LocalPath, Token)]) + [{message, Msg} | base_ctx(Host, + Lang, + Path, + LocalPath, + Token)]) end end catch @@ -250,6 +323,8 @@ process_register_post(Invite, check_csrf(_Token, undefined) -> throw(no_match); +check_csrf(<<>>, _Could) -> + throw(no_match); check_csrf(Token, Could) -> Should = csrf_token(Token), try crypto:hash_equals(Should, Could) of @@ -262,7 +337,7 @@ check_csrf(Token, Could) -> throw(no_match) end. -csrf_token(Msg) -> +csrf_token(Msg) when Msg /= <<>> -> SecretKey = ejabberd_config:get_shared_key(), base64:encode( crypto:mac(hmac, @@ -289,17 +364,6 @@ maybe_add_webchat_url(Host, Ctx) -> [{webchat_url, WebchatUrl} | Ctx] end. -error_class('jid-malformed') -> - username; -error_class('not-allowed') -> - username; -error_class(conflict) -> - username; -error_class('not-acceptable') -> - password; -error_class(_) -> - undefined. - process_roster_token([_Token] = LocalPath, #request{host = Host, lang = Lang} = Request, Invite) -> @@ -313,8 +377,7 @@ process_roster_token([_Token] = LocalPath, #{<<"url">> := Url} -> Url end, - App#{proceed_url => ProceedUrl, - select_text => translate:translate(Lang, ?T("Install"))} + App#{proceed_url => ProceedUrl, select_text => translate(Lang, ?T("Install"))} end, apps_json(Host, Lang, Ctx0)), Ctx = [{apps, Apps} | Ctx0], @@ -338,33 +401,38 @@ app_ctx(Host, AppID, Lang, Ctx) -> throw(not_found) end. -ctx(#request{host = Host, - path = Path, - lang = Lang}, - LocalPath, - Token) -> - OriginalPath = +base_ctx(Host, Lang, Path, LocalPath) -> + base_ctx(Host, Lang, Path, LocalPath, <<>>). + +base_ctx(Host, Lang, Path, LocalPath, Token) -> + Base = configured_base_path(Host, Path, LocalPath, Token), + SiteName = mod_invites_opt:site_name(Host), + [{base, Base}, {domain, Host}, ?STATIC_CTX, ?SITE_NAME_CTX(SiteName), ?LANG(Lang)]. + +configured_base_path(Host, Path, LocalPath, Token) -> + BasePath = case landing_page_tmpl(Host) of <<>> -> - Path; + Path -- LocalPath; Tmpl -> Url = render_url(Tmpl, [{invite, [{token, Token}]}, {host, Host}]), - #{path := OPath} = uri_string:parse(Url), - {LPath, _Q} = ejabberd_http:url_decode_q_split_normalize(OPath), - LPath + #{path := OPath0} = uri_string:parse(Url), + {OPath, _Q} = ejabberd_http:url_decode_q_split_normalize(OPath0), + OPath -- LocalPath end, - Base = - iolist_to_binary(uri_string:normalize( - lists:join(<<"/">>, OriginalPath -- LocalPath))), - SiteName = mod_invites_opt:site_name(Host), - [{base, Base}, ?STATIC_CTX, ?SITE_NAME_CTX(SiteName), ?LANG(Lang)]; -ctx(Invite, #request{host = Host} = Request, LocalPath) -> + iolist_to_binary(uri_string:normalize( + lists:join(<<"/">>, BasePath))). + +ctx(Invite, + #request{host = Host, + lang = Lang, + path = Path}, + LocalPath) -> [{invite, invite_to_proplist(Invite)}, {uri, mod_invites:token_uri(Invite)}, - {domain, Host}, {token, Invite#invite_token.token}, {registration_url, <<(Invite#invite_token.token)/binary, "/", ?REGISTRATION/binary>>} - | ctx(Request, LocalPath, Invite#invite_token.token)]. + | base_ctx(Host, Lang, Path, LocalPath, Invite#invite_token.token)]. apps_json(Host, Lang, Ctx) -> AppsBins = render(Host, Lang, <<"apps.json">>, Ctx), @@ -443,8 +511,7 @@ render(Host, Lang, File, Ctx) -> Renderer:render(Ctx, [{locale, Lang}, {translation_fun, - fun(Msg, TFLang) -> translate:translate(lang(TFLang), list_to_binary(Msg)) - end}]), + fun(Msg, TFLang) -> translate(lang(TFLang), list_to_binary(Msg)) end}]), Rendered. lang(default) -> @@ -454,23 +521,25 @@ lang(Lang) -> render_ok(Host, Invite, Lang, File, Ctx) -> URI = proplists:get_value(uri, Ctx), - Headers = maybe_add_hsts_header([{<<"Link">>, <<"<", URI/binary, ">">>}], Host, Invite), + Headers = + maybe_add_hsts_header([{<<"Link">>, <<"<", URI/binary, ">">>}], + is_https_lp(Host, Invite)), ?HTTP_OK(Headers, render(Host, Lang, File, Ctx)). -maybe_add_hsts_header(Host, Invite) -> - maybe_add_hsts_header([], Host, Invite). - -maybe_add_hsts_header(Headers, Host, Invite) -> +is_https_lp(Host, Invite) -> LP = landing_page(Host, Invite), - case re:run(LP, "^https://") of - nomatch -> - Headers; - {match, _} -> - [{<<"Strict-Transport-Security">>, <<"max-age=31536000; includeSubDomains">>} | Headers] - end. + re:run(LP, "^https://") =/= nomatch. + +maybe_hsts_header(IsHttps) -> + maybe_add_hsts_header([], IsHttps). -render_bad_request(Host, Invite, File, Ctx) -> - Headers = maybe_add_hsts_header(Host, Invite), +maybe_add_hsts_header(Headers, true) -> + [{<<"Strict-Transport-Security">>, <<"max-age=31536000; includeSubDomains">>} | Headers]; +maybe_add_hsts_header(Headers, false) -> + Headers. + +render_bad_request(Host, IsHttps, File, Ctx) -> + Headers = maybe_hsts_header(IsHttps), Renderer = file_to_renderer(Host, File), {ok, Rendered} = Renderer:render(Ctx), ?BAD_REQUEST(Headers, Rendered). @@ -479,10 +548,10 @@ render_bad_request(Host, Invite, File, Ctx) -> guess_content_type(FileName) -> mod_http_fileserver:content_type(FileName, ?DEFAULT_CONTENT_TYPE, ?CONTENT_TYPES). -maybe_add_username(#invite_token{account_name = <<>>}) -> - []; -maybe_add_username(#invite_token{account_name = AccountName}) -> - [{username, AccountName}]. +maybe_add_username(Ctx, #invite_token{account_name = <<>>}) -> + Ctx; +maybe_add_username(Ctx, #invite_token{account_name = AccountName}) -> + [{username, AccountName} | Ctx]. -spec binary_join(binary() | [binary()], binary()) -> binary(). binary_join(Bin, _Sep) when is_binary(Bin) -> @@ -507,3 +576,58 @@ security_headers() -> <<"default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'">>}, {<<"X-Content-Type-Options">>, <<"nosniff">>}, {<<"Referrer-Policy">>, <<"no-referrer">>}]. + +gen_rand_id() -> + p1_rand:get_alphanum_string(32). + +csrf_cookie_string(Key, CSRFCookie) -> + <>. + +add_cookie_header(Headers, Cookie) -> + [{<<"Set-Cookie">>, Cookie} | Headers]. + +get_csrf_cookie(Key, Headers) -> + maps:get(Key, parse_cookie_header(Headers), <<>>). + +parse_cookie_header(Headers) -> + C = proplists:get_value('Cookie', Headers, <<>>), + lists:foldl(fun ([K, V], M) -> + M#{K => V}; + (_, M) -> + M + end, + #{}, + [binary:split(S, <<"=">>) || S <- binary:split(C, <<"; ">>)]). + +error_class('jid-malformed') -> + username; +error_class('not-allowed') -> + username; +error_class(conflict) -> + username; +error_class(num_invites_exceeded) -> + username; +error_class('not-acceptable') -> + password; +error_class(reserved) -> + account_name; +error_class(user_exists) -> + account_name; +error_class(account_name_invalid) -> + account_name; +error_class(_) -> + undefined. + +reason_to_hr(Lang, num_invites_exceeded) -> + translate(Lang, ?T("Maximum number of invites reached")); +reason_to_hr(Lang, user_exists) -> + translate(Lang, ?T("Username exists already")); +reason_to_hr(Lang, account_name_invalid) -> + translate(Lang, ?T("Username contains invalid characters")); +reason_to_hr(Lang, reserved) -> + translate(Lang, ?T("Username is reserved")); +reason_to_hr(_Lang, T) when is_atom(T) -> + atom_to_binary(T); +reason_to_hr(_Lang, T) -> + %% good luck! + T. diff --git a/test/invites_tests.erl b/test/invites_tests.erl index ef944514a7a..9f4117a90e0 100644 --- a/test/invites_tests.erl +++ b/test/invites_tests.erl @@ -146,7 +146,8 @@ single_cases() -> single_test(ibr_reserved), single_test(ibr_subscription), single_test(ibr_conflict), - single_test(http)]}. + single_test(http), + single_test(create_invite_page)]}. %%%=================================================================== @@ -825,6 +826,7 @@ ibr_conflict(Config0) -> http(Config) -> Server = ?config(server, Config), User = ?config(user, Config), + httpc:set_options([{cookies, enabled}]), {TokenURI, LandingPage} = mod_invites:gen_invite(Server), Token = token_from_uri(TokenURI), {ok, {{_, 200, _}, Headers, Body}} = httpc:request(LandingPage), @@ -852,12 +854,8 @@ http(Config) -> [Last] = hd(lists:reverse(RegistrationURLs)), RegURL = <>, - {ok, {{_, 200, _}, _, RegURLBody}} = httpc:request(RegURL), - {match, [[CSRFToken]]} = - re:run(RegURLBody, - <<">, - [global, {capture, [1], binary}]), - ct:pal("extracted csrf token: ~p", [CSRFToken]), + CSRFToken = get_csrf_token(RegURL), + {ok, {{_, 400, _}, _, _}} = post(RegURL, <<"badtoken">>, CSRFToken, <<"foo">>, <<"bar">>), {ok, {{_, 400, _}, _, _}} = post(RegURL, Token, CSRFToken, User, <<"bar">>), {ok, {{_, 400, _}, _, _}} = post(RegURL, Token, CSRFToken, <<"@invalidUser">>, <<"bar">>), @@ -892,6 +890,76 @@ http(Config) -> ?match(1, mod_invites:cleanup_expired()), disconnect(Config). +create_invite_page(Config) -> + Server = ?config(server, Config), + User = ?config(user, Config), + Password = ?config(password, Config), + + AutoURL = ejabberd_http:get_auto_url(any, mod_invites), + BaseURL = misc:expand_keyword(<<"@HOST@">>, AutoURL, Server), + + httpc:set_options([{cookies, disabled}]), + + ?match({ok, {{_, 400, _}, _, _}}, post(BaseURL, [], <<>>)), + ?match({ok, {{_, 400, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, {password, Password}, {csrf_token, <<"some_nonsense">>}]))), + + CSRFToken = get_csrf_token(BaseURL), + %% still not working because no cookie + ?match({ok, {{_, 400, _}, _, _}}, + post(BaseURL, [], to_qs([{user, User}, {password, Password}, {csrf_token, CSRFToken}]))), + + httpc:set_options([{cookies, enabled}]), + + NewCSRFToken = get_csrf_token(BaseURL), + + %% user not allowed to create invites + ?match({ok, {{_, 400, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, {password, Password}, {csrf_token, NewCSRFToken}]))), + + OldOpts = gen_mod:get_module_opts(Server, mod_invites), + NewOpts = gen_mod:set_opt(access_create_account, account_invite, OldOpts), + update_module_opts(Server, mod_invites, NewOpts), + + ?match({ok, {{_, 200, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, {password, Password}, {csrf_token, NewCSRFToken}]))), + ?match({ok, {{_, 400, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, {password, <<"bad_password">>}, {csrf_token, NewCSRFToken}]))), + ?match({ok, {{_, 400, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, + {password, Password}, + {csrf_token, NewCSRFToken}, + {account_name, User}]))), + ?match({ok, {{_, 200, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, + {password, Password}, + {csrf_token, NewCSRFToken}, + {account_name, <<"some_free_account_name">>}]))), + %% now it's reserved + ?match({ok, {{_, 400, _}, _, _}}, + post(BaseURL, + [], + to_qs([{user, User}, + {password, Password}, + {csrf_token, NewCSRFToken}, + {account_name, <<"some_free_account_name">>}]))), + + mod_invites:remove_user(User, Server), + update_module_opts(Server, mod_invites, OldOpts), + disconnect(Config). + %%%=================================================================== %%% Internal functions %%%=================================================================== @@ -1009,24 +1077,39 @@ send_pars(Config, Token) -> to = ServerJID, sub_els = [#preauth{token = Token}]}). -post(URL, Token0, User0, Password0) -> - [Token, User, Password] = [uri_string:quote(V) || V <- [Token0, User0, Password0]], - Data = <<"token=", Token/binary, "&user=", User/binary, "&password=", Password/binary>>, - httpc:request(post, {URL, [], "application/x-www-form-urlencoded", Data}, [], []). +post(URL, Token, User, Password) -> + Data = to_qs([{token, Token}, {user, User}, {password, Password}]), + post(URL, [], Data). -post(URL, Token0, CSRFToken0, User0, Password0) -> - [Token, CSRFToken, User, Password] = - [uri_string:quote(V) || V <- [Token0, CSRFToken0, User0, Password0]], +post(URL, Token, CSRFToken, User, Password) -> Data = - <<"token=", - Token/binary, - "&csrf_token=", - CSRFToken/binary, - "&user=", - User/binary, - "&password=", - Password/binary>>, - httpc:request(post, {URL, [], "application/x-www-form-urlencoded", Data}, [], []). + to_qs([{token, Token}, {user, User}, {password, Password}, {csrf_token, CSRFToken}]), + post(URL, [], Data). + +post(URL, Headers, Data) -> + httpc:request(post, {URL, Headers, "application/x-www-form-urlencoded", Data}, [], []). + +to_qs(List) -> + lists:foldl(fun ({K, V}, <<>>) -> + <<(atom_to_binary(K))/binary, "=", (uri_string:quote(V))/binary>>; + ({K, V}, QS) -> + <>, + [global, {capture, [1], binary}]), + ct:pal("extracted csrf token: ~p", [CSRFToken]), + CSRFToken. gen_mod_set_opts(OldOpts, NewOpts) -> lists:foldl(fun({Opt, Val}, Opts) -> gen_mod:set_opt(Opt, Val, Opts) end,