plain.sessions 0.0.0__tar.gz
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.
- plain_sessions-0.0.0/LICENSE +61 -0
- plain_sessions-0.0.0/PKG-INFO +10 -0
- plain_sessions-0.0.0/plain/sessions/README.md +6 -0
- plain_sessions-0.0.0/plain/sessions/__init__.py +1 -0
- plain_sessions-0.0.0/plain/sessions/backends/__init__.py +0 -0
- plain_sessions-0.0.0/plain/sessions/backends/base.py +348 -0
- plain_sessions-0.0.0/plain/sessions/backends/db.py +110 -0
- plain_sessions-0.0.0/plain/sessions/cli.py +23 -0
- plain_sessions-0.0.0/plain/sessions/config.py +5 -0
- plain_sessions-0.0.0/plain/sessions/default_settings.py +21 -0
- plain_sessions-0.0.0/plain/sessions/exceptions.py +13 -0
- plain_sessions-0.0.0/plain/sessions/middleware.py +77 -0
- plain_sessions-0.0.0/plain/sessions/migrations/0001_initial.py +35 -0
- plain_sessions-0.0.0/plain/sessions/migrations/0002_alter_session_options_alter_session_expire_date_and_more.py +32 -0
- plain_sessions-0.0.0/plain/sessions/migrations/__init__.py +0 -0
- plain_sessions-0.0.0/plain/sessions/models.py +61 -0
- plain_sessions-0.0.0/plain/sessions/preflight.py +98 -0
- plain_sessions-0.0.0/pyproject.toml +17 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
## Plain is released under the BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
BSD 3-Clause License
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2023, Dropseed, LLC
|
|
6
|
+
|
|
7
|
+
Redistribution and use in source and binary forms, with or without
|
|
8
|
+
modification, are permitted provided that the following conditions are met:
|
|
9
|
+
|
|
10
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
list of conditions and the following disclaimer.
|
|
12
|
+
|
|
13
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
and/or other materials provided with the distribution.
|
|
16
|
+
|
|
17
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
18
|
+
contributors may be used to endorse or promote products derived from
|
|
19
|
+
this software without specific prior written permission.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
22
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
23
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
24
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
25
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
26
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
27
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
28
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
29
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
30
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
## This package contains code forked from github.com/django/django
|
|
34
|
+
|
|
35
|
+
Copyright (c) Django Software Foundation and individual contributors.
|
|
36
|
+
All rights reserved.
|
|
37
|
+
|
|
38
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
39
|
+
are permitted provided that the following conditions are met:
|
|
40
|
+
|
|
41
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
42
|
+
this list of conditions and the following disclaimer.
|
|
43
|
+
|
|
44
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
45
|
+
notice, this list of conditions and the following disclaimer in the
|
|
46
|
+
documentation and/or other materials provided with the distribution.
|
|
47
|
+
|
|
48
|
+
3. Neither the name of Django nor the names of its contributors may be used
|
|
49
|
+
to endorse or promote products derived from this software without
|
|
50
|
+
specific prior written permission.
|
|
51
|
+
|
|
52
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
53
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
54
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
55
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
56
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
57
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
58
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
59
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
60
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
61
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: plain.sessions
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: Dave Gaeddert
|
|
6
|
+
Author-email: dave.gaeddert@dropseed.dev
|
|
7
|
+
Requires-Python: >=3.11,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import preflight # noqa
|
|
File without changes
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import string
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from plain import signing
|
|
6
|
+
from plain.runtime import settings
|
|
7
|
+
from plain.utils import timezone
|
|
8
|
+
from plain.utils.crypto import get_random_string
|
|
9
|
+
|
|
10
|
+
# session_key should not be case sensitive because some backends can store it
|
|
11
|
+
# on case insensitive file systems.
|
|
12
|
+
VALID_KEY_CHARS = string.ascii_lowercase + string.digits
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CreateError(Exception):
|
|
16
|
+
"""
|
|
17
|
+
Used internally as a consistent exception type to catch from save (see the
|
|
18
|
+
docstring for SessionBase.save() for details).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UpdateError(Exception):
|
|
25
|
+
"""
|
|
26
|
+
Occurs if Plain tries to update a session that was deleted.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionBase:
|
|
33
|
+
"""
|
|
34
|
+
Base class for all Session classes.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
__not_given = object()
|
|
38
|
+
|
|
39
|
+
def __init__(self, session_key=None):
|
|
40
|
+
self._session_key = session_key
|
|
41
|
+
self.accessed = False
|
|
42
|
+
self.modified = False
|
|
43
|
+
|
|
44
|
+
def __contains__(self, key):
|
|
45
|
+
return key in self._session
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, key):
|
|
48
|
+
return self._session[key]
|
|
49
|
+
|
|
50
|
+
def __setitem__(self, key, value):
|
|
51
|
+
self._session[key] = value
|
|
52
|
+
self.modified = True
|
|
53
|
+
|
|
54
|
+
def __delitem__(self, key):
|
|
55
|
+
del self._session[key]
|
|
56
|
+
self.modified = True
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def key_salt(self):
|
|
60
|
+
return "plain.sessions." + self.__class__.__qualname__
|
|
61
|
+
|
|
62
|
+
def get(self, key, default=None):
|
|
63
|
+
return self._session.get(key, default)
|
|
64
|
+
|
|
65
|
+
def pop(self, key, default=__not_given):
|
|
66
|
+
self.modified = self.modified or key in self._session
|
|
67
|
+
args = () if default is self.__not_given else (default,)
|
|
68
|
+
return self._session.pop(key, *args)
|
|
69
|
+
|
|
70
|
+
def setdefault(self, key, value):
|
|
71
|
+
if key in self._session:
|
|
72
|
+
return self._session[key]
|
|
73
|
+
else:
|
|
74
|
+
self.modified = True
|
|
75
|
+
self._session[key] = value
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
def encode(self, session_dict):
|
|
79
|
+
"Return the given session dictionary serialized and encoded as a string."
|
|
80
|
+
return signing.dumps(
|
|
81
|
+
session_dict,
|
|
82
|
+
salt=self.key_salt,
|
|
83
|
+
compress=True,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def decode(self, session_data):
|
|
87
|
+
try:
|
|
88
|
+
return signing.loads(session_data, salt=self.key_salt)
|
|
89
|
+
except signing.BadSignature:
|
|
90
|
+
logger = logging.getLogger("plain.security.SuspiciousSession")
|
|
91
|
+
logger.warning("Session data corrupted")
|
|
92
|
+
except Exception:
|
|
93
|
+
# ValueError, unpickling exceptions. If any of these happen, just
|
|
94
|
+
# return an empty dictionary (an empty session).
|
|
95
|
+
pass
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
def update(self, dict_):
|
|
99
|
+
self._session.update(dict_)
|
|
100
|
+
self.modified = True
|
|
101
|
+
|
|
102
|
+
def has_key(self, key):
|
|
103
|
+
return key in self._session
|
|
104
|
+
|
|
105
|
+
def keys(self):
|
|
106
|
+
return self._session.keys()
|
|
107
|
+
|
|
108
|
+
def values(self):
|
|
109
|
+
return self._session.values()
|
|
110
|
+
|
|
111
|
+
def items(self):
|
|
112
|
+
return self._session.items()
|
|
113
|
+
|
|
114
|
+
def clear(self):
|
|
115
|
+
# To avoid unnecessary persistent storage accesses, we set up the
|
|
116
|
+
# internals directly (loading data wastes time, since we are going to
|
|
117
|
+
# set it to an empty dict anyway).
|
|
118
|
+
self._session_cache = {}
|
|
119
|
+
self.accessed = True
|
|
120
|
+
self.modified = True
|
|
121
|
+
|
|
122
|
+
def is_empty(self):
|
|
123
|
+
"Return True when there is no session_key and the session is empty."
|
|
124
|
+
try:
|
|
125
|
+
return not self._session_key and not self._session_cache
|
|
126
|
+
except AttributeError:
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def _get_new_session_key(self):
|
|
130
|
+
"Return session key that isn't being used."
|
|
131
|
+
while True:
|
|
132
|
+
session_key = get_random_string(32, VALID_KEY_CHARS)
|
|
133
|
+
if not self.exists(session_key):
|
|
134
|
+
return session_key
|
|
135
|
+
|
|
136
|
+
def _get_or_create_session_key(self):
|
|
137
|
+
if self._session_key is None:
|
|
138
|
+
self._session_key = self._get_new_session_key()
|
|
139
|
+
return self._session_key
|
|
140
|
+
|
|
141
|
+
def _validate_session_key(self, key):
|
|
142
|
+
"""
|
|
143
|
+
Key must be truthy and at least 8 characters long. 8 characters is an
|
|
144
|
+
arbitrary lower bound for some minimal key security.
|
|
145
|
+
"""
|
|
146
|
+
return key and len(key) >= 8
|
|
147
|
+
|
|
148
|
+
def _get_session_key(self):
|
|
149
|
+
return self.__session_key
|
|
150
|
+
|
|
151
|
+
def _set_session_key(self, value):
|
|
152
|
+
"""
|
|
153
|
+
Validate session key on assignment. Invalid values will set to None.
|
|
154
|
+
"""
|
|
155
|
+
if self._validate_session_key(value):
|
|
156
|
+
self.__session_key = value
|
|
157
|
+
else:
|
|
158
|
+
self.__session_key = None
|
|
159
|
+
|
|
160
|
+
session_key = property(_get_session_key)
|
|
161
|
+
_session_key = property(_get_session_key, _set_session_key)
|
|
162
|
+
|
|
163
|
+
def _get_session(self, no_load=False):
|
|
164
|
+
"""
|
|
165
|
+
Lazily load session from storage (unless "no_load" is True, when only
|
|
166
|
+
an empty dict is stored) and store it in the current instance.
|
|
167
|
+
"""
|
|
168
|
+
self.accessed = True
|
|
169
|
+
try:
|
|
170
|
+
return self._session_cache
|
|
171
|
+
except AttributeError:
|
|
172
|
+
if self.session_key is None or no_load:
|
|
173
|
+
self._session_cache = {}
|
|
174
|
+
else:
|
|
175
|
+
self._session_cache = self.load()
|
|
176
|
+
return self._session_cache
|
|
177
|
+
|
|
178
|
+
_session = property(_get_session)
|
|
179
|
+
|
|
180
|
+
def get_session_cookie_age(self):
|
|
181
|
+
return settings.SESSION_COOKIE_AGE
|
|
182
|
+
|
|
183
|
+
def get_expiry_age(self, **kwargs):
|
|
184
|
+
"""Get the number of seconds until the session expires.
|
|
185
|
+
|
|
186
|
+
Optionally, this function accepts `modification` and `expiry` keyword
|
|
187
|
+
arguments specifying the modification and expiry of the session.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
modification = kwargs["modification"]
|
|
191
|
+
except KeyError:
|
|
192
|
+
modification = timezone.now()
|
|
193
|
+
# Make the difference between "expiry=None passed in kwargs" and
|
|
194
|
+
# "expiry not passed in kwargs", in order to guarantee not to trigger
|
|
195
|
+
# self.load() when expiry is provided.
|
|
196
|
+
try:
|
|
197
|
+
expiry = kwargs["expiry"]
|
|
198
|
+
except KeyError:
|
|
199
|
+
expiry = self.get("_session_expiry")
|
|
200
|
+
|
|
201
|
+
if not expiry: # Checks both None and 0 cases
|
|
202
|
+
return self.get_session_cookie_age()
|
|
203
|
+
if not isinstance(expiry, datetime | str):
|
|
204
|
+
return expiry
|
|
205
|
+
if isinstance(expiry, str):
|
|
206
|
+
expiry = datetime.fromisoformat(expiry)
|
|
207
|
+
delta = expiry - modification
|
|
208
|
+
return delta.days * 86400 + delta.seconds
|
|
209
|
+
|
|
210
|
+
def get_expiry_date(self, **kwargs):
|
|
211
|
+
"""Get session the expiry date (as a datetime object).
|
|
212
|
+
|
|
213
|
+
Optionally, this function accepts `modification` and `expiry` keyword
|
|
214
|
+
arguments specifying the modification and expiry of the session.
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
modification = kwargs["modification"]
|
|
218
|
+
except KeyError:
|
|
219
|
+
modification = timezone.now()
|
|
220
|
+
# Same comment as in get_expiry_age
|
|
221
|
+
try:
|
|
222
|
+
expiry = kwargs["expiry"]
|
|
223
|
+
except KeyError:
|
|
224
|
+
expiry = self.get("_session_expiry")
|
|
225
|
+
|
|
226
|
+
if isinstance(expiry, datetime):
|
|
227
|
+
return expiry
|
|
228
|
+
elif isinstance(expiry, str):
|
|
229
|
+
return datetime.fromisoformat(expiry)
|
|
230
|
+
expiry = expiry or self.get_session_cookie_age()
|
|
231
|
+
return modification + timedelta(seconds=expiry)
|
|
232
|
+
|
|
233
|
+
def set_expiry(self, value):
|
|
234
|
+
"""
|
|
235
|
+
Set a custom expiration for the session. ``value`` can be an integer,
|
|
236
|
+
a Python ``datetime`` or ``timedelta`` object or ``None``.
|
|
237
|
+
|
|
238
|
+
If ``value`` is an integer, the session will expire after that many
|
|
239
|
+
seconds of inactivity. If set to ``0`` then the session will expire on
|
|
240
|
+
browser close.
|
|
241
|
+
|
|
242
|
+
If ``value`` is a ``datetime`` or ``timedelta`` object, the session
|
|
243
|
+
will expire at that specific future time.
|
|
244
|
+
|
|
245
|
+
If ``value`` is ``None``, the session uses the global session expiry
|
|
246
|
+
policy.
|
|
247
|
+
"""
|
|
248
|
+
if value is None:
|
|
249
|
+
# Remove any custom expiration for this session.
|
|
250
|
+
try:
|
|
251
|
+
del self["_session_expiry"]
|
|
252
|
+
except KeyError:
|
|
253
|
+
pass
|
|
254
|
+
return
|
|
255
|
+
if isinstance(value, timedelta):
|
|
256
|
+
value = timezone.now() + value
|
|
257
|
+
if isinstance(value, datetime):
|
|
258
|
+
value = value.isoformat()
|
|
259
|
+
self["_session_expiry"] = value
|
|
260
|
+
|
|
261
|
+
def get_expire_at_browser_close(self):
|
|
262
|
+
"""
|
|
263
|
+
Return ``True`` if the session is set to expire when the browser
|
|
264
|
+
closes, and ``False`` if there's an expiry date. Use
|
|
265
|
+
``get_expiry_date()`` or ``get_expiry_age()`` to find the actual expiry
|
|
266
|
+
date/age, if there is one.
|
|
267
|
+
"""
|
|
268
|
+
if (expiry := self.get("_session_expiry")) is None:
|
|
269
|
+
return settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
|
|
270
|
+
return expiry == 0
|
|
271
|
+
|
|
272
|
+
def flush(self):
|
|
273
|
+
"""
|
|
274
|
+
Remove the current session data from the database and regenerate the
|
|
275
|
+
key.
|
|
276
|
+
"""
|
|
277
|
+
self.clear()
|
|
278
|
+
self.delete()
|
|
279
|
+
self._session_key = None
|
|
280
|
+
|
|
281
|
+
def cycle_key(self):
|
|
282
|
+
"""
|
|
283
|
+
Create a new session key, while retaining the current session data.
|
|
284
|
+
"""
|
|
285
|
+
data = self._session
|
|
286
|
+
key = self.session_key
|
|
287
|
+
self.create()
|
|
288
|
+
self._session_cache = data
|
|
289
|
+
if key:
|
|
290
|
+
self.delete(key)
|
|
291
|
+
|
|
292
|
+
# Methods that child classes must implement.
|
|
293
|
+
|
|
294
|
+
def exists(self, session_key):
|
|
295
|
+
"""
|
|
296
|
+
Return True if the given session_key already exists.
|
|
297
|
+
"""
|
|
298
|
+
raise NotImplementedError(
|
|
299
|
+
"subclasses of SessionBase must provide an exists() method"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def create(self):
|
|
303
|
+
"""
|
|
304
|
+
Create a new session instance. Guaranteed to create a new object with
|
|
305
|
+
a unique key and will have saved the result once (with empty data)
|
|
306
|
+
before the method returns.
|
|
307
|
+
"""
|
|
308
|
+
raise NotImplementedError(
|
|
309
|
+
"subclasses of SessionBase must provide a create() method"
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
def save(self, must_create=False):
|
|
313
|
+
"""
|
|
314
|
+
Save the session data. If 'must_create' is True, create a new session
|
|
315
|
+
object (or raise CreateError). Otherwise, only update an existing
|
|
316
|
+
object and don't create one (raise UpdateError if needed).
|
|
317
|
+
"""
|
|
318
|
+
raise NotImplementedError(
|
|
319
|
+
"subclasses of SessionBase must provide a save() method"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
def delete(self, session_key=None):
|
|
323
|
+
"""
|
|
324
|
+
Delete the session data under this key. If the key is None, use the
|
|
325
|
+
current session key value.
|
|
326
|
+
"""
|
|
327
|
+
raise NotImplementedError(
|
|
328
|
+
"subclasses of SessionBase must provide a delete() method"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
def load(self):
|
|
332
|
+
"""
|
|
333
|
+
Load the session data and return a dictionary.
|
|
334
|
+
"""
|
|
335
|
+
raise NotImplementedError(
|
|
336
|
+
"subclasses of SessionBase must provide a load() method"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
@classmethod
|
|
340
|
+
def clear_expired(cls):
|
|
341
|
+
"""
|
|
342
|
+
Remove expired sessions from the session store.
|
|
343
|
+
|
|
344
|
+
If this operation isn't possible on a given backend, it should raise
|
|
345
|
+
NotImplementedError. If it isn't necessary, because the backend has
|
|
346
|
+
a built-in expiration mechanism, it should be a no-op.
|
|
347
|
+
"""
|
|
348
|
+
raise NotImplementedError("This backend does not support clear_expired().")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from plain.exceptions import SuspiciousOperation
|
|
4
|
+
from plain.models import DatabaseError, IntegrityError, router, transaction
|
|
5
|
+
from plain.sessions.backends.base import CreateError, SessionBase, UpdateError
|
|
6
|
+
from plain.utils import timezone
|
|
7
|
+
from plain.utils.functional import cached_property
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionStore(SessionBase):
|
|
11
|
+
"""
|
|
12
|
+
Implement database session store.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, session_key=None):
|
|
16
|
+
super().__init__(session_key)
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def get_model_class(cls):
|
|
20
|
+
# Avoids a circular import and allows importing SessionStore when
|
|
21
|
+
# plain.sessions is not in INSTALLED_PACKAGES.
|
|
22
|
+
from plain.sessions.models import Session
|
|
23
|
+
|
|
24
|
+
return Session
|
|
25
|
+
|
|
26
|
+
@cached_property
|
|
27
|
+
def model(self):
|
|
28
|
+
return self.get_model_class()
|
|
29
|
+
|
|
30
|
+
def _get_session_from_db(self):
|
|
31
|
+
try:
|
|
32
|
+
return self.model.objects.get(
|
|
33
|
+
session_key=self.session_key, expire_date__gt=timezone.now()
|
|
34
|
+
)
|
|
35
|
+
except (self.model.DoesNotExist, SuspiciousOperation) as e:
|
|
36
|
+
if isinstance(e, SuspiciousOperation):
|
|
37
|
+
logger = logging.getLogger("plain.security.%s" % e.__class__.__name__)
|
|
38
|
+
logger.warning(str(e))
|
|
39
|
+
self._session_key = None
|
|
40
|
+
|
|
41
|
+
def load(self):
|
|
42
|
+
s = self._get_session_from_db()
|
|
43
|
+
return self.decode(s.session_data) if s else {}
|
|
44
|
+
|
|
45
|
+
def exists(self, session_key):
|
|
46
|
+
return self.model.objects.filter(session_key=session_key).exists()
|
|
47
|
+
|
|
48
|
+
def create(self):
|
|
49
|
+
while True:
|
|
50
|
+
self._session_key = self._get_new_session_key()
|
|
51
|
+
try:
|
|
52
|
+
# Save immediately to ensure we have a unique entry in the
|
|
53
|
+
# database.
|
|
54
|
+
self.save(must_create=True)
|
|
55
|
+
except CreateError:
|
|
56
|
+
# Key wasn't unique. Try again.
|
|
57
|
+
continue
|
|
58
|
+
self.modified = True
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
def create_model_instance(self, data):
|
|
62
|
+
"""
|
|
63
|
+
Return a new instance of the session model object, which represents the
|
|
64
|
+
current session state. Intended to be used for saving the session data
|
|
65
|
+
to the database.
|
|
66
|
+
"""
|
|
67
|
+
return self.model(
|
|
68
|
+
session_key=self._get_or_create_session_key(),
|
|
69
|
+
session_data=self.encode(data),
|
|
70
|
+
expire_date=self.get_expiry_date(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def save(self, must_create=False):
|
|
74
|
+
"""
|
|
75
|
+
Save the current session data to the database. If 'must_create' is
|
|
76
|
+
True, raise a database error if the saving operation doesn't create a
|
|
77
|
+
new entry (as opposed to possibly updating an existing entry).
|
|
78
|
+
"""
|
|
79
|
+
if self.session_key is None:
|
|
80
|
+
return self.create()
|
|
81
|
+
data = self._get_session(no_load=must_create)
|
|
82
|
+
obj = self.create_model_instance(data)
|
|
83
|
+
using = router.db_for_write(self.model, instance=obj)
|
|
84
|
+
try:
|
|
85
|
+
with transaction.atomic(using=using):
|
|
86
|
+
obj.save(
|
|
87
|
+
force_insert=must_create, force_update=not must_create, using=using
|
|
88
|
+
)
|
|
89
|
+
except IntegrityError:
|
|
90
|
+
if must_create:
|
|
91
|
+
raise CreateError
|
|
92
|
+
raise
|
|
93
|
+
except DatabaseError:
|
|
94
|
+
if not must_create:
|
|
95
|
+
raise UpdateError
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
def delete(self, session_key=None):
|
|
99
|
+
if session_key is None:
|
|
100
|
+
if self.session_key is None:
|
|
101
|
+
return
|
|
102
|
+
session_key = self.session_key
|
|
103
|
+
try:
|
|
104
|
+
self.model.objects.get(session_key=session_key).delete()
|
|
105
|
+
except self.model.DoesNotExist:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def clear_expired(cls):
|
|
110
|
+
cls.get_model_class().objects.filter(expire_date__lt=timezone.now()).delete()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from plain.runtime import settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def cli():
|
|
10
|
+
"""Sessions management commands."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@cli.command()
|
|
15
|
+
def clear_expired():
|
|
16
|
+
engine = import_module(settings.SESSION_ENGINE)
|
|
17
|
+
try:
|
|
18
|
+
engine.SessionStore.clear_expired()
|
|
19
|
+
except NotImplementedError:
|
|
20
|
+
raise NotImplementedError(
|
|
21
|
+
"Session engine '%s' doesn't support clearing expired "
|
|
22
|
+
"sessions." % settings.SESSION_ENGINE
|
|
23
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Cookie name. This can be whatever you want.
|
|
2
|
+
SESSION_COOKIE_NAME = "sessionid"
|
|
3
|
+
# Age of cookie, in seconds (default: 2 weeks).
|
|
4
|
+
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2
|
|
5
|
+
# A string like "example.com", or None for standard domain cookie.
|
|
6
|
+
SESSION_COOKIE_DOMAIN = None
|
|
7
|
+
# Whether the session cookie should be secure (https:// only).
|
|
8
|
+
SESSION_COOKIE_SECURE = False
|
|
9
|
+
# The path of the session cookie.
|
|
10
|
+
SESSION_COOKIE_PATH = "/"
|
|
11
|
+
# Whether to use the HttpOnly flag.
|
|
12
|
+
SESSION_COOKIE_HTTPONLY = True
|
|
13
|
+
# Whether to set the flag restricting cookie leaks on cross-site requests.
|
|
14
|
+
# This can be 'Lax', 'Strict', 'None', or False to disable the flag.
|
|
15
|
+
SESSION_COOKIE_SAMESITE = "Lax"
|
|
16
|
+
# Whether to save the session data on every request.
|
|
17
|
+
SESSION_SAVE_EVERY_REQUEST = False
|
|
18
|
+
# Whether a user's session cookie expires when the web browser is closed.
|
|
19
|
+
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
|
|
20
|
+
# The module to store session data
|
|
21
|
+
SESSION_ENGINE = "plain.sessions.backends.db"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from plain.exceptions import BadRequest, SuspiciousOperation
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SuspiciousSession(SuspiciousOperation):
|
|
5
|
+
"""The session may be tampered with"""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SessionInterrupted(BadRequest):
|
|
11
|
+
"""The session was interrupted."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
|
|
4
|
+
from plain.runtime import settings
|
|
5
|
+
from plain.sessions.backends.base import UpdateError
|
|
6
|
+
from plain.sessions.exceptions import SessionInterrupted
|
|
7
|
+
from plain.utils.cache import patch_vary_headers
|
|
8
|
+
from plain.utils.http import http_date
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SessionMiddleware:
|
|
12
|
+
def __init__(self, get_response):
|
|
13
|
+
self.get_response = get_response
|
|
14
|
+
engine = import_module(settings.SESSION_ENGINE)
|
|
15
|
+
self.SessionStore = engine.SessionStore
|
|
16
|
+
|
|
17
|
+
def __call__(self, request):
|
|
18
|
+
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
|
19
|
+
request.session = self.SessionStore(session_key)
|
|
20
|
+
|
|
21
|
+
response = self.get_response(request)
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
If request.session was modified, or if the configuration is to save the
|
|
25
|
+
session every time, save the changes and set a session cookie or delete
|
|
26
|
+
the session cookie if the session has been emptied.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
accessed = request.session.accessed
|
|
30
|
+
modified = request.session.modified
|
|
31
|
+
empty = request.session.is_empty()
|
|
32
|
+
except AttributeError:
|
|
33
|
+
return response
|
|
34
|
+
# First check if we need to delete this cookie.
|
|
35
|
+
# The session should be deleted only if the session is entirely empty.
|
|
36
|
+
if settings.SESSION_COOKIE_NAME in request.COOKIES and empty:
|
|
37
|
+
response.delete_cookie(
|
|
38
|
+
settings.SESSION_COOKIE_NAME,
|
|
39
|
+
path=settings.SESSION_COOKIE_PATH,
|
|
40
|
+
domain=settings.SESSION_COOKIE_DOMAIN,
|
|
41
|
+
samesite=settings.SESSION_COOKIE_SAMESITE,
|
|
42
|
+
)
|
|
43
|
+
patch_vary_headers(response, ("Cookie",))
|
|
44
|
+
else:
|
|
45
|
+
if accessed:
|
|
46
|
+
patch_vary_headers(response, ("Cookie",))
|
|
47
|
+
if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty:
|
|
48
|
+
if request.session.get_expire_at_browser_close():
|
|
49
|
+
max_age = None
|
|
50
|
+
expires = None
|
|
51
|
+
else:
|
|
52
|
+
max_age = request.session.get_expiry_age()
|
|
53
|
+
expires_time = time.time() + max_age
|
|
54
|
+
expires = http_date(expires_time)
|
|
55
|
+
# Save the session data and refresh the client cookie.
|
|
56
|
+
# Skip session save for 5xx responses.
|
|
57
|
+
if response.status_code < 500:
|
|
58
|
+
try:
|
|
59
|
+
request.session.save()
|
|
60
|
+
except UpdateError:
|
|
61
|
+
raise SessionInterrupted(
|
|
62
|
+
"The request's session was deleted before the "
|
|
63
|
+
"request completed. The user may have logged "
|
|
64
|
+
"out in a concurrent request, for example."
|
|
65
|
+
)
|
|
66
|
+
response.set_cookie(
|
|
67
|
+
settings.SESSION_COOKIE_NAME,
|
|
68
|
+
request.session.session_key,
|
|
69
|
+
max_age=max_age,
|
|
70
|
+
expires=expires,
|
|
71
|
+
domain=settings.SESSION_COOKIE_DOMAIN,
|
|
72
|
+
path=settings.SESSION_COOKIE_PATH,
|
|
73
|
+
secure=settings.SESSION_COOKIE_SECURE or None,
|
|
74
|
+
httponly=settings.SESSION_COOKIE_HTTPONLY or None,
|
|
75
|
+
samesite=settings.SESSION_COOKIE_SAMESITE,
|
|
76
|
+
)
|
|
77
|
+
return response
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import plain.sessions.models
|
|
2
|
+
from plain import models
|
|
3
|
+
from plain.models import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
operations = [
|
|
10
|
+
migrations.CreateModel(
|
|
11
|
+
name="Session",
|
|
12
|
+
fields=[
|
|
13
|
+
(
|
|
14
|
+
"session_key",
|
|
15
|
+
models.CharField(
|
|
16
|
+
max_length=40,
|
|
17
|
+
serialize=False,
|
|
18
|
+
primary_key=True,
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
("session_data", models.TextField()),
|
|
22
|
+
(
|
|
23
|
+
"expire_date",
|
|
24
|
+
models.DateTimeField(db_index=True),
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
options={
|
|
28
|
+
"abstract": False,
|
|
29
|
+
"db_table": "django_session",
|
|
30
|
+
},
|
|
31
|
+
managers=[
|
|
32
|
+
("objects", plain.sessions.models.SessionManager()),
|
|
33
|
+
],
|
|
34
|
+
),
|
|
35
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Generated by Plain 5.0.dev20230814020017 on 2023-08-14 02:09
|
|
2
|
+
|
|
3
|
+
from plain import models
|
|
4
|
+
from plain.models import migrations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
dependencies = [
|
|
9
|
+
("sessions", "0001_initial"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterModelOptions(
|
|
14
|
+
name="session",
|
|
15
|
+
options={},
|
|
16
|
+
),
|
|
17
|
+
migrations.AlterField(
|
|
18
|
+
model_name="session",
|
|
19
|
+
name="expire_date",
|
|
20
|
+
field=models.DateTimeField(db_index=True),
|
|
21
|
+
),
|
|
22
|
+
migrations.AlterField(
|
|
23
|
+
model_name="session",
|
|
24
|
+
name="session_data",
|
|
25
|
+
field=models.TextField(),
|
|
26
|
+
),
|
|
27
|
+
migrations.AlterField(
|
|
28
|
+
model_name="session",
|
|
29
|
+
name="session_key",
|
|
30
|
+
field=models.CharField(max_length=40, primary_key=True, serialize=False),
|
|
31
|
+
),
|
|
32
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from plain import models
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SessionManager(models.Manager):
|
|
5
|
+
use_in_migrations = True
|
|
6
|
+
|
|
7
|
+
def encode(self, session_dict):
|
|
8
|
+
"""
|
|
9
|
+
Return the given session dictionary serialized and encoded as a string.
|
|
10
|
+
"""
|
|
11
|
+
session_store_class = self.model.get_session_store_class()
|
|
12
|
+
return session_store_class().encode(session_dict)
|
|
13
|
+
|
|
14
|
+
def save(self, session_key, session_dict, expire_date):
|
|
15
|
+
s = self.model(session_key, self.encode(session_dict), expire_date)
|
|
16
|
+
if session_dict:
|
|
17
|
+
s.save()
|
|
18
|
+
else:
|
|
19
|
+
s.delete() # Clear sessions with no data.
|
|
20
|
+
return s
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Session(models.Model):
|
|
24
|
+
"""
|
|
25
|
+
Plain provides full support for anonymous sessions. The session
|
|
26
|
+
framework lets you store and retrieve arbitrary data on a
|
|
27
|
+
per-site-visitor basis. It stores data on the server side and
|
|
28
|
+
abstracts the sending and receiving of cookies. Cookies contain a
|
|
29
|
+
session ID -- not the data itself.
|
|
30
|
+
|
|
31
|
+
The Plain sessions framework is entirely cookie-based. It does
|
|
32
|
+
not fall back to putting session IDs in URLs. This is an intentional
|
|
33
|
+
design decision. Not only does that behavior make URLs ugly, it makes
|
|
34
|
+
your site vulnerable to session-ID theft via the "Referer" header.
|
|
35
|
+
|
|
36
|
+
For complete documentation on using Sessions in your code, consult
|
|
37
|
+
the sessions documentation that is shipped with Plain (also available
|
|
38
|
+
on the Plain web site).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
session_key = models.CharField(max_length=40, primary_key=True)
|
|
42
|
+
session_data = models.TextField()
|
|
43
|
+
expire_date = models.DateTimeField(db_index=True)
|
|
44
|
+
|
|
45
|
+
objects = SessionManager()
|
|
46
|
+
|
|
47
|
+
def __str__(self):
|
|
48
|
+
return self.session_key
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def get_session_store_class(cls):
|
|
52
|
+
from plain.sessions.backends.db import SessionStore
|
|
53
|
+
|
|
54
|
+
return SessionStore
|
|
55
|
+
|
|
56
|
+
def get_decoded(self):
|
|
57
|
+
session_store_class = self.get_session_store_class()
|
|
58
|
+
return session_store_class().decode(self.session_data)
|
|
59
|
+
|
|
60
|
+
class Meta:
|
|
61
|
+
db_table = "django_session"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from plain.preflight import Warning, register
|
|
2
|
+
from plain.runtime import settings
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def add_session_cookie_message(message):
|
|
6
|
+
return message + (
|
|
7
|
+
" Using a secure-only session cookie makes it more difficult for "
|
|
8
|
+
"network traffic sniffers to hijack user sessions."
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
W010 = Warning(
|
|
13
|
+
add_session_cookie_message(
|
|
14
|
+
"You have 'plain.sessions' in your INSTALLED_PACKAGES, "
|
|
15
|
+
"but you have not set SESSION_COOKIE_SECURE to True."
|
|
16
|
+
),
|
|
17
|
+
id="security.W010",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
W011 = Warning(
|
|
21
|
+
add_session_cookie_message(
|
|
22
|
+
"You have 'plain.sessions.middleware.SessionMiddleware' "
|
|
23
|
+
"in your MIDDLEWARE, but you have not set "
|
|
24
|
+
"SESSION_COOKIE_SECURE to True."
|
|
25
|
+
),
|
|
26
|
+
id="security.W011",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
W012 = Warning(
|
|
30
|
+
add_session_cookie_message("SESSION_COOKIE_SECURE is not set to True."),
|
|
31
|
+
id="security.W012",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def add_httponly_message(message):
|
|
36
|
+
return message + (
|
|
37
|
+
" Using an HttpOnly session cookie makes it more difficult for "
|
|
38
|
+
"cross-site scripting attacks to hijack user sessions."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
W013 = Warning(
|
|
43
|
+
add_httponly_message(
|
|
44
|
+
"You have 'plain.sessions' in your INSTALLED_PACKAGES, "
|
|
45
|
+
"but you have not set SESSION_COOKIE_HTTPONLY to True.",
|
|
46
|
+
),
|
|
47
|
+
id="security.W013",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
W014 = Warning(
|
|
51
|
+
add_httponly_message(
|
|
52
|
+
"You have 'plain.sessions.middleware.SessionMiddleware' "
|
|
53
|
+
"in your MIDDLEWARE, but you have not set "
|
|
54
|
+
"SESSION_COOKIE_HTTPONLY to True."
|
|
55
|
+
),
|
|
56
|
+
id="security.W014",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
W015 = Warning(
|
|
60
|
+
add_httponly_message("SESSION_COOKIE_HTTPONLY is not set to True."),
|
|
61
|
+
id="security.W015",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register(deploy=True)
|
|
66
|
+
def check_session_cookie_secure(package_configs, **kwargs):
|
|
67
|
+
if settings.SESSION_COOKIE_SECURE is True:
|
|
68
|
+
return []
|
|
69
|
+
errors = []
|
|
70
|
+
if _session_app():
|
|
71
|
+
errors.append(W010)
|
|
72
|
+
if _session_middleware():
|
|
73
|
+
errors.append(W011)
|
|
74
|
+
if len(errors) > 1:
|
|
75
|
+
errors = [W012]
|
|
76
|
+
return errors
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@register(deploy=True)
|
|
80
|
+
def check_session_cookie_httponly(package_configs, **kwargs):
|
|
81
|
+
if settings.SESSION_COOKIE_HTTPONLY is True:
|
|
82
|
+
return []
|
|
83
|
+
errors = []
|
|
84
|
+
if _session_app():
|
|
85
|
+
errors.append(W013)
|
|
86
|
+
if _session_middleware():
|
|
87
|
+
errors.append(W014)
|
|
88
|
+
if len(errors) > 1:
|
|
89
|
+
errors = [W015]
|
|
90
|
+
return errors
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _session_middleware():
|
|
94
|
+
return "plain.sessions.middleware.SessionMiddleware" in settings.MIDDLEWARE
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _session_app():
|
|
98
|
+
return "plain.sessions" in settings.INSTALLED_PACKAGES
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "plain.sessions"
|
|
3
|
+
packages = [
|
|
4
|
+
{ include = "plain" },
|
|
5
|
+
]
|
|
6
|
+
version = "0.0.0"
|
|
7
|
+
description = ""
|
|
8
|
+
authors = ["Dave Gaeddert <dave.gaeddert@dropseed.dev>"]
|
|
9
|
+
# readme = "README.md"
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = "^3.11"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["poetry-core"]
|
|
17
|
+
build-backend = "poetry.core.masonry.api"
|