Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions deployment/community/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions deployment/enterprise/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 11 additions & 3 deletions server/mergin/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion server/mergin/auth/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
27 changes: 10 additions & 17 deletions server/mergin/auth/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions server/mergin/auth/listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -29,6 +30,7 @@
"inactive_since",
"is_admin",
"locked_until",
"auth_version",
}
)

Expand Down
10 changes: 8 additions & 2 deletions server/mergin/auth/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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"""
Expand All @@ -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]:
Expand Down
4 changes: 4 additions & 0 deletions server/mergin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
115 changes: 115 additions & 0 deletions server/mergin/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
28 changes: 28 additions & 0 deletions server/migrations/community/d8a2f6c1e5b4_add_user_auth_version.py
Original file line number Diff line number Diff line change
@@ -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")
Loading