2

So I have this code below for checking a AWS Cognito token. I obviously don't want to add these 6 lines of code to every endpoint. Also I don't know if this is the proper way of validating all I'm doing is expecting the token to be of format ' ', parsing it and just decoding the JWT token part. How can I authenticate the AWS amplify token that comes with every request to ensure the user is properly logged in. I'd like to add this authentication to APIView endpoints and DRF api_view decorated endpoints.

views.py

import django.db.utils
from rest_framework import authentication, permissions, status
from rest_framework.views import APIView
from .serializers import *
from .models import *
from rest_framework.response import Response
from django.http import JsonResponse
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from .core.api import jwt
from django.core.exceptions import ObjectDoesNotExist
class LoginView(APIView):
    def post(self, request):
        # 'Bearer z324weroko2iorjqoi=+3r3+3ij.2o2ij4='
        token = request.META['HTTP_AUTHORIZATION'].split(' ')[1]
        print(token)
    
        # TODO this should be separated out to a login module
        try:
            res = jwt.decode_cognito_jwt(token)
            return Response(status=status.Http_200_OK)
        except:
            return Response("Invalid JWT", status=status.HTTP_401_UNAUTHORIZED)

@api_view(['GET'])
@swagger_auto_schema(
    operation_description="Get Goals joined by User"
)
def get_goals_by_user(request, user_id):
    try:
        # Get goal ids of user with id
        goals_query = JoinGoal.objects.filter(
            joiner_id=user_id).values_list('goal_id', flat=True)
        goals_list = list(goals_query)
        # Get Goals using list of goals PK with descriptions and uuid
        data = list(Goal.objects.filter(
            pk__in=goals_list).values('description', 'uuid'))
        response_data = dict(goals=data)
        return JsonResponse(response_data, status=status.HTTP_200_OK)
    except JoinGoal.DoesNotExist:
        return Response(dict(error=does_not_exist_msg(JoinGoal.__name__, 'joiner_id', user_id)), status=status.HTTP_400_BAD_REQUEST)

2 Answers2

2

If using djangorestframework, the answer from @bdbd would be your best option. Otherwise, you might want to explore the following options:

  1. Implement your own decorator that will perform the authentication. This has the same idea as the @login_required decorator or the @user_passes_test decorator. When writing such decorator for class-based views, you maybe interested with django.utils.decorators.method_decorator.
from functools import partial, wraps

from django.utils.decorators import method_decorator


def cognito_authenticator(view_func=None):
    if view_func is None:
        return partial(cognito_authenticator)

    @wraps(view_func)
    def wrapped_view(request, *args, **kwargs):
        # Check the cognito token from the request.
        token = request.META['HTTP_AUTHORIZATION'].split(' ')[1]

        try:
            res = jwt.decode_cognito_jwt(token)
            # Authenticate res if valid. Raise exception if not.
        except Exception:
            # Fail if invalid
            return HttpResponseForbidden("You are forbidden here!")
        else:
            # Proceed with the view if valid
            return view_func(request, *args, **kwargs)

    return wrapped_view


# We can decorate it here before the class definition but can also be done before the class method itself. See https://docs.djangoproject.com/en/3.2/topics/class-based-views/intro/#decorating-the-class
@method_decorator(
    name="post",
    decorator=[
        cognito_authenticator,
    ],
)
class SomeView(View):
    @method_decorator(cognito_authenticator)  # As explained above, this is another way of putting the decorator
    def get(self, request):
        return HttpResponse("Allowed entry!")

    def post(self, request):
        return HttpResponse("Allowed entry!")


# Or if using function-based views
@api_view(['POST'])
@cognito_authenticator
def some_view(request):
    return HttpResponse(f"Allowed entry!")
  1. Write a custom middleware. Be aware that the order matters. Same idea as the default AuthenticationMiddleware which populates the request.user field. In your case, implement the __call__ method where you would check the Cognito tokens. Do not proceed to the view when the token is invalid by returning e.g. HttpResponseForbidden as in this reference.
class CognitoAuthenticatorMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        token = request.META['HTTP_AUTHORIZATION'].split(' ')[1]

        try:
            res = jwt.decode_cognito_jwt(token)
            # Authenticate res if valid. Raise exception if not.
        except Exception:
            # Fail if invalid
            return HttpResponseForbidden("You are forbidden here!")

        # Proceed if valid
        response = self.get_response(request)

        return response
MIDDLEWARE = [
    ...
    'path.to.CognitoAuthenticatorMiddleware',
    ...
]

Update

Here is a sample run using Option-1. For simplicity, settings.py is just the default settings.

views.py

from functools import partial, wraps

from django.http import HttpResponse, HttpResponseForbidden
from django.utils.decorators import method_decorator
from django.views import View  # If using django views
from rest_framework.views import APIView  # If using djangorestframework views


def cognito_authenticator(view_func=None):
    if view_func is None:
        return partial(cognito_authenticator)

    @wraps(view_func)
    def wrapped_view(request, *args, **kwargs):
        # To simplify the authentication, we would check if there is a query parameter "name=me". If none, it is forbidden.
        if request.GET.get('name') == "me":
            return view_func(request, *args, **kwargs)
        return HttpResponseForbidden("You are forbidden here!")

    return wrapped_view


@method_decorator(  # Try this style-1
    name="get",
    decorator=[
        cognito_authenticator,
    ],
)
class SomeView(View):  # If using djangorestframework view, this can also inherit from APIView or others e.g. class SomeView(APIView):
    @method_decorator(cognito_authenticator)  # Or try this style-2
    def get(self, request):
        return HttpResponse(f"Allowed entry!")

urls.py

from django.urls import path

from my_app import views

urlpatterns = [
    path("some-view/", views.SomeView.as_view()),
]

Sample run:

$ curl http://127.0.0.1:8000/my_app/some-view/?name=notme
You are forbidden here!
$ curl http://127.0.0.1:8000/my_app/some-view/?name=me
Allowed entry!
  • what should "name" be in model_decorator be if it's on an APIView with multiple endpoint requests: GET, POST, PUT? –  Sep 02 '21 at 03:41
  • 1
    It's the name of the class method that you want to decorate, such as "post", "get", "put", etc. See how it's used [here](https://docs.djangoproject.com/en/3.2/topics/class-based-views/intro/#decorating-the-class). As you can see in that reference, you can also just put it directly on the method instead of the class. – Niel Godfrey Pablo Ponciano Sep 02 '21 at 03:52
  • I keep getting 'TypeError: cognito_authenticator() missing 1 required positional argument: 'view_func'' error when I try to use it as described –  Sep 02 '21 at 03:59
  • 1
    I updated the answer, could you check if it works now? – Niel Godfrey Pablo Ponciano Sep 02 '21 at 04:03
  • rest_framework.request.WrappedAttributeError: 'functools.partial' object has no attribute 'authenticate' –  Sep 02 '21 at 04:25
  • if you could help me get this working properly I'll give you the accepted answer –  Sep 02 '21 at 19:38
  • 1
    Just as a side note - your example code does not really validate the token. It just decodes it. Whether it's valid or not is a different story. Since Cognito is an OIDC provider, have a look how Auth0 is handling JWT token validation: https://auth0.com/docs/quickstart/backend/python/01-authorization#create-the-jwt-validation-decorator (it's OIDC as well). Instead of `AUTH0_DOMAIN` you will have your Cognito's hoster UI domain. – Aleksander Wons Sep 03 '21 at 06:40
  • 1
    @user8714896 I updated my answer with a sample run of minimum configuration. You might be interested building on top of it and see what part you would have an error. Also, thank you AleksanderWons for the feedback, I clarified in my example code that further authentication must be performed. – Niel Godfrey Pablo Ponciano Sep 03 '21 at 07:02
  • @NielGodfreyPonciano is there a way to add it to api_view decorator endpoint? On the endpoint with api_view decorator in my original post I get 'TypeError: wrapped_view() missing 1 required positional argument: 'request'' –  Sep 04 '21 at 04:03
  • 1
    @user8714896 I updated my answer to include how you would do it on function-based views. In summary, replace `@method_decorator(cognito_authenticator)` to just `@cognito_authenticator`. I tried it and it worked on my side. – Niel Godfrey Pablo Ponciano Sep 04 '21 at 04:20
  • why do you have to use `@cognito_authenticator` over `@method_decorator?` What are the differences? –  Sep 04 '21 at 04:23
  • 1
    Because `method_decorator`, from the name itself specifically the `"method"` part, implies that it must be used for classes, this is also as documented [here](https://docs.djangoproject.com/en/3.2/topics/class-based-views/intro/#decorating-the-class). If you are not using class-based views, then don't use it, just use the target decorator directly (here is `cognito_authenticator`). – Niel Godfrey Pablo Ponciano Sep 04 '21 at 04:25
  • @NielGodfreyPonciano I have to wait 2 minutes but after that you'll get the bounty –  Sep 04 '21 at 04:35
1

Since it seems you are using DRF, you can create your own authentication class and apply the processing of the JWT there:

from django.contrib.auth.models import AnonymousUser
from rest_framework.authentication import BaseAuthentication
from rest_framework import exceptions


class MyCustomJWTAuthentication(BaseAuthentication):
    def authenticate(self, request):
        token = request.META['HTTP_AUTHORIZATION'].split(' ')[1]
        try:
            jwt.decode_cognito_jwt(token)
        except Exception:
            raise exceptions.AuthenticationFailed('Invalid JWT')

        return AnonymousUser(), None


class MyCustomAPIView(APIView):
    authentication_classes = (MyCustomJWTAuthentication, )

Or if you want to apply it to all APIViews:

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'path.to.MyCustomJWTAuthentication',
    ),
}

Note that once the JWT decode fails, no other authentication classes will be checked. If you don't want this, change the handling for the except clause to not raise AuthenticationFailed.

Brian Destura
  • 11,487
  • 3
  • 18
  • 34
  • would you say the way I am authenticating the token is the "proper way" to do it? At least with Cognito? –  Sep 01 '21 at 07:16
  • IMO it depends on how the token is used. The purpose of authentication is to identify a user, but if you just need a token that acts like a simple api key, then it should be fine. – Brian Destura Sep 01 '21 at 11:06
  • if I do need to identify user such as get their email or username can I get that from the token? –  Sep 01 '21 at 20:41
  • is it possible to apply to all endpoints even DRF ones and have an blacklist of ones not to add it to? –  Sep 01 '21 at 22:23
  • `if I do need to identify user such as get their email or username can I get that from the token?` -- yes but it depends on how the jwt token was setup. If you decode it and find an email or some sort of id to identify a user then you can definitely use that – Brian Destura Sep 02 '21 at 01:05
  • `is it possible to apply to all endpoints even DRF ones and have an blacklist of ones not to add it to?` -- I assume you mean *non-DRF* ones. Yes you can, in that case the answer of @Niel Godfrey Ponciano can work. – Brian Destura Sep 02 '21 at 01:06
  • does your method only work on APIView? Not on DRF endpoints like the get_goals_by_users function in my original post? Looks like that authentication_classes variable needs to be put inside a class. –  Sep 02 '21 at 03:54
  • Yep since you used the `@api_view` decorator in `get_goals_by_users`, it will read the rest framework settings – Brian Destura Sep 02 '21 at 03:59
  • If you want to set authentication classes explicitly in a function-based view, you use `@authentication_classes([])` decorator like shown [here](https://www.django-rest-framework.org/api-guide/authentication/#setting-the-authentication-scheme) – Brian Destura Sep 02 '21 at 04:01
  • apparently api_view extends view which doesn't have a request instance (https://www.django-rest-framework.org/api-guide/views/). Part of authenticating I think is that it requires it to be a request instance? –  Sep 04 '21 at 04:12
  • I think I need request instance, because all the AWS cognito information is added to that. –  Sep 04 '21 at 04:19