From a08ac173df923781472167cf73223f1aef13f58d Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 21 Mar 2025 22:04:03 +0300 Subject: [PATCH 1/3] refactor(auth): Move token version check to authentication class. - Remove TokenVersionMiddleware - Implement CustomJWTAuthentication with same validation logic - No functional changes for end users --- promo_code/promo_code/settings.py | 3 +-- promo_code/user/authentication.py | 17 +++++++++++++++++ promo_code/user/middleware.py | 25 ------------------------- 3 files changed, 18 insertions(+), 27 deletions(-) create mode 100644 promo_code/user/authentication.py delete mode 100644 promo_code/user/middleware.py diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index 560c10d..ac05060 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -50,7 +50,7 @@ def load_bool(name, default): REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'user.authentication.CustomJWTAuthentication' ], } @@ -108,7 +108,6 @@ def load_bool(name, default): 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'user.middleware.TokenVersionMiddleware', ] ROOT_URLCONF = 'promo_code.urls' diff --git a/promo_code/user/authentication.py b/promo_code/user/authentication.py new file mode 100644 index 0000000..ad37b69 --- /dev/null +++ b/promo_code/user/authentication.py @@ -0,0 +1,17 @@ +import rest_framework_simplejwt.exceptions +import rest_framework_simplejwt.authentication + + +class CustomJWTAuthentication(rest_framework_simplejwt.authentication.JWTAuthentication): + def authenticate(self, request): + try: + user_token = super().authenticate(request) + except rest_framework_simplejwt.exceptions.InvalidToken: + raise rest_framework_simplejwt.exceptions.AuthenticationFailed('Token is invalid or expired') + + if user_token: + user, token = user_token + if token.payload.get('token_version') != user.token_version: + raise rest_framework_simplejwt.exceptions.AuthenticationFailed('Token invalid') + + return user_token \ No newline at end of file diff --git a/promo_code/user/middleware.py b/promo_code/user/middleware.py deleted file mode 100644 index d4b1ba3..0000000 --- a/promo_code/user/middleware.py +++ /dev/null @@ -1,25 +0,0 @@ -import django.http -import rest_framework_simplejwt.authentication - - -class TokenVersionMiddleware: - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - auth = rest_framework_simplejwt.authentication.JWTAuthentication() - auth_result = auth.authenticate(request) - - if auth_result is None: - return self.get_response(request) - - user, token = auth_result - if user: - token_version = token.payload.get('token_version', 0) - if token_version != user.token_version: - return django.http.JsonResponse( - {'error': 'Token invalid'}, - status=401, - ) - - return self.get_response(request) From dd53e18f59b0073ef03e98e7cad6a95a63629598 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Mar 2025 00:56:42 +0300 Subject: [PATCH 2/3] 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 --- promo_code/promo_code/settings.py | 11 +++- promo_code/promo_code/validators.py | 54 +++++++++++++++---- promo_code/user/authentication.py | 16 ++++-- .../user/tests/auth/test_registration.py | 2 +- promo_code/user/tests/auth/test_validation.py | 53 ++++++------------ 5 files changed, 82 insertions(+), 54 deletions(-) diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index ac05060..9ca13f1 100644 --- a/promo_code/promo_code/settings.py +++ b/promo_code/promo_code/settings.py @@ -50,7 +50,7 @@ def load_bool(name, default): REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'user.authentication.CustomJWTAuthentication' + 'user.authentication.CustomJWTAuthentication', ], } @@ -159,17 +159,24 @@ def load_bool(name, default): 'NAME': 'django.contrib.auth.password_validation' '.NumericPasswordValidator', }, + { + 'NAME': 'promo_code.validators.ASCIIOnlyPasswordValidator', + }, { 'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator', + 'OPTIONS': {'min_count': 1}, }, { 'NAME': 'promo_code.validators.NumericPasswordValidator', + 'OPTIONS': {'min_count': 1}, }, { - 'NAME': 'promo_code.validators.LatinLetterPasswordValidator', + 'NAME': 'promo_code.validators.LowercaseLatinLetterPasswordValidator', + 'OPTIONS': {'min_count': 1}, }, { 'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator', + 'OPTIONS': {'min_count': 1}, }, ] diff --git a/promo_code/promo_code/validators.py b/promo_code/promo_code/validators.py index 664c3fb..57a5a74 100644 --- a/promo_code/promo_code/validators.py +++ b/promo_code/promo_code/validators.py @@ -1,6 +1,5 @@ import abc import re -import unicodedata import django.core.exceptions from django.utils.translation import gettext as _ @@ -135,27 +134,27 @@ def get_error_message(self) -> str: return _(f'Password must contain at least {self.min_count} digit(s).') -class LatinLetterPasswordValidator(BaseCountPasswordValidator): +class LowercaseLatinLetterPasswordValidator(BaseCountPasswordValidator): """ - Validates presence of minimum required Latin letters (ASCII) + Validates presence of minimum required lowercase Latin letters Args: - min_count (int): Minimum required letters (default: 1) + min_count (int): Minimum required lowercase letters (default: 1) """ def __init__(self, min_count=1): super().__init__(min_count) - self.code = 'password_no_latin_letter' + self.code = 'password_no_lowercase_latin' def validate_char(self, char) -> bool: - """Check if character is a Latin ASCII letter""" - return unicodedata.category(char).startswith('L') and char.isascii() + """Check if character is lower Latin letter""" + return char.islower() and char.isascii() def get_help_text(self) -> str: return _( ( f'Your password must contain at least {self.min_count} ' - 'Latin letter(s).' + 'lowercase Latin letter(s).' ), ) @@ -163,7 +162,7 @@ def get_error_message(self) -> str: return _( ( f'Password must contain at least {self.min_count} ' - 'Latin letter(s).' + 'lowercase Latin letter(s).' ), ) @@ -199,3 +198,40 @@ def get_error_message(self) -> str: 'uppercase Latin letter(s).' ), ) + + +class ASCIIOnlyPasswordValidator: + """ + Validates that password contains only ASCII characters + + Example: + - Valid: 'Passw0rd!123' + - Invalid: 'Pässwörd§123' + """ + + code = 'password_not_only_ascii_characters' + + def validate(self, password, user=None) -> bool: + try: + password.encode('ascii', errors='strict') + except UnicodeEncodeError: + raise django.core.exceptions.ValidationError( + _('Password contains non-ASCII characters'), + code=self.code, + ) + + def get_help_text(self) -> str: + return _( + ( + 'Your password must contain only standard English letters, ' + 'digits and punctuation symbols (ASCII character set)' + ), + ) + + def get_error_message(self) -> str: + return _( + ( + 'Your password must contain only standard English letters, ' + 'digits and punctuation symbols (ASCII character set)' + ), + ) diff --git a/promo_code/user/authentication.py b/promo_code/user/authentication.py index ad37b69..0b6d515 100644 --- a/promo_code/user/authentication.py +++ b/promo_code/user/authentication.py @@ -1,17 +1,23 @@ -import rest_framework_simplejwt.exceptions import rest_framework_simplejwt.authentication +import rest_framework_simplejwt.exceptions -class CustomJWTAuthentication(rest_framework_simplejwt.authentication.JWTAuthentication): +class CustomJWTAuthentication( + rest_framework_simplejwt.authentication.JWTAuthentication, +): def authenticate(self, request): try: user_token = super().authenticate(request) except rest_framework_simplejwt.exceptions.InvalidToken: - raise rest_framework_simplejwt.exceptions.AuthenticationFailed('Token is invalid or expired') + raise rest_framework_simplejwt.exceptions.AuthenticationFailed( + 'Token is invalid or expired', + ) if user_token: user, token = user_token if token.payload.get('token_version') != user.token_version: - raise rest_framework_simplejwt.exceptions.AuthenticationFailed('Token invalid') + raise rest_framework_simplejwt.exceptions.AuthenticationFailed( + 'Token invalid', + ) - return user_token \ No newline at end of file + return user_token diff --git a/promo_code/user/tests/auth/test_registration.py b/promo_code/user/tests/auth/test_registration.py index 08e2ef0..d29ddf7 100644 --- a/promo_code/user/tests/auth/test_registration.py +++ b/promo_code/user/tests/auth/test_registration.py @@ -14,7 +14,7 @@ def tearDown(self): user.models.User.objects.all().delete() super().tearDown() - def test_valid_registration(self): + def test_registration_success(self): valid_data = { 'name': 'Emma', 'surname': 'Thompson', diff --git a/promo_code/user/tests/auth/test_validation.py b/promo_code/user/tests/auth/test_validation.py index 31abbc0..230db18 100644 --- a/promo_code/user/tests/auth/test_validation.py +++ b/promo_code/user/tests/auth/test_validation.py @@ -69,58 +69,37 @@ def test_invalid_email_format(self): rest_framework.status.HTTP_400_BAD_REQUEST, ) - def test_weak_password_common_phrase(self): + @parameterized.parameterized.expand( + [ + ('common_phrase', 'whereismymoney777'), + ('missing_special_char', 'fioejifojfieoAAAA9299'), + ('too_short', 'Aa7$b!'), + ('missing_uppercase', 'lowercase123$'), + ('missing_lowercase', 'UPPERCASE123$'), + ('missing_digits', 'PasswordSpecial$'), + ('non_ascii', 'Päss123$!AAd'), + ('emoji', '😎werY!!*Dj3sd'), + ], + ) + def test_weak_password_cases(self, case_name, password): data = { 'name': 'Emma', 'surname': 'Thompson', - 'email': 'dota.for.fan@gmail.com', - 'password': 'whereismymoney777', + 'email': f'test.user+{case_name}@example.com', + 'password': password, 'other': {'age': 23, 'country': 'us'}, } - response = self.client.post( - django.urls.reverse('api-user:sign-up'), - data, - format='json', - ) - self.assertEqual( - response.status_code, - rest_framework.status.HTTP_400_BAD_REQUEST, - ) - def test_weak_password_missing_special_char(self): - data = { - 'name': 'Emma', - 'surname': 'Thompson', - 'email': 'dota.for.fan@gmail.com', - 'password': 'fioejifojfieoAAAA9299', - 'other': {'age': 23, 'country': 'us'}, - } response = self.client.post( django.urls.reverse('api-user:sign-up'), data, format='json', ) - self.assertEqual( - response.status_code, - rest_framework.status.HTTP_400_BAD_REQUEST, - ) - def test_weak_password_too_short(self): - data = { - 'name': 'Emma', - 'surname': 'Thompson', - 'email': 'dota.for.fan@gmail.com', - 'password': 'Aa7$b!', - 'other': {'age': 23, 'country': 'us'}, - } - response = self.client.post( - django.urls.reverse('api-user:sign-up'), - data, - format='json', - ) self.assertEqual( response.status_code, rest_framework.status.HTTP_400_BAD_REQUEST, + f'Failed for case: {case_name}. Response: {response.data}', ) def generate_test_cases(): From 54b3ca4d412a8cdaffa3d2df3763d2d68c387835 Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 22 Mar 2025 20:43:20 +0300 Subject: [PATCH 3/3] refactor(auth tests): simplify auth test cases using BaseAuthTestCase. - Introduce BaseAuthTestCase to handle common setup/teardown logic: - Predefine authentication-related URLs (signup, signin, protected, refresh) - Clean up User, BlacklistedToken and OutstandingToken records after tests - Remove redundant setUp()/tearDown() methods in individual test classes - Replace hardcoded reverse() calls with inherited URL attributes - Improve test maintainability through centralized configuration This refactoring follows DRY principles and makes test code more focused on actual test scenarios rather than boilerplate setup. --- promo_code/user/tests/auth/base.py | 22 ++++++ .../user/tests/auth/test_authentication.py | 35 +-------- .../user/tests/auth/test_registration.py | 14 +--- promo_code/user/tests/auth/test_tokens.py | 18 +---- promo_code/user/tests/auth/test_validation.py | 74 ++++++++++--------- 5 files changed, 71 insertions(+), 92 deletions(-) create mode 100644 promo_code/user/tests/auth/base.py diff --git a/promo_code/user/tests/auth/base.py b/promo_code/user/tests/auth/base.py new file mode 100644 index 0000000..d8d58f8 --- /dev/null +++ b/promo_code/user/tests/auth/base.py @@ -0,0 +1,22 @@ +import django.urls +import rest_framework.test +import rest_framework_simplejwt.token_blacklist.models as tb_models + +import user.models + + +class BaseAuthTestCase(rest_framework.test.APITestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.client = rest_framework.test.APIClient() + cls.protected_url = django.urls.reverse('api-core:protected') + cls.refresh_url = django.urls.reverse('api-user:token_refresh') + cls.signup_url = django.urls.reverse('api-user:sign-up') + cls.signin_url = django.urls.reverse('api-user:sign-in') + + def tearDown(self): + user.models.User.objects.all().delete() + tb_models.BlacklistedToken.objects.all().delete() + tb_models.OutstandingToken.objects.all().delete() + super().tearDown() diff --git a/promo_code/user/tests/auth/test_authentication.py b/promo_code/user/tests/auth/test_authentication.py index 23e35ea..adbce19 100644 --- a/promo_code/user/tests/auth/test_authentication.py +++ b/promo_code/user/tests/auth/test_authentication.py @@ -4,41 +4,10 @@ import rest_framework.test import user.models +import user.tests.auth.base -class AuthenticationTests(rest_framework.test.APITestCase): - def setUp(self): - self.client = rest_framework.test.APIClient() - super().setUp() - - def tearDown(self): - user.models.User.objects.all().delete() - super().tearDown() - - def test_valid_registration(self): - data = { - 'name': 'Steve', - 'surname': 'Jobs', - 'email': 'minecraft.digger@gmail.com', - 'password': 'SuperStrongPassword2000!', - 'other': {'age': 23, 'country': 'gb'}, - } - response = self.client.post( - django.urls.reverse('api-user:sign-up'), - data, - format='json', - ) - self.assertEqual( - response.status_code, - rest_framework.status.HTTP_200_OK, - ) - self.assertIn('access', response.data) - self.assertTrue( - user.models.User.objects.filter( - email='minecraft.digger@gmail.com', - ).exists(), - ) - +class AuthenticationTests(user.tests.auth.base.BaseAuthTestCase): def test_signin_success(self): user.models.User.objects.create_user( email='minecraft.digger@gmail.com', diff --git a/promo_code/user/tests/auth/test_registration.py b/promo_code/user/tests/auth/test_registration.py index d29ddf7..07783c9 100644 --- a/promo_code/user/tests/auth/test_registration.py +++ b/promo_code/user/tests/auth/test_registration.py @@ -1,19 +1,11 @@ -import django.urls import rest_framework.status import rest_framework.test import user.models +import user.tests.auth.base -class RegistrationTests(rest_framework.test.APITestCase): - def setUp(self): - self.client = rest_framework.test.APIClient() - super().setUp() - - def tearDown(self): - user.models.User.objects.all().delete() - super().tearDown() - +class RegistrationTests(user.tests.auth.base.BaseAuthTestCase): def test_registration_success(self): valid_data = { 'name': 'Emma', @@ -23,7 +15,7 @@ def test_registration_success(self): 'other': {'age': 23, 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, valid_data, format='json', ) diff --git a/promo_code/user/tests/auth/test_tokens.py b/promo_code/user/tests/auth/test_tokens.py index e970160..c4031ea 100644 --- a/promo_code/user/tests/auth/test_tokens.py +++ b/promo_code/user/tests/auth/test_tokens.py @@ -1,18 +1,14 @@ -import django.test -import django.urls import rest_framework.status import rest_framework.test import rest_framework_simplejwt.token_blacklist.models as tb_models import user.models +import user.tests.auth.base -class JWTTests(rest_framework.test.APITestCase): +class JWTTests(user.tests.auth.base.BaseAuthTestCase): def setUp(self): - self.signup_url = django.urls.reverse('api-user:sign-up') - self.signin_url = django.urls.reverse('api-user:sign-in') - self.protected_url = django.urls.reverse('api-core:protected') - self.refresh_url = django.urls.reverse('api-user:token_refresh') + super().setUp() user.models.User.objects.create_user( name='John', surname='Doe', @@ -20,18 +16,12 @@ def setUp(self): password='SuperStrongPassword2000!', other={'age': 25, 'country': 'us'}, ) + self.user_data = { 'email': 'example@example.com', 'password': 'SuperStrongPassword2000!', } - super(JWTTests, self).setUp() - - def tearDown(self): - user.models.User.objects.all().delete() - - super(JWTTests, self).tearDown() - def test_access_protected_view_with_valid_token(self): response = self.client.post( self.signin_url, diff --git a/promo_code/user/tests/auth/test_validation.py b/promo_code/user/tests/auth/test_validation.py index 230db18..a52f742 100644 --- a/promo_code/user/tests/auth/test_validation.py +++ b/promo_code/user/tests/auth/test_validation.py @@ -1,21 +1,12 @@ -import django.test -import django.urls import parameterized import rest_framework.status import rest_framework.test import user.models +import user.tests.auth.base -class RegistrationTestCase(rest_framework.test.APITestCase): - def setUp(self): - self.client = rest_framework.test.APIClient() - super().setUp() - - def tearDown(self): - user.models.User.objects.all().delete() - super().tearDown() - +class RegistrationTestCase(user.tests.auth.base.BaseAuthTestCase): def test_email_duplication(self): valid_data = { 'name': 'Emma', @@ -25,7 +16,7 @@ def test_email_duplication(self): 'other': {'age': 23, 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, valid_data, format='json', ) @@ -42,7 +33,7 @@ def test_email_duplication(self): 'other': {'age': 14, 'country': 'fr'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, duplicate_data, format='json', ) @@ -60,7 +51,7 @@ def test_invalid_email_format(self): 'other': {'age': 23, 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -91,7 +82,7 @@ def test_weak_password_cases(self, case_name, password): } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -127,7 +118,7 @@ def test_invalid_avatar_urls(self, name, url, email): } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -147,7 +138,7 @@ def test_missing_country_field(self): 'other': {'age': 23}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -156,6 +147,30 @@ def test_missing_country_field(self): rest_framework.status.HTTP_400_BAD_REQUEST, ) + def test_invalid_country_code(self): + invalid_data = { + 'name': 'Emma', + 'surname': 'Thompson', + 'email': 'test.invalid.country@example.com', + 'password': 'SuperStrongPassword2000!', + 'other': { + 'age': 23, + 'country': 'XX', + }, + } + + response = self.client.post( + self.signup_url, + invalid_data, + format='json', + ) + + self.assertEqual( + response.status_code, + rest_framework.status.HTTP_400_BAD_REQUEST, + 'Invalid country code should trigger validation error', + ) + def test_invalid_age_type(self): data = { 'name': 'Emma', @@ -165,7 +180,7 @@ def test_invalid_age_type(self): 'other': {'age': '23aaaaaa', 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -183,7 +198,7 @@ def test_missing_age_field(self): 'other': {'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -201,7 +216,7 @@ def test_negative_age_value(self): 'other': {'age': -20, 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -228,7 +243,7 @@ def test_invalid_email_formats(self, name, email): } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -248,7 +263,7 @@ def test_empty_name_field(self): 'other': {'age': 23, 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -267,7 +282,7 @@ def test_empty_surname_field(self): 'other': {'age': 23, 'country': 'us'}, } response = self.client.post( - django.urls.reverse('api-user:sign-up'), + self.signup_url, data, format='json', ) @@ -277,16 +292,7 @@ def test_empty_surname_field(self): ) -class AuthenticationTestCase(rest_framework.test.APITestCase): - def setUp(self): - self.client = rest_framework.test.APIClient() - self.signin_url = django.urls.reverse('api-user:sign-in') - super().setUp() - - def tearDown(self): - user.models.User.objects.all().delete() - super().tearDown() - +class AuthenticationTestCase(user.tests.auth.base.BaseAuthTestCase): @parameterized.parameterized.expand( [ ('missing_password', {'email': 'valid@example.com'}, 'password'), @@ -321,7 +327,7 @@ def test_signin_invalid_password(self): 'password': 'SuperInvalidPassword2000!', } response = self.client.post( - django.urls.reverse('api-user:sign-in'), + self.signin_url, data, format='json', )