Skip to content

Commit 020d218

Browse files
authored
Merge pull request #35 from mikemanger/feature/json-session-serializer
Feature/json session serializer and Django 5 support
2 parents 2077f74 + 2833e7e commit 020d218

File tree

12 files changed

+334
-64
lines changed

12 files changed

+334
-64
lines changed

.github/workflows/main.yml

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ jobs:
1919
- '3.9'
2020
- '3.10'
2121
- '3.11'
22+
- '3.12'
2223

2324
steps:
2425
- uses: actions/checkout@v4

docs/topics/setup.rst

+3-3
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,10 @@ file::
195195
Serializer
196196
============================
197197

198-
For now this app uses the PickleSerializer. This needs to be set up in the Django settings
199-
file::
198+
This app is tested with both PickleSerializer and JsonSerializer.
200199

201-
SESSION_SERIALIZER='django.contrib.sessions.serializers.PickleSerializer'
200+
Django recommends to change from old pickle serializer to json because
201+
possible remote code execution vulnerability.
202202

203203
.. _setup-create-db-tables:
204204

password_policies/conf/settings.py

+5
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,8 @@
184184

185185

186186
PASSWORD_RESET_TIMEOUT_DAYS = 1
187+
188+
PASSWORD_POLICIES_LAST_CHECKED_SESSION_KEY = "_password_policies_last_checked"
189+
PASSWORD_POLICIES_EXPIRED_SESSION_KEY = "_password_policies_expired"
190+
PASSWORD_POLICIES_LAST_CHANGED_SESSION_KEY = "_password_policies_last_changed"
191+
PASSWORD_POLICIES_CHANGE_REQUIRED_SESSION_KEY = "_password_policies_change_required"

password_policies/context_processors.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from password_policies.conf import settings
12
from password_policies.models import PasswordHistory
23

34

@@ -29,9 +30,9 @@ def password_status(request):
2930
auth = auth()
3031

3132
if auth:
32-
if '_password_policies_change_required' not in request.session:
33+
if settings.PASSWORD_POLICIES_CHANGE_REQUIRED_SESSION_KEY not in request.session:
3334
r = PasswordHistory.objects.change_required(request.user)
3435
else:
35-
r = request.session['_password_policies_change_required']
36+
r = request.session[settings.PASSWORD_POLICIES_CHANGE_REQUIRED_SESSION_KEY]
3637
d['password_change_required'] = r
3738
return d

password_policies/middleware.py

+16-12
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@
2020

2121
from password_policies.conf import settings
2222
from password_policies.models import PasswordChangeRequired, PasswordHistory
23-
from password_policies.utils import PasswordCheck
24-
23+
from password_policies.utils import PasswordCheck, string_to_datetime, datetime_to_string
2524

2625
class PasswordChangeMiddleware(MiddlewareMixin):
2726
"""
@@ -70,22 +69,24 @@ class PasswordChangeMiddleware(MiddlewareMixin):
7069
This middleware does not try to redirect using the HTTPS
7170
protocol."""
7271

73-
checked = "_password_policies_last_checked"
74-
expired = "_password_policies_expired"
75-
last = "_password_policies_last_changed"
76-
required = "_password_policies_change_required"
72+
checked = settings.PASSWORD_POLICIES_LAST_CHECKED_SESSION_KEY
73+
expired = settings.PASSWORD_POLICIES_EXPIRED_SESSION_KEY
74+
last = settings.PASSWORD_POLICIES_LAST_CHANGED_SESSION_KEY
75+
required = settings.PASSWORD_POLICIES_CHANGE_REQUIRED_SESSION_KEY
7776
td = timedelta(seconds=settings.PASSWORD_DURATION_SECONDS)
7877

7978
def _check_history(self, request):
8079
if not request.session.get(self.last, None):
8180
newest = PasswordHistory.objects.get_newest(request.user)
8281
if newest:
83-
request.session[self.last] = newest.created
82+
request.session[self.last] = datetime_to_string(newest.created)
8483
else:
8584
# TODO: This relies on request.user.date_joined which might not
8685
# be available!!!
87-
request.session[self.last] = request.user.date_joined
88-
if request.session[self.last] < self.expiry_datetime:
86+
request.session[self.last] = datetime_to_string(request.user.date_joined)
87+
88+
date_last = string_to_datetime(request.session[self.last])
89+
if date_last < self.expiry_datetime:
8990
request.session[self.required] = True
9091
if not PasswordChangeRequired.objects.filter(user=request.user).count():
9192
PasswordChangeRequired.objects.create(user=request.user)
@@ -95,27 +96,30 @@ def _check_history(self, request):
9596
def _check_necessary(self, request):
9697

9798
if not request.session.get(self.checked, None):
98-
request.session[self.checked] = self.now
99+
request.session[self.checked] = datetime_to_string(self.now)
99100

100101
# If the PASSWORD_CHECK_ONLY_AT_LOGIN is set, then only check at the beginning of session, which we can
101102
# tell by self.now time having just been set.
102103
if (
103104
not settings.PASSWORD_CHECK_ONLY_AT_LOGIN
104-
or request.session.get(self.checked, None) == self.now
105+
or request.session.get(self.checked, None) == datetime_to_string(self.now)
105106
):
106107
# If a password change is enforced we won't check
107108
# the user's password history, thus reducing DB hits...
108109
if PasswordChangeRequired.objects.filter(user=request.user).count():
109110
request.session[self.required] = True
110111
return
111-
if request.session[self.checked] < self.expiry_datetime:
112+
113+
date_checked = string_to_datetime(request.session[self.checked])
114+
if date_checked < self.expiry_datetime:
112115
try:
113116
del request.session[self.last]
114117
del request.session[self.checked]
115118
del request.session[self.required]
116119
del request.session[self.expired]
117120
except KeyError:
118121
pass
122+
119123
if settings.PASSWORD_USE_HISTORY:
120124
self._check_history(request)
121125
else:

password_policies/tests/test_middleware.py

+46
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
except ImportError:
1111
from django.urls.base import reverse
1212

13+
from django.test.utils import override_settings
1314
from django.utils import timezone
1415

1516
from password_policies.conf import settings
@@ -71,3 +72,48 @@ def test_password_change_required_enforced_redirect(self):
7172
self.assertEqual(get_response_location(response["Location"]), self.redirect_url)
7273
self.client.logout()
7374
p.delete()
75+
76+
77+
class PasswordPoliciesMiddlewareJsonSerializerTest(TestCase):
78+
def setUp(self):
79+
self.user = create_user()
80+
self.redirect_url = "http://testserver/password/change/?next=/"
81+
82+
def test_password_middleware_without_history(self):
83+
seconds = settings.PASSWORD_DURATION_SECONDS - 60
84+
self.user.date_joined = get_datetime_from_delta(timezone.now(), seconds)
85+
self.user.last_login = get_datetime_from_delta(timezone.now(), seconds)
86+
self.user.save()
87+
self.client.login(username="alice", password=passwords[-1])
88+
response = self.client.get(reverse("home"))
89+
self.assertEqual(response.status_code, 200)
90+
self.client.logout()
91+
92+
def test_password_middleware_with_history(self):
93+
create_password_history(self.user)
94+
self.client.login(username="alice", password=passwords[-1])
95+
response = self.client.get(reverse("home"), follow=False)
96+
self.assertEqual(response.status_code, 302)
97+
self.assertEqual(get_response_location(response["Location"]), self.redirect_url)
98+
self.client.logout()
99+
PasswordHistory.objects.filter(user=self.user).delete()
100+
101+
def test_password_middleware_enforced_redirect(self):
102+
self.client.login(username="alice", password=passwords[-1])
103+
response = self.client.get(reverse("home"), follow=False)
104+
self.assertEqual(response.status_code, 302)
105+
self.assertEqual(get_response_location(response["Location"]), self.redirect_url)
106+
self.client.logout()
107+
108+
def test_password_change_required_enforced_redirect(self):
109+
seconds = settings.PASSWORD_DURATION_SECONDS - 60
110+
self.user.date_joined = get_datetime_from_delta(timezone.now(), seconds)
111+
self.user.last_login = get_datetime_from_delta(timezone.now(), seconds)
112+
self.user.save()
113+
p = PasswordChangeRequired.objects.create(user=self.user)
114+
self.client.login(username="alice", password=passwords[-1])
115+
response = self.client.get(reverse("home"), follow=False)
116+
self.assertEqual(response.status_code, 302)
117+
self.assertEqual(get_response_location(response["Location"]), self.redirect_url)
118+
self.client.logout()
119+
p.delete()

password_policies/tests/test_models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@ def test_password_history_expiration(self):
2323
self.assertEqual(count, settings.PASSWORD_HISTORY_COUNT)
2424

2525
def test_password_history_recent_passwords(self):
26-
self.failIf(PasswordHistory.objects.check_password(self.user, passwords[-1]))
26+
self.assertFalse(PasswordHistory.objects.check_password(self.user, passwords[-1]))

password_policies/tests/test_settings.py

+18-34
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
11
import os
2-
from distutils.version import LooseVersion
3-
4-
from django import get_version
5-
6-
django_version = get_version()
72

83
DEBUG = False
94

@@ -42,36 +37,24 @@
4237

4338
SITE_ID = 1
4439

45-
# This is to maintain compatibility with Django 1.7
46-
if LooseVersion(django_version) < LooseVersion("1.8.0"):
47-
TEMPLATE_DIRS = (os.path.join(os.path.dirname(__file__), "templates"),)
48-
TEMPLATE_CONTEXT_PROCESSORS = (
49-
"django.contrib.auth.context_processors.auth",
50-
"django.core.context_processors.debug",
51-
"django.core.context_processors.i18n",
52-
"django.contrib.messages.context_processors.messages",
53-
"password_policies.context_processors.password_status",
54-
)
55-
56-
else:
57-
TEMPLATES = [
58-
{
59-
"BACKEND": "django.template.backends.django.DjangoTemplates",
60-
"DIRS": [
61-
os.path.join(os.path.dirname(__file__), "templates"),
40+
TEMPLATES = [
41+
{
42+
"BACKEND": "django.template.backends.django.DjangoTemplates",
43+
"DIRS": [
44+
os.path.join(os.path.dirname(__file__), "templates"),
45+
],
46+
"APP_DIRS": True,
47+
"OPTIONS": {
48+
"context_processors": [
49+
"django.contrib.auth.context_processors.auth",
50+
"django.template.context_processors.debug",
51+
"django.template.context_processors.i18n",
52+
"django.contrib.messages.context_processors.messages",
53+
"password_policies.context_processors.password_status",
6254
],
63-
"APP_DIRS": True,
64-
"OPTIONS": {
65-
"context_processors": [
66-
"django.contrib.auth.context_processors.auth",
67-
"django.template.context_processors.debug",
68-
"django.template.context_processors.i18n",
69-
"django.contrib.messages.context_processors.messages",
70-
"password_policies.context_processors.password_status",
71-
],
72-
},
7355
},
74-
]
56+
},
57+
]
7558

7659
MIDDLEWARE = (
7760
"django.middleware.common.CommonMiddleware",
@@ -82,7 +65,8 @@
8265
"django.contrib.messages.middleware.MessageMiddleware",
8366
)
8467

85-
SESSION_SERIALIZER = "django.contrib.sessions.serializers.PickleSerializer"
68+
SESSION_SERIALIZER = "django.contrib.sessions.serializers.JSONSerializer"
69+
8670

8771
MEDIA_URL = "/media/somewhere/"
8872

0 commit comments

Comments
 (0)