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