Skip to content

Commit 291b07e

Browse files
feat: Refactor core logic and enhance security
This commit introduces a major refactoring of core application logic to improve modularity, performance, and security. Key changes include: - **Centralized Core Utilities:** - A new `core` application has been updated to house shared utilities. - `CustomLimitOffsetPagination` is now centralized in `core.pagination`. - A generic `bump_token_version` utility in `core.utils.auth` now handles token versioning for both User and Company models and includes cache invalidation. - **Performance and Security Enhancements:** - Implemented a caching layer for the authentication process. Authenticated user and company instances are now cached to reduce database queries. - Strengthened password security by setting Argon2 as the default password hasher. - **Code Simplification:** - Removed custom email validators in favor of relying on database integrity constraints (`try...except IntegrityError`), making the validation logic more robust and reducing boilerplate code.
1 parent 8dcba07 commit 291b07e

File tree

13 files changed

+150
-155
lines changed

13 files changed

+150
-155
lines changed

promo_code/business/serializers.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212

1313
import business.constants
1414
import business.models
15-
import business.utils.auth
1615
import business.utils.tokens
1716
import business.validators
17+
import core.utils.auth
1818

1919

2020
class CompanySignUpSerializer(rest_framework.serializers.ModelSerializer):
@@ -36,12 +36,6 @@ class CompanySignUpSerializer(rest_framework.serializers.ModelSerializer):
3636
required=True,
3737
min_length=business.constants.COMPANY_EMAIL_MIN_LENGTH,
3838
max_length=business.constants.COMPANY_EMAIL_MAX_LENGTH,
39-
validators=[
40-
business.validators.UniqueEmailValidator(
41-
'This email address is already registered.',
42-
'email_conflict',
43-
),
44-
],
4539
)
4640

4741
class Meta:
@@ -50,11 +44,20 @@ class Meta:
5044

5145
@django.db.transaction.atomic
5246
def create(self, validated_data):
53-
company = business.models.Company.objects.create_company(
54-
**validated_data,
55-
)
47+
try:
48+
company = business.models.Company.objects.create_company(
49+
**validated_data,
50+
)
51+
except django.db.IntegrityError:
52+
exc = rest_framework.exceptions.APIException(
53+
detail={
54+
'email': 'This email address is already registered.',
55+
},
56+
)
57+
exc.status_code = 409
58+
raise exc
5659

57-
return business.utils.auth.bump_company_token_version(company)
60+
return core.utils.auth.bump_token_version(company)
5861

5962

6063
class CompanySignInSerializer(rest_framework.serializers.Serializer):

promo_code/business/utils/auth.py

Lines changed: 0 additions & 13 deletions
This file was deleted.

promo_code/business/validators.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,6 @@
11
import rest_framework.exceptions
22

33
import business.constants
4-
import business.models
5-
6-
7-
class UniqueEmailValidator:
8-
def __init__(self, default_detail=None, default_code=None):
9-
self.status_code = 409
10-
self.default_detail = (
11-
default_detail or 'This email address is already registered.'
12-
)
13-
self.default_code = default_code or 'email_conflict'
14-
15-
def __call__(self, value):
16-
if business.models.Company.objects.filter(email=value).exists():
17-
exc = rest_framework.exceptions.APIException(
18-
detail={
19-
'status': 'error',
20-
'message': self.default_detail,
21-
'code': self.default_code,
22-
},
23-
)
24-
exc.status_code = self.status_code
25-
raise exc
264

275

286
class PromoValidator:

promo_code/business/views.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010
import rest_framework_simplejwt.views
1111

1212
import business.models
13-
import business.pagination
1413
import business.permissions
1514
import business.serializers
16-
import business.utils.auth
1715
import business.utils.tokens
16+
import core.pagination
17+
import core.utils.auth
1818
import user.models
1919

2020

@@ -49,7 +49,7 @@ def post(self, request, *args, **kwargs):
4949
serializer.is_valid(raise_exception=True)
5050

5151
company = serializer.validated_data['company']
52-
company = business.utils.auth.bump_company_token_version(company)
52+
company = core.utils.auth.bump_token_version(company)
5353

5454
return rest_framework.response.Response(
5555
business.utils.tokens.generate_company_tokens(company),
@@ -75,7 +75,7 @@ class CompanyPromoListCreateView(rest_framework.generics.ListCreateAPIView):
7575
business.permissions.IsCompanyUser,
7676
]
7777
# Pagination is only needed for GET (listing)
78-
pagination_class = business.pagination.CustomLimitOffsetPagination
78+
pagination_class = core.pagination.CustomLimitOffsetPagination
7979

8080
_validated_query_params = {}
8181

File renamed without changes.

promo_code/core/utils/auth.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import django.core.cache
2+
import django.db.models
3+
4+
5+
def bump_token_version(
6+
instance: django.db.models.Model,
7+
) -> django.db.models.Model:
8+
"""
9+
Atomically increments token_version for any model instance
10+
(User or Company), invalidates the corresponding cache,
11+
and returns the updated instance.
12+
"""
13+
user_type = instance.__class__.__name__.lower()
14+
15+
old_token_version = instance.token_version
16+
17+
instance.__class__.objects.filter(id=instance.id).update(
18+
token_version=django.db.models.F('token_version') + 1,
19+
)
20+
21+
old_cache_key = (
22+
f'auth_instance_{user_type}_{instance.id}_v{old_token_version}'
23+
)
24+
django.core.cache.cache.delete(old_cache_key)
25+
26+
instance.refresh_from_db()
27+
28+
return instance

promo_code/promo_code/settings.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def load_bool(name, default):
4848

4949
AUTH_USER_MODEL = 'user.User'
5050

51+
AUTH_INSTANCE_CACHE_TIMEOUT = 3600
52+
5153
REST_FRAMEWORK = {
5254
'DEFAULT_AUTHENTICATION_CLASSES': [
5355
'user.authentication.CustomJWTAuthentication',
@@ -197,6 +199,15 @@ def load_bool(name, default):
197199
]
198200

199201

202+
PASSWORD_HASHERS = [
203+
'django.contrib.auth.hashers.Argon2PasswordHasher',
204+
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
205+
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
206+
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
207+
'django.contrib.auth.hashers.ScryptPasswordHasher',
208+
]
209+
210+
200211
LANGUAGE_CODE = 'en-us'
201212

202213
TIME_ZONE = 'UTC'

promo_code/user/authentication.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
1+
import django.conf
2+
import django.core.cache
13
import rest_framework_simplejwt.authentication
24
import rest_framework_simplejwt.exceptions
35

46
import business.models
5-
import user.models as user_models
7+
import user.models
68

79

810
class CustomJWTAuthentication(
911
rest_framework_simplejwt.authentication.JWTAuthentication,
1012
):
1113
def authenticate(self, request):
14+
"""
15+
Authenticates the user or company based on a JWT token,
16+
supporting multiple user types.
17+
Retrieves the appropriate model instance from the token,
18+
checks token versioning and caches the authenticated instance
19+
for performance.
20+
"""
1221
try:
1322
header = self.get_header(request)
1423
if header is None:
@@ -20,9 +29,8 @@ def authenticate(self, request):
2029

2130
validated_token = self.get_validated_token(raw_token)
2231
user_type = validated_token.get('user_type', 'user')
23-
2432
model_mapping = {
25-
'user': (user_models.User, 'user_id'),
33+
'user': (user.models.User, 'user_id'),
2634
'company': (business.models.Company, 'company_id'),
2735
}
2836

@@ -32,21 +40,44 @@ def authenticate(self, request):
3240
)
3341

3442
model_class, id_field = model_mapping[user_type]
35-
instance = model_class.objects.get(
36-
id=validated_token.get(id_field),
43+
instance_id = validated_token.get(id_field)
44+
token_version = validated_token.get('token_version', 0)
45+
46+
cache_key = (
47+
f'auth_instance_{user_type}_{instance_id}_v{token_version}'
3748
)
38-
if instance.token_version != validated_token.get(
39-
'token_version',
40-
0,
41-
):
49+
cached_instance = django.core.cache.cache.get(cache_key)
50+
51+
if cached_instance:
52+
return (cached_instance, validated_token)
53+
54+
if instance_id is None:
55+
raise rest_framework_simplejwt.exceptions.AuthenticationFailed(
56+
f'Missing {id_field} in token',
57+
)
58+
59+
instance = model_class.objects.get(id=instance_id)
60+
61+
if instance.token_version != token_version:
4262
raise rest_framework_simplejwt.exceptions.AuthenticationFailed(
4363
'Token invalid',
4464
)
4565

66+
cache_timeout = getattr(
67+
django.conf.settings,
68+
'AUTH_INSTANCE_CACHE_TIMEOUT',
69+
3600,
70+
)
71+
django.core.cache.cache.set(
72+
cache_key,
73+
instance,
74+
timeout=cache_timeout,
75+
)
76+
4677
return (instance, validated_token)
4778

4879
except (
49-
user_models.User.DoesNotExist,
80+
user.models.User.DoesNotExist,
5081
business.models.Company.DoesNotExist,
5182
):
5283
raise rest_framework_simplejwt.exceptions.AuthenticationFailed(

promo_code/user/pagination.py

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)