Merge branch '179-check-passwords-on-compromised-password-databases' of gitlab.com:yaal/canaille into 179-check-passwords-on-compromised-password-databases

This commit is contained in:
sebastien 2024-11-13 11:37:06 +01:00
commit faa5c6f966
18 changed files with 857 additions and 136 deletions

View file

@ -1,3 +1,10 @@
[0.0.57] - Unreleased
---------------------
Added
^^^^^
- Password compromise check :issue:`179`
[0.0.56] - 2024-11-07
---------------------

View file

@ -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

View file

@ -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

View file

@ -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.
"""

View file

@ -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(),

View file

@ -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",

View file

@ -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,
)

View file

@ -0,0 +1,81 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="https://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width"/>
<style type="text/css" style="font-weight: 300">@import url({{ url_for('static', filename='fonts/lato.css', _external=True) }});</style>
<title>{{ title }}</title>
</head>
<body style="color: rgba(0,0,0,.87); padding: 1em; margin: auto; width: 700px; font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif; font-weight: 400; font-size: 14px;">
<table cellspacing="0" cellpadding="0" border="0" style="font-weight: 400; background: #fff; font-size: 1rem; margin-top: 0; margin-bottom: 0; width: 700px;">
<tr>
<td colspan="2">
<h3 style="font-weight: 700; line-height: 1.3em; font-size: 1.3rem; padding: .8rem 1rem; margin: 0; box-shadow: none; border: 1px solid #d4d4d5; border-radius: .3rem .3rem 0 0;">
{% if logo %}
<img src="{{ logo }}" alt="{{ site_name }}" style="font-size: 1.3rem; border-style: none; width: 50px; display: inline-block; margin-top: .14em; vertical-align: middle;">
{% endif %}
<div style="font-size: 1.3rem; display: inline-block; padding-left: .75rem; vertical-align: middle;">
{% trans %}Compromised password check failure{% endtrans %}
</div>
</h3>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
<p>
{% trans %}Our services were unable to verify if the {{ user_name }}'s password is compromised.{% endtrans %}</br>
</p>
<p>
{% trans %}You have to check manually if the new password of the user {{ user_name }} is compromised.{% endtrans %}</br>
{% trans %}Follow this steps : {% endtrans %}</br>
{% trans %}1. click on the link above "Check if password is compromised".{% endtrans %}</br>
</p>
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td colspan="2" style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="{{ check_password_url }}" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %}Check if password is compromised{% endtrans %}</a>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
<p>
{% trans %}2. in the page that will open, search the following hashed password in the page : {{ hashed_password }}{% endtrans %}</br>
{% trans %}3. if the password is in the list :{% endtrans %}</br>
{% trans %}3.1. open this link and reset user's password. {% endtrans %}</br>
</p>
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td colspan="2" style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="http://127.0.0.1:5000/profile/{{ user_name }}/settings" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %} Reset {{ user_name }}'s password {% endtrans %}</a>
</td>
</tr>
<tr>
<td colspan="2" style="background: #f8f8f9; padding: 1em 1.5em; line-height: 1.4em; font-size: 1em; margin: 0; border-radius: 0; text-align: justify; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5;">
<p>
{% trans %}3.2. send an email to the user to explain the situation : {{ user_email }}{% endtrans %}</br>
</p>
</td>
</tr>
<tr style="margin: 0; border-radius: 0; text-align:center; border-left: 1px solid #d4d4d5; border-right: 1px solid #d4d4d5; border-bottom: 1px solid #d4d4d5;">
<td style="background: #e0e1e2; width:50%; border-radius: 0 0 0 .3rem;">
<a href="{{ site_url }}" style="width: 100%; display: inline-block; vertical-align: middle; color: rgba(0,0,0,.6); padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem;margin: 0; border-left: none; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{{ site_name }}</a>
</td>
<td style="background: #2185d0; width:50%; border-radius: 0 0 .3rem 0;">
<a href="{{ check_password_url }}" style="width: 100%; display: inline-block; vertical-align: middle; padding: .8em 0; font-weight: 700; line-height: 1em; text-align: center; text-decoration: none; font-size: 1rem; color: #fff; margin: 0; border-radius: 0; box-shadow: 0 0 0 1px transparent inset,0 0 0 0 rgba(34,36,38,.15) inset;">{% trans %}Check if password is compromised{% endtrans %}</a>
</td>
</tr>
</table>
</body>
</html>

View file

@ -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 }}

View file

@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

View file

@ -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",
]

View file

@ -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"')

View file

@ -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"

View file

@ -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"

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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" },