Compare commits

...
11 Commits
Author SHA1 Message Date
finnandClaude Opus 5.5 c1843b3e49 Note that fail2ban depends on the order of the audit fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:42:53 +00:00
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 330 additions and 205 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 && \ echo "$USERNAME ALL=(root) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME && \
chmod 0440 /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 # Project venv, owned by the dev user so pip works without sudo
ENV VIRTUAL_ENV=/opt/venv ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV && chown -R $USERNAME:$USERNAME $VIRTUAL_ENV 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 USER max
ENTRYPOINT ["./entrypoint.sh"] 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: 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-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; 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 ## Dependencies
Direct dependencies are listed in `requirements.in` (production) and 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_auth(app)
init_routes(app) init_routes(app)
init_security_headers(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 return app
+21
View File
@@ -0,0 +1,21 @@
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.
"""
# The order of the first fields is fixed: fail2ban matches
# 'audit event=… actor_user_id=… actor_email=… ip="<HOST>"' (filter max.conf on the VPS).
# Change the filter when changing the order or the names.
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 from .hashing import MAX_PASSWORD_BYTES, check_password, hash_password # noqa
change_password, from .middlewares import AuthMiddleware, no_authentication_required # noqa
check_password, from .password_policy import ( # noqa
hash_password, MIN_PASSWORD_LENGTH,
PASSWORD_MESSAGE_ARGUMENTS,
validate_password, validate_password,
) )
from .middlewares import AuthMiddleware, no_authentication_required # 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,
)
)
-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.middlewares import BaseMiddleware
from max.views import redirect from max.views import redirect
from .auth import logout from .login import logout
DEBUG_DEFAULT_LOGIN = False 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 flask import flash, session, url_for
from max.audit import audit
from max.csrf import no_csrf from max.csrf import no_csrf
from max.permissions import AllowAnyAccess from max.permissions import AllowAnyAccess
from max.translations import t from max.translations import t
from max.views import BaseTemplateGetView, BaseView from max.views import BaseTemplateGetView, BaseView
from .auth import login, logout from .login import login, logout
from .middlewares import no_authentication_required from .middlewares import no_authentication_required
# Messages for the login page (text, category), selected by ?msg=<key>. # 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") email = self.request.form.get("email")
password = self.request.form.get("password") password = self.request.form.get("password")
error = login(email, password) error, user = login(email, password)
if error: if error:
self.app.logger.info( audit("login.failed", attempted_email=email)
"Failed login attempt from %s", self.request.real_remote_addr
)
self.context["message"] = error self.context["message"] = error
self.context["message_category"] = "error" self.context["message_category"] = "error"
self.context["email"] = email self.context["email"] = email
else: else:
audit("login", actor=user)
flash(t("Login was successful!"), category="success") flash(t("Login was successful!"), category="success")
return self.redirect("/") return self.redirect("/")
+8 -2
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):
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")
+6 -6
View File
@@ -33,12 +33,12 @@ class CsrfMiddleware(BaseMiddleware):
# Stateless check, so it also protects routes without a session (login) # Stateless check, so it also protects routes without a session (login)
if not self.is_origin_allowed(): if not self.is_origin_allowed():
self.app.logger.debug( self.app.logger.debug(
'Blocked cross-origin request %s %s origin="%s" sec_fetch_site="%s" from %s', "Blocked cross-origin request",
request.method, method=request.method,
request.path, path=request.path,
request.headers.get("Origin"), origin=request.headers.get("Origin"),
request.headers.get("Sec-Fetch-Site"), sec_fetch_site=request.headers.get("Sec-Fetch-Site"),
get_real_remote_addr(self.app), ip=get_real_remote_addr(self.app),
) )
return "Cross-origin request blocked", 403 return "Cross-origin request blocked", 403
+1 -1
View File
@@ -92,7 +92,7 @@ class Database:
def add_query(self, query, args=None): def add_query(self, query, args=None):
if args is None: if args is None:
args = [] 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 self.query_count += 1
def collect_query_count(self): def collect_query_count(self):
+3 -1
View File
@@ -66,12 +66,13 @@ def update_user(cur, id, **fields):
def create_user( def create_user(
cur, email, passwordhash, enabled, is_admin, can_use_different_alias_domain, note 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) email_id = create_email(cur, email)
cur.execute( cur.execute(
"""\ """\
INSERT INTO users INSERT INTO users
(email_id, passwordhash, enabled, is_admin, can_use_different_alias_domain, note) (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, email_id,
passwordhash, passwordhash,
@@ -81,6 +82,7 @@ def create_user(
note, note,
], ],
) )
return cur.fetchone()[0]
@with_cursor @with_cursor
+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.
""" """
+60 -16
View File
@@ -1,3 +1,4 @@
import json
import logging import logging
import sys import sys
import time import time
@@ -9,6 +10,46 @@ from max.db import database
from max.middlewares import get_real_remote_addr from max.middlewares import get_real_remote_addr
from max.translations import TranslationHandler 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): class NoDevServerRequestsFilter(logging.Filter):
"""Drops the request lines of the Werkzeug dev server, the app logs requests itself""" """Drops the request lines of the Werkzeug dev server, the app logs requests itself"""
@@ -22,7 +63,10 @@ def init_logging(app):
{ {
"version": 1, "version": 1,
"formatters": { "formatters": {
"default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"} "default": {
"()": FieldsFormatter,
"fmt": "[%(asctime)s] [%(levelname)s]: %(message)s",
}
}, },
"handlers": { "handlers": {
"stdout": { "stdout": {
@@ -36,6 +80,7 @@ def init_logging(app):
) )
logger = logging.getLogger(app.name) logger = logging.getLogger(app.name)
logger.setLevel(logging.DEBUG if app.debug else logging.INFO) logger.setLevel(logging.DEBUG if app.debug else logging.INFO)
app.logger = FieldsLogger(logger)
if app.debug: if app.debug:
logging.getLogger("werkzeug").addFilter(NoDevServerRequestsFilter()) logging.getLogger("werkzeug").addFilter(NoDevServerRequestsFilter())
@@ -59,21 +104,20 @@ def init_request_logging(app):
# so the response may be None # so the response may be None
response = g.get("response") response = g.get("response")
start = g.get("request_start") 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( app.logger.info(
'method=%s url=%s ip=%s user_agent="%s" referrer="%s" db_queries=%s ' "request",
+ "status=%s content_length=%s user_id=%s session=%s lang=%s time_ms=%s", method=request.method,
request.method, url=request.url,
request.url, ip=get_real_remote_addr(app),
get_real_remote_addr(app), user_agent=request.user_agent.string,
request.user_agent, referrer=request.referrer,
request.referrer, db_queries=database.collect_query_count(),
database.collect_query_count(), status=response.status_code if response is not None else 500,
response.status_code if response is not None else 500, content_length=response.content_length if response is not None else None,
response.content_length if response is not None else None, user_id=session.get("user_id"),
session.get("user_id"), session=session.get_log_id(),
session.get_log_id(), lang=TranslationHandler.get_language(),
TranslationHandler.get_language(), time_ms=time_in_ms,
time_in_ms,
) )
+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",
+14 -2
View File
@@ -1,6 +1,12 @@
from flask import flash 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.permissions import AllowAdminOrSelf
from max.translations import t from max.translations import t
@@ -38,6 +44,11 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe
flash(t("The current password must be given"), category="error") flash(t("The current password must be given"), category="error")
return return
if not check_password(current_password, self.user.passwordhash): 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") flash(t("The current password is wrong"), category="error")
return return
@@ -47,9 +58,10 @@ 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)
audit("user.change_password", actor=self.auth_user, target=self.user)
flash(t("Password changed successfully!"), category="success") flash(t("Password changed successfully!"), category="success")
return self.redirect() return self.redirect()
+13 -3
View File
@@ -1,6 +1,7 @@
from flask import flash, url_for 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.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 +51,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
@@ -96,7 +97,7 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
passwordhash = hash_password(self.password) passwordhash = hash_password(self.password)
try: try:
create_user( user_id = create_user(
full_email, full_email,
passwordhash, passwordhash,
self.enabled, self.enabled,
@@ -114,6 +115,15 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
flash(t("Email already exists.")) flash(t("Email already exists."))
return 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( flash(
t("Creation of {email} was successful!", email=full_email), t("Creation of {email} was successful!", email=full_email),
category="success", category="success",
+2
View File
@@ -1,5 +1,6 @@
from flask import flash, session, url_for from flask import flash, session, url_for
from max.audit import audit
from max.db import delete_user, is_last_enabled_admin from max.db import delete_user, is_last_enabled_admin
from max.permissions import AllowAdmin from max.permissions import AllowAdmin
from max.sessions import session_storage from max.sessions import session_storage
@@ -30,6 +31,7 @@ class UserDelete(AllowAdmin, BackToUsersMixin, FetchUserTemplateGetView):
delete_user(self.user) delete_user(self.user)
session_storage.remove_user_sessions(self.user.id) session_storage.remove_user_sessions(self.user.id)
audit("user.delete", actor=self.auth_user, target=self.user)
flash( flash(
t("{email} was deleted successfully", email=self.user.email), t("{email} was deleted successfully", email=self.user.email),
category="success", category="success",
+7
View File
@@ -1,5 +1,6 @@
from flask import flash from flask import flash
from max.audit import audit
from max.db import is_last_enabled_admin, update_user from max.db import is_last_enabled_admin, update_user
from max.permissions import AllowAdmin from max.permissions import AllowAdmin
from max.translations import t from max.translations import t
@@ -18,6 +19,12 @@ class UserToggleAdmin(AllowAdmin, FetchUserMixin, BackToUsersMixin, BaseView):
flash(message, category="error") flash(message, category="error")
else: else:
update_user(self.user.id, is_admin=(not self.user.is_admin)) 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() _, back_url = self.get_back_text_and_url()
return self.redirect(back_url) return self.redirect(back_url)
+7
View File
@@ -1,5 +1,6 @@
from flask import flash from flask import flash
from max.audit import audit
from max.db import is_last_enabled_admin, update_user from max.db import is_last_enabled_admin, update_user
from max.permissions import AllowAdmin from max.permissions import AllowAdmin
from max.sessions import session_storage from max.sessions import session_storage
@@ -21,6 +22,12 @@ class UserToggleEnabled(AllowAdmin, FetchUserMixin, BackToUsersMixin, BaseView):
) )
else: else:
update_user(self.user.id, enabled=(not self.user.enabled)) 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 if self.user.enabled: # the user was deactivated
session_storage.remove_user_sessions(self.user.id) session_storage.remove_user_sessions(self.user.id)
+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">
+4 -2
View File
@@ -50,7 +50,7 @@ class TranslationHandler:
@classmethod @classmethod
def set_language(cls, language): def set_language(cls, language):
if not cls.is_language_available(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" language = "en"
g.language = language g.language = language
@@ -68,7 +68,9 @@ class TranslationHandler:
return string.format(**interpolation) return string.format(**interpolation)
if string not in cls.translations[language]: 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 string.format(**interpolation)
return cls.translations[language][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, redirect as flask_redirect,
render_template, render_template,
request, request,
session,
) )
from werkzeug.exceptions import MethodNotAllowed from werkzeug.exceptions import MethodNotAllowed
from max.audit import audit
from max.exceptions import ImproperlyConfigured, PermissionDenied from max.exceptions import ImproperlyConfigured, PermissionDenied
@@ -78,11 +78,12 @@ class BaseView:
def check_access(self): def check_access(self):
if not self.can_access(): if not self.can_access():
self.app.logger.debug( audit(
"Access denied for user %s to %s (%s)", "access_denied",
session.get("user_id"), actor=getattr(self, "auth_user", None),
self.request.path, method=self.request.method,
self.__class__.__name__, path=self.request.path,
view=self.__class__.__name__,
) )
raise PermissionDenied() raise PermissionDenied()