400 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
			
		
		
	
	
			400 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Python
		
	
	
	
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
 | 
						|
 | 
						|
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  # 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.
 | 
						|
 | 
						|
    The session object returned by the :meth:`open_session` method has to
 | 
						|
    provide a dictionary like interface plus the properties and methods
 | 
						|
    from the :class:`SessionMixin`.  We recommend just subclassing a dict
 | 
						|
    and adding that mixin::
 | 
						|
 | 
						|
        class Session(dict, SessionMixin):
 | 
						|
            pass
 | 
						|
 | 
						|
    If :meth:`open_session` returns ``None`` Flask will call into
 | 
						|
    :meth:`make_null_session` to create a session that acts as replacement
 | 
						|
    if the session support cannot work because some requirement is not
 | 
						|
    fulfilled.  The default :class:`NullSession` class that is created
 | 
						|
    will complain that the secret key was not set.
 | 
						|
 | 
						|
    To replace the session interface on an application all you have to do
 | 
						|
    is to assign :attr:`flask.Flask.session_interface`::
 | 
						|
 | 
						|
        app = Flask(__name__)
 | 
						|
        app.session_interface = MySessionInterface()
 | 
						|
 | 
						|
    Multiple requests with the same session may be sent and handled
 | 
						|
    concurrently. When implementing a new session interface, consider
 | 
						|
    whether reads or writes to the backing store must be synchronized.
 | 
						|
    There is no guarantee on the order in which the session for each
 | 
						|
    request is opened or saved, it will occur in the order that requests
 | 
						|
    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
 | 
						|
        real session support could not be loaded due to a configuration
 | 
						|
        error.  This mainly aids the user experience because the job of the
 | 
						|
        null session is to still support lookup without complaining but
 | 
						|
        modifications are answered with a helpful error message of what
 | 
						|
        failed.
 | 
						|
 | 
						|
        This creates an instance of :attr:`null_session_class` by default.
 | 
						|
        """
 | 
						|
        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.
 | 
						|
 | 
						|
        This must return an object which implements a dictionary-like
 | 
						|
        interface as well as the :class:`SessionMixin` interface.
 | 
						|
 | 
						|
        This will return ``None`` to indicate that loading failed in
 | 
						|
        some way that is not immediately an error. The request
 | 
						|
        context will fall back to using :meth:`make_null_session`
 | 
						|
        in this case.
 | 
						|
        """
 | 
						|
        raise NotImplementedError()
 | 
						|
 | 
						|
    def save_session(
 | 
						|
        self, app: Flask, session: SessionMixin, response: Response
 | 
						|
    ) -> None:
 | 
						|
        """This is called at the end of each request, after generating
 | 
						|
        a response, before removing the request context. It is skipped
 | 
						|
        if :meth:`is_null_session` returns ``True``.
 | 
						|
        """
 | 
						|
        raise NotImplementedError()
 | 
						|
 | 
						|
 | 
						|
session_json_serializer = TaggedJSONSerializer()
 | 
						|
 | 
						|
 | 
						|
def _lazy_sha1(string: bytes = b"") -> t.Any:
 | 
						|
    """Don't access ``hashlib.sha1`` until runtime. FIPS builds may not include
 | 
						|
    SHA-1, in which case the import and use as a default would fail before the
 | 
						|
    developer can configure something else.
 | 
						|
    """
 | 
						|
    return hashlib.sha1(string)
 | 
						|
 | 
						|
 | 
						|
class SecureCookieSessionInterface(SessionInterface):
 | 
						|
    """The default session interface that stores sessions in signed cookies
 | 
						|
    through the :mod:`itsdangerous` module.
 | 
						|
    """
 | 
						|
 | 
						|
    #: the salt that should be applied on top of the secret key for the
 | 
						|
    #: signing of cookie based sessions.
 | 
						|
    salt = "cookie-session"
 | 
						|
    #: the hash function to use for the signature.  The default is sha1
 | 
						|
    digest_method = staticmethod(_lazy_sha1)
 | 
						|
    #: the name of the itsdangerous supported key derivation.  The default
 | 
						|
    #: is hmac.
 | 
						|
    key_derivation = "hmac"
 | 
						|
    #: A python serializer for the payload.  The default is a compact
 | 
						|
    #: JSON derived serializer with support for some extra Python types
 | 
						|
    #: such as datetime objects or tuples.
 | 
						|
    serializer = session_json_serializer
 | 
						|
    session_class = SecureCookieSession
 | 
						|
 | 
						|
    def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
 | 
						|
        if not app.secret_key:
 | 
						|
            return None
 | 
						|
 | 
						|
        keys: list[str | bytes] = []
 | 
						|
 | 
						|
        if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:
 | 
						|
            keys.extend(fallbacks)
 | 
						|
 | 
						|
        keys.append(app.secret_key)  # itsdangerous expects current key at top
 | 
						|
        return URLSafeTimedSerializer(
 | 
						|
            keys,  # type: ignore[arg-type]
 | 
						|
            salt=self.salt,
 | 
						|
            serializer=self.serializer,
 | 
						|
            signer_kwargs={
 | 
						|
                "key_derivation": self.key_derivation,
 | 
						|
                "digest_method": self.digest_method,
 | 
						|
            },
 | 
						|
        )
 | 
						|
 | 
						|
    def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:
 | 
						|
        s = self.get_signing_serializer(app)
 | 
						|
        if s is None:
 | 
						|
            return None
 | 
						|
        val = request.cookies.get(self.get_cookie_name(app))
 | 
						|
        if not val:
 | 
						|
            return self.session_class()
 | 
						|
        max_age = int(app.permanent_session_lifetime.total_seconds())
 | 
						|
        try:
 | 
						|
            data = s.loads(val, max_age=max_age)
 | 
						|
            return self.session_class(data)
 | 
						|
        except BadSignature:
 | 
						|
            return self.session_class()
 | 
						|
 | 
						|
    def save_session(
 | 
						|
        self, app: Flask, session: SessionMixin, response: Response
 | 
						|
    ) -> None:
 | 
						|
        name = self.get_cookie_name(app)
 | 
						|
        domain = self.get_cookie_domain(app)
 | 
						|
        path = self.get_cookie_path(app)
 | 
						|
        secure = self.get_cookie_secure(app)
 | 
						|
        partitioned = self.get_cookie_partitioned(app)
 | 
						|
        samesite = self.get_cookie_samesite(app)
 | 
						|
        httponly = self.get_cookie_httponly(app)
 | 
						|
 | 
						|
        # Add a "Vary: Cookie" header if the session was accessed at all.
 | 
						|
        if session.accessed:
 | 
						|
            response.vary.add("Cookie")
 | 
						|
 | 
						|
        # If the session is modified to be empty, remove the cookie.
 | 
						|
        # If the session is empty, return without setting the cookie.
 | 
						|
        if not session:
 | 
						|
            if session.modified:
 | 
						|
                response.delete_cookie(
 | 
						|
                    name,
 | 
						|
                    domain=domain,
 | 
						|
                    path=path,
 | 
						|
                    secure=secure,
 | 
						|
                    partitioned=partitioned,
 | 
						|
                    samesite=samesite,
 | 
						|
                    httponly=httponly,
 | 
						|
                )
 | 
						|
                response.vary.add("Cookie")
 | 
						|
 | 
						|
            return
 | 
						|
 | 
						|
        if not self.should_set_cookie(app, session):
 | 
						|
            return
 | 
						|
 | 
						|
        expires = self.get_expiration_time(app, session)
 | 
						|
        val = self.get_signing_serializer(app).dumps(dict(session))  # type: ignore[union-attr]
 | 
						|
        response.set_cookie(
 | 
						|
            name,
 | 
						|
            val,
 | 
						|
            expires=expires,
 | 
						|
            httponly=httponly,
 | 
						|
            domain=domain,
 | 
						|
            path=path,
 | 
						|
            secure=secure,
 | 
						|
            partitioned=partitioned,
 | 
						|
            samesite=samesite,
 | 
						|
        )
 | 
						|
        response.vary.add("Cookie")
 |