diff --git a/CHANGES.rst b/CHANGES.rst
index d726035b..c5f3d715 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -3,11 +3,19 @@
Added
^^^^^
+- 1 new parameter : MAX_PASSWORD_LENGTH :issue:`174`
+- 1 new validator : maximum password length (default 1000) :issue:`174`
+- password strength progress bar :issue:`174`
+- implementation of zxcvbn-rs-py which score the password strength :issue:`174`
- New security events logs :issue:`177`
- Support for Python 3.13 :pr:`186`
Changed
^^^^^^^
+- Maximum Python requirement is < 3.13 (because of the password_strength_calculator : zxcvbn-rs-py)
+- MIN_PASSWORD_LENGTH become a parameter :issue:`174`
+- all password tests and validator are supported by password1 field :issue:`174`
+- password2 (or Password confirmation) field only support "EQUAL TO PASSWORD" test :issue:`174`
- Update to HTMX 2.0.3 :pr:`184`
Removed
diff --git a/canaille/__init__.py b/canaille/__init__.py
index d281c767..27da9ec0 100644
--- a/canaille/__init__.py
+++ b/canaille/__init__.py
@@ -6,6 +6,8 @@ from flask import request
from flask import session
from flask_wtf.csrf import CSRFProtect
+from canaille.app.forms import password_strength_calculator
+
csrf = CSRFProtect()
@@ -28,6 +30,7 @@ def setup_sentry(app): # pragma: no cover
def setup_jinja(app):
app.jinja_env.filters["len"] = len
+ app.jinja_env.filters["password_strength"] = password_strength_calculator
app.jinja_env.policies["ext.i18n.trimmed"] = True
diff --git a/canaille/app/forms.py b/canaille/app/forms.py
index e0959618..0ed33212 100644
--- a/canaille/app/forms.py
+++ b/canaille/app/forms.py
@@ -46,6 +46,44 @@ def phone_number(form, field):
raise wtforms.ValidationError(_("Not a valid phone number"))
+def password_length_validator(form, field):
+ minimum_password_length = current_app.config["CANAILLE"]["MIN_PASSWORD_LENGTH"]
+ if minimum_password_length:
+ if len(field.data) < minimum_password_length:
+ raise wtforms.ValidationError(
+ _(
+ "Field must be at least {minimum_password_length} characters long."
+ ).format(minimum_password_length=str(minimum_password_length))
+ )
+
+
+def password_too_long_validator(form, field):
+ maximum_password_length = min(
+ current_app.config["CANAILLE"]["MAX_PASSWORD_LENGTH"] or 4096, 4096
+ )
+ if len(field.data) > maximum_password_length:
+ raise wtforms.ValidationError(
+ _(
+ "Field cannot be longer than {maximum_password_length} characters."
+ ).format(maximum_password_length=str(maximum_password_length))
+ )
+
+
+def password_strength_calculator(password):
+ try:
+ from zxcvbn_rs_py import zxcvbn
+ except ImportError:
+ return None
+
+ strength_score = 0
+
+ if password and type(password) is str:
+ strength_score = zxcvbn(password).score
+ strength_score = strength_score * 100 // 4
+
+ return strength_score
+
+
def email_validator(form, field):
try:
import email_validator # noqa: F401
diff --git a/canaille/core/configuration.py b/canaille/core/configuration.py
index c8100e4d..c1a4fc02 100644
--- a/canaille/core/configuration.py
+++ b/canaille/core/configuration.py
@@ -298,3 +298,21 @@ class CoreSettings(BaseModel):
[CANAILLE.ACL.ADMIN]
WRITE = ["user_name", "groups"]
"""
+
+ MIN_PASSWORD_LENGTH: int = 8
+ """Minimum length for user password.
+
+ Defaults to 8.
+
+ It is possible not to set a minimum, by entering None or 0.
+ """
+
+ MAX_PASSWORD_LENGTH: int = 1000
+ """Maximum length for user password.
+
+ Defaults to 1000.
+
+ There is a technical limit with passlib used by sql database of 4096
+ characters. If the value entered is 0 or None, or greater than 4096,
+ then 4096 will be retained.
+ """
diff --git a/canaille/core/endpoints/account.py b/canaille/core/endpoints/account.py
index e69f9639..1c8ee5ec 100644
--- a/canaille/core/endpoints/account.py
+++ b/canaille/core/endpoints/account.py
@@ -35,6 +35,8 @@ from canaille.app.flask import user_needed
from canaille.app.forms import IDToModel
from canaille.app.forms import TableForm
from canaille.app.forms import is_readonly
+from canaille.app.forms import password_length_validator
+from canaille.app.forms import password_too_long_validator
from canaille.app.forms import set_readonly
from canaille.app.forms import set_writable
from canaille.app.i18n import gettext as _
@@ -47,7 +49,6 @@ from ..mails import send_invitation_mail
from ..mails import send_password_initialization_mail
from ..mails import send_password_reset_mail
from ..mails import send_registration_mail
-from .forms import MINIMUM_PASSWORD_LENGTH
from .forms import EmailConfirmationForm
from .forms import InvitationForm
from .forms import JoinForm
@@ -313,11 +314,11 @@ def registration(data=None, hash=None):
form["password1"].validators = [
wtforms.validators.DataRequired(),
- wtforms.validators.Length(min=MINIMUM_PASSWORD_LENGTH),
+ password_length_validator,
+ password_too_long_validator,
]
form["password2"].validators = [
wtforms.validators.DataRequired(),
- wtforms.validators.Length(min=MINIMUM_PASSWORD_LENGTH),
]
form["password1"].flags.required = True
form["password2"].flags.required = True
diff --git a/canaille/core/endpoints/forms.py b/canaille/core/endpoints/forms.py
index ff48380b..e2925cb5 100644
--- a/canaille/core/endpoints/forms.py
+++ b/canaille/core/endpoints/forms.py
@@ -12,6 +12,8 @@ from canaille.app.forms import Form
from canaille.app.forms import IDToModel
from canaille.app.forms import email_validator
from canaille.app.forms import is_uri
+from canaille.app.forms import password_length_validator
+from canaille.app.forms import password_too_long_validator
from canaille.app.forms import phone_number
from canaille.app.forms import set_readonly
from canaille.app.forms import unique_values
@@ -20,8 +22,6 @@ from canaille.app.i18n import lazy_gettext as _
from canaille.app.i18n import native_language_name_from_code
from canaille.backends import Backend
-MINIMUM_PASSWORD_LENGTH = 8
-
def unique_user_name(form, field):
if Backend.instance.get(models.User, user_name=field.data) and (
@@ -263,7 +263,8 @@ PROFILE_FORM_FIELDS = dict(
_("Password"),
validators=[
wtforms.validators.Optional(),
- wtforms.validators.Length(min=MINIMUM_PASSWORD_LENGTH),
+ password_length_validator,
+ password_too_long_validator,
],
render_kw={
"autocomplete": "new-password",
diff --git a/canaille/static/css/base.css b/canaille/static/css/base.css
index 3c620cc8..33d21501 100644
--- a/canaille/static/css/base.css
+++ b/canaille/static/css/base.css
@@ -74,6 +74,9 @@ footer a {
color: rgba(0,0,0,1);
}
+.progress_bar:first-child {
+ margin: 1em 0 .28571429rem 0;
+}
/* Fix button appearance for semantic-ui on webkit */
[type=button] {
@@ -255,4 +258,7 @@ select.ui.multiple.dropdown option[selected] {
box-shadow: 0 0 0 100px #888888 inset !important;
border: 1px solid rgba(255,255,255,0.87) !important;
}
+ .ui.progress {
+ background: #222222;
+ }
}
diff --git a/canaille/templates/macro/form.html b/canaille/templates/macro/form.html
index b999cbb8..620ca105 100644
--- a/canaille/templates/macro/form.html
+++ b/canaille/templates/macro/form.html
@@ -19,7 +19,7 @@ del_button=false
{% set inline_validation = field.validators and field.type not in ("FileField", "MultipleFileField") %}
{% if inline_validation %}
{% set ignore_me = kwargs.update({"hx-post": ""}) %}
- {% set ignore_me = kwargs.update({"hx-indicator": "closest .input"}) %}
+ {% set ignore_me = kwargs.update({"hx-indicator": "closest .input", "hx-trigger": "input changed delay:500ms"}) %}
{% endif %}
{% if container and field_visible %}
@@ -114,6 +114,16 @@ del_button=false
{% endfor %}
{% endif %}
+{% if field.name == "password1" and field.data|password_strength and not field.errors %}
+
+
{% trans %}Password strength{% endtrans %}
+
+
+{% endif %}
+
{% if container and field_visible %}
{% endif %}
diff --git a/canaille/translations/messages.pot b/canaille/translations/messages.pot
index 35717f78..a8b4f68e 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-09-12 19:28+0200\n"
+"POT-Creation-Date: 2024-10-28 11:57+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -21,23 +21,31 @@ msgstr ""
msgid "No SMTP server has been configured"
msgstr ""
-#: canaille/app/forms.py:26
+#: canaille/app/forms.py:27
msgid "This is not a valid URL"
msgstr ""
-#: canaille/app/forms.py:33 canaille/app/forms.py:34
+#: canaille/app/forms.py:34 canaille/app/forms.py:35
msgid "This value is a duplicate"
msgstr ""
-#: canaille/app/forms.py:46
+#: canaille/app/forms.py:47
msgid "Not a valid phone number"
msgstr ""
-#: canaille/app/forms.py:206
+#: canaille/app/forms.py:55
+msgid "Field must be at least {minimum_password_length} characters long."
+msgstr ""
+
+#: canaille/app/forms.py:67
+msgid "Field cannot be longer than {maximum_password_length} characters."
+msgstr ""
+
+#: canaille/app/forms.py:240
msgid "The page number is not valid"
msgstr ""
-#: canaille/app/forms.py:234
+#: canaille/app/forms.py:268
msgid "Not a valid datetime value."
msgstr ""
@@ -54,7 +62,7 @@ msgid "John Doe"
msgstr ""
#: canaille/backends/ldap/backend.py:178 canaille/core/endpoints/forms.py:164
-#: canaille/core/endpoints/forms.py:423
+#: canaille/core/endpoints/forms.py:424
msgid "jdoe"
msgstr ""
@@ -128,8 +136,8 @@ msgstr ""
msgid "You are already logged in, you cannot create an account."
msgstr ""
-#: canaille/core/endpoints/account.py:299 canaille/core/endpoints/forms.py:313
-#: canaille/core/endpoints/forms.py:441 canaille/core/templates/groups.html:5
+#: 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/templates/groups.html:23
#: canaille/core/templates/partial/group-members.html:15
#: canaille/core/templates/partial/users.html:18
@@ -175,76 +183,76 @@ msgstr ""
msgid "User account creation succeed."
msgstr ""
-#: canaille/core/endpoints/account.py:610
-#: canaille/core/endpoints/account.py:771
+#: canaille/core/endpoints/account.py:615
+#: canaille/core/endpoints/account.py:784
msgid "Profile edition failed."
msgstr ""
-#: canaille/core/endpoints/account.py:614
-#: canaille/core/endpoints/account.py:786
+#: canaille/core/endpoints/account.py:625
+#: canaille/core/endpoints/account.py:802
msgid "Profile updated successfully."
msgstr ""
-#: canaille/core/endpoints/account.py:621
+#: canaille/core/endpoints/account.py:633
msgid "Email addition failed."
msgstr ""
-#: canaille/core/endpoints/account.py:626
+#: canaille/core/endpoints/account.py:638
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:633
+#: canaille/core/endpoints/account.py:645
msgid "Could not send the verification email"
msgstr ""
-#: canaille/core/endpoints/account.py:643
+#: canaille/core/endpoints/account.py:655
msgid "Email deletion failed."
msgstr ""
-#: canaille/core/endpoints/account.py:646
+#: canaille/core/endpoints/account.py:658
msgid "The email have been successfully deleted."
msgstr ""
-#: canaille/core/endpoints/account.py:683
+#: canaille/core/endpoints/account.py:695
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:690 canaille/core/endpoints/auth.py:159
+#: canaille/core/endpoints/account.py:702 canaille/core/endpoints/auth.py:159
msgid "Could not send the password initialization email"
msgstr ""
-#: canaille/core/endpoints/account.py:701
+#: canaille/core/endpoints/account.py:713
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:708
+#: canaille/core/endpoints/account.py:720
msgid "Could not send the password reset email"
msgstr ""
-#: canaille/core/endpoints/account.py:724
+#: canaille/core/endpoints/account.py:736
msgid "The account has been locked"
msgstr ""
-#: canaille/core/endpoints/account.py:735
+#: canaille/core/endpoints/account.py:747
msgid "The account has been unlocked"
msgstr ""
-#: canaille/core/endpoints/account.py:806
+#: canaille/core/endpoints/account.py:822
#, python-format
msgid "The user %(user)s has been successfully deleted"
msgstr ""
-#: canaille/core/endpoints/account.py:823
+#: canaille/core/endpoints/account.py:839
msgid "Locked users cannot be impersonated."
msgstr ""
-#: canaille/core/endpoints/account.py:827 canaille/core/endpoints/auth.py:112
+#: canaille/core/endpoints/account.py:843 canaille/core/endpoints/auth.py:112
#, python-format
msgid "Connection successful. Welcome %(user)s"
msgstr ""
@@ -256,8 +264,8 @@ 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:409 canaille/core/endpoints/forms.py:435
-#: canaille/core/endpoints/forms.py:459 canaille/core/endpoints/forms.py:475
+#: canaille/core/endpoints/forms.py:410 canaille/core/endpoints/forms.py:436
+#: canaille/core/endpoints/forms.py:460 canaille/core/endpoints/forms.py:476
msgid "jane@doe.com"
msgstr ""
@@ -314,15 +322,15 @@ msgid ""
"We cannot send a password reset email."
msgstr ""
-#: canaille/core/endpoints/auth.py:207
+#: canaille/core/endpoints/auth.py:213
msgid "We encountered an issue while we sent the password recovery email."
msgstr ""
-#: canaille/core/endpoints/auth.py:230
+#: canaille/core/endpoints/auth.py:236
msgid "The password reset link that brought you here was invalid."
msgstr ""
-#: canaille/core/endpoints/auth.py:239
+#: canaille/core/endpoints/auth.py:245
msgid "Your password has been updated successfully"
msgstr ""
@@ -368,11 +376,11 @@ msgstr ""
msgid "Password"
msgstr ""
-#: canaille/core/endpoints/forms.py:136 canaille/core/endpoints/forms.py:273
+#: canaille/core/endpoints/forms.py:136 canaille/core/endpoints/forms.py:274
msgid "Password confirmation"
msgstr ""
-#: canaille/core/endpoints/forms.py:139 canaille/core/endpoints/forms.py:276
+#: canaille/core/endpoints/forms.py:139 canaille/core/endpoints/forms.py:277
msgid "Password and confirmation do not match."
msgstr ""
@@ -384,8 +392,8 @@ msgstr ""
msgid "Username"
msgstr ""
-#: canaille/core/endpoints/forms.py:167 canaille/core/endpoints/forms.py:365
-#: canaille/core/endpoints/forms.py:379
+#: canaille/core/endpoints/forms.py:167 canaille/core/endpoints/forms.py:366
+#: canaille/core/endpoints/forms.py:380
#: canaille/core/templates/partial/group-members.html:12
#: canaille/core/templates/partial/groups.html:6
#: canaille/core/templates/partial/users.html:12
@@ -426,12 +434,12 @@ msgstr ""
msgid "Johnny"
msgstr ""
-#: canaille/core/endpoints/forms.py:199 canaille/core/endpoints/forms.py:465
+#: canaille/core/endpoints/forms.py:199 canaille/core/endpoints/forms.py:466
#: canaille/core/templates/profile_edit.html:176
msgid "Email addresses"
msgstr ""
-#: canaille/core/endpoints/forms.py:205 canaille/core/endpoints/forms.py:455
+#: canaille/core/endpoints/forms.py:205 canaille/core/endpoints/forms.py:456
msgid ""
"This email will be used as a recovery address to reset the password if "
"needed"
@@ -491,68 +499,68 @@ msgstr ""
msgid "Delete the photo"
msgstr ""
-#: canaille/core/endpoints/forms.py:284
+#: canaille/core/endpoints/forms.py:285
msgid "User number"
msgstr ""
-#: canaille/core/endpoints/forms.py:286 canaille/core/endpoints/forms.py:292
+#: canaille/core/endpoints/forms.py:287 canaille/core/endpoints/forms.py:293
msgid "1234"
msgstr ""
-#: canaille/core/endpoints/forms.py:290
+#: canaille/core/endpoints/forms.py:291
msgid "Department"
msgstr ""
-#: canaille/core/endpoints/forms.py:296
+#: canaille/core/endpoints/forms.py:297
msgid "Organization"
msgstr ""
-#: canaille/core/endpoints/forms.py:298
+#: canaille/core/endpoints/forms.py:299
msgid "Cogip LTD."
msgstr ""
-#: canaille/core/endpoints/forms.py:302
+#: canaille/core/endpoints/forms.py:303
msgid "Website"
msgstr ""
-#: canaille/core/endpoints/forms.py:304
+#: canaille/core/endpoints/forms.py:305
msgid "https://mywebsite.tld"
msgstr ""
-#: canaille/core/endpoints/forms.py:309
+#: canaille/core/endpoints/forms.py:310
msgid "Preferred language"
msgstr ""
-#: canaille/core/endpoints/forms.py:319
+#: canaille/core/endpoints/forms.py:320
msgid "users, admins …"
msgstr ""
-#: canaille/core/endpoints/forms.py:344
+#: canaille/core/endpoints/forms.py:345
msgid "Account expiration"
msgstr ""
-#: canaille/core/endpoints/forms.py:368
+#: canaille/core/endpoints/forms.py:369
msgid "group"
msgstr ""
-#: canaille/core/endpoints/forms.py:372 canaille/core/endpoints/forms.py:389
+#: canaille/core/endpoints/forms.py:373 canaille/core/endpoints/forms.py:390
#: canaille/core/templates/partial/groups.html:7
msgid "Description"
msgstr ""
-#: canaille/core/endpoints/forms.py:403 canaille/core/endpoints/forms.py:428
+#: canaille/core/endpoints/forms.py:404 canaille/core/endpoints/forms.py:429
msgid "Email address"
msgstr ""
-#: canaille/core/endpoints/forms.py:422
+#: canaille/core/endpoints/forms.py:423
msgid "User name"
msgstr ""
-#: canaille/core/endpoints/forms.py:426
+#: canaille/core/endpoints/forms.py:427
msgid "Username editable by the invitee"
msgstr ""
-#: canaille/core/endpoints/forms.py:468
+#: canaille/core/endpoints/forms.py:469
msgid "New email address"
msgstr ""
@@ -1424,29 +1432,29 @@ msgstr ""
msgid "The client has been deleted."
msgstr ""
-#: canaille/oidc/endpoints/consents.py:73
-#: canaille/oidc/endpoints/consents.py:108
+#: canaille/oidc/endpoints/consents.py:75
+#: canaille/oidc/endpoints/consents.py:114
msgid "Could not revoke this access"
msgstr ""
-#: canaille/oidc/endpoints/consents.py:76
+#: canaille/oidc/endpoints/consents.py:78
msgid "The access is already revoked"
msgstr ""
-#: canaille/oidc/endpoints/consents.py:80
-#: canaille/oidc/endpoints/consents.py:123
+#: canaille/oidc/endpoints/consents.py:86
+#: canaille/oidc/endpoints/consents.py:129
msgid "The access has been revoked"
msgstr ""
-#: canaille/oidc/endpoints/consents.py:89
+#: canaille/oidc/endpoints/consents.py:95
msgid "Could not restore this access"
msgstr ""
-#: canaille/oidc/endpoints/consents.py:92
+#: canaille/oidc/endpoints/consents.py:98
msgid "The access is not revoked"
msgstr ""
-#: canaille/oidc/endpoints/consents.py:99
+#: canaille/oidc/endpoints/consents.py:105
msgid "The access has been restored"
msgstr ""
@@ -1519,15 +1527,15 @@ msgstr ""
msgid "Pre-consent"
msgstr ""
-#: canaille/oidc/endpoints/oauth.py:355
+#: canaille/oidc/endpoints/oauth.py:369
msgid "You have been disconnected"
msgstr ""
-#: canaille/oidc/endpoints/oauth.py:372
+#: canaille/oidc/endpoints/oauth.py:386
msgid "You have not been disconnected"
msgstr ""
-#: canaille/oidc/endpoints/tokens.py:45
+#: canaille/oidc/endpoints/tokens.py:50
msgid "The token has successfully been revoked."
msgstr ""
@@ -1832,6 +1840,10 @@ msgstr ""
msgid "Add another field"
msgstr ""
+#: canaille/templates/macro/form.html:119
+msgid "Password strength"
+msgstr ""
+
#: canaille/templates/macro/table.html:8
msgid "Search…"
msgstr ""
diff --git a/poetry.lock b/poetry.lock
index afb9bfaa..8485a739 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -916,17 +916,6 @@ MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
-[[package]]
-name = "legacy-cgi"
-version = "2.6.1"
-description = "Fork of the standard library cgi and cgitb modules, being deprecated in PEP-594"
-optional = false
-python-versions = "<4.0,>=3.10"
-files = [
- {file = "legacy_cgi-2.6.1-py3-none-any.whl", hash = "sha256:8eacc1522d9f76451337a4b5a0abf494158d39250754b0d1bc19a14c6512af9b"},
- {file = "legacy_cgi-2.6.1.tar.gz", hash = "sha256:f2ada99c747c3d72a473a6aaff6259a61f226b06fe9f3106e495ab83fd8f7a42"},
-]
-
[[package]]
name = "lxml"
version = "5.3.0"
@@ -1344,10 +1333,7 @@ files = [
[package.dependencies]
annotated-types = ">=0.6.0"
pydantic-core = "2.23.4"
-typing-extensions = [
- {version = ">=4.12.2", markers = "python_version >= \"3.13\""},
- {version = ">=4.6.1", markers = "python_version < \"3.13\""},
-]
+typing-extensions = {version = ">=4.6.1", markers = "python_version < \"3.13\""}
[package.extras]
email = ["email-validator (>=2.0.0)"]
@@ -2469,9 +2455,6 @@ files = [
{file = "webob-1.8.9.tar.gz", hash = "sha256:ad6078e2edb6766d1334ec3dee072ac6a7f95b1e32ce10def8ff7f0f02d56589"},
]
-[package.dependencies]
-legacy-cgi = {version = ">=2.6", markers = "python_version >= \"3.13\""}
-
[package.extras]
docs = ["Sphinx (>=1.7.5)", "pylons-sphinx-themes"]
testing = ["coverage", "pytest (>=3.1.0)", "pytest-cov", "pytest-xdist"]
@@ -2530,9 +2513,97 @@ markupsafe = "*"
[package.extras]
email = ["email-validator"]
+[[package]]
+name = "zxcvbn-rs-py"
+version = "0.1.1"
+description = "Python bindings for zxcvbn-rs, the Rust implementation of zxcvbn"
+optional = true
+python-versions = ">=3.7, <3.13"
+files = [
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2c16d2ffa0933ac014b8e79bccfd2e9cf2ef767db047df99a7cc4597bfdf4eb2"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c010de71c57b08b8cdf04c36b5b523ca532f1533f8191b1f565d22f884490ad3"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c2fe75ffb4b6b2226c9417943fc9c175f0202cf5a9111de523417c8a0a19b60"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5ee2612f3ed959543aeed74c9886b35b1a968a0a499cca275a863ca89b700be"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eef690fd029b29bad9bb2ab2025b944aafb40ebf390f64734888808742398242"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00a405d33d3c250db6b2326c4f1050ba7cc386b1b07b5b8703ed7ccfa6965d38"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6705c5a819cfb0c95df3f7e02719830c88c354e8d7e634c83df1271fdc9c973e"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0a94936e4cf5fb56f844923e381ba35b33cc07533f48f0a97ee5e6dfb2846ee"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-none-win32.whl", hash = "sha256:80189ae562eeff0e1d44dd97f8de5861fccfe4799bb4751c27bd98bf5d62bf42"},
+ {file = "zxcvbn_rs_py-0.1.1-cp310-none-win_amd64.whl", hash = "sha256:585bffa0887fb37e80e58d1d87a6523e5b5b4c6d518941e95e4b4012cc7131da"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4364513fd68ae347382feebd68f49b4a60f81ae4e905af6a9f337d684cadce1c"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9333bfe083f6a2271174cb865ffa9541ce22c54e245a8478c46efcdfde6dea78"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bff2927550ec00f9a3d4e082510ecf8124b52faf899c1eb812626ca8dc4caa4a"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02a1e488aa0f21f49f14dcdd9bb802d337dbc47c0ddf329b30333997e32a391e"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb0fb76d0a331d0655cec6fd53a4acd87a0a60f26751999267c7319d0edc8e05"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a0c3427e82b8fec06cd2b3a6932ad747bcaf1e83d6794fdd1e4a59de808283c"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c139aaa1801dfacf308024e06901e0ce9a4438b1a189c70cba9126a729523e1"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:da866bb00c855afa0ab4ac882fb7ae8fa7bbeccaafd1ab83776b290959f5ac6d"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-none-win32.whl", hash = "sha256:01b13c536e7058a52d0288b7d83a53108b4fbacb01bb29039570e8cd09c8cae3"},
+ {file = "zxcvbn_rs_py-0.1.1-cp311-none-win_amd64.whl", hash = "sha256:d5dbd8376b699c290d2a2ed4330ac1602e6ff7ff07b8d473ad81f31d0bacf279"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:502ea698acd13c07f2c52e2b092c9a44749baf26aa30c8f5d2d8d3d355d230fc"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4ddbcd63f795795f41811e126c01fb9fe8ebf3eeb436aa7bbdf6130374486bb"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28938ef0b0ed4a72c541737eb82f03e17823996a4b3d7efc88d071287378dea"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc1f1318b2d69c97d1486b8cb25e453c28d743cff49e6f03734d1d2b84378b3c"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c435b625981dcc4f47ba7835e82a8fa6d517dc023ebe67de708375e49e19f30"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33a501782fb75e296441c8901779681ba363cccb3ebe421ac416e161b51f0c13"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74119c768708993af9eddc5840da0b0ce1d97b900341c5a911663b4ae9896656"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:30a62fc1d3f284f087b35254f34ff53591b0de658ea15625ee52484a043d2e58"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-none-win32.whl", hash = "sha256:93ab1d484b9357d30a273a7c3ce92999686b744140af3ec790a5dc422f77542e"},
+ {file = "zxcvbn_rs_py-0.1.1-cp312-none-win_amd64.whl", hash = "sha256:5e658a75a1e224acea935e4f9c7b3d7734d5fecd4e41570fec20e24aea69a65d"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66a2dd6831b4fac382d71c97e8513698b922bda0d926b62d26f13f1aa56792b4"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c113702b0856d160b77a2c13bc70ed90fdb480355bba1ecb4f6ed2534a43656c"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4b1e70de8c2684e83ba2d3bb42162a1d934bce4a68878836ec0f4f78bb467ad"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c72c98ce471965f45755c95864e6839fbc8d7da0d23636b1499f82ea80107907"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a947cd4f49da1f6d63f106362db665733338363c0fa7e094f619ce854ff5919"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:79dd2bee5fd956d4d5cf31fca4b7fea4a59f6f3a70f252f1170d2fa6d6f0a2ae"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-none-win32.whl", hash = "sha256:605b9e6c4e09be5ba776dcb5864b8d8494fe5d0fdeab263e12f5bab7318cc788"},
+ {file = "zxcvbn_rs_py-0.1.1-cp37-none-win_amd64.whl", hash = "sha256:76f99daff842e13bd89af407708a0ba4f355461a9845d358a31f646dfa859dc7"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:865a4d409b27fbff8168ecaa2b729c2a3213352d1b7a9041710273a117e6ec03"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:600911814dff1fe5cb264ef6a340280e9adf4cef82d1b6e2b7c610ee2c3be3c1"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3861c3e6ddc8d24a38c4c887229b211b0f39207a8560c9a1437290989bc7f623"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fafe3c3172ba76f08d50005a52f8a372be08a95f44bdf4e633c0cf5dc3967de"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fd2f02d6cc567f3b0f6458a6828b736de6f6eee2a15f1937a4cc3ab4ac0b9ce"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48062e952fbbe226b1c093c641dbb8118490f8746fb005bec720a6cabd372222"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-none-win32.whl", hash = "sha256:c4446dfa9483007e777dee191ae92d453af5f819a9a9eacecaa6230f2a8d278a"},
+ {file = "zxcvbn_rs_py-0.1.1-cp38-none-win_amd64.whl", hash = "sha256:557344aed8d7b275d3745373612610333fc253fc67d8908f16357216f1f5d175"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebc72038af96cc16258eee5b7528078236b15f29c83cf31f293721a16d81a633"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a4e81616ea67bec0398260fe6290b5a8f7c5112dc5c45a0b060c744e31d4fdb"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a05ba99a9ccb62da78bdf34e1e24a708edc7976bfb699f69d1fb271899846af5"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb11d14958f28c3e3f5320089d1c442083024888492dba8b16dd7a7e597482ef"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b83a5a743b4404b42b5d25aa538c7133356483f95217929f0f83dabd9b0606db"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc238a9af18d8e9009e7c391d48afa056ce08fb4da67bf739bb08bcc366c22c2"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-none-win32.whl", hash = "sha256:48a0a7ac6da7d929f1535b04ea7a6a5c1ad3c2089b60b63422a9a516389a9c13"},
+ {file = "zxcvbn_rs_py-0.1.1-cp39-none-win_amd64.whl", hash = "sha256:9756d2ec94ac8571051304a1b0a6d58496e8f3997d89565bae51ad61193bfb5b"},
+ {file = "zxcvbn_rs_py-0.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b9bfd147cd678c4cd4567f3758d47bdd965e1337c8e4260b6ccffd75db905b5"},
+ {file = "zxcvbn_rs_py-0.1.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10f1fc4c246f7abeeeff5951655a8b8f694d2793210625d6d66dc877646f3bfd"},
+ {file = "zxcvbn_rs_py-0.1.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5eef1ed5dd7cf5c8c687fc2c10562fa4649e03b1c0bff493ade7eea26f1ea5d"},
+ {file = "zxcvbn_rs_py-0.1.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b79c6241d341082add22dc869d31dbcc4afa14fd9607f6ffac2431144602653"},
+ {file = "zxcvbn_rs_py-0.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5f194b37fc82133b6b2d9e5ed12d2de4084707cdd8cfa5cbf22836e88d9d58e"},
+ {file = "zxcvbn_rs_py-0.1.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d21b03ad410a4e1980a70269e534f23048af5c40a507c59b6fac90b0a72d374f"},
+ {file = "zxcvbn_rs_py-0.1.1-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1207ff8367c8f2bad1b12973775f739ef651a20cda23527e2a647b3d8d4ac88a"},
+ {file = "zxcvbn_rs_py-0.1.1-pp37-pypy37_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aa34e5c817f76f908dea2f3de4b312436f23ef862892c1bf3cbd9f7c6076af08"},
+ {file = "zxcvbn_rs_py-0.1.1-pp37-pypy37_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af633d2ff881a36af5469884f9aa9605363485f07bb1125d2853f83ae99bc558"},
+ {file = "zxcvbn_rs_py-0.1.1-pp37-pypy37_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:51dde7e0b4534b3eff7870677983dbd1382ae5d17defc4ae1d9ef3248da1777b"},
+ {file = "zxcvbn_rs_py-0.1.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:645ce91c85bde350e1bb8fd5e00c687d78e6f5809e8aeb8ff20e6843b729090f"},
+ {file = "zxcvbn_rs_py-0.1.1-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0d19283743e1ed9ade89cb2f74c5b8baf6450e8ca427cd0ebf242615edeb345f"},
+ {file = "zxcvbn_rs_py-0.1.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6516ba5f1b18b532fca1eb4b63b7dec11197e0f3da7bdde946ea4bba4acf80ae"},
+ {file = "zxcvbn_rs_py-0.1.1-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:febae3939795bdad83925c5f974e17d9435e03ff6ef0cf5050c7c88cd40d3f9b"},
+ {file = "zxcvbn_rs_py-0.1.1-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe9ac157fe179a7a5d0d57b028ea249ca703d4a105c577afc8585d3bc94ef3d8"},
+ {file = "zxcvbn_rs_py-0.1.1-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e442a9cd779d6abfa0de821ac017cd44c6110b13fc86e764804c44dbed0ad846"},
+ {file = "zxcvbn_rs_py-0.1.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04fb2ffd3770c28a3bf500579e1925862f9f88fa3d287ac6b871e3d07723d585"},
+ {file = "zxcvbn_rs_py-0.1.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8e075c00b2e56db6dc2e01104a57a0dc8430a66ec8d9e50a4ac608461a9422af"},
+ {file = "zxcvbn_rs_py-0.1.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ebc51924ac5200084401946c4dffab86c7a7242a84a68b202aa449eb59f12ce"},
+ {file = "zxcvbn_rs_py-0.1.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e196bdc69697afef49d0317a9c3491bbad657047855b785d056fbf229845936"},
+ {file = "zxcvbn_rs_py-0.1.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0079f6704d13471db7401c4ff5f7c7255a815156ca112554c71d7f7c190f4441"},
+ {file = "zxcvbn_rs_py-0.1.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a5afd57b654d1a2d1ebb0a8e177183b42b52e7b539d6f4f867970eec58beecc"},
+ {file = "zxcvbn_rs_py-0.1.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e2c1be214b012840fc45aed2a8eb2b43bba867322f8930d597ea40749bbb3fd"},
+ {file = "zxcvbn_rs_py-0.1.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:553a8be92870e0dafd2855b634f81f09208142b17e5e499943a6e83c534e749e"},
+ {file = "zxcvbn_rs_py-0.1.1.tar.gz", hash = "sha256:ec4649fd619e91fb278aca93b3d770c1b4226ba3a50c4c77311f6692a488fa00"},
+]
+
[extras]
-all = ["authlib", "email_validator", "flask-babel", "flask-themer", "passlib", "pycountry", "python-ldap", "pytz", "sentry-sdk", "sqlalchemy", "sqlalchemy-json", "sqlalchemy-utils", "toml"]
-front = ["email_validator", "flask-babel", "flask-themer", "pycountry", "pytz", "toml"]
+all = ["authlib", "email_validator", "flask-babel", "flask-themer", "passlib", "pycountry", "python-ldap", "pytz", "sentry-sdk", "sqlalchemy", "sqlalchemy-json", "sqlalchemy-utils", "toml", "zxcvbn-rs-py"]
+front = ["email_validator", "flask-babel", "flask-themer", "pycountry", "pytz", "toml", "zxcvbn-rs-py"]
ldap = ["python-ldap"]
oidc = ["authlib"]
sentry = ["sentry-sdk"]
@@ -2540,5 +2611,5 @@ sql = ["passlib", "sqlalchemy", "sqlalchemy-json", "sqlalchemy-utils"]
[metadata]
lock-version = "2.0"
-python-versions = "^3.10"
-content-hash = "4b4e4ce2d7a8d6d586177e74b67b3e1fcd744f33f36701b7d64b2994f10396f6"
+python-versions = "<3.13,>=3.10"
+content-hash = "6918a9fca20831192025f6d512c1c6ed567a76b1c908496788edd2d727d3d05d"
diff --git a/pyproject.toml b/pyproject.toml
index db2d01c7..dd39f056 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,7 @@ readme = "README.md"
include = ["canaille/translations/*/LC_MESSAGES/*.mo"]
[tool.poetry.dependencies]
-python = "^3.10"
+python = "<3.13,>=3.10"
flask = "^3.0.0"
flask-wtf = "^1.2.1"
pydantic-settings = "^2.0.3"
@@ -49,6 +49,7 @@ flask-themer = {version = "^2.0.0", optional=true}
pycountry = {version = ">=22.1.10", optional=true}
pytz = {version = ">=2022.7", optional=true}
toml = {version = "^0.10.0", optional=true, python = "<3.11"}
+zxcvbn-rs-py = {version = "^0.1.1", optional=true}
# extra : oidc
authlib = {version = "^1.2.1", optional=true}
@@ -119,6 +120,7 @@ front = [
"pycountry",
"pytz",
"toml",
+ "zxcvbn-rs-py",
]
ldap = [
"python-ldap",
@@ -150,6 +152,7 @@ all = [
"sqlalchemy",
"sqlalchemy-json",
"sqlalchemy-utils",
+ "zxcvbn-rs-py",
]
[tool.poetry.scripts]
diff --git a/tests/app/test_forms.py b/tests/app/test_forms.py
index e3d556eb..3fa729d7 100644
--- a/tests/app/test_forms.py
+++ b/tests/app/test_forms.py
@@ -7,6 +7,8 @@ from flask import current_app
from werkzeug.datastructures import ImmutableMultiDict
from canaille.app.forms import DateTimeUTCField
+from canaille.app.forms import password_length_validator
+from canaille.app.forms import password_too_long_validator
from canaille.app.forms import phone_number
@@ -259,3 +261,75 @@ def test_phone_number_validator():
with pytest.raises(wtforms.ValidationError):
phone_number(None, Field("invalid"))
+
+
+def test_minimum_password_length_config(testclient):
+ class Field:
+ def __init__(self, data):
+ self.data = data
+
+ current_app.config["CANAILLE"]["MIN_PASSWORD_LENGTH"] = 20
+ password_length_validator(None, Field("12345678901234567890"))
+
+ with pytest.raises(wtforms.ValidationError):
+ password_length_validator(None, Field("1234567890123456789"))
+
+ current_app.config["CANAILLE"]["MIN_PASSWORD_LENGTH"] = 8
+ password_length_validator(None, Field("12345678"))
+
+ with pytest.raises(wtforms.ValidationError):
+ password_length_validator(None, Field("1234567"))
+ with pytest.raises(wtforms.ValidationError):
+ password_length_validator(None, Field("1"))
+
+ current_app.config["CANAILLE"]["MIN_PASSWORD_LENGTH"] = 0
+ password_length_validator(None, Field(""))
+
+ current_app.config["CANAILLE"]["MIN_PASSWORD_LENGTH"] = None
+ password_length_validator(None, Field(""))
+
+
+def test_password_strength_progress_bar(testclient, logged_user):
+ res = testclient.get("/profile/user/settings")
+ res = testclient.post(
+ "/profile/user/settings",
+ {
+ "csrf_token": res.form["csrf_token"].value,
+ "password1": "new_password",
+ },
+ headers={
+ "HX-Request": "true",
+ "HX-Trigger-Name": "password1",
+ },
+ )
+ res.mustcontain('data-percent="50"')
+
+
+def test_maximum_password_length_config(testclient):
+ class Field:
+ def __init__(self, data):
+ self.data = data
+
+ password_too_long_validator(None, Field("a" * 1000))
+ with pytest.raises(wtforms.ValidationError):
+ password_too_long_validator(None, Field("a" * 1001))
+
+ current_app.config["CANAILLE"]["MAX_PASSWORD_LENGTH"] = 500
+ password_too_long_validator(None, Field("a" * 500))
+ with pytest.raises(wtforms.ValidationError):
+ password_too_long_validator(None, Field("a" * 501))
+
+ current_app.config["CANAILLE"]["MAX_PASSWORD_LENGTH"] = None
+ password_too_long_validator(None, Field("a" * 4096))
+ with pytest.raises(wtforms.ValidationError):
+ password_too_long_validator(None, Field("a" * 4097))
+
+ current_app.config["CANAILLE"]["MAX_PASSWORD_LENGTH"] = 0
+ password_too_long_validator(None, Field("a" * 4096))
+ with pytest.raises(wtforms.ValidationError):
+ password_too_long_validator(None, Field("a" * 4097))
+
+ current_app.config["CANAILLE"]["MAX_PASSWORD_LENGTH"] = 5000
+ password_too_long_validator(None, Field("a" * 4096))
+ with pytest.raises(wtforms.ValidationError):
+ password_too_long_validator(None, Field("a" * 4097))
diff --git a/tests/core/test_profile_settings.py b/tests/core/test_profile_settings.py
index d106b026..aea157bd 100644
--- a/tests/core/test_profile_settings.py
+++ b/tests/core/test_profile_settings.py
@@ -2,6 +2,7 @@ import datetime
import logging
from unittest import mock
+from flask import current_app
from flask import g
from canaille.app import models
@@ -97,6 +98,64 @@ def test_profile_settings_edition_dynamic_validation(testclient, logged_admin):
res.mustcontain("Field must be at least 8 characters long.")
+def test_profile_settings_minimum_password_length_validation(testclient, logged_user):
+ """Tests minimum length of password defined in configuration."""
+
+ def with_different_values(password, length):
+ current_app.config["CANAILLE"]["MIN_PASSWORD_LENGTH"] = length
+ 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(f"Field must be at least {length} characters long.")
+
+ with_different_values("short", 8)
+ with_different_values("aa", 3)
+ with_different_values("1234567890123456789", 20)
+
+
+def test_profile_settings_too_long_password(testclient, logged_user):
+ """Tests maximum length of password."""
+
+ def with_different_values(password, length, message):
+ current_app.config["CANAILLE"]["MAX_PASSWORD_LENGTH"] = length
+ 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(
+ "a" * 1001, 1000, "Field cannot be longer than 1000 characters."
+ )
+ with_different_values("a1!A" * 250, 1000, 'data-percent="25"')
+ with_different_values("a" * 501, 500, "Field cannot be longer than 500 characters.")
+ with_different_values("a1!A" * 125, 500, 'data-percent="25"')
+ with_different_values("a" * 4097, 0, "Field cannot be longer than 4096 characters.")
+ with_different_values(
+ "a" * 4097, None, "Field cannot be longer than 4096 characters."
+ )
+ with_different_values(
+ "a" * 4097, 5000, "Field cannot be longer than 4096 characters."
+ )
+
+
def test_edition_without_groups(
testclient,
logged_user,