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 && \
|
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
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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.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
|
||||||
|
|
||||||
|
|||||||
@@ -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 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("/")
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -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):
|
||||||
|
if current_app.debug:
|
||||||
|
# Short passwords like "admin" are fine for development
|
||||||
|
error = validate_password(password, min_length=0)
|
||||||
|
else:
|
||||||
error = validate_password(password)
|
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
|
||||||
)
|
)
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
"Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!",
|
"Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!",
|
||||||
"Both passwords must be given": "Beide Passwörter müssen angegeben werden",
|
"Both passwords must be given": "Beide Passwörter müssen angegeben werden",
|
||||||
"Both passwords must be the same": "Beide Passwörter müssen übereinstimmen",
|
"Both passwords must be the same": "Beide Passwörter müssen übereinstimmen",
|
||||||
"The password must be at most 72 bytes long (72 characters, fewer with umlauts or special characters)": "Das Passwort darf höchstens 72 Bytes lang sein (72 Zeichen, mit Umlauten oder Sonderzeichen weniger)",
|
"The password must be at least {min_length} characters long": "Das Passwort muss mindestens {min_length} Zeichen lang sein",
|
||||||
|
"The password must be at most {max_bytes} bytes long ({max_bytes} characters, fewer with umlauts or special characters)": "Das Passwort darf höchstens {max_bytes} Bytes lang sein ({max_bytes} Zeichen, mit Umlauten oder Sonderzeichen weniger)",
|
||||||
"Current password": "Aktuelles Passwort",
|
"Current password": "Aktuelles Passwort",
|
||||||
"The current password must be given": "Das aktuelle Passwort muss angegeben werden",
|
"The current password must be given": "Das aktuelle Passwort muss angegeben werden",
|
||||||
"The current password is wrong": "Das aktuelle Passwort ist falsch",
|
"The current password is wrong": "Das aktuelle Passwort ist falsch",
|
||||||
|
|||||||
@@ -1,6 +1,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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="password1">{{ t("Password") }}</label>
|
<label for="password1">{{ t("Password") }}</label>
|
||||||
<input id="password1" type="password" name="password1" autocomplete="new-password" maxlength="72" placeholder="{{ t("New password") }}">
|
<input id="password1" type="password" name="password1" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("New password") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="password2">{{ t("Re-enter password") }}</label>
|
<label for="password2">{{ t("Re-enter password") }}</label>
|
||||||
<input id="password2" type="password" name="password2" autocomplete="new-password" maxlength="72" placeholder="{{ t("New password") }}">
|
<input id="password2" type="password" name="password2" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("New password") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-controls">
|
<div class="pure-controls">
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="password1">{{ t("Password") }}</label>
|
<label for="password1">{{ t("Password") }}</label>
|
||||||
<input id="password1" type="password" name="password1" autocomplete="new-password" maxlength="72" placeholder="{{ t("Password") }}">
|
<input id="password1" type="password" name="password1" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("Password") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-control-group">
|
<div class="pure-control-group">
|
||||||
<label for="password2">{{ t("Re-enter password") }}</label>
|
<label for="password2">{{ t("Re-enter password") }}</label>
|
||||||
<input id="password2" type="password" name="password2" autocomplete="new-password" maxlength="72" placeholder="{{ t("Re-enter password") }}">
|
<input id="password2" type="password" name="password2" autocomplete="new-password" minlength="{{ min_password_length }}" maxlength="{{ max_password_bytes }}" placeholder="{{ t("Re-enter password") }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pure-controls checkbox">
|
<div class="pure-controls checkbox">
|
||||||
|
|||||||
+4
-2
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user