rusackas commented on code in PR #40695: URL: https://github.com/apache/superset/pull/40695#discussion_r3385394039
########## superset/security/session_invalidation.py: ########## @@ -0,0 +1,205 @@ +# 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 Review Comment: Added in 6234ed3ada. `tests/unit_tests/security/test_session_invalidation.py` now unit-tests every branch of `enforce_session_validity`: unauthenticated user, guest user (skipped), no-epoch, a valid session that logged in after the epoch, an invalidated session (asserts `logout_user`/`session.clear`/`flash` fire), and the fail-open handler (asserts the warning is logged and the request is allowed). ########## superset/security/session_invalidation.py: ########## @@ -0,0 +1,205 @@ +# 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() + Review Comment: Added in 6234ed3ada. There are now unit tests for `invalidate_user_sessions` covering all three paths: the update of an existing row (no INSERT/SAVEPOINT), the insert of a missing row inside a SAVEPOINT, and the IntegrityError retry where a concurrent disable wins the insert race and the row is stamped via UPDATE instead of duplicated. ########## superset/initialization/__init__.py: ########## @@ -764,6 +764,23 @@ def cleanup_analytics_memory(response: Response) -> Response: gc.collect() return response + @self.superset_app.before_request + def enforce_session_validity() -> Any: Review Comment: This is already the case: `enforce_session_validity` is declared `-> Optional[Response]` and `Optional` is imported on the `from typing import Any, Optional` line. No change needed. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
