From 80da3afc049f356b20faeb3820478cb15a836ddc Mon Sep 17 00:00:00 2001 From: Finn Stutzenstein Date: Fri, 23 Oct 2026 13:33:48 +0000 Subject: [PATCH] Require a minimum password length of 12 characters for new passwords Existing shorter passwords keep working and are still rehashed. The CLI accepts shorter passwords in debug mode for development. Co-Authored-By: Claude Opus 5.5 --- Makefile | 2 +- max/auth/__init__.py | 14 ++++++++++++-- max/auth/password_policy.py | 24 +++++++++++++++++++----- max/auth/passwords.py | 9 ++------- max/cli.py | 10 ++++++++-- max/exceptions.py | 3 ++- max/private_static/i18n/de.json | 3 ++- max/routes/user/change_password.py | 9 +++++++-- max/routes/user/create.py | 4 ++-- max/templates/user/change-password.html | 4 ++-- max/templates/user/create.html | 4 ++-- 11 files changed, 59 insertions(+), 27 deletions(-) diff --git a/Makefile b/Makefile index c1c67eb..80a6111 100644 --- a/Makefile +++ b/Makefile @@ -43,4 +43,4 @@ stop-prod: setup-prod-initial-login: docker-compose exec max flask add-domain finn.st - docker-compose exec max flask reset-admin admin@finn.st admin + docker-compose exec max flask reset-admin admin@finn.st admin-initial diff --git a/max/auth/__init__.py b/max/auth/__init__.py index af11b5e..bbe5494 100644 --- a/max/auth/__init__.py +++ b/max/auth/__init__.py @@ -1,9 +1,19 @@ -from .hashing import check_password, hash_password # noqa +from .hashing import MAX_PASSWORD_BYTES, check_password, hash_password # noqa from .middlewares import AuthMiddleware, no_authentication_required # noqa -from .password_policy import validate_password # noqa +from .password_policy import ( # noqa + MIN_PASSWORD_LENGTH, + PASSWORD_MESSAGE_ARGUMENTS, + validate_password, +) from .passwords import change_password # noqa from .routes import init_routes def init(app): init_routes(app) + app.context_processor( + lambda: dict( + min_password_length=MIN_PASSWORD_LENGTH, + max_password_bytes=MAX_PASSWORD_BYTES, + ) + ) diff --git a/max/auth/password_policy.py b/max/auth/password_policy.py index 84bfc21..f50b71a 100644 --- a/max/auth/password_policy.py +++ b/max/auth/password_policy.py @@ -1,13 +1,27 @@ -from .hashing import is_password_too_long +from .hashing import MAX_PASSWORD_BYTES, is_password_too_long +MIN_PASSWORD_LENGTH = 12 + +PASSWORD_TOO_SHORT_MESSAGE = ( + "The password must be at least {min_length} characters long" +) PASSWORD_TOO_LONG_MESSAGE = ( - "The password must be at most 72 bytes long " - "(72 characters, fewer with umlauts or special characters)" + "The password must be at most {max_bytes} bytes long " + "({max_bytes} characters, fewer with umlauts or special characters)" +) +# Fills the placeholders of the messages, e.g. t(error, **PASSWORD_MESSAGE_ARGUMENTS) +PASSWORD_MESSAGE_ARGUMENTS = dict( + min_length=MIN_PASSWORD_LENGTH, max_bytes=MAX_PASSWORD_BYTES ) -def validate_password(password: str): - """Returns the (untranslated) error message, or None if the password is valid""" +def validate_password(password: str, min_length=MIN_PASSWORD_LENGTH): + """ + Checks a new password. Returns the (untranslated) error message, or None if the + password is valid. Existing passwords are not checked against this policy. + """ + if len(password) < min_length: + return PASSWORD_TOO_SHORT_MESSAGE if is_password_too_long(password): return PASSWORD_TOO_LONG_MESSAGE return None diff --git a/max/auth/passwords.py b/max/auth/passwords.py index 18bb9b9..611b2dd 100644 --- a/max/auth/passwords.py +++ b/max/auth/passwords.py @@ -4,7 +4,6 @@ from max.db import update_user from max.sessions import session_storage from .hashing import hash_password, needs_rehash -from .password_policy import validate_password def change_password(user, password): @@ -23,11 +22,7 @@ def rehash_password_if_needed(user, password): """Replaces an outdated hash, the plain password is only known at the login""" if not needs_rehash(user.passwordhash): return - error = validate_password(password) - if error: - current_app.logger.warning( - 'Cannot rehash the password of user %s: "%s"', user.id, error - ) - return + # check_password accepted the password, so hash_password can hash it. The policy for + # new passwords does not apply here. update_user(user.id, passwordhash=hash_password(password)) current_app.logger.info("Rehashed the password of user %s", user.id) diff --git a/max/cli.py b/max/cli.py index 6e5fb67..a712313 100644 --- a/max/cli.py +++ b/max/cli.py @@ -1,6 +1,8 @@ import click +from flask import current_app from .auth import ( + PASSWORD_MESSAGE_ARGUMENTS, check_password as auth_check_password, hash_password, validate_password, @@ -22,9 +24,13 @@ def register_cli(app): def abort_if_password_invalid(password): - error = validate_password(password) + if current_app.debug: + # Short passwords like "admin" are fine for development + error = validate_password(password, min_length=0) + else: + error = validate_password(password) if error: - raise click.ClickException(error) + raise click.ClickException(error.format(**PASSWORD_MESSAGE_ARGUMENTS)) @click.argument("email") diff --git a/max/exceptions.py b/max/exceptions.py index 025fbd4..876739c 100644 --- a/max/exceptions.py +++ b/max/exceptions.py @@ -23,7 +23,8 @@ class PermissionDenied(MaxException): class InvalidPassword(MaxException): """ - Callers must check the password with validate_password before hashing it. + The password is too long for bcrypt. Callers must check new passwords with + validate_password before hashing them. """ diff --git a/max/private_static/i18n/de.json b/max/private_static/i18n/de.json index 145b238..323eed9 100644 --- a/max/private_static/i18n/de.json +++ b/max/private_static/i18n/de.json @@ -50,7 +50,8 @@ "Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!", "Both passwords must be given": "Beide Passwörter müssen angegeben werden", "Both passwords must be the same": "Beide Passwörter müssen übereinstimmen", - "The password must be at most 72 bytes long (72 characters, fewer with umlauts or special characters)": "Das Passwort darf höchstens 72 Bytes lang sein (72 Zeichen, mit Umlauten oder Sonderzeichen weniger)", + "The password must be at least {min_length} characters long": "Das Passwort muss mindestens {min_length} Zeichen lang sein", + "The password must be at most {max_bytes} bytes long ({max_bytes} characters, fewer with umlauts or special characters)": "Das Passwort darf höchstens {max_bytes} Bytes lang sein ({max_bytes} Zeichen, mit Umlauten oder Sonderzeichen weniger)", "Current password": "Aktuelles Passwort", "The current password must be given": "Das aktuelle Passwort muss angegeben werden", "The current password is wrong": "Das aktuelle Passwort ist falsch", diff --git a/max/routes/user/change_password.py b/max/routes/user/change_password.py index 6f58c55..20d76a8 100644 --- a/max/routes/user/change_password.py +++ b/max/routes/user/change_password.py @@ -1,6 +1,11 @@ from flask import flash -from max.auth import change_password, check_password, validate_password +from max.auth import ( + PASSWORD_MESSAGE_ARGUMENTS, + change_password, + check_password, + validate_password, +) from max.permissions import AllowAdminOrSelf from max.translations import t @@ -47,7 +52,7 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe password_error = validate_password(pw1) if password_error: - flash(t(password_error), category="error") + flash(t(password_error, **PASSWORD_MESSAGE_ARGUMENTS), category="error") return change_password(self.user, pw1) diff --git a/max/routes/user/create.py b/max/routes/user/create.py index f9a6b16..093d89b 100644 --- a/max/routes/user/create.py +++ b/max/routes/user/create.py @@ -1,6 +1,6 @@ from flask import flash, url_for -from max.auth import hash_password, validate_password +from max.auth import PASSWORD_MESSAGE_ARGUMENTS, hash_password, validate_password from max.db import EmailAlreadyExists, create_user, get_domains from max.permissions import AllowAdmin from max.translations import t @@ -50,7 +50,7 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView): else: password_error = validate_password(pw1) if password_error: - flash(t(password_error), category="error") + flash(t(password_error, **PASSWORD_MESSAGE_ARGUMENTS), category="error") error = True else: self.password = pw1 diff --git a/max/templates/user/change-password.html b/max/templates/user/change-password.html index e65432c..b5e9218 100644 --- a/max/templates/user/change-password.html +++ b/max/templates/user/change-password.html @@ -14,12 +14,12 @@ {% endif %}
- +
- +
diff --git a/max/templates/user/create.html b/max/templates/user/create.html index 013a2bc..b9cda31 100644 --- a/max/templates/user/create.html +++ b/max/templates/user/create.html @@ -19,12 +19,12 @@
- +
- +