diff --git a/apps/authorization/migrations/0002_blacklistedaccesstoken.py b/apps/authorization/migrations/0002_blacklistedaccesstoken.py new file mode 100644 index 00000000..7a2aac2a --- /dev/null +++ b/apps/authorization/migrations/0002_blacklistedaccesstoken.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.4 on 2019-08-13 16:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('authorization', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='BlacklistedAccessToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jti', models.CharField(max_length=255, unique=True, verbose_name='Unique access_token identifier')), + ('token', models.TextField(verbose_name='Access token')), + ('blacklisted_at', models.DateTimeField(auto_now_add=True, verbose_name='Blacklisted datetime')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'unique_together': {('token', 'user')}, + }, + ), + ] diff --git a/apps/authorization/models.py b/apps/authorization/models.py index a0d9205c..4758788b 100644 --- a/apps/authorization/models.py +++ b/apps/authorization/models.py @@ -1,6 +1,7 @@ from django.db import models from oauth2_provider import models as oauth2_models from oauth2_provider.models import AbstractApplication +from django.utils.translation import gettext_lazy as _ from utils.models import PlatformMixin @@ -30,3 +31,42 @@ class Application(PlatformMixin, AbstractApplication): def natural_key(self): return (self.client_id,) + + +class BlacklistedAccessTokenQuerySet(models.QuerySet): + """Queryset for model BlacklistedAccessToken""" + + def by_user(self, user): + """Filter by user""" + return self.filter(user=user) + + def by_token(self, token): + """Filter by token""" + return self.filter(token=token) + + def by_jti(self, jti): + """Filter by unique access_token identifier""" + return self.filter(jti=jti) + + +class BlacklistedAccessToken(models.Model): + + user = models.ForeignKey('account.User', + on_delete=models.CASCADE, + verbose_name=_('User')) + + jti = models.CharField(max_length=255, unique=True, + verbose_name=_('Unique access_token identifier')) + token = models.TextField(verbose_name=_('Access token')) + + blacklisted_at = models.DateTimeField(auto_now_add=True, + verbose_name=_('Blacklisted datetime')) + + objects = BlacklistedAccessTokenQuerySet.as_manager() + + class Meta: + """Meta class""" + unique_together = ('token', 'user') + + def __str__(self): + return 'Blacklisted access token for {}'.format(self.user) diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 048c1a05..43b6fe07 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -6,11 +6,12 @@ from django.contrib.auth import authenticate from django.conf import settings from account import models as account_models -from authorization.models import Application +from authorization.models import Application, BlacklistedAccessToken from utils import exceptions as utils_exceptions +from rest_framework_simplejwt.tokens import RefreshToken # JWT -from rest_framework_simplejwt.tokens import RefreshToken, SlidingToken, UntypedToken +from rest_framework_simplejwt import tokens JWT_SETTINGS = settings.SIMPLE_JWT @@ -34,7 +35,7 @@ class JWTBaseMixin(serializers.Serializer): def get_token(self): """Create JWT token""" user = self.instance - token = RefreshToken.for_user(user) + token = tokens.RefreshToken.for_user(user) token['user'] = user.get_user_info() return token @@ -178,6 +179,29 @@ class RefreshTokenSerializer(serializers.Serializer): return data +class LogoutSerializer(serializers.ModelSerializer): + """Serializer class for model Logout""" + + class Meta: + model = BlacklistedAccessToken + fields = '__all__' + read_only_fields = [ + 'jti', 'token', 'user' + ] + + def create(self, validated_data, *args, **kwargs): + """Override create method""" + request = self.context.get('request') + token = request._request.headers.get('Authorization')\ + .split(' ')[::-1][0] + access_token = tokens.AccessToken(token) + # Prepare validated data + validated_data['user'] = request.user + validated_data['token'] = access_token.token + validated_data['jti'] = access_token.payload.get('jti') + return super().create(validated_data) + + # OAuth class OAuth2Serialzier(BaseAuthSerializerMixin): """Serializer OAuth2 authorization""" diff --git a/apps/authorization/urls/common.py b/apps/authorization/urls/common.py index 1b9e1c4c..cf8469e7 100644 --- a/apps/authorization/urls/common.py +++ b/apps/authorization/urls/common.py @@ -40,8 +40,8 @@ urlpatterns_jwt = [ path('refresh-token/', views.RefreshTokenView.as_view(), name="refresh-token"), # logout - # path('logout/', views.LogoutView.as_view(), - # name="logout"), + path('logout/', views.LogoutView.as_view(), + name="logout"), ] diff --git a/apps/authorization/views/common.py b/apps/authorization/views/common.py index 55514575..84ed378f 100644 --- a/apps/authorization/views/common.py +++ b/apps/authorization/views/common.py @@ -1,5 +1,6 @@ """Common views for application Account""" import json +from collections import namedtuple from braces.views import CsrfExemptMixin from django.conf import settings @@ -11,12 +12,9 @@ from rest_framework import generics from rest_framework import permissions from rest_framework import status from rest_framework.response import Response +from rest_framework_simplejwt import tokens as jwt_tokens from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer -from rest_framework_simplejwt import tokens as jwt_tokens -from rest_framework.settings import settings as rest_settings -from django.utils import timezone -from rest_framework_simplejwt.utils import datetime_to_epoch from account.models import User from authorization.models import Application @@ -80,22 +78,50 @@ class OAuth2ViewMixin(CsrfExemptMixin, OAuthLibMixin, BaseOAuth2ViewMixin): # Login base view mixin class JWTViewMixin(generics.GenericAPIView): """JWT view mixin""" + def _handle_cookies(self, request, access_token, refresh_token): + """ + CHECK locale in cookies and PUT access and refresh tokens there. + _cookies it is list that contain tuples. + _cookies would contain key, value and secure parameters. + i.e. + [ + (locale, 'ru-RU', True), # Key, Value, Secure flag + ('access_token', 'token', True), # Key, Value, Secure flag + ('refresh_token', 'token', True), # Key, Value, Secure flag + ] + """ + cookies = list() + COOKIE = namedtuple('COOKIE', ['key', 'value', 'secure']) + + if 'locale' in request.COOKIES: + # Write locale in cookie + _locale = COOKIE(key='locale', value=request.COOKIES.get('locale'), secure=False) + cookies.append(_locale) + + # Write to cookie access and refresh token with secure flag + _access_token = COOKIE(key='access_token', value=access_token, secure=True) + _refresh_token = COOKIE(key='refresh_token', value=refresh_token, secure=True) + cookies.extend([_access_token, _refresh_token]) + return cookies + + def _put_cookies_in_response(self, cookies: list, response: Response): + """Update COOKIES in response obj""" + for cookie in cookies: + response.set_cookie(key=cookie.key, + value=cookie.value, + secure=cookie.secure) + return response + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) response = Response(serializer.data, status=status.HTTP_200_OK) - if 'locale' in request.COOKIES: - # Write locale in cookie - key, value = 'locale', request.COOKIES.get('locale') - response.set_cookie(key=key, value=value) - # Write to cookie access and refresh token with secure flag - response.set_cookie(key='access_token', - value=serializer.data.get('access_token'), - secure=True) - response.set_cookie(key='refresh_token', - value=serializer.data.get('refresh_token'), - secure=True) - return response + access_token = serializer.data.get('access_token') + refresh_token = serializer.data.get('access_token') + return self._put_cookies_in_response( + cookies=self._handle_cookies(request, access_token, refresh_token), + response=response) # Serializers @@ -176,22 +202,16 @@ class SignUpView(JWTViewMixin): serializer_class = serializers.SignupSerializer def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() response = Response(serializer.data, status=status.HTTP_201_CREATED) - if 'locale' in request.COOKIES: - # Write locale in cookie - key, value = 'locale', request.COOKIES.get('locale') - response.set_cookie(key=key, value=value) - # Write to cookie access and refresh token with secure flag - response.set_cookie(key='access_token', - value=serializer.data.get('access_token'), - secure=True) - response.set_cookie(key='refresh_token', - value=serializer.data.get('refresh_token'), - secure=True) - return response + access_token = serializer.data.get('access_token') + refresh_token = serializer.data.get('access_token') + return self._put_cookies_in_response( + cookies=self._handle_cookies(request, access_token, refresh_token), + response=response) # Login by username + password @@ -209,38 +229,31 @@ class LoginByEmailView(JWTViewMixin): # Refresh access_token -class RefreshTokenView(generics.GenericAPIView): +class RefreshTokenView(JWTViewMixin): """Refresh access_token""" permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.RefreshTokenSerializer def post(self, request, *args, **kwargs): - """POST method""" + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) response = Response(serializer.validated_data, status=status.HTTP_200_OK) - if 'locale' in request.COOKIES: - # Write locale in cookie - key, value = 'locale', request.COOKIES.get('locale') - response.set_cookie(key=key, value=value) - # Write to cookie access and refresh token with secure flag - response.set_cookie(key='access_token', - value=serializer.data.get('access_token'), - secure=True) - response.set_cookie(key='refresh_token', - value=serializer.data.get('refresh_token'), - secure=True) - return response -# Logout -# class LogoutView(generics.GenericAPIView): -# """Logout user""" -# permission_classes = (permissions.IsAuthenticated,) -# -# def post(self, request, *args, **kwargs): -# """POST method""" -# current_datetime = timezone.now() -# token = request.headers.get('Authorization').split(' ')[::-1][0] -# access_token = jwt_tokens.AccessToken(token) -# access_token.lifetime = timezone.timedelta(seconds=1) -# return Response(status=status.HTTP_200_OK) + access_token = serializer.data.get('access_token') + refresh_token = serializer.data.get('access_token') + return self._put_cookies_in_response( + cookies=self._handle_cookies(request, access_token, refresh_token), + response=response) + +# Logout +class LogoutView(generics.CreateAPIView): + """Logout user""" + serializer_class = serializers.LogoutSerializer + + def create(self, request, *args, **kwargs): + """Override create method""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(status=status.HTTP_200_OK) diff --git a/apps/utils/methods.py b/apps/utils/methods.py index c25c748b..20d056be 100644 --- a/apps/utils/methods.py +++ b/apps/utils/methods.py @@ -1,5 +1,7 @@ """Utils app method.""" import random +from django.http.request import HttpRequest +from rest_framework.request import Request def generate_code(digits=6, string_output=True): @@ -8,3 +10,12 @@ def generate_code(digits=6, string_output=True): min_value = 10 ** (digits - 1) value = random.randint(min_value, max_value) return str(value) if string_output else value + + +def get_token_from_request(request): + """Get access token from request""" + assert isinstance(request, (HttpRequest, Request)) + 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] diff --git a/apps/utils/permissions.py b/apps/utils/permissions.py index 8f0a5b6c..748f1d28 100644 --- a/apps/utils/permissions.py +++ b/apps/utils/permissions.py @@ -1,21 +1,21 @@ """Project custom permissions""" from rest_framework.permissions import BasePermission +from authorization.models import BlacklistedAccessToken +from utils.methods import get_token_from_request -class IsAuthenticatedAndHasRefreshToken(BasePermission): +class IsAuthenticatedAndTokenIsValid(BasePermission): """ - Check if requested user is authenticated and has refresh token + Check if user has a valid token and authenticated """ def has_permission(self, request, view): - token = request.data.get('refresh_token') + """Check permissions by access token and default rest permission IsAuthenticated""" user = request.user - if token and hasattr(user, 'oauth2_provider_refreshtoken'): - refresh_token_qs = user.oauth2_provider_refreshtoken - return ( - user.is_authenticated and - user.is_active and - refresh_token_qs.filter(token=token).exists() - ) - else: - return False + token = get_token_from_request(request) + blacklisted = BlacklistedAccessToken.objects.by_user(user)\ + .by_token(token)\ + .exists() + return bool(user and + user.is_authenticated and + not blacklisted) diff --git a/project/settings/base.py b/project/settings/base.py index 9fa8bfa2..2af9fe5f 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -208,9 +208,7 @@ REST_FRAMEWORK = { 'DEFAULT_VERSION': (AVAILABLE_VERSIONS['current'],), 'ALLOWED_VERSIONS': AVAILABLE_VERSIONS.values(), 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.IsAuthenticated', - # todo: oauth2 scope + drf permission - # 'oauth2_provider.contrib.rest_framework.permissions.IsAuthenticatedOrTokenHasScope', + 'utils.permissions.IsAuthenticatedAndTokenIsValid', ), # 'DATETIME_FORMAT': '%m-%d-%Y %H:%M:%S', # experiment # 'DATE_FORMAT': '%s.%f', # experiment @@ -231,12 +229,6 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) -# todo: not used until outh2 scopes permission is enabled -# OAUTH2_PROVIDER = { -# # this is the list of available scopes -# 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'} -# } - # Override default OAuth2 namespace DRFSO2_URL_NAMESPACE = 'auth' SOCIAL_AUTH_URL_NAMESPACE = 'auth'