diff --git a/CHANGES.rst b/CHANGES.rst
index c08f4828..f908bb91 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,10 @@
+[0.0.57] - Unreleased
+---------------------
+
+Added
+^^^^^
+- Password compromise check :issue:`179`
+
[0.0.56] - 2024-11-07
---------------------
diff --git a/canaille/app/forms.py b/canaille/app/forms.py
index 0ed33212..054bc27f 100644
--- a/canaille/app/forms.py
+++ b/canaille/app/forms.py
@@ -1,7 +1,9 @@
import datetime
import math
import re
+from hashlib import sha1
+import requests
import wtforms.validators
from flask import abort
from flask import current_app
@@ -15,6 +17,7 @@ from canaille.app.i18n import DEFAULT_LANGUAGE_CODE
from canaille.app.i18n import gettext as _
from canaille.app.i18n import locale_selector
from canaille.app.i18n import timezone_selector
+from canaille.app.mails_sending_conditions import check_if_send_mail_to_admins
from canaille.backends import Backend
from . import validate_uri
@@ -84,6 +87,28 @@ def password_strength_calculator(password):
return strength_score
+def compromised_password_validator(form, field):
+ hashed_password = sha1(field.data.encode("utf-8")).hexdigest()
+ hashed_password_prefix, hashed_password_suffix = (
+ hashed_password[:5].upper(),
+ hashed_password[5:].upper(),
+ )
+
+ api_url = f"https://api.pwnedpasswords.com/range/{hashed_password_prefix}"
+
+ try:
+ response = requests.api.get(api_url, timeout=10)
+ except Exception:
+ check_if_send_mail_to_admins(form, api_url, hashed_password_suffix)
+ return None
+
+ decoded_response = response.content.decode("utf8").split("\r\n")
+
+ for each in decoded_response:
+ if hashed_password_suffix == each.split(":")[0]:
+ raise wtforms.ValidationError(_("This password is compromised."))
+
+
def email_validator(form, field):
try:
import email_validator # noqa: F401
diff --git a/canaille/app/mails_sending_conditions.py b/canaille/app/mails_sending_conditions.py
new file mode 100644
index 00000000..fbfcb822
--- /dev/null
+++ b/canaille/app/mails_sending_conditions.py
@@ -0,0 +1,81 @@
+from flask import current_app
+from flask import flash
+
+from canaille.app import models
+from canaille.app.i18n import gettext as _
+from canaille.backends import Backend
+from canaille.core.mails import send_compromised_password_check_failure_mail
+
+from .flask import request_is_htmx
+
+
+def check_if_send_mail_to_admins(form, api_url, hashed_password_suffix):
+ if current_app.features.has_smtp and not request_is_htmx():
+ flash(
+ _(
+ "Password compromise investigation failed. "
+ "Please contact the administrators."
+ ),
+ "error",
+ )
+
+ group_user = Backend.instance.query(models.User)
+
+ if (
+ current_app.config["CANAILLE"]["ACL"]
+ and current_app.config["CANAILLE"]["ACL"]["ADMIN"]
+ and current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"]
+ and current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"]["groups"]
+ ):
+ admin_group_display_name = current_app.config["CANAILLE"]["ACL"]["ADMIN"][
+ "FILTER"
+ ]["groups"]
+
+ admin_emails = [
+ user.emails[0]
+ for user in group_user
+ if any(
+ group.display_name == admin_group_display_name
+ for group in user.groups
+ )
+ ]
+ else:
+ admin_emails = [current_app.config["CANAILLE"]["ADMIN_EMAIL"]]
+
+ if form.user is not None:
+ user_name = form.user.user_name
+ user_email = form.user.emails[0]
+ else:
+ user_name = form["user_name"].data
+ user_email = form["emails"].data[0]
+
+ number_emails_send = 0
+
+ for email in admin_emails:
+ if send_compromised_password_check_failure_mail(
+ api_url, user_name, user_email, hashed_password_suffix, email
+ ):
+ number_emails_send += 1
+ else:
+ pass
+
+ if number_emails_send > 0:
+ flash(
+ _(
+ "We have informed your administrator about the failure of the password compromise investigation."
+ ),
+ "success",
+ )
+ else:
+ flash(
+ _(
+ "An error occurred while communicating the incident to the administrators. "
+ "Please update your password as soon as possible. "
+ "If this still happens, please contact the administrators."
+ ),
+ "error",
+ )
+ return None
+
+ return number_emails_send
+ return None
diff --git a/canaille/core/configuration.py b/canaille/core/configuration.py
index 672c435b..1d9a9331 100644
--- a/canaille/core/configuration.py
+++ b/canaille/core/configuration.py
@@ -312,3 +312,11 @@ class CoreSettings(BaseModel):
characters. If the value entered is 0 or None, or greater than 4096,
then 4096 will be retained.
"""
+
+ ADMIN_EMAIL: str = None
+ """Administration email contact.
+
+ In certain special cases (example : questioning about password
+ corruption), it is necessary to provide an administration contact
+ email.
+ """
diff --git a/canaille/core/endpoints/account.py b/canaille/core/endpoints/account.py
index 2667ab22..485afc13 100644
--- a/canaille/core/endpoints/account.py
+++ b/canaille/core/endpoints/account.py
@@ -31,6 +31,7 @@ from canaille.app.flask import smtp_needed
from canaille.app.flask import user_needed
from canaille.app.forms import IDToModel
from canaille.app.forms import TableForm
+from canaille.app.forms import compromised_password_validator
from canaille.app.forms import is_readonly
from canaille.app.forms import password_length_validator
from canaille.app.forms import password_too_long_validator
@@ -316,6 +317,7 @@ def registration(data=None, hash=None):
wtforms.validators.DataRequired(),
password_length_validator,
password_too_long_validator,
+ compromised_password_validator,
]
form["password2"].validators = [
wtforms.validators.DataRequired(),
diff --git a/canaille/core/endpoints/forms.py b/canaille/core/endpoints/forms.py
index e2925cb5..5b3bddc3 100644
--- a/canaille/core/endpoints/forms.py
+++ b/canaille/core/endpoints/forms.py
@@ -10,6 +10,7 @@ from canaille.app.forms import BaseForm
from canaille.app.forms import DateTimeUTCField
from canaille.app.forms import Form
from canaille.app.forms import IDToModel
+from canaille.app.forms import compromised_password_validator
from canaille.app.forms import email_validator
from canaille.app.forms import is_uri
from canaille.app.forms import password_length_validator
@@ -265,6 +266,7 @@ PROFILE_FORM_FIELDS = dict(
wtforms.validators.Optional(),
password_length_validator,
password_too_long_validator,
+ compromised_password_validator,
],
render_kw={
"autocomplete": "new-password",
diff --git a/canaille/core/mails.py b/canaille/core/mails.py
index 364b8694..fdd09d6b 100644
--- a/canaille/core/mails.py
+++ b/canaille/core/mails.py
@@ -210,3 +210,42 @@ def send_registration_mail(email, registration_url):
html=html_body,
attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
)
+
+
+def send_compromised_password_check_failure_mail(
+ check_password_url, user_name, user_email, hashed_password, admin_email
+):
+ base_url = url_for("core.account.index", _external=True)
+ logo_cid, logo_filename, logo_raw = logo()
+
+ subject = _("compromised password check failure on {website_name}").format(
+ website_name=current_app.config["CANAILLE"]["NAME"]
+ )
+ text_body = render_template(
+ "mails/compromised_password_check_failure.txt",
+ site_name=current_app.config["CANAILLE"]["NAME"],
+ site_url=base_url,
+ check_password_url=check_password_url,
+ user_name=user_name,
+ user_email=user_email,
+ hashed_password=hashed_password,
+ )
+ html_body = render_template(
+ "mails/compromised_password_check_failure.html",
+ site_name=current_app.config["CANAILLE"]["NAME"],
+ site_url=base_url,
+ check_password_url=check_password_url,
+ logo=f"cid:{logo_cid[1:-1]}" if logo_cid else None,
+ title=subject,
+ user_name=user_name,
+ user_email=user_email,
+ hashed_password=hashed_password,
+ )
+
+ return send_email(
+ subject=subject,
+ recipient=admin_email,
+ text=text_body,
+ html=html_body,
+ attachments=[(logo_cid, logo_filename, logo_raw)] if logo_filename else None,
+ )
diff --git a/canaille/core/templates/mails/compromised_password_check_failure.html b/canaille/core/templates/mails/compromised_password_check_failure.html
new file mode 100644
index 00000000..a7a1707b
--- /dev/null
+++ b/canaille/core/templates/mails/compromised_password_check_failure.html
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+ {% if logo %}
+
+ {% endif %}
+
+ {% trans %}Compromised password check failure{% endtrans %}
+
+
+ |
+
+
+
+
+
+ {% trans %}Our services were unable to verify if the {{ user_name }}'s password is compromised.{% endtrans %}
+
+
+ {% trans %}You have to check manually if the new password of the user {{ user_name }} is compromised.{% endtrans %}
+ {% trans %}Follow this steps : {% endtrans %}
+ {% trans %}1. click on the link above "Check if password is compromised".{% endtrans %}
+
+ |
+
+
+
+
+ {% trans %}Check if password is compromised{% endtrans %}
+ |
+
+
+
+
+
+ {% trans %}2. in the page that will open, search the following hashed password in the page : {{ hashed_password }}{% endtrans %}
+ {% trans %}3. if the password is in the list :{% endtrans %}
+ {% trans %}3.1. open this link and reset user's password. {% endtrans %}
+
+ |
+
+
+
+
+
+ {% trans %} Reset {{ user_name }}'s password {% endtrans %}
+ |
+
+
+
+
+
+ {% trans %}3.2. send an email to the user to explain the situation : {{ user_email }}{% endtrans %}
+
+ |
+
+
+
+
+
+ {{ site_name }}
+ |
+
+ {% trans %}Check if password is compromised{% endtrans %}
+ |
+
+
+
+
+
diff --git a/canaille/core/templates/mails/compromised_password_check_failure.txt b/canaille/core/templates/mails/compromised_password_check_failure.txt
new file mode 100644
index 00000000..ca0fad65
--- /dev/null
+++ b/canaille/core/templates/mails/compromised_password_check_failure.txt
@@ -0,0 +1,14 @@
+# {% trans %}Compromised password check failure{% endtrans %}
+
+{% trans %}Our services were unable to verify if the http://127.0.0.1:5000/profile/{{ user_name }}/settings's password is compromised.{% endtrans %}
+
+{% trans %}You have to check manually if the new password of the user {{ user_name }} is compromised.{% endtrans %}
+{% trans %}Follow this steps : {% endtrans %}
+{% trans %}1. click on the link above "Check if password is compromised".{% endtrans %}
+{% trans %}2. in the page that will open, search the following hashed password in the page : {{ hashed_password }}{% endtrans %}
+{% trans %}3. if the password is in the list :{% endtrans %}
+{% trans %}3.1. open this link http://127.0.0.1:5000/profile/{{ user_name }}/settings and reset user's password.{% endtrans %}
+{% trans %}3.2. send an email to the user to explain the situation : {{ user_email }}.{% endtrans %}
+
+{% trans %}Check if password is compromised{% endtrans %}: {{ check_password_url }}
+{{ site_name }}: {{ site_url }}
diff --git a/canaille/translations/messages.pot b/canaille/translations/messages.pot
index a8b4f68e..8b6d2ef6 100644
--- a/canaille/translations/messages.pot
+++ b/canaille/translations/messages.pot
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2024-10-28 11:57+0100\n"
+"POT-Creation-Date: 2024-11-12 16:52+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -17,38 +17,61 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.16.0\n"
-#: canaille/app/flask.py:100
+#: canaille/app/flask.py:53
msgid "No SMTP server has been configured"
msgstr ""
-#: canaille/app/forms.py:27
+#: canaille/app/forms.py:29
msgid "This is not a valid URL"
msgstr ""
-#: canaille/app/forms.py:34 canaille/app/forms.py:35
+#: canaille/app/forms.py:36 canaille/app/forms.py:37
msgid "This value is a duplicate"
msgstr ""
-#: canaille/app/forms.py:47
+#: canaille/app/forms.py:49
msgid "Not a valid phone number"
msgstr ""
-#: canaille/app/forms.py:55
+#: canaille/app/forms.py:57
msgid "Field must be at least {minimum_password_length} characters long."
msgstr ""
-#: canaille/app/forms.py:67
+#: canaille/app/forms.py:69
msgid "Field cannot be longer than {maximum_password_length} characters."
msgstr ""
-#: canaille/app/forms.py:240
+#: canaille/app/forms.py:109
+msgid "This password is compromised."
+msgstr ""
+
+#: canaille/app/forms.py:269
msgid "The page number is not valid"
msgstr ""
-#: canaille/app/forms.py:268
+#: canaille/app/forms.py:297
msgid "Not a valid datetime value."
msgstr ""
+#: canaille/app/mails_sending_conditions.py:15
+msgid ""
+"Password compromise investigation failed. Please contact the "
+"administrators."
+msgstr ""
+
+#: canaille/app/mails_sending_conditions.py:64
+msgid ""
+"We have informed your administrator about the failure of the password "
+"compromise investigation."
+msgstr ""
+
+#: canaille/app/mails_sending_conditions.py:71
+msgid ""
+"An error occurred while communicating the incident to the administrators."
+" Please update your password as soon as possible. If this still happens, "
+"please contact the administrators."
+msgstr ""
+
#: canaille/backends/ldap/backend.py:99
msgid "Could not connect to the LDAP server '{uri}'"
msgstr ""
@@ -61,8 +84,8 @@ msgstr ""
msgid "John Doe"
msgstr ""
-#: canaille/backends/ldap/backend.py:178 canaille/core/endpoints/forms.py:164
-#: canaille/core/endpoints/forms.py:424
+#: canaille/backends/ldap/backend.py:178 canaille/core/endpoints/forms.py:165
+#: canaille/core/endpoints/forms.py:426
msgid "jdoe"
msgstr ""
@@ -108,36 +131,40 @@ msgstr ""
msgid "Continue your registration on {website_name}"
msgstr ""
-#: canaille/core/endpoints/account.py:91 canaille/core/endpoints/account.py:117
+#: canaille/core/mails.py:221
+msgid "compromised password check failure on {website_name}"
+msgstr ""
+
+#: canaille/core/endpoints/account.py:92 canaille/core/endpoints/account.py:118
msgid "You will receive soon an email to continue the registration process."
msgstr ""
-#: canaille/core/endpoints/account.py:124
+#: canaille/core/endpoints/account.py:125
msgid ""
"An error happened while sending your registration mail. Please try again "
"in a few minutes. If this still happens, please contact the "
"administrators."
msgstr ""
-#: canaille/core/endpoints/account.py:248
-#: canaille/core/endpoints/account.py:271
+#: canaille/core/endpoints/account.py:249
+#: canaille/core/endpoints/account.py:272
msgid "The registration link that brought you here was invalid."
msgstr ""
-#: canaille/core/endpoints/account.py:255
+#: canaille/core/endpoints/account.py:256
msgid "The registration link that brought you here has expired."
msgstr ""
-#: canaille/core/endpoints/account.py:264
+#: canaille/core/endpoints/account.py:265
msgid "Your account has already been created."
msgstr ""
-#: canaille/core/endpoints/account.py:278
+#: canaille/core/endpoints/account.py:279
msgid "You are already logged in, you cannot create an account."
msgstr ""
-#: canaille/core/endpoints/account.py:299 canaille/core/endpoints/forms.py:314
-#: canaille/core/endpoints/forms.py:442 canaille/core/templates/groups.html:5
+#: canaille/core/endpoints/account.py:300 canaille/core/endpoints/forms.py:316
+#: canaille/core/endpoints/forms.py:444 canaille/core/templates/groups.html:5
#: canaille/core/templates/groups.html:23
#: canaille/core/templates/partial/group-members.html:15
#: canaille/core/templates/partial/users.html:18
@@ -145,114 +172,114 @@ msgstr ""
msgid "Groups"
msgstr ""
-#: canaille/core/endpoints/account.py:336
-#: canaille/core/endpoints/account.py:429
+#: canaille/core/endpoints/account.py:338
+#: canaille/core/endpoints/account.py:431
msgid "User account creation failed."
msgstr ""
-#: canaille/core/endpoints/account.py:347
+#: canaille/core/endpoints/account.py:349
msgid "Your account has been created successfully."
msgstr ""
-#: canaille/core/endpoints/account.py:362
-#: canaille/core/endpoints/account.py:384
+#: canaille/core/endpoints/account.py:364
+#: canaille/core/endpoints/account.py:386
msgid "The email confirmation link that brought you here is invalid."
msgstr ""
-#: canaille/core/endpoints/account.py:369
+#: canaille/core/endpoints/account.py:371
msgid "The email confirmation link that brought you here has expired."
msgstr ""
-#: canaille/core/endpoints/account.py:376
+#: canaille/core/endpoints/account.py:378
msgid "The invitation link that brought you here was invalid."
msgstr ""
-#: canaille/core/endpoints/account.py:391
+#: canaille/core/endpoints/account.py:393
msgid "This address email have already been confirmed."
msgstr ""
-#: canaille/core/endpoints/account.py:398
+#: canaille/core/endpoints/account.py:400
msgid "This address email is already associated with another account."
msgstr ""
-#: canaille/core/endpoints/account.py:405
+#: canaille/core/endpoints/account.py:407
msgid "Your email address have been confirmed."
msgstr ""
-#: canaille/core/endpoints/account.py:439
+#: canaille/core/endpoints/account.py:441
msgid "User account creation succeed."
msgstr ""
-#: canaille/core/endpoints/account.py:615
-#: canaille/core/endpoints/account.py:784
+#: canaille/core/endpoints/account.py:617
+#: canaille/core/endpoints/account.py:786
msgid "Profile edition failed."
msgstr ""
-#: canaille/core/endpoints/account.py:625
-#: canaille/core/endpoints/account.py:802
+#: canaille/core/endpoints/account.py:627
+#: canaille/core/endpoints/account.py:804
msgid "Profile updated successfully."
msgstr ""
-#: canaille/core/endpoints/account.py:633
+#: canaille/core/endpoints/account.py:635
msgid "Email addition failed."
msgstr ""
-#: canaille/core/endpoints/account.py:638
+#: canaille/core/endpoints/account.py:640
msgid ""
"An email has been sent to the email address. Please check your inbox and "
"click on the verification link it contains"
msgstr ""
-#: canaille/core/endpoints/account.py:645
+#: canaille/core/endpoints/account.py:647
msgid "Could not send the verification email"
msgstr ""
-#: canaille/core/endpoints/account.py:655
+#: canaille/core/endpoints/account.py:657
msgid "Email deletion failed."
msgstr ""
-#: canaille/core/endpoints/account.py:658
+#: canaille/core/endpoints/account.py:660
msgid "The email have been successfully deleted."
msgstr ""
-#: canaille/core/endpoints/account.py:695
+#: canaille/core/endpoints/account.py:697
msgid ""
"A password initialization link has been sent at the user email address. "
"It should be received within a few minutes."
msgstr ""
-#: canaille/core/endpoints/account.py:702 canaille/core/endpoints/auth.py:159
+#: canaille/core/endpoints/account.py:704 canaille/core/endpoints/auth.py:159
msgid "Could not send the password initialization email"
msgstr ""
-#: canaille/core/endpoints/account.py:713
+#: canaille/core/endpoints/account.py:715
msgid ""
"A password reset link has been sent at the user email address. It should "
"be received within a few minutes."
msgstr ""
-#: canaille/core/endpoints/account.py:720
+#: canaille/core/endpoints/account.py:722
msgid "Could not send the password reset email"
msgstr ""
-#: canaille/core/endpoints/account.py:736
+#: canaille/core/endpoints/account.py:738
msgid "The account has been locked"
msgstr ""
-#: canaille/core/endpoints/account.py:747
+#: canaille/core/endpoints/account.py:749
msgid "The account has been unlocked"
msgstr ""
-#: canaille/core/endpoints/account.py:822
+#: canaille/core/endpoints/account.py:824
#, python-format
msgid "The user %(user)s has been successfully deleted"
msgstr ""
-#: canaille/core/endpoints/account.py:839
+#: canaille/core/endpoints/account.py:841
msgid "Locked users cannot be impersonated."
msgstr ""
-#: canaille/core/endpoints/account.py:843 canaille/core/endpoints/auth.py:112
+#: canaille/core/endpoints/account.py:845 canaille/core/endpoints/auth.py:112
#, python-format
msgid "Connection successful. Welcome %(user)s"
msgstr ""
@@ -262,10 +289,10 @@ msgstr ""
msgid "Email"
msgstr ""
-#: canaille/core/endpoints/admin.py:29 canaille/core/endpoints/forms.py:97
-#: canaille/core/endpoints/forms.py:120 canaille/core/endpoints/forms.py:209
-#: canaille/core/endpoints/forms.py:410 canaille/core/endpoints/forms.py:436
-#: canaille/core/endpoints/forms.py:460 canaille/core/endpoints/forms.py:476
+#: canaille/core/endpoints/admin.py:29 canaille/core/endpoints/forms.py:98
+#: canaille/core/endpoints/forms.py:121 canaille/core/endpoints/forms.py:210
+#: canaille/core/endpoints/forms.py:412 canaille/core/endpoints/forms.py:438
+#: canaille/core/endpoints/forms.py:462 canaille/core/endpoints/forms.py:478
msgid "jane@doe.com"
msgstr ""
@@ -334,66 +361,66 @@ msgstr ""
msgid "Your password has been updated successfully"
msgstr ""
-#: canaille/core/endpoints/forms.py:31
+#: canaille/core/endpoints/forms.py:32
msgid "The user name '{user_name}' already exists"
msgstr ""
-#: canaille/core/endpoints/forms.py:40
+#: canaille/core/endpoints/forms.py:41
msgid "The email '{email}' is already used"
msgstr ""
-#: canaille/core/endpoints/forms.py:47
+#: canaille/core/endpoints/forms.py:48
msgid "The group '{group}' already exists"
msgstr ""
-#: canaille/core/endpoints/forms.py:56
+#: canaille/core/endpoints/forms.py:57
msgid "The login '{login}' does not exist"
msgstr ""
-#: canaille/core/endpoints/forms.py:63
+#: canaille/core/endpoints/forms.py:64
msgid "The user you are trying to remove does not exist."
msgstr ""
-#: canaille/core/endpoints/forms.py:68
+#: canaille/core/endpoints/forms.py:69
msgid "The user '{user}' has already been removed from the group '{group}'"
msgstr ""
-#: canaille/core/endpoints/forms.py:86
+#: canaille/core/endpoints/forms.py:87
msgid ""
"The group '{group}' cannot be removed, because it must have at least one "
"user left."
msgstr ""
-#: canaille/core/endpoints/forms.py:94 canaille/core/endpoints/forms.py:117
+#: canaille/core/endpoints/forms.py:95 canaille/core/endpoints/forms.py:118
#: canaille/core/templates/partial/group-members.html:9
#: canaille/core/templates/partial/users.html:9
msgid "Login"
msgstr ""
-#: canaille/core/endpoints/forms.py:107 canaille/core/endpoints/forms.py:129
-#: canaille/core/endpoints/forms.py:263
+#: canaille/core/endpoints/forms.py:108 canaille/core/endpoints/forms.py:130
+#: canaille/core/endpoints/forms.py:264
#: canaille/core/templates/profile_settings.html:63
msgid "Password"
msgstr ""
-#: canaille/core/endpoints/forms.py:136 canaille/core/endpoints/forms.py:274
+#: canaille/core/endpoints/forms.py:137 canaille/core/endpoints/forms.py:276
msgid "Password confirmation"
msgstr ""
-#: canaille/core/endpoints/forms.py:139 canaille/core/endpoints/forms.py:277
+#: canaille/core/endpoints/forms.py:140 canaille/core/endpoints/forms.py:279
msgid "Password and confirmation do not match."
msgstr ""
-#: canaille/core/endpoints/forms.py:158
+#: canaille/core/endpoints/forms.py:159
msgid "Automatic"
msgstr ""
-#: canaille/core/endpoints/forms.py:163
+#: canaille/core/endpoints/forms.py:164
msgid "Username"
msgstr ""
-#: canaille/core/endpoints/forms.py:167 canaille/core/endpoints/forms.py:366
-#: canaille/core/endpoints/forms.py:380
+#: canaille/core/endpoints/forms.py:168 canaille/core/endpoints/forms.py:368
+#: canaille/core/endpoints/forms.py:382
#: canaille/core/templates/partial/group-members.html:12
#: canaille/core/templates/partial/groups.html:6
#: canaille/core/templates/partial/users.html:12
@@ -402,165 +429,165 @@ msgstr ""
msgid "Name"
msgstr ""
-#: canaille/core/endpoints/forms.py:169
+#: canaille/core/endpoints/forms.py:170
msgid "Title"
msgstr ""
-#: canaille/core/endpoints/forms.py:169
+#: canaille/core/endpoints/forms.py:170
msgid "Vice president"
msgstr ""
-#: canaille/core/endpoints/forms.py:172
+#: canaille/core/endpoints/forms.py:173
msgid "Given name"
msgstr ""
-#: canaille/core/endpoints/forms.py:174
+#: canaille/core/endpoints/forms.py:175
msgid "John"
msgstr ""
-#: canaille/core/endpoints/forms.py:180
+#: canaille/core/endpoints/forms.py:181
msgid "Family Name"
msgstr ""
-#: canaille/core/endpoints/forms.py:183
+#: canaille/core/endpoints/forms.py:184
msgid "Doe"
msgstr ""
-#: canaille/core/endpoints/forms.py:189
+#: canaille/core/endpoints/forms.py:190
msgid "Display Name"
msgstr ""
-#: canaille/core/endpoints/forms.py:192
+#: canaille/core/endpoints/forms.py:193
msgid "Johnny"
msgstr ""
-#: canaille/core/endpoints/forms.py:199 canaille/core/endpoints/forms.py:466
+#: canaille/core/endpoints/forms.py:200 canaille/core/endpoints/forms.py:468
#: canaille/core/templates/profile_edit.html:176
msgid "Email addresses"
msgstr ""
-#: canaille/core/endpoints/forms.py:205 canaille/core/endpoints/forms.py:456
+#: canaille/core/endpoints/forms.py:206 canaille/core/endpoints/forms.py:458
msgid ""
"This email will be used as a recovery address to reset the password if "
"needed"
msgstr ""
-#: canaille/core/endpoints/forms.py:219
+#: canaille/core/endpoints/forms.py:220
msgid "Phone numbers"
msgstr ""
-#: canaille/core/endpoints/forms.py:220
+#: canaille/core/endpoints/forms.py:221
msgid "555-000-555"
msgstr ""
-#: canaille/core/endpoints/forms.py:227
+#: canaille/core/endpoints/forms.py:228
msgid "Address"
msgstr ""
-#: canaille/core/endpoints/forms.py:229
+#: canaille/core/endpoints/forms.py:230
msgid "132, Foobar Street, Gotham City 12401, XX"
msgstr ""
-#: canaille/core/endpoints/forms.py:233
+#: canaille/core/endpoints/forms.py:234
msgid "Street"
msgstr ""
-#: canaille/core/endpoints/forms.py:235
+#: canaille/core/endpoints/forms.py:236
msgid "132, Foobar Street"
msgstr ""
-#: canaille/core/endpoints/forms.py:239
+#: canaille/core/endpoints/forms.py:240
msgid "Postal Code"
msgstr ""
-#: canaille/core/endpoints/forms.py:245
+#: canaille/core/endpoints/forms.py:246
msgid "Locality"
msgstr ""
-#: canaille/core/endpoints/forms.py:247
+#: canaille/core/endpoints/forms.py:248
msgid "Gotham City"
msgstr ""
-#: canaille/core/endpoints/forms.py:251
+#: canaille/core/endpoints/forms.py:252
msgid "Region"
msgstr ""
-#: canaille/core/endpoints/forms.py:253
+#: canaille/core/endpoints/forms.py:254
msgid "North Pole"
msgstr ""
-#: canaille/core/endpoints/forms.py:257
+#: canaille/core/endpoints/forms.py:258
msgid "Photo"
msgstr ""
-#: canaille/core/endpoints/forms.py:261
+#: canaille/core/endpoints/forms.py:262
#: canaille/core/templates/profile_add.html:56
#: canaille/core/templates/profile_edit.html:64
msgid "Delete the photo"
msgstr ""
-#: canaille/core/endpoints/forms.py:285
+#: canaille/core/endpoints/forms.py:287
msgid "User number"
msgstr ""
-#: canaille/core/endpoints/forms.py:287 canaille/core/endpoints/forms.py:293
+#: canaille/core/endpoints/forms.py:289 canaille/core/endpoints/forms.py:295
msgid "1234"
msgstr ""
-#: canaille/core/endpoints/forms.py:291
+#: canaille/core/endpoints/forms.py:293
msgid "Department"
msgstr ""
-#: canaille/core/endpoints/forms.py:297
+#: canaille/core/endpoints/forms.py:299
msgid "Organization"
msgstr ""
-#: canaille/core/endpoints/forms.py:299
+#: canaille/core/endpoints/forms.py:301
msgid "Cogip LTD."
msgstr ""
-#: canaille/core/endpoints/forms.py:303
+#: canaille/core/endpoints/forms.py:305
msgid "Website"
msgstr ""
-#: canaille/core/endpoints/forms.py:305
+#: canaille/core/endpoints/forms.py:307
msgid "https://mywebsite.tld"
msgstr ""
-#: canaille/core/endpoints/forms.py:310
+#: canaille/core/endpoints/forms.py:312
msgid "Preferred language"
msgstr ""
-#: canaille/core/endpoints/forms.py:320
+#: canaille/core/endpoints/forms.py:322
msgid "users, admins …"
msgstr ""
-#: canaille/core/endpoints/forms.py:345
+#: canaille/core/endpoints/forms.py:347
msgid "Account expiration"
msgstr ""
-#: canaille/core/endpoints/forms.py:369
+#: canaille/core/endpoints/forms.py:371
msgid "group"
msgstr ""
-#: canaille/core/endpoints/forms.py:373 canaille/core/endpoints/forms.py:390
+#: canaille/core/endpoints/forms.py:375 canaille/core/endpoints/forms.py:392
#: canaille/core/templates/partial/groups.html:7
msgid "Description"
msgstr ""
-#: canaille/core/endpoints/forms.py:404 canaille/core/endpoints/forms.py:429
+#: canaille/core/endpoints/forms.py:406 canaille/core/endpoints/forms.py:431
msgid "Email address"
msgstr ""
-#: canaille/core/endpoints/forms.py:423
+#: canaille/core/endpoints/forms.py:425
msgid "User name"
msgstr ""
-#: canaille/core/endpoints/forms.py:427
+#: canaille/core/endpoints/forms.py:429
msgid "Username editable by the invitee"
msgstr ""
-#: canaille/core/endpoints/forms.py:469
+#: canaille/core/endpoints/forms.py:471
msgid "New email address"
msgstr ""
@@ -1139,6 +1166,89 @@ msgstr ""
msgid "Registration"
msgstr ""
+#: canaille/core/templates/mails/compromised_password_check_failure.html:19
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:1
+msgid "Compromised password check failure"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:28
+#, python-format
+msgid ""
+"Our services were unable to verify if the %(user_name)s's password is "
+"compromised."
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:31
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:5
+#, python-format
+msgid ""
+"You have to check manually if the new password of the user %(user_name)s "
+"is compromised."
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:32
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:6
+msgid "Follow this steps :"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:33
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:7
+msgid "1. click on the link above \"Check if password is compromised\"."
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:40
+#: canaille/core/templates/mails/compromised_password_check_failure.html:75
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:13
+msgid "Check if password is compromised"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:47
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:8
+#, python-format
+msgid ""
+"2. in the page that will open, search the following hashed password in "
+"the page : %(hashed_password)s"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:48
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:9
+msgid "3. if the password is in the list :"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:49
+msgid "3.1. open this link and reset user's password."
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:57
+#, python-format
+msgid "Reset %(user_name)s's password"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.html:64
+#, python-format
+msgid "3.2. send an email to the user to explain the situation : %(user_email)s"
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:3
+#, python-format
+msgid ""
+"Our services were unable to verify if the "
+"http://127.0.0.1:5000/profile/%(user_name)s/settings's password is "
+"compromised."
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:10
+#, python-format
+msgid ""
+"3.1. open this link http://127.0.0.1:5000/profile/%(user_name)s/settings "
+"and reset user's password."
+msgstr ""
+
+#: canaille/core/templates/mails/compromised_password_check_failure.txt:11
+#, python-format
+msgid "3.2. send an email to the user to explain the situation : %(user_email)s."
+msgstr ""
+
#: canaille/core/templates/mails/email-confirmation.html:19
msgid "Email address confirmation"
msgstr ""
diff --git a/pyproject.toml b/pyproject.toml
index 7945c694..94c47fa2 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dependencies = [
"flask >= 3.0.0",
"flask-wtf >= 1.2.1",
"pydantic-settings >= 2.0.3",
+ "requests>=2.32.3",
"wtforms >= 3.1.1",
]
diff --git a/tests/app/test_forms.py b/tests/app/test_forms.py
index 3fa729d7..82448ac5 100644
--- a/tests/app/test_forms.py
+++ b/tests/app/test_forms.py
@@ -1,4 +1,5 @@
import datetime
+from unittest import mock
import pytest
import wtforms
@@ -7,6 +8,7 @@ from flask import current_app
from werkzeug.datastructures import ImmutableMultiDict
from canaille.app.forms import DateTimeUTCField
+from canaille.app.forms import compromised_password_validator
from canaille.app.forms import password_length_validator
from canaille.app.forms import password_too_long_validator
from canaille.app.forms import phone_number
@@ -295,14 +297,14 @@ def test_password_strength_progress_bar(testclient, logged_user):
"/profile/user/settings",
{
"csrf_token": res.form["csrf_token"].value,
- "password1": "new_password",
+ "password1": "i'm a little pea",
},
headers={
"HX-Request": "true",
"HX-Trigger-Name": "password1",
},
)
- res.mustcontain('data-percent="50"')
+ res.mustcontain('data-percent="100"')
def test_maximum_password_length_config(testclient):
@@ -333,3 +335,66 @@ def test_maximum_password_length_config(testclient):
password_too_long_validator(None, Field("a" * 4096))
with pytest.raises(wtforms.ValidationError):
password_too_long_validator(None, Field("a" * 4097))
+
+
+def test_compromised_password_validator(testclient):
+ class Field:
+ def __init__(self, data):
+ self.data = data
+
+ compromised_password_validator(None, Field("i'm a little pea"))
+ compromised_password_validator(None, Field("i'm a little chickpea"))
+ compromised_password_validator(None, Field("i'm singing in the rain"))
+ with pytest.raises(wtforms.ValidationError):
+ compromised_password_validator(None, Field("password"))
+ with pytest.raises(wtforms.ValidationError):
+ compromised_password_validator(None, Field("987654321"))
+ with pytest.raises(wtforms.ValidationError):
+ compromised_password_validator(None, Field("correct horse battery staple"))
+ with pytest.raises(wtforms.ValidationError):
+ compromised_password_validator(None, Field("zxcvbn123"))
+ with pytest.raises(wtforms.ValidationError):
+ compromised_password_validator(None, Field("azertyuiop123"))
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_no_SMTP_in_config(
+ api_get, testclient, logged_user
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+ current_app.config["CANAILLE"]["SMTP"] = None
+
+ class Field:
+ def __init__(self, data):
+ self.data = data
+
+ compromised_password_validator(None, Field("i'm a little pea"))
+ compromised_password_validator(None, Field("i'm a little chickpea"))
+ compromised_password_validator(None, Field("i'm singing in the rain"))
+ compromised_password_validator(None, Field("password"))
+ compromised_password_validator(None, Field("987654321"))
+ compromised_password_validator(None, Field("correct horse battery staple"))
+ compromised_password_validator(None, Field("zxcvbn123"))
+ compromised_password_validator(None, Field("azertyuiop123"))
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_only_with_htmx(
+ api_get, testclient, logged_user
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+
+ res = testclient.get("/profile/user/settings")
+ res = testclient.post(
+ "/profile/user/settings",
+ {
+ "csrf_token": res.form["csrf_token"].value,
+ "password1": "correct horse battery staple",
+ },
+ headers={
+ "HX-Request": "true",
+ "HX-Trigger-Name": "password1",
+ },
+ )
+
+ res.mustcontain('data-percent="100"')
diff --git a/tests/conftest.py b/tests/conftest.py
index 165cfd34..6ee2cd74 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -151,6 +151,7 @@ def configuration(smtpd):
},
"disable_existing_loggers": False,
},
+ "ADMIN_EMAIL": "admin_default_mail@mymail.com",
},
}
return conf
@@ -270,6 +271,18 @@ def bar_group(app, admin, backend):
backend.delete(group)
+@pytest.fixture
+def admins_group(app, admin, backend):
+ group = models.Group(
+ members=[admin],
+ display_name="admins",
+ )
+ backend.save(group)
+ backend.reload(admin)
+ yield group
+ backend.delete(group)
+
+
@pytest.fixture
def jpeg_photo():
return b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x01,\x01,\x00\x00\xff\xfe\x00\x13Created with GIMP\xff\xe2\x02\xb0ICC_PROFILE\x00\x01\x01\x00\x00\x02\xa0lcms\x040\x00\x00mntrRGB XYZ \x07\xe5\x00\x0c\x00\x08\x00\x0f\x00\x16\x00(acspAPPL\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-lcms\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\rdesc\x00\x00\x01 \x00\x00\x00@cprt\x00\x00\x01`\x00\x00\x006wtpt\x00\x00\x01\x98\x00\x00\x00\x14chad\x00\x00\x01\xac\x00\x00\x00,rXYZ\x00\x00\x01\xd8\x00\x00\x00\x14bXYZ\x00\x00\x01\xec\x00\x00\x00\x14gXYZ\x00\x00\x02\x00\x00\x00\x00\x14rTRC\x00\x00\x02\x14\x00\x00\x00 gTRC\x00\x00\x02\x14\x00\x00\x00 bTRC\x00\x00\x02\x14\x00\x00\x00 chrm\x00\x00\x024\x00\x00\x00$dmnd\x00\x00\x02X\x00\x00\x00$dmdd\x00\x00\x02|\x00\x00\x00$mluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00$\x00\x00\x00\x1c\x00G\x00I\x00M\x00P\x00 \x00b\x00u\x00i\x00l\x00t\x00-\x00i\x00n\x00 \x00s\x00R\x00G\x00Bmluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x1a\x00\x00\x00\x1c\x00P\x00u\x00b\x00l\x00i\x00c\x00 \x00D\x00o\x00m\x00a\x00i\x00n\x00\x00XYZ \x00\x00\x00\x00\x00\x00\xf6\xd6\x00\x01\x00\x00\x00\x00\xd3-sf32\x00\x00\x00\x00\x00\x01\x0cB\x00\x00\x05\xde\xff\xff\xf3%\x00\x00\x07\x93\x00\x00\xfd\x90\xff\xff\xfb\xa1\xff\xff\xfd\xa2\x00\x00\x03\xdc\x00\x00\xc0nXYZ \x00\x00\x00\x00\x00\x00o\xa0\x00\x008\xf5\x00\x00\x03\x90XYZ \x00\x00\x00\x00\x00\x00$\x9f\x00\x00\x0f\x84\x00\x00\xb6\xc4XYZ \x00\x00\x00\x00\x00\x00b\x97\x00\x00\xb7\x87\x00\x00\x18\xd9para\x00\x00\x00\x00\x00\x03\x00\x00\x00\x02ff\x00\x00\xf2\xa7\x00\x00\rY\x00\x00\x13\xd0\x00\x00\n[chrm\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\xa3\xd7\x00\x00T|\x00\x00L\xcd\x00\x00\x99\x9a\x00\x00&g\x00\x00\x0f\\mluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x08\x00\x00\x00\x1c\x00G\x00I\x00M\x00Pmluc\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x0cenUS\x00\x00\x00\x08\x00\x00\x00\x1c\x00s\x00R\x00G\x00B\xff\xdb\x00C\x00\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xff\xdb\x00C\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\xff\xc2\x00\x11\x08\x00\x01\x00\x01\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01\xff\xc4\x00\x14\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\t\xff\xc4\x00\x14\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03\x01\x00\x02\x10\x03\x10\x00\x00\x01\x7f\x0f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01\x05\x02\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x03\x01\x01?\x01\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x02\x01\x01?\x01\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x06?\x02\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01?!\x7f\xff\xda\x00\x0c\x03\x01\x00\x02\x00\x03\x00\x00\x00\x10\x1f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x03\x01\x01?\x10\x7f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x02\x01\x01?\x10\x7f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x08\x01\x01\x00\x01?\x10\x7f\xff\xd9"
diff --git a/tests/core/test_invitation.py b/tests/core/test_invitation.py
index 5861bb44..8d8d8e74 100644
--- a/tests/core/test_invitation.py
+++ b/tests/core/test_invitation.py
@@ -36,8 +36,8 @@ def test_invitation(testclient, logged_admin, foo_group, smtpd, backend):
assert res.form["emails-0"].value == "someone@domain.tld"
assert res.form["groups"].value == [foo_group.id]
- res.form["password1"] = "whatever"
- res.form["password2"] = "whatever"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["given_name"] = "George"
res.form["family_name"] = "Abitbol"
@@ -48,7 +48,7 @@ def test_invitation(testclient, logged_admin, foo_group, smtpd, backend):
user = backend.get(models.User, user_name="someone")
backend.reload(foo_group)
- assert backend.check_user_password(user, "whatever")[0]
+ assert backend.check_user_password(user, "i'm a little pea")[0]
assert user.groups == [foo_group]
with testclient.session_transaction() as sess:
@@ -92,8 +92,8 @@ def test_invitation_editable_user_name(
assert res.form["groups"].value == [foo_group.id]
res.form["user_name"] = "djorje"
- res.form["password1"] = "whatever"
- res.form["password2"] = "whatever"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["given_name"] = "George"
res.form["family_name"] = "Abitbol"
@@ -104,7 +104,7 @@ def test_invitation_editable_user_name(
user = backend.get(models.User, user_name="djorje")
backend.reload(foo_group)
- assert backend.check_user_password(user, "whatever")[0]
+ assert backend.check_user_password(user, "i'm a little pea")[0]
assert user.groups == [foo_group]
with testclient.session_transaction() as sess:
@@ -141,8 +141,8 @@ def test_generate_link(testclient, logged_admin, foo_group, smtpd, backend):
assert res.form["emails-0"].value == "sometwo@domain.tld"
assert res.form["groups"].value == [foo_group.id]
- res.form["password1"] = "whatever"
- res.form["password2"] = "whatever"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["given_name"] = "George"
res.form["family_name"] = "Abitbol"
@@ -151,7 +151,7 @@ def test_generate_link(testclient, logged_admin, foo_group, smtpd, backend):
user = backend.get(models.User, user_name="sometwo")
backend.reload(foo_group)
- assert backend.check_user_password(user, "whatever")[0]
+ assert backend.check_user_password(user, "i'm a little pea")[0]
assert user.groups == [foo_group]
with testclient.session_transaction() as sess:
@@ -323,8 +323,8 @@ def test_groups_are_saved_even_when_user_does_not_have_read_permission(
assert res.form["groups"].value == [foo_group.id]
assert "readonly" in res.form["groups"].attrs
- res.form["password1"] = "whatever"
- res.form["password2"] = "whatever"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["given_name"] = "George"
res.form["family_name"] = "Abitbol"
diff --git a/tests/core/test_profile_settings.py b/tests/core/test_profile_settings.py
index aea157bd..1e3968cd 100644
--- a/tests/core/test_profile_settings.py
+++ b/tests/core/test_profile_settings.py
@@ -156,6 +156,118 @@ def test_profile_settings_too_long_password(testclient, logged_user):
)
+def test_profile_settings_compromised_password(testclient, logged_user):
+ """Tests if password is compromised."""
+
+ def with_different_values(password, message):
+ res = testclient.get("/profile/user/settings")
+ res = testclient.post(
+ "/profile/user/settings",
+ {
+ "csrf_token": res.form["csrf_token"].value,
+ "password1": password,
+ },
+ headers={
+ "HX-Request": "true",
+ "HX-Trigger-Name": "password1",
+ },
+ )
+ res.mustcontain(message)
+
+ with_different_values("aaaaaaaa", "This password is compromised.")
+ with_different_values("azertyuiop", "This password is compromised.")
+ with_different_values("a" * 1000, 'data-percent="25"')
+ with_different_values("i'm a little pea", 'data-percent="100"')
+
+
+@mock.patch("requests.api.get")
+def test_profile_settings_compromised_password_request_api_failed_but_password_updated(
+ api_get, testclient, logged_user, backend
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {"groups": "admins"}
+
+ res = testclient.get("/profile/user/settings", status=200)
+
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+
+ res = res.form.submit(name="action", value="edit-settings")
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert ("success", "Profile updated successfully.") in res.flashes
+
+ backend.reload(logged_user)
+
+ assert logged_user.user_name == "user"
+ assert backend.check_user_password(logged_user, "123456789")[0]
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_success_mail_to_admins_from_settings_form(
+ api_get, testclient, backend, admins_group, user, logged_user
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+
+ def with_and_without_admin_group(group):
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = group
+
+ res = testclient.get("/profile/user/settings", status=200)
+
+ res.form.user = user
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+
+ res = res.form.submit(name="action", value="edit-settings")
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert (
+ "success",
+ "We have informed your administrator about the failure of the password compromise investigation.",
+ ) in res.flashes
+ assert ("success", "Profile updated successfully.") in res.flashes
+
+ with_and_without_admin_group({"groups": "admins"})
+ with_and_without_admin_group(None)
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_fail_to_send_mail_to_admins_from_settings_form(
+ api_get, testclient, backend, admins_group, user, logged_user
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {"groups": "admins"}
+ current_app.config["CANAILLE"]["SMTP"]["TLS"] = False
+
+ assert not backend.query(models.User, user_name="newuser")
+
+ res = testclient.get("/profile/user/settings", status=200)
+ res.form.user = user
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+
+ res = res.form.submit(name="action", value="edit-settings")
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert (
+ "error",
+ "An error occurred while communicating the incident to the administrators. "
+ "Please update your password as soon as possible. "
+ "If this still happens, please contact the administrators.",
+ ) in res.flashes
+ assert ("success", "Profile updated successfully.") in res.flashes
+
+
def test_edition_without_groups(
testclient,
logged_user,
@@ -181,18 +293,18 @@ def test_edition_without_groups(
def test_password_change(testclient, logged_user, backend, caplog):
res = testclient.get("/profile/user/settings", status=200)
- res.form["password1"] = "new_password"
- res.form["password2"] = "new_password"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res = res.form.submit(name="action", value="edit-settings").follow()
backend.reload(logged_user)
- assert backend.check_user_password(logged_user, "new_password")[0]
+ assert backend.check_user_password(logged_user, "i'm a little pea")[0]
res = testclient.get("/profile/user/settings", status=200)
- res.form["password1"] = "correct horse battery staple"
- res.form["password2"] = "correct horse battery staple"
+ res.form["password1"] = "i'm a little chickpea"
+ res.form["password2"] = "i'm a little chickpea"
res = res.form.submit(name="action", value="edit-settings")
assert ("success", "Profile updated successfully.") in res.flashes
@@ -206,14 +318,14 @@ def test_password_change(testclient, logged_user, backend, caplog):
res = res.follow()
backend.reload(logged_user)
- assert backend.check_user_password(logged_user, "correct horse battery staple")[0]
+ assert backend.check_user_password(logged_user, "i'm a little chickpea")[0]
def test_password_change_fail(testclient, logged_user, backend):
res = testclient.get("/profile/user/settings", status=200)
- res.form["password1"] = "new_password"
- res.form["password2"] = "other_password"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little chickpea"
res = res.form.submit(name="action", value="edit-settings", status=200)
@@ -222,7 +334,7 @@ def test_password_change_fail(testclient, logged_user, backend):
res = testclient.get("/profile/user/settings", status=200)
- res.form["password1"] = "new_password"
+ res.form["password1"] = "i'm a little pea"
res.form["password2"] = ""
res = res.form.submit(name="action", value="edit-settings", status=200)
diff --git a/tests/core/test_registration.py b/tests/core/test_registration.py
index 17f31d08..7b531ae1 100644
--- a/tests/core/test_registration.py
+++ b/tests/core/test_registration.py
@@ -1,6 +1,7 @@
from unittest import mock
import time_machine
+from flask import current_app
from flask import url_for
from canaille.app import models
@@ -15,8 +16,8 @@ def test_registration_without_email_validation(testclient, backend, foo_group):
assert not backend.query(models.User, user_name="newuser")
res = testclient.get(url_for("core.account.registration"), status=200)
res.form["user_name"] = "newuser"
- res.form["password1"] = "password"
- res.form["password2"] = "password"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["family_name"] = "newuser"
res.form["emails-0"] = "newuser@example.com"
res = res.form.submit()
@@ -64,8 +65,8 @@ def test_registration_with_email_validation(testclient, backend, smtpd, foo_grou
with time_machine.travel("2020-01-01 02:01:00+00:00", tick=False):
res = testclient.get(registration_url, status=200)
res.form["user_name"] = "newuser"
- res.form["password1"] = "password"
- res.form["password2"] = "password"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["family_name"] = "newuser"
res = res.form.submit()
@@ -150,3 +151,161 @@ def test_registration_mail_error(SMTP, testclient, backend, smtpd, foo_group):
)
]
assert len(smtpd.messages) == 0
+
+
+def test_registration_with_compromised_password(testclient, backend, foo_group):
+ """Tests a nominal registration with compromised password."""
+ testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
+ testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
+
+ assert not backend.query(models.User, user_name="newuser")
+ res = testclient.get(url_for("core.account.registration"), status=200)
+ res.form["user_name"] = "newuser"
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+ res.form["family_name"] = "newuser"
+ res.form["emails-0"] = "newuser@example.com"
+ res = res.form.submit()
+ res.mustcontain("This password is compromised.")
+
+ user = backend.get(models.User, user_name="newuser")
+ assert user is None
+
+
+@mock.patch("requests.api.get")
+def test_registration_with_compromised_password_request_api_failed_but_account_created(
+ api_get, testclient, backend
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {"groups": "admins"}
+ testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
+ testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
+
+ assert not backend.query(models.User, user_name="newuser")
+
+ res = testclient.get(url_for("core.account.registration"), status=200)
+ res.form["user_name"] = "newuser"
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+ res.form["family_name"] = "newuser"
+ res.form["emails-0"] = "newuser@example.com"
+
+ res = res.form.submit()
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert ("success", "Your account has been created successfully.") in res.flashes
+
+ user = backend.get(models.User, user_name="newuser")
+ assert user
+ backend.delete(user)
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_success_mail_to_admins_from_register_form_with_admin_group(
+ api_get, testclient, backend, admins_group
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {"groups": "admins"}
+ testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
+ testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
+
+ assert not backend.query(models.User, user_name="newuser")
+
+ res = testclient.get(url_for("core.account.registration"), status=200)
+ res.form["user_name"] = "newuser"
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+ res.form["family_name"] = "newuser"
+ res.form["emails-0"] = "newuser@example.com"
+
+ res = res.form.submit()
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert (
+ "success",
+ "We have informed your administrator about the failure of the password compromise investigation.",
+ ) in res.flashes
+ assert ("success", "Your account has been created successfully.") in res.flashes
+
+ user = backend.get(models.User, user_name="newuser")
+ assert user
+ backend.delete(user)
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_success_mail_to_admins_from_register_form_without_admin_group(
+ api_get, testclient, backend, admins_group
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = None
+ testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
+ testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
+
+ assert not backend.query(models.User, user_name="newuser")
+
+ res = testclient.get(url_for("core.account.registration"), status=200)
+ res.form["user_name"] = "newuser"
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+ res.form["family_name"] = "newuser"
+ res.form["emails-0"] = "newuser@example.com"
+
+ res = res.form.submit()
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert (
+ "success",
+ "We have informed your administrator about the failure of the password compromise investigation.",
+ ) in res.flashes
+ assert ("success", "Your account has been created successfully.") in res.flashes
+
+ user = backend.get(models.User, user_name="newuser")
+ assert user
+ backend.delete(user)
+
+
+@mock.patch("requests.api.get")
+def test_compromised_password_validator_with_failure_of_api_request_and_fail_to_send_mail_to_admins_from_register_form(
+ api_get, testclient, backend, admins_group
+):
+ api_get.side_effect = mock.Mock(side_effect=Exception())
+ current_app.config["CANAILLE"]["ACL"]["ADMIN"]["FILTER"] = {"groups": "admins"}
+ current_app.config["CANAILLE"]["SMTP"]["TLS"] = False
+ testclient.app.config["CANAILLE"]["ENABLE_REGISTRATION"] = True
+ testclient.app.config["CANAILLE"]["EMAIL_CONFIRMATION"] = False
+
+ assert not backend.query(models.User, user_name="newuser")
+
+ res = testclient.get(url_for("core.account.registration"), status=200)
+ res.form["user_name"] = "newuser"
+ res.form["password1"] = "123456789"
+ res.form["password2"] = "123456789"
+ res.form["family_name"] = "newuser"
+ res.form["emails-0"] = "newuser@example.com"
+
+ res = res.form.submit()
+
+ assert (
+ "error",
+ "Password compromise investigation failed. Please contact the administrators.",
+ ) in res.flashes
+ assert (
+ "error",
+ "An error occurred while communicating the incident to the administrators. "
+ "Please update your password as soon as possible. "
+ "If this still happens, please contact the administrators.",
+ ) in res.flashes
+ assert ("success", "Your account has been created successfully.") in res.flashes
+
+ user = backend.get(models.User, user_name="newuser")
+ assert user
+ backend.delete(user)
diff --git a/tests/oidc/test_authorization_prompt.py b/tests/oidc/test_authorization_prompt.py
index 79569be9..943a3e0c 100644
--- a/tests/oidc/test_authorization_prompt.py
+++ b/tests/oidc/test_authorization_prompt.py
@@ -205,8 +205,8 @@ def test_prompt_create_not_logged(testclient, trusted_client, smtpd):
# Fill the user creation form
res = testclient.get(registration_url)
res.form["user_name"] = "newuser"
- res.form["password1"] = "password"
- res.form["password2"] = "password"
+ res.form["password1"] = "i'm a little pea"
+ res.form["password2"] = "i'm a little pea"
res.form["family_name"] = "newuser"
res = res.form.submit()
diff --git a/uv.lock b/uv.lock
index 03eafa2c..780d3b5c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -127,6 +127,7 @@ dependencies = [
{ name = "flask" },
{ name = "flask-wtf" },
{ name = "pydantic-settings" },
+ { name = "requests" },
{ name = "wtforms" },
]
@@ -220,6 +221,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = ">=2.0.3" },
{ name = "python-ldap", marker = "extra == 'ldap'", specifier = ">=3.4.0" },
{ name = "pytz", marker = "extra == 'front'", specifier = ">=2022.7" },
+ { name = "requests", specifier = ">=2.32.3" },
{ name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0.0" },
{ name = "sqlalchemy", marker = "extra == 'sqlite'", specifier = ">=2.0.23" },
{ name = "sqlalchemy", extras = ["mysql"], marker = "extra == 'mysql'", specifier = ">=2.0.23" },