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.
- django_session_security_continued-3.0.0a1.dist-info/METADATA +209 -0
- django_session_security_continued-3.0.0a1.dist-info/RECORD +25 -0
- django_session_security_continued-3.0.0a1.dist-info/WHEEL +5 -0
- django_session_security_continued-3.0.0a1.dist-info/licenses/LICENSE +22 -0
- django_session_security_continued-3.0.0a1.dist-info/top_level.txt +1 -0
- session_security/__init__.py +0 -0
- session_security/middleware.py +119 -0
- session_security/models.py +0 -0
- session_security/settings.py +53 -0
- session_security/templatetags/__init__.py +0 -0
- session_security/templatetags/session_security_tags.py +29 -0
- session_security/tests/__init__.py +0 -0
- session_security/tests/conftest.py +149 -0
- session_security/tests/project/__init__.py +0 -0
- session_security/tests/project/settings.py +112 -0
- session_security/tests/project/urls.py +33 -0
- session_security/tests/project/wsgi.py +20 -0
- session_security/tests/test_base.py +50 -0
- session_security/tests/test_middleware.py +87 -0
- session_security/tests/test_script.py +77 -0
- session_security/tests/test_templates.py +17 -0
- session_security/tests/test_views.py +43 -0
- session_security/urls.py +29 -0
- session_security/utils.py +14 -0
- session_security/views.py +29 -0
|
@@ -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,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
|
session_security/urls.py
ADDED
|
@@ -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")
|