Skip to content

Commit 20f8292

Browse files
committed
Merge branch 'security-1' into 'main'
Security fix : magic link is not anymore in reset password e-mails unless TRUSTED_HOSTS is configured See merge request yaal/canaille!240
2 parents d685c6a + a52577c commit 20f8292

20 files changed

+384
-71
lines changed

CHANGES.rst

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Added
66
- Instructions in CONTRIBUTING.rst to update the docker image :issue:`59`
77
- Instructions in README.md to discover Canaille interface with a docker image :issue:`59`
88
- The :ref:`cli dump <cli_dump>` command can dump only some given models.
9+
- Implement the :class:`canaille.app.configuration.RootSettings.TRUSTED_HOSTS` configuration parameter, to secure password reset e-mails.
910

1011
Fixed
1112
^^^^^

canaille/app/configuration.py

+6
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ class RootSettings(BaseSettings):
7070
This sets domain name on which canaille will be served.
7171
"""
7272

73+
TRUSTED_HOSTS: list[str] | None = None
74+
"""The Flask :external:py:data:`TRUSTED_HOSTS` configuration setting.
75+
76+
This sets trusted values for hosts and validates hosts during requests.
77+
"""
78+
7379
PREFERRED_URL_SCHEME: str = "https"
7480
"""The Flask :external:py:data:`PREFERRED_URL_SCHEME` configuration
7581
setting.

canaille/app/features.py

+8
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ def has_email_confirmation(self):
9494
self.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] is None and self.has_smtp
9595
)
9696

97+
@property
98+
def has_trusted_hosts(self):
99+
"""Indicate whether the Flask TRUSTED_HOSTS option is enabled.
100+
101+
It is controlled by the :attr:`TRUSTED_HOSTS <canaille.app.configuration.RootSettings.TRUSTED_HOSTS>` configuration parameter.
102+
"""
103+
return bool(self.app.config["TRUSTED_HOSTS"])
104+
97105
@property
98106
def has_oidc(self):
99107
"""Indicate whether the OIDC feature is enabled.

canaille/core/endpoints/account.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -905,4 +905,4 @@ def reset(user):
905905
)
906906
)
907907

908-
return render_template("core/reset-password.html", form=form, user=user, hash=None)
908+
return render_template("core/reset-password.html", form=form, user=user, token=None)

canaille/core/endpoints/admin.py

+15-5
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
from wtforms import StringField
77
from wtforms.validators import DataRequired
88

9+
from canaille.app import build_hash
910
from canaille.app import obj_to_b64
1011
from canaille.app.flask import user_needed
1112
from canaille.app.forms import Form
1213
from canaille.app.forms import email_validator
1314
from canaille.app.i18n import gettext as _
1415
from canaille.app.templating import render_template
15-
from canaille.core.mails import build_hash
1616
from canaille.core.mails import send_test_mail
1717

1818
bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -79,7 +79,7 @@ def password_init_html(user):
7979
reset_url = url_for(
8080
"core.auth.reset",
8181
user=user,
82-
hash=build_hash(user.identifier, user.preferred_email, user.password),
82+
token=user.generate_url_safe_token(),
8383
title=_("Password initialization on {website_name}").format(
8484
website_name=current_app.config["CANAILLE"]["NAME"]
8585
),
@@ -105,7 +105,7 @@ def password_init_txt(user):
105105
reset_url = url_for(
106106
"core.auth.reset",
107107
user=user,
108-
hash=build_hash(user.identifier, user.preferred_email, user.password),
108+
token=user.generate_url_safe_token(),
109109
_external=True,
110110
)
111111

@@ -121,10 +121,12 @@ def password_init_txt(user):
121121
@user_needed("manage_oidc")
122122
def password_reset_html(user):
123123
base_url = url_for("core.account.index", _external=True)
124+
server_name = current_app.config.get("SERVER_NAME")
125+
reset_token = user.generate_url_safe_token()
124126
reset_url = url_for(
125127
"core.auth.reset",
126128
user=user,
127-
hash=build_hash(user.identifier, user.preferred_email, user.password),
129+
token=reset_token,
128130
title=_("Password reset on {website_name}").format(
129131
website_name=current_app.config["CANAILLE"]["NAME"]
130132
),
@@ -136,6 +138,9 @@ def password_reset_html(user):
136138
site_name=current_app.config["CANAILLE"]["NAME"],
137139
site_url=base_url,
138140
reset_url=reset_url,
141+
server_name=server_name,
142+
reset_token=reset_token,
143+
reset_code=None,
139144
logo=current_app.config["CANAILLE"]["LOGO"],
140145
title=_("Password reset on {website_name}").format(
141146
website_name=current_app.config["CANAILLE"]["NAME"]
@@ -147,10 +152,12 @@ def password_reset_html(user):
147152
@user_needed("manage_oidc")
148153
def password_reset_txt(user):
149154
base_url = url_for("core.account.index", _external=True)
155+
server_name = current_app.config.get("SERVER_NAME")
156+
reset_token = user.generate_url_safe_token()
150157
reset_url = url_for(
151158
"core.auth.reset",
152159
user=user,
153-
hash=build_hash(user.identifier, user.preferred_email, user.password),
160+
token=reset_token,
154161
_external=True,
155162
)
156163

@@ -159,6 +166,9 @@ def password_reset_txt(user):
159166
site_name=current_app.config["CANAILLE"]["NAME"],
160167
site_url=current_app.config.get("SERVER_NAME", base_url),
161168
reset_url=reset_url,
169+
server_name=server_name,
170+
reset_token=reset_token,
171+
reset_code=None,
162172
)
163173

164174

canaille/core/endpoints/auth.py

+52-17
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from ..mails import send_password_initialization_mail
2929
from ..mails import send_password_reset_mail
3030
from .forms import FirstLoginForm
31+
from .forms import ForgottenPasswordCodeForm
3132
from .forms import ForgottenPasswordForm
3233
from .forms import LoginForm
3334
from .forms import PasswordForm
@@ -195,13 +196,15 @@ def forgotten():
195196
if not request.form:
196197
return render_template("core/forgotten-password.html", form=form)
197198

199+
item_name = "link" if current_app.features.has_trusted_hosts else "code"
200+
198201
if not form.validate():
199-
flash(_("Could not send the password reset link."), "error")
202+
flash(_(f"Could not send the password reset {item_name}."), "error")
200203
return render_template("core/forgotten-password.html", form=form)
201204

202205
user = get_user_from_login(form.login.data)
203206
success_message = _(
204-
"A password reset link has been sent at your email address. "
207+
f"A password reset {item_name} has been sent at your email address. "
205208
"You should receive it within a few minutes."
206209
)
207210
if current_app.config["CANAILLE"]["HIDE_INVALID_LOGINS"] and (
@@ -237,32 +240,62 @@ def forgotten():
237240
"error",
238241
)
239242

240-
return render_template("core/forgotten-password.html", form=form)
243+
if current_app.features.has_trusted_hosts:
244+
return render_template("core/forgotten-password.html", form=form)
245+
else:
246+
return redirect(url_for(".forgotten_code", user=user))
241247

242248

243-
@bp.route("/reset/<user:user>/<hash>", methods=["GET", "POST"])
244-
def reset(user, hash):
245-
if not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]:
249+
@bp.route("/reset-code/<user:user>", methods=["GET", "POST"])
250+
@smtp_needed()
251+
def forgotten_code(user):
252+
if (
253+
not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]
254+
or current_app.features.has_trusted_hosts
255+
):
246256
abort(404)
247257

248-
form = PasswordResetForm(request.form)
249-
hashes = {
250-
build_hash(
251-
user.identifier,
252-
email,
253-
user.password if user.has_password() else "",
258+
if not user.can_edit_self:
259+
flash(
260+
_(
261+
"The user '%(user)s' does not have permissions to update their password. ",
262+
user=user.formatted_name,
263+
),
264+
"error",
254265
)
255-
for email in user.emails
256-
}
257-
if not user or hash not in hashes:
266+
return redirect(url_for(".forgotten"))
267+
268+
form = ForgottenPasswordCodeForm(request.form)
269+
if not request.form:
270+
return render_template("core/forgotten-password-code.html", form=form)
271+
272+
if not form.validate() or not user.is_otp_valid(form.code.data, "EMAIL_OTP"):
273+
flash(_("Invalid code."), "error")
274+
return render_template("core/forgotten-password-code.html", form=form)
275+
276+
return redirect(url_for(".reset", user=user, token=form.code.data))
277+
278+
279+
@bp.route("/reset/<user:user>/<token>", methods=["GET", "POST"])
280+
def reset(user, token):
281+
if not current_app.config["CANAILLE"]["ENABLE_PASSWORD_RECOVERY"]:
282+
abort(404)
283+
form = PasswordResetForm(request.form)
284+
285+
if current_app.features.has_trusted_hosts:
286+
token = build_hash(token)
287+
if not user or not user.is_otp_valid(token, "EMAIL_OTP"):
288+
item_name = "link" if current_app.features.has_trusted_hosts else "code"
258289
flash(
259-
_("The password reset link that brought you here was invalid."),
290+
_(f"The password reset {item_name} that brought you here was invalid."),
260291
"error",
261292
)
262293
return redirect(url_for("core.account.index"))
263294

264295
if request.form and form.validate():
265296
Backend.instance.set_user_password(user, form.password.data)
297+
user.clear_otp()
298+
Backend.instance.save(user)
266299
login_user(user)
267300

268301
flash(_("Your password has been updated successfully"), "success")
@@ -273,7 +306,9 @@ def reset(user, hash):
273306
)
274307
)
275308

276-
return render_template("core/reset-password.html", form=form, user=user, hash=hash)
309+
return render_template(
310+
"core/reset-password.html", form=form, user=user, token=token
311+
)
277312

278313

279314
@bp.route("/setup-mfa")

canaille/core/endpoints/forms.py

+16
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from canaille.app.i18n import lazy_gettext as _
2222
from canaille.app.i18n import native_language_name_from_code
2323
from canaille.backends import Backend
24+
from canaille.core.mails import RESET_CODE_LENGTH
2425
from canaille.core.models import OTP_DIGITS
2526
from canaille.core.validators import existing_group_member
2627
from canaille.core.validators import existing_login
@@ -65,6 +66,21 @@ class ForgottenPasswordForm(Form):
6566
)
6667

6768

69+
class ForgottenPasswordCodeForm(Form):
70+
code = wtforms.StringField(
71+
_("Code"),
72+
validators=[
73+
wtforms.validators.DataRequired(),
74+
wtforms.validators.Length(min=RESET_CODE_LENGTH, max=RESET_CODE_LENGTH),
75+
],
76+
render_kw={
77+
"placeholder": _("123456"),
78+
"spellcheck": "false",
79+
"autocorrect": "off",
80+
},
81+
)
82+
83+
6884
class PasswordResetForm(Form):
6985
password = wtforms.PasswordField(
7086
_("Password"),

canaille/core/mails.py

+30-17
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from flask import current_app
22
from flask import url_for
33

4-
from canaille.app import build_hash
54
from canaille.app.i18n import gettext as _
65
from canaille.app.mails import logo
76
from canaille.app.mails import send_email
87
from canaille.app.templating import render_template
8+
from canaille.backends import Backend
9+
10+
RESET_CODE_LENGTH = 6
911

1012

1113
def send_test_mail(email):
@@ -39,32 +41,45 @@ def send_test_mail(email):
3941

4042
def send_password_reset_mail(user, mail):
4143
base_url = url_for("core.account.index", _external=True)
42-
reset_url = url_for(
43-
"core.auth.reset",
44-
user=user,
45-
hash=build_hash(
46-
user.identifier,
47-
mail,
48-
user.password if user.has_password() else "",
49-
),
50-
_external=True,
51-
)
44+
server_name = current_app.config.get("SERVER_NAME")
5245
logo_cid, logo_filename, logo_raw = logo()
53-
5446
subject = _("Password reset on {website_name}").format(
5547
website_name=current_app.config["CANAILLE"]["NAME"]
5648
)
49+
50+
reset_token = None
51+
reset_url = None
52+
reset_code = None
53+
if current_app.features.has_trusted_hosts:
54+
reset_token = user.generate_url_safe_token()
55+
Backend.instance.save(user)
56+
reset_url = url_for(
57+
"core.auth.reset",
58+
user=user,
59+
token=reset_token,
60+
_external=True,
61+
)
62+
else:
63+
reset_code = user.generate_sms_or_mail_otp(length=RESET_CODE_LENGTH)
64+
Backend.instance.save(user)
65+
5766
text_body = render_template(
5867
"core/mails/reset.txt",
5968
site_name=current_app.config["CANAILLE"]["NAME"],
6069
site_url=base_url,
6170
reset_url=reset_url,
71+
server_name=server_name,
72+
reset_token=reset_token,
73+
reset_code=reset_code,
6274
)
6375
html_body = render_template(
6476
"core/mails/reset.html",
6577
site_name=current_app.config["CANAILLE"]["NAME"],
6678
site_url=base_url,
6779
reset_url=reset_url,
80+
server_name=server_name,
81+
reset_token=reset_token,
82+
reset_code=reset_code,
6883
logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
6984
title=subject,
7085
)
@@ -80,14 +95,12 @@ def send_password_reset_mail(user, mail):
8095

8196
def send_password_initialization_mail(user, email):
8297
base_url = url_for("core.account.index", _external=True)
98+
reset_token = user.generate_url_safe_token()
99+
Backend.instance.save(user)
83100
reset_url = url_for(
84101
"core.auth.reset",
85102
user=user,
86-
hash=build_hash(
87-
user.identifier,
88-
email,
89-
user.password if user.has_password() else "",
90-
),
103+
token=reset_token,
91104
_external=True,
92105
)
93106
logo_cid, logo_filename, logo_raw = logo()

0 commit comments

Comments
 (0)