codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import collections.abc as c4import hashlib5import typing as t6from collections.abc import MutableMapping7from datetime import datetime8from datetime import timezone9 10from itsdangerous import BadSignature11from itsdangerous import URLSafeTimedSerializer12from werkzeug.datastructures import CallbackDict13 14from .json.tag import TaggedJSONSerializer15 16if t.TYPE_CHECKING: # pragma: no cover17 import typing_extensions as te18 19 from .app import Flask20 from .wrappers import Request21 from .wrappers import Response22 23 24class SessionMixin(MutableMapping[str, t.Any]):25 """Expands a basic dictionary with session attributes."""26 27 @property28 def permanent(self) -> bool:29 """This reflects the ``'_permanent'`` key in the dict."""30 return self.get("_permanent", False) # type: ignore[no-any-return]31 32 @permanent.setter33 def permanent(self, value: bool) -> None:34 self["_permanent"] = bool(value)35 36 #: Some implementations can detect whether a session is newly37 #: created, but that is not guaranteed. Use with caution. The mixin38 # default is hard-coded ``False``.39 new = False40 41 #: Some implementations can detect changes to the session and set42 #: this when that happens. The mixin default is hard coded to43 #: ``True``.44 modified = True45 46 accessed = False47 """Indicates if the session was accessed, even if it was not modified. This48 is set when the session object is accessed through the request context,49 including the global :data:`.session` proxy. A ``Vary: cookie`` header will50 be added if this is ``True``.51 52 .. versionchanged:: 3.1.353 This is tracked by the request context.54 """55 56 57class SecureCookieSession(CallbackDict[str, t.Any], SessionMixin):58 """Base class for sessions based on signed cookies.59 60 This session backend will set the :attr:`modified` and61 :attr:`accessed` attributes. It cannot reliably track whether a62 session is new (vs. empty), so :attr:`new` remains hard coded to63 ``False``.64 """65 66 #: When data is changed, this is set to ``True``. Only the session67 #: dictionary itself is tracked; if the session contains mutable68 #: data (for example a nested dict) then this must be set to69 #: ``True`` manually when modifying that data. The session cookie70 #: will only be written to the response if this is ``True``.71 modified = False72 73 def __init__(74 self,75 initial: c.Mapping[str, t.Any] | None = None,76 ) -> None:77 def on_update(self: te.Self) -> None:78 self.modified = True79 80 super().__init__(initial, on_update)81 82 83class NullSession(SecureCookieSession):84 """Class used to generate nicer error messages if sessions are not85 available. Will still allow read-only access to the empty session86 but fail on setting.87 """88 89 def _fail(self, *args: t.Any, **kwargs: t.Any) -> t.NoReturn:90 raise RuntimeError(91 "The session is unavailable because no secret "92 "key was set. Set the secret_key on the "93 "application to something unique and secret."94 )95 96 __setitem__ = __delitem__ = clear = pop = popitem = update = setdefault = _fail97 del _fail98 99 100class SessionInterface:101 """The basic interface you have to implement in order to replace the102 default session interface which uses werkzeug's securecookie103 implementation. The only methods you have to implement are104 :meth:`open_session` and :meth:`save_session`, the others have105 useful defaults which you don't need to change.106 107 The session object returned by the :meth:`open_session` method has to108 provide a dictionary like interface plus the properties and methods109 from the :class:`SessionMixin`. We recommend just subclassing a dict110 and adding that mixin::111 112 class Session(dict, SessionMixin):113 pass114 115 If :meth:`open_session` returns ``None`` Flask will call into116 :meth:`make_null_session` to create a session that acts as replacement117 if the session support cannot work because some requirement is not118 fulfilled. The default :class:`NullSession` class that is created119 will complain that the secret key was not set.120 121 To replace the session interface on an application all you have to do122 is to assign :attr:`flask.Flask.session_interface`::123 124 app = Flask(__name__)125 app.session_interface = MySessionInterface()126 127 Multiple requests with the same session may be sent and handled128 concurrently. When implementing a new session interface, consider129 whether reads or writes to the backing store must be synchronized.130 There is no guarantee on the order in which the session for each131 request is opened or saved, it will occur in the order that requests132 begin and end processing.133 134 .. versionadded:: 0.8135 """136 137 #: :meth:`make_null_session` will look here for the class that should138 #: be created when a null session is requested. Likewise the139 #: :meth:`is_null_session` method will perform a typecheck against140 #: this type.141 null_session_class = NullSession142 143 #: A flag that indicates if the session interface is pickle based.144 #: This can be used by Flask extensions to make a decision in regards145 #: to how to deal with the session object.146 #:147 #: .. versionadded:: 0.10148 pickle_based = False149 150 def make_null_session(self, app: Flask) -> NullSession:151 """Creates a null session which acts as a replacement object if the152 real session support could not be loaded due to a configuration153 error. This mainly aids the user experience because the job of the154 null session is to still support lookup without complaining but155 modifications are answered with a helpful error message of what156 failed.157 158 This creates an instance of :attr:`null_session_class` by default.159 """160 return self.null_session_class()161 162 def is_null_session(self, obj: object) -> bool:163 """Checks if a given object is a null session. Null sessions are164 not asked to be saved.165 166 This checks if the object is an instance of :attr:`null_session_class`167 by default.168 """169 return isinstance(obj, self.null_session_class)170 171 def get_cookie_name(self, app: Flask) -> str:172 """The name of the session cookie. Uses``app.config["SESSION_COOKIE_NAME"]``."""173 return app.config["SESSION_COOKIE_NAME"] # type: ignore[no-any-return]174 175 def get_cookie_domain(self, app: Flask) -> str | None:176 """The value of the ``Domain`` parameter on the session cookie. If not set,177 browsers will only send the cookie to the exact domain it was set from.178 Otherwise, they will send it to any subdomain of the given value as well.179 180 Uses the :data:`SESSION_COOKIE_DOMAIN` config.181 182 .. versionchanged:: 2.3183 Not set by default, does not fall back to ``SERVER_NAME``.184 """185 return app.config["SESSION_COOKIE_DOMAIN"] # type: ignore[no-any-return]186 187 def get_cookie_path(self, app: Flask) -> str:188 """Returns the path for which the cookie should be valid. The189 default implementation uses the value from the ``SESSION_COOKIE_PATH``190 config var if it's set, and falls back to ``APPLICATION_ROOT`` or191 uses ``/`` if it's ``None``.192 """193 return app.config["SESSION_COOKIE_PATH"] or app.config["APPLICATION_ROOT"] # type: ignore[no-any-return]194 195 def get_cookie_httponly(self, app: Flask) -> bool:196 """Returns True if the session cookie should be httponly. This197 currently just returns the value of the ``SESSION_COOKIE_HTTPONLY``198 config var.199 """200 return app.config["SESSION_COOKIE_HTTPONLY"] # type: ignore[no-any-return]201 202 def get_cookie_secure(self, app: Flask) -> bool:203 """Returns True if the cookie should be secure. This currently204 just returns the value of the ``SESSION_COOKIE_SECURE`` setting.205 """206 return app.config["SESSION_COOKIE_SECURE"] # type: ignore[no-any-return]207 208 def get_cookie_samesite(self, app: Flask) -> str | None:209 """Return ``'Strict'`` or ``'Lax'`` if the cookie should use the210 ``SameSite`` attribute. This currently just returns the value of211 the :data:`SESSION_COOKIE_SAMESITE` setting.212 """213 return app.config["SESSION_COOKIE_SAMESITE"] # type: ignore[no-any-return]214 215 def get_cookie_partitioned(self, app: Flask) -> bool:216 """Returns True if the cookie should be partitioned. By default, uses217 the value of :data:`SESSION_COOKIE_PARTITIONED`.218 219 .. versionadded:: 3.1220 """221 return app.config["SESSION_COOKIE_PARTITIONED"] # type: ignore[no-any-return]222 223 def get_expiration_time(self, app: Flask, session: SessionMixin) -> datetime | None:224 """A helper method that returns an expiration date for the session225 or ``None`` if the session is linked to the browser session. The226 default implementation returns now + the permanent session227 lifetime configured on the application.228 """229 if session.permanent:230 return datetime.now(timezone.utc) + app.permanent_session_lifetime231 return None232 233 def should_set_cookie(self, app: Flask, session: SessionMixin) -> bool:234 """Used by session backends to determine if a ``Set-Cookie`` header235 should be set for this session cookie for this response. If the session236 has been modified, the cookie is set. If the session is permanent and237 the ``SESSION_REFRESH_EACH_REQUEST`` config is true, the cookie is238 always set.239 240 This check is usually skipped if the session was deleted.241 242 .. versionadded:: 0.11243 """244 245 return session.modified or (246 session.permanent and app.config["SESSION_REFRESH_EACH_REQUEST"]247 )248 249 def open_session(self, app: Flask, request: Request) -> SessionMixin | None:250 """This is called at the beginning of each request, after251 pushing the request context, before matching the URL.252 253 This must return an object which implements a dictionary-like254 interface as well as the :class:`SessionMixin` interface.255 256 This will return ``None`` to indicate that loading failed in257 some way that is not immediately an error. The request258 context will fall back to using :meth:`make_null_session`259 in this case.260 """261 raise NotImplementedError()262 263 def save_session(264 self, app: Flask, session: SessionMixin, response: Response265 ) -> None:266 """This is called at the end of each request, after generating267 a response, before removing the request context. It is skipped268 if :meth:`is_null_session` returns ``True``.269 """270 raise NotImplementedError()271 272 273session_json_serializer = TaggedJSONSerializer()274 275 276def _lazy_sha1(string: bytes = b"") -> t.Any:277 """Don't access ``hashlib.sha1`` until runtime. FIPS builds may not include278 SHA-1, in which case the import and use as a default would fail before the279 developer can configure something else.280 """281 return hashlib.sha1(string)282 283 284class SecureCookieSessionInterface(SessionInterface):285 """The default session interface that stores sessions in signed cookies286 through the :mod:`itsdangerous` module.287 """288 289 #: the salt that should be applied on top of the secret key for the290 #: signing of cookie based sessions.291 salt = "cookie-session"292 #: the hash function to use for the signature. The default is sha1293 digest_method = staticmethod(_lazy_sha1)294 #: the name of the itsdangerous supported key derivation. The default295 #: is hmac.296 key_derivation = "hmac"297 #: A python serializer for the payload. The default is a compact298 #: JSON derived serializer with support for some extra Python types299 #: such as datetime objects or tuples.300 serializer = session_json_serializer301 session_class = SecureCookieSession302 303 def get_signing_serializer(self, app: Flask) -> URLSafeTimedSerializer | None:304 if not app.secret_key:305 return None306 307 keys: list[str | bytes] = []308 309 if fallbacks := app.config["SECRET_KEY_FALLBACKS"]:310 keys.extend(fallbacks)311 312 keys.append(app.secret_key) # itsdangerous expects current key at top313 return URLSafeTimedSerializer(314 keys, # type: ignore[arg-type]315 salt=self.salt,316 serializer=self.serializer,317 signer_kwargs={318 "key_derivation": self.key_derivation,319 "digest_method": self.digest_method,320 },321 )322 323 def open_session(self, app: Flask, request: Request) -> SecureCookieSession | None:324 s = self.get_signing_serializer(app)325 if s is None:326 return None327 val = request.cookies.get(self.get_cookie_name(app))328 if not val:329 return self.session_class()330 max_age = int(app.permanent_session_lifetime.total_seconds())331 try:332 data = s.loads(val, max_age=max_age)333 return self.session_class(data)334 except BadSignature:335 return self.session_class()336 337 def save_session(338 self, app: Flask, session: SessionMixin, response: Response339 ) -> None:340 name = self.get_cookie_name(app)341 domain = self.get_cookie_domain(app)342 path = self.get_cookie_path(app)343 secure = self.get_cookie_secure(app)344 partitioned = self.get_cookie_partitioned(app)345 samesite = self.get_cookie_samesite(app)346 httponly = self.get_cookie_httponly(app)347 348 # Add a "Vary: Cookie" header if the session was accessed at all.349 if session.accessed:350 response.vary.add("Cookie")351 352 # If the session is modified to be empty, remove the cookie.353 # If the session is empty, return without setting the cookie.354 if not session:355 if session.modified:356 response.delete_cookie(357 name,358 domain=domain,359 path=path,360 secure=secure,361 partitioned=partitioned,362 samesite=samesite,363 httponly=httponly,364 )365 response.vary.add("Cookie")366 367 return368 369 if not self.should_set_cookie(app, session):370 return371 372 expires = self.get_expiration_time(app, session)373 val = self.get_signing_serializer(app).dumps(dict(session)) # type: ignore[union-attr]374 response.set_cookie(375 name,376 val,377 expires=expires,378 httponly=httponly,379 domain=domain,380 path=path,381 secure=secure,382 partitioned=partitioned,383 samesite=samesite,384 )385 response.vary.add("Cookie")386 