fix bug sms

This commit is contained in:
javad committed 2026-10-09 10:32:27 +03:30
1 parent 48d0ecba79
commit ae4dbab3cc
5 files changed
+93 -13

No files matched your search

+10 -3
View File
@@ -94,11 +94,18 @@ def login_by_mobile(mobile: int):
from math import ceil from math import ceil
raise SmsRequestThrottled(ceil(remaining)) raise SmsRequestThrottled(ceil(remaining))
code = secrets.randbelow(900000) + 100000 code = secrets.randbelow(900000) + 100000
if not DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}'): delivery = DefaultSmsProvider().send_sms(str(user.mobile), f'کد ورود شما: {code}')
if delivery is False:
raise SmsDeliveryUnavailable() raise SmsDeliveryUnavailable()
SmsOpt.objects.filter(mobile=user.mobile).delete() if delivery is True:
SmsOpt.objects.filter(mobile=user.mobile).delete()
else:
# Keep any previous unexpired code too: either SMS may have arrived.
SmsOpt.objects.filter(mobile=user.mobile, expire__lte=timezone.now()).delete()
SmsOpt.objects.create(mobile=user.mobile, opt=code, expire=timezone.now() + timedelta(minutes=3)) SmsOpt.objects.create(mobile=user.mobile, opt=code, expire=timezone.now() + timedelta(minutes=3))
return user.mobile return {'mobile': user.mobile, 'delivery_status': 'accepted' if delivery is True else 'unconfirmed',
'message': 'opt code send' if delivery is True else 'وضعیت ارسال پیامک تأیید نشد. اگر کد را دریافت کرده‌اید، آن را وارد کنید.',
'retry_after': SMS_RESEND_INTERVAL}
@transaction.atomic @transaction.atomic
+49 -1
View File
@@ -1,14 +1,62 @@
"""Customer OTP journey, repeat submits, and recoverable failure contracts.""" """Customer OTP journey, repeat submits, and recoverable failure contracts."""
from datetime import timedelta from datetime import timedelta
from unittest.mock import patch from unittest.mock import Mock, patch
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import requests
from apps.cuser.models import SmsOpt from apps.cuser.models import SmsOpt
from apps.jwt_auth.test_regressions import RegressionFixtures from apps.jwt_auth.test_regressions import RegressionFixtures
class CustomerLoginTests(RegressionFixtures): class CustomerLoginTests(RegressionFixtures):
def test_unrecognized_provider_reply_keeps_delivered_code_usable(self):
for body in ('1', '654321987', '<string>accepted</string>', ''):
with self.subTest(body=body):
SmsOpt.objects.all().delete()
with patch('utils.services.sms.requests.get', return_value=Mock(text=body)):
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 202)
self.assertEqual(sent.data['result']['delivery_status'], 'unconfirmed')
code = SmsOpt.objects.get(mobile=self.user.mobile).opt
verified = self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code})
self.assertEqual(verified.status_code, 200)
self.assertEqual(self.post('token/', {'uuid_opt': verified.data[0]['uuid']}).status_code, 200)
def test_lost_provider_reply_keeps_code_and_still_throttles_repeat_sms(self):
for error in (requests.Timeout, requests.ConnectionError):
with self.subTest(error=error):
SmsOpt.objects.all().delete()
with patch('utils.services.sms.requests.get', side_effect=error) as send:
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 202)
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 429)
send.assert_called_once()
code = SmsOpt.objects.get().opt
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': code}).status_code, 200)
def test_uncertain_resend_retains_previous_delivered_code(self):
original = self.otp()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('utils.services.sms.requests.get', return_value=Mock(text='unexpected')):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 202)
self.assertEqual(SmsOpt.objects.filter(mobile=self.user.mobile).count(), 2)
self.assertEqual(self.post('login/mobile/verify/', {'mobile': self.user.mobile, 'opt': original.opt}).status_code, 200)
self.assertFalse(SmsOpt.objects.filter(mobile=self.user.mobile).exists())
@override_settings(SMS_SUCCESS_CODES='0,1', SMS_FAILURE_CODES='error,-1')
def test_configured_response_codes_and_explicit_rejection(self):
with patch('utils.services.sms.requests.get', return_value=Mock(text='\ufeff1\r\n')):
sent = self.post('login/mobile/', {'mobile': self.user.mobile})
self.assertEqual(sent.status_code, 200)
self.assertEqual(sent.data['result']['delivery_status'], 'accepted')
original = SmsOpt.objects.get()
SmsOpt.objects.filter(pk=original.pk).update(created_at=timezone.now() - timedelta(seconds=61))
with patch('utils.services.sms.requests.get', return_value=Mock(text='-1')):
self.assertEqual(self.post('login/mobile/', {'mobile': self.user.mobile}).status_code, 503)
self.assertEqual(SmsOpt.objects.get().pk, original.pk)
def test_complete_customer_journey_login_refresh_and_logout(self): def test_complete_customer_journey_login_refresh_and_logout(self):
with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send: with patch('apps.jwt_auth.services.DefaultSmsProvider.send_sms', return_value=True) as send:
sent = self.post('login/mobile/', {'mobile': '۰۹۱۲۳۴۵۶۷۸۹'}) sent = self.post('login/mobile/', {'mobile': '۰۹۱۲۳۴۵۶۷۸۹'})
+1 -1
View File
@@ -249,7 +249,7 @@ class AuthRegressionTests(RegressionFixtures):
with patch('utils.services.sms.requests.get', return_value=Mock(text='error')): with patch('utils.services.sms.requests.get', return_value=Mock(text='error')):
self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test')) self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
with patch('utils.services.sms.requests.get', side_effect=requests.Timeout): with patch('utils.services.sms.requests.get', side_effect=requests.Timeout):
self.assertFalse(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test')) self.assertIsNone(DosefrNavadHashtSMS().send_sms(str(self.user.mobile), 'Test'))
def test_iranian_mobile_prefix_is_not_accidentally_removed(self): def test_iranian_mobile_prefix_is_not_accidentally_removed(self):
self.assertEqual(normalize_mobile('9891234567'), 9891234567) self.assertEqual(normalize_mobile('9891234567'), 9891234567)
+3 -3
View File
@@ -9,7 +9,7 @@ from rest_framework_simplejwt.tokens import RefreshToken
from apps.cuser.models import CUser, CuserRole, RoleType from apps.cuser.models import CUser, CuserRole, RoleType
from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt from apps.jwt_auth.models import JwtToken, CUserRoleLoginOpt
from apps.jwt_auth.services import login_by_username, login_by_mobile, verify_mobile_and_get_token, SMS_RESEND_INTERVAL from apps.jwt_auth.services import login_by_username, login_by_mobile, verify_mobile_and_get_token
from apps.jwt_auth.session import issue_tokens, validate_session_claims from apps.jwt_auth.session import issue_tokens, validate_session_claims
from apps.workspace.models import Workspace from apps.workspace.models import Workspace
@@ -69,8 +69,8 @@ class LoginByMobileView(PublicAuthView):
def post(self, request): def post(self, request):
serializer = self.InputSerializer(data=request.data) serializer = self.InputSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
mobile = login_by_mobile(**serializer.validated_data) result = login_by_mobile(**serializer.validated_data)
return Response({'result': {'mobile': mobile, 'message': 'opt code send', 'retry_after': SMS_RESEND_INTERVAL}}) return Response({'result': result}, status=202 if result['delivery_status'] == 'unconfirmed' else 200)
class LoginByUserView(PublicAuthView): class LoginByUserView(PublicAuthView):
+30 -5
View File
@@ -1,22 +1,25 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import requests import requests
import logging
from django.conf import settings from django.conf import settings
logger = logging.getLogger(__name__)
class SmsProviderABC(ABC): class SmsProviderABC(ABC):
@abstractmethod @abstractmethod
def send_sms(self, mobile: str, message: str) -> bool: def send_sms(self, mobile: str, message: str) -> bool | None:
pass pass
class DefaultSmsProvider(SmsProviderABC): class DefaultSmsProvider(SmsProviderABC):
def send_sms(self, mobile: str, message: str) -> bool: def send_sms(self, mobile: str, message: str) -> bool | None:
default_provider = DosefrNavadHashtSMS() default_provider = DosefrNavadHashtSMS()
return default_provider.send_sms(mobile, message) return default_provider.send_sms(mobile, message)
class DosefrNavadHashtSMS(SmsProviderABC): class DosefrNavadHashtSMS(SmsProviderABC):
def send_sms(self, mobile: str, message: str) -> bool: def send_sms(self, mobile: str, message: str) -> bool | None:
payload = { payload = {
'USERNAME': 'vsms7937', 'USERNAME': 'vsms7937',
'PASSWORD': '335577775533', 'PASSWORD': '335577775533',
@@ -27,9 +30,31 @@ class DosefrNavadHashtSMS(SmsProviderABC):
try: try:
r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload, timeout=10) r = requests.get('http://www.0098sms.com/sendsmslink.aspx', params=payload, timeout=10)
r.raise_for_status() r.raise_for_status()
except (requests.Timeout, requests.ConnectionError):
# The provider may have dispatched the SMS before its reply was lost.
logger.warning('SMS provider reply unavailable; dispatch is unconfirmed')
return None
except requests.HTTPError as error:
if error.response is not None and error.response.status_code >= 500:
logger.warning('SMS provider server error; dispatch is unconfirmed')
return None
return False
except requests.RequestException: except requests.RequestException:
return False return False
# An HTTP 200 alone does not mean the SMS provider accepted the message. # Unknown bodies are NOT evidence of rejection. In particular, never
# discard a delivered OTP just because the response contract differs.
success_codes = getattr(settings, 'SMS_SUCCESS_CODES', ('0',)) success_codes = getattr(settings, 'SMS_SUCCESS_CODES', ('0',))
return r.text.strip() in success_codes failure_codes = getattr(settings, 'SMS_FAILURE_CODES', ('error',))
body = r.text.strip().lstrip('\ufeff').strip()
def codes(values):
if isinstance(values, str):
values = values.split(',')
return {str(value).strip() for value in values}
if body in codes(success_codes):
return True
if body in codes(failure_codes):
return False
# Do not log the reply, URL, credentials, mobile, or OTP.
logger.warning('SMS provider returned an unrecognized reply; dispatch is unconfirmed')
return None