155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
"""
|
|
app/tenancy/session_binding.py
|
|
------------------------------
|
|
MT-21: bind a browser session to the tenant that issued it.
|
|
|
|
Why this exists
|
|
---------------
|
|
Every tenant is served by the same Flask application with the same
|
|
``SECRET_KEY``. Before this module, nothing in a signed cookie identified
|
|
*which* tenant it was issued by, so a cookie minted on one tenant host
|
|
validated perfectly on another:
|
|
|
|
1. A legitimate user of tenant A logs in at a.jqc.app.
|
|
2. They copy their session cookie onto b.jqc.app (devtools / curl —
|
|
host-only cookie scoping stops the *browser* replaying it, but not a
|
|
person doing it by hand).
|
|
3. The tenancy middleware resolves Host → tenant B and binds tenant B's
|
|
database.
|
|
4. Flask-Login calls load_user('7') and gets **tenant B's user #7**.
|
|
|
|
The signature was always valid, because it is the same key. The identity was
|
|
never checked against the tenant. Result: authentication as an arbitrary user
|
|
in any tenant whose hostname the attacker knows.
|
|
|
|
Two layers close this, and both live here:
|
|
|
|
``SESSION_TENANT_KEY``
|
|
A ``_tenant_id`` marker written into the session at every point where the
|
|
session starts carrying identity (login, MFA challenge hand-off,
|
|
impersonation). ``enforce_session_tenant()`` clears the whole session when
|
|
that marker disagrees with the resolved tenant, so pre-authentication
|
|
session state (``mfa_pending_user_id`` and friends) cannot cross tenants
|
|
either.
|
|
|
|
``tag_user_id`` / ``parse_user_id``
|
|
Flask-Login derives BOTH the session ``_user_id`` and the "remember me"
|
|
cookie payload from ``User.get_id()``, and feeds both back through
|
|
``user_loader``. Tagging the id there — ``"<tenant_id>:<user_id>"`` — is
|
|
therefore a single seam that covers both cookies. Clearing the session
|
|
alone would not have been enough: a remember-me cookie repopulates the
|
|
session immediately afterwards.
|
|
|
|
Single-tenant behaviour is unchanged. When ``MULTI_TENANT_ENABLED`` is False,
|
|
or no tenant is bound (cron, CLI, exempt paths), ids stay bare integers and
|
|
the enforcement is a no-op.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from flask import current_app, g, session
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SESSION_TENANT_KEY = '_tenant_id'
|
|
|
|
|
|
def current_tenant_id():
|
|
"""Resolved tenant id for this request, or None in single-tenant mode."""
|
|
if not current_app.config.get('MULTI_TENANT_ENABLED', False):
|
|
return None
|
|
tenant = getattr(g, 'tenant', None)
|
|
return tenant.id if tenant is not None else None
|
|
|
|
|
|
def bind_session_tenant():
|
|
"""Stamp the current session with the tenant that issued it.
|
|
|
|
Called at every point that puts identity into the session. No-op in
|
|
single-tenant mode so existing sessions keep working untouched.
|
|
"""
|
|
tid = current_tenant_id()
|
|
if tid is not None:
|
|
session[SESSION_TENANT_KEY] = tid
|
|
|
|
|
|
def enforce_session_tenant():
|
|
"""Clear the session if it was issued by a different tenant.
|
|
|
|
Returns True when the session was cleared. Runs after tenant resolution,
|
|
from the tenancy middleware.
|
|
"""
|
|
tid = current_tenant_id()
|
|
if tid is None:
|
|
return False
|
|
|
|
bound = session.get(SESSION_TENANT_KEY)
|
|
|
|
if bound is None:
|
|
# An untagged session carrying identity predates this binding, or was
|
|
# lifted from somewhere else. Either way it cannot be trusted here.
|
|
if '_user_id' in session or 'mfa_pending_user_id' in session:
|
|
logger.warning('TENANCY | session_untagged_cleared | tenant=%s', tid)
|
|
session.clear()
|
|
return True
|
|
return False
|
|
|
|
if bound != tid:
|
|
logger.warning('TENANCY | session_tenant_mismatch | bound=%s resolved=%s',
|
|
bound, tid)
|
|
session.clear()
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def tag_user_id(user_id):
|
|
"""Render a Flask-Login identity string, tenant-tagged when applicable.
|
|
|
|
Used by ``User.get_id()``. Feeds both the session ``_user_id`` and the
|
|
remember-me cookie.
|
|
"""
|
|
tid = current_tenant_id()
|
|
if tid is None:
|
|
return str(user_id)
|
|
return f'{tid}:{user_id}'
|
|
|
|
|
|
def parse_user_id(raw):
|
|
"""Inverse of :func:`tag_user_id`, with the tenant check applied.
|
|
|
|
Returns the integer user id, or None when the identity must be rejected —
|
|
a foreign tenant tag, an untagged id arriving in multi-tenant mode, or
|
|
anything unparseable. ``user_loader`` turns None into an anonymous user,
|
|
which sends the caller back to the login page.
|
|
"""
|
|
if raw is None:
|
|
return None
|
|
raw = str(raw)
|
|
tid = current_tenant_id()
|
|
|
|
if ':' in raw:
|
|
tag, _, uid = raw.partition(':')
|
|
if tid is None:
|
|
# Tagged id replayed at a single-tenant / unbound context.
|
|
return None
|
|
try:
|
|
if int(tag) != tid:
|
|
logger.warning('TENANCY | user_id_tenant_mismatch | tag=%s resolved=%s',
|
|
tag, tid)
|
|
return None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
else:
|
|
uid = raw
|
|
if tid is not None:
|
|
# Untagged id in multi-tenant mode: either a session predating
|
|
# MT-21 or one lifted from another host. Force re-authentication.
|
|
logger.warning('TENANCY | user_id_untagged_rejected | tenant=%s', tid)
|
|
return None
|
|
|
|
try:
|
|
return int(uid)
|
|
except (TypeError, ValueError):
|
|
return None
|