From 09a98e4311401ad75cfddd67d9e1736da4b464c6 Mon Sep 17 00:00:00 2001 From: Denis Skripnik <264741654+web3blind@users.noreply.github.com> Date: Sun, 14 Jun 2026 11:46:54 +0000 Subject: [PATCH 1/2] fix(auth): add cleanup command for stale nonces --- backend/ethereum_auth/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/cleanup_nonces.py | 49 ++++++++++++++++ backend/ethereum_auth/tests.py | 57 +++++++++++++++++++ 4 files changed, 106 insertions(+) create mode 100644 backend/ethereum_auth/management/__init__.py create mode 100644 backend/ethereum_auth/management/commands/__init__.py create mode 100644 backend/ethereum_auth/management/commands/cleanup_nonces.py diff --git a/backend/ethereum_auth/management/__init__.py b/backend/ethereum_auth/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ethereum_auth/management/commands/__init__.py b/backend/ethereum_auth/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/ethereum_auth/management/commands/cleanup_nonces.py b/backend/ethereum_auth/management/commands/cleanup_nonces.py new file mode 100644 index 00000000..92e13e57 --- /dev/null +++ b/backend/ethereum_auth/management/commands/cleanup_nonces.py @@ -0,0 +1,49 @@ +from datetime import timedelta + +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Q +from django.utils import timezone + +from ethereum_auth.models import Nonce + + +class Command(BaseCommand): + help = 'Remove used or expired SIWE nonces after a configurable grace period.' + + def add_arguments(self, parser): + parser.add_argument( + '--hours', + type=float, + default=1, + help='Keep stale nonces for this many hours after their expiry time (default: 1).', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Show how many nonces would be deleted without removing them.', + ) + + def handle(self, *args, **options): + hours = options['hours'] + if hours < 0: + raise CommandError('--hours must be greater than or equal to 0') + + cutoff = timezone.now() - timedelta(hours=hours) + stale_nonces = Nonce.objects.filter( + Q(used=True) | Q(expires_at__lte=timezone.now()), + expires_at__lte=cutoff, + ) + count = stale_nonces.count() + + if options['dry_run']: + self.stdout.write( + self.style.WARNING( + f'Dry run: {count} stale nonce(s) would be deleted.' + ) + ) + return + + deleted_count, _ = stale_nonces.delete() + self.stdout.write( + self.style.SUCCESS(f'Deleted {deleted_count} stale nonce(s).') + ) diff --git a/backend/ethereum_auth/tests.py b/backend/ethereum_auth/tests.py index 87586fc5..18b346ca 100644 --- a/backend/ethereum_auth/tests.py +++ b/backend/ethereum_auth/tests.py @@ -1,4 +1,8 @@ +from io import StringIO + from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.core.management.base import CommandError from django.test import TestCase, override_settings from django.utils import timezone from eth_account import Account @@ -162,3 +166,56 @@ def test_login_rejects_recovery_message_shape(self): self.assertEqual(response.status_code, 400) nonce.refresh_from_db() self.assertFalse(nonce.used) + + +class CleanupNoncesCommandTests(TestCase): + def _nonce(self, value, *, used=False, expires_delta=None): + return Nonce.objects.create( + value=value, + used=used, + expires_at=timezone.now() + expires_delta, + ) + + def test_cleanup_nonces_deletes_only_stale_used_or_expired_nonces(self): + old_expired = self._nonce( + 'oldExpiredNonce', + expires_delta=timezone.timedelta(hours=-3), + ) + old_used = self._nonce( + 'oldUsedNonce', + used=True, + expires_delta=timezone.timedelta(hours=-2), + ) + recent_expired = self._nonce( + 'recentExpiredNonce', + expires_delta=timezone.timedelta(minutes=-15), + ) + active = self._nonce( + 'activeNonce', + expires_delta=timezone.timedelta(minutes=5), + ) + + output = StringIO() + call_command('cleanup_nonces', hours=1, stdout=output) + + self.assertIn('Deleted 2 stale nonce(s).', output.getvalue()) + self.assertFalse(Nonce.objects.filter(pk=old_expired.pk).exists()) + self.assertFalse(Nonce.objects.filter(pk=old_used.pk).exists()) + self.assertTrue(Nonce.objects.filter(pk=recent_expired.pk).exists()) + self.assertTrue(Nonce.objects.filter(pk=active.pk).exists()) + + def test_cleanup_nonces_dry_run_reports_without_deleting(self): + old_expired = self._nonce( + 'dryRunExpiredNonce', + expires_delta=timezone.timedelta(hours=-3), + ) + + output = StringIO() + call_command('cleanup_nonces', hours=1, dry_run=True, stdout=output) + + self.assertIn('Dry run: 1 stale nonce(s) would be deleted.', output.getvalue()) + self.assertTrue(Nonce.objects.filter(pk=old_expired.pk).exists()) + + def test_cleanup_nonces_rejects_negative_retention_window(self): + with self.assertRaises(CommandError): + call_command('cleanup_nonces', hours=-1) From 1fc489aae5258d89ec350c7e99d671a6e25a9ca8 Mon Sep 17 00:00:00 2001 From: Denis Skripnik <264741654+web3blind@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:44:24 +0000 Subject: [PATCH 2/2] fix(auth): validate nonce cleanup retention input --- backend/CLAUDE.md | 9 +++++++ .../management/commands/cleanup_nonces.py | 21 ++++++++++------ backend/ethereum_auth/tests.py | 24 ++++++++++++++----- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 414c4c70..8ab0a12c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -473,6 +473,15 @@ python manage.py shell # Run tests python manage.py test +# Preview stale SIWE nonce cleanup +python manage.py cleanup_nonces --dry-run + +# Delete nonces expired more than 1 hour ago and used nonces created more than 1 hour ago +python manage.py cleanup_nonces + +# Use a custom cleanup threshold, in hours +python manage.py cleanup_nonces --hours 24 + # Collect static files python manage.py collectstatic ``` diff --git a/backend/ethereum_auth/management/commands/cleanup_nonces.py b/backend/ethereum_auth/management/commands/cleanup_nonces.py index 92e13e57..c1ebb25e 100644 --- a/backend/ethereum_auth/management/commands/cleanup_nonces.py +++ b/backend/ethereum_auth/management/commands/cleanup_nonces.py @@ -1,3 +1,4 @@ +import math from datetime import timedelta from django.core.management.base import BaseCommand, CommandError @@ -8,14 +9,17 @@ class Command(BaseCommand): - help = 'Remove used or expired SIWE nonces after a configurable grace period.' + help = 'Remove expired SIWE nonces and used SIWE nonces older than the cleanup threshold.' def add_arguments(self, parser): parser.add_argument( '--hours', type=float, default=1, - help='Keep stale nonces for this many hours after their expiry time (default: 1).', + help=( + 'Delete nonces expired more than this many hours ago and used ' + 'nonces created more than this many hours ago (default: 1).' + ), ) parser.add_argument( '--dry-run', @@ -25,13 +29,16 @@ def add_arguments(self, parser): def handle(self, *args, **options): hours = options['hours'] - if hours < 0: - raise CommandError('--hours must be greater than or equal to 0') + if not math.isfinite(hours) or hours < 0: + raise CommandError('--hours must be a finite number greater than or equal to 0') + + try: + cutoff = timezone.now() - timedelta(hours=hours) + except OverflowError as exc: + raise CommandError('--hours is too large') from exc - cutoff = timezone.now() - timedelta(hours=hours) stale_nonces = Nonce.objects.filter( - Q(used=True) | Q(expires_at__lte=timezone.now()), - expires_at__lte=cutoff, + Q(expires_at__lte=cutoff) | Q(used=True, created_at__lte=cutoff) ) count = stale_nonces.count() diff --git a/backend/ethereum_auth/tests.py b/backend/ethereum_auth/tests.py index 18b346ca..0928d886 100644 --- a/backend/ethereum_auth/tests.py +++ b/backend/ethereum_auth/tests.py @@ -169,11 +169,13 @@ def test_login_rejects_recovery_message_shape(self): class CleanupNoncesCommandTests(TestCase): - def _nonce(self, value, *, used=False, expires_delta=None): + def _nonce(self, value, *, used=False, expires_delta=None, created_delta=None): + now = timezone.now() return Nonce.objects.create( value=value, used=used, - expires_at=timezone.now() + expires_delta, + created_at=now + (created_delta or timezone.timedelta()), + expires_at=now + expires_delta, ) def test_cleanup_nonces_deletes_only_stale_used_or_expired_nonces(self): @@ -184,12 +186,19 @@ def test_cleanup_nonces_deletes_only_stale_used_or_expired_nonces(self): old_used = self._nonce( 'oldUsedNonce', used=True, - expires_delta=timezone.timedelta(hours=-2), + created_delta=timezone.timedelta(hours=-2), + expires_delta=timezone.timedelta(minutes=5), ) recent_expired = self._nonce( 'recentExpiredNonce', expires_delta=timezone.timedelta(minutes=-15), ) + recent_used = self._nonce( + 'recentUsedNonce', + used=True, + created_delta=timezone.timedelta(minutes=-15), + expires_delta=timezone.timedelta(minutes=5), + ) active = self._nonce( 'activeNonce', expires_delta=timezone.timedelta(minutes=5), @@ -202,6 +211,7 @@ def test_cleanup_nonces_deletes_only_stale_used_or_expired_nonces(self): self.assertFalse(Nonce.objects.filter(pk=old_expired.pk).exists()) self.assertFalse(Nonce.objects.filter(pk=old_used.pk).exists()) self.assertTrue(Nonce.objects.filter(pk=recent_expired.pk).exists()) + self.assertTrue(Nonce.objects.filter(pk=recent_used.pk).exists()) self.assertTrue(Nonce.objects.filter(pk=active.pk).exists()) def test_cleanup_nonces_dry_run_reports_without_deleting(self): @@ -216,6 +226,8 @@ def test_cleanup_nonces_dry_run_reports_without_deleting(self): self.assertIn('Dry run: 1 stale nonce(s) would be deleted.', output.getvalue()) self.assertTrue(Nonce.objects.filter(pk=old_expired.pk).exists()) - def test_cleanup_nonces_rejects_negative_retention_window(self): - with self.assertRaises(CommandError): - call_command('cleanup_nonces', hours=-1) + def test_cleanup_nonces_rejects_invalid_retention_window(self): + for invalid_hours in (-1, float('nan'), float('inf'), float('-inf'), 1e20): + with self.subTest(hours=invalid_hours): + with self.assertRaises(CommandError): + call_command('cleanup_nonces', hours=invalid_hours)