Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Enhancement] An approach to adding DRF Authentication #342

Open
JackAtOmenApps opened this issue Aug 24, 2019 · 3 comments
Open

[Enhancement] An approach to adding DRF Authentication #342

JackAtOmenApps opened this issue Aug 24, 2019 · 3 comments

Comments

@JackAtOmenApps
Copy link

JackAtOmenApps commented Aug 24, 2019

I'd like some extra sets of eyes to make sure we didn't miss anything dangerous.

We have been looking for a Django Rest Framework (DRF) solution to go along with oidc-provider for some time. We have a bunch of additional models tied to Client, as I'm sure many of you do, and wanted to be able to provide information about these associated models to their clients via DRF.

So we looked at how django-oidc-provider, mozilla-django-oidc, and django-oauth-toolkit approach protecting endpoints, as well as digging through the docs for DRF, and ended up with what's below. It doesn't modify any files in django-oidc-provider - just adds functionality to protect your DRF views. (Portions of this file are excerpted from those projects.)

Here I'll show the the authentication class, the permission class (to limit to scope), settings, and a basic example. Again, please take a look and let us know if anything looks unsafe or if you have recommendations.

drf.py

import logging

from django.contrib.auth import get_user_model
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from rest_framework import authentication
from rest_framework.exceptions import PermissionDenied, AuthenticationFailed
from rest_framework.permissions import BasePermission
from requests.exceptions import HTTPError
from urllib.request import parse_http_list, parse_keqv_list

from oidc_provider.models import Token
from oidc_provider.lib.utils.oauth2 import extract_access_token


LOGGER = logging.getLogger(__name__)

User = get_user_model()


def parse_www_authenticate_header(header):
    """
    Convert a WWW-Authentication header into a dict that can be used
    in a JSON response.
    """
    items = parse_http_list(header)
    return parse_keqv_list(items)


class OIDCAuthentication(authentication.BaseAuthentication):
    """
    Provide OpenID Connect authentication for DRF.
    """

    # used by the authenticate_header method.
    www_authenticate_realm = 'api'

    def authenticate(self, request):
        """
        Authenticate the request and return a tuple of (user, token) or None
        if there was no authentication attempt.
        """

        access_token = extract_access_token(request)

        if not access_token:
            return None

        try:

            try:
                token = Token.objects.get(access_token=access_token)
            except Token.DoesNotExist:
                LOGGER.debug('[UserInfo] Token does not exist: %s', access_token)
                raise AuthenticationFailed('Invalid_token')

            # if the token has an associated user, return it
            # otherwise (e.g. if using client_credentials), get_or_create a default API user
            user = token.user
            if not user:
                try:
                    # Check for case where the User model is using email rather than username
                    User._meta.get_field("username")
                    user = User.objects.get_or_create(username="default_api_user")[0]
                except FieldDoesNotExist:
                    user = User.objects.get_or_create(email="[email protected]")[0]

            return user, access_token

        except HTTPError as exc:
            resp = exc.response

            # if the oidc provider returns 401, it means the token is invalid.
            # in that case, we want to return the upstream error message (which
            # we can get from the www-authentication header) in the response.
            if resp.status_code == 401 and 'www-authenticate' in resp.headers:
                data = parse_www_authenticate_header(resp.headers['www-authenticate'])
                raise AuthenticationFailed(data['error_description'])

            # for all other http errors, just re-raise the exception.
            raise

        except SuspiciousOperation as exc:
            LOGGER.info('Login failed: %s', exc)
            raise AuthenticationFailed('Login failed')

        return None

    def authenticate_header(self, request):
        """
        If this method returns None, a generic HTTP 403 forbidden response is
        returned by DRF when authentication fails.

        By making the method return a string, a 401 is returned instead. The
        return value will be used as the WWW-Authenticate header.
        """
        return 'Bearer realm="%s"' % self.www_authenticate_realm


class TokenHasScope(BasePermission):
    """
    The request is authenticated and the token used has the right scope
    """

    def has_permission(self, request, view):

        try:
            token = Token.objects.get(access_token=request.auth)  # The Token object retrieved from access_token

            if token.has_expired():
                self.message = {
                    "detail": PermissionDenied.default_detail,
                    "error": 'Token has expired',
                }
                return False

        except Token.DoesNotExist:
            self.message = {
                "detail": PermissionDenied.default_detail,
                "error": "No token provided or token does not exist",
            }

            return False

        if hasattr(token, "scope"):
            required_scopes = self.get_scopes(request, view)
            LOGGER.debug("Required scopes to access resource: {0}".format(required_scopes))

            if set(required_scopes).issubset(set(token.scope)):
                return True                

            # Provide information about required scope
            self.message = {
                "detail": PermissionDenied.default_detail,
                "error": "Permission denied, required_scopes: " + str(list(required_scopes)),
            }

            return False

        return False

    def get_scopes(self, request, view):
        try:
            return getattr(view, "required_scopes")
        except AttributeError:
            raise ImproperlyConfigured(
                "TokenHasScope requires the view to define the required_scopes attribute"
            )

Add to settings.py, assuming drf.py is in your project's root:

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'my_project.drf.OIDCAuthentication',
    ),
}

Here is a basic example which simply serializes a bit of the Client instance for the client which is requesting the API endpoint. In reality, you would use the Client instance to serialize associated models.

serializers.py

class ClientTestSerializer(serializers.ModelSerializer):
    class Meta:
        model = Client
        fields = ('name', 'owner', "date_created", "website_url")

views.py

from my_project.drf import TokenHasScope

class ClientViewSet(viewsets.ModelViewSet):
    """
    A viewset for viewing and editing client instances.
    """
    permission_classes = [TokenHasScope]
    required_scopes = ['read_books']
    serializer_class = ClientTestSerializer
    queryset = Client.objects.none()

    def list(self, request, *args, **kwargs):
        access_token = request.META.get('HTTP_AUTHORIZATION').split()[1]

        if access_token is None:
            queryset = Client.objects.none()
        else:
            queryset = Client.objects.filter(token__access_token=access_token)

        serializer = self.get_serializer(queryset, many=True)

        return Response(serializer.data)

routers.py

from rest_framework import routers
from my_project.my_app_name.api.views import ClientViewSet

router = routers.DefaultRouter()

router.register(r'^client', ClientViewSet, base_name='client')

Request a token using client_credentials flow:

curl -X POST \
-d "client_id=514698&client_secret=40a9b32864095593d568de1f2a674d76ed3aeaa98a1a7c925ac36fc2&grant_type=client_credentials" \
https://myproject.com/token

Result: {"access_token": "f321ac39b4714a67aa4de38308566403", "expires_in": 3600, "token_type": "bearer", "scope": "read_books"}

Use the resulting access_token to grab the API endpoint:

curl -X GET \
  -H "Authorization: Bearer f321ac39b4714a67aa4de38308566403" \
  https://myproject.com/client/

Result: [{"name":"Test Client","owner":null,"date_created":"2019-06-24","website_url":"https://myotherproject.com/"}]

@FluidSense
Copy link

FluidSense commented Sep 18, 2020

Is there any news on the DRF-django-oidc-provider development?

@JackAtOmenApps
Copy link
Author

Sadly, it doesn't look like it. The repo owner has only made two or three github contributions in the past year, and does not appear to be maintaining this repo any longer. It's a shame, because it's a great package and he clearly put in an incredible amount of work. His LinkedIn suggests he got a new job a year ago, so that probably became a higher priority.

@inventionlabsSydney
Copy link

@juanifioren is there any way we can take this over and implement, providing a PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants