sessions.py 15 KB

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