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.
@@ -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,6 @@
1
+ ## Sessions - db backed
2
+
3
+ Manage sessions and save them in the database.
4
+
5
+ - associate with users?
6
+ - devices?
@@ -0,0 +1 @@
1
+ from . import preflight # noqa
@@ -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,5 @@
1
+ from plain.packages import PackageConfig
2
+
3
+
4
+ class SessionsConfig(PackageConfig):
5
+ name = "plain.sessions"
@@ -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
+ ]
@@ -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"