From 000193fda0a7e69fa53038f3ebf6cae0358d2de5 Mon Sep 17 00:00:00 2001 From: Bernardo Smaniotto Date: Mon, 16 Apr 2018 09:43:19 -0300 Subject: [PATCH 1/4] Refactor: create auth view base class and apply to register and login endpoints --- README.md | 3 +- cklauth/api/v1/views.py | 70 +++++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index b59c1df..ecd783f 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Response (depends on REGISTER_FIELDS and USER_SERIALIZER) - 200 OK } } ``` +**Note:** the user payload may vary according to specified REGISTER_FIELDS and USER_SERIALIZER. `POST /api/v1/register` Body (depends on REGISTER_FIELDS and USER_SERIALIZER -- always has a password) @@ -84,12 +85,12 @@ Response (depends on REGISTER_FIELDS and USER_SERIALIZER) - 201 CREATED "user": { "id": 1, "email": "example@example.com", - "password": "secret", "first_name": "Example", "last_name": "Example" } } ``` +**Note:** the user payload may vary according to specified REGISTER_FIELDS and USER_SERIALIZER. `POST /api/v1/password-reset/` Body diff --git a/cklauth/api/v1/views.py b/cklauth/api/v1/views.py index b30c507..02ca596 100644 --- a/cklauth/api/v1/views.py +++ b/cklauth/api/v1/views.py @@ -4,6 +4,7 @@ import requests from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.forms import PasswordResetForm +from django.core.exceptions import ImproperlyConfigured from django.http import JsonResponse from django.shortcuts import redirect from rest_framework import status @@ -21,37 +22,60 @@ UserSerializer = locate(settings.CKL_REST_AUTH.get('USER_SERIALIZER')) -@api_view(['POST',]) -def register(request): - RegisterSerializer = RegisterSerializerFactory(UserSerializer) +class AuthError(Exception): + def __init__(self, message, status): + self.message = message + self.status = status - serializer = RegisterSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - user, token = serializer.save() - return JsonResponse({ - 'token': token.key, - 'user': UserSerializer(instance=user).data, - }, status=status.HTTP_201_CREATED) +class AuthView(APIView): + status_code = status.HTTP_200_OK + def post(self, request): + try: + user, token = self.perform_action(request) + except AuthError as error: + return JsonResponse( + {'non_field_errors': [error.message]}, + status=error.status + ) -@api_view(['POST',]) -def login(request): - fields = [settings.CKL_REST_AUTH['LOGIN_FIELD'], 'password'] - serializer = LoginSerializer(data=request.data, fields=fields) + return JsonResponse({ + 'token': token.key, + 'user': UserSerializer(instance=user).data, + }, status=self.status_code) + + def perform_action(self, request): + raise NotImplementedError('The view should implement `perform_login` method') - serializer.is_valid(raise_exception=True) - username = serializer.validated_data[settings.CKL_REST_AUTH['LOGIN_FIELD']] - password = serializer.validated_data['password'] - user = authenticate(username=username, password=password) - if user: +class RegisterView(AuthView): + status_code = status.HTTP_201_CREATED + + def perform_action(self, request): + RegisterSerializer = RegisterSerializerFactory(UserSerializer) + + serializer = RegisterSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + return serializer.save() + + +class LoginView(AuthView): + def perform_action(self, request): + fields = [settings.CKL_REST_AUTH['LOGIN_FIELD'], 'password'] + serializer = LoginSerializer(data=request.data, fields=fields) + + serializer.is_valid(raise_exception=True) + login_field = serializer.validated_data[settings.CKL_REST_AUTH['LOGIN_FIELD']] + password = serializer.validated_data['password'] + user = authenticate(username=login_field, password=password) + + if not user: + raise AuthError(message='Wrong credentials.', status=status.HTTP_401_UNAUTHORIZED) + token, _ = Token.objects.get_or_create(user=user) + return user, token - return JsonResponse({ - 'token': token.key, - 'user': UserSerializer(instance=user).data, - }, status=status.HTTP_200_OK) return JsonResponse({ 'non_field_errors': ['Wrong credentials.'] From b065270d1a132945754e8afcae0f3cb318784c4f Mon Sep 17 00:00:00 2001 From: Bernardo Smaniotto Date: Mon, 16 Apr 2018 09:44:21 -0300 Subject: [PATCH 2/4] Feat: adapt google and facebook auth logic to base auth view and make it flexible with register fields --- cklauth/api/v1/views.py | 320 +++++++++++++++++----------------------- 1 file changed, 139 insertions(+), 181 deletions(-) diff --git a/cklauth/api/v1/views.py b/cklauth/api/v1/views.py index 02ca596..947a0bf 100644 --- a/cklauth/api/v1/views.py +++ b/cklauth/api/v1/views.py @@ -77,37 +77,126 @@ def perform_action(self, request): return user, token - return JsonResponse({ - 'non_field_errors': ['Wrong credentials.'] - }, status=status.HTTP_401_UNAUTHORIZED) +class SocialAuthView(AuthView): + def __init__(self, *args, **kwargs): + platform_settings = settings.CKL_REST_AUTH.get(self.platform, {}) + if not platform_settings: + raise ImproperlyConfigured( + 'Add {} CLIENT_ID and REDIRECT_URI to settings.'.format(self.platform) + ) + self.CLIENT_ID = platform_settings.get('CLIENT_ID') + self.CLIENT_SECRET = platform_settings.get('CLIENT_SECRET') + self.REDIRECT_URI = platform_settings.get('REDIRECT_URI') -@api_view(['POST',]) -def password_reset(request): - serializer = PasswordResetSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + super().__init__(*args, **kwargs) - form = PasswordResetForm(serializer.validated_data) - if form.is_valid(): - form.save( - from_email=settings.CKL_REST_AUTH.get('FROM_EMAIL'), - email_template_name='registration/password_reset_email.html', - request=request + def get_access_token(self, request): + if request.data.get('access_token'): + return request.data.get('access_token') + + if not request.data.get('code'): + raise AuthError(message='Missing auth token.', status=status.HTTP_400_BAD_REQUEST) + + payload = { + 'client_id': self.CLIENT_ID, + 'client_secret': self.CLIENT_SECRET, + 'grant_type': 'authorization_code', + 'redirect_uri': self.REDIRECT_URI, + 'code': request.data['code'], + } + + response = requests.post(self.token_url, data=payload) + + if response.status_code != status.HTTP_200_OK: + raise AuthError(message='Bad token.', status=status.HTTP_400_BAD_REQUEST) + + return response.json()['access_token'] + + def get_username(self, username, current_username=None, count=0): + if count == 0: + current_username = username + try: + User.objects.get(username=current_username) + count = count + 1 + current_username = '{0}_{1}'.format( + username, + count + ) + return self.get_username( + username=username, + current_username=current_username, + count=count + ) + except User.DoesNotExist: + return current_username + + def create_user(self, user_info): + register_info = { + register_key: user_info.get(provider_key) + for register_key, provider_key in self.user_info_mapping.items() + } + register_info['username'] = self.get_username( + username='{0}_{1}'.format( + register_info.get('first_name').lower().replace(" ", "_"), + register_info.get('last_name').lower().replace(" ", "_") + ) ) - return JsonResponse(request.data, status=status.HTTP_200_OK) + serializer = UserSerializer(data=register_info) + serializer.is_valid(raise_exception=True) + + return User.objects.create_user(**register_info) + + def perform_action(self, request): + access_token = self.get_access_token(request) + user_info = self.get_user_info(access_token) + + try: + # email registered with social account + social_account = SocialAccount.objects.get(user__email=user_info.get('email')) + user = social_account.user + if not getattr(social_account, self.social_account_field): + setattr(social_account, self.social_account_field, user_info.get('id')) + social_account.save() + except SocialAccount.DoesNotExist: + try: + # email registered without social account + user = User.objects.get(email=user_info.get('email')) + raise AuthError( + message='Registered with email.', + status=status.HTTP_400_BAD_REQUEST + ) + except User.DoesNotExist: + # user and social account don't exist + user = self.create_user(user_info) + + SocialAccount.objects.create(**{ + 'user': user, + self.social_account_field: user_info.get('id') + }) + + self.status_code = status.HTTP_201_CREATED + + token, _ = Token.objects.get_or_create(user=user) + return user, token -class GoogleAuthView(APIView): - permission_classes = [] +class GoogleAuthView(SocialAuthView): + platform = 'GOOGLE' + social_account_field = 'google_id' + token_url = constants.GOOGLE_TOKEN_URL + user_info_mapping = { + 'first_name': 'given_name', + 'last_name': 'family_name', + 'email': 'email', + } def get(self, request, format=None): - # Check link below to retrieve info wanted - # https://developers.google.com/gmail/api/auth/scopes payload = { 'response_type': 'code', - 'client_id': settings.CKL_REST_AUTH.get('GOOGLE', {}).get('CLIENT_ID'), - 'redirect_uri': settings.CKL_REST_AUTH.get('GOOGLE', {}).get('REDIRECT_URI'), + 'client_id': self.CLIENT_ID, + 'redirect_uri': self.REDIRECT_URI, 'scope': ( 'https://www.googleapis.com/auth/userinfo.profile ', 'https://www.googleapis.com/auth/userinfo.email' @@ -117,127 +206,39 @@ def get(self, request, format=None): request = requests.Request('GET', constants.GOOGLE_AUTH_URL, params=payload).prepare() return redirect(request.url) - def post(self, request): - if not request.data.get('access_token'): - if not request.data.get('code'): - return JsonResponse({ - 'message': 'Missing auth token' - }, status=status.HTTP_400_BAD_REQUEST) - - payload = { - 'client_id': settings.CKL_REST_AUTH.get('GOOGLE', {}).get('CLIENT_ID'), - 'client_secret': settings.CKL_REST_AUTH.get('GOOGLE', {}).get('CLIENT_SECRET'), - 'grant_type': 'authorization_code', - 'redirect_uri': settings.CKL_REST_AUTH.get('GOOGLE', {}).get('REDIRECT_URI'), - 'code': request.data['code'], - } - - response = requests.post(constants.GOOGLE_TOKEN_URL, data=payload) - - if response.status_code != status.HTTP_200_OK: - return JsonResponse({ - 'message': 'Google bad token' - }, status=status.HTTP_400_BAD_REQUEST) - - access_token = response.json()['access_token'] - else: - access_token = request.data.get('access_token') - + def get_user_info(self, access_token): response = requests.get(constants.GOOGLE_USER_URL, headers={ 'Authorization': 'Bearer %s' % access_token }) if response.status_code != status.HTTP_200_OK: - return JsonResponse({ - 'message': 'Cannot get google info' - }, status=status.HTTP_401_UNAUTHORIZED) + raise AuthError(message='Cannot get user info', status=tatus.HTTP_401_UNAUTHORIZED) - data = response.json() - - try: - # email registered with social account - social_account = SocialAccount.objects.get(user__email=data.get('email')) - user = social_account.user - if not social_account.google_id: - social_account.google_id = data.get('id') - social_account.save() - except SocialAccount.DoesNotExist: - try: - # email registered without social account - User.objects.get(email=data.get('email')) - return JsonResponse({ - 'message': 'Registered with email' - }, status=status.HTTP_400_BAD_REQUEST) - except User.DoesNotExist: - # user and social account don't exist - username = get_username( - username='{0}_{1}'.format( - data.get('given_name').lower().replace(" ", "_"), - data.get('family_name').lower().replace(" ", "_") - ) - ) - user = User.objects.create_user( - username=username, - email=data.get('email'), - first_name=data.get('given_name'), - last_name=data.get('family_name'), - ) - SocialAccount.objects.create( - user=user, - google_id=data.get('id') - ) - token = Token.objects.create(user=user) - return JsonResponse({ - 'token': token.key - }, status=status.HTTP_201_CREATED) - - token = Token.objects.get(user=user) - return JsonResponse({ - 'token': token.key - }, status=status.HTTP_200_OK) + return response.json() -# https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow -class FacebookAuthView(APIView): - permission_classes = [] +class FacebookAuthView(SocialAuthView): + platform = 'FACEBOOK' + social_account_field = 'facebook_id' + token_url = constants.FACEBOOK_TOKEN_URL + user_info_mapping = { + 'first_name': 'first_name', + 'last_name': 'last_name', + 'email': 'email', + } def get(self, request, format=None): payload = { 'response_type': 'code', - 'client_id': settings.CKL_REST_AUTH.get('FACEBOOK', {}).get('CLIENT_ID'), - 'redirect_uri': settings.CKL_REST_AUTH.get('FACEBOOK', {}).get('REDIRECT_URI'), + 'client_id': self.CLIENT_ID, + 'redirect_uri': self.REDIRECT_URI, 'state': json.dumps(request.query_params), 'scope': 'email', } request = requests.Request('GET', constants.FACEBOOK_AUTH_URL, params=payload).prepare() return redirect(request.url) - def post(self, request): - if not request.data.get('access_token'): - if not request.data.get('code'): - return JsonResponse({ - 'message': 'Missing auth token' - }, status=status.HTTP_400_BAD_REQUEST) - - payload = { - 'client_id': settings.CKL_REST_AUTH.get('FACEBOOK', {}).get('CLIENT_ID'), - 'client_secret': settings.CKL_REST_AUTH.get('FACEBOOK', {}).get('CLIENT_SECRET'), - 'grant_type': 'authorization_code', - 'redirect_uri': settings.CKL_REST_AUTH.get('FACEBOOK', {}).get('REDIRECT_URI'), - 'code': request.data['code'], - } - - response = requests.post(constants.FACEBOOK_TOKEN_URL, data=payload) - - if response.status_code != status.HTTP_200_OK: - return JsonResponse({ - 'message': 'Facebook bad token' - }, status=status.HTTP_400_BAD_REQUEST) - - access_token = response.json()['access_token'] - else: - access_token = request.data.get('access_token') - + def get_user_info(self, access_token): response = requests.get(constants.FACEBOOK_USER_URL, headers={ 'Authorization': 'Bearer %s' % access_token }, params={ @@ -245,65 +246,22 @@ def post(self, request): }) if response.status_code != status.HTTP_200_OK: - return JsonResponse({ - 'message': 'Cannot get facebook info' - }, status=status.HTTP_401_UNAUTHORIZED) + raise AuthError(message='Cannot get user info.', status=status.HTTP_401_UNAUTHORIZED) - data = response.json() + return response.json() - try: - # email registered with social account - social_account = SocialAccount.objects.get(user__email=data.get('email')) - user = social_account.user - if not social_account.facebook_id: - social_account.facebook_id = data.get('id') - social_account.save() - except SocialAccount.DoesNotExist: - try: - # email registered without social account - User.objects.get(email=data.get('email')) - return JsonResponse({ - 'message': 'Registered with email' - }, status=status.HTTP_400_BAD_REQUEST) - except User.DoesNotExist: - # user and social account don't exist - username = get_username( - username='{0}_{1}'.format( - data.get('first_name').lower().replace(" ", "_"), - data.get('last_name').lower().replace(" ", "_") - ) - ) - user = User.objects.create_user( - username=username, - email=data.get('email'), - first_name=data.get('first_name'), - last_name=data.get('last_name'), - ) - SocialAccount.objects.create( - user=user, - facebook_id=data.get('id') - ) - token = Token.objects.create(user=user) - return JsonResponse({ - 'token': token.key - }, status=status.HTTP_201_CREATED) - token = Token.objects.get(user=user) - return JsonResponse({ - 'token': token.key - }, status=status.HTTP_200_OK) - - -def get_username(username, current_username=None, count=0): - if count == 0: - current_username = username - try: - User.objects.get(username=current_username) - count = count + 1 - current_username = '{0}_{1}'.format( - username, - count +@api_view(['POST',]) +def password_reset(request): + serializer = PasswordResetSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + form = PasswordResetForm(serializer.validated_data) + if form.is_valid(): + form.save( + from_email=settings.CKL_REST_AUTH.get('FROM_EMAIL'), + email_template_name='registration/password_reset_email.html', + request=request ) - return get_username(username=username, current_username=current_username, count=count) - except User.DoesNotExist: - return current_username + + return JsonResponse(request.data, status=status.HTTP_200_OK) From f2c325ea3f2ca6b37679ee5a9ed17baefae734e5 Mon Sep 17 00:00:00 2001 From: Bernardo Smaniotto Date: Mon, 16 Apr 2018 09:44:52 -0300 Subject: [PATCH 3/4] Docs: describe social login/register endpoints --- README.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ecd783f..f81e158 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,33 @@ Note: it always returns success, even if the provided email is not registered. ## Social Endpoints -TODO +`GET /api/v1/google` +`GET /api/v1/facebook` +**Note:** this should not be XHR request, the user will be redirected to consent screen. After +consent, the user is redirected to platform REDIRECT_URI added on settings, where a code is +extracted from the URL hash. + +`POST /api/v1/google` +`POST /api/v1/facebook` +Body +``` +{ + "code": "" +} +``` +Response - 200 OK +``` +{ + "token": "supersecret", + "user": { + "id": 1, + "email": "example@example.com", + "first_name": "Example", + "last_name": "Example" + } +} +``` +**Note:** the user payload may vary according to specified REGISTER_FIELDS and USER_SERIALIZER. ## Contributing From 9f75133ebea670fdf3b3ba00d7698a38f0b5ba22 Mon Sep 17 00:00:00 2001 From: Bernardo Smaniotto Date: Mon, 16 Apr 2018 09:47:26 -0300 Subject: [PATCH 4/4] Feat: move social logins to social/* endpoint to be less ambiguous --- README.md | 8 ++++---- cklauth/api/v1/urls.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f81e158..05fed37 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,14 @@ Note: it always returns success, even if the provided email is not registered. ## Social Endpoints -`GET /api/v1/google` -`GET /api/v1/facebook` +`GET /api/v1/social/google` +`GET /api/v1/social/facebook` **Note:** this should not be XHR request, the user will be redirected to consent screen. After consent, the user is redirected to platform REDIRECT_URI added on settings, where a code is extracted from the URL hash. -`POST /api/v1/google` -`POST /api/v1/facebook` +`POST /api/v1/social/google` +`POST /api/v1/social/facebook` Body ``` { diff --git a/cklauth/api/v1/urls.py b/cklauth/api/v1/urls.py index 44ca28d..c6d59f3 100644 --- a/cklauth/api/v1/urls.py +++ b/cklauth/api/v1/urls.py @@ -3,9 +3,9 @@ from . import views urlpatterns = [ - path('register', views.register, name='register'), - path('login', views.login, name='login'), + path('register', views.RegisterView.as_view(), name='register'), + path('login', views.LoginView.as_view(), name='login'), + path('social/google', views.GoogleAuthView.as_view(), name='google'), + path('social/facebook', views.FacebookAuthView.as_view(), name='facebook'), path('password-reset', views.password_reset, name='password-reset'), - path('google', views.GoogleAuthView.as_view(), name='google'), - path('facebook', views.FacebookAuthView.as_view(), name='facebook'), ]