From 67f12ad5c8bc6dff49d02a1c161e8d15463bd958 Mon Sep 17 00:00:00 2001 From: pgjones Date: Tue, 24 Dec 2024 20:07:04 +0000 Subject: [PATCH] Move parts of the session code to sansio This will allow it to be used in Quart, thereby reducing duplication, and ensuring the APIs match. --- src/flask/sansio/sessions.py | 215 +++++++++++++++++++++++++++++++++++ src/flask/sessions.py | 213 ++-------------------------------- 2 files changed, 225 insertions(+), 203 deletions(-) create mode 100644 src/flask/sansio/sessions.py diff --git a/src/flask/sansio/sessions.py b/src/flask/sansio/sessions.py new file mode 100644 index 0000000000..2d607c13b3 --- /dev/null +++ b/src/flask/sansio/sessions.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import collections.abc as c +import typing as t +from abc import ABCMeta +from collections.abc import MutableMapping +from datetime import datetime +from datetime import timezone + +from werkzeug.datastructures import CallbackDict + +from .app import App + +if t.TYPE_CHECKING: # pragma: no cover + import typing_extensions as te + + +class SessionMixin(MutableMapping[str, t.Any]): + """Expands a basic dictionary with session attributes.""" + + @property + def permanent(self) -> bool: + """This reflects the ``'_permanent'`` key in the dict.""" + return self.get("_permanent", False) + + @permanent.setter + def permanent(self, value: bool) -> None: + self["_permanent"] = bool(value) + + #: Some implementations can detect whether a session is newly + #: created, but that is not guaranteed. Use with caution. The mixin + # default is hard-coded ``False``. + new = False + + #: Some implementations can detect changes to the session and set + #: this when that happens. The mixin default is hard coded to + #: ``True``. + modified = True + + #: Some implementations can detect when session data is read or + #: written and set this when that happens. The mixin default is hard + #: coded to ``True``. + accessed = True + + +class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin): + """Base class for sessions based on signed cookies. + + This session backend will set the :attr:`modified` and + :attr:`accessed` attributes. It cannot reliably track whether a + session is new (vs. empty), so :attr:`new` remains hard coded to + ``False``. + """ + + #: When data is changed, this is set to ``True``. Only the session + #: dictionary itself is tracked; if the session contains mutable + #: data (for example a nested dict) then this must be set to + #: ``True`` manually when modifying that data. The session cookie + #: will only be written to the response if this is ``True``. + modified = False + + #: When data is read or written, this is set to ``True``. Used by + # :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie`` + #: header, which allows caching proxies to cache different pages for + #: different users. + accessed = False + + def __init__( + self, + initial: c.Mapping[str, t.Any] | c.Iterable[tuple[str, t.Any]] | None = None, + ) -> None: + def on_update(self: te.Self) -> None: + self.modified = True + self.accessed = True + + super().__init__(initial, on_update) + + def __getitem__(self, key: str) -> t.Any: + self.accessed = True + return super().__getitem__(key) + + def get(self, key: str, default: t.Any = None) -> t.Any: + self.accessed = True + return super().get(key, default) + + def setdefault(self, key: str, default: t.Any = None) -> t.Any: + self.accessed = True + return super().setdefault(key, default) + + +class NullSession(SecureCookieSession): + """Class used to generate nicer error messages if sessions are not + available. Will still allow read-only access to the empty session + but fail on setting. + """ + + def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: + raise RuntimeError( + "The session is unavailable because no secret " + "key was set. Set the secret_key on the " + "application to something unique and secret." + ) + + __setitem__ = __delitem__ = clear = pop = popitem = update = setdefault = _fail # type: ignore # noqa: B950 + del _fail + + +class SessionInterface(metaclass=ABCMeta): + """This is a SansIO abstract base class used by Flask and Quart to + then define thebasic interface to implement in order to replace + the default session interface of the frameworks. + + .. versionadded:: 3.2.0 + + """ + + #: :meth:`make_null_session` will look here for the class that should + #: be created when a null session is requested. Likewise the + #: :meth:`is_null_session` method will perform a typecheck against + #: this type. + null_session_class = NullSession + + #: A flag that indicates if the session interface is pickle based. + #: This can be used by Flask extensions to make a decision in regards + #: to how to deal with the session object. + #: + #: .. versionadded:: 0.10 + pickle_based = False + + def is_null_session(self, obj: object) -> bool: + """Checks if a given object is a null session. Null sessions are + not asked to be saved. + + This checks if the object is an instance of :attr:`null_session_class` + by default. + """ + return isinstance(obj, self.null_session_class) + + def get_cookie_name(self, app: App) -> str: + """The name of the session cookie. Uses``app.config["SESSION_COOKIE_NAME"]``.""" + return app.config["SESSION_COOKIE_NAME"] # type: ignore[no-any-return] + + def get_cookie_domain(self, app: App) -> str | None: + """The value of the ``Domain`` parameter on the session cookie. If not set, + browsers will only send the cookie to the exact domain it was set from. + Otherwise, they will send it to any subdomain of the given value as well. + + Uses the :data:`SESSION_COOKIE_DOMAIN` config. + + .. versionchanged:: 2.3 + Not set by default, does not fall back to ``SERVER_NAME``. + """ + return app.config["SESSION_COOKIE_DOMAIN"] # type: ignore[no-any-return] + + def get_cookie_path(self, app: App) -> str: + """Returns the path for which the cookie should be valid. The + default implementation uses the value from the ``SESSION_COOKIE_PATH`` + config var if it's set, and falls back to ``APPLICATION_ROOT`` or + uses ``/`` if it's ``None``. + """ + return app.config["SESSION_COOKIE_PATH"] or app.config["APPLICATION_ROOT"] # type: ignore[no-any-return] + + def get_cookie_httponly(self, app: App) -> bool: + """Returns True if the session cookie should be httponly. This + currently just returns the value of the ``SESSION_COOKIE_HTTPONLY`` + config var. + """ + return app.config["SESSION_COOKIE_HTTPONLY"] # type: ignore[no-any-return] + + def get_cookie_secure(self, app: App) -> bool: + """Returns True if the cookie should be secure. This currently + just returns the value of the ``SESSION_COOKIE_SECURE`` setting. + """ + return app.config["SESSION_COOKIE_SECURE"] # type: ignore[no-any-return] + + def get_cookie_samesite(self, app: App) -> str | None: + """Return ``'Strict'`` or ``'Lax'`` if the cookie should use the + ``SameSite`` attribute. This currently just returns the value of + the :data:`SESSION_COOKIE_SAMESITE` setting. + """ + return app.config["SESSION_COOKIE_SAMESITE"] # type: ignore[no-any-return] + + def get_cookie_partitioned(self, app: App) -> bool: + """Returns True if the cookie should be partitioned. By default, uses + the value of :data:`SESSION_COOKIE_PARTITIONED`. + + .. versionadded:: 3.1 + """ + return app.config["SESSION_COOKIE_PARTITIONED"] # type: ignore[no-any-return] + + def get_expiration_time(self, app: App, session: SessionMixin) -> datetime | None: + """A helper method that returns an expiration date for the session + or ``None`` if the session is linked to the browser session. The + default implementation returns now + the permanent session + lifetime configured on the application. + """ + if session.permanent: + return datetime.now(timezone.utc) + app.permanent_session_lifetime + return None + + def should_set_cookie(self, app: App, session: SessionMixin) -> bool: + """Used by session backends to determine if a ``Set-Cookie`` header + should be set for this session cookie for this response. If the session + has been modified, the cookie is set. If the session is permanent and + the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is + always set. + + This check is usually skipped if the session was deleted. + + .. versionadded:: 0.11 + """ + + return session.modified or ( + session.permanent and app.config["SESSION_REFRESH_EACH_REQUEST"] + ) diff --git a/src/flask/sessions.py b/src/flask/sessions.py index 375de06574..0eff6f1389 100644 --- a/src/flask/sessions.py +++ b/src/flask/sessions.py @@ -1,122 +1,28 @@ from __future__ import annotations -import collections.abc as c import hashlib import typing as t -from collections.abc import MutableMapping -from datetime import datetime -from datetime import timezone from itsdangerous import BadSignature from itsdangerous import URLSafeTimedSerializer -from werkzeug.datastructures import CallbackDict from .json.tag import TaggedJSONSerializer +from .sansio.sessions import NullSession as NullSession +from .sansio.sessions import SecureCookieSession as SecureCookieSession +from .sansio.sessions import SessionInterface as SansioSessionInterface +from .sansio.sessions import SessionMixin as SessionMixin if t.TYPE_CHECKING: # pragma: no cover - import typing_extensions as te - from .app import Flask from .wrappers import Request from .wrappers import Response -class SessionMixin(MutableMapping[str, t.Any]): - """Expands a basic dictionary with session attributes.""" - - @property - def permanent(self) -> bool: - """This reflects the ``'_permanent'`` key in the dict.""" - return self.get("_permanent", False) - - @permanent.setter - def permanent(self, value: bool) -> None: - self["_permanent"] = bool(value) - - #: Some implementations can detect whether a session is newly - #: created, but that is not guaranteed. Use with caution. The mixin - # default is hard-coded ``False``. - new = False - - #: Some implementations can detect changes to the session and set - #: this when that happens. The mixin default is hard coded to - #: ``True``. - modified = True - - #: Some implementations can detect when session data is read or - #: written and set this when that happens. The mixin default is hard - #: coded to ``True``. - accessed = True - - -class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin): - """Base class for sessions based on signed cookies. - - This session backend will set the :attr:`modified` and - :attr:`accessed` attributes. It cannot reliably track whether a - session is new (vs. empty), so :attr:`new` remains hard coded to - ``False``. - """ - - #: When data is changed, this is set to ``True``. Only the session - #: dictionary itself is tracked; if the session contains mutable - #: data (for example a nested dict) then this must be set to - #: ``True`` manually when modifying that data. The session cookie - #: will only be written to the response if this is ``True``. - modified = False - - #: When data is read or written, this is set to ``True``. Used by - # :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie`` - #: header, which allows caching proxies to cache different pages for - #: different users. - accessed = False - - def __init__( - self, - initial: c.Mapping[str, t.Any] | c.Iterable[tuple[str, t.Any]] | None = None, - ) -> None: - def on_update(self: te.Self) -> None: - self.modified = True - self.accessed = True - - super().__init__(initial, on_update) - - def __getitem__(self, key: str) -> t.Any: - self.accessed = True - return super().__getitem__(key) - - def get(self, key: str, default: t.Any = None) -> t.Any: - self.accessed = True - return super().get(key, default) - - def setdefault(self, key: str, default: t.Any = None) -> t.Any: - self.accessed = True - return super().setdefault(key, default) - - -class NullSession(SecureCookieSession): - """Class used to generate nicer error messages if sessions are not - available. Will still allow read-only access to the empty session - but fail on setting. - """ - - def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn: - raise RuntimeError( - "The session is unavailable because no secret " - "key was set. Set the secret_key on the " - "application to something unique and secret." - ) - - __setitem__ = __delitem__ = clear = pop = popitem = update = setdefault = _fail # type: ignore # noqa: B950 - del _fail - - -class SessionInterface: - """The basic interface you have to implement in order to replace the - default session interface which uses werkzeug's securecookie - implementation. The only methods you have to implement are - :meth:`open_session` and :meth:`save_session`, the others have - useful defaults which you don't need to change. +class SessionInterface(SansioSessionInterface): + """The basic interface you have to implement in order to replace + the default session interface. The only methods you have to + implement are :meth:`open_session` and :meth:`save_session`, the + others have useful defaults which you don't need to change. The session object returned by the :meth:`open_session` method has to provide a dictionary like interface plus the properties and methods @@ -146,20 +52,8 @@ class Session(dict, SessionMixin): begin and end processing. .. versionadded:: 0.8 - """ - - #: :meth:`make_null_session` will look here for the class that should - #: be created when a null session is requested. Likewise the - #: :meth:`is_null_session` method will perform a typecheck against - #: this type. - null_session_class = NullSession - #: A flag that indicates if the session interface is pickle based. - #: This can be used by Flask extensions to make a decision in regards - #: to how to deal with the session object. - #: - #: .. versionadded:: 0.10 - pickle_based = False + """ def make_null_session(self, app: Flask) -> NullSession: """Creates a null session which acts as a replacement object if the @@ -173,93 +67,6 @@ def make_null_session(self, app: Flask) -> NullSession: """ return self.null_session_class() - def is_null_session(self, obj: object) -> bool: - """Checks if a given object is a null session. Null sessions are - not asked to be saved. - - This checks if the object is an instance of :attr:`null_session_class` - by default. - """ - return isinstance(obj, self.null_session_class) - - def get_cookie_name(self, app: Flask) -> str: - """The name of the session cookie. Uses``app.config["SESSION_COOKIE_NAME"]``.""" - return app.config["SESSION_COOKIE_NAME"] # type: ignore[no-any-return] - - def get_cookie_domain(self, app: Flask) -> str | None: - """The value of the ``Domain`` parameter on the session cookie. If not set, - browsers will only send the cookie to the exact domain it was set from. - Otherwise, they will send it to any subdomain of the given value as well. - - Uses the :data:`SESSION_COOKIE_DOMAIN` config. - - .. versionchanged:: 2.3 - Not set by default, does not fall back to ``SERVER_NAME``. - """ - return app.config["SESSION_COOKIE_DOMAIN"] # type: ignore[no-any-return] - - def get_cookie_path(self, app: Flask) -> str: - """Returns the path for which the cookie should be valid. The - default implementation uses the value from the ``SESSION_COOKIE_PATH`` - config var if it's set, and falls back to ``APPLICATION_ROOT`` or - uses ``/`` if it's ``None``. - """ - return app.config["SESSION_COOKIE_PATH"] or app.config["APPLICATION_ROOT"] # type: ignore[no-any-return] - - def get_cookie_httponly(self, app: Flask) -> bool: - """Returns True if the session cookie should be httponly. This - currently just returns the value of the ``SESSION_COOKIE_HTTPONLY`` - config var. - """ - return app.config["SESSION_COOKIE_HTTPONLY"] # type: ignore[no-any-return] - - def get_cookie_secure(self, app: Flask) -> bool: - """Returns True if the cookie should be secure. This currently - just returns the value of the ``SESSION_COOKIE_SECURE`` setting. - """ - return app.config["SESSION_COOKIE_SECURE"] # type: ignore[no-any-return] - - def get_cookie_samesite(self, app: Flask) -> str | None: - """Return ``'Strict'`` or ``'Lax'`` if the cookie should use the - ``SameSite`` attribute. This currently just returns the value of - the :data:`SESSION_COOKIE_SAMESITE` setting. - """ - return app.config["SESSION_COOKIE_SAMESITE"] # type: ignore[no-any-return] - - def get_cookie_partitioned(self, app: Flask) -> bool: - """Returns True if the cookie should be partitioned. By default, uses - the value of :data:`SESSION_COOKIE_PARTITIONED`. - - .. versionadded:: 3.1 - """ - return app.config["SESSION_COOKIE_PARTITIONED"] # type: ignore[no-any-return] - - def get_expiration_time(self, app: Flask, session: SessionMixin) -> datetime | None: - """A helper method that returns an expiration date for the session - or ``None`` if the session is linked to the browser session. The - default implementation returns now + the permanent session - lifetime configured on the application. - """ - if session.permanent: - return datetime.now(timezone.utc) + app.permanent_session_lifetime - return None - - def should_set_cookie(self, app: Flask, session: SessionMixin) -> bool: - """Used by session backends to determine if a ``Set-Cookie`` header - should be set for this session cookie for this response. If the session - has been modified, the cookie is set. If the session is permanent and - the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is - always set. - - This check is usually skipped if the session was deleted. - - .. versionadded:: 0.11 - """ - - return session.modified or ( - session.permanent and app.config["SESSION_REFRESH_EACH_REQUEST"] - ) - def open_session(self, app: Flask, request: Request) -> SessionMixin | None: """This is called at the beginning of each request, after pushing the request context, before matching the URL.