Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Use a custom cleanup threshold, in hours
python manage.py cleanup_nonces --hours 24

# Collect static files
python manage.py collectstatic
```
Expand Down
Empty file.
Empty file.
56 changes: 56 additions & 0 deletions backend/ethereum_auth/management/commands/cleanup_nonces.py
Original file line number Diff line number Diff line change
@@ -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).')
)
69 changes: 69 additions & 0 deletions backend/ethereum_auth/tests.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)