Skip to content

Commit 9efb7fd

Browse files
feat: add brute force protection for SMS verification codes
Add comprehensive brute force protection to prevent automated attacks on SMS verification codes: - Add failed_attempts field to SMSVerification model with database migration - Implement MAX_FAILED_ATTEMPTS setting (default: 5) for session lockout - Implement MIN_TOKEN_LENGTH setting (default: 6) to enforce minimum security code length - Track failed attempts for invalid, expired, and already-verified codes - Reset failed_attempts to 0 on successful verification - Update validation logic to check brute force limit before other validations - Add SECURITY_CODE_TOO_MANY_ATTEMPTS status code and error handling - Update sandbox backends to support brute force protection while maintaining test-friendly behavior - Add comprehensive test coverage for brute force protection scenarios - Update documentation with new security settings Closes #100 Co-authored-by: Harsh <[email protected]>
1 parent ee855a6 commit 9efb7fd

File tree

12 files changed

+207
-23
lines changed

12 files changed

+207
-23
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Release Notes
66

77
Added
88
"""""
9+
- **Brute Force Protection**: Added comprehensive brute force protection for SMS verification codes to prevent automated attacks. New settings: ``MAX_FAILED_ATTEMPTS`` (default: 5) for session lockout threshold and ``MIN_TOKEN_LENGTH`` (default: 6) to enforce minimum security code length. Added ``failed_attempts`` field to ``SMSVerification`` model with migration for backward compatibility. Contributed by `Harsh <https://github.com/Kaos599>`_. Closes `#100 <https://github.com/CuriousLearner/django-phone-verify/issues/100>`_.
910
- **Internationalization (i18n)**: Added support for localizing verification messages based on the ``Accept-Language`` HTTP header. The library now automatically detects the user's preferred language and sends verification messages in that language using Django's translation system. Contributed by `Hari Mahadevan <https://github.com/harikvpy>`_.
1011
- **Documentation**: Completely overhauled documentation to professional, enterprise-grade quality:
1112

docs/getting_started.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ Add the ``PHONE_VERIFICATION`` configuration to your ``settings.py``.
108108
'FROM': os.environ.get('TWILIO_PHONE_NUMBER'), # Your Twilio phone number (e.g., '+1234567890')
109109
},
110110
'TOKEN_LENGTH': 6, # Length of security code
111+
'MIN_TOKEN_LENGTH': 6, # Minimum allowed token length for security
112+
'MAX_FAILED_ATTEMPTS': 5, # Maximum failed verification attempts before locking session
111113
'MESSAGE': 'Welcome to {app}! Please use security code {security_code} to proceed.',
112114
'APP_NAME': 'MyApp', # Your app name (used in MESSAGE)
113115
'SECURITY_CODE_EXPIRATION_TIME': 600, # 10 minutes (in seconds)
@@ -129,6 +131,8 @@ Add the ``PHONE_VERIFICATION`` configuration to your ``settings.py``.
129131
'FROM': 'MyApp', # Sender ID (alphanumeric or phone number)
130132
},
131133
'TOKEN_LENGTH': 6,
134+
'MIN_TOKEN_LENGTH': 6, # Minimum allowed token length for security
135+
'MAX_FAILED_ATTEMPTS': 5, # Maximum failed verification attempts before locking session
132136
'MESSAGE': 'Welcome to {app}! Please use security code {security_code} to proceed.',
133137
'APP_NAME': 'MyApp',
134138
'SECURITY_CODE_EXPIRATION_TIME': 600,
@@ -171,6 +175,8 @@ Here's what each setting does:
171175
- Nexmo: ``KEY``, ``SECRET``, ``FROM``
172176

173177
- **TOKEN_LENGTH**: Number of digits in the security code (recommended: 6)
178+
- **MIN_TOKEN_LENGTH**: Minimum allowed token length for security (default: 6). Prevents setting TOKEN_LENGTH to insecure low values
179+
- **MAX_FAILED_ATTEMPTS**: Maximum failed verification attempts before session lockout (default: 5). Protects against brute force attacks
174180
- **MESSAGE**: SMS message template. Variables: ``{app}`` and ``{security_code}``
175181
- **APP_NAME**: Your application name (used in MESSAGE template)
176182
- **SECURITY_CODE_EXPIRATION_TIME**: How long codes are valid (in seconds). Recommended: 300-600 (5-10 minutes)

phone_verify/admin.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@
99

1010
@admin.register(SMSVerification)
1111
class SMSVerificationAdmin(admin.ModelAdmin):
12-
list_display = ("id", "security_code", "phone_number", "is_verified", "created_at")
12+
list_display = ("id", "security_code", "phone_number", "is_verified", "failed_attempts", "created_at")
1313
search_fields = ("phone_number",)
1414
ordering = ("phone_number",)
1515
readonly_fields = (
1616
"security_code",
1717
"phone_number",
1818
"session_token",
1919
"is_verified",
20+
"failed_attempts",
2021
"created_at",
2122
"modified_at",
2223
)

phone_verify/backends/base.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66
# Third Party Stuff
77
import jwt
88
from django.conf import settings as django_settings
9+
from django.db import models
910
from django.utils import timezone
1011
from django.utils.crypto import get_random_string
1112

1213
from ..models import SMSVerification
1314

1415
DEFAULT_TOKEN_LENGTH = 6
16+
DEFAULT_MIN_TOKEN_LENGTH = 6
17+
DEFAULT_MAX_FAILED_ATTEMPTS = 5
1518

1619

1720
class BaseBackend(metaclass=ABCMeta):
@@ -20,6 +23,7 @@ class BaseBackend(metaclass=ABCMeta):
2023
SECURITY_CODE_EXPIRED = 2
2124
SECURITY_CODE_VERIFIED = 3
2225
SESSION_TOKEN_INVALID = 4
26+
SECURITY_CODE_TOO_MANY_ATTEMPTS = 5
2327

2428
def __init__(self, **settings):
2529
self.exception_class = None
@@ -102,6 +106,34 @@ def create_security_code_and_session_token(self, number):
102106
)
103107
return security_code, session_token
104108

109+
def _should_bypass_code_check(self, security_code):
110+
"""
111+
Hook for sandbox backends to bypass security code validation.
112+
Returns True if the code check should be bypassed.
113+
114+
:param security_code: The security code to check
115+
:return: Boolean indicating whether to bypass code validation
116+
"""
117+
return False
118+
119+
def _increment_failed_attempts(self, verification):
120+
"""Atomically increment failed attempts counter."""
121+
verification.failed_attempts = models.F('failed_attempts') + 1
122+
verification.save(update_fields=['failed_attempts'])
123+
verification.refresh_from_db()
124+
125+
def _reset_failed_attempts(self, verification):
126+
"""Reset failed attempts counter to 0."""
127+
verification.failed_attempts = 0
128+
verification.save(update_fields=['failed_attempts'])
129+
130+
def _check_brute_force_limit(self, verification):
131+
"""Check if verification has exceeded failed attempts limit."""
132+
max_failed_attempts = django_settings.PHONE_VERIFICATION.get(
133+
"MAX_FAILED_ATTEMPTS", DEFAULT_MAX_FAILED_ATTEMPTS
134+
)
135+
return verification.failed_attempts >= max_failed_attempts
136+
105137
def validate_security_code(self, security_code, phone_number, session_token):
106138
"""
107139
A utility method to verify if the `security_code` entered is valid for
@@ -120,32 +152,53 @@ def validate_security_code(self, security_code, phone_number, session_token):
120152
- `BaseBackend.SECURITY_CODE_EXPIRED`
121153
- `BaseBackend.SECURITY_CODE_VERIFIED`
122154
- `BaseBackend.SESSION_TOKEN_INVALID`
155+
- `BaseBackend.SECURITY_CODE_TOO_MANY_ATTEMPTS`
123156
"""
124157
stored_verification = SMSVerification.objects.filter(
125-
security_code=security_code, phone_number=phone_number
158+
phone_number=phone_number, session_token=session_token
126159
).first()
127160

128-
# check security_code exists
129-
if stored_verification is None:
130-
return stored_verification, self.SECURITY_CODE_INVALID
161+
# Allow sandbox backends to bypass validation (but check brute force first if verification exists)
162+
if self._should_bypass_code_check(security_code):
163+
if stored_verification is None:
164+
return SMSVerification.objects.none(), self.SECURITY_CODE_VALID
165+
166+
# Even for sandbox, check brute force limit first
167+
if self._check_brute_force_limit(stored_verification):
168+
return stored_verification, self.SECURITY_CODE_TOO_MANY_ATTEMPTS
131169

132-
# check session code exists
133-
if not stored_verification.session_token == session_token:
170+
self._reset_failed_attempts(stored_verification)
171+
return SMSVerification.objects.none(), self.SECURITY_CODE_VALID
172+
173+
# check verification exists
174+
if stored_verification is None:
134175
return stored_verification, self.SESSION_TOKEN_INVALID
135176

177+
# check if too many failed attempts
178+
if self._check_brute_force_limit(stored_verification):
179+
return stored_verification, self.SECURITY_CODE_TOO_MANY_ATTEMPTS
180+
181+
# check security_code matches
182+
if stored_verification.security_code != security_code:
183+
self._increment_failed_attempts(stored_verification)
184+
return stored_verification, self.SECURITY_CODE_INVALID
185+
136186
# check security_code is not expired
137187
if self.check_security_code_expiry(stored_verification):
188+
self._increment_failed_attempts(stored_verification)
138189
return stored_verification, self.SECURITY_CODE_EXPIRED
139190

140191
# check security_code is not verified
141192
if stored_verification.is_verified and django_settings.PHONE_VERIFICATION.get(
142193
"VERIFY_SECURITY_CODE_ONLY_ONCE"
143194
):
195+
self._increment_failed_attempts(stored_verification)
144196
return stored_verification, self.SECURITY_CODE_VERIFIED
145197

146198
# mark security_code as verified
147199
stored_verification.is_verified = True
148-
stored_verification.save()
200+
stored_verification.failed_attempts = 0
201+
stored_verification.save(update_fields=['is_verified', 'failed_attempts'])
149202

150203
return stored_verification, self.SECURITY_CODE_VALID
151204

phone_verify/backends/nexmo.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import nexmo
66
from nexmo.errors import ClientError
77

8-
from phone_verify.models import SMSVerification
9-
108
# Local
119
from .base import BaseBackend
1210

@@ -58,5 +56,9 @@ def generate_security_code(self):
5856
"""
5957
return self._token
6058

61-
def validate_security_code(self, security_code, phone_number, session_token):
62-
return SMSVerification.objects.none(), self.SECURITY_CODE_VALID
59+
def _should_bypass_code_check(self, security_code):
60+
"""
61+
Sandbox mode: bypass code validation if the security code matches the sandbox token.
62+
This allows testing without real SMS while still enforcing brute force protection.
63+
"""
64+
return security_code == self._token

phone_verify/backends/twilio.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
from twilio.base.exceptions import TwilioRestException
66
from twilio.rest import Client as TwilioRestClient
77

8-
from phone_verify.models import SMSVerification
9-
108
# Local
119
from .base import BaseBackend
1210

@@ -57,5 +55,9 @@ def generate_security_code(self):
5755
"""
5856
return self._token
5957

60-
def validate_security_code(self, security_code, phone_number, session_token):
61-
return SMSVerification.objects.none(), self.SECURITY_CODE_VALID
58+
def _should_bypass_code_check(self, security_code):
59+
"""
60+
Sandbox mode: bypass code validation if the security code matches the sandbox token.
61+
This allows testing without real SMS while still enforcing brute force protection.
62+
"""
63+
return security_code == self._token
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.0.1 on 2025-10-12 20:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('phone_verify', '0002_auto_20190817_1753'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='smsverification',
15+
name='failed_attempts',
16+
field=models.PositiveIntegerField(default=0, verbose_name='Failed Attempts'),
17+
),
18+
]

phone_verify/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class SMSVerification(TimeStampedUUIDModel):
3939
phone_number = PhoneNumberField(_("Phone Number"))
4040
session_token = models.CharField(_("Device Session Token"), max_length=500)
4141
is_verified = models.BooleanField(_("Security Code Verified"), default=False)
42+
failed_attempts = models.PositiveIntegerField(_("Failed Attempts"), default=0)
4243

4344
class Meta:
4445
db_table = "sms_verification"

phone_verify/serializers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ def validate(self, attrs):
4444
raise serializers.ValidationError(_("Security code is not valid"))
4545
elif token_validatation == backend.SESSION_TOKEN_INVALID:
4646
raise serializers.ValidationError(_("Session Token mis-match"))
47+
elif token_validatation == backend.SECURITY_CODE_INVALID:
48+
raise serializers.ValidationError(_("Security code is not valid"))
4749
elif token_validatation == backend.SECURITY_CODE_EXPIRED:
4850
raise serializers.ValidationError(_("Security code has expired"))
4951
elif token_validatation == backend.SECURITY_CODE_VERIFIED:
5052
raise serializers.ValidationError(_("Security code is already verified"))
53+
elif token_validatation == backend.SECURITY_CODE_TOO_MANY_ATTEMPTS:
54+
raise serializers.ValidationError(_("Too many failed verification attempts. Please request a new code."))
5155

5256
return attrs

phone_verify/services.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
# phone_verify stuff
1212
from .backends import get_sms_backend
13+
from .backends.base import DEFAULT_MIN_TOKEN_LENGTH, DEFAULT_TOKEN_LENGTH
1314

1415
logger = logging.getLogger(__name__)
1516

@@ -88,6 +89,14 @@ def _check_required_settings(self):
8889
)
8990
)
9091

92+
# Validate minimum token length
93+
token_length = settings.PHONE_VERIFICATION.get("TOKEN_LENGTH", DEFAULT_TOKEN_LENGTH)
94+
min_token_length = settings.PHONE_VERIFICATION.get("MIN_TOKEN_LENGTH", DEFAULT_MIN_TOKEN_LENGTH)
95+
if token_length < min_token_length:
96+
raise ImproperlyConfigured(
97+
f"TOKEN_LENGTH ({token_length}) cannot be less than MIN_TOKEN_LENGTH ({min_token_length})"
98+
)
99+
91100

92101
def send_security_code_and_generate_session_token(phone_number, language=None):
93102
sms_backend = get_sms_backend(phone_number)

0 commit comments

Comments
 (0)