diff --git a/max/auth/__init__.py b/max/auth/__init__.py index b041bca..af11b5e 100644 --- a/max/auth/__init__.py +++ b/max/auth/__init__.py @@ -1,10 +1,7 @@ -from .auth import ( # noqa - change_password, - check_password, - hash_password, - validate_password, -) +from .hashing import check_password, hash_password # noqa from .middlewares import AuthMiddleware, no_authentication_required # noqa +from .password_policy import validate_password # noqa +from .passwords import change_password # noqa from .routes import init_routes diff --git a/max/auth/auth.py b/max/auth/auth.py deleted file mode 100644 index 1c49098..0000000 --- a/max/auth/auth.py +++ /dev/null @@ -1,112 +0,0 @@ -import bcrypt -from flask import current_app, session - -from max.crypto import 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}" -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): - # "$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)) - - raise RuntimeError("The hash has an unknown scheme") diff --git a/max/auth/hashing.py b/max/auth/hashing.py new file mode 100644 index 0000000..ec3b922 --- /dev/null +++ b/max/auth/hashing.py @@ -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") diff --git a/max/auth/login.py b/max/auth/login.py new file mode 100644 index 0000000..9972f8e --- /dev/null +++ b/max/auth/login.py @@ -0,0 +1,44 @@ +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): + 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() diff --git a/max/auth/middlewares.py b/max/auth/middlewares.py index a4e1d85..8c06009 100644 --- a/max/auth/middlewares.py +++ b/max/auth/middlewares.py @@ -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 diff --git a/max/auth/password_policy.py b/max/auth/password_policy.py new file mode 100644 index 0000000..84bfc21 --- /dev/null +++ b/max/auth/password_policy.py @@ -0,0 +1,13 @@ +from .hashing import is_password_too_long + +PASSWORD_TOO_LONG_MESSAGE = ( + "The password must be at most 72 bytes long " + "(72 characters, fewer with umlauts or special characters)" +) + + +def validate_password(password: str): + """Returns the (untranslated) error message, or None if the password is valid""" + if is_password_too_long(password): + return PASSWORD_TOO_LONG_MESSAGE + return None diff --git a/max/auth/passwords.py b/max/auth/passwords.py new file mode 100644 index 0000000..18bb9b9 --- /dev/null +++ b/max/auth/passwords.py @@ -0,0 +1,33 @@ +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 +from .password_policy import validate_password + + +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) diff --git a/max/auth/routes.py b/max/auth/routes.py index 0bd101f..3f56296 100644 --- a/max/auth/routes.py +++ b/max/auth/routes.py @@ -5,7 +5,7 @@ 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=.