django-session-security-continued 3.0.0a1__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.
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-session-security-continued
3
+ Version: 3.0.0a1
4
+ Summary: Client and server-side session timeout enforcement with warnings for Django 4.2+.
5
+ Author: Matt Bosworth (https://github.com/mattbo), Fabio Caritas Barrionuevo da Luz (https://github.com/luzfcb), Pēteris Caune (https://github.com/cuu508), John David Giese (https://github.com/johndgiese), Jose Antonio Martin Prieto (https://github.com/jantoniomartin), Richard Moorhead (https://github.com/autodidacticon), Jean-Michel Nirgal Vourgère (https://github.com/nirgal), Michał Pasternak (https://github.com/mpasternak), James Pic (https://github.com/jpic), Matthew Schettler (https://github.com/mschettler), Scott Sexton (https://github.com/scottsexton), Jacek Ostański (https://github.com/jacoor), Aaron Krill (https://github.com/krillr), @yscumc (https://github.com/yscumc), Marco Fucci (https://github.com/marcofucci), Andrei Coman (https://github.com/comandrei), Ali Hasan Imam (https://github.com/alihasanimam), Joel Hillacre (https://github.com/jhillacre), Peter Mack (https://github.com/pmack)
6
+ Maintainer-email: Arrai Innovations <support@arrai.com>
7
+ Project-URL: repository, https://github.com/arrai-innovations/django-session-security-continued
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Framework :: Django
11
+ Classifier: Framework :: Django :: 4.2
12
+ Classifier: Framework :: Django :: 5.0
13
+ Classifier: Framework :: Django :: 5.1
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: django<5.3,>=4.2
29
+ Dynamic: license-file
30
+
31
+ # django-session-security-continued
32
+
33
+ [![code style: ruff][]][ruff] [![code style: prettier][]][prettier] ![ruff status][] ![pip-audit status][]
34
+
35
+ ![python 3.9 status][]
36
+ ![python 3.10 status][]
37
+ ![python 3.11 status][]
38
+ ![python 3.12 status][]
39
+ ![coverage status][]
40
+
41
+ <!--prettier-ignore-start-->
42
+ <!--TOC-->
43
+
44
+ - [About](#about)
45
+ - [Requirements / Compatibility](#requirements--compatibility)
46
+ - [Installation](#installation)
47
+ - [Single Sign-On (SSO) Considerations](#single-sign-on-sso-considerations)
48
+ - [Development](#development)
49
+ - [Testing](#testing)
50
+ - [JavaScript coverage](#javascript-coverage)
51
+ - [Contributing](#contributing)
52
+
53
+ <!--TOC-->
54
+ <!--prettier-ignore-end-->
55
+
56
+ ## About
57
+
58
+ A minimal JavaScript and Django middleware app that automatically logs out users after inactivity. It tracks activity across all browser tabs, warns users before logging them out, and protects sensitive data.
59
+
60
+ Built for CRMs, intranets, and similar applications, it prevents abandoned sessions from staying open when users leave their workstations. Unlike simply setting session expiry, this approach ensures users aren’t logged out while reading, reviewing data, or filling out forms; preserving their work and reducing frustration while still enforcing inactivity-based security.
61
+
62
+ This fork is maintained by Arrai Innovations Inc. based on the original [`django-session-security`](https://github.com/yourlabs/django-session-security) by Yourlabs.
63
+
64
+ ## Requirements / Compatibility
65
+
66
+ - **Django:** 4.2, 5.2
67
+ - `django.contrib.staticfiles`
68
+ - **Python:** 3.9, 3.10, 3.11, 3.12
69
+
70
+ ## Installation
71
+
72
+ ```console
73
+ # Install the package
74
+ $ pip install django-session-security-continued
75
+ ```
76
+
77
+ ```python
78
+ # settings.py
79
+
80
+ INSTALLED_APPS = [
81
+ # Add the app
82
+ 'session_security',
83
+ # ...
84
+ ]
85
+
86
+ MIDDLEWARE = [
87
+ # Make sure this comes AFTER the authentication middleware
88
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
89
+ 'session_security.middleware.SessionSecurityMiddleware',
90
+ # ...
91
+ ]
92
+
93
+ TEMPLATES = [
94
+ {
95
+ # ...
96
+ 'OPTIONS': {
97
+ 'context_processors': [
98
+ # Ensure this is present
99
+ 'django.template.context_processors.request',
100
+ # ...
101
+ ],
102
+ },
103
+ },
104
+ ]
105
+
106
+ # Optional settings (see configuration section for details)
107
+ SESSION_SECURITY_WARN_AFTER = 540 # Warn user after 9 minutes
108
+ SESSION_SECURITY_EXPIRE_AFTER = 600 # Log out after 10 minutes
109
+ SESSION_SECURITY_PASSIVE_URLS = [] # URLs that won’t reset the timer
110
+ SESSION_SECURITY_REDIRECT_TO_LOGOUT = False # Set True for SSO setups
111
+ SESSION_SECURITY_PING_URL = '/session_security/ping/' # Activity endpoint
112
+ SESSION_SECURITY_JS_PATH = 'session_security/script.js' # Override to load custom bundles (tests/coverage)
113
+ ```
114
+
115
+ ```python
116
+ # urls.py
117
+
118
+ from django.urls import include, path
119
+
120
+ urlpatterns = [
121
+ # Add this route to enable the session security endpoints
122
+ path('session_security/', include('session_security.urls')),
123
+ # ...
124
+ ]
125
+ ```
126
+
127
+ ```html
128
+ <!-- base.html (or equivalent) -->
129
+ {% load static %}
130
+ ...
131
+ {% include "session_security/all.html" %}
132
+ <script>
133
+ // optional: disable form discard confirmation dialog
134
+ sessionSecurity.confirmFormDiscard = undefined;
135
+ // optional: register custom activity
136
+ sessionSecurity.activity();
137
+ </script>
138
+ ```
139
+
140
+ ## Single Sign-On (SSO) Considerations
141
+
142
+ When using SSO, the default page reload after timeout may cause automatic re-login if the SSO session remains valid. Set `SESSION_SECURITY_REDIRECT_TO_LOGOUT = True` to explicitly end the app session by redirecting to `LOGOUT_REDIRECT_URL`. Note that this does **not** terminate the SSO provider session; configure a matching timeout on your SSO server for full coverage.
143
+
144
+ ## Development
145
+
146
+ This project uses `uv` for managing the development environment. To set up the development environment, follow these steps:
147
+
148
+ ```console
149
+ # Clone the repository
150
+ $ git clone https://github.com/arrai-innovations/django-session-security-continued.git
151
+ $ cd django-session-security-continued
152
+
153
+ # Ensure a compatible Python (>=3.9) is installed
154
+
155
+ # Install uv if not already installed
156
+ $ pip install --user --upgrade uv
157
+
158
+ # Create and sync the dev environment
159
+ # (default group includes dev dependencies)
160
+ $ uv sync
161
+
162
+ # (Optional) Run Git hooks setup
163
+ $ uv run pre-commit install
164
+
165
+ # Install JS tooling for the client bundle / coverage builds
166
+ $ npm install
167
+ ```
168
+
169
+ ## Testing
170
+
171
+ Chrome is required for the Selenium end-to-end tests (Selenium Manager will download the matching chromedriver automatically). Run the full suite with pytest:
172
+
173
+ ```console
174
+ $ uv run pytest
175
+ ```
176
+
177
+ If Chrome isn’t available (or you only want the fast unit tests), skip the browser suite with `uv run pytest -m "not selenium"`.
178
+
179
+ Add extra breathing room to the Selenium waits (in CI) by exporting `SESSION_SECURITY_TIMEOUT_PADDING` (in seconds). For example, `SESSION_SECURITY_TIMEOUT_PADDING=5 uv run pytest -k selenium` gives each warning/expiry wait up to five additional seconds before failing.
180
+
181
+ ### JavaScript coverage
182
+
183
+ We ship a Vite + Istanbul build that instruments the client bundle and collects coverage from the Selenium run:
184
+
185
+ 1. `npm run build:coverage`
186
+ 2. `SESSION_SECURITY_JS_COVERAGE=1 uv run pytest -k selenium`
187
+ 3. `npm run coverage:report` (writes reports to `coverage-js/` and `lcov.info`)
188
+
189
+ The `SESSION_SECURITY_JS_COVERAGE` flag makes the Django test settings load the instrumented bundle and dumps `window.__coverage__` into `.nyc_output/` after each Selenium test.
190
+
191
+ ## Contributing
192
+
193
+ Contributions are welcome. Please fork the repository and create a pull request with your changes. We reserve the right to review and modify your contributions before merging them into the main branch. By submitting a change you confirm that:
194
+
195
+ - You wrote the code (or have the right to contribute it), and
196
+ - You’re happy for it to be released under this project’s MIT license.
197
+
198
+ [code style: ruff]: https://img.shields.io/badge/code%20style-ruff-000000.svg?style=for-the-badge
199
+ [ruff]: https://docs.astral.sh/ruff/formatter/#style-guide
200
+ [code style: prettier]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=for-the-badge
201
+ [prettier]: https://github.com/prettier/prettier
202
+ [python 3.9 status]: https://docs.arrai.dev/dssc/artifacts/main/python_3.9.svg
203
+ [python 3.10 status]: https://docs.arrai.dev/dssc/artifacts/main/python_3.10.svg
204
+ [python 3.11 status]: https://docs.arrai.dev/dssc/artifacts/main/python_3.11.svg
205
+ [python 3.12 status]: https://docs.arrai.dev/dssc/artifacts/main/python_3.12.svg
206
+ [coverage status]: https://docs.arrai.dev/dssc/artifacts/main/python_3.9.coverage.svg
207
+ [ruff status]: https://docs.arrai.dev/dssc/artifacts/main/ruff.svg
208
+ [pipenv]: https://github.com/pypa/pipenv
209
+ [pip-audit status]: https://docs.arrai.dev/dssc/artifacts/main/pip-audit.svg
@@ -0,0 +1,25 @@
1
+ django_session_security_continued-3.0.0a1.dist-info/licenses/LICENSE,sha256=sTEwnChiEDBXv8ZDFVYDAhXfIA1wjpwuIhTVDhGLssw,1107
2
+ session_security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ session_security/middleware.py,sha256=matyK1lCSv5ZeIRWoxj-yThKNDMHRUM1Xf929pWTVmE,4008
4
+ session_security/models.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ session_security/settings.py,sha256=pIxklXg3F1uyvsb0dl7ArQ0vquWLCie0CC5vy_fICas,2242
6
+ session_security/urls.py,sha256=QK_diUsqjyQkU6UQpUW9SV_3kpf0223glrtnM7jft7M,510
7
+ session_security/utils.py,sha256=d19NpP7f5kEdrVdZIae95c-Oeuw9gk0m4pATzuzlw3w,466
8
+ session_security/views.py,sha256=ktla3T5Pk8qajdVTIhatWKprm10iIjM-Ek495APDBLs,879
9
+ session_security/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ session_security/templatetags/session_security_tags.py,sha256=Vhrxe0ThWpEbQZUlqxKl9XXOx8HhF96EoBmVk5hxhrc,627
11
+ session_security/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ session_security/tests/conftest.py,sha256=MFGSYiQ_9ViCKgr0BL0x9cB4eJR29m1XjMkoY1J6SeE,4756
13
+ session_security/tests/test_base.py,sha256=bvqz385wyyBQNhrpeTyUTY0fWosLB9wOhPOBrt0tOBM,1775
14
+ session_security/tests/test_middleware.py,sha256=iLFlXXfoBwUBNUJPhyRvPR657y-MORj1nyjh3GXFNPc,3612
15
+ session_security/tests/test_script.py,sha256=y-gNGt6vLgsjfvqIk7GsuS5RijoSVo50kzKpKkUXcJs,2707
16
+ session_security/tests/test_templates.py,sha256=qXukArdCZvvthGLyHZzise9xV6Q8kFx-eUy8TCxsYXU,471
17
+ session_security/tests/test_views.py,sha256=97Ssf4nXGj0OIGc-fFf3_LdjC3k9yFMr6QD9CRsTrLc,1183
18
+ session_security/tests/project/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ session_security/tests/project/settings.py,sha256=BAN820rroRvYMKQPtTYvqkdjmy9ce5fznFWelrBG58c,2999
20
+ session_security/tests/project/urls.py,sha256=wFMGt1wQLCdoaAsJRltFypR473RJnkxbqReWh0FMFWE,1136
21
+ session_security/tests/project/wsgi.py,sha256=VFz8yKLbSm4-C5ejuLJ_ZuPoKZ1WP17WJVMx2R17Z8M,426
22
+ django_session_security_continued-3.0.0a1.dist-info/METADATA,sha256=jSCRkYxzDg-MpeqLKPxe23zGuccNJg4oBE1LUZsr5Kg,8935
23
+ django_session_security_continued-3.0.0a1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
24
+ django_session_security_continued-3.0.0a1.dist-info/top_level.txt,sha256=sUgnA1DNG4V434n9luYoNsltkfMgkCnI6GBZV6oKWJI,17
25
+ django_session_security_continued-3.0.0a1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 YourLabs
4
+ Copyright (c) 2025 Arrai Innovations Inc.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ session_security
File without changes
@@ -0,0 +1,119 @@
1
+ """
2
+ SessionSecurityMiddleware is the heart of the security that this application
3
+ attemps to provide.
4
+
5
+ To install this middleware, add to ``settings.MIDDLEWARE``::
6
+
7
+ 'session_security.middleware.SessionSecurityMiddleware'
8
+
9
+ Place it after authentication middleware.
10
+ """
11
+
12
+ from datetime import datetime
13
+ from datetime import timedelta
14
+
15
+ from django.conf import settings as django_settings
16
+ from django.urls import Resolver404
17
+ from django.urls import resolve
18
+ from django.urls import reverse
19
+ from django.utils.deprecation import MiddlewareMixin
20
+
21
+ from session_security.utils import get_last_activity
22
+ from session_security.utils import set_last_activity
23
+
24
+
25
+ class SessionSecurityMiddleware(MiddlewareMixin):
26
+ """
27
+ In charge of maintaining the real 'last activity' time, and log out the
28
+ user if appropriate.
29
+ """
30
+
31
+ def is_passive_request(self, request):
32
+ """Should we skip activity update on this URL/View."""
33
+ from session_security.settings import PASSIVE_URL_NAMES as DEFAULT_PASSIVE_URL_NAMES
34
+ from session_security.settings import PASSIVE_URLS as DEFAULT_PASSIVE_URLS
35
+
36
+ passive_urls = getattr(
37
+ django_settings,
38
+ "SESSION_SECURITY_PASSIVE_URLS",
39
+ DEFAULT_PASSIVE_URLS,
40
+ )
41
+ passive_url_names = getattr(
42
+ django_settings,
43
+ "SESSION_SECURITY_PASSIVE_URL_NAMES",
44
+ DEFAULT_PASSIVE_URL_NAMES,
45
+ )
46
+
47
+ if request.path in passive_urls:
48
+ return True
49
+
50
+ try:
51
+ match = resolve(request.path)
52
+ # TODO: check namespaces too
53
+ if match.url_name in passive_url_names:
54
+ return True
55
+ except Resolver404:
56
+ pass
57
+
58
+ return False
59
+
60
+ def get_expire_seconds(self, request):
61
+ """Return time (in seconds) before the user should be logged out."""
62
+ from session_security.settings import EXPIRE_AFTER
63
+
64
+ return EXPIRE_AFTER
65
+
66
+ def process_request(self, request):
67
+ """Update last activity time or logout."""
68
+ if not self.is_authenticated(request):
69
+ return
70
+
71
+ now = datetime.now()
72
+ if "_session_security" not in request.session:
73
+ set_last_activity(request.session, now)
74
+ return
75
+
76
+ delta = now - get_last_activity(request.session)
77
+ expire_seconds = self.get_expire_seconds(request)
78
+ if delta >= timedelta(seconds=expire_seconds):
79
+ self.do_logout(request)
80
+ elif request.path == reverse("session_security_ping") and "idleFor" in request.GET:
81
+ self.update_last_activity(request, now)
82
+ elif not self.is_passive_request(request):
83
+ set_last_activity(request.session, now)
84
+
85
+ def update_last_activity(self, request, now):
86
+ """
87
+ If ``request.GET['idleFor']`` is set, check if it refers to a more
88
+ recent activity than ``request.session['_session_security']`` and
89
+ update it in this case.
90
+ """
91
+ last_activity = get_last_activity(request.session)
92
+ server_idle_for = (now - last_activity).seconds
93
+
94
+ # Gracefully ignore non-integer values
95
+ try:
96
+ client_idle_for = int(request.GET["idleFor"])
97
+ except ValueError:
98
+ return
99
+
100
+ # Disallow negative values, causes problems with delta calculation
101
+ if client_idle_for < 0:
102
+ client_idle_for = 0
103
+
104
+ if client_idle_for < server_idle_for:
105
+ # Client has more recent activity than we have in the session
106
+ last_activity = now - timedelta(seconds=client_idle_for)
107
+
108
+ # Update the session
109
+ set_last_activity(request.session, last_activity)
110
+
111
+ def is_authenticated(self, request):
112
+ """Provide a hook for subclasses that want custom auth logic."""
113
+ return request.user.is_authenticated
114
+
115
+ def do_logout(self, request):
116
+ """Provide a hook for subclasses that want a custom logout implementation."""
117
+ from django.contrib.auth import logout
118
+
119
+ logout(request)
File without changes
@@ -0,0 +1,53 @@
1
+ """
2
+ Settings for django-session-security.
3
+
4
+ WARN_AFTER
5
+ Time (in seconds) before the user should be warned that is session will
6
+ expire because of inactivity. Default 540. Overridable in
7
+ ``settings.SESSION_SECURITY_WARN_AFTER``.
8
+
9
+ EXPIRE_AFTER
10
+ Time (in seconds) before the user should be logged out if inactive. Default
11
+ is 600. Overridable in ``settings.SESSION_SECURITY_EXPIRE_AFTER``.
12
+
13
+ PASSIVE_URLS
14
+ List of urls that should be ignored by the middleware. For example the ping
15
+ ajax request of session_security is made without user intervention, as such
16
+ it should not be used to update the user's last activity datetime.
17
+ Overridable in ``settings.SESSION_SECURITY_PASSIVE_URLS``.
18
+
19
+ PASSIVE_URL_NAMES
20
+ Same as PASSIVE_URLS, but takes Django URL names instead of a path. This
21
+ is useful in case path names change, or contain parameterized values, and
22
+ thus cannot be described statically. NOTE: currently namespaces are not
23
+ handled. Overridable in ``settings.SESSION_SECURITY_PASSIVE_URL_NAMES``.
24
+
25
+ SESSION_SECURITY_INSECURE
26
+ Set this to True in your settings if you want the project to run without
27
+ having to set SESSION_EXPIRE_AT_BROWSER_CLOSE=True, which you should
28
+ because it makes no sense to use this app with
29
+ ``SESSION_EXPIRE_AT_BROWSER_CLOSE`` to False.
30
+
31
+ SESSION_SECURITY_JS_PATH
32
+ Override the static path for the client bundle. Defaults to
33
+ ``session_security/script.js``; useful for loading instrumented builds in tests.
34
+ """
35
+
36
+ from django.conf import settings
37
+
38
+
39
+ __all__ = ["EXPIRE_AFTER", "PASSIVE_URLS", "WARN_AFTER"]
40
+
41
+ # WARNING: These values cannot be reconfigured by tests
42
+ EXPIRE_AFTER = getattr(settings, "SESSION_SECURITY_EXPIRE_AFTER", 600)
43
+
44
+ WARN_AFTER = getattr(settings, "SESSION_SECURITY_WARN_AFTER", 540)
45
+
46
+ PASSIVE_URLS = getattr(settings, "SESSION_SECURITY_PASSIVE_URLS", [])
47
+ PASSIVE_URL_NAMES = getattr(settings, "SESSION_SECURITY_PASSIVE_URL_NAMES", [])
48
+
49
+ expire_at_browser_close = getattr(settings, "SESSION_EXPIRE_AT_BROWSER_CLOSE", False)
50
+ force_insecurity = getattr(settings, "SESSION_SECURITY_INSECURE", False)
51
+
52
+ if not (expire_at_browser_close or force_insecurity):
53
+ raise Exception("Enable SESSION_EXPIRE_AT_BROWSER_CLOSE or SESSION_SECURITY_INSECURE")
File without changes
@@ -0,0 +1,29 @@
1
+ from django import template
2
+ from django.conf import settings
3
+
4
+ from session_security.settings import EXPIRE_AFTER
5
+ from session_security.settings import WARN_AFTER
6
+
7
+
8
+ register = template.Library()
9
+
10
+
11
+ @register.filter
12
+ def expire_after(request):
13
+ return EXPIRE_AFTER
14
+
15
+
16
+ @register.filter
17
+ def warn_after(request):
18
+ return WARN_AFTER
19
+
20
+
21
+ @register.filter
22
+ def redirect_to_logout(request):
23
+ redirect = getattr(settings, "SESSION_SECURITY_REDIRECT_TO_LOGOUT", False)
24
+ return redirect
25
+
26
+
27
+ @register.simple_tag
28
+ def session_security_script_path():
29
+ return getattr(settings, "SESSION_SECURITY_JS_PATH", "session_security/script.js")
File without changes
@@ -0,0 +1,149 @@
1
+ import json
2
+ import os
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import datetime
6
+ from datetime import timedelta
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from selenium import webdriver
11
+ from selenium.webdriver.chrome.options import Options as ChromeOptions
12
+ from selenium.webdriver.common.by import By
13
+
14
+
15
+ @dataclass
16
+ class ActivityWindow:
17
+ min_warn_after: float
18
+ max_warn_after: float
19
+ min_expire_after: float
20
+ max_expire_after: float
21
+
22
+
23
+ @pytest.fixture
24
+ def admin_user(db, django_user_model):
25
+ return django_user_model.objects.create_superuser(
26
+ username="test",
27
+ email="test@example.com",
28
+ password="test",
29
+ )
30
+
31
+
32
+ @pytest.fixture
33
+ def user(db, django_user_model):
34
+ return django_user_model.objects.create_user(
35
+ username="regular",
36
+ email="user@example.com",
37
+ password="test",
38
+ )
39
+
40
+
41
+ @pytest.fixture
42
+ def authenticated_client(client, admin_user):
43
+ assert client.login(username="test", password="test")
44
+ return client
45
+
46
+
47
+ TIMEOUT_PADDING_ENV = "SESSION_SECURITY_TIMEOUT_PADDING"
48
+
49
+
50
+ def _timeout_padding_seconds() -> float:
51
+ raw_value = os.environ.get(TIMEOUT_PADDING_ENV)
52
+ if not raw_value:
53
+ return 0.0
54
+ try:
55
+ padding = float(raw_value)
56
+ except ValueError as exc:
57
+ raise RuntimeError(
58
+ f"{TIMEOUT_PADDING_ENV} must be a number representing seconds of extra wait time; got {raw_value!r}."
59
+ ) from exc
60
+ return max(0.0, padding)
61
+
62
+
63
+ @pytest.fixture
64
+ def activity_window(settings):
65
+ expire_after = settings.SESSION_SECURITY_EXPIRE_AFTER
66
+ warn_after = settings.SESSION_SECURITY_WARN_AFTER
67
+ padding = _timeout_padding_seconds()
68
+ warn_margin = 0.5 # always keep at least this much headroom before expiry
69
+ max_warn_cap = max(warn_after, expire_after - warn_margin)
70
+ max_warn_after = min(expire_after * 0.9 + padding, max_warn_cap)
71
+ return ActivityWindow(
72
+ min_warn_after=warn_after,
73
+ max_warn_after=max_warn_after,
74
+ min_expire_after=expire_after,
75
+ max_expire_after=expire_after * 1.5 + padding,
76
+ )
77
+
78
+
79
+ @pytest.fixture
80
+ def frozen_time(monkeypatch):
81
+ class FrozenDateTime:
82
+ def __init__(self):
83
+ self._current = datetime.now()
84
+
85
+ def now(self):
86
+ return self._current
87
+
88
+ def advance(self, seconds: float):
89
+ self._current += timedelta(seconds=seconds)
90
+
91
+ freezer = FrozenDateTime()
92
+ monkeypatch.setattr("session_security.middleware.datetime", freezer)
93
+ monkeypatch.setattr("session_security.views.datetime", freezer)
94
+ return freezer
95
+
96
+
97
+ JS_COVERAGE_ENV = "SESSION_SECURITY_JS_COVERAGE"
98
+ JS_COVERAGE_STATIC_PATH = "session_security/coverage/script.js"
99
+ REPO_ROOT = Path(__file__).resolve().parents[2]
100
+ NYC_DIR = Path(".nyc_output")
101
+
102
+
103
+ @pytest.fixture
104
+ def selenium_browser(live_server, admin_user, settings):
105
+ use_js_coverage = bool(os.environ.get(JS_COVERAGE_ENV))
106
+ if use_js_coverage:
107
+ settings.SESSION_SECURITY_JS_PATH = JS_COVERAGE_STATIC_PATH
108
+ coverage_bundle = REPO_ROOT / "session_security" / "static" / "session_security" / "coverage" / "script.js"
109
+ if not coverage_bundle.exists():
110
+ raise RuntimeError(
111
+ "Instrumented session security bundle not found. "
112
+ "Run `npm install` (once) and `npm run build:coverage` before running Selenium coverage tests."
113
+ )
114
+
115
+ options = ChromeOptions()
116
+ options.add_argument("--headless=new")
117
+ options.add_argument("--disable-gpu")
118
+ options.add_argument("--no-sandbox")
119
+ options.add_argument("--disable-dev-shm-usage")
120
+
121
+ driver = webdriver.Chrome(options=options)
122
+ driver.get(f"{live_server.url}/admin/")
123
+ driver.find_element(By.NAME, "username").send_keys("test")
124
+ driver.find_element(By.NAME, "password").send_keys("test")
125
+ driver.find_element(By.XPATH, '//input[@value="Log in"]').click()
126
+ driver.execute_script('window.open("/admin/", "other")')
127
+
128
+ if use_js_coverage:
129
+ script_sources = driver.execute_script(
130
+ "return Array.from(document.getElementsByTagName('script')).map(s => s.src);"
131
+ )
132
+ if not any("session_security/coverage/script.js" in src for src in script_sources):
133
+ raise RuntimeError(
134
+ "Instrumented session security script was not loaded; check SESSION_SECURITY_JS_PATH configuration."
135
+ )
136
+
137
+ yield driver
138
+
139
+ if use_js_coverage:
140
+ NYC_DIR.mkdir(exist_ok=True)
141
+ try:
142
+ coverage_data = driver.execute_script("return window.__coverage__ || null;")
143
+ except Exception:
144
+ coverage_data = None
145
+ if coverage_data:
146
+ filename = f"{uuid.uuid4().hex}.json"
147
+ (NYC_DIR / filename).write_text(json.dumps(coverage_data))
148
+
149
+ driver.quit()
File without changes
@@ -0,0 +1,112 @@
1
+ """
2
+ Django settings for project project.
3
+
4
+ Generated by 'django-admin startproject' using Django 1.8.3.dev20150604012123.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/dev/topics/settings/
8
+
9
+ For the full list of settings and their values, see
10
+ https://docs.djangoproject.com/en/dev/ref/settings/
11
+ """
12
+
13
+ # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
14
+ import os
15
+
16
+
17
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
18
+
19
+
20
+ # Quick-start development settings - unsuitable for production
21
+ # See https://docs.djangoproject.com/en/dev/howto/deployment/checklist/
22
+
23
+ # SECURITY WARNING: keep the secret key used in production secret!
24
+ SECRET_KEY = "#vhyi-*846#q09+him)ogenb#j7^3(w5($c8c1@)sy781(!8fm"
25
+
26
+ # SECURITY WARNING: don't run with debug turned on in production!
27
+ DEBUG = True
28
+
29
+ ALLOWED_HOSTS = []
30
+
31
+
32
+ # Application definition
33
+
34
+ INSTALLED_APPS = (
35
+ "django.contrib.admin",
36
+ "django.contrib.auth",
37
+ "django.contrib.contenttypes",
38
+ "django.contrib.sessions",
39
+ "django.contrib.messages",
40
+ "django.contrib.staticfiles",
41
+ "session_security",
42
+ )
43
+ MIDDLEWARE = (
44
+ "django.middleware.common.CommonMiddleware",
45
+ "django.contrib.sessions.middleware.SessionMiddleware",
46
+ "django.middleware.csrf.CsrfViewMiddleware",
47
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
48
+ "django.contrib.messages.middleware.MessageMiddleware",
49
+ "session_security.middleware.SessionSecurityMiddleware",
50
+ # Uncomment the next line for simple clickjacking protection:
51
+ # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
52
+ )
53
+
54
+ ROOT_URLCONF = "session_security.tests.project.urls"
55
+
56
+ TEMPLATE_DIRS = [os.path.join(BASE_DIR, "templates"), "templates"]
57
+
58
+ TEMPLATES = [
59
+ {
60
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
61
+ "DIRS": TEMPLATE_DIRS,
62
+ "APP_DIRS": True,
63
+ "OPTIONS": {
64
+ "context_processors": [
65
+ "django.template.context_processors.debug",
66
+ "django.template.context_processors.request",
67
+ "django.contrib.auth.context_processors.auth",
68
+ "django.contrib.messages.context_processors.messages",
69
+ ]
70
+ },
71
+ },
72
+ ]
73
+
74
+ STATICFILES_DIRS = (os.path.join(BASE_DIR, "static"),)
75
+
76
+ WSGI_APPLICATION = "session_security.tests.project.wsgi.application"
77
+
78
+
79
+ # Database
80
+ # https://docs.djangoproject.com/en/dev/ref/settings/#databases
81
+
82
+ DATABASES = {
83
+ "default": {
84
+ "ENGINE": "django.db.backends.sqlite3",
85
+ "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
86
+ }
87
+ }
88
+
89
+
90
+ # Internationalization
91
+ # https://docs.djangoproject.com/en/dev/topics/i18n/
92
+
93
+ LANGUAGE_CODE = "en-us"
94
+
95
+ TIME_ZONE = "UTC"
96
+
97
+ USE_I18N = True
98
+
99
+ USE_L10N = True
100
+
101
+ USE_TZ = True
102
+
103
+
104
+ # Static files (CSS, JavaScript, Images)
105
+ # https://docs.djangoproject.com/en/dev/howto/static-files/
106
+
107
+ STATIC_URL = "/static/"
108
+
109
+ SESSION_SECURITY_EXPIRE_AFTER = 10
110
+ SESSION_SECURITY_WARN_AFTER = 5
111
+ SESSION_EXPIRE_AT_BROWSER_CLOSE = True
112
+ SESSION_SECURITY_PASSIVE_URL_NAMES = ["ignore"]
@@ -0,0 +1,33 @@
1
+ import time
2
+
3
+ from django.contrib import admin
4
+ from django.contrib.auth.decorators import login_required
5
+ from django.urls import include
6
+ from django.urls import path
7
+ from django.views import generic
8
+
9
+
10
+ class SleepView(generic.TemplateView):
11
+ def get(self, request, *args, **kwargs):
12
+ time.sleep(int(request.GET.get("seconds", 0)))
13
+ return super().get(request, *args, **kwargs)
14
+
15
+
16
+ urlpatterns = [
17
+ path("", generic.TemplateView.as_view(template_name="home.html")),
18
+ path("sleep/", login_required(SleepView.as_view(template_name="home.html")), name="sleep"),
19
+ path("admin/", admin.site.urls),
20
+ path("auth/", include("django.contrib.auth.urls")),
21
+ path("session_security/", include("session_security.urls")),
22
+ path("ignore/", login_required(generic.TemplateView.as_view(template_name="home.html")), name="ignore"),
23
+ path(
24
+ "passive/",
25
+ login_required(generic.TemplateView.as_view(template_name="home.html")),
26
+ name="passive",
27
+ ),
28
+ path(
29
+ "template/",
30
+ login_required(generic.TemplateView.as_view(template_name="template.html")),
31
+ name="template",
32
+ ),
33
+ ]
@@ -0,0 +1,20 @@
1
+ """
2
+ WSGI config for project project.
3
+
4
+ It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/dev/howto/deployment/wsgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.wsgi import get_wsgi_application
13
+
14
+
15
+ os.environ.setdefault(
16
+ "DJANGO_SETTINGS_MODULE",
17
+ "session_security.tests.project.settings",
18
+ )
19
+
20
+ application = get_wsgi_application()
@@ -0,0 +1,50 @@
1
+ import os
2
+
3
+ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
4
+ from django.test import LiveServerTestCase
5
+ from selenium import webdriver
6
+ from selenium.webdriver.chrome.options import Options as ChromeOptions
7
+ from selenium.webdriver.common.by import By
8
+ from selenium.webdriver.common.keys import Keys
9
+
10
+ from session_security.settings import EXPIRE_AFTER
11
+ from session_security.settings import WARN_AFTER
12
+
13
+
14
+ WAIT_TIME = 5 if not os.environ.get("CI", False) else 30
15
+
16
+
17
+ class SettingsMixin:
18
+ def setUp(self):
19
+ # Give some time for selenium lag
20
+ self.min_warn_after = WARN_AFTER
21
+ self.max_warn_after = EXPIRE_AFTER * 0.9
22
+ self.min_expire_after = EXPIRE_AFTER
23
+ self.max_expire_after = EXPIRE_AFTER * 1.5
24
+ super().setUp()
25
+
26
+
27
+ class BaseLiveServerTestCase(SettingsMixin, StaticLiveServerTestCase, LiveServerTestCase):
28
+ fixtures = ["session_security_test_user"]
29
+
30
+ def setUp(self):
31
+ super().setUp()
32
+ options = ChromeOptions()
33
+ options.add_argument("--headless=new")
34
+ options.add_argument("--disable-gpu")
35
+ options.add_argument("--no-sandbox")
36
+ options.add_argument("--disable-dev-shm-usage")
37
+ self.sel = webdriver.Chrome(options=options)
38
+ self.sel.get(f"{self.live_server_url}/admin/")
39
+ self.sel.find_element(By.NAME, "username").send_keys("test")
40
+ self.sel.find_element(By.NAME, "password").send_keys("test")
41
+ self.sel.find_element(By.XPATH, '//input[@value="Log in"]').click()
42
+ self.sel.execute_script('window.open("/admin/", "other")')
43
+
44
+ def press_space(self):
45
+ body = self.sel.find_element(By.TAG_NAME, "body")
46
+ body.send_keys(Keys.SPACE)
47
+
48
+ def tearDown(self):
49
+ self.sel.quit()
50
+ super().tearDown()
@@ -0,0 +1,87 @@
1
+ from datetime import datetime
2
+ from datetime import timedelta
3
+
4
+ import pytest
5
+ from django.test import override_settings
6
+
7
+ from session_security.utils import get_last_activity
8
+ from session_security.utils import set_last_activity
9
+
10
+
11
+ pytestmark = pytest.mark.django_db
12
+
13
+
14
+ def test_auto_logout(authenticated_client, activity_window, frozen_time):
15
+ authenticated_client.get("/admin/")
16
+ assert "_auth_user_id" in authenticated_client.session
17
+ frozen_time.advance(activity_window.max_expire_after)
18
+ authenticated_client.get("/admin/")
19
+ assert "_auth_user_id" not in authenticated_client.session
20
+
21
+
22
+ def test_last_activity_in_future(authenticated_client, activity_window):
23
+ now = datetime.now()
24
+ future = now + timedelta(seconds=activity_window.max_expire_after * 2)
25
+ set_last_activity(authenticated_client.session, future)
26
+ authenticated_client.get("/admin/")
27
+ assert "_auth_user_id" in authenticated_client.session
28
+
29
+
30
+ def test_non_javascript_browse_no_logout(authenticated_client, activity_window, frozen_time):
31
+ authenticated_client.get("/admin/")
32
+ frozen_time.advance(activity_window.max_warn_after)
33
+ authenticated_client.get("/admin/")
34
+ assert "_auth_user_id" in authenticated_client.session
35
+ frozen_time.advance(activity_window.min_warn_after)
36
+ authenticated_client.get("/admin/")
37
+ assert "_auth_user_id" in authenticated_client.session
38
+
39
+
40
+ def test_javascript_activity_no_logout(authenticated_client, activity_window, frozen_time):
41
+ authenticated_client.get("/admin/")
42
+ frozen_time.advance(activity_window.max_warn_after)
43
+ authenticated_client.get("/session_security/ping/?idleFor=1")
44
+ assert "_auth_user_id" in authenticated_client.session
45
+ frozen_time.advance(activity_window.min_warn_after)
46
+ authenticated_client.get("/admin/")
47
+ assert "_auth_user_id" in authenticated_client.session
48
+
49
+
50
+ def test_url_names(authenticated_client, activity_window, frozen_time):
51
+ authenticated_client.get("/admin/")
52
+ activity1 = get_last_activity(authenticated_client.session)
53
+ frozen_time.advance(min(2, activity_window.min_warn_after))
54
+ authenticated_client.get("/admin/")
55
+ activity2 = get_last_activity(authenticated_client.session)
56
+ assert activity2 > activity1
57
+ frozen_time.advance(min(2, activity_window.min_warn_after))
58
+ authenticated_client.get("/ignore/")
59
+ activity3 = get_last_activity(authenticated_client.session)
60
+ assert activity2 == activity3
61
+
62
+
63
+ @override_settings(SESSION_SECURITY_PASSIVE_URLS=["/passive/"])
64
+ def test_passive_urls(authenticated_client, activity_window, frozen_time):
65
+ authenticated_client.get("/admin/")
66
+ activity1 = get_last_activity(authenticated_client.session)
67
+ frozen_time.advance(min(2, activity_window.min_warn_after))
68
+ authenticated_client.get("/passive/")
69
+ activity2 = get_last_activity(authenticated_client.session)
70
+ assert activity1 == activity2
71
+
72
+
73
+ def test_idle_for_non_integer(authenticated_client):
74
+ authenticated_client.get("/admin/")
75
+ activity1 = get_last_activity(authenticated_client.session)
76
+ authenticated_client.get("/session_security/ping/?idleFor=not-a-number")
77
+ activity2 = get_last_activity(authenticated_client.session)
78
+ assert activity1 == activity2
79
+
80
+
81
+ def test_idle_for_negative(authenticated_client):
82
+ authenticated_client.get("/admin/")
83
+ activity1 = get_last_activity(authenticated_client.session)
84
+ authenticated_client.get("/session_security/ping/?idleFor=-5")
85
+ activity2 = get_last_activity(authenticated_client.session)
86
+ # Negative values are coerced to zero, so activity should stay unchanged.
87
+ assert activity1 == activity2
@@ -0,0 +1,77 @@
1
+ import datetime
2
+ import time
3
+
4
+ import pytest
5
+ from selenium.webdriver.common.by import By
6
+ from selenium.webdriver.common.keys import Keys
7
+ from selenium.webdriver.support import expected_conditions
8
+ from selenium.webdriver.support.ui import WebDriverWait
9
+
10
+
11
+ pytestmark = [pytest.mark.django_db, pytest.mark.selenium]
12
+
13
+
14
+ def _press_space(driver):
15
+ driver.find_element(By.TAG_NAME, "body").send_keys(Keys.SPACE)
16
+
17
+
18
+ def _iterate_windows(driver):
19
+ for handle in driver.window_handles:
20
+ driver.switch_to.window(handle)
21
+ yield
22
+
23
+
24
+ def test_warning_shows_and_session_expires(selenium_browser, activity_window):
25
+ start = datetime.datetime.now()
26
+
27
+ for _ in _iterate_windows(selenium_browser):
28
+ warning = WebDriverWait(selenium_browser, activity_window.max_warn_after).until(
29
+ expected_conditions.visibility_of_element_located((By.ID, "session_security_warning"))
30
+ )
31
+ assert warning.is_displayed()
32
+
33
+ delta = datetime.datetime.now() - start
34
+ assert delta.seconds >= activity_window.min_warn_after
35
+ assert delta.seconds <= activity_window.max_warn_after
36
+
37
+ for _ in _iterate_windows(selenium_browser):
38
+ password_field = WebDriverWait(selenium_browser, activity_window.max_expire_after).until(
39
+ expected_conditions.visibility_of_element_located((By.ID, "id_password"))
40
+ )
41
+ assert password_field.is_displayed()
42
+ delta = datetime.datetime.now() - start
43
+ assert delta.seconds >= activity_window.min_expire_after
44
+ assert delta.seconds <= activity_window.max_expire_after
45
+
46
+
47
+ def test_activity_hides_warning(selenium_browser, activity_window):
48
+ time.sleep(activity_window.min_warn_after * 0.7)
49
+ WebDriverWait(selenium_browser, activity_window.max_warn_after).until(
50
+ expected_conditions.visibility_of_element_located((By.ID, "session_security_warning"))
51
+ )
52
+
53
+ _press_space(selenium_browser)
54
+
55
+ for _ in _iterate_windows(selenium_browser):
56
+ pass
57
+
58
+ assert WebDriverWait(selenium_browser, 20).until(
59
+ expected_conditions.invisibility_of_element_located((By.ID, "session_security_warning"))
60
+ )
61
+
62
+
63
+ def test_activity_prevents_warning(selenium_browser, activity_window):
64
+ time.sleep(activity_window.min_warn_after * 0.7)
65
+ _press_space(selenium_browser)
66
+ start = datetime.datetime.now()
67
+
68
+ warning = WebDriverWait(selenium_browser, activity_window.max_warn_after).until(
69
+ expected_conditions.visibility_of_element_located((By.ID, "session_security_warning"))
70
+ )
71
+ assert warning.is_displayed()
72
+
73
+ for _ in _iterate_windows(selenium_browser):
74
+ pass
75
+
76
+ delta = datetime.datetime.now() - start
77
+ assert delta.seconds >= activity_window.min_warn_after
@@ -0,0 +1,17 @@
1
+ import pytest
2
+
3
+
4
+ pytestmark = pytest.mark.django_db
5
+
6
+
7
+ def test_default_template_has_no_return_to_url(client, user):
8
+ client.force_login(user)
9
+ response = client.get("/template/")
10
+ assert b"returnToUrl" not in response.content
11
+
12
+
13
+ def test_setting_enables_return_to_url(client, user, settings):
14
+ client.force_login(user)
15
+ settings.SESSION_SECURITY_REDIRECT_TO_LOGOUT = True
16
+ response = client.get("/template/")
17
+ assert b"returnToUrl" in response.content
@@ -0,0 +1,43 @@
1
+ from datetime import datetime
2
+ from datetime import timedelta
3
+
4
+ import pytest
5
+
6
+ from session_security.utils import set_last_activity
7
+
8
+
9
+ pytestmark = pytest.mark.django_db
10
+
11
+
12
+ PING_CASES = (
13
+ (1, 4, "1", True),
14
+ (3, 2, "2", True),
15
+ (5, 5, "5", True),
16
+ (12, 14, '"logout"', False),
17
+ )
18
+
19
+
20
+ def test_anonymous_ping(client):
21
+ client.logout()
22
+ client.get("/admin/")
23
+ response = client.get("/session_security/ping/?idleFor=81")
24
+ assert response.content == b'"logout"'
25
+
26
+
27
+ @pytest.mark.parametrize("server_idle, client_idle, expected, authenticated", PING_CASES)
28
+ def test_ping(client, admin_user, settings, server_idle, client_idle, expected, authenticated):
29
+ settings.SESSION_SECURITY_WARN_AFTER = 5
30
+ settings.SESSION_SECURITY_EXPIRE_AFTER = 10
31
+
32
+ assert client.login(username="test", password="test")
33
+ client.get("/admin/")
34
+
35
+ now = datetime.now()
36
+ session = client.session
37
+ set_last_activity(session, now - timedelta(seconds=server_idle))
38
+ session.save()
39
+
40
+ response = client.get(f"/session_security/ping/?idleFor={client_idle}")
41
+
42
+ assert response.content == expected.encode("utf-8")
43
+ assert ("_auth_user_id" in client.session) is authenticated
@@ -0,0 +1,29 @@
1
+ """
2
+ One url meant to be used by JavaScript.
3
+
4
+ session_security_ping
5
+ Connects the PingView.
6
+
7
+ To install this url, include it in ``urlpatterns`` definition in ``urls.py``,
8
+ ie::
9
+
10
+ urlpatterns = [
11
+ # ....
12
+ path("session_security/", include("session_security.urls")),
13
+ # ....
14
+ ]
15
+
16
+ """
17
+
18
+ from django.urls import re_path
19
+
20
+ from session_security.views import PingView
21
+
22
+
23
+ urlpatterns = [
24
+ re_path(
25
+ "ping/$",
26
+ PingView.as_view(),
27
+ name="session_security_ping",
28
+ )
29
+ ]
@@ -0,0 +1,14 @@
1
+ """Helpers to support json encoding of session data"""
2
+
3
+ from datetime import datetime
4
+
5
+
6
+ def set_last_activity(session, dt):
7
+ """Set the last activity datetime as a string in the session."""
8
+ session["_session_security"] = dt.strftime("%Y-%m-%dT%H:%M:%S.%f")
9
+
10
+
11
+ def get_last_activity(session):
12
+ """Return the stored last-activity timestamp as a datetime."""
13
+ value = session["_session_security"]
14
+ return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
@@ -0,0 +1,29 @@
1
+ """One view method for AJAX requests by SessionSecurity objects."""
2
+
3
+ from datetime import datetime
4
+
5
+ from django import http
6
+ from django.views import generic
7
+
8
+ from session_security.utils import get_last_activity
9
+
10
+
11
+ __all__ = [
12
+ "PingView",
13
+ ]
14
+
15
+
16
+ class PingView(generic.View):
17
+ """
18
+ This view is just in charge of returning the number of seconds since the
19
+ 'real last activity' that is maintained in the session by the middleware.
20
+ """
21
+
22
+ def get(self, request, *args, **kwargs):
23
+ if "_session_security" not in request.session:
24
+ # It probably has expired already
25
+ return http.HttpResponse('"logout"', content_type="application/json")
26
+
27
+ last_activity = get_last_activity(request.session)
28
+ inactive_for = (datetime.now() - last_activity).seconds
29
+ return http.HttpResponse(inactive_for, content_type="application/json")