Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .flake8
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[flake8]
max-line-length = 79
application_import_names = promo_code
application_import_names = promo_code, user
import-order-style = google
exclude = */migrations/, venv/, verdict.py, .venv/, env/, venv, .git, __pycache__
max-complexity = 10
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/lint.yml → .github/workflows/linting.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
name: Linting
on:
push:
branches: [main]
branches: [main, develop]
pull_request:
branches: [main]
branches: [main, develop]
workflow_dispatch:

jobs:
lint:
Expand Down
2 changes: 1 addition & 1 deletion .isort.cfg
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[settings]
profile = black
skip = migrations, venv/, venv
known_first_party = promo_code
known_first_party = promo_code, user
default_section = THIRDPARTY
force_sort_within_sections = true
line_length = 79
86 changes: 75 additions & 11 deletions promo_code/promo_code/settings.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import os
import pathlib

Expand Down Expand Up @@ -26,19 +27,70 @@ def load_bool(name, default):

DEBUG = load_bool('DJANGO_DEBUG', False)

ALLOWED_HOSTS = []

ALLOWED_HOSTS = ['*']

INSTALLED_APPS = [
'core.apps.CoreConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
#
'rest_framework',
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist',
#
'core.apps.CoreConfig',
'user.apps.UserConfig',
]

AUTH_USER_MODEL = 'user.User'

REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',),
'DEFAULT_AUTHENTICATION_CLASSES': [
'user.authentication.CustomJWTAuthentication',
],
}

SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': datetime.timedelta(hours=1),
'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'UPDATE_LAST_LOGIN': False, # !
#
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'VERIFYING_KEY': None,
'AUDIENCE': None,
'ISSUER': None,
'JSON_ENCODER': None,
'JWK_URL': None,
'LEEWAY': 0,
#
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION',
'USER_ID_FIELD': 'id',
'USER_ID_CLAIM': 'user_id',
'USER_AUTHENTICATION_RULE': (
'rest_framework_simplejwt.authentication'
'.default_user_authentication_rule',
),
#
'TOKEN_TYPE_CLAIM': 'token_type',
'TOKEN_USER_CLASS': 'rest_framework_simplejwt.models.TokenUser',
#
'JTI_CLAIM': 'jti',
#
'SLIDING_TOKEN_REFRESH_EXP_CLAIM': 'refresh_exp',
'SLIDING_TOKEN_LIFETIME': datetime.timedelta(minutes=5),
'SLIDING_TOKEN_REFRESH_LIFETIME': datetime.timedelta(days=1),
#
'ACCESS_TOKEN_CLASS': 'user.tokens.CustomAccessToken',
}

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
Expand Down Expand Up @@ -83,20 +135,32 @@ def load_bool(name, default):

AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.'
'UserAttributeSimilarityValidator',
'NAME': 'django.contrib.auth.password_validation'
'.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation'
'.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation'
'.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation'
'.NumericPasswordValidator',
},
{
'NAME': 'promo_code.validators.SpecialCharacterPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.'
'MinimumLengthValidator',
'NAME': 'promo_code.validators.NumericPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.'
'CommonPasswordValidator',
'NAME': 'promo_code.validators.LatinLetterPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.'
'NumericPasswordValidator',
'NAME': 'promo_code.validators.UppercaseLatinLetterPasswordValidator',
},
]

Expand Down
1 change: 1 addition & 0 deletions promo_code/promo_code/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@

urlpatterns = [
django.urls.path('api/ping/', django.urls.include('core.urls')),
django.urls.path('api/user/', django.urls.include('user.urls')),
django.urls.path('admin/', django.contrib.admin.site.urls),
]
201 changes: 201 additions & 0 deletions promo_code/promo_code/validators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import abc
import re
import unicodedata

import django.core.exceptions
from django.utils.translation import gettext as _


class BaseCountPasswordValidator(abc.ABC):
"""
Abstract base class for password validators checking
character count requirements.

Attributes:
min_count (int): Minimum required character count (>=1)

Raises:
ValueError: If min_count is less than 1 during initialization
"""

def __init__(self, min_count=1):
if min_count < 1:
raise ValueError('min_count must be at least 1')

self.min_count = min_count

@abc.abstractmethod
def get_help_text(self) -> str:
"""Abstract method to return user-friendly help text"""
pass

def validate(self, password, user=None):
"""
Validate password meets the character count requirement

Args:
password (str): Password to validate
user (User): Optional user object (not used)

Raises:
ValidationError: If validation fails
"""
count = sum(1 for char in password if self.validate_char(char))
if count < self.min_count:
raise django.core.exceptions.ValidationError(
self.get_error_message(),
code=self.get_code(),
)

def validate_char(self, char) -> bool:
"""
Check if character meets validation criteria

Args:
char (str): Single character to check

Returns:
bool: Validation result
"""
raise NotImplementedError

def get_code(self) -> str:
"""Get error code identifier"""
return getattr(self, 'code', 'base_code')

def get_error_message(self) -> str:
"""Get localized error message"""
raise NotImplementedError


class SpecialCharacterPasswordValidator(BaseCountPasswordValidator):
"""
Validates presence of minimum required special characters

Args:
special_chars (str): Regex pattern for valid special characters
min_count (int): Minimum required count (default: 1)

Example:
SpecialCharacterValidator(r'[!@#$%^&*]', min_count=2)
"""

def __init__(
self,
special_chars=r'[!@#$%^&*()_+\-=\[\]{};\':",./<>?`~\\]',
min_count=1,
):
super().__init__(min_count)
self.pattern = re.compile(special_chars)
self.code = 'password_no_special_char'

def validate_char(self, char) -> bool:
"""Check if character matches special characters pattern"""
return bool(self.pattern.match(char))

def get_help_text(self) -> str:
return _(
(
f'Your password must contain at least {self.min_count} '
'special character(s).'
),
)

def get_error_message(self) -> str:
return _(
(
f'Password must contain at least {self.min_count} '
'special character(s).'
),
)


class NumericPasswordValidator(BaseCountPasswordValidator):
"""
Validates presence of minimum required numeric digits

Args:
min_count (int): Minimum required digits (default: 1)
"""

def __init__(self, min_count=1):
super().__init__(min_count)
self.code = 'password_no_number'

def validate_char(self, char) -> bool:
"""Check if character is a digit"""
return char.isdigit()

def get_help_text(self) -> str:
return _(
f'Your password must contain at least {self.min_count} digit(s).',
)

def get_error_message(self) -> str:
return _(f'Password must contain at least {self.min_count} digit(s).')


class LatinLetterPasswordValidator(BaseCountPasswordValidator):
"""
Validates presence of minimum required Latin letters (ASCII)

Args:
min_count (int): Minimum required letters (default: 1)
"""

def __init__(self, min_count=1):
super().__init__(min_count)
self.code = 'password_no_latin_letter'

def validate_char(self, char) -> bool:
"""Check if character is a Latin ASCII letter"""
return unicodedata.category(char).startswith('L') and char.isascii()

def get_help_text(self) -> str:
return _(
(
f'Your password must contain at least {self.min_count} '
'Latin letter(s).'
),
)

def get_error_message(self) -> str:
return _(
(
f'Password must contain at least {self.min_count} '
'Latin letter(s).'
),
)


class UppercaseLatinLetterPasswordValidator(BaseCountPasswordValidator):
"""
Validates presence of minimum required uppercase Latin letters

Args:
min_count (int): Minimum required uppercase letters (default: 1)
"""

def __init__(self, min_count=1):
super().__init__(min_count)
self.code = 'password_no_uppercase_latin'

def validate_char(self, char) -> bool:
"""Check if character is uppercase Latin letter"""
return char.isupper() and char.isascii()

def get_help_text(self) -> str:
return _(
(
f'Your password must contain at least {self.min_count} '
'uppercase Latin letter(s).'
),
)

def get_error_message(self) -> str:
return _(
(
f'Password must contain at least {self.min_count} '
'uppercase Latin letter(s).'
),
)
Empty file added promo_code/user/__init__.py
Empty file.
6 changes: 6 additions & 0 deletions promo_code/user/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import django.apps


class UserConfig(django.apps.AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'user'
20 changes: 20 additions & 0 deletions promo_code/user/authentication.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import datetime

import rest_framework.exceptions
import rest_framework_simplejwt.authentication


class CustomJWTAuthentication(
rest_framework_simplejwt.authentication.JWTAuthentication,
):
def get_user(self, validated_token):
user = super().get_user(validated_token)
last_login_str = validated_token.get('last_login')
if last_login_str:
last_login = datetime.datetime.fromisoformat(last_login_str)
if user.last_login and user.last_login > last_login:
raise rest_framework.exceptions.AuthenticationFailed(
'Token has been invalidated',
)

return user
Loading