pyramid-session-redis 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyramid_session_redis/__init__.py +750 -0
- pyramid_session_redis/compat.py +29 -0
- pyramid_session_redis/connection.py +109 -0
- pyramid_session_redis/exceptions.py +96 -0
- pyramid_session_redis/py.typed +0 -0
- pyramid_session_redis/session.py +931 -0
- pyramid_session_redis/util.py +622 -0
- pyramid_session_redis-2.0.0.dist-info/METADATA +871 -0
- pyramid_session_redis-2.0.0.dist-info/RECORD +12 -0
- pyramid_session_redis-2.0.0.dist-info/WHEEL +5 -0
- pyramid_session_redis-2.0.0.dist-info/licenses/LICENSE.txt +104 -0
- pyramid_session_redis-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# stdlib
|
|
3
|
+
import functools
|
|
4
|
+
import pickle
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Callable
|
|
7
|
+
from typing import Dict
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
# pypi
|
|
12
|
+
from webob.cookies import SignedSerializer
|
|
13
|
+
|
|
14
|
+
# local
|
|
15
|
+
from . import compat # noqa: F401 ; trigger compat routines
|
|
16
|
+
from .connection import get_default_connection
|
|
17
|
+
from .exceptions import InvalidSession
|
|
18
|
+
from .exceptions import InvalidSession_NoSessionCookie
|
|
19
|
+
from .session import RedisSession
|
|
20
|
+
from .util import _generate_session_id
|
|
21
|
+
from .util import _parse_settings
|
|
22
|
+
from .util import _StringSerializer
|
|
23
|
+
from .util import _validate_session_options
|
|
24
|
+
from .util import configs_bool # noqa: F401 ; included for legacy
|
|
25
|
+
from .util import configs_dotable
|
|
26
|
+
from .util import create_unique_session_id
|
|
27
|
+
from .util import empty_session_payload
|
|
28
|
+
from .util import LazyCreateSession
|
|
29
|
+
from .util import NotSpecified
|
|
30
|
+
from .util import TYPING_COOKIE_EXPIRES
|
|
31
|
+
from .util import TYPING_SESSION_ID
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from pyramid.config import Configurator
|
|
35
|
+
from redis.client import Redis as RedisClient
|
|
36
|
+
from redis.connection import ConnectionPool
|
|
37
|
+
from webob.request import Request
|
|
38
|
+
from webob.response import Response
|
|
39
|
+
|
|
40
|
+
from .util import SignedSerializerInterface
|
|
41
|
+
|
|
42
|
+
# from pyramid.request import Request # webob has stubs; pyramid does not
|
|
43
|
+
# from pyramid.response import Response # webob has stubs; pyramid does not
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__VERSION__ = "2.0.0"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ==============================================================================
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
INVALIDATE_EMPTY_SESSION = True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_response_allow_cookies(response: "Response") -> bool:
|
|
56
|
+
"""
|
|
57
|
+
reference implementation for ``func_check_response_allow_cookies``
|
|
58
|
+
If view has set any of these response headers, do not add a session
|
|
59
|
+
cookie on this response. This way views generating cacheable content,
|
|
60
|
+
like images, can signal the downstream web server that this content
|
|
61
|
+
is safe. Otherwise if we set a cookie on these responses it could
|
|
62
|
+
result to user session leakage.
|
|
63
|
+
"""
|
|
64
|
+
# The view signals this is cacheable response
|
|
65
|
+
# and we should not stamp a session cookie on it
|
|
66
|
+
cookieless_headers = ["expires", "cache-control"]
|
|
67
|
+
for header in cookieless_headers:
|
|
68
|
+
if header in response.headers:
|
|
69
|
+
return False
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def includeme(config: "Configurator") -> None:
|
|
74
|
+
"""
|
|
75
|
+
This function is detected by Pyramid so that you can easily include
|
|
76
|
+
`pyramid_session_redis` in your `main` method like so::
|
|
77
|
+
|
|
78
|
+
config.include('pyramid_session_redis')
|
|
79
|
+
|
|
80
|
+
Parameters:
|
|
81
|
+
|
|
82
|
+
``config``
|
|
83
|
+
A Pyramid ``config.Configurator``
|
|
84
|
+
"""
|
|
85
|
+
settings = config.registry.settings
|
|
86
|
+
|
|
87
|
+
# special rule for converting dotted python paths to callables
|
|
88
|
+
for option in configs_dotable:
|
|
89
|
+
key = "redis.sessions.%s" % option
|
|
90
|
+
if key in settings:
|
|
91
|
+
settings[key] = config.maybe_dotted(settings[key])
|
|
92
|
+
session_factory = session_factory_from_settings(settings)
|
|
93
|
+
config.set_session_factory(session_factory)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def session_factory_from_settings(settings: Dict) -> Callable:
|
|
97
|
+
"""
|
|
98
|
+
Convenience method to construct a ``RedisSessionFactory`` callable from
|
|
99
|
+
Paste config settings. Only settings prefixed with "redis.sessions" will be
|
|
100
|
+
inspected and, if needed, coerced to their appropriate types (for example,
|
|
101
|
+
casting the ``timeout`` value as an `int`).
|
|
102
|
+
|
|
103
|
+
Parameters:
|
|
104
|
+
|
|
105
|
+
``settings``
|
|
106
|
+
A dict of Pyramid application settings
|
|
107
|
+
"""
|
|
108
|
+
options = _parse_settings(settings)
|
|
109
|
+
return RedisSessionFactory(**options)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def RedisSessionFactory(
|
|
113
|
+
secret: Optional[str] = None, # alternate is `cookie_signer`
|
|
114
|
+
timeout: Optional[int] = 1200,
|
|
115
|
+
cookie_name: str = "session",
|
|
116
|
+
cookie_max_age: Optional[int] = None,
|
|
117
|
+
cookie_path: str = "/",
|
|
118
|
+
cookie_domain: Optional[str] = None,
|
|
119
|
+
cookie_secure: bool = False,
|
|
120
|
+
cookie_httponly: bool = True,
|
|
121
|
+
cookie_expires: TYPING_COOKIE_EXPIRES = None, # TYPING_COOKIE_EXPIRES includes None
|
|
122
|
+
cookie_comment: Optional[str] = None,
|
|
123
|
+
cookie_samesite: Optional[str] = None,
|
|
124
|
+
cookie_on_exception: bool = True,
|
|
125
|
+
redis_url: Optional[str] = None,
|
|
126
|
+
redis_host: str = "localhost",
|
|
127
|
+
redis_port: int = 6379,
|
|
128
|
+
redis_db: int = 0,
|
|
129
|
+
redis_password: Optional[str] = None,
|
|
130
|
+
redis_client_callable: Optional[Callable[..., "RedisClient"]] = None,
|
|
131
|
+
serialize: Callable[[Dict], bytes] = pickle.dumps, # dict->bytes
|
|
132
|
+
deserialize: Callable[[bytes], Dict] = pickle.loads, # bytes->dict
|
|
133
|
+
id_generator: Callable[[], str] = _generate_session_id,
|
|
134
|
+
set_redis_ttl: bool = True,
|
|
135
|
+
set_redis_ttl_readheavy: Optional[bool] = None,
|
|
136
|
+
detect_changes: bool = True,
|
|
137
|
+
deserialized_fails_new: Optional[bool] = None,
|
|
138
|
+
func_check_response_allow_cookies: Optional[Callable[["Response"], bool]] = None,
|
|
139
|
+
func_invalid_logger: Optional[Callable[["Request", InvalidSession], Any]] = None,
|
|
140
|
+
timeout_trigger: Optional[int] = None,
|
|
141
|
+
python_expires: bool = True,
|
|
142
|
+
cookie_signer: Optional[
|
|
143
|
+
"SignedSerializerInterface"
|
|
144
|
+
] = None, # alternate for `secret`
|
|
145
|
+
redis_socket_timeout: Optional[float] = None,
|
|
146
|
+
redis_connection_pool: Optional["ConnectionPool"] = None,
|
|
147
|
+
redis_encoding: Optional[str] = None,
|
|
148
|
+
redis_encoding_errors: Optional[str] = None,
|
|
149
|
+
redis_unix_socket_path: Optional[str] = None,
|
|
150
|
+
invalidate_empty_session: bool = INVALIDATE_EMPTY_SESSION,
|
|
151
|
+
optimistic_concurrency: bool = False,
|
|
152
|
+
) -> Callable:
|
|
153
|
+
"""
|
|
154
|
+
Constructs and returns a session factory that will provide session data
|
|
155
|
+
from a Redis server. The returned factory can be supplied as the
|
|
156
|
+
``session_factory`` argument of a :class:`pyramid.config.Configurator`
|
|
157
|
+
constructor, or used as the ``session_factory`` argument of the
|
|
158
|
+
:meth:`pyramid.config.Configurator.set_session_factory` method.
|
|
159
|
+
|
|
160
|
+
Parameters:
|
|
161
|
+
|
|
162
|
+
``secret``
|
|
163
|
+
A string which is used to sign the cookie. As an alternate, you can set
|
|
164
|
+
this to ``None`` and provide a ``cookie_signer`` argument.
|
|
165
|
+
|
|
166
|
+
``timeout``
|
|
167
|
+
A number of seconds of inactivity before a session times out.
|
|
168
|
+
If set to 0 or None, no timeout will occur or be managed in Redis or Python.
|
|
169
|
+
|
|
170
|
+
``cookie_name``
|
|
171
|
+
The name of the cookie used for sessioning. Default: ``session``.
|
|
172
|
+
This is passed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
173
|
+
framework as ``name``.
|
|
174
|
+
|
|
175
|
+
``cookie_max_age``
|
|
176
|
+
The maximum age of the cookie used for sessioning (in seconds).
|
|
177
|
+
Default: ``None`` (browser scope).
|
|
178
|
+
This is passed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
179
|
+
framework as ``max_age``.
|
|
180
|
+
|
|
181
|
+
``cookie_path``
|
|
182
|
+
The path used for the session cookie. Default: ``/``.
|
|
183
|
+
This is passed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
184
|
+
framework as ``path``.
|
|
185
|
+
|
|
186
|
+
``cookie_domain``
|
|
187
|
+
The domain used for the session cookie. Default: ``None`` (no domain).
|
|
188
|
+
This is passed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
189
|
+
framework as ``domain``.
|
|
190
|
+
|
|
191
|
+
``cookie_secure``
|
|
192
|
+
Boolean value; Default: ``False``.
|
|
193
|
+
The 'secure' flag of the session cookie.
|
|
194
|
+
This is passed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
195
|
+
framework as ``secure``.
|
|
196
|
+
|
|
197
|
+
``cookie_httponly``
|
|
198
|
+
Boolean value; Default: ``True``.
|
|
199
|
+
The 'httpOnly' flag of the session cookie.
|
|
200
|
+
This is passed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
201
|
+
framework as ``httponly``.
|
|
202
|
+
|
|
203
|
+
``cookie_expires``
|
|
204
|
+
Default: ``None``.
|
|
205
|
+
Passed to `WebOb.response.Response.set_cookie` as ``expires``.
|
|
206
|
+
This is a ``datetime.datetime``, ``datetime.timedelta``, or ``None``.
|
|
207
|
+
BEWARE: WebOb may be removing this in 1.9.
|
|
208
|
+
|
|
209
|
+
``cookie_comment``
|
|
210
|
+
Default: ``None``.
|
|
211
|
+
The 'comment' attribute of the session cookie.
|
|
212
|
+
This is paseed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
213
|
+
framework as ``comment``.
|
|
214
|
+
If set to ``None`` or not specified, it will not be passed on.
|
|
215
|
+
|
|
216
|
+
``cookie_samesite``
|
|
217
|
+
Default: ``None``.
|
|
218
|
+
The 'SameSite' attribute of the session cookie.
|
|
219
|
+
This is paseed on to the underlying ``WebOb.response.Response.set_cookie``
|
|
220
|
+
framework as ``samesite`` and **requires WebOb 1.8.0 or higher**.
|
|
221
|
+
If set to ``None`` or not specified, it will not be passed on.
|
|
222
|
+
Should only be ``"strict"``, ``"lax"`` or ``"none"``.
|
|
223
|
+
|
|
224
|
+
``cookie_on_exception``
|
|
225
|
+
Boolean value; Default: ``True``.
|
|
226
|
+
If ``True``, set a session cookie even if an exception occurs
|
|
227
|
+
while rendering a view.
|
|
228
|
+
|
|
229
|
+
``redis_url``
|
|
230
|
+
Default: ``None``.
|
|
231
|
+
A connection string for a Redis server, in the format:
|
|
232
|
+
redis://username:password@localhost:6379/0
|
|
233
|
+
|
|
234
|
+
``redis_host`` replacement
|
|
235
|
+
Default: ``localhost``.
|
|
236
|
+
A string representing the IP of your Redis server.
|
|
237
|
+
|
|
238
|
+
``redis_port`` replacement
|
|
239
|
+
Default: ``6379``.
|
|
240
|
+
An integer representing the port of your Redis server.
|
|
241
|
+
|
|
242
|
+
``redis_db`` replacement
|
|
243
|
+
Integer value; Default: ``0``
|
|
244
|
+
An integer to select a specific database on your Redis server.
|
|
245
|
+
|
|
246
|
+
``redis_password`` replacement
|
|
247
|
+
Default: ``None``.
|
|
248
|
+
A string password to connect to your Redis server/database if
|
|
249
|
+
required.
|
|
250
|
+
|
|
251
|
+
``redis_client_callable`` replacement
|
|
252
|
+
Default: ``None``.
|
|
253
|
+
A python callable that accepts a Pyramid `request` and Redis config options
|
|
254
|
+
and returns a Redis client such as redis-py's `StrictRedis`.
|
|
255
|
+
|
|
256
|
+
``serialize``
|
|
257
|
+
Default: ``pickle.dumps``
|
|
258
|
+
A function to serialize the session dict for storage in Redis.
|
|
259
|
+
|
|
260
|
+
``deserialize``
|
|
261
|
+
Default: ``pickle.loads``.
|
|
262
|
+
A function to deserialize the stored session data in Redis.
|
|
263
|
+
|
|
264
|
+
``id_generator``
|
|
265
|
+
Default: private function that uses sha1 with the time and random elements
|
|
266
|
+
to create a 40 character unique ID.
|
|
267
|
+
A function to create a unique ID to be used as the session key when a
|
|
268
|
+
session is first created.
|
|
269
|
+
As of `v1.7`, ``id_generator`` MUST return a str.
|
|
270
|
+
|
|
271
|
+
``set_redis_ttl``
|
|
272
|
+
Boolean value; Default `True`.
|
|
273
|
+
If set to ``True``, will set a TTL. If
|
|
274
|
+
``False`` will not set a TTL and assumes that Redis is configured as a
|
|
275
|
+
least-recently-used cache [http://redis.io/topics/lru-cache] and will NOT
|
|
276
|
+
send EXPIRY data of sessions to Redis (the value of `timeout` will be
|
|
277
|
+
ignored if set). This does not require or imply that no ``timeout`` data is
|
|
278
|
+
handled within the Python payload, it just determines if Redis will be
|
|
279
|
+
involved with timeout logic.
|
|
280
|
+
|
|
281
|
+
``set_redis_ttl_readheavy``
|
|
282
|
+
Boolean value; Default: ``None``.
|
|
283
|
+
If ``True``, sets TTL data in Redis within
|
|
284
|
+
a PIPELINE via GET+EXPIRE and supresses automatic TTL refresh during the
|
|
285
|
+
deferred cleanup phase. If not ``True``, an EXPIRE is sent as a separate
|
|
286
|
+
action during the deferred cleanup phase. The optimized behavior improves
|
|
287
|
+
performance on read-heavy operations, but may degrade performance on
|
|
288
|
+
write-heavy operations.
|
|
289
|
+
This requires a ``timeout`` and ``set_redis_ttl`` to be True; it is not
|
|
290
|
+
compatible with ``timeout_trigger`` or ``python_expires``.
|
|
291
|
+
|
|
292
|
+
``detect_changes``
|
|
293
|
+
Boolean value; Default: ``True``.
|
|
294
|
+
If set to ``True``, will calculate nested changes after
|
|
295
|
+
serialization to ensure persistence of nested data.
|
|
296
|
+
|
|
297
|
+
``deserialized_fails_new``
|
|
298
|
+
Boolean value; Default: ``None``.
|
|
299
|
+
If ``True`` will handle deserializtion errors
|
|
300
|
+
by creating a new session.
|
|
301
|
+
|
|
302
|
+
``func_check_response_allow_cookies``
|
|
303
|
+
Default: ``None``.
|
|
304
|
+
A callable function that accepts a response, returning ``True`` if the
|
|
305
|
+
cookie can be sent and ``False`` if it should not.
|
|
306
|
+
An example callable is available in
|
|
307
|
+
``check_response_allow_cookies``, which checks for `expires` and
|
|
308
|
+
`cache-control` cookies.
|
|
309
|
+
|
|
310
|
+
``func_invalid_logger``
|
|
311
|
+
Default: ``None``.
|
|
312
|
+
A callable function that expects a single argument of a raised
|
|
313
|
+
`InvalidSession` exception. If not ``None``, this will be called so your
|
|
314
|
+
application can monitor.
|
|
315
|
+
|
|
316
|
+
``timeout_trigger``
|
|
317
|
+
Integer value; Default ``None``.
|
|
318
|
+
If unset or ``0``, timeouts will be updated on
|
|
319
|
+
every access by setting an EXPIRY in Redis and/or updating the ``expires``
|
|
320
|
+
value in the session payload. If set to an INT, the updates will only be
|
|
321
|
+
set once the threshold is crossed.
|
|
322
|
+
|
|
323
|
+
``python_expires``
|
|
324
|
+
Boolean value; Default ``True``.
|
|
325
|
+
If ``True``, allows for timeout logic to be
|
|
326
|
+
tracked in Python.
|
|
327
|
+
|
|
328
|
+
``cookie_signer``
|
|
329
|
+
Default: ``None``
|
|
330
|
+
If specified, ``secret`` MUST be ``None``.
|
|
331
|
+
An object with two methods, ``loads`` and ``dumps``::
|
|
332
|
+
The ``loads`` method should accept bytes and return a Python string.
|
|
333
|
+
The ``dumps`` method should accept a Python string and return bytes.
|
|
334
|
+
A ``ValueError`` should be raised for malformed inputs.
|
|
335
|
+
|
|
336
|
+
``redis_socket_timeout``
|
|
337
|
+
Default: ``None``.
|
|
338
|
+
Passthrough argument to the `StrictRedis` constructor.
|
|
339
|
+
|
|
340
|
+
``redis_connection_pool``
|
|
341
|
+
Default: ``None``.
|
|
342
|
+
Passthrough argument to the `StrictRedis` constructor.
|
|
343
|
+
|
|
344
|
+
``redis_encoding``
|
|
345
|
+
Default: ``utf-8``.
|
|
346
|
+
Passthrough argument to the `StrictRedis` constructor.
|
|
347
|
+
|
|
348
|
+
``redis_encoding_errors``
|
|
349
|
+
Default: ``strict``.
|
|
350
|
+
Passthrough argument to the `StrictRedis` constructor.
|
|
351
|
+
|
|
352
|
+
``redis_unix_socket_path``
|
|
353
|
+
Default: ``None``.
|
|
354
|
+
Passthrough argument to the `StrictRedis` constructor.
|
|
355
|
+
|
|
356
|
+
``invalidate_empty_session``
|
|
357
|
+
Default: ``False``
|
|
358
|
+
Will invalidate an empty session, deleting the cookie and expiring
|
|
359
|
+
the backend storage.
|
|
360
|
+
* Defaulted to `True` in 1.8
|
|
361
|
+
* Added in v1.7 with default as `False`
|
|
362
|
+
|
|
363
|
+
``optimistic_concurrency``
|
|
364
|
+
Default: ``False``. If enabled, persistence uses WATCH/GET/MULTI/EXEC
|
|
365
|
+
to reject writes when the stored payload changed or was removed since
|
|
366
|
+
it was loaded. Raises ``SessionConflict`` without retrying stale data.
|
|
367
|
+
This adds Redis round trips only when enabled. All session writers
|
|
368
|
+
must enable it for consistent protection against lost updates.
|
|
369
|
+
|
|
370
|
+
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
|
371
|
+
|
|
372
|
+
Timeout Example
|
|
373
|
+
---------------
|
|
374
|
+
|
|
375
|
+
Given this example:
|
|
376
|
+
|
|
377
|
+
timeout = 1200
|
|
378
|
+
timeout_trigger = 600
|
|
379
|
+
|
|
380
|
+
The following timeline would occur
|
|
381
|
+
|
|
382
|
+
time | action | timeout | next threshold
|
|
383
|
+
-----+--------+---------+--------------
|
|
384
|
+
0 | CREATE | 1200 | 600
|
|
385
|
+
599 | | 1200 | 600
|
|
386
|
+
600 | UPDATE | 1800 | 1200
|
|
387
|
+
599 | | 1800 | 1200
|
|
388
|
+
1200 | UPDATE | 2400 | 1800
|
|
389
|
+
|
|
390
|
+
The feature has the ability to significantly lower the amount of processing
|
|
391
|
+
on Redis, however it means a session timeout expires after the last trigger
|
|
392
|
+
and not the last usage.
|
|
393
|
+
|
|
394
|
+
For example, with a timeout trigger of 10 minutes on a 1 hour session, if a
|
|
395
|
+
user leaves the site at 49 minutes and returns at 61 minutes, the trigger
|
|
396
|
+
will not have been made and the session will have expired.
|
|
397
|
+
|
|
398
|
+
Redis **kwargs
|
|
399
|
+
---------------
|
|
400
|
+
|
|
401
|
+
The following arguments are passed straight to the ``StrictRedis``
|
|
402
|
+
constructor and allow you to further configure the Redis client::
|
|
403
|
+
|
|
404
|
+
modern | previously deprecated name
|
|
405
|
+
-----------------------+--------------------
|
|
406
|
+
redis_socket_timeout | socket_timeout
|
|
407
|
+
redis_connection_pool | connection_pool
|
|
408
|
+
redis_encoding | charset
|
|
409
|
+
redis_encoding_errors | errors
|
|
410
|
+
redis_unix_socket_path | unix_socket_path
|
|
411
|
+
"""
|
|
412
|
+
_validate_session_options(
|
|
413
|
+
timeout=timeout,
|
|
414
|
+
set_redis_ttl=set_redis_ttl,
|
|
415
|
+
set_redis_ttl_readheavy=set_redis_ttl_readheavy,
|
|
416
|
+
timeout_trigger=timeout_trigger,
|
|
417
|
+
python_expires=python_expires,
|
|
418
|
+
)
|
|
419
|
+
if timeout == 0:
|
|
420
|
+
timeout = None
|
|
421
|
+
|
|
422
|
+
if timeout_trigger and not python_expires: # fix this
|
|
423
|
+
python_expires = True
|
|
424
|
+
|
|
425
|
+
_set_redis_ttl_onexit = False
|
|
426
|
+
if (timeout and set_redis_ttl) and (
|
|
427
|
+
not timeout_trigger and not python_expires and not set_redis_ttl_readheavy
|
|
428
|
+
):
|
|
429
|
+
_set_redis_ttl_onexit = True
|
|
430
|
+
|
|
431
|
+
# good for all factory() requests
|
|
432
|
+
set_cookie_kwargs: Dict[str, Any] = {
|
|
433
|
+
"max_age": cookie_max_age,
|
|
434
|
+
"path": cookie_path,
|
|
435
|
+
"domain": cookie_domain,
|
|
436
|
+
"secure": cookie_secure,
|
|
437
|
+
"httponly": cookie_httponly,
|
|
438
|
+
}
|
|
439
|
+
if cookie_comment is not None:
|
|
440
|
+
set_cookie_kwargs["comment"] = cookie_comment
|
|
441
|
+
if cookie_expires is not None:
|
|
442
|
+
set_cookie_kwargs["expires"] = cookie_expires
|
|
443
|
+
if cookie_samesite is not None:
|
|
444
|
+
set_cookie_kwargs["samesite"] = cookie_samesite
|
|
445
|
+
|
|
446
|
+
# good for all factory() requests
|
|
447
|
+
redis_options = dict(
|
|
448
|
+
host=redis_host,
|
|
449
|
+
port=redis_port,
|
|
450
|
+
db=redis_db,
|
|
451
|
+
password=redis_password,
|
|
452
|
+
socket_timeout=redis_socket_timeout,
|
|
453
|
+
connection_pool=redis_connection_pool,
|
|
454
|
+
unix_socket_path=redis_unix_socket_path,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# accept newer encoding and encoding_errors args while
|
|
458
|
+
# retaining backwards compatibility
|
|
459
|
+
if redis_encoding is not None:
|
|
460
|
+
redis_options["encoding"] = redis_encoding
|
|
461
|
+
if redis_encoding_errors is not None:
|
|
462
|
+
redis_options["encoding_errors"] = redis_encoding_errors
|
|
463
|
+
|
|
464
|
+
# good for all factory() requests
|
|
465
|
+
new_payload_func = functools.partial(
|
|
466
|
+
empty_session_payload,
|
|
467
|
+
timeout=timeout or 0,
|
|
468
|
+
python_expires=python_expires,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# good for all factory() requests
|
|
472
|
+
delete_cookie_func = functools.partial(
|
|
473
|
+
_delete_cookie,
|
|
474
|
+
cookie_name=cookie_name,
|
|
475
|
+
cookie_path=cookie_path,
|
|
476
|
+
cookie_domain=cookie_domain,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
_secret_cookiesigner = (secret, cookie_signer)
|
|
480
|
+
_cookie_signer: "SignedSerializerInterface"
|
|
481
|
+
if all(_secret_cookiesigner) or not any(_secret_cookiesigner):
|
|
482
|
+
raise ValueError(
|
|
483
|
+
"One, and only one, of `secret` and `cookie_signer` must be provided."
|
|
484
|
+
)
|
|
485
|
+
if secret is not None:
|
|
486
|
+
# the second argument is the salt.
|
|
487
|
+
# customizing this would needlessly complicate integration
|
|
488
|
+
|
|
489
|
+
# webob docs `SignedSerializer`:
|
|
490
|
+
# An object with two methods: `loads`` and ``dumps``. The ``loads`` method
|
|
491
|
+
# should accept bytes and return a Python object. The ``dumps`` method
|
|
492
|
+
# should accept a Python object and return bytes.
|
|
493
|
+
|
|
494
|
+
# since we use a string session_id, utilizing `_StringSerializer`
|
|
495
|
+
# saves the overhead of using the default JSON serializer
|
|
496
|
+
|
|
497
|
+
# SignedSerializer(secret, salt, hashalg="sha512", serializer=None)
|
|
498
|
+
_cookie_signer = SignedSerializer(
|
|
499
|
+
secret,
|
|
500
|
+
"pyramid_session_redis.",
|
|
501
|
+
"sha512",
|
|
502
|
+
serializer=_StringSerializer(), # convert to a string, not json
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
if TYPE_CHECKING:
|
|
506
|
+
assert cookie_signer is not None
|
|
507
|
+
_cookie_signer = cookie_signer
|
|
508
|
+
|
|
509
|
+
def factory(
|
|
510
|
+
request: "Request",
|
|
511
|
+
new_session_id_func: Callable[..., str] = create_unique_session_id,
|
|
512
|
+
) -> RedisSession:
|
|
513
|
+
# an explicit client callable gets priority over the default
|
|
514
|
+
redis_conn: "RedisClient" = (
|
|
515
|
+
redis_client_callable(request, **redis_options)
|
|
516
|
+
if redis_client_callable is not None
|
|
517
|
+
else get_default_connection(request, url=redis_url, **redis_options) # type: ignore[arg-type]
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
new_session_func = functools.partial(
|
|
521
|
+
new_session_id_func,
|
|
522
|
+
redis=redis_conn,
|
|
523
|
+
timeout=timeout,
|
|
524
|
+
serialize=serialize,
|
|
525
|
+
generator=id_generator,
|
|
526
|
+
set_redis_ttl=set_redis_ttl,
|
|
527
|
+
# set_redis_ttl_readheavy=set_redis_ttl_readheavy, # not needed on NEW
|
|
528
|
+
# _set_redis_ttl_onexit=_set_redis_ttl_onexit, # not needed on NEW
|
|
529
|
+
new_payload_func=new_payload_func,
|
|
530
|
+
python_expires=python_expires,
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
session_id: Optional[TYPING_SESSION_ID]
|
|
534
|
+
try:
|
|
535
|
+
# attempt to retrieve a session_id from the cookie
|
|
536
|
+
session_id = _get_session_id_from_cookie(
|
|
537
|
+
request=request,
|
|
538
|
+
cookie_name=cookie_name,
|
|
539
|
+
cookie_signer=_cookie_signer,
|
|
540
|
+
)
|
|
541
|
+
if not session_id:
|
|
542
|
+
raise InvalidSession_NoSessionCookie("No `session_id` in cookie.")
|
|
543
|
+
session_cookie_was_valid = True
|
|
544
|
+
session = RedisSession(
|
|
545
|
+
redis=redis_conn,
|
|
546
|
+
session_id=session_id,
|
|
547
|
+
new=False,
|
|
548
|
+
new_session=new_session_func,
|
|
549
|
+
new_payload_func=new_payload_func,
|
|
550
|
+
serialize=serialize,
|
|
551
|
+
deserialize=deserialize,
|
|
552
|
+
set_redis_ttl=set_redis_ttl,
|
|
553
|
+
set_redis_ttl_readheavy=set_redis_ttl_readheavy,
|
|
554
|
+
_set_redis_ttl_onexit=_set_redis_ttl_onexit,
|
|
555
|
+
detect_changes=detect_changes,
|
|
556
|
+
deserialized_fails_new=deserialized_fails_new,
|
|
557
|
+
timeout_trigger=timeout_trigger,
|
|
558
|
+
timeout=timeout,
|
|
559
|
+
python_expires=python_expires,
|
|
560
|
+
optimistic_concurrency=optimistic_concurrency,
|
|
561
|
+
)
|
|
562
|
+
except InvalidSession as e:
|
|
563
|
+
if func_invalid_logger is not None:
|
|
564
|
+
# send the instance for logging
|
|
565
|
+
func_invalid_logger(request, e)
|
|
566
|
+
session_id = LazyCreateSession
|
|
567
|
+
session_cookie_was_valid = False
|
|
568
|
+
session = RedisSession(
|
|
569
|
+
redis=redis_conn,
|
|
570
|
+
session_id=session_id,
|
|
571
|
+
new=True,
|
|
572
|
+
new_session=new_session_func,
|
|
573
|
+
new_payload_func=new_payload_func,
|
|
574
|
+
serialize=serialize,
|
|
575
|
+
deserialize=deserialize,
|
|
576
|
+
set_redis_ttl=set_redis_ttl,
|
|
577
|
+
# set_redis_ttl_readheavy=set_redis_ttl_readheavy, # not needed on NEW
|
|
578
|
+
# _set_redis_ttl_onexit=_set_redis_ttl_onexit, # not needed on NEW
|
|
579
|
+
detect_changes=detect_changes,
|
|
580
|
+
timeout_trigger=timeout_trigger,
|
|
581
|
+
timeout=timeout,
|
|
582
|
+
python_expires=python_expires,
|
|
583
|
+
optimistic_concurrency=optimistic_concurrency,
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
set_cookie_func = functools.partial(
|
|
587
|
+
_set_cookie,
|
|
588
|
+
session,
|
|
589
|
+
cookie_signer=_cookie_signer,
|
|
590
|
+
cookie_name=cookie_name,
|
|
591
|
+
**set_cookie_kwargs,
|
|
592
|
+
)
|
|
593
|
+
cookie_callback = functools.partial(
|
|
594
|
+
_cookie_callback,
|
|
595
|
+
session,
|
|
596
|
+
session_cookie_was_valid=session_cookie_was_valid,
|
|
597
|
+
cookie_on_exception=cookie_on_exception,
|
|
598
|
+
set_cookie_func=set_cookie_func,
|
|
599
|
+
delete_cookie_func=delete_cookie_func,
|
|
600
|
+
func_check_response_allow_cookies=func_check_response_allow_cookies,
|
|
601
|
+
invalidate_empty_session=invalidate_empty_session,
|
|
602
|
+
)
|
|
603
|
+
request.add_response_callback(cookie_callback)
|
|
604
|
+
request.add_finished_callback(session._deferred_callback)
|
|
605
|
+
return session
|
|
606
|
+
|
|
607
|
+
return factory
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _get_session_id_from_cookie(
|
|
611
|
+
request: "Request",
|
|
612
|
+
cookie_name: str,
|
|
613
|
+
cookie_signer: "SignedSerializerInterface", # has `.loads`, `.dumps`; MUST return a str
|
|
614
|
+
) -> Optional[str]:
|
|
615
|
+
"""
|
|
616
|
+
Attempts to retrieve and return a session ID from a session cookie in the
|
|
617
|
+
current request. Returns None if the cookie isn't found or the value cannot
|
|
618
|
+
be deserialized for any reason.
|
|
619
|
+
"""
|
|
620
|
+
cookieval = request.cookies.get(cookie_name)
|
|
621
|
+
if cookieval is not None:
|
|
622
|
+
try:
|
|
623
|
+
session_id = cookie_signer.loads(cookieval.encode())
|
|
624
|
+
if TYPE_CHECKING:
|
|
625
|
+
# because we load this, it can't be a `LazyCreateSession`
|
|
626
|
+
assert isinstance(session_id, str)
|
|
627
|
+
return session_id
|
|
628
|
+
except ValueError:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
return None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _set_cookie(
|
|
635
|
+
session: RedisSession,
|
|
636
|
+
request: "Request",
|
|
637
|
+
response: "Response",
|
|
638
|
+
cookie_signer: "SignedSerializerInterface", # has `.loads`, `.dumps`; MUST return a str
|
|
639
|
+
cookie_name: str,
|
|
640
|
+
**kwargs,
|
|
641
|
+
) -> None:
|
|
642
|
+
"""
|
|
643
|
+
`session` is via functools.partial
|
|
644
|
+
`request` and `response` are appended by add_response_callback
|
|
645
|
+
"""
|
|
646
|
+
cookieval = cookie_signer.dumps(session.ensure_id())
|
|
647
|
+
# `.dumps` generates bytes; `.set_cookie` expects str
|
|
648
|
+
response.set_cookie(cookie_name, cookieval.decode(), **kwargs)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _delete_cookie(
|
|
652
|
+
response: "Response",
|
|
653
|
+
cookie_name: str,
|
|
654
|
+
cookie_path: str,
|
|
655
|
+
cookie_domain: Optional[str] = None,
|
|
656
|
+
) -> None:
|
|
657
|
+
response.delete_cookie(cookie_name, path=cookie_path, domain=cookie_domain)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _cookie_callback(
|
|
661
|
+
session: RedisSession,
|
|
662
|
+
request: "Request",
|
|
663
|
+
response: "Response",
|
|
664
|
+
session_cookie_was_valid: bool,
|
|
665
|
+
cookie_on_exception: bool,
|
|
666
|
+
set_cookie_func: Callable[..., None],
|
|
667
|
+
delete_cookie_func: Callable[..., None],
|
|
668
|
+
func_check_response_allow_cookies: Optional[Callable[["Response"], bool]],
|
|
669
|
+
invalidate_empty_session: bool,
|
|
670
|
+
) -> None:
|
|
671
|
+
"""
|
|
672
|
+
Response callback to set the appropriate Set-Cookie header.
|
|
673
|
+
`session` is via functools.partial
|
|
674
|
+
`request` and `response` are appended by add_response_callback
|
|
675
|
+
|
|
676
|
+
Important: `invalidate_empty_session` must remain an option so people
|
|
677
|
+
can keep empty sessions if using a workaround.
|
|
678
|
+
|
|
679
|
+
see: https://github.com/jvanasco/pyramid_session_redis/issues/67
|
|
680
|
+
"""
|
|
681
|
+
|
|
682
|
+
# `session._session_state` will not exist after `invalidate` and other methods
|
|
683
|
+
# so we will extract some info from it...
|
|
684
|
+
please_recookie = None
|
|
685
|
+
_override_args = {}
|
|
686
|
+
if "_session_state" in session.__dict__:
|
|
687
|
+
if session._session_state.please_recookie:
|
|
688
|
+
please_recookie = True
|
|
689
|
+
if session._session_state.cookie_expires != NotSpecified:
|
|
690
|
+
_override_args["expires"] = session._session_state.cookie_expires
|
|
691
|
+
if session._session_state.cookie_max_age != NotSpecified:
|
|
692
|
+
_override_args["max_age"] = session._session_state.cookie_max_age
|
|
693
|
+
|
|
694
|
+
# helper function for multiple contexts
|
|
695
|
+
# note how this helper function uses the above-set `_override_args`
|
|
696
|
+
def _set_cookie_and_response() -> None:
|
|
697
|
+
set_cookie_func(request=request, response=response, **_override_args)
|
|
698
|
+
|
|
699
|
+
# If we set a cookie we need to make sure that downstream
|
|
700
|
+
# web servicess do not serve this response from a cache
|
|
701
|
+
# for requests coming in with a different session cookie.
|
|
702
|
+
# Otherwise we might leak sessions between users.
|
|
703
|
+
varies = ("Cookie",)
|
|
704
|
+
vary = set(response.vary if response.vary is not None else [])
|
|
705
|
+
vary |= set(varies)
|
|
706
|
+
response.vary = vary
|
|
707
|
+
|
|
708
|
+
if func_check_response_allow_cookies is not None:
|
|
709
|
+
if not func_check_response_allow_cookies(response):
|
|
710
|
+
# if we don't want to send cookies on this response,
|
|
711
|
+
# we might not want to persist or refresh
|
|
712
|
+
# session._session_state.dont_persist = True
|
|
713
|
+
# session._session_state.dont_refresh = True
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
# gate this function against `session._invalidated`
|
|
717
|
+
# `session.new` will create a new `_session_state` if invalidated
|
|
718
|
+
# creating a new _session_state will lose the invalidated check
|
|
719
|
+
if invalidate_empty_session and not session._invalidated:
|
|
720
|
+
# if the session is empty...
|
|
721
|
+
if not session.managed_dict:
|
|
722
|
+
if session.new:
|
|
723
|
+
# mark this session to not persist
|
|
724
|
+
session._session_state.dont_persist = True
|
|
725
|
+
session._session_state.dont_refresh = True
|
|
726
|
+
else:
|
|
727
|
+
# invalidate the session, clearing Redis and setting a marker
|
|
728
|
+
session.invalidate()
|
|
729
|
+
# the next block will delete the cookie and return
|
|
730
|
+
|
|
731
|
+
if session._invalidated:
|
|
732
|
+
if session_cookie_was_valid:
|
|
733
|
+
delete_cookie_func(response=response)
|
|
734
|
+
return
|
|
735
|
+
|
|
736
|
+
if session.new:
|
|
737
|
+
if not session.session_id_safecheck:
|
|
738
|
+
return
|
|
739
|
+
if request.exception is None or cookie_on_exception is True:
|
|
740
|
+
_set_cookie_and_response()
|
|
741
|
+
elif session_cookie_was_valid:
|
|
742
|
+
# We don't set a cookie for the new session here (as
|
|
743
|
+
# `cookie_on_exception` is `False` and an exception was raised), but we
|
|
744
|
+
# still need to delete the existing cookie for the session that the
|
|
745
|
+
# request started with (as the session has now been invalidated).
|
|
746
|
+
delete_cookie_func(response=response)
|
|
747
|
+
else:
|
|
748
|
+
if please_recookie:
|
|
749
|
+
if request.exception is None or cookie_on_exception is True:
|
|
750
|
+
_set_cookie_and_response()
|