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:
1 parent
4e71c809da
commit
80da3afc04
11 files changed
+58
-26
No files matched your search
@@ -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
|
||||
+12
-2
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+7
-1
@@ -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):
|
||||
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")
|
||||
|
||||
+2
-1
@@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
{% endif %}
|
||||
<div class="pure-control-group">
|
||||
<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 class="pure-control-group">
|
||||
<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 class="pure-controls">
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
|
||||
<div class="pure-control-group">
|
||||
<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 class="pure-control-group">
|
||||
<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 class="pure-controls checkbox">
|
||||
|
||||
Reference in new issue
Block a user