Files
superset2/superset/security/session_invalidation.py
2026-06-11 19:37:13 -07:00

206 lines
8.1 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Backend-agnostic session invalidation.
Outstanding sessions are terminated by comparing the time a session was
authenticated (``session["_login_at"]``, stamped at login) against a per-user
invalidation epoch (``UserAttribute.sessions_invalidated_at``). When a session
predates the user's epoch it is forced to log out on its next request.
The epoch is stamped whenever an account is *disabled* (``active`` flips to
``False``), via a SQLAlchemy ``after_update`` listener so it fires regardless of
the code path that disabled the user (FAB admin UI, REST API, or CLI). This
works for both client-side cookie sessions and server-side session stores
without enumerating the store by user. A deleted user is already rejected by
Flask-Login's user loader, so deletion needs no epoch.
The mechanism is inert until an epoch is set: users that were never disabled
(NULL epoch) are never affected, so it is backwards compatible by default.
"""
import logging
import math
from datetime import datetime, timezone
from typing import Any, Optional
from flask import flash, session
from flask_babel import gettext as __
from flask_login import current_user, logout_user
from sqlalchemy import event, inspect
from sqlalchemy.exc import IntegrityError
from werkzeug.wrappers import Response
logger = logging.getLogger(__name__)
#: Session key holding the epoch-seconds timestamp of when the session logged in.
SESSION_LOGIN_AT_KEY = "_login_at"
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def stamp_login_time() -> None:
"""Record the current session's authentication time. Call on login."""
session[SESSION_LOGIN_AT_KEY] = _utcnow().timestamp()
def _as_utc_timestamp(value: datetime) -> float:
"""Epoch seconds for ``value``, treating naive datetimes as UTC.
The ``sessions_invalidated_at`` column is a naive ``DateTime`` storing a UTC
instant; calling ``.timestamp()`` on a naive datetime would otherwise assume
*local* time and skew the comparison by the local UTC offset.
"""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def is_session_invalidated(
login_at: Optional[float], invalidated_at: Optional[datetime]
) -> bool:
"""
Return True if a session authenticated at ``login_at`` is invalidated by an
epoch of ``invalidated_at``.
- No epoch (``invalidated_at is None``) ⇒ never invalidated (the common case
and the reason the feature is inert/backwards compatible by default).
- An epoch with no recorded login time ⇒ invalidated. A session old enough
to predate the feature carries no ``_login_at``; if the user has since
been disabled, fail closed.
- Otherwise the session is invalidated iff it logged in before the epoch.
"""
if invalidated_at is None:
return False
if login_at is None:
return True
return login_at < _as_utc_timestamp(invalidated_at)
def _get_user_invalidated_at(user: Any) -> Optional[datetime]:
extra_attributes = getattr(user, "extra_attributes", None)
if not extra_attributes:
return None
return extra_attributes[0].sessions_invalidated_at
def enforce_session_validity() -> Optional[Response]:
"""
``before_request`` hook: force logout of sessions invalidated by the user's
epoch.
Fails open — any error here logs a warning and allows the request rather
than risk locking everyone out on a bug in the check.
"""
try:
user = current_user
if not user or not getattr(user, "is_authenticated", False):
return None
# Guest (embedded) users are not FAB users and have their own
# revocation mechanism; skip them.
if getattr(user, "is_guest_user", False):
return None
invalidated_at = _get_user_invalidated_at(user)
if invalidated_at is None:
return None
login_at = session.get(SESSION_LOGIN_AT_KEY)
if not is_session_invalidated(login_at, invalidated_at):
return None
# Clear the authenticated session and let the request continue as
# anonymous: each route's own decorator then responds correctly for its
# type (401 for the REST API, redirect-to-login for HTML views) without
# this hook needing to know the route kind.
logout_user()
session.clear()
flash(__("Your session has ended. Please sign in again."), "warning")
return None
except Exception: # noqa: BLE001 # pylint: disable=broad-except
logger.warning(
"Session-invalidation check failed; allowing request", exc_info=True
)
return None
def invalidate_user_sessions(connection: Any, user_id: int) -> None:
"""Stamp the invalidation epoch for ``user_id`` using ``connection``.
Upserts the user's ``UserAttribute`` row so the mechanism works even for
users that have no attribute row yet. ``user_attribute.user_id`` carries a
unique constraint, so the insert is safe against a concurrent disable of the
same user: the loser's insert raises ``IntegrityError``, which is caught and
retried as an update rather than creating a duplicate row.
"""
# pylint: disable=import-outside-toplevel
from superset.models.user_attributes import UserAttribute
table = UserAttribute.__table__
# Round the epoch up to the next whole second. Some backends (e.g. MySQL)
# store ``DATETIME`` columns without sub-second precision and truncate the
# value; a session that logged in earlier in the same wall-clock second
# carries a fractional ``_login_at`` that would otherwise compare as >= the
# truncated epoch and survive invalidation. Ceiling the stamp guarantees it
# strictly exceeds any login time from the same second.
now_epoch = _utcnow().timestamp()
now = datetime.fromtimestamp(math.ceil(now_epoch), timezone.utc).replace(
tzinfo=None
)
def _stamp_existing() -> int:
return connection.execute(
table.update()
.where(table.c.user_id == user_id)
.values(sessions_invalidated_at=now, changed_on=now)
).rowcount
if _stamp_existing():
return
try:
with connection.begin_nested():
connection.execute(
table.insert().values(
user_id=user_id,
sessions_invalidated_at=now,
created_on=now,
changed_on=now,
)
)
except IntegrityError:
# A concurrent disable inserted the row first; stamp it instead.
_stamp_existing()
def _stamp_epoch_on_disable(_mapper: Any, connection: Any, target: Any) -> None:
history = inspect(target).attrs.active.history
# Only act when ``active`` actually changed to False — ignore the
# last_login / login_count updates FAB writes on every login.
if not history.has_changes() or target.active:
return
invalidate_user_sessions(connection, target.id)
def register_session_invalidation_events(user_model: Any) -> None:
"""Register the ``after_update`` listener that stamps the epoch on disable.
Idempotent: safe to call on every app initialization (e.g. across tests).
"""
if not event.contains(user_model, "after_update", _stamp_epoch_on_disable):
event.listen(user_model, "after_update", _stamp_epoch_on_disable)