You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
367 lines
14 KiB
367 lines
14 KiB
from __future__ import annotations |
|
|
|
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 |
|
from .app import Flask |
|
from .wrappers import Request, Response |
|
|
|
|
|
class SessionMixin(MutableMapping): |
|
"""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, 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: t.Any = None) -> None: |
|
def on_update(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. |
|
|
|
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"] |
|
|
|
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``. |
|
""" |
|
rv = app.config["SESSION_COOKIE_DOMAIN"] |
|
return rv if rv else None |
|
|
|
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"] |
|
|
|
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"] |
|
|
|
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"] |
|
|
|
def get_cookie_samesite(self, app: Flask) -> str: |
|
"""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"] |
|
|
|
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() |
|
|
|
|
|
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(hashlib.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 |
|
signer_kwargs = dict( |
|
key_derivation=self.key_derivation, digest_method=self.digest_method |
|
) |
|
return URLSafeTimedSerializer( |
|
app.secret_key, |
|
salt=self.salt, |
|
serializer=self.serializer, |
|
signer_kwargs=signer_kwargs, |
|
) |
|
|
|
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) |
|
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, |
|
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 |
|
response.set_cookie( |
|
name, |
|
val, # type: ignore |
|
expires=expires, |
|
httponly=httponly, |
|
domain=domain, |
|
path=path, |
|
secure=secure, |
|
samesite=samesite, |
|
) |
|
response.vary.add("Cookie")
|
|
|