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/__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..c1ebb25e --- /dev/null +++ b/backend/ethereum_auth/management/commands/cleanup_nonces.py @@ -0,0 +1,56 @@ +import math +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 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=( + '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', + action='store_true', + help='Show how many nonces would be deleted without removing them.', + ) + + def handle(self, *args, **options): + hours = options['hours'] + 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 + + stale_nonces = Nonce.objects.filter( + Q(expires_at__lte=cutoff) | Q(used=True, created_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..0928d886 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,68 @@ 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, created_delta=None): + now = timezone.now() + return Nonce.objects.create( + value=value, + used=used, + 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): + old_expired = self._nonce( + 'oldExpiredNonce', + expires_delta=timezone.timedelta(hours=-3), + ) + old_used = self._nonce( + 'oldUsedNonce', + used=True, + 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), + ) + + 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=recent_used.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_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)