diff --git a/deployment/community/.env.template b/deployment/community/.env.template index 152d970c..908c08d9 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -75,6 +75,8 @@ SECURITY_UNLOCK_SALT=fixme #WTF_CSRF_TIME_LIMIT=3600 * 24 # in seconds +#PERMANENT_SESSION_LIFETIME=3600 * 24 * 31 # in seconds, lifetime of web session cookie + # Mail configuration diff --git a/deployment/enterprise/.env.template b/deployment/enterprise/.env.template index 511a9756..27b24992 100644 --- a/deployment/enterprise/.env.template +++ b/deployment/enterprise/.env.template @@ -80,6 +80,8 @@ SECURITY_UNLOCK_SALT=fixme #WTF_CSRF_TIME_LIMIT=3600 * 24 # in seconds +#PERMANENT_SESSION_LIFETIME=3600 * 24 * 31 # in seconds, lifetime of web session cookie + # for flask mail diff --git a/server/mergin/app.py b/server/mergin/app.py index 146767a4..e1cc83d8 100644 --- a/server/mergin/app.py +++ b/server/mergin/app.py @@ -191,9 +191,12 @@ def create_app(public_keys: List[str] = None) -> Flask: # adjust login manager @login_manager.user_loader - def load_user(user_id): # pylint: disable=W0613,W0612 + def load_user(session_id): # pylint: disable=W0613,W0612 + user_id, *rest = session_id.split(":") + # legacy session cookies carry only the user id + auth_version = int(rest[0]) if rest else 0 user = User.query.get(user_id) - if user and user.active: + if user and user.active and user.auth_version == auth_version: return user @login_manager.header_loader @@ -209,7 +212,12 @@ def load_user_from_header(header_val): # pylint: disable=W0613,W0612 user = User.query.filter_by( id=data["user_id"], username=data["username"], email=data["email"] ).one_or_none() - if user and user.active: + # legacy tokens were issued without auth version + if ( + user + and user.active + and user.auth_version == data.get("auth_version", 0) + ): return user except (BadSignature, BadTimeSignature, KeyError): pass diff --git a/server/mergin/auth/app.py b/server/mergin/auth/app.py index 2e05f2a1..1fd45efc 100644 --- a/server/mergin/auth/app.py +++ b/server/mergin/auth/app.py @@ -4,13 +4,15 @@ import functools import logging -from typing import Optional +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple from blinker import signal from flask import current_app, render_template, Flask from flask_login import current_user from itsdangerous import URLSafeTimedSerializer, BadData from sqlalchemy import func +from .bearer import encode_token from .commands import add_commands from .config import Configuration from .listeners import register_listeners @@ -205,6 +207,24 @@ def generate_password_reset_token(app: Flask, user: User) -> str: ) +def generate_bearer_token( + app: Flask, user: User, expiration: int +) -> Tuple[str, datetime]: + """Sign a bearer token valid for expiration seconds, bound to the current user auth version.""" + expire = datetime.now(timezone.utc) + timedelta(seconds=expiration) + token_data = { + "user_id": user.id, + "username": user.username, + "email": user.email, + "expire": str(expire), + "auth_version": user.auth_version, + } + token = encode_token( + app.config["SECRET_KEY"], app.config["SECURITY_BEARER_SALT"], token_data + ) + return token, expire + + def generate_unlock_token(app: Flask, user: User) -> str: """Sign a token binding the current lock episode (email + locked_until) to the user.""" serializer = URLSafeTimedSerializer(app.config["SECRET_KEY"]) diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index 215ccf24..bbc3d44e 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -3,12 +3,10 @@ # SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial import os -import pytz -from datetime import datetime, timedelta from connexion import NoContent from sqlalchemy import func, desc, asc from sqlalchemy.sql.operators import is_ -from flask import request, current_app, jsonify, abort, render_template +from flask import request, current_app, jsonify, abort, render_template, session from flask_login import login_user, logout_user, current_user from flask_wtf.csrf import generate_csrf @@ -23,9 +21,9 @@ user_created, user_account_closed, edit_profile_enabled, + generate_bearer_token, CANNOT_EDIT_PROFILE_MSG, ) -from .bearer import encode_token from .models import User, LoginHistory from .schemas import UserSchema, UserSearchSchema, UserProfileSchema, UserInfoSchema from .forms import ( @@ -145,19 +143,8 @@ def login_public(): # noqa: E501 if form.validate(): user = authenticate(form.login.data, form.password.data) if user and user.active: - expire = datetime.now(pytz.utc) + timedelta( - seconds=current_app.config["BEARER_TOKEN_EXPIRATION"] - ) - token_data = { - "user_id": user.id, - "username": user.username, - "email": user.email, - "expire": str(expire), - } - token = encode_token( - current_app.config["SECRET_KEY"], - current_app.config["SECURITY_BEARER_SALT"], - token_data, + token, expire = generate_bearer_token( + current_app, user, current_app.config["BEARER_TOKEN_EXPIRATION"] ) data = user_profile(user) @@ -340,8 +327,13 @@ def change_password(): # pylint: disable=W0613,W0612 form.old_password.errors.append("The old password is incorrect") return jsonify(form.errors), 400 current_user.assign_password(form.password.data) + current_user.revoke_sessions() db.session.add(current_user) db.session.commit() + # keep the session that made the change logged in (bearer token callers need to log in again) + # "_user_id" is flask-login session key, present only for cookie-based authentication + if "_user_id" in session: + login_user(current_user._get_current_object()) emit( AuthEventType.USER_PASSWORD_CHANGED, **actor_context(), @@ -445,6 +437,7 @@ def confirm_new_password(token): # pylint: disable=W0613,W0612 form = UserPasswordForm.from_json(request.json) if form.validate(): user.assign_password(form.password.data) + user.revoke_sessions() user.reset_lockout() db.session.add(user) db.session.commit() diff --git a/server/mergin/auth/listeners.py b/server/mergin/auth/listeners.py index 14a5093a..db52edcf 100644 --- a/server/mergin/auth/listeners.py +++ b/server/mergin/auth/listeners.py @@ -18,6 +18,7 @@ # - lifecycle state fields covered by dedicated events (active, inactive_since) # - is_admin covered by the dedicated user.admin_panel_access.changed event # - locked_until covered by the dedicated user.locked / user.unlocked events +# - auth_version bumped only with password change, covered by user.password_* events # frozenset prevents accidental mutation of module-level state. _EXCLUDED_FROM_USER_UPDATED = frozenset( { @@ -29,6 +30,7 @@ "inactive_since", "is_admin", "locked_until", + "auth_version", } ) diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 69a3dddb..d8f7462e 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -54,6 +54,8 @@ class User(db.Model): locked_until = db.Column(db.DateTime(), nullable=True) # nonce of the only valid password reset token, cleared on any password change password_reset_nonce = db.Column(db.String(64), nullable=True) + # bumped to invalidate all issued bearer tokens and session cookies + auth_version = db.Column(db.Integer, nullable=False, default=0, server_default="0") receive_notifications = db.Column( db.Boolean, default=True, nullable=False, index=True ) @@ -152,6 +154,10 @@ def rotate_password_reset_nonce(self) -> str: self.password_reset_nonce = secrets.token_urlsafe(32) return self.password_reset_nonce + def revoke_sessions(self) -> None: + """Invalidate all bearer tokens and session cookies issued so far.""" + self.auth_version += 1 + @property def is_authenticated(self): """For Flask-Login""" @@ -168,8 +174,8 @@ def is_anonymous(self): return False def get_id(self): - """For Flask-Login ... must return unicode user ID""" - return str(self.id) + """For Flask-Login, stored in session cookie ... user ID bound to auth version""" + return f"{self.id}:{self.auth_version}" @staticmethod def search(like: str, limit: int = 10, only_active: bool = True) -> List[User]: diff --git a/server/mergin/config.py b/server/mergin/config.py index f2041410..31ffba64 100644 --- a/server/mergin/config.py +++ b/server/mergin/config.py @@ -20,6 +20,10 @@ class Configuration(object): # expiration time in seconds WTF_CSRF_TIME_LIMIT = config("WTF_CSRF_TIME_LIMIT", default=3600 * 24, cast=int) WTF_CSRF_ENABLED = config("WTF_CSRF_ENABLED", default=True, cast=bool) + # how long a web session cookie is accepted, in seconds + PERMANENT_SESSION_LIFETIME = config( + "PERMANENT_SESSION_LIFETIME", default=3600 * 24 * 31, cast=int + ) # Mergin DB related SQLALCHEMY_TRACK_MODIFICATIONS = config( diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index 47cebd6a..69e3d7d4 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -455,6 +455,8 @@ def test_bcrypt_lazy_rehash(app): db.session.refresh(user) hash_rounds = int(user.passwd.split("$")[2]) assert hash_rounds == 5 + # rehash is not a password change, existing sessions stay valid + assert user.auth_version == 0 def test_deactivated_user_session_rejected(client): @@ -1564,3 +1566,116 @@ def test_bearer_token_expiration(app): "/v1/user/profile", headers={**json_headers, "Authorization": f"Bearer {token}"} ) assert resp.status_code == 401 + + +def _api_token(client, username, password): + resp = client.post( + "/v1/auth/login", + data=json.dumps({"login": username, "password": password}), + headers=json_headers, + ) + assert resp.status_code == 200 + return resp.json["session"]["token"] + + +def _profile_status(client, token=None): + headers = {**json_headers} + if token: + headers["Authorization"] = f"Bearer {token}" + # fresh app context, so flask-login does not reuse user cached in g by previous request + with client.application.app_context(): + return client.get("/v1/user/profile", headers=headers).status_code + + +def test_password_change_revokes_sessions(app): + """Password change invalidates other sessions and bearer tokens, but not the current session""" + user = add_user("revokeuser", "oldpassword") + current_session = app.test_client() + other_session = app.test_client() + api_client = app.test_client() + login(current_session, user.username, "oldpassword") + login(other_session, user.username, "oldpassword") + token = _api_token(api_client, user.username, "oldpassword") + assert _profile_status(other_session) == 200 + assert _profile_status(api_client, token) == 200 + + resp = current_session.post( + url_for("/.mergin_auth_controller_change_password"), + json={ + "old_password": "oldpassword", + "password": "changed#1", + "confirm": "changed#1", + }, + ) + assert resp.status_code == 200 + assert user.auth_version == 1 + assert _profile_status(current_session) == 200 + assert _profile_status(other_session) == 401 + assert _profile_status(api_client, token) == 401 + + # login with new credentials works + new_token = _api_token(api_client, user.username, "changed#1") + assert _profile_status(api_client, new_token) == 200 + login(other_session, user.username, "changed#1") + assert _profile_status(other_session) == 200 + + +def test_password_change_with_bearer_token(app): + """Password change through bearer token does not create session cookie, caller needs to log in again""" + user = add_user("revokeuser", "oldpassword") + api_client = app.test_client() + token = _api_token(api_client, user.username, "oldpassword") + resp = api_client.post( + url_for("/.mergin_auth_controller_change_password"), + json={ + "old_password": "oldpassword", + "password": "changed#1", + "confirm": "changed#1", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + assert "Set-Cookie" not in resp.headers + assert _profile_status(api_client, token) == 401 + + +def test_password_reset_revokes_sessions(app, client): + """Password reset invalidates all sessions and bearer tokens""" + user = add_user("revokeuser", "oldpassword") + web_session = app.test_client() + login(web_session, user.username, "oldpassword") + token = _api_token(client, user.username, "oldpassword") + + reset_token = generate_password_reset_token(app, user) + db.session.commit() + assert _confirm_new_password(client, reset_token).status_code == 200 + assert user.auth_version == 1 + assert _profile_status(web_session) == 401 + assert _profile_status(client, token) == 401 + + +def test_legacy_sessions_accepted_until_revoked(app): + """Session cookies and bearer tokens issued before auth version was introduced are valid until password change""" + user = add_user("legacyuser", "password") + web_session = app.test_client() + with web_session.session_transaction() as sess: + sess["_user_id"] = str(user.id) + sess["_fresh"] = True + legacy_token = encode_token( + app.config["SECRET_KEY"], + app.config["SECURITY_BEARER_SALT"], + { + "user_id": user.id, + "username": user.username, + "email": user.email, + "expire": str(datetime.now(timezone.utc) + timedelta(hours=1)), + }, + ) + api_client = app.test_client() + assert _profile_status(web_session) == 200 + assert _profile_status(api_client, legacy_token) == 200 + + user.revoke_sessions() + db.session.commit() + assert _profile_status(web_session) == 401 + assert _profile_status(api_client, legacy_token) == 401 diff --git a/server/migrations/community/d8a2f6c1e5b4_add_user_auth_version.py b/server/migrations/community/d8a2f6c1e5b4_add_user_auth_version.py new file mode 100644 index 00000000..6645f9a9 --- /dev/null +++ b/server/migrations/community/d8a2f6c1e5b4_add_user_auth_version.py @@ -0,0 +1,28 @@ +"""Add auth_version to user table + +Revision ID: d8a2f6c1e5b4 +Revises: c4e7b1d9a2f3 +Create Date: 2026-10-06 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "d8a2f6c1e5b4" +down_revision = "c4e7b1d9a2f3" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "user", + sa.Column("auth_version", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade(): + op.drop_column("user", "auth_version")