Skip to content

Commit cfa67b0

Browse files
Merge pull request #2 from RandomProgramm3r(max)/develop
feat: Create a user application for registration and authentication, write tests.
2 parents e968dd9 + f98b4d2 commit cfa67b0

File tree

20 files changed

+1110
-16
lines changed

20 files changed

+1110
-16
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[flake8]
22
max-line-length = 79
3-
application_import_names = promo_code
3+
application_import_names = promo_code, user
44
import-order-style = google
55
exclude = */migrations/, venv/, verdict.py, .venv/, env/, venv, .git, __pycache__
66
max-complexity = 10

.github/workflows/lint.yml renamed to .github/workflows/linting.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
name: Linting
22
on:
33
push:
4-
branches: [main]
4+
branches: [main, develop]
55
pull_request:
6-
branches: [main]
6+
branches: [main, develop]
7+
workflow_dispatch:
78

89
jobs:
910
lint:

.isort.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[settings]
22
profile = black
33
skip = migrations, venv/, venv
4-
known_first_party = promo_code
4+
known_first_party = promo_code, user
55
default_section = THIRDPARTY
66
force_sort_within_sections = true
77
line_length = 79

promo_code/promo_code/settings.py

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import datetime
12
import os
23
import pathlib
34

@@ -26,19 +27,70 @@ def load_bool(name, default):
2627

2728
DEBUG = load_bool('DJANGO_DEBUG', False)
2829

29-
ALLOWED_HOSTS = []
30-
30+
ALLOWED_HOSTS = ['*']
3131

3232
INSTALLED_APPS = [
33-
'core.apps.CoreConfig',
3433
'django.contrib.admin',
3534
'django.contrib.auth',
3635
'django.contrib.contenttypes',
3736
'django.contrib.sessions',
3837
'django.contrib.messages',
3938
'django.contrib.staticfiles',
39+
#
40+
'rest_framework',
41+
'rest_framework_simplejwt',
42+
'rest_framework_simplejwt.token_blacklist',
43+
#
44+
'core.apps.CoreConfig',
45+
'user.apps.UserConfig',
4046
]
4147

48+
AUTH_USER_MODEL = 'user.User'
49+
50+
REST_FRAMEWORK = {
51+
'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',),
52+
'DEFAULT_AUTHENTICATION_CLASSES': [
53+
'user.authentication.CustomJWTAuthentication',
54+
],
55+
}
56+
57+
SIMPLE_JWT = {
58+
'ACCESS_TOKEN_LIFETIME': datetime.timedelta(hours=1),
59+
'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1),
60+
'ROTATE_REFRESH_TOKENS': True,
61+
'BLACKLIST_AFTER_ROTATION': True,
62+
'UPDATE_LAST_LOGIN': False, # !
63+
#
64+
'ALGORITHM': 'HS256',
65+
'SIGNING_KEY': SECRET_KEY,
66+
'VERIFYING_KEY': None,
67+
'AUDIENCE': None,
68+
'ISSUER': None,
69+
'JSON_ENCODER': None,
70+
'JWK_URL': None,
71+
'LEEWAY': 0,
72+
#
73+
'AUTH_HEADER_TYPES': ('Bearer',),
74+
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
75+
'USER_ID_FIELD': 'id',
76+
'USER_ID_CLAIM': 'user_id',
77+
'USER_AUTHENTICATION_RULE': (
78+
'rest_framework_simplejwt.authentication'
79+
'.default_user_authentication_rule',
80+
),
81+
#
82+
'TOKEN_TYPE_CLAIM': 'token_type',
83+
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
84+
#
85+
'JTI_CLAIM': 'jti',
86+
#
87+
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
88+
'SLIDING_TOKEN_LIFETIME': datetime.timedelta(minutes=5),
89+
'SLIDING_TOKEN_REFRESH_LIFETIME': datetime.timedelta(days=1),
90+
#
91+
'ACCESS_TOKEN_CLASS': 'user.tokens.CustomAccessToken',
92+
}
93+
4294
MIDDLEWARE = [
4395
'django.middleware.security.SecurityMiddleware',
4496
'django.contrib.sessions.middleware.SessionMiddleware',
@@ -83,20 +135,32 @@ def load_bool(name, default):
83135

84136
AUTH_PASSWORD_VALIDATORS = [
85137
{
86-
'NAME': 'django.contrib.auth.password_validation.'
87-
'UserAttributeSimilarityValidator',
138+
'NAME': 'django.contrib.auth.password_validation'
139+
'.UserAttributeSimilarityValidator',
140+
},
141+
{
142+
'NAME': 'django.contrib.auth.password_validation'
143+
'.MinimumLengthValidator',
144+
},
145+
{
146+
'NAME': 'django.contrib.auth.password_validation'
147+
'.CommonPasswordValidator',
148+
},
149+
{
150+
'NAME': 'django.contrib.auth.password_validation'
151+
'.NumericPasswordValidator',
152+
},
153+
{
154+
'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator',
88155
},
89156
{
90-
'NAME': 'django.contrib.auth.password_validation.'
91-
'MinimumLengthValidator',
157+
'NAME': 'promo_code.validators.NumericPasswordValidator',
92158
},
93159
{
94-
'NAME': 'django.contrib.auth.password_validation.'
95-
'CommonPasswordValidator',
160+
'NAME': 'promo_code.validators.LatinLetterPasswordValidator',
96161
},
97162
{
98-
'NAME': 'django.contrib.auth.password_validation.'
99-
'NumericPasswordValidator',
163+
'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator',
100164
},
101165
]
102166

promo_code/promo_code/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33

44
urlpatterns = [
55
django.urls.path('api/ping/', django.urls.include('core.urls')),
6+
django.urls.path('api/user/', django.urls.include('user.urls')),
67
django.urls.path('admin/', django.contrib.admin.site.urls),
78
]

promo_code/promo_code/validators.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import abc
2+
import re
3+
import unicodedata
4+
5+
import django.core.exceptions
6+
from django.utils.translation import gettext as _
7+
8+
9+
class BaseCountPasswordValidator(abc.ABC):
10+
"""
11+
Abstract base class for password validators checking
12+
character count requirements.
13+
14+
Attributes:
15+
min_count (int): Minimum required character count (>=1)
16+
17+
Raises:
18+
ValueError: If min_count is less than 1 during initialization
19+
"""
20+
21+
def __init__(self, min_count=1):
22+
if min_count < 1:
23+
raise ValueError('min_count must be at least 1')
24+
25+
self.min_count = min_count
26+
27+
@abc.abstractmethod
28+
def get_help_text(self) -> str:
29+
"""Abstract method to return user-friendly help text"""
30+
pass
31+
32+
def validate(self, password, user=None):
33+
"""
34+
Validate password meets the character count requirement
35+
36+
Args:
37+
password (str): Password to validate
38+
user (User): Optional user object (not used)
39+
40+
Raises:
41+
ValidationError: If validation fails
42+
"""
43+
count = sum(1 for char in password if self.validate_char(char))
44+
if count < self.min_count:
45+
raise django.core.exceptions.ValidationError(
46+
self.get_error_message(),
47+
code=self.get_code(),
48+
)
49+
50+
def validate_char(self, char) -> bool:
51+
"""
52+
Check if character meets validation criteria
53+
54+
Args:
55+
char (str): Single character to check
56+
57+
Returns:
58+
bool: Validation result
59+
"""
60+
raise NotImplementedError
61+
62+
def get_code(self) -> str:
63+
"""Get error code identifier"""
64+
return getattr(self, 'code', 'base_code')
65+
66+
def get_error_message(self) -> str:
67+
"""Get localized error message"""
68+
raise NotImplementedError
69+
70+
71+
class SpecialCharacterPasswordValidator(BaseCountPasswordValidator):
72+
"""
73+
Validates presence of minimum required special characters
74+
75+
Args:
76+
special_chars (str): Regex pattern for valid special characters
77+
min_count (int): Minimum required count (default: 1)
78+
79+
Example:
80+
SpecialCharacterValidator(r'[!@#$%^&*]', min_count=2)
81+
"""
82+
83+
def __init__(
84+
self,
85+
special_chars=r'[!@#$%^&*()_+\-=\[\]{};\':",./<>?`~\\]',
86+
min_count=1,
87+
):
88+
super().__init__(min_count)
89+
self.pattern = re.compile(special_chars)
90+
self.code = 'password_no_special_char'
91+
92+
def validate_char(self, char) -> bool:
93+
"""Check if character matches special characters pattern"""
94+
return bool(self.pattern.match(char))
95+
96+
def get_help_text(self) -> str:
97+
return _(
98+
(
99+
f'Your password must contain at least {self.min_count} '
100+
'special character(s).'
101+
),
102+
)
103+
104+
def get_error_message(self) -> str:
105+
return _(
106+
(
107+
f'Password must contain at least {self.min_count} '
108+
'special character(s).'
109+
),
110+
)
111+
112+
113+
class NumericPasswordValidator(BaseCountPasswordValidator):
114+
"""
115+
Validates presence of minimum required numeric digits
116+
117+
Args:
118+
min_count (int): Minimum required digits (default: 1)
119+
"""
120+
121+
def __init__(self, min_count=1):
122+
super().__init__(min_count)
123+
self.code = 'password_no_number'
124+
125+
def validate_char(self, char) -> bool:
126+
"""Check if character is a digit"""
127+
return char.isdigit()
128+
129+
def get_help_text(self) -> str:
130+
return _(
131+
f'Your password must contain at least {self.min_count} digit(s).',
132+
)
133+
134+
def get_error_message(self) -> str:
135+
return _(f'Password must contain at least {self.min_count} digit(s).')
136+
137+
138+
class LatinLetterPasswordValidator(BaseCountPasswordValidator):
139+
"""
140+
Validates presence of minimum required Latin letters (ASCII)
141+
142+
Args:
143+
min_count (int): Minimum required letters (default: 1)
144+
"""
145+
146+
def __init__(self, min_count=1):
147+
super().__init__(min_count)
148+
self.code = 'password_no_latin_letter'
149+
150+
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()
153+
154+
def get_help_text(self) -> str:
155+
return _(
156+
(
157+
f'Your password must contain at least {self.min_count} '
158+
'Latin letter(s).'
159+
),
160+
)
161+
162+
def get_error_message(self) -> str:
163+
return _(
164+
(
165+
f'Password must contain at least {self.min_count} '
166+
'Latin letter(s).'
167+
),
168+
)
169+
170+
171+
class UppercaseLatinLetterPasswordValidator(BaseCountPasswordValidator):
172+
"""
173+
Validates presence of minimum required uppercase Latin letters
174+
175+
Args:
176+
min_count (int): Minimum required uppercase letters (default: 1)
177+
"""
178+
179+
def __init__(self, min_count=1):
180+
super().__init__(min_count)
181+
self.code = 'password_no_uppercase_latin'
182+
183+
def validate_char(self, char) -> bool:
184+
"""Check if character is uppercase Latin letter"""
185+
return char.isupper() and char.isascii()
186+
187+
def get_help_text(self) -> str:
188+
return _(
189+
(
190+
f'Your password must contain at least {self.min_count} '
191+
'uppercase Latin letter(s).'
192+
),
193+
)
194+
195+
def get_error_message(self) -> str:
196+
return _(
197+
(
198+
f'Password must contain at least {self.min_count} '
199+
'uppercase Latin letter(s).'
200+
),
201+
)

promo_code/user/__init__.py

Whitespace-only changes.

promo_code/user/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import django.apps
2+
3+
4+
class UserConfig(django.apps.AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'user'

promo_code/user/authentication.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import datetime
2+
3+
import rest_framework.exceptions
4+
import rest_framework_simplejwt.authentication
5+
6+
7+
class CustomJWTAuthentication(
8+
rest_framework_simplejwt.authentication.JWTAuthentication,
9+
):
10+
def get_user(self, validated_token):
11+
user = super().get_user(validated_token)
12+
last_login_str = validated_token.get('last_login')
13+
if last_login_str:
14+
last_login = datetime.datetime.fromisoformat(last_login_str)
15+
if user.last_login and user.last_login > last_login:
16+
raise rest_framework.exceptions.AuthenticationFailed(
17+
'Token has been invalidated',
18+
)
19+
20+
return user

0 commit comments

Comments
 (0)