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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user