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
+59
-27
No files matched your search
@@ -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
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
+8
-2
@@ -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):
|
||||||
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:
|
if error:
|
||||||
raise click.ClickException(error)
|
raise click.ClickException(error.format(**PASSWORD_MESSAGE_ARGUMENTS))
|
||||||
|
|
||||||
|
|
||||||
@click.argument("email")
|
@click.argument("email")
|
||||||
|
|||||||
+2
-1
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in new issue
Block a user