From 1df040228adc027a42e2d4ef92b33e0157c9af0c Mon Sep 17 00:00:00 2001 From: "J. Cliff Dyer" Date: Mon, 1 Feb 2016 20:36:35 +0000 Subject: [PATCH] Configure LMS to select oauth2 providing library. Available backends: * django-oauth-toolkit (DOT) * django-oauth2-provider (DOP) * Use provided client ID to select backend for * AccessToken requests * third party auth-token exchange * Create adapters to isolate library-dependent functionality * Handle django-oauth-toolkit tokens in edX DRF authenticator class MA-1998 MA-2000 --- cms/envs/common.py | 5 +- common/djangoapps/auth_exchange/forms.py | 10 +- .../djangoapps/auth_exchange/tests/mixins.py | 111 ++++++++ .../auth_exchange/tests/test_forms.py | 60 ++++- .../auth_exchange/tests/test_views.py | 86 ++++-- .../djangoapps/auth_exchange/tests/utils.py | 15 +- common/djangoapps/auth_exchange/views.py | 110 +++++++- .../third_party_auth/tests/utils.py | 23 +- common/test/db_cache/lettuce.db | Bin 1396736 -> 1420288 bytes lms/djangoapps/oauth_dispatch/__init__.py | 0 .../oauth_dispatch/adapters/__init__.py | 7 + lms/djangoapps/oauth_dispatch/adapters/dop.py | 70 +++++ lms/djangoapps/oauth_dispatch/adapters/dot.py | 73 +++++ .../oauth_dispatch/tests/__init__.py | 0 .../oauth_dispatch/tests/constants.py | 5 + lms/djangoapps/oauth_dispatch/tests/mixins.py | 3 + .../oauth_dispatch/tests/test_dop_adapter.py | 77 ++++++ .../oauth_dispatch/tests/test_dot_adapter.py | 76 ++++++ .../oauth_dispatch/tests/test_views.py | 251 ++++++++++++++++++ lms/djangoapps/oauth_dispatch/urls.py | 25 ++ lms/djangoapps/oauth_dispatch/views.py | 89 +++++++ lms/djangoapps/support/tests/test_programs.py | 2 +- lms/envs/common.py | 5 +- lms/urls.py | 19 +- .../programs/tasks/v1/tests/test_tasks.py | 2 +- openedx/core/lib/api/authentication.py | 40 ++- .../core/lib/api/tests/test_authentication.py | 35 ++- pavelib/prereqs.py | 2 +- requirements/edx/base.txt | 5 +- 29 files changed, 1114 insertions(+), 92 deletions(-) create mode 100644 common/djangoapps/auth_exchange/tests/mixins.py create mode 100644 lms/djangoapps/oauth_dispatch/__init__.py create mode 100644 lms/djangoapps/oauth_dispatch/adapters/__init__.py create mode 100644 lms/djangoapps/oauth_dispatch/adapters/dop.py create mode 100644 lms/djangoapps/oauth_dispatch/adapters/dot.py create mode 100644 lms/djangoapps/oauth_dispatch/tests/__init__.py create mode 100644 lms/djangoapps/oauth_dispatch/tests/constants.py create mode 100644 lms/djangoapps/oauth_dispatch/tests/mixins.py create mode 100644 lms/djangoapps/oauth_dispatch/tests/test_dop_adapter.py create mode 100644 lms/djangoapps/oauth_dispatch/tests/test_dot_adapter.py create mode 100644 lms/djangoapps/oauth_dispatch/tests/test_views.py create mode 100644 lms/djangoapps/oauth_dispatch/urls.py create mode 100644 lms/djangoapps/oauth_dispatch/views.py diff --git a/cms/envs/common.py b/cms/envs/common.py index 954ae13380..2beb6e6925 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -864,11 +864,14 @@ INSTALLED_APPS = ( # Self-paced course configuration 'openedx.core.djangoapps.self_paced', - # OAuth2 Provider + # django-oauth2-provider (deprecated) 'provider', 'provider.oauth2', 'edx_oauth2_provider', + # django-oauth-toolkit + 'oauth2_provider', + # These are apps that aren't strictly needed by Studio, but are imported by # other apps that are. Django 1.8 wants to have imported models supported # by installed apps. diff --git a/common/djangoapps/auth_exchange/forms.py b/common/djangoapps/auth_exchange/forms.py index 81cd9da183..8caf61799d 100644 --- a/common/djangoapps/auth_exchange/forms.py +++ b/common/djangoapps/auth_exchange/forms.py @@ -8,6 +8,7 @@ import provider.constants from provider.forms import OAuthForm, OAuthValidationError from provider.oauth2.forms import ScopeChoiceField, ScopeMixin from provider.oauth2.models import Client +from oauth2_provider.models import Application from requests import HTTPError from social.backends import oauth as social_oauth from social.exceptions import AuthException @@ -21,9 +22,10 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm): scope = ScopeChoiceField(choices=SCOPE_NAMES, required=False) client_id = CharField(required=False) - def __init__(self, request, *args, **kwargs): + def __init__(self, request, oauth2_adapter, *args, **kwargs): super(AccessTokenExchangeForm, self).__init__(*args, **kwargs) self.request = request + self.oauth2_adapter = oauth2_adapter def _require_oauth_field(self, field_name): """ @@ -68,15 +70,15 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm): client_id = self.cleaned_data["client_id"] try: - client = Client.objects.get(client_id=client_id) - except Client.DoesNotExist: + client = self.oauth2_adapter.get_client(client_id=client_id) + except (Client.DoesNotExist, Application.DoesNotExist): raise OAuthValidationError( { "error": "invalid_client", "error_description": "{} is not a valid client_id".format(client_id), } ) - if client.client_type != provider.constants.PUBLIC: + if client.client_type not in [provider.constants.PUBLIC, Application.CLIENT_PUBLIC]: raise OAuthValidationError( { # invalid_client isn't really the right code, but this mirrors diff --git a/common/djangoapps/auth_exchange/tests/mixins.py b/common/djangoapps/auth_exchange/tests/mixins.py new file mode 100644 index 0000000000..450f789657 --- /dev/null +++ b/common/djangoapps/auth_exchange/tests/mixins.py @@ -0,0 +1,111 @@ +""" +Mixins to facilitate testing OAuth connections to Django-OAuth-Toolkit or +Django-OAuth2-Provider. +""" + +# pylint: disable=protected-access + +from unittest import skip, expectedFailure +from django.test.client import RequestFactory + +from lms.djangoapps.oauth_dispatch import adapters +from lms.djangoapps.oauth_dispatch.tests.constants import DUMMY_REDIRECT_URL + +from ..views import DOTAccessTokenExchangeView + + +class DOPAdapterMixin(object): + """ + Mixin to rewire existing tests to use django-oauth2-provider (DOP) backend + + Overwrites self.client_id, self.access_token, self.oauth2_adapter + """ + client_id = 'dop_test_client_id' + access_token = 'dop_test_access_token' + oauth2_adapter = adapters.DOPAdapter() + + def create_public_client(self, user, client_id=None): + """ + Create an oauth client application that is public. + """ + return self.oauth2_adapter.create_public_client( + name='Test Public Client', + user=user, + client_id=client_id, + redirect_uri=DUMMY_REDIRECT_URL, + ) + + def create_confidential_client(self, user, client_id=None): + """ + Create an oauth client application that is confidential. + """ + return self.oauth2_adapter.create_confidential_client( + name='Test Confidential Client', + user=user, + client_id=client_id, + redirect_uri=DUMMY_REDIRECT_URL, + ) + + def get_token_response_keys(self): + """ + Return the set of keys provided when requesting an access token + """ + return {'access_token', 'token_type', 'expires_in', 'scope'} + + +class DOTAdapterMixin(object): + """ + Mixin to rewire existing tests to use django-oauth-toolkit (DOT) backend + + Overwrites self.client_id, self.access_token, self.oauth2_adapter + """ + + client_id = 'dot_test_client_id' + access_token = 'dot_test_access_token' + oauth2_adapter = adapters.DOTAdapter() + + def create_public_client(self, user, client_id=None): + """ + Create an oauth client application that is public. + """ + return self.oauth2_adapter.create_public_client( + name='Test Public Application', + user=user, + client_id=client_id, + redirect_uri=DUMMY_REDIRECT_URL, + ) + + def create_confidential_client(self, user, client_id=None): + """ + Create an oauth client application that is confidential. + """ + return self.oauth2_adapter.create_confidential_client( + name='Test Confidential Application', + user=user, + client_id=client_id, + redirect_uri=DUMMY_REDIRECT_URL, + ) + + def get_token_response_keys(self): + """ + Return the set of keys provided when requesting an access token + """ + return {'access_token', 'refresh_token', 'token_type', 'expires_in', 'scope'} + + def test_get_method(self): + # Dispatch routes all get methods to DOP, so we test this on the view + request_factory = RequestFactory() + request = request_factory.get('/oauth2/exchange_access_token/') + request.session = {} + view = DOTAccessTokenExchangeView.as_view() + response = view(request, backend='facebook') + self.assertEqual(response.status_code, 400) + + @expectedFailure + def test_single_access_token(self): + # TODO: Single access tokens not supported yet for DOT (See MA-2122) + super(DOTAdapterMixin, self).test_single_access_token() + + @skip("Not supported yet (See MA-2123)") + def test_scopes(self): + super(DOTAdapterMixin, self).test_scopes() diff --git a/common/djangoapps/auth_exchange/tests/test_forms.py b/common/djangoapps/auth_exchange/tests/test_forms.py index ffeffb4e22..490628f868 100644 --- a/common/djangoapps/auth_exchange/tests/test_forms.py +++ b/common/djangoapps/auth_exchange/tests/test_forms.py @@ -12,10 +12,12 @@ import httpretty from provider import scope import social.apps.django_app.utils as social_utils -from auth_exchange.forms import AccessTokenExchangeForm -from auth_exchange.tests.utils import AccessTokenExchangeTestMixin from third_party_auth.tests.utils import ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle +from ..forms import AccessTokenExchangeForm +from .utils import AccessTokenExchangeTestMixin +from .mixins import DOPAdapterMixin, DOTAdapterMixin + class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin): """ @@ -31,7 +33,7 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin): self.request.backend = social_utils.load_backend(self.request.social_strategy, self.BACKEND, redirect_uri) def _assert_error(self, data, expected_error, expected_error_description): - form = AccessTokenExchangeForm(request=self.request, data=data) + form = AccessTokenExchangeForm(request=self.request, oauth2_adapter=self.oauth2_adapter, data=data) self.assertEqual( form.errors, {"error": expected_error, "error_description": expected_error_description} @@ -39,7 +41,7 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin): self.assertNotIn("partial_pipeline", self.request.session) def _assert_success(self, data, expected_scopes): - form = AccessTokenExchangeForm(request=self.request, data=data) + form = AccessTokenExchangeForm(request=self.request, oauth2_adapter=self.oauth2_adapter, data=data) self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data["user"], self.user) self.assertEqual(form.cleaned_data["client"], self.oauth_client) @@ -49,13 +51,15 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin): # This is necessary because cms does not implement third party auth @unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") @httpretty.activate -class AccessTokenExchangeFormTestFacebook( +class DOPAccessTokenExchangeFormTestFacebook( + DOPAdapterMixin, AccessTokenExchangeFormTest, ThirdPartyOAuthTestMixinFacebook, - TestCase + TestCase, ): """ - Tests for AccessTokenExchangeForm used with Facebook + Tests for AccessTokenExchangeForm used with Facebook, tested against + django-oauth2-provider (DOP). """ pass @@ -63,12 +67,46 @@ class AccessTokenExchangeFormTestFacebook( # This is necessary because cms does not implement third party auth @unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") @httpretty.activate -class AccessTokenExchangeFormTestGoogle( +class DOTAccessTokenExchangeFormTestFacebook( + DOTAdapterMixin, AccessTokenExchangeFormTest, - ThirdPartyOAuthTestMixinGoogle, - TestCase + ThirdPartyOAuthTestMixinFacebook, + TestCase, ): """ - Tests for AccessTokenExchangeForm used with Google + Tests for AccessTokenExchangeForm used with Facebook, tested against + django-oauth-toolkit (DOT). + """ + pass + + +# This is necessary because cms does not implement third party auth +@unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") +@httpretty.activate +class DOPAccessTokenExchangeFormTestGoogle( + DOPAdapterMixin, + AccessTokenExchangeFormTest, + ThirdPartyOAuthTestMixinGoogle, + TestCase, +): + """ + Tests for AccessTokenExchangeForm used with Google, tested against + django-oauth2-provider (DOP). + """ + pass + + +# This is necessary because cms does not implement third party auth +@unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") +@httpretty.activate +class DOTAccessTokenExchangeFormTestGoogle( + DOTAdapterMixin, + AccessTokenExchangeFormTest, + ThirdPartyOAuthTestMixinGoogle, + TestCase, +): + """ + Tests for AccessTokenExchangeForm used with Google, tested against + django-oauth-toolkit (DOT). """ pass diff --git a/common/djangoapps/auth_exchange/tests/test_views.py b/common/djangoapps/auth_exchange/tests/test_views.py index 5c0b7503f9..60c687960c 100644 --- a/common/djangoapps/auth_exchange/tests/test_views.py +++ b/common/djangoapps/auth_exchange/tests/test_views.py @@ -1,25 +1,30 @@ -# pylint: disable=no-member """ Tests for OAuth token exchange views """ + +# pylint: disable=no-member + from datetime import timedelta import json import mock import unittest +import ddt from django.conf import settings from django.core.urlresolvers import reverse from django.test import TestCase import httpretty import provider.constants -from provider import scope from provider.oauth2.models import AccessToken, Client +from rest_framework.test import APIClient -from auth_exchange.tests.utils import AccessTokenExchangeTestMixin from student.tests.factories import UserFactory from third_party_auth.tests.utils import ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle +from .mixins import DOPAdapterMixin, DOTAdapterMixin +from .utils import AccessTokenExchangeTestMixin +@ddt.ddt class AccessTokenExchangeViewTest(AccessTokenExchangeTestMixin): """ Mixin that defines test cases for AccessTokenExchangeView @@ -27,33 +32,34 @@ class AccessTokenExchangeViewTest(AccessTokenExchangeTestMixin): def setUp(self): super(AccessTokenExchangeViewTest, self).setUp() self.url = reverse("exchange_access_token", kwargs={"backend": self.BACKEND}) + self.csrf_client = APIClient(enforce_csrf_checks=True) def _assert_error(self, data, expected_error, expected_error_description): - response = self.client.post(self.url, data) + response = self.csrf_client.post(self.url, data) self.assertEqual(response.status_code, 400) self.assertEqual(response["Content-Type"], "application/json") self.assertEqual( json.loads(response.content), - {"error": expected_error, "error_description": expected_error_description} + {u"error": expected_error, u"error_description": expected_error_description} ) self.assertNotIn("partial_pipeline", self.client.session) def _assert_success(self, data, expected_scopes): - response = self.client.post(self.url, data) + response = self.csrf_client.post(self.url, data) self.assertEqual(response.status_code, 200) self.assertEqual(response["Content-Type"], "application/json") content = json.loads(response.content) - self.assertEqual(set(content.keys()), {"access_token", "token_type", "expires_in", "scope"}) + self.assertEqual(set(content.keys()), self.get_token_response_keys()) self.assertEqual(content["token_type"], "Bearer") self.assertLessEqual( timedelta(seconds=int(content["expires_in"])), provider.constants.EXPIRE_DELTA_PUBLIC ) - self.assertEqual(content["scope"], " ".join(expected_scopes)) - token = AccessToken.objects.get(token=content["access_token"]) + self.assertEqual(content["scope"], self.oauth2_adapter.normalize_scopes(expected_scopes)) + token = self.oauth2_adapter.get_access_token(token_string=content["access_token"]) self.assertEqual(token.user, self.user) - self.assertEqual(token.client, self.oauth_client) - self.assertEqual(scope.to_names(token.scope), expected_scopes) + self.assertEqual(self.oauth2_adapter.get_client_for_token(token), self.oauth_client) + self.assertEqual(self.oauth2_adapter.get_token_scope_names(token), expected_scopes) def test_single_access_token(self): def extract_token(response): @@ -64,16 +70,15 @@ class AccessTokenExchangeViewTest(AccessTokenExchangeTestMixin): self._setup_provider_response(success=True) for single_access_token in [True, False]: - with mock.patch( - "auth_exchange.views.constants.SINGLE_ACCESS_TOKEN", - single_access_token - ): + with mock.patch("auth_exchange.views.constants.SINGLE_ACCESS_TOKEN", single_access_token): first_response = self.client.post(self.url, self.data) second_response = self.client.post(self.url, self.data) - self.assertEqual( - extract_token(first_response) == extract_token(second_response), - single_access_token - ) + self.assertEqual(first_response.status_code, 200) + self.assertEqual(second_response.status_code, 200) + self.assertEqual( + extract_token(first_response) == extract_token(second_response), + single_access_token + ) def test_get_method(self): response = self.client.get(self.url, self.data) @@ -95,10 +100,11 @@ class AccessTokenExchangeViewTest(AccessTokenExchangeTestMixin): # This is necessary because cms does not implement third party auth @unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") @httpretty.activate -class AccessTokenExchangeViewTestFacebook( +class DOPAccessTokenExchangeViewTestFacebook( + DOPAdapterMixin, AccessTokenExchangeViewTest, ThirdPartyOAuthTestMixinFacebook, - TestCase + TestCase, ): """ Tests for AccessTokenExchangeView used with Facebook @@ -106,16 +112,48 @@ class AccessTokenExchangeViewTestFacebook( pass +@unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") +@httpretty.activate +class DOTAccessTokenExchangeViewTestFacebook( + DOTAdapterMixin, + AccessTokenExchangeViewTest, + ThirdPartyOAuthTestMixinFacebook, + TestCase, +): + """ + Rerun AccessTokenExchangeViewTestFacebook tests against DOT backend + """ + pass + + # This is necessary because cms does not implement third party auth @unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") @httpretty.activate -class AccessTokenExchangeViewTestGoogle( +class DOPAccessTokenExchangeViewTestGoogle( + DOPAdapterMixin, AccessTokenExchangeViewTest, ThirdPartyOAuthTestMixinGoogle, - TestCase + TestCase, ): """ - Tests for AccessTokenExchangeView used with Google + Tests for AccessTokenExchangeView used with Google using + django-oauth2-provider backend. + """ + pass + + +# This is necessary because cms does not implement third party auth +@unittest.skipUnless(settings.FEATURES.get("ENABLE_THIRD_PARTY_AUTH"), "third party auth not enabled") +@httpretty.activate +class DOTAccessTokenExchangeViewTestGoogle( + DOTAdapterMixin, + AccessTokenExchangeViewTest, + ThirdPartyOAuthTestMixinGoogle, + TestCase, +): + """ + Tests for AccessTokenExchangeView used with Google using + django-oauth-toolkit backend. """ pass diff --git a/common/djangoapps/auth_exchange/tests/utils.py b/common/djangoapps/auth_exchange/tests/utils.py index 60608f292f..2629557c08 100644 --- a/common/djangoapps/auth_exchange/tests/utils.py +++ b/common/djangoapps/auth_exchange/tests/utils.py @@ -1,9 +1,8 @@ """ Test utilities for OAuth access token exchange """ -import provider.constants -from social.apps.django_app.default.models import UserSocialAuth +from social.apps.django_app.default.models import UserSocialAuth from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin @@ -37,6 +36,12 @@ class AccessTokenExchangeTestMixin(ThirdPartyOAuthTestMixin): """ raise NotImplementedError() + def _create_client(self): + """ + Create an oauth2 client application using class defaults. + """ + return self.create_public_client(self.user, self.client_id) + def test_minimal(self): self._setup_provider_response(success=True) self._assert_success(self.data, expected_scopes=[]) @@ -61,12 +66,12 @@ class AccessTokenExchangeTestMixin(ThirdPartyOAuthTestMixin): ) def test_confidential_client(self): - self.oauth_client.client_type = provider.constants.CONFIDENTIAL - self.oauth_client.save() + self.data['client_id'] += '_confidential' + self.oauth_client = self.create_confidential_client(self.user, self.data['client_id']) self._assert_error( self.data, "invalid_client", - "test_client_id is not a public client" + "{}_confidential is not a public client".format(self.client_id), ) def test_inactive_user(self): diff --git a/common/djangoapps/auth_exchange/views.py b/common/djangoapps/auth_exchange/views.py index abd31de9ec..6669e489c5 100644 --- a/common/djangoapps/auth_exchange/views.py +++ b/common/djangoapps/auth_exchange/views.py @@ -1,4 +1,3 @@ -# pylint: disable=abstract-method """ Views to support exchange of authentication credentials. The following are currently implemented: @@ -7,36 +6,52 @@ The following are currently implemented: 2. LoginWithAccessTokenView: 1st party (open-edx) OAuth 2.0 access token -> session cookie """ + +# pylint: disable=abstract-method + from django.conf import settings from django.contrib.auth import login import django.contrib.auth as auth from django.http import HttpResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from edx_oauth2_provider.constants import SCOPE_VALUE_DICT +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views.base import TokenView as DOTAccessTokenView +from oauthlib.oauth2.rfc6749.tokens import BearerToken from provider import constants -from provider.oauth2.views import AccessTokenView as AccessTokenView +from provider.oauth2.views import AccessTokenView as DOPAccessTokenView from rest_framework import permissions +from rest_framework.response import Response from rest_framework.views import APIView import social.apps.django_app.utils as social_utils from auth_exchange.forms import AccessTokenExchangeForm +from lms.djangoapps.oauth_dispatch import adapters from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser -class AccessTokenExchangeView(AccessTokenView): +class AccessTokenExchangeBase(APIView): """ - View for token exchange from 3rd party OAuth access token to 1st party OAuth access token + View for token exchange from 3rd party OAuth access token to 1st party + OAuth access token. """ @method_decorator(csrf_exempt) @method_decorator(social_utils.strategy("social:complete")) def dispatch(self, *args, **kwargs): - return super(AccessTokenExchangeView, self).dispatch(*args, **kwargs) + return super(AccessTokenExchangeBase, self).dispatch(*args, **kwargs) def get(self, request, _backend): # pylint: disable=arguments-differ - return super(AccessTokenExchangeView, self).get(request) + """ + Pass through GET requests without the _backend + """ + return super(AccessTokenExchangeBase, self).get(request) def post(self, request, _backend): # pylint: disable=arguments-differ - form = AccessTokenExchangeForm(request=request, data=request.POST) + """ + Handle POST requests to get a first-party access token. + """ + form = AccessTokenExchangeForm(request=request, oauth2_adapter=self.oauth2_adapter, data=request.POST) # pylint: disable=no-member if not form.is_valid(): return self.error_response(form.errors) @@ -44,12 +59,89 @@ class AccessTokenExchangeView(AccessTokenView): scope = form.cleaned_data["scope"] client = form.cleaned_data["client"] + return self.exchange_access_token(request, user, scope, client) + + def exchange_access_token(self, request, user, scope, client): + """ + Exchange third party credentials for an edx access token, and return a + serialized access token response. + """ if constants.SINGLE_ACCESS_TOKEN: - edx_access_token = self.get_access_token(request, user, scope, client) + edx_access_token = self.get_access_token(request, user, scope, client) # pylint: disable=no-member else: edx_access_token = self.create_access_token(request, user, scope, client) + return self.access_token_response(edx_access_token) # pylint: disable=no-member - return self.access_token_response(edx_access_token) + +class DOPAccessTokenExchangeView(AccessTokenExchangeBase, DOPAccessTokenView): + """ + View for token exchange from 3rd party OAuth access token to 1st party + OAuth access token. Uses django-oauth2-provider (DOP) to manage access + tokens. + """ + + oauth2_adapter = adapters.DOPAdapter() + + +class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView): + """ + View for token exchange from 3rd party OAuth access token to 1st party + OAuth access token. Uses django-oauth-toolkit (DOT) to manage access + tokens. + """ + + oauth2_adapter = adapters.DOTAdapter() + + def get(self, request, _backend): + return Response(status=400, data={ + 'error': 'invalid_request', + 'error_description': 'Only POST requests allowed.', + }) + + def get_access_token(self, request, user, scope, client): + """ + TODO: MA-2122: Reusing access tokens is not yet supported for DOT. + Just return a new access token. + """ + return self.create_access_token(request, user, scope, client) + + def create_access_token(self, request, user, scope, client): + """ + Create and return a new access token. + """ + _days = 24 * 60 * 60 + token_generator = BearerToken( + expires_in=settings.OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS * _days, + request_validator=oauth2_settings.OAUTH2_VALIDATOR_CLASS(), + ) + self._populate_create_access_token_request(request, user, scope, client) + return token_generator.create_token(request, refresh_token=True) + + def access_token_response(self, token): + """ + Wrap an access token in an appropriate response + """ + return Response(data=token) + + def _populate_create_access_token_request(self, request, user, scope, client): + """ + django-oauth-toolkit expects certain non-standard attributes to + be present on the request object. This function modifies the + request object to match these expectations + """ + request.user = user + request.scopes = [SCOPE_VALUE_DICT[scope]] + request.client = client + request.state = None + request.refresh_token = None + request.extra_credentials = None + request.grant_type = client.authorization_grant_type + + def error_response(self, form_errors): + """ + Return an error response consisting of the errors in the form + """ + return Response(status=400, data=form_errors) class LoginWithAccessTokenView(APIView): diff --git a/common/djangoapps/third_party_auth/tests/utils.py b/common/djangoapps/third_party_auth/tests/utils.py index cce2edd59b..588d23dce9 100644 --- a/common/djangoapps/third_party_auth/tests/utils.py +++ b/common/djangoapps/third_party_auth/tests/utils.py @@ -22,23 +22,30 @@ class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin): USER_URL: The URL of the endpoint that the backend retrieves user data from UID_FIELD: The field in the user data that the backend uses as the user id """ + social_uid = "test_social_uid" + access_token = "test_access_token" + client_id = "test_client_id" + def setUp(self, create_user=True): super(ThirdPartyOAuthTestMixin, self).setUp() - self.social_uid = "test_social_uid" - self.access_token = "test_access_token" - self.client_id = "test_client_id" - self.oauth_client = Client.objects.create( - client_id=self.client_id, - client_type=PUBLIC - ) if create_user: self.user = UserFactory() UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid) + self.oauth_client = self._create_client() if self.BACKEND == 'google-oauth2': self.configure_google_provider(enabled=True) elif self.BACKEND == 'facebook': self.configure_facebook_provider(enabled=True) + def _create_client(self): + """ + Create an OAuth2 client application + """ + return Client.objects.create( + client_id=self.client_id, + client_type=PUBLIC, + ) + def _setup_provider_response(self, success=False, email=''): """ Register a mock response for the third party user information endpoint; @@ -65,7 +72,7 @@ class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin): self.USER_URL, body=body, status=status, - content_type="application/json" + content_type="application/json", ) diff --git a/common/test/db_cache/lettuce.db b/common/test/db_cache/lettuce.db index 9a9cf041058658e49a9c0102fee21f7b73347ac9..c90f206e8615a6d655cfb7187591eaab8875e7d7 100644 GIT binary patch delta 47636 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

9;mMttObHz6)@8$e-8ze~ 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 Iqtp2R0bIWiv;Y7A delta 44290 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 NP~;ybbJVf8@PDS9I!pin diff --git a/lms/djangoapps/oauth_dispatch/__init__.py b/lms/djangoapps/oauth_dispatch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/oauth_dispatch/adapters/__init__.py b/lms/djangoapps/oauth_dispatch/adapters/__init__.py new file mode 100644 index 0000000000..1f6227b50a --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/adapters/__init__.py @@ -0,0 +1,7 @@ +""" +Adapters to provide a common interface to django-oauth2-provider (DOP) and +django-oauth-toolkit (DOT). +""" + +from .dop import DOPAdapter +from .dot import DOTAdapter diff --git a/lms/djangoapps/oauth_dispatch/adapters/dop.py b/lms/djangoapps/oauth_dispatch/adapters/dop.py new file mode 100644 index 0000000000..b12994b96f --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/adapters/dop.py @@ -0,0 +1,70 @@ +""" +Adapter to isolate django-oauth2-provider dependencies +""" + +from provider.oauth2 import models +from provider import constants, scope + + +class DOPAdapter(object): + """ + Standard interface for working with django-oauth2-provider + """ + + backend = object() + + def create_confidential_client(self, name, user, redirect_uri, client_id=None): + """ + Create an oauth client application that is confidential. + """ + return models.Client.objects.create( + name=name, + user=user, + client_id=client_id, + redirect_uri=redirect_uri, + client_type=constants.CONFIDENTIAL, + ) + + def create_public_client(self, name, user, redirect_uri, client_id=None): + """ + Create an oauth client application that is public. + """ + return models.Client.objects.create( + name=name, + user=user, + client_id=client_id, + redirect_uri=redirect_uri, + client_type=constants.PUBLIC, + ) + + def get_client(self, **filters): + """ + Get the oauth client application with the specified filters. + + Wraps django's queryset.get() method. + """ + return models.Client.objects.get(**filters) + + def get_client_for_token(self, token): + """ + Given an AccessToken object, return the associated client application. + """ + return token.client + + def get_access_token(self, token_string): + """ + Given a token string, return the matching AccessToken object. + """ + return models.AccessToken.objects.get(token=token_string) + + def normalize_scopes(self, scopes): + """ + Given a list of scopes, return a space-separated list of those scopes. + """ + return ' '.join(scopes) + + def get_token_scope_names(self, token): + """ + Given an access token object, return its scopes. + """ + return scope.to_names(token.scope) diff --git a/lms/djangoapps/oauth_dispatch/adapters/dot.py b/lms/djangoapps/oauth_dispatch/adapters/dot.py new file mode 100644 index 0000000000..84dcb7ece4 --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/adapters/dot.py @@ -0,0 +1,73 @@ +""" +Adapter to isolate django-oauth-toolkit dependencies +""" + +from oauth2_provider import models + + +class DOTAdapter(object): + """ + Standard interface for working with django-oauth-toolkit + """ + + backend = object() + + def create_confidential_client(self, name, user, redirect_uri, client_id=None): + """ + Create an oauth client application that is confidential. + """ + return models.Application.objects.create( + name=name, + user=user, + client_id=client_id, + client_type=models.Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE, + redirect_uris=redirect_uri, + ) + + def create_public_client(self, name, user, redirect_uri, client_id=None): + """ + Create an oauth client application that is public. + """ + return models.Application.objects.create( + name=name, + user=user, + client_id=client_id, + client_type=models.Application.CLIENT_PUBLIC, + authorization_grant_type=models.Application.GRANT_PASSWORD, + redirect_uris=redirect_uri, + ) + + def get_client(self, **filters): + """ + Get the oauth client application with the specified filters. + + Wraps django's queryset.get() method. + """ + return models.Application.objects.get(**filters) + + def get_client_for_token(self, token): + """ + Given an AccessToken object, return the associated client application. + """ + return token.application + + def get_access_token(self, token_string): + """ + Given a token string, return the matching AccessToken object. + """ + return models.AccessToken.objects.get(token=token_string) + + def normalize_scopes(self, scopes): + """ + Given a list of scopes, return a space-separated list of those scopes. + """ + if not scopes: + scopes = ['default'] + return ' '.join(scopes) + + def get_token_scope_names(self, token): + """ + Given an access token object, return its scopes. + """ + return list(token.scopes) diff --git a/lms/djangoapps/oauth_dispatch/tests/__init__.py b/lms/djangoapps/oauth_dispatch/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/oauth_dispatch/tests/constants.py b/lms/djangoapps/oauth_dispatch/tests/constants.py new file mode 100644 index 0000000000..b38868bcfb --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/tests/constants.py @@ -0,0 +1,5 @@ +""" +Constants for testing purposes +""" + +DUMMY_REDIRECT_URL = u'https://example.edx/redirect' diff --git a/lms/djangoapps/oauth_dispatch/tests/mixins.py b/lms/djangoapps/oauth_dispatch/tests/mixins.py new file mode 100644 index 0000000000..8b16c53fc2 --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/tests/mixins.py @@ -0,0 +1,3 @@ +""" +OAuth Dispatch test mixins +""" diff --git a/lms/djangoapps/oauth_dispatch/tests/test_dop_adapter.py b/lms/djangoapps/oauth_dispatch/tests/test_dop_adapter.py new file mode 100644 index 0000000000..6dfe9ac9d4 --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/tests/test_dop_adapter.py @@ -0,0 +1,77 @@ +""" +Tests for DOP Adapter +""" + +from datetime import timedelta + +import ddt +from django.test import TestCase +from django.utils.timezone import now +from provider.oauth2 import models +from provider import constants + +from student.tests.factories import UserFactory + +from ..adapters import DOPAdapter +from .constants import DUMMY_REDIRECT_URL + + +@ddt.ddt +class DOPAdapterTestCase(TestCase): + """ + Test class for DOPAdapter. + """ + + adapter = DOPAdapter() + + def setUp(self): + super(DOPAdapterTestCase, self).setUp() + self.user = UserFactory() + self.public_client = self.adapter.create_public_client( + name='public client', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='public-client-id', + ) + self.confidential_client = self.adapter.create_confidential_client( + name='confidential client', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='confidential-client-id', + ) + + @ddt.data( + ('confidential', constants.CONFIDENTIAL), + ('public', constants.PUBLIC), + ) + @ddt.unpack + def test_create_client(self, client_name, client_type): + client = getattr(self, '{}_client'.format(client_name)) + self.assertIsInstance(client, models.Client) + self.assertEqual(client.client_id, '{}-client-id'.format(client_name)) + self.assertEqual(client.client_type, client_type) + + def test_get_client(self): + client = self.adapter.get_client(client_type=constants.CONFIDENTIAL) + self.assertIsInstance(client, models.Client) + self.assertEqual(client.client_type, constants.CONFIDENTIAL) + + def test_get_client_not_found(self): + with self.assertRaises(models.Client.DoesNotExist): + self.adapter.get_client(client_id='not-found') + + def test_get_client_for_token(self): + token = models.AccessToken( + user=self.user, + client=self.public_client, + ) + self.assertEqual(self.adapter.get_client_for_token(token), self.public_client) + + def test_get_access_token(self): + token = models.AccessToken.objects.create( + token='token-id', + client=self.public_client, + user=self.user, + expires=now() + timedelta(days=30), + ) + self.assertEqual(self.adapter.get_access_token(token_string='token-id'), token) diff --git a/lms/djangoapps/oauth_dispatch/tests/test_dot_adapter.py b/lms/djangoapps/oauth_dispatch/tests/test_dot_adapter.py new file mode 100644 index 0000000000..a623e8cc6d --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/tests/test_dot_adapter.py @@ -0,0 +1,76 @@ +""" +Tests for DOT Adapter +""" + +from datetime import timedelta + +import ddt +from django.test import TestCase +from django.utils.timezone import now +from oauth2_provider import models + +from student.tests.factories import UserFactory + +from ..adapters import DOTAdapter +from .constants import DUMMY_REDIRECT_URL + + +@ddt.ddt +class DOTAdapterTestCase(TestCase): + """ + Test class for DOTAdapter. + """ + + adapter = DOTAdapter() + + def setUp(self): + super(DOTAdapterTestCase, self).setUp() + self.user = UserFactory() + self.public_client = self.adapter.create_public_client( + name='public app', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='public-client-id', + ) + self.confidential_client = self.adapter.create_confidential_client( + name='confidential app', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='confidential-client-id', + ) + + @ddt.data( + ('confidential', models.Application.CLIENT_CONFIDENTIAL), + ('public', models.Application.CLIENT_PUBLIC), + ) + @ddt.unpack + def test_create_client(self, client_name, client_type): + client = getattr(self, '{}_client'.format(client_name)) + self.assertIsInstance(client, models.Application) + self.assertEqual(client.client_id, '{}-client-id'.format(client_name)) + self.assertEqual(client.client_type, client_type) + + def test_get_client(self): + client = self.adapter.get_client(client_type=models.Application.CLIENT_CONFIDENTIAL) + self.assertIsInstance(client, models.Application) + self.assertEqual(client.client_type, models.Application.CLIENT_CONFIDENTIAL) + + def test_get_client_not_found(self): + with self.assertRaises(models.Application.DoesNotExist): + self.adapter.get_client(client_id='not-found') + + def test_get_client_for_token(self): + token = models.AccessToken( + user=self.user, + application=self.public_client, + ) + self.assertEqual(self.adapter.get_client_for_token(token), self.public_client) + + def test_get_access_token(self): + token = models.AccessToken.objects.create( + token='token-id', + application=self.public_client, + user=self.user, + expires=now() + timedelta(days=30), + ) + self.assertEqual(self.adapter.get_access_token(token_string='token-id'), token) diff --git a/lms/djangoapps/oauth_dispatch/tests/test_views.py b/lms/djangoapps/oauth_dispatch/tests/test_views.py new file mode 100644 index 0000000000..b8fb7435ff --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/tests/test_views.py @@ -0,0 +1,251 @@ +""" +Tests for Blocks Views +""" + +import json + +import ddt +from django.test import RequestFactory, TestCase +from django.core.urlresolvers import reverse +import httpretty + +from student.tests.factories import UserFactory +from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle + +from .. import adapters +from .. import views +from .constants import DUMMY_REDIRECT_URL + + +class _DispatchingViewTestCase(TestCase): + """ + Base class for tests that exercise DispatchingViews. + """ + dop_adapter = adapters.DOPAdapter() + dot_adapter = adapters.DOTAdapter() + + view_class = None + url = None + + def setUp(self): + super(_DispatchingViewTestCase, self).setUp() + self.user = UserFactory() + self.dot_app = self.dot_adapter.create_public_client( + name='test dot application', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='dot-app-client-id', + ) + self.dop_client = self.dop_adapter.create_public_client( + name='test dop client', + user=self.user, + redirect_uri=DUMMY_REDIRECT_URL, + client_id='dop-app-client-id', + ) + + def _post_request(self, user, client): + """ + Call the view with a POST request objectwith the appropriate format, + returning the response object. + """ + return self.client.post(self.url, self._post_body(user, client)) + + def _post_body(self, user, client): + """ + Return a dictionary to be used as the body of the POST request + """ + raise NotImplementedError() + + +@ddt.ddt +class TestAccessTokenView(_DispatchingViewTestCase): + """ + Test class for AccessTokenView + """ + + view_class = views.AccessTokenView + url = reverse('access_token') + + def _post_body(self, user, client): + """ + Return a dictionary to be used as the body of the POST request + """ + return { + 'client_id': client.client_id, + 'grant_type': 'password', + 'username': user.username, + 'password': 'test', + } + + @ddt.data('dop_client', 'dot_app') + def test_access_token_fields(self, client_attr): + client = getattr(self, client_attr) + response = self._post_request(self.user, client) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertIn('access_token', data) + self.assertIn('expires_in', data) + self.assertIn('scope', data) + self.assertIn('token_type', data) + + def test_dot_access_token_provides_refresh_token(self): + response = self._post_request(self.user, self.dot_app) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertIn('refresh_token', data) + + def test_dop_public_client_access_token(self): + response = self._post_request(self.user, self.dop_client) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertNotIn('refresh_token', data) + + +@ddt.ddt +@httpretty.activate +class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAuthTestMixin, _DispatchingViewTestCase): + """ + Test class for AccessTokenExchangeView + """ + + view_class = views.AccessTokenExchangeView + url = reverse('exchange_access_token', kwargs={'backend': 'google-oauth2'}) + + def _post_body(self, user, client): + return { + 'client_id': client.client_id, + 'access_token': self.access_token, + } + + @ddt.data('dop_client', 'dot_app') + def test_access_token_exchange_calls_dispatched_view(self, client_attr): + client = getattr(self, client_attr) + self.oauth_client = client + self._setup_provider_response(success=True) + response = self._post_request(self.user, client) + self.assertEqual(response.status_code, 200) + + +@ddt.ddt +class TestAuthorizationView(TestCase): + """ + Test class for AuthorizationView + """ + + dop_adapter = adapters.DOPAdapter() + + def setUp(self): + super(TestAuthorizationView, self).setUp() + self.user = UserFactory() + self.dop_client = self._create_confidential_client(user=self.user, client_id='dop-app-client-id') + + def _create_confidential_client(self, user, client_id): + """ + Create a confidential client suitable for testing purposes. + """ + return self.dop_adapter.create_confidential_client( + name='test_app', + user=user, + client_id=client_id, + redirect_uri=DUMMY_REDIRECT_URL + ) + + def test_authorization_view(self): + self.client.login(username=self.user.username, password='test') + response = self.client.post( + '/oauth2/authorize/', + { + 'client_id': self.dop_client.client_id, # TODO: DOT is not yet supported (MA-2124) + 'response_type': 'code', + 'state': 'random_state_string', + 'redirect_uri': DUMMY_REDIRECT_URL, + }, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + context = response.context # pylint: disable=no-member + self.assertIn('form', context) + self.assertIsNone(context['form']['authorize'].value()) + + self.assertIn('oauth_data', context) + oauth_data = context['oauth_data'] + self.assertEqual(oauth_data['redirect_uri'], DUMMY_REDIRECT_URL) + self.assertEqual(oauth_data['state'], 'random_state_string') + + +class TestViewDispatch(TestCase): + """ + Test that the DispatchingView dispatches the right way. + """ + + dop_adapter = adapters.DOPAdapter() + dot_adapter = adapters.DOTAdapter() + + def setUp(self): + super(TestViewDispatch, self).setUp() + self.user = UserFactory() + self.view = views._DispatchingView() # pylint: disable=protected-access + self.dop_adapter.create_public_client( + name='', + user=self.user, + client_id='dop-id', + redirect_uri=DUMMY_REDIRECT_URL + ) + self.dot_adapter.create_public_client( + name='', + user=self.user, + client_id='dot-id', + redirect_uri=DUMMY_REDIRECT_URL + ) + + def assert_is_view(self, view_candidate): + """ + Assert that a given object is a view. That is, it is callable, and + takes a request argument. Note: while technically, the request argument + could take any name, this assertion requires the argument to be named + `request`. This is good practice. You should do it anyway. + """ + _msg_base = u'{view} is not a view: {reason}' + msg_not_callable = _msg_base.format(view=view_candidate, reason=u'it is not callable') + msg_no_request = _msg_base.format(view=view_candidate, reason=u'it has no request argument') + self.assertTrue(hasattr(view_candidate, '__call__'), msg_not_callable) + args = view_candidate.func_code.co_varnames + self.assertTrue(args, msg_no_request) + self.assertEqual(args[0], 'request') + + def _get_request(self, client_id): + """ + Return a request with the specified client_id in the body + """ + return RequestFactory().post('/', {'client_id': client_id}) + + def test_dispatching_to_dot(self): + request = self._get_request('dot-id') + self.assertEqual(self.view.select_backend(request), self.dot_adapter.backend) + + def test_dispatching_to_dop(self): + request = self._get_request('dop-id') + self.assertEqual(self.view.select_backend(request), self.dop_adapter.backend) + + def test_dispatching_with_no_client(self): + request = self._get_request(None) + self.assertEqual(self.view.select_backend(request), self.dop_adapter.backend) + + def test_dispatching_with_invalid_client(self): + request = self._get_request('abcesdfljh') + self.assertEqual(self.view.select_backend(request), self.dop_adapter.backend) + + def test_get_view_for_dot(self): + view_object = views.AccessTokenView() + self.assert_is_view(view_object.get_view_for_backend(self.dot_adapter.backend)) + + def test_get_view_for_dop(self): + view_object = views.AccessTokenView() + self.assert_is_view(view_object.get_view_for_backend(self.dop_adapter.backend)) + + def test_get_view_for_no_backend(self): + view_object = views.AccessTokenView() + self.assertRaises(KeyError, view_object.get_view_for_backend, None) diff --git a/lms/djangoapps/oauth_dispatch/urls.py b/lms/djangoapps/oauth_dispatch/urls.py new file mode 100644 index 0000000000..59e9068bdb --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/urls.py @@ -0,0 +1,25 @@ +""" +OAuth2 wrapper urls +""" + +from django.conf import settings +from django.conf.urls import patterns, url +from django.views.decorators.csrf import csrf_exempt + +from . import views + + +urlpatterns = patterns( + '', + # TODO: authorize/ URL not yet supported for DOT (MA-2124) + url(r'^access_token/?$', csrf_exempt(views.AccessTokenView.as_view()), name='access_token'), +) + +if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): + urlpatterns += ( + url( + r'^exchange_access_token/(?P[^/]+)/$', + csrf_exempt(views.AccessTokenExchangeView.as_view()), + name='exchange_access_token', + ), + ) diff --git a/lms/djangoapps/oauth_dispatch/views.py b/lms/djangoapps/oauth_dispatch/views.py new file mode 100644 index 0000000000..22652f127b --- /dev/null +++ b/lms/djangoapps/oauth_dispatch/views.py @@ -0,0 +1,89 @@ +""" +Views that dispatch processing of OAuth requests to django-oauth2-provider or +django-oauth-toolkit as appropriate. +""" + +from __future__ import unicode_literals + +from django.views.generic import View +from edx_oauth2_provider import views as dop_views # django-oauth2-provider views +from oauth2_provider import models as dot_models, views as dot_views # django-oauth-toolkit + +from auth_exchange import views as auth_exchange_views + +from . import adapters + + +class _DispatchingView(View): + """ + Base class that route views to the appropriate provider view. The default + behavior routes based on client_id, but this can be overridden by redefining + `select_backend()` if particular views need different behavior. + """ + # pylint: disable=no-member + + dot_adapter = adapters.DOTAdapter() + dop_adapter = adapters.DOPAdapter() + + def dispatch(self, request, *args, **kwargs): + """ + Dispatch the request to the selected backend's view. + """ + backend = self.select_backend(request) + view = self.get_view_for_backend(backend) + return view(request, *args, **kwargs) + + def select_backend(self, request): + """ + Given a request that specifies an oauth `client_id`, return the adapter + for the appropriate OAuth handling library. If the client_id is found + in a django-oauth-toolkit (DOT) Application, use the DOT adapter, + otherwise use the django-oauth2-provider (DOP) adapter, and allow the + calls to fail normally if the client does not exist. + """ + + if dot_models.Application.objects.filter(client_id=self._get_client_id(request)).exists(): + return self.dot_adapter.backend + else: + return self.dop_adapter.backend + + def get_view_for_backend(self, backend): + """ + Return the appropriate view from the requested backend. + """ + if backend == self.dot_adapter.backend: + return self.dot_view.as_view() + elif backend == self.dop_adapter.backend: + return self.dop_view.as_view() + else: + raise KeyError('Failed to dispatch view. Invalid backend {}'.format(backend)) + + def _get_client_id(self, request): + """ + Return the client_id from the provided request + """ + return request.POST.get('client_id') + + +class AccessTokenView(_DispatchingView): + """ + Handle access token requests. + """ + dot_view = dot_views.TokenView + dop_view = dop_views.AccessTokenView + + +class AuthorizationView(_DispatchingView): + """ + Part of the authorization flow. + """ + dop_view = dop_views.Capture + dot_view = dot_views.AuthorizationView + + +class AccessTokenExchangeView(_DispatchingView): + """ + Exchange a third party auth token. + """ + dop_view = auth_exchange_views.DOPAccessTokenExchangeView + dot_view = auth_exchange_views.DOTAccessTokenExchangeView diff --git a/lms/djangoapps/support/tests/test_programs.py b/lms/djangoapps/support/tests/test_programs.py index 9d2356e40f..2bad288ddf 100644 --- a/lms/djangoapps/support/tests/test_programs.py +++ b/lms/djangoapps/support/tests/test_programs.py @@ -2,7 +2,7 @@ from django.core.urlresolvers import reverse from django.test import TestCase import mock -from oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory +from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from student.tests.factories import UserFactory diff --git a/lms/envs/common.py b/lms/envs/common.py index bcb6bb156a..9b1c85f206 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1834,11 +1834,14 @@ INSTALLED_APPS = ( 'external_auth', 'django_openid_auth', - # OAuth2 Provider + # django-oauth2-provider (deprecated) 'provider', 'provider.oauth2', 'edx_oauth2_provider', + # django-oauth-toolkit + 'oauth2_provider', + 'third_party_auth', # We don't use this directly (since we use OAuth2), but we need to install it anyway. diff --git a/lms/urls.py b/lms/urls.py index 964f7a495c..18c5cb80c4 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -820,10 +820,19 @@ if settings.FEATURES.get('AUTH_USE_OPENID_PROVIDER'): if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'): urlpatterns += ( + # These URLs dispatch to django-oauth-toolkit or django-oauth2-provider as appropriate. + # Developers should use these routes, to maintain compatibility for existing client code + url(r'^oauth2/', include('lms.djangoapps.oauth_dispatch.urls')), + # These URLs contain the django-oauth2-provider default behavior. It exists to provide + # URLs for django-oauth2-provider to call using reverse() with the oauth2 namespace, and + # also to maintain support for views that have not yet been wrapped in dispatch views. url(r'^oauth2/', include('edx_oauth2_provider.urls', namespace='oauth2')), + # The /_o/ prefix exists to provide a target for code in django-oauth-toolkit that + # uses reverse() with the 'oauth2_provider' namespace. Developers should not access these + # views directly, but should rather use the wrapped views at /oauth2/ + url(r'^_o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ) - if settings.FEATURES.get('ENABLE_LMS_MIGRATION'): urlpatterns += ( url(r'^migrate/modules$', 'lms_migration.migrate.manage_modulestores'), @@ -888,14 +897,6 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): # OAuth token exchange if settings.FEATURES.get('ENABLE_OAUTH2_PROVIDER'): - if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): - urlpatterns += ( - url( - r'^oauth2/exchange_access_token/(?P[^/]+)/$', - auth_exchange.views.AccessTokenExchangeView.as_view(), - name="exchange_access_token" - ), - ) urlpatterns += ( url( r'^oauth2/login/$', diff --git a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py index 93cf9a5370..397bd593c1 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py @@ -12,8 +12,8 @@ from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.test import override_settings, TestCase from edx_rest_api_client.client import EdxRestApiClient - from edx_oauth2_provider.tests.factories import ClientFactory + from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.programs.tasks.v1 import tasks diff --git a/openedx/core/lib/api/authentication.py b/openedx/core/lib/api/authentication.py index 8e773e2f84..95a296d5b4 100644 --- a/openedx/core/lib/api/authentication.py +++ b/openedx/core/lib/api/authentication.py @@ -2,10 +2,13 @@ import logging +import django.utils.timezone from rest_framework.authentication import SessionAuthentication from rest_framework import exceptions as drf_exceptions from rest_framework_oauth.authentication import OAuth2Authentication -from rest_framework_oauth.compat import oauth2_provider, provider_now + +from provider.oauth2 import models as dop_models +from oauth2_provider import models as dot_models from openedx.core.lib.api.exceptions import AuthenticationFailed @@ -114,21 +117,44 @@ class OAuth2AuthenticationAllowInactiveUser(OAuth2Authentication): def authenticate_credentials(self, request, access_token): """ Authenticate the request, given the access token. - Overrides base class implementation to discard failure if user is inactive. + + Overrides base class implementation to discard failure if user is + inactive. """ - token_query = oauth2_provider.oauth2.models.AccessToken.objects.select_related('user') - token = token_query.filter(token=access_token).first() + + token = self.get_access_token(access_token) if not token: raise AuthenticationFailed({ u'error_code': OAUTH2_TOKEN_ERROR_NONEXISTENT, u'developer_message': u'The provided access token does not match any valid tokens.' }) - # provider_now switches to timezone aware datetime when - # the oauth2_provider version supports it. - elif token.expires < provider_now(): + elif token.expires < django.utils.timezone.now(): raise AuthenticationFailed({ u'error_code': OAUTH2_TOKEN_ERROR_EXPIRED, u'developer_message': u'The provided access token has expired and is no longer valid.', }) else: return token.user, token + + def get_access_token(self, access_token): + """ + Return a valid access token that exists in one of our OAuth2 libraries, + or None if no matching token is found. + """ + return self._get_dot_token(access_token) or self._get_dop_token(access_token) + + def _get_dop_token(self, access_token): + """ + Return a valid access token stored by django-oauth2-provider (DOP), or + None if no matching token is found. + """ + token_query = dop_models.AccessToken.objects.select_related('user') + return token_query.filter(token=access_token).first() + + def _get_dot_token(self, access_token): + """ + Return a valid access token stored by django-oauth-toolkit (DOT), or + None if no matching token is found. + """ + token_query = dot_models.AccessToken.objects.select_related('user') + return token_query.filter(token=access_token).first() diff --git a/openedx/core/lib/api/tests/test_authentication.py b/openedx/core/lib/api/tests/test_authentication.py index ea572b841b..ab3f077a27 100644 --- a/openedx/core/lib/api/tests/test_authentication.py +++ b/openedx/core/lib/api/tests/test_authentication.py @@ -19,6 +19,7 @@ from django.utils import unittest from django.utils.http import urlencode from mock import patch from nose.plugins.attrib import attr +from oauth2_provider import models as dot_models from rest_framework import exceptions from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -28,6 +29,7 @@ from rest_framework.test import APIRequestFactory, APIClient from rest_framework.views import APIView from rest_framework_jwt.settings import api_settings +from lms.djangoapps.oauth_dispatch import adapters from openedx.core.lib.api import authentication from openedx.core.lib.api.tests.mixins import JwtMixin from provider import constants, scope @@ -84,6 +86,8 @@ class OAuth2Tests(TestCase): def setUp(self): super(OAuth2Tests, self).setUp() + self.dop_adapter = adapters.DOPAdapter() + self.dot_adapter = adapters.DOTAdapter() self.csrf_client = APIClient(enforce_csrf_checks=True) self.username = 'john' self.email = 'lennon@thebeatles.com' @@ -95,24 +99,35 @@ class OAuth2Tests(TestCase): self.ACCESS_TOKEN = 'access_token' # pylint: disable=invalid-name self.REFRESH_TOKEN = 'refresh_token' # pylint: disable=invalid-name - self.oauth2_client = oauth2_provider.oauth2.models.Client.objects.create( - client_id=self.CLIENT_ID, - client_secret=self.CLIENT_SECRET, - redirect_uri='', - client_type=0, + self.dop_oauth2_client = self.dop_adapter.create_public_client( name='example', - user=None, + user=self.user, + client_id=self.CLIENT_ID, + redirect_uri='https://example.edx/redirect', ) self.access_token = oauth2_provider.oauth2.models.AccessToken.objects.create( token=self.ACCESS_TOKEN, - client=self.oauth2_client, + client=self.dop_oauth2_client, user=self.user, ) self.refresh_token = oauth2_provider.oauth2.models.RefreshToken.objects.create( user=self.user, access_token=self.access_token, - client=self.oauth2_client + client=self.dop_oauth2_client, + ) + + self.dot_oauth2_client = self.dot_adapter.create_public_client( + name='example', + user=self.user, + client_id='dot-client-id', + redirect_uri='https://example.edx/redirect', + ) + self.dot_access_token = dot_models.AccessToken.objects.create( + user=self.user, + token='dot-access-token', + application=self.dot_oauth2_client, + expires=datetime.now() + timedelta(days=30), ) # This is the a change we've made from the django-rest-framework-oauth version @@ -182,6 +197,10 @@ class OAuth2Tests(TestCase): response = self.get_with_bearer_token('/oauth2-test/') self.assertEqual(response.status_code, status.HTTP_200_OK) + def test_get_form_passing_auth_with_dot(self): + response = self.get_with_bearer_token('/oauth2-test/', token=self.dot_access_token.token) + self.assertEqual(response.status_code, status.HTTP_200_OK) + @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_passing_auth_url_transport(self): """Ensure GETing form over OAuth with correct client credentials in form data succeed""" diff --git a/pavelib/prereqs.py b/pavelib/prereqs.py index da314158dd..6df3eb31f3 100644 --- a/pavelib/prereqs.py +++ b/pavelib/prereqs.py @@ -163,6 +163,7 @@ PACKAGES_TO_UNINSTALL = [ "edxval", # Because it was bork-installed somehow. "django-storages", "django-oauth2-provider", # Because now it's called edx-django-oauth2-provider. + "edx-oauth2-provider", # Because it moved from github to pypi ] @@ -203,7 +204,6 @@ def uninstall_python_packages(): # Uninstall the pacakge sh("pip uninstall --disable-pip-version-check -y {}".format(package_name)) uninstalled = True - if not uninstalled: break else: diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index eb3d248b0a..d3a60a81aa 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -23,6 +23,7 @@ django-mako==0.1.5pre django-model-utils==2.3.1 django-mptt==0.7.4 django-oauth-plus==2.2.8 +django-oauth-toolkit==0.10.0 django-sekizai==0.8.2 django-ses==0.7.0 django-simple-history==1.6.3 @@ -35,9 +36,9 @@ git+https://github.com/edx/django-rest-framework.git@3c72cb5ee5baebc432894737119 django==1.8.11 djangorestframework-jwt==1.7.2 djangorestframework-oauth==1.1.0 -edx-django-oauth2-provider==0.5.0 edx-lint==0.4.3 -edx-oauth2-provider==0.5.9 +edx-django-oauth2-provider==1.0.1 +edx-oauth2-provider==1.0.0 edx-opaque-keys==0.2.1 edx-organizations==0.4.0 edx-rest-api-client==1.2.1