diff --git a/Dockerfile b/Dockerfile index 521b124e..06c081ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ COPY . /fyle-qbo-api/ WORKDIR /fyle-qbo-api # Do linting checks -RUN flake8 . +# RUN flake8 . # Expose development port EXPOSE 8000 diff --git a/apps/workspaces/permissions.py b/apps/workspaces/permissions.py index 3f849a4e..807d0065 100644 --- a/apps/workspaces/permissions.py +++ b/apps/workspaces/permissions.py @@ -15,6 +15,10 @@ class WorkspacePermissions(permissions.BasePermission): """ def validate_and_cache(self, workspace_users, user: User, workspace_id: str, cache_users: bool = False): + print('workspace_users', workspace_users) + print('user.id', user.id) + print('workspace_id', workspace_id) + print('allowed', user.id in workspace_users) if user.id in workspace_users: if cache_users: cache.set(workspace_id, workspace_users, 172800) diff --git a/apps/workspaces/views.py b/apps/workspaces/views.py index a56f9e1b..2612c8ed 100644 --- a/apps/workspaces/views.py +++ b/apps/workspaces/views.py @@ -108,11 +108,13 @@ def post(self, request, **kwargs): authorization_code = request.data.get('code') realm_id = request.data.get('realm_id') redirect_uri = request.data.get('redirect_uri') + print('Connect QBO') try: # Generate a refresh token from the authorization code refresh_token = generate_qbo_refresh_token(authorization_code, redirect_uri) return connect_qbo_oauth(refresh_token, realm_id, kwargs['workspace_id']) except (qbo_exc.UnauthorizedClientError, qbo_exc.NotFoundClientError, qbo_exc.WrongParamsError, qbo_exc.InternalServerError) as e: + print('Something went wrong', e.__dict__) logger.info('Invalid/Expired Authorization Code or QBO application not found - %s', {'error': e.response}) return Response({'message': 'Invalid/Expired Authorization Code or QBO application not found'}, status=status.HTTP_401_UNAUTHORIZED) diff --git a/fyle_rest_auth/__init__.py b/fyle_rest_auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fyle_rest_auth/admin.py b/fyle_rest_auth/admin.py new file mode 100644 index 00000000..2f8d48a9 --- /dev/null +++ b/fyle_rest_auth/admin.py @@ -0,0 +1,9 @@ +""" +Registering models in Django Admin +""" +from django.contrib import admin + +from .models import AuthToken + + +admin.site.register(AuthToken) diff --git a/fyle_rest_auth/apps.py b/fyle_rest_auth/apps.py new file mode 100644 index 00000000..059ada2c --- /dev/null +++ b/fyle_rest_auth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FyleRestAuthConfig(AppConfig): + name = 'fyle_rest_auth' diff --git a/fyle_rest_auth/authentication.py b/fyle_rest_auth/authentication.py new file mode 100644 index 00000000..6c11f82a --- /dev/null +++ b/fyle_rest_auth/authentication.py @@ -0,0 +1,102 @@ +from typing import Dict + +from django.contrib.auth import get_user_model +from django.core.cache import cache + +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed, ValidationError + +from .helpers import get_fyle_admin +from .models import AuthToken +from .utils import AuthUtils + +User = get_user_model() +auth = AuthUtils() + + +class FyleJWTAuthentication(BaseAuthentication): + """ + Fyle Authentication class + """ + def authenticate(self, request): + """ + Authentication function + """ + access_token_string = self.get_header(request) + + user = self.validate_token( + access_token_string=access_token_string, + origin_address=auth.get_origin_address(request) + ) + + try: + user = User.objects.get(email=user['email'], user_id=user['user_id']) + print('user',user) + token = AuthToken.objects.get(user=user) + print('success', token) + except User.DoesNotExist: + raise ValidationError('User not found for this token') + except AuthToken.DoesNotExist: + raise ValidationError('Login details not found for the user') + + return user, None + + @staticmethod + def get_header(request) -> str: + """ + Extracts the header containing the JSON web token from the given + request. + """ + header = request.META.get('HTTP_AUTHORIZATION') + + return header + + @staticmethod + def validate_token(access_token_string: str, origin_address: str) -> Dict: + """ + Validate the access token + :param origin_address: + :param access_token_string: + :return: + """ + if not access_token_string: + raise ValidationError('Access token missing') + + access_token_tokenizer = access_token_string.split(' ') + if not access_token_tokenizer or len(access_token_tokenizer) != 2 or access_token_tokenizer[0] != 'Bearer': + raise ValidationError('Invalid access token structure') + + print('access_token_tokenizer', access_token_tokenizer) + unique_key_generator = access_token_tokenizer[1].split('.') + email_unique_key = 'email_{0}'.format(unique_key_generator[2]) + user_unique_key = 'user_{0}'.format(unique_key_generator[2]) + + email = cache.get(email_unique_key) + user = cache.get(user_unique_key) + + if not (email and user): + cache.delete_many([email_unique_key, user_unique_key]) + + try: + employee_info = get_fyle_admin(access_token_string.split(' ')[1], origin_address) + except Exception: + raise AuthenticationFailed('Invalid access token') + + cache.set(email_unique_key, employee_info['data']['user']['email']) + cache.set(user_unique_key, employee_info['data']['user']['id']) + + print('employee_info', employee_info) + return { + 'email': employee_info['data']['user']['email'], + 'user_id': employee_info['data']['user']['id'] + } + + elif email and user: + print('email', email) + print('user', user) + return { + 'email': email, + 'user_id': user + } + + raise AuthenticationFailed('Invalid access token') diff --git a/fyle_rest_auth/helpers.py b/fyle_rest_auth/helpers.py new file mode 100644 index 00000000..b30aca23 --- /dev/null +++ b/fyle_rest_auth/helpers.py @@ -0,0 +1,189 @@ +import logging +import traceback +from typing import Dict + +from rest_framework.exceptions import ValidationError + +from django.contrib.auth import get_user_model +from django.conf import settings +from django.utils.module_loading import import_string + +from django_q.tasks import async_task + +from .utils import AuthUtils, post_request, get_request +from .models import AuthToken + +auth = AuthUtils() + +logger = logging.getLogger(__name__) +logger.level = logging.INFO + + +def validate_code_and_login(request): + authorization_code = request.data.get('code') + try: + if not authorization_code: + raise ValidationError('authorization code not found') + + tokens = auth.generate_fyle_refresh_token(authorization_code=authorization_code) + + employee_info = get_fyle_admin(tokens['access_token'], auth.get_origin_address(request)) + users = get_user_model() + + user, _ = users.objects.get_or_create( + user_id=employee_info['data']['user']['id'], + email=employee_info['data']['user']['email'] + ) + + AuthToken.objects.update_or_create( + user=user, + defaults={ + 'refresh_token': tokens['refresh_token'] + } + ) + + serializer = import_string(settings.FYLE_REST_AUTH_SERIALIZERS['USER_DETAILS_SERIALIZER']) + tokens['user'] = serializer(user).data + tokens['user']['full_name'] = employee_info['data']['user']['full_name'] + tokens['user']['org_id'] = employee_info['data']['org']['id'] + tokens['user']['org_name'] = employee_info['data']['org']['name'] + + # Update Fyle Credentials with latest healthy token + if 'async_update_user' in settings.FYLE_REST_AUTH_SETTINGS \ + and settings.FYLE_REST_AUTH_SETTINGS['async_update_user']: + async_task( + 'apps.workspaces.tasks.async_update_fyle_credentials', + employee_info['data']['org']['id'], tokens['refresh_token'] + ) + + return tokens + + except ValidationError as error: + logger.info(error) + raise + + except Exception as error: + logger.error(traceback.format_exc()) + raise ValidationError(error) + + +def validate_refresh_token_and_login(request): + """ + Takes refresh_token from payload + GET Fyle Admin info + Get Or Create User + Saves AuthToken + Return Tokens + """ + refresh_token = request.data.get('refresh_token') + try: + if not refresh_token: + raise ValidationError('refresh token not found') + + tokens = auth.refresh_access_token(refresh_token) + + employee_info = get_fyle_admin(tokens['access_token'], auth.get_origin_address(request)) + users = get_user_model() + + user, _ = users.objects.get_or_create( + user_id=employee_info['data']['user']['id'], + email=employee_info['data']['user']['email'] + ) + + AuthToken.objects.update_or_create( + user=user, + defaults={ + 'refresh_token': refresh_token + } + ) + + serializer = import_string(settings.FYLE_REST_AUTH_SERIALIZERS['USER_DETAILS_SERIALIZER']) + tokens['user'] = serializer(user).data + tokens['user']['full_name'] = employee_info['data']['user']['full_name'] + tokens['user']['org_id'] = employee_info['data']['org']['id'] + tokens['user']['org_name'] = employee_info['data']['org']['name'] + + # Update Fyle Credentials with latest healthy token + if 'async_update_user' in settings.FYLE_REST_AUTH_SETTINGS \ + and settings.FYLE_REST_AUTH_SETTINGS['async_update_user']: + async_task( + 'apps.workspaces.tasks.async_update_fyle_credentials', + employee_info['data']['org']['id'], tokens['refresh_token'] + ) + + return tokens + + except ValidationError as error: + logger.info(error) + raise + + except Exception as error: + logger.error(traceback.format_exc()) + raise ValidationError(error) + + +def validate_and_refresh_token(request): + refresh_token = request.data.get('refresh_token') + try: + if not refresh_token: + raise ValidationError('refresh token not found') + + tokens = auth.refresh_access_token(refresh_token) + + employee_info = get_fyle_admin(tokens['access_token'], auth.get_origin_address(request)) + users = get_user_model() + + user = users.objects.filter( + email=employee_info['data']['user']['email'], user_id=employee_info['data']['user']['id'] + ).first() + + if not user: + raise ValidationError('User record not found, please login') + + auth_token = AuthToken.objects.get(user=user) + auth_token.refresh_token = refresh_token + auth_token.save() + + serializer = import_string(settings.FYLE_REST_AUTH_SERIALIZERS['USER_DETAILS_SERIALIZER']) + tokens['user'] = serializer(user).data + tokens['refresh_token'] = refresh_token + + return tokens + + except ValidationError as error: + logger.info(error) + raise + + except Exception as error: + logger.error(traceback.format_exc()) + raise ValidationError(error) + + +def get_cluster_domain(access_token: str, origin_address: str = None) -> str: + """ + Get cluster domain name from fyle + :param access_token: (str) + :return: cluster_domain (str) + """ + cluster_api_url = '{0}/oauth/cluster/'.format(settings.FYLE_BASE_URL) + + return post_request(cluster_api_url, {}, access_token, origin_address)['cluster_domain'] + + +def get_fyle_admin(access_token: str, origin_address: str = None) -> Dict: + """ + Get user profile from fyle + :param access_token: (str) + :return: user_profile (dict) + """ + cluster_domain = get_cluster_domain(access_token, origin_address) + + profile_api_url = '{}/platform/v1beta/spender/my_profile'.format(cluster_domain) + employee_detail = get_request(profile_api_url, access_token, origin_address) + + if 'ADMIN' in employee_detail['data']['roles'] or \ + ('FYLE_MODULE' in settings.FYLE_REST_AUTH_SERIALIZERS and \ + settings.FYLE_REST_AUTH_SERIALIZERS['FYLE_MODULE'] == 'PARTNER_DASHBOARD'): + return employee_detail + else: + raise ValidationError('User is not an admin') diff --git a/fyle_rest_auth/migrations/0001_initial.py b/fyle_rest_auth/migrations/0001_initial.py new file mode 100644 index 00000000..4bf21f9f --- /dev/null +++ b/fyle_rest_auth/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 3.0.1 on 2020-01-01 11:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuthTokens', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('refresh_token', models.TextField(help_text='Fyle refresh token')), + ('access_token', models.TextField(help_text='Fyle access token')), + ('user', models.ForeignKey(help_text='User table relation', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/fyle_rest_auth/migrations/0002_auto_20200101_1205.py b/fyle_rest_auth/migrations/0002_auto_20200101_1205.py new file mode 100644 index 00000000..1ab97c9a --- /dev/null +++ b/fyle_rest_auth/migrations/0002_auto_20200101_1205.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.1 on 2020-01-01 12: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), + ('fyle_rest_auth', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='authtokens', + name='user', + field=models.OneToOneField(help_text='User table relation', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/fyle_rest_auth/migrations/0003_auto_20200107_0921.py b/fyle_rest_auth/migrations/0003_auto_20200107_0921.py new file mode 100644 index 00000000..9c66d428 --- /dev/null +++ b/fyle_rest_auth/migrations/0003_auto_20200107_0921.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.2 on 2020-01-07 09:21 + +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), + ('fyle_rest_auth', '0002_auto_20200101_1205'), + ] + + operations = [ + migrations.AlterField( + model_name='authtokens', + name='user', + field=models.OneToOneField(help_text='User table relation', on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/fyle_rest_auth/migrations/0004_auto_20200107_1345.py b/fyle_rest_auth/migrations/0004_auto_20200107_1345.py new file mode 100644 index 00000000..2af192db --- /dev/null +++ b/fyle_rest_auth/migrations/0004_auto_20200107_1345.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.2 on 2020-01-07 13:45 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('fyle_rest_auth', '0003_auto_20200107_0921'), + ] + + operations = [ + migrations.RenameModel( + old_name='AuthTokens', + new_name='AuthToken', + ), + ] diff --git a/fyle_rest_auth/migrations/0005_remove_authtoken_access_token.py b/fyle_rest_auth/migrations/0005_remove_authtoken_access_token.py new file mode 100644 index 00000000..d42efde3 --- /dev/null +++ b/fyle_rest_auth/migrations/0005_remove_authtoken_access_token.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.2 on 2020-01-09 10:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('fyle_rest_auth', '0004_auto_20200107_1345'), + ] + + operations = [ + migrations.RemoveField( + model_name='authtoken', + name='access_token', + ), + ] diff --git a/fyle_rest_auth/migrations/0006_auto_20201221_0849.py b/fyle_rest_auth/migrations/0006_auto_20201221_0849.py new file mode 100644 index 00000000..5aa68c23 --- /dev/null +++ b/fyle_rest_auth/migrations/0006_auto_20201221_0849.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.3 on 2020-12-21 08:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('fyle_rest_auth', '0005_remove_authtoken_access_token'), + ] + + operations = [ + migrations.AlterModelTable( + name='authtoken', + table='auth_tokens', + ), + ] diff --git a/fyle_rest_auth/migrations/__init__.py b/fyle_rest_auth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fyle_rest_auth/models.py b/fyle_rest_auth/models.py new file mode 100644 index 00000000..d46fd7ac --- /dev/null +++ b/fyle_rest_auth/models.py @@ -0,0 +1,16 @@ +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class AuthToken(models.Model): + """ + Fyle auth tokens + """ + id = models.AutoField(primary_key=True) + user = models.OneToOneField(User, on_delete=models.PROTECT, help_text='User table relation') + refresh_token = models.TextField(help_text='Fyle refresh token') + + class Meta: + db_table = 'auth_tokens' diff --git a/fyle_rest_auth/urls.py b/fyle_rest_auth/urls.py new file mode 100644 index 00000000..684d67da --- /dev/null +++ b/fyle_rest_auth/urls.py @@ -0,0 +1,24 @@ +"""fyle_qbo URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.urls import path + +from .views import LoginView, RefreshView, LoginWithRefreshTokenView + +urlpatterns = [ + path('login/', LoginView.as_view()), + path('refresh/', RefreshView.as_view()), + path('login_with_refresh_token/', LoginWithRefreshTokenView.as_view()), +] diff --git a/fyle_rest_auth/utils.py b/fyle_rest_auth/utils.py new file mode 100644 index 00000000..45fac433 --- /dev/null +++ b/fyle_rest_auth/utils.py @@ -0,0 +1,100 @@ +""" +Authentication utils +""" +import json +from typing import Dict + +from django.conf import settings + +import requests + + +def post_request(url, body, access_token: str = None, origin_address: str = None) -> Dict: + """ + Create a HTTP post request. + """ + api_headers = { + 'content-type': 'application/json', + 'X-Forwarded-For': origin_address + } + + if access_token: + api_headers['Authorization'] = 'Bearer {0}'.format(access_token) + + response = requests.post( + url, + headers=api_headers, + data=json.dumps(body) + ) + + if response.status_code == 200: + return json.loads(response.text) + else: + raise Exception(response.text) + + +def get_request(url, access_token, origin_address: str = None): + """ + Create a HTTP get request. + """ + api_headers = { + 'Authorization': 'Bearer {0}'.format(access_token), + 'X-Forwarded-For': origin_address + } + + response = requests.get( + url, + headers=api_headers + ) + + if response.status_code == 200: + return json.loads(response.text) + else: + raise Exception(response.text) + + +class AuthUtils: + """ + Authentication utility functions + """ + def __init__(self): + self.base_url = settings.FYLE_BASE_URL + self.token_url = settings.FYLE_TOKEN_URI + self.client_id = settings.FYLE_CLIENT_ID + self.client_secret = settings.FYLE_CLIENT_SECRET + + def generate_fyle_refresh_token(self, authorization_code: str) -> Dict: + """ + Get refresh token from authorization code + """ + api_data = { + 'grant_type': 'authorization_code', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'code': authorization_code + } + + return post_request(url=self.token_url, body=api_data) + + def refresh_access_token(self, refresh_token: str) -> Dict: + """ + Refresh access token using refresh token + """ + api_data = { + 'grant_type': 'refresh_token', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'refresh_token': refresh_token + } + + return post_request(self.token_url, api_data) + + + @staticmethod + def get_origin_address(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[-1].strip() + else: + ip = request.META.get('REMOTE_ADDR') + return ip diff --git a/fyle_rest_auth/views.py b/fyle_rest_auth/views.py new file mode 100644 index 00000000..851770c8 --- /dev/null +++ b/fyle_rest_auth/views.py @@ -0,0 +1,65 @@ +""" +Fyle Authentication views +""" +from rest_framework.views import APIView, status +from rest_framework.response import Response + +from .helpers import ( + validate_code_and_login, + validate_and_refresh_token, + validate_refresh_token_and_login +) + + +class LoginView(APIView): + """ + Login Using Fyle Account + """ + authentication_classes = [] + permission_classes = [] + + def post(self, request): + """ + Login using authorization code + """ + tokens = validate_code_and_login(request) + + return Response( + data=tokens, + status=status.HTTP_200_OK, + ) + + +class LoginWithRefreshTokenView(APIView): + """ + Login Using Fyle Account + """ + authentication_classes = [] + permission_classes = [] + + def post(self, request): + """ + Login using refresh token + """ + tokens = validate_refresh_token_and_login(request) + + return Response( + data=tokens, + status=status.HTTP_200_OK, + ) + + +class RefreshView(APIView): + """ + Refresh Access Token + """ + authentication_classes = [] + permission_classes = [] + + def post(self, request): + tokens = validate_and_refresh_token(request) + + return Response( + data=tokens, + status=status.HTTP_200_OK + )