From cb3fbcac93744ead5259dece1876473c22078c2f Mon Sep 17 00:00:00 2001 From: Anatoly Date: Fri, 9 Aug 2019 22:10:13 +0300 Subject: [PATCH] version 0.0.5.4: added endpoints to login with username or email, refactored auth serializers, added project exceptions, refactored base settings --- apps/account/models.py | 4 + apps/account/views/web.py | 1 - apps/authorization/serializers/common.py | 56 ++++-- apps/authorization/urls/common.py | 9 +- apps/authorization/views/common.py | 214 ++++++++++++++++++++++- apps/authorization/views/web.py | 171 +----------------- apps/utils/exceptions.py | 21 ++- project/settings/base.py | 23 +-- 8 files changed, 289 insertions(+), 210 deletions(-) diff --git a/apps/account/models.py b/apps/account/models.py index b0c3a8fa..f203345c 100644 --- a/apps/account/models.py +++ b/apps/account/models.py @@ -54,3 +54,7 @@ class User(ImageMixin, AbstractUser): def remove_token(self): Token.objects.filter(user=self).delete() + + @property + def get_username(self): + return self.username diff --git a/apps/account/views/web.py b/apps/account/views/web.py index eb26fc2f..e86ab47d 100644 --- a/apps/account/views/web.py +++ b/apps/account/views/web.py @@ -9,7 +9,6 @@ from account.serializers import web as serializers class UserView(generics.RetrieveUpdateAPIView): """### User update view.""" - permission_classes = (permissions.IsAuthenticated,) serializer_class = serializers.UserSerializer queryset = models.User.objects.active() diff --git a/apps/authorization/serializers/common.py b/apps/authorization/serializers/common.py index 11974bac..5250cf3d 100644 --- a/apps/authorization/serializers/common.py +++ b/apps/authorization/serializers/common.py @@ -4,7 +4,9 @@ from rest_framework import serializers from rest_framework import validators as rest_validators from account import models as account_models +from utils import exceptions as utils_exceptions from authorization.models import Application +from django.utils.translation import gettext_lazy as _ # Mixins @@ -13,25 +15,49 @@ class BaseAuthSerializerMixin(serializers.Serializer): source = serializers.ChoiceField(choices=Application.SOURCES) -# Classic -class SignUpSerializer(BaseAuthSerializerMixin, serializers.ModelSerializer): - """Serializer for signing up user""" - email = serializers.CharField( - validators=(rest_validators.UniqueValidator(queryset=account_models.User.objects.all()), ), - write_only=True - ) - username = serializers.CharField( - validators=(rest_validators.UniqueValidator(queryset=account_models.User.objects.all()), ), - write_only=True - ) +class LoginSerializerMixin(BaseAuthSerializerMixin): + """Mixin for login serializers""" + password = serializers.CharField(write_only=True) + + +class ClassicAuthSerializerMixin(BaseAuthSerializerMixin): + """Classic authorization serializer mixin""" password = serializers.CharField(write_only=True) newsletter = serializers.BooleanField() + +# Classic +class LoginByEmailSerializer(LoginSerializerMixin, serializers.ModelSerializer): + """Serializer for signing up user by email""" + email = serializers.CharField(write_only=True) + class Meta: """Meta-class""" model = account_models.User - fields = ('email', 'username', 'newsletter', - 'password', 'source') + fields = ('email', 'password', 'source') + + def validate(self, attrs): + """Override validate method""" + try: + user = account_models.User.objects.get(email=attrs.get('email')) + attrs['username'] = user.get_username + except account_models.User.DoesNotExist: + raise utils_exceptions.UserNotFoundError() + else: + return attrs + + +class UsernameSignUpSerializer(ClassicAuthSerializerMixin, serializers.ModelSerializer): + """Serializer for signing up user by username""" + username = serializers.CharField( + validators=(rest_validators.UniqueValidator(queryset=account_models.User.objects.all()), ), + write_only=True + ) + + class Meta: + """Meta-class""" + model = account_models.User + fields = ('username', 'newsletter', 'password', 'source') def validate_password(self, data): """Custom password validation""" @@ -50,9 +76,7 @@ class SignUpSerializer(BaseAuthSerializerMixin, serializers.ModelSerializer): class LoginSerializer(BaseAuthSerializerMixin, serializers.ModelSerializer): """Serializer for login user""" - username = serializers.CharField( - write_only=True - ) + username = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True) class Meta: diff --git a/apps/authorization/urls/common.py b/apps/authorization/urls/common.py index 80a9e4a5..06761575 100644 --- a/apps/authorization/urls/common.py +++ b/apps/authorization/urls/common.py @@ -3,12 +3,11 @@ from django.conf import settings from django.conf.urls import url, include from django.urls import path from oauth2_provider.views import AuthorizationView -from rest_framework_social_oauth2.views import (ConvertTokenView, TokenView, - RevokeTokenView, invalidate_sessions) +from rest_framework_social_oauth2.views import invalidate_sessions from social_core.utils import setting_name from social_django import views as social_django_views -from authorization.views import web as views +from authorization.views import common as views extra = getattr(settings, setting_name('TRAILING_SLASH'), True) and '/' or '' @@ -36,8 +35,10 @@ urlpatterns_rest_framework_social_oauth2 = [ urlpatterns_api = [ path('social/signup/', views.SocialSignUpView.as_view(), name='signup-social'), - path('login/', views.LoginView.as_view(), name='login-classic'), + path('login/username/', views.LoginByUsernameView.as_view(), name='login-username'), + path('login/email/', views.LoginByEmailView.as_view(), name='login-email'), path('revoke-token/', views.RevokeTokenView.as_view(), name="revoke_token"), + path('token/', views.TokenView.as_view(), name="token"), # for admin login page ] urlpatterns = urlpatterns_api + \ diff --git a/apps/authorization/views/common.py b/apps/authorization/views/common.py index 03e496c3..1aea4d78 100644 --- a/apps/authorization/views/common.py +++ b/apps/authorization/views/common.py @@ -1,6 +1,212 @@ -"""Common views for application authorization""" -from django.shortcuts import render -from rest_framework import views as rest_views +"""Common views for application Account""" +import json + +from braces.views import CsrfExemptMixin +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from oauth2_provider.oauth2_backends import OAuthLibCore +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views.mixins import OAuthLibMixin +from rest_framework import permissions +from rest_framework.generics import GenericAPIView, CreateAPIView +from rest_framework.response import Response +from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore +from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer + +from authorization.models import Application +from authorization.serializers import common as serializers +from utils import exceptions as utils_exceptions -# Create your views here. \ No newline at end of file +# Mixins +class OAuth2ViewMixin(GenericAPIView): + """Basic mixin for OAuth2 views""" + + def get_client_id(self, source) -> str: + """Get application client id""" + qs = Application.objects.by_source(source=source) + if qs.exists(): + return qs.first().client_id + else: + raise utils_exceptions.SerivceError(data={ + 'detail': _('Application is not found')}) + + def get_client_secret(self, source) -> str: + """Get application client id""" + if source == Application.MOBILE: + qs = Application.objects.by_source(source=source) + if qs.exists: + return qs.first().client_secret + else: + raise utils_exceptions.SerivceError(data={ + 'detail': _('Not found an application with this source')}) + + def prepare_request_data(self, validated_data: dict) -> dict: + """Preparing request data""" + source = validated_data.get('source') + # Set OAuth2 request parameters + _request_data = { + 'client_id': self.get_client_id(source) + } + # Fill client secret parameter by platform + if validated_data.get('source') == Application.MOBILE: + _request_data['client_secret'] = self.get_client_secret(source) + # Fill token parameter if transfer + if validated_data.get('token'): + _request_data['token'] = validated_data.get('token') + if _request_data: + return _request_data + else: + raise utils_exceptions.ServiceError() + + +# Create your views here. +class LoginByUsernameView(CsrfExemptMixin, OAuthLibMixin, + OAuth2ViewMixin, GenericAPIView): + """ + Implements an endpoint to provide access tokens + + The endpoint is used in the following flows: + + * Authorization code + * Password + * Client credentials + """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = OAuthLibCore + permission_classes = (permissions.AllowAny,) + serializer_class = serializers.LoginSerializer + + def post(self, request, *args, **kwargs): + # Preparing request data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + request_data = self.prepare_request_data(serializer.validated_data) + request_data.update({ + 'grant_type': 'password', + 'username': serializer.validated_data.get('username'), + 'password': serializer.validated_data.get('password'), + }) + # Use the rest framework `.data` to fake the post body of the django request. + request._request.POST = request._request.POST.copy() + for key, value in request_data.items(): + request._request.POST[key] = value + + url, headers, body, status = self.create_token_response(request._request) + response = Response(data=json.loads(body), status=status) + + for k, v in headers.items(): + response[k] = v + return response + + +class LoginByEmailView(LoginByUsernameView): + """ + Implements an endpoint to provide access tokens + + The endpoint is used in the following flows: + + * Authorization code + * Password + * Client credentials + """ + serializer_class = serializers.LoginByEmailSerializer + + +class SocialSignUpView(CsrfExemptMixin, OAuthLibMixin, + OAuth2ViewMixin, GenericAPIView): + """ + Implements an endpoint to convert a provider token to an access token + + The endpoint is used in the following flows: + + * Authorization code + * Client credentials + """ + server_class = SocialTokenServer + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = KeepRequestCore + permission_classes = (permissions.AllowAny,) + serializer_class = serializers.OAuth2Serialzier + + def post(self, request, *args, **kwargs): + """Override POST method""" + # Preparing request data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + request_data = self.prepare_request_data(serializer.validated_data) + request_data.update({ + 'grant_type': settings.OAUTH2_SOCIAL_AUTH_GRANT_TYPE, + 'backend': settings.OAUTH2_SOCIAL_AUTH_BACKEND_NAME + }) + # Use the rest framework `.data` to fake the post body of the django request. + request._request.POST = request._request.POST.copy() + for key, value in request_data.items(): + request._request.POST[key] = value + + url, headers, body, status = self.create_token_response(request._request) + response = Response(data=json.loads(body), status=status) + + for k, v in headers.items(): + response[k] = v + return response + + +class RevokeTokenView(CsrfExemptMixin, OAuthLibMixin, + OAuth2ViewMixin, GenericAPIView): + """ + Implements an endpoint to revoke access or refresh tokens + """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = OAuthLibCore + permission_classes = (permissions.AllowAny,) + serializer_class = serializers.OAuth2Serialzier + + def post(self, request, *args, **kwargs): + # Use the rest framework `.data` to fake the post body of the django request. + # Preparing request data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + request_data = self.prepare_request_data(serializer.validated_data) + # Use the rest framework `.data` to fake the post body of the django request. + request._request.POST = request._request.POST.copy() + for key, value in request_data.items(): + request._request.POST[key] = value + + url, headers, body, status = self.create_revocation_response(request._request) + response = Response(data=json.loads(body) if body else '', status=status if body else 204) + + for k, v in headers.items(): + response[k] = v + return response + + +class TokenView(CsrfExemptMixin, OAuthLibMixin, GenericAPIView): + """ + Implements an endpoint to provide access tokens + + The endpoint is used in the following flows: + + * Authorization code + * Password + * Client credentials + """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = OAuthLibCore + permission_classes = (permissions.AllowAny,) + + def post(self, request, *args, **kwargs): + # Use the rest framework `.data` to fake the post body of the django request. + request._request.POST = request._request.POST.copy() + for key, value in request.data.items(): + request._request.POST[key] = value + + url, headers, body, status = self.create_token_response(request._request) + response = Response(data=json.loads(body), status=status) + + for k, v in headers.items(): + response[k] = v + return response diff --git a/apps/authorization/views/web.py b/apps/authorization/views/web.py index 9ea0d9d4..7cb0a940 100644 --- a/apps/authorization/views/web.py +++ b/apps/authorization/views/web.py @@ -1,171 +1,2 @@ -"""Views for application Account""" -import json +"""Web views for application Account""" -from braces.views import CsrfExemptMixin -from django.conf import settings -from django.utils.translation import gettext_lazy as _ -from oauth2_provider.oauth2_backends import OAuthLibCore -from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views.mixins import OAuthLibMixin -from rest_framework import permissions -from rest_framework.generics import GenericAPIView, CreateAPIView -from rest_framework.response import Response -from rest_framework_social_oauth2.oauth2_backends import KeepRequestCore -from rest_framework_social_oauth2.oauth2_endpoints import SocialTokenServer - -from authorization.models import Application -from authorization.serializers import common as serializers -from utils import exceptions as utils_exceptions - - -# Mixins -class OAuth2ViewMixin(GenericAPIView): - """Basic mixin for OAuth2 views""" - - def get_client_id(self, source) -> str: - """Get application client id""" - qs = Application.objects.by_source(source=source) - if qs.exists(): - return qs.first().client_id - else: - raise utils_exceptions.SerivceError(data={ - 'detail': _('Application is not found')}) - - def get_client_secret(self, source) -> str: - """Get application client id""" - if source == Application.MOBILE: - qs = Application.objects.by_source(source=source) - if qs.exists: - return qs.first().client_secret - else: - raise utils_exceptions.SerivceError(data={ - 'detail': _('Not found an application with this source')}) - - def prepare_request_data(self, validated_data: dict) -> dict: - """Preparing request data""" - source = validated_data.get('source') - # Set OAuth2 request parameters - _request_data = { - 'client_id': self.get_client_id(source) - } - # Fill client secret parameter by platform - if validated_data.get('source') == Application.MOBILE: - _request_data['client_secret'] = self.get_client_secret(source) - # Fill token parameter if transfer - if validated_data.get('token'): - _request_data['token'] = validated_data.get('token') - if _request_data: - return _request_data - else: - raise utils_exceptions.SerivceError(data={ - 'detail': 'Unknown request data'}) - - -# Create your views here. -class LoginView(CsrfExemptMixin, OAuthLibMixin, - OAuth2ViewMixin, GenericAPIView): - """ - Implements an endpoint to provide access tokens - - The endpoint is used in the following flows: - - * Authorization code - * Password - * Client credentials - """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = OAuthLibCore - permission_classes = (permissions.AllowAny,) - serializer_class = serializers.LoginSerializer - - def post(self, request, *args, **kwargs): - # Preparing request data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - request_data = self.prepare_request_data(serializer.validated_data) - request_data.update({ - 'grant_type': 'password', - 'username': serializer.validated_data.get('username'), - 'password': serializer.validated_data.get('password'), - }) - # Use the rest framework `.data` to fake the post body of the django request. - request._request.POST = request._request.POST.copy() - for key, value in request_data.items(): - request._request.POST[key] = value - - url, headers, body, status = self.create_token_response(request._request) - response = Response(data=json.loads(body), status=status) - - for k, v in headers.items(): - response[k] = v - return response - - -class SocialSignUpView(CsrfExemptMixin, OAuthLibMixin, - OAuth2ViewMixin, GenericAPIView): - """ - Implements an endpoint to convert a provider token to an access token - - The endpoint is used in the following flows: - - * Authorization code - * Client credentials - """ - server_class = SocialTokenServer - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = KeepRequestCore - permission_classes = (permissions.AllowAny,) - serializer_class = serializers.OAuth2Serialzier - - def post(self, request, *args, **kwargs): - """Override POST method""" - # Preparing request data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - request_data = self.prepare_request_data(serializer.validated_data) - request_data.update({ - 'grant_type': settings.OAUTH2_SOCIAL_AUTH_GRANT_TYPE, - 'backend': settings.OAUTH2_SOCIAL_AUTH_BACKEND_NAME - }) - # Use the rest framework `.data` to fake the post body of the django request. - request._request.POST = request._request.POST.copy() - for key, value in request_data.items(): - request._request.POST[key] = value - - url, headers, body, status = self.create_token_response(request._request) - response = Response(data=json.loads(body), status=status) - - for k, v in headers.items(): - response[k] = v - return response - - -class RevokeTokenView(CsrfExemptMixin, OAuthLibMixin, - OAuth2ViewMixin, GenericAPIView): - """ - Implements an endpoint to revoke access or refresh tokens - """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = OAuthLibCore - permission_classes = (permissions.AllowAny,) - serializer_class = serializers.OAuth2Serialzier - - def post(self, request, *args, **kwargs): - # Use the rest framework `.data` to fake the post body of the django request. - # Preparing request data - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - request_data = self.prepare_request_data(serializer.validated_data) - # Use the rest framework `.data` to fake the post body of the django request. - request._request.POST = request._request.POST.copy() - for key, value in request_data.items(): - request._request.POST[key] = value - - url, headers, body, status = self.create_revocation_response(request._request) - response = Response(data=json.loads(body) if body else '', status=status if body else 204) - - for k, v in headers.items(): - response[k] = v - return response diff --git a/apps/utils/exceptions.py b/apps/utils/exceptions.py index 26b37ca1..01407c47 100644 --- a/apps/utils/exceptions.py +++ b/apps/utils/exceptions.py @@ -2,14 +2,27 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import exceptions, status -class SerivceError(exceptions.APIException): - """Service error.""" - status_code = status.HTTP_503_SERVICE_UNAVAILABLE - default_detail = _('Service is temporarily unavailable') +class ProjectBaseException(exceptions.APIException): + """Base exception""" + status_code = status.HTTP_400_BAD_REQUEST + default_detail = _('Bad request') def __init__(self, data=None): if data: self.default_detail = { + 'detail': self.default_detail, **data } super().__init__() + + +class ServiceError(ProjectBaseException): + """Service error.""" + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_detail = _('Service is temporarily unavailable') + + +class UserNotFoundError(ProjectBaseException): + """The exception should be thrown when the user cannot get""" + status_code = status.HTTP_404_NOT_FOUND + default_detail = _('User not found') diff --git a/project/settings/base.py b/project/settings/base.py index 23276400..907bd0e7 100644 --- a/project/settings/base.py +++ b/project/settings/base.py @@ -151,11 +151,6 @@ LOGOUT_URL = 'admin:logout' LANGUAGE_CODE = 'ru' -# Facebook configuration -SOCIAL_AUTH_FACEBOOK_KEY = '386843648701452' -SOCIAL_AUTH_FACEBOOK_SECRET = 'a71cf0bf3980843a8f1ea74c6d805fd7' - - TIME_ZONE = 'UTC' USE_I18N = True @@ -209,8 +204,9 @@ REST_FRAMEWORK = { 'DEFAULT_VERSION': (AVAILABLE_VERSIONS['current'],), 'ALLOWED_VERSIONS': AVAILABLE_VERSIONS.values(), 'DEFAULT_PERMISSION_CLASSES': ( - # 'rest_framework.permissions.IsAuthenticated', - 'oauth2_provider.contrib.rest_framework.permissions.IsAuthenticatedOrTokenHasScope' + 'rest_framework.permissions.IsAuthenticated', + # todo: oauth2 scope + drf permission + # 'oauth2_provider.contrib.rest_framework.permissions.IsAuthenticatedOrTokenHasScope', ), # 'DATETIME_FORMAT': '%m-%d-%Y %H:%M:%S', # experiment # 'DATE_FORMAT': '%s.%f', # experiment @@ -231,10 +227,11 @@ AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', ) -OAUTH2_PROVIDER = { - # this is the list of available scopes - 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'} -} +# 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 = 'oauth2' @@ -242,6 +239,10 @@ OAUTH2_SOCIAL_AUTH_BACKEND_NAME = 'facebook' OAUTH2_SOCIAL_AUTH_GRANT_TYPE = 'convert_token' OAUTH2_PROVIDER_APPLICATION_MODEL = 'authorization.Application' +# Facebook configuration +SOCIAL_AUTH_FACEBOOK_KEY = '386843648701452' +SOCIAL_AUTH_FACEBOOK_SECRET = 'a71cf0bf3980843a8f1ea74c6d805fd7' + # SMS Settings SMS_EXPIRATION = 5 SMS_SEND_DELAY = 30