Skip to content

Commit aef96d4

Browse files
committed
mod_invites: add generate_reset_token
Adds a new command `generate_reset_token <user> <host>` that allows to change the password for this account in case access to it was lost.
1 parent 03fc96c commit aef96d4

10 files changed

Lines changed: 334 additions & 83 deletions

include/mod_invites.hrl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
invitee = <<>> :: binary(),
1616
created_at = calendar:now_to_datetime(erlang:timestamp()) :: calendar:datetime(),
1717
expires = calendar:gregorian_seconds_to_datetime(calendar:datetime_to_gregorian_seconds(calendar:now_to_datetime(erlang:timestamp())) + ?INVITE_TOKEN_EXPIRE_SECONDS_DEFAULT) :: calendar:datetime(),
18-
type = roster_only :: roster_only | account_only | account_subscription,
18+
type = roster_only :: roster_only | account_only | account_subscription | reset_token,
1919
%% If type is 'roster_only' then we indicate a token has been used to create
2020
%% an account (if allowed) by setting `account_name` to the name of the user
2121
%% (which should match `invitee`).

priv/mod_invites/reset_error.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% extends "base_min.html" %}
2+
{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 my-3 mt-md-5{% endblock %}
3+
{% block content %}
4+
<h2 class="card-title h5">{% trans "Password reset error" %}</h2>
5+
6+
<p>{% if message %}{{ message }}{% else %}{% trans "Sorry, there was a problem changing your account password." %}{% endif %}</p>
7+
{% endblock%}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{% extends "base_min.html" %}
2+
{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 my-3 mt-md-5{% endblock %}
3+
{% block title %}{% trans "Password Change Success" %} | {{site_name}}{% endblock %}
4+
{% block h1 %}{{site_name}}{% endblock %}
5+
{% block content %}
6+
<h2 class="card-title h5">{% trans "Congratulations!" %}</h2>
7+
8+
<p>{% blocktrans %}You have changed your password on <strong>{{ site_name }}</strong>.{% endblocktrans %}</p>
9+
10+
<p>{% trans "To continue chatting, you need to enter your new account credentials into your chosen XMPP software." %}</p>
11+
12+
13+
<p>{% trans "As a final reminder, your account details are shown below:" %}</p>
14+
15+
<form class="account-details col-12 mx-auto">
16+
<div class="input-group">
17+
<label for="user" class="col-md-4 col-form-label fw-bold">{% trans "Chat address (JID)" %}:</label>
18+
<div class="col-md-8 col-12">
19+
<input type="text" class="form-control-plaintext" readonly value="{{ username }}@{{ domain }}">
20+
</div>
21+
</div>
22+
{% if password %}
23+
<div class="input-group">
24+
<label for="password" class="col-md-4 col-12 col-form-label fw-bold">{% trans "Password" %}:</label>
25+
<div class="col-md-8 col-12">
26+
<div class="input-group">
27+
<input type="password" readonly disabled aria-label="{% trans "Password" %}" class="form-control" value="{{ password }}">
28+
<div class="input-group-append">
29+
<button id="toggle-pw-button" class="btn btn-outline-secondary rounded-start-0" type="button"
30+
data-text-show="{% trans "Show" %}" data-text-hide="{% trans "Hide" %}">{% trans "Show" %}</button>
31+
</div>
32+
</div>
33+
</div>
34+
</div>
35+
{% endif %}
36+
</form>
37+
38+
{% if password %}
39+
<p class="mt-3">{% trans "Your password is stored encrypted on the server and will not be accessible after you close this page. Keep it safe and never share it with anyone." %}</p>
40+
{% endif %}
41+
{% endblock %}

priv/mod_invites/reset_token.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{% extends "base_min.html" %}
2+
3+
{% block title %}{% if error %}{% trans "Form Error" %}{% else %}{% trans "Reset Password" %}{% endif %} | {{ site_name }}{% endblock %}
4+
{% block h1 %}{% if error %}{% trans "Form Error" %}{% else %}{% blocktrans %}Reset Password on {{ site_name }}{% endblocktrans %}{% endif %}{% endblock %}
5+
6+
{% block form_class %}container col-md-8 col-md-offset-2 col-sm-8 cold-sm-offset-2 col-lg-6 col-lg-offset-3 my-3 mt-md-5{% endblock %}
7+
8+
{% block content %}
9+
<p>{% blocktrans %}Hey, <strong class="text-alert">{{ username }}</strong>, if you'd like to change your password, maybe because you forgot your old one, you've come to the right place!{% endblocktrans %}</p>
10+
<p class="alert alert-warning">{% blocktrans %}If this is not you, please disregard!{% endblocktrans %}</p>
11+
<div class="alert alert-info">If you have a compatible XMPP/Jabber client installed on your system you can try using this button to reset your password instead. This way you don't need to copy your password. Otherwise proceed with the form below!</div>
12+
<div class="text-center">
13+
<a href="{{ uri }}" type="button" class="btn btn-secondary">Change Password using your installed client</a></div>
14+
15+
<h3 class="card-title h4 my-3">{% trans "Reset Password" %}</h3>
16+
{%if error and error.class == 'undefined' %}<div class="alert alert-danger" role="alert">{{ error.text }}</div>{% endif %}
17+
<form method="post" class="needs-validation" novalidate>
18+
<div class="row mb-3">
19+
<label for="user" class="col-md-4 form-label fw-bold">{% trans "Username" %}:</label>
20+
<div class="col-md-8">
21+
<div class="input-group">
22+
<input
23+
type="text" name="user" id="user" class="form-control" readonly minlength="1" maxlength="30" length="30" value="{{ username }}">
24+
<div class="input-group-append">
25+
<span class="input-group-text">@{{ domain }}</span>
26+
</div>
27+
</div>
28+
</div>
29+
</div>
30+
<div class="row mb-3">
31+
<label for="password" class="col-md-4 form-label col-form-label fw-bold">{% trans "New Password" %}:</label>
32+
<div class="col-md-8">
33+
<input type="password" name="password" id="password" class="form-control {% if error.class == 'password' %}is-invalid{% endif %}" aria-describedby="passwordHelp" tabindex="1"
34+
autocomplete="new-password" required autofocus minlength="{{ password_min_length }}">
35+
<div class="invalid-feedback">
36+
{% if error.class == 'password' %}{{ error.text }}{% else %}
37+
{% blocktrans %}Please provide a password! Minimum length: {{ password_min_length }}{% endblocktrans %}{% endif %}
38+
</div>
39+
<small id="passwordHelp" class="form-text text-muted">{% trans "Enter a secure password that you do not use anywhere else." %}</small>
40+
</div>
41+
</div>
42+
<div>
43+
<input type="hidden" name="token" value="{{ token }}">
44+
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
45+
<button type="submit" tabindex="2" class="btn btn-primary float-end">{% trans "Submit" %}</button>
46+
</div>
47+
</form>
48+
{% endblock %}

src/mod_invites.erl

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444

4545
%% commands
4646
-export([cleanup_expired/0, delete_invite_by_token/2, expire_invites/2, expire_invite_by_token/2, generate_invite/1,
47-
generate_invite/2, list_invites/1]).
47+
generate_invite/2, generate_reset_token/2, list_invites/1]).
4848

4949
%% helpers
5050
-export([create_account_allowed/2, create_account_invite/4, get_invite/2, get_invites_tree_t/2,
@@ -63,7 +63,7 @@
6363
-include("ejabberd_web_admin.hrl").
6464

6565
-ifdef(TEST).
66-
-export([create_roster_invite/2, find_invites_tree_root_t/4, gen_invite/1,
66+
-export([create_roster_invite/2, create_reset_token/2, find_invites_tree_root_t/4, gen_invite/1,
6767
gen_invite/2, get_invites/2, get_invites_tree_as_root_t/2, is_token_valid/3]).
6868
-endif.
6969

@@ -380,6 +380,7 @@ webadmin_page_host(_, Host, #request{path = [<<"invites">> | RPath], lang = Lang
380380
Set = [make_command(generate_invite, Request, BaseArguments, [{force_execution, false}]),
381381
make_command(generate_invite_with_username, Request, BaseArguments, [{force_execution, false}]),
382382
make_command(expire_invite_tokens, Request, BaseArguments, []),
383+
make_command(generate_reset_token, Request, BaseArguments, [{force_execution, false}]),
383384
make_command(cleanup_expired_invite_tokens, Request, [], [{style, danger}, {force_execution, false}])],
384385
ExpireInvites = action_button(expire_invite_by_token, Request, BaseArguments, []),
385386
DeleteInvites = action_button(delete_invite_by_token, Request, BaseArguments, [{style, danger}]),
@@ -480,6 +481,22 @@ get_commands_spec() ->
480481
{<<"xmpp:juliet@example.com?register;preauth=4bsdpwVrRDQYnF9aQQKXGbF7">>,
481482
<<"https://example.com/invites/4bsdpwVrRDQYnF9aQQKXGbF7">>},
482483
result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}},
484+
#ejabberd_commands{name = generate_reset_token,
485+
tags = [accounts],
486+
desc =
487+
"Create a password reset token for user with given name on given host.",
488+
module = ?MODULE,
489+
function = generate_reset_token,
490+
note = "added in 26.05",
491+
args = [{username, binary}, {host, binary}],
492+
args_desc =
493+
["Username",
494+
"Hostname"],
495+
args_example = [<<"juliet">>, <<"example.com">>],
496+
result_example =
497+
{<<"xmpp:juliet@example.com?register;preauth=4bsdpwVrRDQYnF9aQQKXGbF7">>,
498+
<<"https://example.com/invites/4bsdpwVrRDQYnF9aQQKXGbF7">>},
499+
result = {invite, {tuple, [{invite_uri, string}, {landing_page, string}]}}},
483500
#ejabberd_commands{name = list_invites,
484501
tags = [accounts],
485502
desc = "List invite tokens",
@@ -532,11 +549,11 @@ expire_invites(User0, Server0) ->
532549
expire_invite_by_token(Host, Token) ->
533550
pretty_format_command_result(try_db_call(Host, expire_invite_by_token, [Host, Token])).
534551

535-
-spec generate_invite(binary()) -> binary() | {error, any()}.
552+
-spec generate_invite(binary()) -> {binary(), binary()} | {error, any()}.
536553
generate_invite(Host) ->
537554
generate_invite(<<>>, Host).
538555

539-
-spec generate_invite(binary(), binary()) -> binary() | {error, any()}.
556+
-spec generate_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}.
540557
generate_invite(AccountName, Host) ->
541558
pretty_format_command_result(gen_invite(AccountName, Host)).
542559

@@ -548,7 +565,7 @@ gen_invite(Host) ->
548565

549566
-endif.
550567

551-
-spec gen_invite(binary(), binary()) -> binary() | {error, any()}.
568+
-spec gen_invite(binary(), binary()) -> {binary(), binary()} | {error, any()}.
552569
gen_invite(AccountName, Host0) ->
553570
Host = jid:nameprep(Host0),
554571
case create_account_invite(Host, {<<>>, Host}, AccountName, false) of
@@ -558,6 +575,16 @@ gen_invite(AccountName, Host0) ->
558575
{token_uri(Invite), landing_page(Host, Invite)}
559576
end.
560577

578+
-spec generate_reset_token(binary(), binary()) -> {binary(), binary()} | {error, any()}.
579+
generate_reset_token(User, Host) ->
580+
Res = case create_reset_token(User, Host) of
581+
{error, _Reason} = Error ->
582+
Error;
583+
Invite ->
584+
{token_uri(Invite), landing_page(Host, Invite)}
585+
end,
586+
pretty_format_command_result(Res).
587+
561588
list_invites(Host) ->
562589
Res = maybe
563590
{ok, Invites} ?= try_db_call(Host, list_invites, [Host]),
@@ -1111,27 +1138,44 @@ invite_token_t(Type, Host, Inviter, AccountName0) ->
11111138
account_name = AccountName},
11121139
mod_invites_opt:token_expire_seconds(Host)).
11131140

1114-
token_uri(#invite_token{type = Type,
1141+
-spec create_reset_token(binary(), binary()) -> invite_token() | {error, any()}.
1142+
create_reset_token(User, Host) ->
1143+
maybe
1144+
#invite_token{} = ResetToken ?= reset_token(User, Host),
1145+
F = fun() -> db_call(Host, create_invite_t, [ResetToken]) end,
1146+
transaction(Host, F)
1147+
end.
1148+
1149+
reset_token(User, Host) ->
1150+
maybe
1151+
true ?= lists:member(Host, ejabberd_option:hosts()) orelse {error, host_unknown},
1152+
true ?= ejabberd_auth:user_exists(User, Host) orelse {error, user_not_exists},
1153+
set_token_expires(#invite_token{token = p1_rand:get_alphanum_string(?INVITE_TOKEN_LENGTH_DEFAULT),
1154+
inviter = {<<>>, Host},
1155+
type = reset_token,
1156+
account_name = User},
1157+
mod_invites_opt:token_expire_seconds(Host))
1158+
end.
1159+
1160+
token_uri(#invite_token{type = roster_only,
11151161
token = Token,
1162+
inviter = {User, Host}}) ->
1163+
IBR = maybe_add_ibr_allowed(User, Host),
1164+
Inviter =
1165+
jid:encode(
1166+
jid:make(User, Host)),
1167+
<<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>;
1168+
token_uri(#invite_token{token = Token,
11161169
account_name = AccountName,
1117-
inviter = {_User, Host}})
1118-
when Type =:= account_only; Type =:= account_subscription ->
1170+
inviter = {_User, Host}}) ->
11191171
Invitee =
11201172
case AccountName of
11211173
<<>> ->
11221174
Host;
11231175
_ ->
11241176
<<AccountName/binary, "@", Host/binary>>
11251177
end,
1126-
<<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>;
1127-
token_uri(#invite_token{type = roster_only,
1128-
token = Token,
1129-
inviter = {User, Host}}) ->
1130-
IBR = maybe_add_ibr_allowed(User, Host),
1131-
Inviter =
1132-
jid:encode(
1133-
jid:make(User, Host)),
1134-
<<"xmpp:", Inviter/binary, "?roster;preauth=", Token/binary, IBR/binary>>.
1178+
<<"xmpp:", Invitee/binary, "?register;preauth=", Token/binary>>.
11351179

11361180
maybe_add_ibr_allowed(User, Host) ->
11371181
case create_account_allowed(Host, jid:make(User, Host)) of
@@ -1295,6 +1339,8 @@ pretty_format_command_result({error, host_unknown}) ->
12951339
{error, "Virtual host not known"};
12961340
pretty_format_command_result({error, user_exists}) ->
12971341
{error, "Username already taken"};
1342+
pretty_format_command_result({error, user_not_exists}) ->
1343+
{error, "User does not exist"};
12981344
pretty_format_command_result({ok, Result}) ->
12991345
Result;
13001346
pretty_format_command_result(Result) ->

0 commit comments

Comments
 (0)