Compare commits

..
10 Commits
Author SHA1 Message Date
finnandClaude Opus 5.5 a424d9b87f bump version to 1.3.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:23:37 +00:00
finnandClaude Opus 5.5 e88704017e Raise the minimum password length to 20 characters
The initial admin password of setup-prod-initial-login is now
admin-initial-password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:23:37 +00:00
finnandClaude Opus 5.5 fc1463c290 Create /workspaces in the devcontainer for the dev user
Folders next to the project, like a clone of another repository, can be
created without root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:23:37 +00:00
finnandClaude Opus 5.5 4a159006aa Log structured fields through a logger adapter
app.logger.info("message", key=value) appends the keyword arguments as
JSON encoded fields, so callers no longer format the values themselves.
All log calls use fixed messages and put variable values into fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:58:44 +00:00
finnandClaude Opus 5.5 39eb7431e3 JSON encode the values in the request and cross-origin log lines
User agent, referrer, URL and Origin could fake additional fields with
quotes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:49 +00:00
finnandClaude Opus 5.5 d33821dd1b Log security relevant events to an audit log
Successful logins, user creation and deletion, password changes and the
enabled and admin toggles are logged with actor, target and IP. So are
failed logins, a wrong current password and denied access. Values are
JSON encoded, so client input cannot break the line or add fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:55:49 +00:00
finnandClaude Opus 5.5 80da3afc04 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>
2026-10-23 13:33:48 +00:00
finnandClaude Opus 5.5 4e71c809da Split the auth module into hashing, password policy, passwords and login
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:25:58 +00:00
finnandClaude Opus 5.5 c72bc8c6af Disable the gunicorn control socket in the Docker image
Gunicorn 25.1+ creates a control socket under $HOME/.gunicorn, which
fails in the read-only container without a home directory and logs
"Control server error". The socket is not used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:19:05 +00:00
finnandClaude Opus 5.5 2e40c0ff47 Remove the SSHA512 password hashes
All passwords are migrated to bcrypt, so {SSHA512} hashes are no longer
checked; an unknown scheme raises like before. The README documents that
Dovecot must support BLF-CRYPT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:18:50 +00:00
31 changed files with 326 additions and 204 deletions

No files matched your search

+4
View File
@@ -27,6 +27,10 @@ RUN groupadd --gid $USER_GID $USERNAME && \
echo "$USERNAME ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME && \
chmod 0440 /etc/sudoers.d/$USERNAME
# Owned by the dev user so folders can be created next to the project. Docker
# would otherwise create it as root when bind mounting /workspaces/max.
RUN mkdir /workspaces && chown $USERNAME:$USERNAME /workspaces
# Project venv, owned by the dev user so pip works without sudo
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV && chown -R $USERNAME:$USERNAME $VIRTUAL_ENV
+1 -1
View File
@@ -39,4 +39,4 @@ RUN python -m compileall -q max
USER max
ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"]
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "--no-control-socket", "max.app:app"]
+1 -1
View File
@@ -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-password
+5
View File
@@ -24,6 +24,11 @@ value the client sends. Max uses this address for audit logging. For nginx:
proxy_set_header X-Real-IP $remote_addr;
```
## Dovecot
Dovecot reads the password hashes written by Max from the `users` table. Max stores bcrypt
hashes as `{BLF-CRYPT}$2b$12$…`. Dovecot must therefore support `BLF-CRYPT` (check with `doveadm pw -l`).
## Dependencies
Direct dependencies are listed in `requirements.in` (production) and
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.3.0
+1 -1
View File
@@ -67,7 +67,7 @@ def main():
init_auth(app)
init_routes(app)
init_security_headers(app)
app.logger.info("Max initialized, version %s.", app.config["VERSION"])
app.logger.info("Max initialized", version=app.config["VERSION"])
return app
+18
View File
@@ -0,0 +1,18 @@
from flask import current_app, request
def audit(event, actor=None, target=None, **fields):
"""
Logs a security relevant event as one line with the message "audit". actor is the
acting user, None for anonymous requests. target is the affected user, if any.
"""
values = dict(
event=event,
actor_user_id=actor.id if actor is not None else None,
actor_email=actor.email if actor is not None else None,
ip=getattr(request, "real_remote_addr", None),
)
if target is not None:
values.update(target_user_id=target.id, target_email=target.email)
values.update(fields)
current_app.logger.info("audit", **values)
+12 -5
View File
@@ -1,12 +1,19 @@
from .auth import ( # noqa
change_password,
check_password,
hash_password,
from .hashing import MAX_PASSWORD_BYTES, check_password, hash_password # noqa
from .middlewares import AuthMiddleware, no_authentication_required # noqa
from .password_policy import ( # noqa
MIN_PASSWORD_LENGTH,
PASSWORD_MESSAGE_ARGUMENTS,
validate_password,
)
from .middlewares import AuthMiddleware, no_authentication_required # noqa
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,
)
)
-145
View File
@@ -1,145 +0,0 @@
import base64
import hashlib
import bcrypt
from flask import current_app, session
from max.crypto import constant_time_compare, force_bytes
from max.db import get_user_by_email, update_user
from max.exceptions import InvalidPassword
from max.sessions import session_storage
from max.translations import t
BLF_CRYPT_PREFIX = "{BLF-CRYPT}"
SSHA512_PREFIX = "{SSHA512}"
BCRYPT_ROUNDS = 12
# bcrypt only uses the first 72 bytes
MAX_PASSWORD_BYTES = 72
# Hash of a random, unknown password. It is checked for unknown users, so the
# response time does not tell whether an email exists.
DUMMY_HASH = (
BLF_CRYPT_PREFIX + "$2b$12$J27D9dEpQ5ff/I48HG05p.97oJGTO7UiaKGIbVuzV6nw0lo6gZ5wq"
)
LOGIN_FAILED_MESSAGE = "Email or password is wrong, or the account is deactivated"
PASSWORD_TOO_LONG_MESSAGE = (
"The password must be at most 72 bytes long "
"(72 characters, fewer with umlauts or special characters)"
)
def login(email, password):
if not isinstance(email, str) or not isinstance(password, str):
return t(LOGIN_FAILED_MESSAGE)
if not email or not password:
return t(LOGIN_FAILED_MESSAGE)
user = get_user_by_email(email)
if user is None:
check_password(password, DUMMY_HASH)
return t(LOGIN_FAILED_MESSAGE)
if not check_password(password, user.passwordhash):
return t(LOGIN_FAILED_MESSAGE)
if not user.enabled:
return t(LOGIN_FAILED_MESSAGE)
rehash_password_if_needed(user, password)
# Start a new session (new id, new CSRF token) against session fixation.
session.clear()
session["user_id"] = user.id
return None
def logout():
session.clear()
def change_password(user, password):
hash = hash_password(password)
update_user(user.id, passwordhash=hash)
if session.get("user_id") == user.id:
# Own password: keep this session (with a new id), end all others
session.regenerate()
session_storage.remove_user_sessions(user.id, except_id=session.id)
else:
# An admin changes the password of another user: end all of their sessions
session_storage.remove_user_sessions(user.id)
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
update_user(user.id, passwordhash=hash_password(password))
current_app.logger.info("Rehashed the password of user %s", user.id)
def validate_password(password: str):
"""Returns the (untranslated) error message, or None if the password is valid"""
if len(force_bytes(password)) > MAX_PASSWORD_BYTES:
return PASSWORD_TOO_LONG_MESSAGE
return None
def hash_password(password: str):
error = validate_password(password)
if error:
raise InvalidPassword(error)
hash = bcrypt.hashpw(force_bytes(password), bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
return BLF_CRYPT_PREFIX + hash.decode()
def needs_rehash(hash: str):
if not hash.startswith(BLF_CRYPT_PREFIX):
return True
# "$2b$12$…": the cost factor follows the version
rounds = int(hash[len(BLF_CRYPT_PREFIX) :].split("$")[2]) # noqa
return rounds < BCRYPT_ROUNDS
def check_password(password: str, hash: str):
if hash.startswith(BLF_CRYPT_PREFIX):
# Such a password cannot have been set, and bcrypt would raise
if validate_password(password):
return False
bcrypt_hash = hash[len(BLF_CRYPT_PREFIX) :] # noqa
return bcrypt.checkpw(force_bytes(password), force_bytes(bcrypt_hash))
if hash.startswith(SSHA512_PREFIX):
return check_ssha512_password(password, hash)
raise RuntimeError("The hash has an unknown scheme")
# {SSHA512} is outdated. Existing hashes are still checked and replaced at the
# next login. Remove this once no hash uses it anymore (see F-08).
def hash_ssha512_password(password: str, salt: bytes):
sha = hashlib.sha512()
sha.update(force_bytes(password))
sha.update(salt)
sha_and_salt = base64.b64encode(sha.digest() + salt)
return SSHA512_PREFIX + sha_and_salt.decode()
def get_ssha512_salt(hash: str):
sha_and_salt = base64.b64decode(hash[len(SSHA512_PREFIX) :]) # noqa
return sha_and_salt[64:]
def check_ssha512_password(password: str, hash: str):
salt = get_ssha512_salt(hash)
hash_from_password = hash_ssha512_password(password, salt)
return constant_time_compare(hash, hash_from_password)
+38
View File
@@ -0,0 +1,38 @@
import bcrypt
from max.crypto import force_bytes
from max.exceptions import InvalidPassword
BLF_CRYPT_PREFIX = "{BLF-CRYPT}"
BCRYPT_ROUNDS = 12
# bcrypt only uses the first 72 bytes
MAX_PASSWORD_BYTES = 72
def is_password_too_long(password: str):
return len(force_bytes(password)) > MAX_PASSWORD_BYTES
def hash_password(password: str):
if is_password_too_long(password):
raise InvalidPassword(f"The password is longer than {MAX_PASSWORD_BYTES} bytes")
hash = bcrypt.hashpw(force_bytes(password), bcrypt.gensalt(rounds=BCRYPT_ROUNDS))
return BLF_CRYPT_PREFIX + hash.decode()
def needs_rehash(hash: str):
# "$2b$12$…": the cost factor follows the version
rounds = int(hash[len(BLF_CRYPT_PREFIX) :].split("$")[2]) # noqa
return rounds < BCRYPT_ROUNDS
def check_password(password: str, hash: str):
if hash.startswith(BLF_CRYPT_PREFIX):
# Such a password cannot have been set, and bcrypt would raise
if is_password_too_long(password):
return False
bcrypt_hash = hash[len(BLF_CRYPT_PREFIX) :] # noqa
return bcrypt.checkpw(force_bytes(password), force_bytes(bcrypt_hash))
raise RuntimeError("The hash has an unknown scheme")
+45
View File
@@ -0,0 +1,45 @@
from flask import session
from max.db import get_user_by_email
from max.translations import t
from .hashing import BLF_CRYPT_PREFIX, check_password
from .passwords import rehash_password_if_needed
# Hash of a random, unknown password. It is checked for unknown users, so the
# response time does not tell whether an email exists.
DUMMY_HASH = (
BLF_CRYPT_PREFIX + "$2b$12$J27D9dEpQ5ff/I48HG05p.97oJGTO7UiaKGIbVuzV6nw0lo6gZ5wq"
)
LOGIN_FAILED_MESSAGE = "Email or password is wrong, or the account is deactivated"
def login(email, password):
"""Returns (error, None) if the login failed, else (None, user)"""
if not isinstance(email, str) or not isinstance(password, str):
return t(LOGIN_FAILED_MESSAGE), None
if not email or not password:
return t(LOGIN_FAILED_MESSAGE), None
user = get_user_by_email(email)
if user is None:
check_password(password, DUMMY_HASH)
return t(LOGIN_FAILED_MESSAGE), None
if not check_password(password, user.passwordhash):
return t(LOGIN_FAILED_MESSAGE), None
if not user.enabled:
return t(LOGIN_FAILED_MESSAGE), None
rehash_password_if_needed(user, password)
# Start a new session (new id, new CSRF token) against session fixation.
session.clear()
session["user_id"] = user.id
return None, user
def logout():
session.clear()
+1 -1
View File
@@ -7,7 +7,7 @@ from max.db import get_user_by_user_id
from max.middlewares import BaseMiddleware
from max.views import redirect
from .auth import logout
from .login import logout
DEBUG_DEFAULT_LOGIN = False
+27
View File
@@ -0,0 +1,27 @@
from .hashing import MAX_PASSWORD_BYTES, is_password_too_long
MIN_PASSWORD_LENGTH = 20
PASSWORD_TOO_SHORT_MESSAGE = (
"The password must be at least {min_length} characters long"
)
PASSWORD_TOO_LONG_MESSAGE = (
"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, 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
+28
View File
@@ -0,0 +1,28 @@
from flask import current_app, session
from max.db import update_user
from max.sessions import session_storage
from .hashing import hash_password, needs_rehash
def change_password(user, password):
hash = hash_password(password)
update_user(user.id, passwordhash=hash)
if session.get("user_id") == user.id:
# Own password: keep this session (with a new id), end all others
session.regenerate()
session_storage.remove_user_sessions(user.id, except_id=session.id)
else:
# An admin changes the password of another user: end all of their sessions
session_storage.remove_user_sessions(user.id)
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
# 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", user_id=user.id)
+5 -5
View File
@@ -1,11 +1,12 @@
from flask import flash, session, url_for
from max.audit import audit
from max.csrf import no_csrf
from max.permissions import AllowAnyAccess
from max.translations import t
from max.views import BaseTemplateGetView, BaseView
from .auth import login, logout
from .login import login, logout
from .middlewares import no_authentication_required
# Messages for the login page (text, category), selected by ?msg=<key>.
@@ -46,15 +47,14 @@ class Login(AllowAnyAccess, BaseTemplateGetView):
email = self.request.form.get("email")
password = self.request.form.get("password")
error = login(email, password)
error, user = login(email, password)
if error:
self.app.logger.info(
"Failed login attempt from %s", self.request.real_remote_addr
)
audit("login.failed", attempted_email=email)
self.context["message"] = error
self.context["message_category"] = "error"
self.context["email"] = email
else:
audit("login", actor=user)
flash(t("Login was successful!"), category="success")
return self.redirect("/")
+7 -1
View File
@@ -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")
+6 -6
View File
@@ -33,12 +33,12 @@ class CsrfMiddleware(BaseMiddleware):
# Stateless check, so it also protects routes without a session (login)
if not self.is_origin_allowed():
self.app.logger.debug(
'Blocked cross-origin request %s %s origin="%s" sec_fetch_site="%s" from %s',
request.method,
request.path,
request.headers.get("Origin"),
request.headers.get("Sec-Fetch-Site"),
get_real_remote_addr(self.app),
"Blocked cross-origin request",
method=request.method,
path=request.path,
origin=request.headers.get("Origin"),
sec_fetch_site=request.headers.get("Sec-Fetch-Site"),
ip=get_real_remote_addr(self.app),
)
return "Cross-origin request blocked", 403
+1 -1
View File
@@ -92,7 +92,7 @@ class Database:
def add_query(self, query, args=None):
if args is None:
args = []
self.app.logger.debug('query="%s", args="%s"', query, str(args))
self.app.logger.debug("query", query=query, args=args)
self.query_count += 1
def collect_query_count(self):
+3 -1
View File
@@ -66,12 +66,13 @@ def update_user(cur, id, **fields):
def create_user(
cur, email, passwordhash, enabled, is_admin, can_use_different_alias_domain, note
):
"""Returns the id of the new user"""
email_id = create_email(cur, email)
cur.execute(
"""\
INSERT INTO users
(email_id, passwordhash, enabled, is_admin, can_use_different_alias_domain, note)
VALUES (%s, %s, %s, %s, %s, %s)""",
VALUES (%s, %s, %s, %s, %s, %s) RETURNING id""",
[
email_id,
passwordhash,
@@ -81,6 +82,7 @@ def create_user(
note,
],
)
return cur.fetchone()[0]
@with_cursor
+2 -1
View File
@@ -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.
"""
+60 -16
View File
@@ -1,3 +1,4 @@
import json
import logging
import sys
import time
@@ -9,6 +10,46 @@ from max.db import database
from max.middlewares import get_real_remote_addr
from max.translations import TranslationHandler
# Keyword arguments handled by logging itself, all others become fields
LOGGING_KWARGS = frozenset(("exc_info", "stack_info", "stacklevel", "extra"))
def format_log_fields(**fields):
"""
Formats the fields as "key=value key=value". Values are JSON encoded, so values from
the client cannot break the line or add fields.
"""
return " ".join(
f"{key}={json.dumps(value, ensure_ascii=False, default=str)}"
for key, value in fields.items()
)
class FieldsLogger(logging.LoggerAdapter):
"""
Turns keyword arguments into fields: logger.info("message", key=value). The message
is a fixed text, everything variable goes into the fields.
"""
def process(self, msg, kwargs):
fields = {
key: kwargs.pop(key) for key in list(kwargs) if key not in LOGGING_KWARGS
}
if fields:
kwargs["extra"] = {**kwargs.get("extra", {}), "fields": fields}
return msg, kwargs
class FieldsFormatter(logging.Formatter):
"""Appends the fields of a FieldsLogger record to the line"""
def format(self, record):
line = super().format(record)
fields = getattr(record, "fields", None)
if not fields:
return line
return f"{line} {format_log_fields(**fields)}"
class NoDevServerRequestsFilter(logging.Filter):
"""Drops the request lines of the Werkzeug dev server, the app logs requests itself"""
@@ -22,7 +63,10 @@ def init_logging(app):
{
"version": 1,
"formatters": {
"default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"}
"default": {
"()": FieldsFormatter,
"fmt": "[%(asctime)s] [%(levelname)s]: %(message)s",
}
},
"handlers": {
"stdout": {
@@ -36,6 +80,7 @@ def init_logging(app):
)
logger = logging.getLogger(app.name)
logger.setLevel(logging.DEBUG if app.debug else logging.INFO)
app.logger = FieldsLogger(logger)
if app.debug:
logging.getLogger("werkzeug").addFilter(NoDevServerRequestsFilter())
@@ -59,21 +104,20 @@ def init_request_logging(app):
# so the response may be None
response = g.get("response")
start = g.get("request_start")
time_in_ms = f"{(time.time() - start) * 1000:.2f}" if start else None
time_in_ms = round((time.time() - start) * 1000, 2) if start else None
app.logger.info(
'method=%s url=%s ip=%s user_agent="%s" referrer="%s" db_queries=%s '
+ "status=%s content_length=%s user_id=%s session=%s lang=%s time_ms=%s",
request.method,
request.url,
get_real_remote_addr(app),
request.user_agent,
request.referrer,
database.collect_query_count(),
response.status_code if response is not None else 500,
response.content_length if response is not None else None,
session.get("user_id"),
session.get_log_id(),
TranslationHandler.get_language(),
time_in_ms,
"request",
method=request.method,
url=request.url,
ip=get_real_remote_addr(app),
user_agent=request.user_agent.string,
referrer=request.referrer,
db_queries=database.collect_query_count(),
status=response.status_code if response is not None else 500,
content_length=response.content_length if response is not None else None,
user_id=session.get("user_id"),
session=session.get_log_id(),
lang=TranslationHandler.get_language(),
time_ms=time_in_ms,
)
+2 -1
View File
@@ -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",
+14 -2
View File
@@ -1,6 +1,12 @@
from flask import flash
from max.auth import change_password, check_password, validate_password
from max.audit import audit
from max.auth import (
PASSWORD_MESSAGE_ARGUMENTS,
change_password,
check_password,
validate_password,
)
from max.permissions import AllowAdminOrSelf
from max.translations import t
@@ -38,6 +44,11 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe
flash(t("The current password must be given"), category="error")
return
if not check_password(current_password, self.user.passwordhash):
audit(
"user.change_password.wrong_current",
actor=self.auth_user,
target=self.user,
)
flash(t("The current password is wrong"), category="error")
return
@@ -47,9 +58,10 @@ 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)
audit("user.change_password", actor=self.auth_user, target=self.user)
flash(t("Password changed successfully!"), category="success")
return self.redirect()
+13 -3
View File
@@ -1,6 +1,7 @@
from flask import flash, url_for
from max.auth import hash_password, validate_password
from max.audit import audit
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 +51,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
@@ -96,7 +97,7 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
passwordhash = hash_password(self.password)
try:
create_user(
user_id = create_user(
full_email,
passwordhash,
self.enabled,
@@ -114,6 +115,15 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
flash(t("Email already exists."))
return
audit(
"user.create",
actor=self.auth_user,
target_user_id=user_id,
target_email=full_email,
enabled=self.enabled,
is_admin=self.is_admin,
can_use_different_alias_domain=self.can_use_different_alias_domain,
)
flash(
t("Creation of {email} was successful!", email=full_email),
category="success",
+2
View File
@@ -1,5 +1,6 @@
from flask import flash, session, url_for
from max.audit import audit
from max.db import delete_user, is_last_enabled_admin
from max.permissions import AllowAdmin
from max.sessions import session_storage
@@ -30,6 +31,7 @@ class UserDelete(AllowAdmin, BackToUsersMixin, FetchUserTemplateGetView):
delete_user(self.user)
session_storage.remove_user_sessions(self.user.id)
audit("user.delete", actor=self.auth_user, target=self.user)
flash(
t("{email} was deleted successfully", email=self.user.email),
category="success",
+7
View File
@@ -1,5 +1,6 @@
from flask import flash
from max.audit import audit
from max.db import is_last_enabled_admin, update_user
from max.permissions import AllowAdmin
from max.translations import t
@@ -18,6 +19,12 @@ class UserToggleAdmin(AllowAdmin, FetchUserMixin, BackToUsersMixin, BaseView):
flash(message, category="error")
else:
update_user(self.user.id, is_admin=(not self.user.is_admin))
audit(
"user.toggle_admin",
actor=self.auth_user,
target=self.user,
is_admin=not self.user.is_admin,
)
_, back_url = self.get_back_text_and_url()
return self.redirect(back_url)
+7
View File
@@ -1,5 +1,6 @@
from flask import flash
from max.audit import audit
from max.db import is_last_enabled_admin, update_user
from max.permissions import AllowAdmin
from max.sessions import session_storage
@@ -21,6 +22,12 @@ class UserToggleEnabled(AllowAdmin, FetchUserMixin, BackToUsersMixin, BaseView):
)
else:
update_user(self.user.id, enabled=(not self.user.enabled))
audit(
"user.toggle_enabled",
actor=self.auth_user,
target=self.user,
enabled=not self.user.enabled,
)
if self.user.enabled: # the user was deactivated
session_storage.remove_user_sessions(self.user.id)
+2 -2
View File
@@ -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">
+2 -2
View File
@@ -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">
+4 -2
View File
@@ -50,7 +50,7 @@ class TranslationHandler:
@classmethod
def set_language(cls, language):
if not cls.is_language_available(language):
cls.app.logger.info(f"Cannot find language {language}. Default to en.")
cls.app.logger.info("Unknown language, default to en", language=language)
language = "en"
g.language = language
@@ -68,7 +68,9 @@ class TranslationHandler:
return string.format(**interpolation)
if string not in cls.translations[language]:
cls.app.logger.warning(f'Unknown translation to {language} for "{string}"')
cls.app.logger.warning(
"Unknown translation", language=language, string=string
)
return string.format(**interpolation)
return cls.translations[language][string].format(**interpolation)
+7 -6
View File
@@ -5,10 +5,10 @@ from flask import (
redirect as flask_redirect,
render_template,
request,
session,
)
from werkzeug.exceptions import MethodNotAllowed
from max.audit import audit
from max.exceptions import ImproperlyConfigured, PermissionDenied
@@ -78,11 +78,12 @@ class BaseView:
def check_access(self):
if not self.can_access():
self.app.logger.debug(
"Access denied for user %s to %s (%s)",
session.get("user_id"),
self.request.path,
self.__class__.__name__,
audit(
"access_denied",
actor=getattr(self, "auth_user", None),
method=self.request.method,
path=self.request.path,
view=self.__class__.__name__,
)
raise PermissionDenied()