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 %} + {{ site_name }} + {% 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" },