From 39e833ec1da0359d502dfc89edcdebad88b8fde2 Mon Sep 17 00:00:00 2001 From: Anatoly Date: Thu, 29 Aug 2019 09:43:31 +0300 Subject: [PATCH 1/6] added custom JWT authentication --- apps/authorization/serializers/common.py | 7 +++-- apps/utils/authentication.py | 36 ++++++++++++++++++++++++ apps/utils/exceptions.py | 2 +- apps/utils/methods.py | 16 +++++++++-- apps/utils/permissions.py | 24 ++++++++++------ apps/utils/views.py | 4 +-- project/settings/base.py | 5 ++-- project/settings/local.py | 3 -- 8 files changed, 75 insertions(+), 22 deletions(-) create mode 100644 apps/utils/authentication.py diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 2a38ca09..36ae8a3e 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -200,8 +200,11 @@ class LogoutSerializer(serializers.ModelSerializer): def validate(self, attrs): """Override validated data""" request = self.context.get('request') - token = request.headers.get('Authorization') \ - .split(' ')[::-1][0] + # Get token bytes from cookies (result: b'Bearer ') + token_bytes = utils_methods.get_token_from_cookies(request) + # Get token value from bytes + token = token_bytes.decode().split(' ')[::-1][0] + # Get access token obj access_token = tokens.AccessToken(token) # Prepare validated data attrs['user'] = request.user diff --git a/apps/utils/authentication.py b/apps/utils/authentication.py new file mode 100644 index 00000000..044d6d75 --- /dev/null +++ b/apps/utils/authentication.py @@ -0,0 +1,36 @@ +"""Custom authentication based on JWTAuthentication class""" +from rest_framework import HTTP_HEADER_ENCODING +from rest_framework_simplejwt.authentication import JWTAuthentication +from rest_framework_simplejwt.settings import api_settings + +from utils.methods import get_token_from_cookies + +AUTH_HEADER_TYPES = api_settings.AUTH_HEADER_TYPES + +if not isinstance(api_settings.AUTH_HEADER_TYPES, (list, tuple)): + AUTH_HEADER_TYPES = (AUTH_HEADER_TYPES,) + +AUTH_HEADER_TYPE_BYTES = set( + h.encode(HTTP_HEADER_ENCODING) + for h in AUTH_HEADER_TYPES +) + + +class GMJWTAuthentication(JWTAuthentication): + """ + An authentication plugin that authenticates requests through a JSON web + token provided in a request cookies. + """ + + def authenticate(self, request): + token = get_token_from_cookies(request) + if token is None: + return None + + raw_token = self.get_raw_token(token) + if raw_token is None: + return None + + validated_token = self.get_validated_token(raw_token) + + return self.get_user(validated_token), None diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index 16eb12fc..90348498 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -73,7 +73,7 @@ class NotValidUsernameError(exceptions.APIException): class NotValidTokenError(exceptions.APIException): """The exception should be thrown when token in url is not valid """ - status_code = status.HTTP_400_BAD_REQUEST + status_code = status.HTTP_401_UNAUTHORIZED default_detail = _('Not valid token') diff --git a/apps/utils/methods.py b/apps/utils/methods.py index a26c306c..9dcafa97 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -16,13 +16,23 @@ def generate_code(digits=6, string_output=True): return str(value) if string_output else value +def get_token_from_cookies(request): + """Get access token from request cookies""" + cookies = request.COOKIES + if cookies.get('access_token'): + token = f'Bearer {cookies.get("access_token")}' + return token.encode() + + def get_token_from_request(request): """Get access token from request""" + token = None if 'Authorization' in request.headers: if isinstance(request, HttpRequest): - return request.headers.get('Authorization').split(' ')[::-1][0] - elif isinstance(request, Request): - return request._request.headers.get('Authorization').split(' ')[::-1][0] + token = request.headers.get('Authorization').split(' ')[::-1][0] + if isinstance(request, Request): + token = request.headers.get('Authorization').split(' ')[::-1][0] + return token def username_validator(username: str) -> bool: diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index 3e4a1d33..d1f8c430 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -1,7 +1,10 @@ """Project custom permissions""" from rest_framework.permissions import BasePermission +from rest_framework_simplejwt.exceptions import TokenBackendError + from authorization.models import BlacklistedAccessToken -from utils.methods import get_token_from_request +from utils.exceptions import NotValidTokenError +from utils.methods import get_token_from_cookies class IsAuthenticatedAndTokenIsValid(BasePermission): @@ -12,12 +15,17 @@ class IsAuthenticatedAndTokenIsValid(BasePermission): def has_permission(self, request, view): """Check permissions by access token and default REST permission IsAuthenticated""" user = request.user - if user and user.is_authenticated: - token = get_token_from_request(request) - # Check if user access token not expired - expired = BlacklistedAccessToken.objects.by_token(token)\ - .by_user(user)\ - .exists() - return not expired + try: + if user and user.is_authenticated: + token_bytes = get_token_from_cookies(request) + # Get access token key + token = token_bytes.decode().split(' ')[1] + # Check if user access token not expired + blacklisted = BlacklistedAccessToken.objects.by_token(token) \ + .by_user(user) \ + .exists() + return not blacklisted + except TokenBackendError: + raise NotValidTokenError() else: return False diff --git a/apps/utils/views.py b/apps/utils/views.py index f413e3cc..b222a4d7 100644 --- a/apps/utils/views.py +++ b/apps/utils/views.py @@ -67,8 +67,8 @@ class JWTGenericViewMixin(generics.GenericAPIView): """Update COOKIES in response from namedtuple""" for cookie in cookies: # todo: remove config for develop - import os - configuration = os.environ.get('SETTINGS_CONFIGURATION', None) + from os import environ + configuration = environ.get('SETTINGS_CONFIGURATION', None) if configuration == 'development': response.set_cookie(key=cookie.key, value=cookie.value, diff --git a/project/settings/base.py b/project/settings/base.py index 45dbb693..e73dbb65 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -205,10 +205,9 @@ REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'utils.pagination.ProjectMobilePagination', 'COERCE_DECIMAL_TO_STRING': False, 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', - 'rest_framework.authentication.SessionAuthentication', # JWT - 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'utils.authentication.GMJWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', ), 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.AcceptHeaderVersioning', 'DEFAULT_VERSION': (AVAILABLE_VERSIONS['current'],), diff --git a/project/settings/local.py b/project/settings/local.py index 2858973c..93e6948b 100644 --- a/project/settings/local.py +++ b/project/settings/local.py @@ -21,6 +21,3 @@ API_HOST_URL = 'http://%s' % API_HOST BROKER_URL = 'amqp://rabbitmq:5672' CELERY_RESULT_BACKEND = BROKER_URL CELERY_BROKER_URL = BROKER_URL - -# Increase access token lifetime for local deploy -SIMPLE_JWT['ACCESS_TOKEN_LIFETIME'] = timedelta(days=365) From 5598290456424ad4ba00d348e6862b58fc557ea6 Mon Sep 17 00:00:00 2001 From: Anatoly Date: Thu, 29 Aug 2019 11:40:00 +0300 Subject: [PATCH 2/6] refactored refresh-token endpoint --- apps/account/views/web.py | 4 ++-- apps/authorization/serializers/common.py | 12 ++++++++++-- apps/utils/exceptions.py | 13 ++++++++++--- apps/utils/permissions.py | 4 ++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/account/views/web.py b/apps/account/views/web.py index bebdd9d6..75ab66c4 100644 --- a/apps/account/views/web.py +++ b/apps/account/views/web.py @@ -58,7 +58,7 @@ class VerifyEmailConfirmView(JWTGenericViewMixin): if user_qs.exists(): user = user_qs.first() if not gm_token_generator.check_token(user, token): - raise utils_exceptions.NotValidTokenError() + raise utils_exceptions.NotValidAccessTokenError() # Change email status user.confirm_email() return Response(status=status.HTTP_200_OK) @@ -96,7 +96,7 @@ class PasswordResetConfirmView(JWTGenericViewMixin): obj = get_object_or_404(queryset, **filter_kwargs) if not gm_token_generator.check_token(user=obj.user, token=token): - raise utils_exceptions.NotValidTokenError() + raise utils_exceptions.NotValidAccessTokenError() # May raise a permission denied self.check_object_permissions(self.request, obj) diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 36ae8a3e..baeac5d0 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -154,12 +154,20 @@ class LoginByUsernameOrEmailSerializer(JWTBaseSerializerMixin, serializers.Model class RefreshTokenSerializer(serializers.Serializer): """Serializer for refresh token view""" - refresh_token = serializers.CharField() + refresh_token = serializers.CharField(read_only=True) access_token = serializers.CharField(read_only=True) + def get_request(self): + """Return request""" + return self.context.get('request') + def validate(self, attrs): """Override validate method""" - token = tokens.RefreshToken(attrs['refresh_token']) + refresh_token = self.get_request().COOKIES.get('refresh_token') + if not refresh_token: + raise utils_exceptions.NotValidRefreshTokenError() + + token = tokens.RefreshToken(token=refresh_token) data = {'access_token': str(token.access_token)} diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index 90348498..5d2c973d 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -70,11 +70,18 @@ class NotValidUsernameError(exceptions.APIException): default_detail = _('Wrong username') -class NotValidTokenError(exceptions.APIException): - """The exception should be thrown when token in url is not valid +class NotValidAccessTokenError(exceptions.APIException): + """The exception should be thrown when access token in url is not valid """ status_code = status.HTTP_401_UNAUTHORIZED - default_detail = _('Not valid token') + default_detail = _('Not valid access token') + + +class NotValidRefreshTokenError(exceptions.APIException): + """The exception should be thrown when refresh token is not valid + """ + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('Not valid refresh token') class PasswordsAreEqual(exceptions.APIException): diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index d1f8c430..d8510e17 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -3,7 +3,7 @@ from rest_framework.permissions import BasePermission from rest_framework_simplejwt.exceptions import TokenBackendError from authorization.models import BlacklistedAccessToken -from utils.exceptions import NotValidTokenError +from utils.exceptions import NotValidAccessTokenError from utils.methods import get_token_from_cookies @@ -26,6 +26,6 @@ class IsAuthenticatedAndTokenIsValid(BasePermission): .exists() return not blacklisted except TokenBackendError: - raise NotValidTokenError() + raise NotValidAccessTokenError() else: return False From 57066048d68bc8e879a496ad73390dd7543c5d2c Mon Sep 17 00:00:00 2001 From: Anatoly Date: Thu, 29 Aug 2019 11:41:14 +0300 Subject: [PATCH 3/6] added 95.213.204.126 to development allowed hosts --- project/settings/development.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/project/settings/development.py b/project/settings/development.py index 8f67c38a..4d937716 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -1,7 +1,6 @@ """Development settings.""" -from .base import * -ALLOWED_HOSTS = ['gm.id-east.ru', ] +ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126'] SEND_SMS = False SMS_CODE_SHOW = True From 79edd6c51d4560daab9d3220fe1288ad4b0cc9db Mon Sep 17 00:00:00 2001 From: Anatoly Date: Thu, 29 Aug 2019 11:42:52 +0300 Subject: [PATCH 4/6] fixed collection list and news list endpoints --- apps/collection/serializers/common.py | 6 +----- apps/news/serializers/common.py | 8 +------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/collection/serializers/common.py b/apps/collection/serializers/common.py index 073e554a..69319f74 100644 --- a/apps/collection/serializers/common.py +++ b/apps/collection/serializers/common.py @@ -8,7 +8,7 @@ from location import models as location_models class CollectionSerializer(serializers.ModelSerializer): """Collection serializer""" # RESPONSE - image_url = serializers.SerializerMethodField() + image_url = serializers.ImageField(source='image.image') # COMMON block_size = serializers.JSONField() @@ -46,10 +46,6 @@ class CollectionSerializer(serializers.ModelSerializer): 'block_size', ] - def get_image_url(self, obj): - """Return absolute image URL""" - return obj.image.get_full_image_url(request=self.context.get('request')) - class CollectionItemSerializer(serializers.ModelSerializer): """CollectionItem serializer""" diff --git a/apps/news/serializers/common.py b/apps/news/serializers/common.py index bb041071..904c6f89 100644 --- a/apps/news/serializers/common.py +++ b/apps/news/serializers/common.py @@ -23,7 +23,7 @@ class NewsSerializer(serializers.ModelSerializer): title_translated = serializers.CharField(read_only=True, allow_null=True) subtitle_translated = serializers.CharField(read_only=True, allow_null=True) description_translated = serializers.CharField(read_only=True, allow_null=True) - image_url = serializers.SerializerMethodField() + image_url = serializers.ImageField(source='image.image') class Meta: model = models.News @@ -42,12 +42,6 @@ class NewsSerializer(serializers.ModelSerializer): 'description_translated', ] - def get_image_url(self, obj): - """Return absolute image URL""" - if obj.image: - return obj.image.get_full_image_url(request=self.context.get('request')) - return None - class NewsCreateUpdateSerializer(NewsSerializer): """News update serializer.""" From c9fdf9074e993b367f1ee6b0bd339324eb9fc677 Mon Sep 17 00:00:00 2001 From: Anatoly Date: Thu, 29 Aug 2019 11:53:59 +0300 Subject: [PATCH 5/6] small fix --- project/settings/development.py | 1 + 1 file changed, 1 insertion(+) diff --git a/project/settings/development.py b/project/settings/development.py index 4d937716..798eb6b0 100644 --- a/project/settings/development.py +++ b/project/settings/development.py @@ -1,4 +1,5 @@ """Development settings.""" +from .base import * ALLOWED_HOSTS = ['gm.id-east.ru', '95.213.204.126'] From 8a234b6d8f2e063632efd395e8e90a2416fb8cd0 Mon Sep 17 00:00:00 2001 From: Anatoly Date: Thu, 29 Aug 2019 12:52:46 +0300 Subject: [PATCH 6/6] added query search by title in news list endpoint --- apps/news/filters.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/news/filters.py b/apps/news/filters.py index 75e66c36..7af3452d 100644 --- a/apps/news/filters.py +++ b/apps/news/filters.py @@ -8,10 +8,21 @@ class NewsListFilterSet(django_filters.FilterSet): """FilterSet for News list""" is_highlighted = django_filters.BooleanFilter() + title = django_filters.CharFilter(method='by_title') class Meta: """Meta class""" model = models.News fields = ( + 'title', 'is_highlighted', ) + + def by_title(self, queryset, name, value): + """Crappy search by title according to locale""" + if value: + locale = self.request.locale + filters = {f'{name}__{locale}': value} + return queryset.filter(**filters) + else: + return queryset