From a4bd03f71c99add078e4d713859519619320ae1c Mon Sep 17 00:00:00 2001 From: sebastien yaal Date: Mon, 28 Oct 2024 21:17:47 +0000 Subject: [PATCH] feat: password strength indicator --- CHANGES.rst | 8 ++ canaille/__init__.py | 3 + canaille/app/forms.py | 38 ++++++++ canaille/core/configuration.py | 18 ++++ canaille/core/endpoints/account.py | 7 +- canaille/core/endpoints/forms.py | 7 +- canaille/static/css/base.css | 6 ++ canaille/templates/macro/form.html | 12 ++- canaille/translations/messages.pot | 142 +++++++++++++++------------- poetry.lock | 115 +++++++++++++++++----- pyproject.toml | 5 +- tests/app/test_forms.py | 74 +++++++++++++++ tests/core/test_profile_settings.py | 59 ++++++++++++ 13 files changed, 399 insertions(+), 95 deletions(-) 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 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 = "logging.handlers.WatchedFileHandler" [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.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_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 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 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 @@ def available_language_choices(): _("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 @@ {% 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 @@ {% 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 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 __init__(self, data): 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 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,