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:
@@ -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),
|
||||
|
||||
28
accounts/migrations/0007_hash_refresh_tokens.py
Normal file
28
accounts/migrations/0007_hash_refresh_tokens.py
Normal 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),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user