sessions.py 15 KB

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