Signed payment links, hashed refresh tokens, login-only leaderboard, load test tools

- /api/payment/orders/{id}/pay requires the short-lived signature that paymentUrl carries;
  without it (or with another order's) it is the same 404 as an unknown order
- Session.refresh_token stores the SHA-256 of the token; migration accounts.0007 hashes the
  existing rows so tokens clients already hold keep working
- /api/leaderboard requires a token (guests have one); names of children were scrapable
- RAG mock only for `manage.py test`, not any command with an argument named "test"
- tools/loadtest: user factory, load generator, fake GapGPT+Qdrant, with method notes
- security-before-production.md lists only open items; closed ones become a guards table
  with the tests that hold them. Comments describe current behaviour, not history

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 21:12:28 +03:30
parent b3f624a512
commit 645baf846b
22 changed files with 584 additions and 193 deletions

View File

@@ -37,7 +37,7 @@ from django.db import transaction
from django.utils import timezone
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, Profile, Session, User
from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
# --- شناسه‌های ثابتِ داده‌ی seed --------------------------------------------
# شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛
@@ -177,7 +177,7 @@ class Command(BaseCommand):
)
Session.objects.get_or_create(
user=main_user,
refresh_token=f'seed-refresh-{MAIN_PHONE}',
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
defaults=dict(
device_id='seed-device-main',
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
@@ -192,7 +192,7 @@ class Command(BaseCommand):
)
Session.objects.get_or_create(
user=guest_user,
refresh_token=f'seed-refresh-{GUEST_DEVICE_ID}',
refresh_token=hash_refresh_token(f'seed-refresh-{GUEST_DEVICE_ID}'),
defaults=dict(
device_id=GUEST_DEVICE_ID,
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),

View File

@@ -0,0 +1,28 @@
"""
Session.refresh_token از این به بعد هشِ SHA-256 است (accounts/models.py، hash_refresh_token).
ردیف‌های موجود هش می‌شوند تا توکن‌هایی که کلاینت‌ها در دست دارند همچنان کار کنند. از هش نمی‌شود
خودِ توکن را ساخت؛ برگرداندنِ این migration هش‌ها را سرِ جایشان می‌گذارد و کدِ قبلی آن‌ها را
نمی‌شناسد، یعنی هر کاربر یک بار دوباره وارد می‌شود.
"""
import hashlib
from django.db import migrations
def hash_existing(apps, schema_editor):
Session = apps.get_model('accounts', 'Session')
# اول مادی کردن، بعد نوشتن: کرسرِ بازِ SQLite وسطِ حلقه‌ی نوشتن قفل می‌سازد (CLAUDE.md).
rows = list(Session.objects.values_list('id', 'refresh_token'))
for pk, raw in rows:
Session.objects.filter(pk=pk).update(refresh_token=hashlib.sha256(raw.encode()).hexdigest())
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_profile_birth_date_drop_parent_phone'),
]
operations = [
migrations.RunPython(hash_existing, migrations.RunPython.noop),
]

View File

@@ -1,3 +1,5 @@
import hashlib
from django.db import models
@@ -60,11 +62,18 @@ class Profile(models.Model):
return f"profile-{self.user_id}"
def hash_refresh_token(raw: str) -> str:
"""آنچه در Session.refresh_token ذخیره و با آن جست‌وجو می‌شود؛ خودِ توکن فقط دستِ کلاینت است."""
return hashlib.sha256(raw.encode()).hexdigest()
class Session(models.Model):
"""
نگهداری refresh_token هر ورود. توکن دسترسی (access token) در دیتابیس
ذخیره نمی‌شه؛ به‌صورت stateless امضا (signed) می‌شه — دقیقاً طبق فیلدهای
همین مدل که فقط refresh_token داره، نه access token.
یک ورود. توکنِ دسترسی ذخیره نمی‌شود (امضاشده و stateless است، accounts/authentication.py).
`refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
یک بکاپ یا پنلِ ادمین را ببیند با آن نمی‌تواند به‌جای کاربر وارد شود. توکن ۶۴ نویسه‌ی تصادفی
است، پس هشِ سریعِ بی‌نمک کافی است.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
refresh_token = models.CharField(max_length=200, unique=True)

View File

@@ -13,7 +13,7 @@ from rest_framework.test import APIClient
from rest_framework.throttling import ScopedRateThrottle
from .authentication import issue_access_token
from .models import OTPCode, Profile, Session, User
from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, SMSDeliveryError, send_otp_sms
@@ -286,9 +286,10 @@ class RefreshTokenReplayTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(is_guest=True, device_id='device-refresh-test')
self.raw = 'test-refresh-token-abc123'
self.session = Session.objects.create(
user=self.user,
refresh_token='test-refresh-token-abc123',
refresh_token=hash_refresh_token(self.raw),
device_id='device-refresh-test',
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
@@ -296,19 +297,19 @@ class RefreshTokenReplayTests(TestCase):
def test_valid_refresh_token_succeeds(self):
response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['token'])
def test_same_refresh_token_rejected_on_second_use(self):
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(first_response.status_code, 200)
second_response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(second_response.status_code, 401)
@@ -317,15 +318,15 @@ class RefreshTokenReplayTests(TestCase):
def test_successful_refresh_returns_new_refresh_token(self):
response = self.client.post(
'/api/auth/refresh', {'refreshToken': self.session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': self.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
new_refresh_token = response.data['refreshToken']
self.assertTrue(new_refresh_token)
self.assertNotEqual(new_refresh_token, self.session.refresh_token)
self.assertNotEqual(new_refresh_token, self.raw)
self.assertTrue(
Session.objects.filter(
refresh_token=new_refresh_token, user=self.user, revoked=False,
refresh_token=hash_refresh_token(new_refresh_token), user=self.user, revoked=False,
).exists()
)
@@ -761,20 +762,23 @@ class RefreshTokenThrottleTests(TestCase):
self.user = User.objects.create(is_guest=True, device_id='device-refresh-throttle-test')
def _create_session(self, token_suffix):
return Session.objects.create(
raw = f'test-refresh-token-{token_suffix}'
session = Session.objects.create(
user=self.user,
refresh_token=f'test-refresh-token-{token_suffix}',
refresh_token=hash_refresh_token(raw),
device_id='device-refresh-throttle-test',
expires_at=timezone.now() + timezone.timedelta(days=30),
revoked=False,
)
session.raw = raw
return session
def test_requests_within_the_limit_still_succeed(self):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '3/hour'}):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
@@ -783,13 +787,13 @@ class RefreshTokenThrottleTests(TestCase):
for i in range(3):
session = self._create_session(i)
response = self.client.post(
'/api/auth/refresh', {'refreshToken': session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': session.raw}, format='json',
)
self.assertEqual(response.status_code, 200)
extra_session = self._create_session('extra')
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': extra_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': extra_session.raw}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
@@ -797,7 +801,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'token_refresh': '1/hour'}):
first_session = self._create_session('first')
first_response = self.client.post(
'/api/auth/refresh', {'refreshToken': first_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': first_session.raw}, format='json',
)
self.assertEqual(first_response.status_code, 200)
@@ -806,7 +810,7 @@ class RefreshTokenThrottleTests(TestCase):
with patch('accounts.views.issue_access_token') as mock_issue_token:
blocked_response = self.client.post(
'/api/auth/refresh', {'refreshToken': blocked_session.refresh_token}, format='json',
'/api/auth/refresh', {'refreshToken': blocked_session.raw}, format='json',
)
self.assertEqual(blocked_response.status_code, 429)
mock_issue_token.assert_not_called()
@@ -1669,3 +1673,31 @@ class TestLoginPhoneParsingTests(TestCase):
'09120000001:abcdef', '09120000001:1234567'):
with self.subTest(raw=raw), self.assertRaises(ImproperlyConfigured):
self._parse(raw)
class RefreshTokenStoredHashedTests(TestCase):
"""دیتابیس، بکاپ یا پنلِ ادمین نباید خودِ توکنِ تمدید را داشته باشد؛ فقط هشش."""
def test_issued_tokens_are_stored_only_as_hash(self):
client = APIClient()
body = client.post('/api/auth/session', {'type': 'guest', 'deviceId': 'hash-dev'}, format='json').json()
raw = body['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=raw).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(raw)).exists())
renewed = client.post('/api/auth/refresh', {'refreshToken': raw}, format='json').json()['refreshToken']
self.assertFalse(Session.objects.filter(refresh_token=renewed).exists())
self.assertTrue(Session.objects.filter(refresh_token=hash_refresh_token(renewed), revoked=False).exists())
def test_migration_hashes_existing_rows(self):
from importlib import import_module
from django.apps import apps as global_apps
user = User.objects.create(is_guest=True, device_id='hash-mig')
Session.objects.create(user=user, refresh_token='old-raw-token',
expires_at=timezone.now() + timezone.timedelta(days=1))
import_module('accounts.migrations.0007_hash_refresh_tokens').hash_existing(global_apps, None)
self.assertEqual(Session.objects.get(user=user).refresh_token, hash_refresh_token('old-raw-token'))
response = APIClient().post('/api/auth/refresh', {'refreshToken': 'old-raw-token'}, format='json')
self.assertEqual(response.status_code, 200)

View File

@@ -16,7 +16,7 @@ from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .authentication import invalidate_user_cache, issue_access_token
from .models import OTPCode, Profile, Session, User
from .models import OTPCode, Profile, Session, User, hash_refresh_token
from .services import OTPDeliveryError, send_otp_sms
from .serializers import (
DeleteAccountRequestSerializer,
@@ -101,7 +101,7 @@ class GuestSessionView(APIView):
refresh_token = secrets.token_hex(32)
Session.objects.create(
user=user,
refresh_token=refresh_token,
refresh_token=hash_refresh_token(refresh_token),
device_id=device_id,
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
@@ -385,7 +385,7 @@ class LoginView(APIView):
refresh_token = uuid.uuid4().hex
Session.objects.create(
user=user,
refresh_token=refresh_token,
refresh_token=hash_refresh_token(refresh_token),
device_id=request.data.get('deviceId', ''),
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)
@@ -504,7 +504,7 @@ class LogoutView(APIView):
with transaction.atomic():
if refresh_token:
Session.objects.filter(
user_id=request.user.id, refresh_token=refresh_token,
user_id=request.user.id, refresh_token=hash_refresh_token(refresh_token),
).update(revoked=True)
User.objects.filter(pk=request.user.id).update(tokens_valid_after=timezone.now())
invalidate_user_cache(request.user.id)
@@ -536,13 +536,13 @@ class RefreshTokenView(APIView):
# UPDATE اتمیک جلوی استفاده‌ی هم‌زمان از یک refresh token را می‌گیرد (replay protection).
with transaction.atomic():
updated_count = Session.objects.filter(
refresh_token=refresh_token, revoked=False, expires_at__gte=timezone.now()
refresh_token=hash_refresh_token(refresh_token), revoked=False, expires_at__gte=timezone.now()
).update(revoked=True)
if not updated_count:
return Response({'detail': 'رفرش‌توکن نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_401_UNAUTHORIZED)
old_session = Session.objects.get(refresh_token=refresh_token)
old_session = Session.objects.get(refresh_token=hash_refresh_token(refresh_token))
# ثبتِ فعالیتِ کاربر برای رفرشِ موفقِ توکن.
User.objects.filter(pk=old_session.user_id).update(last_activity_at=timezone.now())
@@ -550,7 +550,7 @@ class RefreshTokenView(APIView):
new_refresh_token = secrets.token_hex(32)
new_session = Session.objects.create(
user_id=old_session.user_id,
refresh_token=new_refresh_token,
refresh_token=hash_refresh_token(new_refresh_token),
device_id=old_session.device_id,
expires_at=timezone.now() + timezone.timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
)