Skip to content

Commit

Permalink
Merge branch 'password-strength' into 'main'
Browse files Browse the repository at this point in the history
Password strength

See merge request yaal/canaille!182
  • Loading branch information
azmeuk committed Oct 28, 2024
2 parents 6b5e3e1 + a4bd03f commit 05cc09a
Show file tree
Hide file tree
Showing 13 changed files with 399 additions and 95 deletions.
8 changes: 8 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions canaille/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from flask import session
from flask_wtf.csrf import CSRFProtect

from canaille.app.forms import password_strength_calculator

csrf = CSRFProtect()


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


Expand Down
38 changes: 38 additions & 0 deletions canaille/app/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions canaille/core/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
7 changes: 4 additions & 3 deletions canaille/core/endpoints/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions canaille/core/endpoints/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions canaille/static/css/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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;
}
}
12 changes: 11 additions & 1 deletion canaille/templates/macro/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
Expand Down Expand Up @@ -114,6 +114,16 @@
{% endfor %}
{% endif %}

{% if field.name == "password1" and field.data|password_strength and not field.errors %}
<div>
<p class="progress_bar">{% trans %}Password strength{% endtrans %}</p>
<div class="ui indicating progress" data-percent="{{ field.data|password_strength }}">
<div class="bar" style="width: {{ field.data|password_strength }}%;">
</div>
</div>
</div>
{% endif %}

{% if container and field_visible %}
</div>
{% endif %}
Expand Down
Loading

0 comments on commit 05cc09a

Please sign in to comment.