Format: text/x-python
⬇ Download File (15.1 KB)
   1  from __future__ import annotations
   2  
   3  import collections.abc as c
   4  import hashlib
   5  import typing as t
   6  from collections.abc import MutableMapping
   7  from datetime import datetime
   8  from datetime import timezone
   9  
  10  from itsdangerous import BadSignature
  11  from itsdangerous import URLSafeTimedSerializer
  12  from werkzeug.datastructures import CallbackDict
  13  
  14  from .json.tag import TaggedJSONSerializer
  15  
  16  if t.TYPE_CHECKING:  # pragma: no cover
  17      import typing_extensions as te
  18  
  19      from .app import Flask
  20      from .wrappers import Request
  21      from .wrappers import Response
  22  
  23  
  24  class SessionMixin(MutableMapping[str, t.Any]):
  25      """Expands a basic dictionary with session attributes."""
  26  
  27      @property
  28      def permanent(self) -> bool:
  29          """This reflects the ``'_permanent'`` key in the dict."""
  30          return self.get("_permanent", False)
  31  
  32      @permanent.setter
  33      def permanent(self, value: bool) -> None:
  34          self["_permanent"] = bool(value)
  35  
  36      #: Some implementations can detect whether a session is newly
  37      #: created, but that is not guaranteed. Use with caution. The mixin
  38      # default is hard-coded ``False``.
  39      new = False
  40  
  41      #: Some implementations can detect changes to the session and set
  42      #: this when that happens. The mixin default is hard coded to
  43      #: ``True``.
  44      modified = True
  45  
  46      #: Some implementations can detect when session data is read or
  47      #: written and set this when that happens. The mixin default is hard
  48      #: coded to ``True``.
  49      accessed = True
  50  
  51  
  52  class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin):
  53      """Base class for sessions based on signed cookies.
  54  
  55      This session backend will set the :attr:`modified` and
  56      :attr:`accessed` attributes. It cannot reliably track whether a
  57      session is new (vs. empty), so :attr:`new` remains hard coded to
  58      ``False``.
  59      """
  60  
  61      #: When data is changed, this is set to ``True``. Only the session
  62      #: dictionary itself is tracked; if the session contains mutable
  63      #: data (for example a nested dict) then this must be set to
  64      #: ``True`` manually when modifying that data. The session cookie
  65      #: will only be written to the response if this is ``True``.
  66      modified = False
  67  
  68      #: When data is read or written, this is set to ``True``. Used by
  69      # :class:`.SecureCookieSessionInterface` to add a ``Vary: Cookie``
  70      #: header, which allows caching proxies to cache different pages for
  71      #: different users.
  72      accessed = False
  73  
  74      def __init__(
  75          self,
  76          initial: c.Mapping[str, t.Any] | c.Iterable[tuple[str, t.Any]] | None = None,
  77      ) -> None:
  78          def on_update(self: te.Self) -> None:
  79              self.modified = True
  80              self.accessed = True
  81  
  82          super().__init__(initial, on_update)
  83  
  84      def __getitem__(self, key: str) -> t.Any:
  85          self.accessed = True
  86          return super().__getitem__(key)
  87  
  88      def get(self, key: str, default: t.Any = None) -> t.Any:
  89          self.accessed = True
  90          return super().get(key, default)
  91  
  92      def setdefault(self, key: str, default: t.Any = None) -> t.Any:
  93          self.accessed = True
  94          return super().setdefault(key, default)
  95  
  96  
  97  class NullSession(SecureCookieSession):
  98      """Class used to generate nicer error messages if sessions are not
  99      available.  Will still allow read-only access to the empty session
 100      but fail on setting.
 101      """
 102  
 103      def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn:
 104          raise RuntimeError(
 105              "The session is unavailable because no secret "
 106              "key was set.  Set the secret_key on the "
 107              "application to something unique and secret."
 108          )
 109  
 110      __setitem__ = __delitem__ = clear = pop = popitem = update = setdefault = _fail  # type: ignore # noqa: B950
 111      del _fail
 112  
 113  
 114  class SessionInterface:
 115      """The basic interface you have to implement in order to replace the
 116      default session interface which uses werkzeug's securecookie
 117      implementation.  The only methods you have to implement are
 118      :meth:`open_session` and :meth:`save_session`, the others have
 119      useful defaults which you don't need to change.
 120  
 121      The session object returned by the :meth:`open_session` method has to
 122      provide a dictionary like interface plus the properties and methods
 123      from the :class:`SessionMixin`.  We recommend just subclassing a dict
 124      and adding that mixin::
 125  
 126          class Session(dict, SessionMixin):
 127              pass
 128  
 129      If :meth:`open_session` returns ``None`` Flask will call into
 130      :meth:`make_null_session` to create a session that acts as replacement
 131      if the session support cannot work because some requirement is not
 132      fulfilled.  The default :class:`NullSession` class that is created
 133      will complain that the secret key was not set.
 134  
 135      To replace the session interface on an application all you have to do
 136      is to assign :attr:`flask.Flask.session_interface`::
 137  
 138          app = Flask(__name__)
 139          app.session_interface = MySessionInterface()
 140  
 141      Multiple requests with the same session may be sent and handled
 142      concurrently. When implementing a new session interface, consider
 143      whether reads or writes to the backing store must be synchronized.
 144      There is no guarantee on the order in which the session for each
 145      request is opened or saved, it will occur in the order that requests
 146      begin and end processing.
 147  
 148      .. versionadded:: 0.8
 149      """
 150  
 151      #: :meth:`make_null_session` will look here for the class that should
 152      #: be created when a null session is requested.  Likewise the
 153      #: :meth:`is_null_session` method will perform a typecheck against
 154      #: this type.
 155      null_session_class = NullSession
 156  
 157      #: A flag that indicates if the session interface is pickle based.
 158      #: This can be used by Flask extensions to make a decision in regards
 159      #: to how to deal with the session object.
 160      #:
 161      #: .. versionadded:: 0.10
 162      pickle_based = False
 163  
 164      def make_null_session(self, app: Flask) -> NullSession:
 165          """Creates a null session which acts as a replacement object if the
 166          real session support could not be loaded due to a configuration
 167          error.  This mainly aids the user experience because the job of the
 168          null session is to still support lookup without complaining but
 169          modifications are answered with a helpful error message of what
 170          failed.
 171  
 172          This creates an instance of :attr:`null_session_class` by default.
 173          """
 174          return self.null_session_class()
 175  
 176      def is_null_session(self, obj: object) -> bool:
 177          """Checks if a given object is a null session.  Null sessions are
 178          not asked to be saved.
 179  
 180          This checks if the object is an instance of :attr:`null_session_class`
 181          by default.
 182          """
 183          return isinstance(obj, self.null_session_class)
 184  
 185      def get_cookie_name(self, app: Flask) -> str:
 186          """The name of the session cookie. Uses``app.config["SESSION_COOKIE_NAME"]``."""
 187          return app.config["SESSION_COOKIE_NAME"]  # type: ignore[no-any-return]
 188  
 189      def get_cookie_domain(self, app: Flask) -> str | None:
 190          """The value of the ``Domain`` parameter on the session cookie. If not set,
 191          browsers will only send the cookie to the exact domain it was set from.
 192          Otherwise, they will send it to any subdomain of the given value as well.
 193  
 194          Uses the :data:`SESSION_COOKIE_DOMAIN` config.
 195  
 196          .. versionchanged:: 2.3
 197              Not set by default, does not fall back to ``SERVER_NAME``.
 198          """
 199          return app.config["SESSION_COOKIE_DOMAIN"]  # type: ignore[no-any-return]
 200  
 201      def get_cookie_path(self, app: Flask) -> str:
 202          """Returns the path for which the cookie should be valid.  The
 203          default implementation uses the value from the ``SESSION_COOKIE_PATH``
 204          config var if it's set, and falls back to ``APPLICATION_ROOT`` or
 205          uses ``/`` if it's ``None``.
 206          """
 207          return app.config["SESSION_COOKIE_PATH"] or app.config["APPLICATION_ROOT"]  # type: ignore[no-any-return]
 208  
 209      def get_cookie_httponly(self, app: Flask) -> bool:
 210          """Returns True if the session cookie should be httponly.  This
 211          currently just returns the value of the ``SESSION_COOKIE_HTTPONLY``
 212          config var.
 213          """
 214          return app.config["SESSION_COOKIE_HTTPONLY"]  # type: ignore[no-any-return]
 215  
 216      def get_cookie_secure(self, app: Flask) -> bool:
 217          """Returns True if the cookie should be secure.  This currently
 218          just returns the value of the ``SESSION_COOKIE_SECURE`` setting.
 219          """
 220          return app.config["SESSION_COOKIE_SECURE"]  # type: ignore[no-any-return]
 221  
 222      def get_cookie_samesite(self, app: Flask) -> str | None:
 223          """Return ``'Strict'`` or ``'Lax'`` if the cookie should use the
 224          ``SameSite`` attribute. This currently just returns the value of
 225          the :data:`SESSION_COOKIE_SAMESITE` setting.
 226          """
 227          return app.config["SESSION_COOKIE_SAMESITE"]  # type: ignore[no-any-return]
 228  
 229      def get_cookie_partitioned(self, app: Flask) -> bool:
 230          """Returns True if the cookie should be partitioned. By default, uses
 231          the value of :data:`SESSION_COOKIE_PARTITIONED`.
 232  
 233          .. versionadded:: 3.1
 234          """
 235          return app.config["SESSION_COOKIE_PARTITIONED"]  # type: ignore[no-any-return]
 236  
 237      def get_expiration_time(self, app: Flask, session: SessionMixin) -> datetime | None:
 238          """A helper method that returns an expiration date for the session
 239          or ``None`` if the session is linked to the browser session.  The
 240          default implementation returns now + the permanent session
 241          lifetime configured on the application.
 242          """
 243          if session.permanent:
 244              return datetime.now(timezone.utc) + app.permanent_session_lifetime
 245          return None
 246  
 247      def should_set_cookie(self, app: Flask, session: SessionMixin) -> bool:
 248          """Used by session backends to determine if a ``Set-Cookie`` header
 249          should be set for this session cookie for this response. If the session
 250          has been modified, the cookie is set. If the session is permanent and
 251          the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is
 252          always set.
 253  
 254          This check is usually skipped if the session was deleted.
 255  
 256          .. versionadded:: 0.11
 257          """
 258  
 259          return session.modified or (
 260              session.permanent and app.config["SESSION_REFRESH_EACH_REQUEST"]
 261          )
 262  
 263      def open_session(self, app: Flask, request: Request) -> SessionMixin | None:
 264          """This is called at the beginning of each request, after
 265          pushing the request context, before matching the URL.
 266  
 267          This must return an object which implements a dictionary-like
 268          interface as well as the :class:`SessionMixin` interface.
 269  
 270          This will return ``None`` to indicate that loading failed in
 271          some way that is not immediately an error. The request
 272          context will fall back to using :meth:`make_null_session`
 273          in this case.
 274          """
 275          raise NotImplementedError()
 276  
 277      def save_session(
 278          self, app: Flask, session: SessionMixin, response: Response
 279      ) -> None:
 280          """This is called at the end of each request, after generating
 281          a response, before removing the request context. It is skipped
 282          if :meth:`is_null_session` returns ``True``.
 283          """
 284          raise NotImplementedError()
 285  
 286  
 287  session_json_serializer = TaggedJSONSerializer()
 288  
 289  
 290  def _lazy_sha1(string: bytes = b"") -> t.Any:
 291      """Don't access ``hashlib.sha1`` until runtime. FIPS builds may not include
 292      SHA-1, in which case the import and use as a default would fail before the
 293      developer can configure something else.
 294      """
 295      return hashlib.sha1(string)
 296  
 297  
 298  class SecureCookieSessionInterface(SessionInterface):
 299      """The default session interface that stores sessions in signed cookies
 300      through the :mod:`itsdangerous` module.
 301      """
 302  
 303      #: the salt that should be applied on top of the secret key for the
 304      #: signing of cookie based sessions.
 305      salt = "cookie-session"
 306      #: the hash function to use for the signature.  The default is sha1
 307      digest_method = staticmethod(_lazy_sha1)
 308      #: the name of the itsdangerous supported key derivation.  The default
 309      #: is hmac.
 310      key_derivation = "hmac"
 311      #: A python serializer for the payload.  The default is a compact
 312      #: JSON derived serializer with support for some extra Python types
 313      #: such as datetime objects or tuples.
 314      serializer = session_json_serializer
 315      session_class = SecureCookieSession
 316  
 317      def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:
 318          if not app.secret_key:
 319              return None
 320  
 321          keys: list[str | bytes] = [app.secret_key]
 322  
 323          if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:
 324              keys.extend(fallbacks)
 325  
 326          return URLSafeTimedSerializer(
 327              keys,  # type: ignore[arg-type]
 328              salt=self.salt,
 329              serializer=self.serializer,
 330              signer_kwargs={
 331                  "key_derivation": self.key_derivation,
 332                  "digest_method": self.digest_method,
 333              },
 334          )
 335  
 336      def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:
 337          s = self.get_signing_serializer(app)
 338          if s is None:
 339              return None
 340          val = request.cookies.get(self.get_cookie_name(app))
 341          if not val:
 342              return self.session_class()
 343          max_age = int(app.permanent_session_lifetime.total_seconds())
 344          try:
 345              data = s.loads(val, max_age=max_age)
 346              return self.session_class(data)
 347          except BadSignature:
 348              return self.session_class()
 349  
 350      def save_session(
 351          self, app: Flask, session: SessionMixin, response: Response
 352      ) -> None:
 353          name = self.get_cookie_name(app)
 354          domain = self.get_cookie_domain(app)
 355          path = self.get_cookie_path(app)
 356          secure = self.get_cookie_secure(app)
 357          partitioned = self.get_cookie_partitioned(app)
 358          samesite = self.get_cookie_samesite(app)
 359          httponly = self.get_cookie_httponly(app)
 360  
 361          # Add a "Vary: Cookie" header if the session was accessed at all.
 362          if session.accessed:
 363              response.vary.add("Cookie")
 364  
 365          # If the session is modified to be empty, remove the cookie.
 366          # If the session is empty, return without setting the cookie.
 367          if not session:
 368              if session.modified:
 369                  response.delete_cookie(
 370                      name,
 371                      domain=domain,
 372                      path=path,
 373                      secure=secure,
 374                      partitioned=partitioned,
 375                      samesite=samesite,
 376                      httponly=httponly,
 377                  )
 378                  response.vary.add("Cookie")
 379  
 380              return
 381  
 382          if not self.should_set_cookie(app, session):
 383              return
 384  
 385          expires = self.get_expiration_time(app, session)
 386          val = self.get_signing_serializer(app).dumps(dict(session))  # type: ignore[union-attr]
 387          response.set_cookie(
 388              name,
 389              val,
 390              expires=expires,
 391              httponly=httponly,
 392              domain=domain,
 393              path=path,
 394              secure=secure,
 395              partitioned=partitioned,
 396              samesite=samesite,
 397          )
 398          response.vary.add("Cookie")
 399