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 2a38ca09..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)} @@ -200,8 +208,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..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 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 token') + default_detail = _('Not valid refresh token') class PasswordsAreEqual(exceptions.APIException): 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..d8510e17 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 NotValidAccessTokenError +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 NotValidAccessTokenError() 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 0f040f94..5cd8b5b0 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -207,10 +207,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)