Skip to content

Commit 8724405

Browse files
authored
Merge pull request #1198 from sevdog/access-log-identify-session
Add session hash to access log
2 parents f70138e + 014483c commit 8724405

File tree

6 files changed

+71
-9
lines changed

6 files changed

+71
-9
lines changed

axes/handlers/database.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from axes.conf import settings
1515
from axes.handlers.base import AxesBaseHandler, AbstractAxesHandler
1616
from axes.helpers import (
17+
get_client_session_hash,
1718
get_client_str,
1819
get_client_username,
1920
get_credentials,
@@ -284,6 +285,9 @@ def user_logged_in(self, sender, request, user, **kwargs):
284285
http_accept=request.axes_http_accept,
285286
path_info=request.axes_path_info,
286287
attempt_time=request.axes_attempt_time,
288+
# evaluate session hash here to ensure having the correct
289+
# value which is stored on the backend
290+
session_hash=get_client_session_hash(request),
287291
)
288292

289293
if settings.AXES_RESET_ON_SUCCESS:
@@ -317,7 +321,10 @@ def user_logged_out(self, sender, request, user, **kwargs):
317321
if username and not settings.AXES_DISABLE_ACCESS_LOG:
318322
# 2. database query: Update existing attempt logs with logout time
319323
AccessLog.objects.filter(
320-
username=username, logout_time__isnull=True
324+
username=username,
325+
logout_time__isnull=True,
326+
# update only access log for given session
327+
session_hash=get_client_session_hash(request),
321328
).update(logout_time=request.axes_attempt_time)
322329

323330
def post_save_access_attempt(self, instance, **kwargs):

axes/helpers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.core.cache import BaseCache, caches
99
from django.http import HttpRequest, HttpResponse, JsonResponse, QueryDict
1010
from django.shortcuts import redirect, render
11+
from django.utils.encoding import force_bytes
1112
from django.utils.module_loading import import_string
1213

1314
from axes.conf import settings
@@ -614,3 +615,24 @@ def inner(*args, **kwargs): # pylint: disable=inconsistent-return-statements
614615
return func(*args, **kwargs)
615616

616617
return inner
618+
619+
620+
def get_client_session_hash(request: HttpRequest) -> str:
621+
"""
622+
Get client session and returns the SHA256 hash of session key, forcing session creation if required.
623+
624+
If no session is available on request returns an empty string.
625+
"""
626+
try:
627+
session = request.session
628+
except AttributeError:
629+
# when no session is available just return an empty string
630+
return ""
631+
632+
# ensure that a session key exists at this point
633+
# because session middleware usually creates the session key at the end
634+
# of request cycle
635+
if session.session_key is None:
636+
session.create()
637+
638+
return sha256(force_bytes(session.session_key)).hexdigest()
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 4.2.2 on 2024-04-30 07:57
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("axes", "0008_accessfailurelog"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="accesslog",
14+
name="session_hash",
15+
field=models.CharField(
16+
blank=True,
17+
default="",
18+
max_length=64,
19+
verbose_name="Session key hash (sha256)",
20+
),
21+
),
22+
]

axes/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class Meta:
5353

5454
class AccessLog(AccessBase):
5555
logout_time = models.DateTimeField(_("Logout Time"), null=True, blank=True)
56+
session_hash = models.CharField(_("Session key hash (sha256)"), default="", blank=True, max_length=64)
5657

5758
def __str__(self):
5859
return f"Access Log for {self.username} @ {self.attempt_time}"

docs/4_configuration.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ with the ``AXES_HANDLER`` setting in project configuration:
137137
logs attempts to database and creates AccessAttempt and AccessLog records
138138
that persist until removed from the database manually or automatically
139139
after their cool offs expire (checked on each login event).
140+
141+
.. note::
142+
To keep track of concurrent sessions AccessLog stores an hash of ``session_key`` if the session engine is configured.
143+
When no session engine is configured each access is stored with the same dummy value, then a logout will cause each *not-logged-out yet* logs to set a logout time.
144+
Due to how ``django.contrib.auth`` works it is not possible to correctly track the logout of a session in which the user changed its password, since it will create a new session without firing any logout event.
145+
140146
- ``axes.handlers.cache.AxesCacheHandler``
141147
only uses the cache for monitoring attempts and does not persist data
142148
other than in the cache backend; this data can be purged automatically

tests/test_logging.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from unittest.mock import patch
22

33
from django.test import override_settings
4-
from django.urls import reverse
54

65
from axes import __version__
76
from axes.apps import AppConfig
@@ -59,16 +58,21 @@ def test_axes_config_log_user_or_ip(self, log):
5958
class AccessLogTestCase(AxesTestCase):
6059
def test_access_log_on_logout(self):
6160
"""
62-
Test a valid logout and make sure the logout_time is updated.
61+
Test a valid logout and make sure the logout_time is updated only for that.
6362
"""
6463

6564
self.login(is_valid_username=True, is_valid_password=True)
66-
self.assertIsNone(AccessLog.objects.latest("id").logout_time)
65+
latest_log = AccessLog.objects.latest("id")
66+
self.assertIsNone(latest_log.logout_time)
67+
other_log = self.create_log(session_hash='not-the-session')
68+
self.assertIsNone(other_log.logout_time)
6769

68-
response = self.client.post(reverse("admin:logout"))
70+
response = self.logout()
6971
self.assertContains(response, "Logged out")
70-
71-
self.assertIsNotNone(AccessLog.objects.latest("id").logout_time)
72+
other_log.refresh_from_db()
73+
self.assertIsNone(other_log.logout_time)
74+
latest_log.refresh_from_db()
75+
self.assertIsNotNone(latest_log.logout_time)
7276

7377
@override_settings(DATA_UPLOAD_MAX_NUMBER_FIELDS=1500)
7478
def test_log_data_truncated(self):
@@ -86,7 +90,7 @@ def test_valid_logout_without_success_log(self):
8690
AccessLog.objects.all().delete()
8791

8892
response = self.login(is_valid_username=True, is_valid_password=True)
89-
response = self.client.post(reverse("admin:logout"))
93+
response = self.logout()
9094

9195
self.assertEqual(AccessLog.objects.all().count(), 0)
9296
self.assertContains(response, "Logged out", html=True)
@@ -109,7 +113,7 @@ def test_valid_logout_without_log(self):
109113
AccessLog.objects.all().delete()
110114

111115
response = self.login(is_valid_username=True, is_valid_password=True)
112-
response = self.client.post(reverse("admin:logout"))
116+
response = self.logout()
113117

114118
self.assertEqual(AccessLog.objects.count(), 0)
115119
self.assertContains(response, "Logged out", html=True)

0 commit comments

Comments
 (0)