Compare commits
11
Commits
4cbe1435dc
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1843b3e49 | ||
|
|
a424d9b87f | ||
|
|
e88704017e | ||
|
|
fc1463c290 | ||
|
|
4a159006aa | ||
|
|
39eb7431e3 | ||
|
|
d33821dd1b | ||
|
|
80da3afc04 | ||
|
|
4e71c809da | ||
|
|
c72bc8c6af | ||
|
|
2e40c0ff47 |
No files matched your search
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
+4
-2
@@ -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
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user