diff --git a/promo_code/promo_code/settings.py b/promo_code/promo_code/settings.py index 560c10d..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': [ - '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' @@ -160,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 new file mode 100644 index 0000000..0b6d515 --- /dev/null +++ b/promo_code/user/authentication.py @@ -0,0 +1,23 @@ +import rest_framework_simplejwt.authentication +import rest_framework_simplejwt.exceptions + + +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 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) 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 08e2ef0..07783c9 100644 --- a/promo_code/user/tests/auth/test_registration.py +++ b/promo_code/user/tests/auth/test_registration.py @@ -1,20 +1,12 @@ -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() - - def test_valid_registration(self): +class RegistrationTests(user.tests.auth.base.BaseAuthTestCase): + def test_registration_success(self): valid_data = { 'name': 'Emma', 'surname': 'Thompson', @@ -23,7 +15,7 @@ def test_valid_registration(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 31abbc0..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', ) @@ -69,58 +60,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'), + self.signup_url, 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(): @@ -148,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', ) @@ -168,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', ) @@ -177,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', @@ -186,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', ) @@ -204,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', ) @@ -222,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', ) @@ -249,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', ) @@ -269,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', ) @@ -288,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', ) @@ -298,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'), @@ -342,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', )