sessions.py 15 KB

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