Skip to content

Commit dd53e18

Browse files
refactor(password-validation): Add some new password validators, optimize validation tests.
- Add LowercaseLatinLetterPasswordValidator to check for minimum number of lowercase letters - Replace LatinLetterValidator with ASCIIOnlyPasswordValidator - Optimize validation tests using parameterized ones and add some new ones
1 parent a08ac17 commit dd53e18

File tree

5 files changed

+82
-54
lines changed

5 files changed

+82
-54
lines changed

promo_code/promo_code/settings.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def load_bool(name, default):
5050

5151
REST_FRAMEWORK = {
5252
'DEFAULT_AUTHENTICATION_CLASSES': [
53-
'user.authentication.CustomJWTAuthentication'
53+
'user.authentication.CustomJWTAuthentication',
5454
],
5555
}
5656

@@ -159,17 +159,24 @@ def load_bool(name, default):
159159
'NAME': 'django.contrib.auth.password_validation'
160160
'.NumericPasswordValidator',
161161
},
162+
{
163+
'NAME': 'promo_code.validators.ASCIIOnlyPasswordValidator',
164+
},
162165
{
163166
'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator',
167+
'OPTIONS': {'min_count': 1},
164168
},
165169
{
166170
'NAME': 'promo_code.validators.NumericPasswordValidator',
171+
'OPTIONS': {'min_count': 1},
167172
},
168173
{
169-
'NAME': 'promo_code.validators.LatinLetterPasswordValidator',
174+
'NAME': 'promo_code.validators.LowercaseLatinLetterPasswordValidator',
175+
'OPTIONS': {'min_count': 1},
170176
},
171177
{
172178
'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator',
179+
'OPTIONS': {'min_count': 1},
173180
},
174181
]
175182

promo_code/promo_code/validators.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import abc
22
import re
3-
import unicodedata
43

54
import django.core.exceptions
65
from django.utils.translation import gettext as _
@@ -135,35 +134,35 @@ def get_error_message(self) -> str:
135134
return _(f'Password must contain at least {self.min_count} digit(s).')
136135

137136

138-
class LatinLetterPasswordValidator(BaseCountPasswordValidator):
137+
class LowercaseLatinLetterPasswordValidator(BaseCountPasswordValidator):
139138
"""
140-
Validates presence of minimum required Latin letters (ASCII)
139+
Validates presence of minimum required lowercase Latin letters
141140
142141
Args:
143-
min_count (int): Minimum required letters (default: 1)
142+
min_count (int): Minimum required lowercase letters (default: 1)
144143
"""
145144

146145
def __init__(self, min_count=1):
147146
super().__init__(min_count)
148-
self.code = 'password_no_latin_letter'
147+
self.code = 'password_no_lowercase_latin'
149148

150149
def validate_char(self, char) -> bool:
151-
"""Check if character is a Latin ASCII letter"""
152-
return unicodedata.category(char).startswith('L') and char.isascii()
150+
"""Check if character is lower Latin letter"""
151+
return char.islower() and char.isascii()
153152

154153
def get_help_text(self) -> str:
155154
return _(
156155
(
157156
f'Your password must contain at least {self.min_count} '
158-
'Latin letter(s).'
157+
'lowercase Latin letter(s).'
159158
),
160159
)
161160

162161
def get_error_message(self) -> str:
163162
return _(
164163
(
165164
f'Password must contain at least {self.min_count} '
166-
'Latin letter(s).'
165+
'lowercase Latin letter(s).'
167166
),
168167
)
169168

@@ -199,3 +198,40 @@ def get_error_message(self) -> str:
199198
'uppercase Latin letter(s).'
200199
),
201200
)
201+
202+
203+
class ASCIIOnlyPasswordValidator:
204+
"""
205+
Validates that password contains only ASCII characters
206+
207+
Example:
208+
- Valid: 'Passw0rd!123'
209+
- Invalid: 'Pässwörd§123'
210+
"""
211+
212+
code = 'password_not_only_ascii_characters'
213+
214+
def validate(self, password, user=None) -> bool:
215+
try:
216+
password.encode('ascii', errors='strict')
217+
except UnicodeEncodeError:
218+
raise django.core.exceptions.ValidationError(
219+
_('Password contains non-ASCII characters'),
220+
code=self.code,
221+
)
222+
223+
def get_help_text(self) -> str:
224+
return _(
225+
(
226+
'Your password must contain only standard English letters, '
227+
'digits and punctuation symbols (ASCII character set)'
228+
),
229+
)
230+
231+
def get_error_message(self) -> str:
232+
return _(
233+
(
234+
'Your password must contain only standard English letters, '
235+
'digits and punctuation symbols (ASCII character set)'
236+
),
237+
)

promo_code/user/authentication.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
1-
import rest_framework_simplejwt.exceptions
21
import rest_framework_simplejwt.authentication
2+
import rest_framework_simplejwt.exceptions
33

44

5-
class CustomJWTAuthentication(rest_framework_simplejwt.authentication.JWTAuthentication):
5+
class CustomJWTAuthentication(
6+
rest_framework_simplejwt.authentication.JWTAuthentication,
7+
):
68
def authenticate(self, request):
79
try:
810
user_token = super().authenticate(request)
911
except rest_framework_simplejwt.exceptions.InvalidToken:
10-
raise rest_framework_simplejwt.exceptions.AuthenticationFailed('Token is invalid or expired')
12+
raise rest_framework_simplejwt.exceptions.AuthenticationFailed(
13+
'Token is invalid or expired',
14+
)
1115

1216
if user_token:
1317
user, token = user_token
1418
if token.payload.get('token_version') != user.token_version:
15-
raise rest_framework_simplejwt.exceptions.AuthenticationFailed('Token invalid')
19+
raise rest_framework_simplejwt.exceptions.AuthenticationFailed(
20+
'Token invalid',
21+
)
1622

17-
return user_token
23+
return user_token

promo_code/user/tests/auth/test_registration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ def tearDown(self):
1414
user.models.User.objects.all().delete()
1515
super().tearDown()
1616

17-
def test_valid_registration(self):
17+
def test_registration_success(self):
1818
valid_data = {
1919
'name': 'Emma',
2020
'surname': 'Thompson',

promo_code/user/tests/auth/test_validation.py

Lines changed: 16 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -69,58 +69,37 @@ def test_invalid_email_format(self):
6969
rest_framework.status.HTTP_400_BAD_REQUEST,
7070
)
7171

72-
def test_weak_password_common_phrase(self):
72+
@parameterized.parameterized.expand(
73+
[
74+
('common_phrase', 'whereismymoney777'),
75+
('missing_special_char', 'fioejifojfieoAAAA9299'),
76+
('too_short', 'Aa7$b!'),
77+
('missing_uppercase', 'lowercase123$'),
78+
('missing_lowercase', 'UPPERCASE123$'),
79+
('missing_digits', 'PasswordSpecial$'),
80+
('non_ascii', 'Päss123$!AAd'),
81+
('emoji', '😎werY!!*Dj3sd'),
82+
],
83+
)
84+
def test_weak_password_cases(self, case_name, password):
7385
data = {
7486
'name': 'Emma',
7587
'surname': 'Thompson',
76-
'email': 'dota.for.fan@gmail.com',
77-
'password': 'whereismymoney777',
88+
'email': f'test.user+{case_name}@example.com',
89+
'password': password,
7890
'other': {'age': 23, 'country': 'us'},
7991
}
80-
response = self.client.post(
81-
django.urls.reverse('api-user:sign-up'),
82-
data,
83-
format='json',
84-
)
85-
self.assertEqual(
86-
response.status_code,
87-
rest_framework.status.HTTP_400_BAD_REQUEST,
88-
)
8992

90-
def test_weak_password_missing_special_char(self):
91-
data = {
92-
'name': 'Emma',
93-
'surname': 'Thompson',
94-
'email': '[email protected]',
95-
'password': 'fioejifojfieoAAAA9299',
96-
'other': {'age': 23, 'country': 'us'},
97-
}
9893
response = self.client.post(
9994
django.urls.reverse('api-user:sign-up'),
10095
data,
10196
format='json',
10297
)
103-
self.assertEqual(
104-
response.status_code,
105-
rest_framework.status.HTTP_400_BAD_REQUEST,
106-
)
10798

108-
def test_weak_password_too_short(self):
109-
data = {
110-
'name': 'Emma',
111-
'surname': 'Thompson',
112-
'email': '[email protected]',
113-
'password': 'Aa7$b!',
114-
'other': {'age': 23, 'country': 'us'},
115-
}
116-
response = self.client.post(
117-
django.urls.reverse('api-user:sign-up'),
118-
data,
119-
format='json',
120-
)
12199
self.assertEqual(
122100
response.status_code,
123101
rest_framework.status.HTTP_400_BAD_REQUEST,
102+
f'Failed for case: {case_name}. Response: {response.data}',
124103
)
125104

126105
def generate_test_cases():

0 commit comments

Comments
 (0)