diff --git a/axes/admin.py b/axes/admin.py index 48aa95b..7c3cfab 100644 --- a/axes/admin.py +++ b/axes/admin.py @@ -7,14 +7,26 @@ from axes.models import AccessAttempt, AccessLog, AccessFailureLog class AccessAttemptAdmin(admin.ModelAdmin): - list_display = ( - "attempt_time", - "ip_address", - "user_agent", - "username", - "path_info", - "failures_since_start", - ) + if settings.AXES_INDIVIDUAL_ATTEMPT_EXPIRY: + list_display = ( + "attempt_time", + "expires_at", + "ip_address", + "user_agent", + "username", + "path_info", + "failures_since_start", + ) + else: + list_display = ( + "attempt_time", + "ip_address", + "user_agent", + "username", + "path_info", + "failures_since_start", + ) + list_filter = ["attempt_time", "path_info"] @@ -23,7 +35,7 @@ class AccessAttemptAdmin(admin.ModelAdmin): date_hierarchy = "attempt_time" fieldsets = ( - (None, {"fields": ("username", "path_info", "failures_since_start")}), + (None, {"fields": ("username", "path_info", "failures_since_start", "expires_at")}), (_("Form Data"), {"fields": ("get_data", "post_data")}), (_("Meta Data"), {"fields": ("user_agent", "ip_address", "http_accept")}), ) diff --git a/axes/attempts.py b/axes/attempts.py index 83367f9..9d22f9c 100644 --- a/axes/attempts.py +++ b/axes/attempts.py @@ -24,3 +24,19 @@ def get_cool_off_threshold(request: Optional[HttpRequest] = None) -> datetime: if attempt_time is None: return now() - cool_off return attempt_time - cool_off + +def get_individual_attempt_expiry(request: Optional[HttpRequest] = None) -> datetime: + """ + Get threshold for fetching access attempts from the database. + """ + + cool_off = get_cool_off(request) + if cool_off is None: + raise TypeError( + "Cool off threshold can not be calculated with settings.AXES_COOLOFF_TIME set to None" + ) + + attempt_time = request.axes_attempt_time + if attempt_time is None: + return now() + cool_off + return attempt_time + cool_off \ No newline at end of file diff --git a/axes/conf.py b/axes/conf.py index d19a3f4..f030ca9 100644 --- a/axes/conf.py +++ b/axes/conf.py @@ -87,6 +87,8 @@ settings.AXES_LOCKOUT_URL = getattr(settings, "AXES_LOCKOUT_URL", None) settings.AXES_COOLOFF_TIME = getattr(settings, "AXES_COOLOFF_TIME", None) +settings.AXES_INDIVIDUAL_ATTEMPT_EXPIRY = getattr(settings, "AXES_INDIVIDUAL_ATTEMPT_EXPIRY", False) + settings.AXES_VERBOSE = getattr(settings, "AXES_VERBOSE", settings.AXES_ENABLED) # whitelist and blacklist diff --git a/axes/handlers/database.py b/axes/handlers/database.py index 64f6357..2be6ccb 100644 --- a/axes/handlers/database.py +++ b/axes/handlers/database.py @@ -7,7 +7,7 @@ from django.db.models.functions import Concat from django.http import HttpRequest from django.utils import timezone -from axes.attempts import get_cool_off_threshold +from axes.attempts import get_cool_off_threshold, get_individual_attempt_expiry from axes.conf import settings from axes.handlers.base import AbstractAxesHandler, AxesBaseHandler from axes.helpers import ( @@ -185,6 +185,12 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): "path_info": request.axes_path_info, "failures_since_start": 1, "attempt_time": request.axes_attempt_time, + # Set the expiry time for the attempt based on the cool off period. + "expires_at": ( + get_individual_attempt_expiry(request) + if settings.AXES_INDIVIDUAL_ATTEMPT_EXPIRY + else None + ), }, ) @@ -212,6 +218,8 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): attempt.path_info = request.axes_path_info attempt.failures_since_start = F("failures_since_start") + 1 attempt.attempt_time = request.axes_attempt_time + if settings.AXES_INDIVIDUAL_ATTEMPT_EXPIRY: + attempt.expires_at = max(get_individual_attempt_expiry(request), attempt.expires_at) attempt.save() log.warning( @@ -382,13 +390,22 @@ class AxesDatabaseHandler(AbstractAxesHandler, AxesBaseHandler): ) return 0 - threshold = get_cool_off_threshold(request) - count, _ = AccessAttempt.objects.filter(attempt_time__lt=threshold).delete() - log.info( - "AXES: Cleaned up %s expired access attempts from database that were older than %s", - count, - threshold, - ) + if settings.AXES_INDIVIDUAL_ATTEMPT_EXPIRY: + threshold = timezone.now() + count, _ = AccessAttempt.objects.filter(expires_at__lt=threshold).delete() + log.info( + "AXES: Cleaned up %s expired access attempts from database that expiry were older than %s", + count, + threshold, + ) + else: + threshold = get_cool_off_threshold(request) + count, _ = AccessAttempt.objects.filter(attempt_time__lt=threshold).delete() + log.info( + "AXES: Cleaned up %s expired access attempts from database that were older than %s", + count, + threshold, + ) return count def reset_user_attempts( diff --git a/axes/migrations/0010_accessattempt_expires_at.py b/axes/migrations/0010_accessattempt_expires_at.py new file mode 100644 index 0000000..86af0a6 --- /dev/null +++ b/axes/migrations/0010_accessattempt_expires_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-05-27 18:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('axes', '0009_add_session_hash'), + ] + + operations = [ + migrations.AddField( + model_name='accessattempt', + name='expires_at', + field=models.DateTimeField(blank=True, help_text='The time when this access attempt expires and is no longer valid.', null=True, verbose_name='Expires At'), + ), + ] diff --git a/axes/models.py b/axes/models.py index 9c8a7da..b1a7458 100644 --- a/axes/models.py +++ b/axes/models.py @@ -42,6 +42,13 @@ class AccessAttempt(AccessBase): failures_since_start = models.PositiveIntegerField(_("Failed Logins")) + expires_at = models.DateTimeField( + _("Expires At"), + null=True, + blank=True, + help_text=_("The time when this access attempt expires and is no longer valid."), + ) + def __str__(self): return f"Attempted Access: {self.attempt_time}"