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 <noreply@anthropic.com>
This commit is contained in:
finnandClaude Opus 5.5 committed 2026-10-23 13:33:48 +00:00
1 parent 4e71c809da
commit 80da3afc04
11 files changed
+58 -26

No files matched your search

+1 -1
View File
@@ -43,4 +43,4 @@ stop-prod:
setup-prod-initial-login: setup-prod-initial-login:
docker-compose exec max flask add-domain finn.st 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
+12 -2
View File
@@ -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 .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 .passwords import change_password # noqa
from .routes import init_routes from .routes import init_routes
def init(app): def init(app):
init_routes(app) init_routes(app)
app.context_processor(
lambda: dict(
min_password_length=MIN_PASSWORD_LENGTH,
max_password_bytes=MAX_PASSWORD_BYTES,
)
)
+19 -5
View File
@@ -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 = ( PASSWORD_TOO_LONG_MESSAGE = (
"The password must be at most 72 bytes long " "The password must be at most {max_bytes} bytes long "
"(72 characters, fewer with umlauts or special characters)" "({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): def validate_password(password: str, min_length=MIN_PASSWORD_LENGTH):
"""Returns the (untranslated) error message, or None if the password is valid""" """
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): if is_password_too_long(password):
return PASSWORD_TOO_LONG_MESSAGE return PASSWORD_TOO_LONG_MESSAGE
return None return None
+2 -7
View File
@@ -4,7 +4,6 @@ from max.db import update_user
from max.sessions import session_storage from max.sessions import session_storage
from .hashing import hash_password, needs_rehash from .hashing import hash_password, needs_rehash
from .password_policy import validate_password
def change_password(user, 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""" """Replaces an outdated hash, the plain password is only known at the login"""
if not needs_rehash(user.passwordhash): if not needs_rehash(user.passwordhash):
return return
error = validate_password(password) # check_password accepted the password, so hash_password can hash it. The policy for
if error: # new passwords does not apply here.
current_app.logger.warning(
'Cannot rehash the password of user %s: "%s"', user.id, error
)
return
update_user(user.id, passwordhash=hash_password(password)) update_user(user.id, passwordhash=hash_password(password))
current_app.logger.info("Rehashed the password of user %s", user.id) current_app.logger.info("Rehashed the password of user %s", user.id)
+7 -1
View File
@@ -1,6 +1,8 @@
import click import click
from flask import current_app
from .auth import ( from .auth import (
PASSWORD_MESSAGE_ARGUMENTS,
check_password as auth_check_password, check_password as auth_check_password,
hash_password, hash_password,
validate_password, validate_password,
@@ -22,9 +24,13 @@ def register_cli(app):
def abort_if_password_invalid(password): def abort_if_password_invalid(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) error = validate_password(password)
if error: if error:
raise click.ClickException(error) raise click.ClickException(error.format(**PASSWORD_MESSAGE_ARGUMENTS))
@click.argument("email") @click.argument("email")
+2 -1
View File
@@ -23,7 +23,8 @@ class PermissionDenied(MaxException):
class InvalidPassword(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.
""" """
+2 -1
View File
@@ -50,7 +50,8 @@
"Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!", "Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!",
"Both passwords must be given": "Beide Passwörter müssen angegeben werden", "Both passwords must be given": "Beide Passwörter müssen angegeben werden",
"Both passwords must be the same": "Beide Passwörter müssen übereinstimmen", "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", "Current password": "Aktuelles Passwort",
"The current password must be given": "Das aktuelle Passwort muss angegeben werden", "The current password must be given": "Das aktuelle Passwort muss angegeben werden",
"The current password is wrong": "Das aktuelle Passwort ist falsch", "The current password is wrong": "Das aktuelle Passwort ist falsch",
+7 -2
View File
@@ -1,6 +1,11 @@
from flask import flash 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.permissions import AllowAdminOrSelf
from max.translations import t from max.translations import t
@@ -47,7 +52,7 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe
password_error = validate_password(pw1) password_error = validate_password(pw1)
if password_error: if password_error:
flash(t(password_error), category="error") flash(t(password_error, **PASSWORD_MESSAGE_ARGUMENTS), category="error")
return return
change_password(self.user, pw1) change_password(self.user, pw1)
+2 -2
View File
@@ -1,6 +1,6 @@
from flask import flash, url_for 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.db import EmailAlreadyExists, create_user, get_domains
from max.permissions import AllowAdmin from max.permissions import AllowAdmin
from max.translations import t from max.translations import t
@@ -50,7 +50,7 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
else: else:
password_error = validate_password(pw1) password_error = validate_password(pw1)
if password_error: if password_error:
flash(t(password_error), category="error") flash(t(password_error, **PASSWORD_MESSAGE_ARGUMENTS), category="error")
error = True error = True
else: else:
self.password = pw1 self.password = pw1
+2 -2
View File
@@ -14,12 +14,12 @@
{% endif %} {% endif %}
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password1">{{ t("Password") }}</label> <label for="password1">{{ t("Password") }}</label>
<input id="password1" type="password" name="password1" autocomplete="new-password" maxlength="72" placeholder="{{ t("New password") }}"> <input id="password1" type="password" name="password1" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("New password") }}">
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password2">{{ t("Re-enter password") }}</label> <label for="password2">{{ t("Re-enter password") }}</label>
<input id="password2" type="password" name="password2" autocomplete="new-password" maxlength="72" placeholder="{{ t("New password") }}"> <input id="password2" type="password" name="password2" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("New password") }}">
</div> </div>
<div class="pure-controls"> <div class="pure-controls">
+2 -2
View File
@@ -19,12 +19,12 @@
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password1">{{ t("Password") }}</label> <label for="password1">{{ t("Password") }}</label>
<input id="password1" type="password" name="password1" autocomplete="new-password" maxlength="72" placeholder="{{ t("Password") }}"> <input id="password1" type="password" name="password1" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("Password") }}">
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password2">{{ t("Re-enter password") }}</label> <label for="password2">{{ t("Re-enter password") }}</label>
<input id="password2" type="password" name="password2" autocomplete="new-password" maxlength="72" placeholder="{{ t("Re-enter password") }}"> <input id="password2" type="password" name="password2" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("Re-enter password") }}">
</div> </div>
<div class="pure-controls checkbox"> <div class="pure-controls checkbox">