mirror of
https://github.com/apache/superset.git
synced 2026-07-09 16:25:36 +00:00
206 lines
8.1 KiB
Python
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)
|