Removing DOP from auth_exchange (#23187)

-  This PR removes all imports from provider by either bringing them into edx-platform or finding dot replacement. Removing tests that tested dop parts of code. 

-  Skipping some tests and removing dop

The tests are difficult to fix due to its entanglement with dop use in third_party_auth.
These tests should be restarted once dop has been removed from third_party_auth and its tests.

-  set ENABLE_DOP_ADAPTER = False for devstack
This commit is contained in:
Manjinder Singh
2020-02-26 10:21:27 -05:00
committed by GitHub
parent 45c97c789d
commit e9e584b28b
9 changed files with 143 additions and 127 deletions

View File

@@ -137,6 +137,7 @@ REQUIRE_DEBUG = DEBUG
########################### OAUTH2 #################################
OAUTH_OIDC_ISSUER = 'http://127.0.0.1:8000/oauth2'
ENABLE_DOP_ADAPTER = False
# pylint: disable=unicode-format-string

View File

@@ -143,6 +143,7 @@ FEATURES['ENABLE_OAUTH2_PROVIDER'] = True
OAUTH_OIDC_ISSUER = 'http://127.0.0.1:8000/oauth2'
FEATURES['ENABLE_MOBILE_REST_API'] = True
FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
ENABLE_DOP_ADAPTER = False
########################## SECURITY #######################
FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False

View File

@@ -2,15 +2,13 @@
Forms to support third-party to first-party OAuth 2.0 access token exchange
"""
import provider.constants
from django import forms
from django.contrib.auth.models import User
from django.forms import CharField
from edx_oauth2_provider.constants import SCOPE_NAMES
from django.conf import settings
from django.utils.encoding import smart_text
from django.utils.translation import ugettext as _
from oauth2_provider.models import Application
from provider.forms import OAuthForm, OAuthValidationError
from provider.oauth2.forms import ScopeChoiceField, ScopeMixin
from provider.oauth2.models import Client
from requests import HTTPError
from social_core.backends import oauth as social_oauth
from social_core.exceptions import AuthException
@@ -18,10 +16,73 @@ from social_core.exceptions import AuthException
from third_party_auth import pipeline
class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
class OAuthValidationError(Exception):
"""
Exception to throw inside :class:`AccessTokenExchangeForm` if any OAuth2 related errors
are encountered such as invalid grant type, invalid client, etc.
:attr:`OAuthValidationError` expects a dictionary outlining the OAuth error
as its first argument when instantiating.
:example:
::
class GrantValidationForm(AccessTokenExchangeForm):
grant_type = forms.CharField()
def clean_grant(self):
if not self.cleaned_data.get('grant_type') == 'code':
raise OAuthValidationError({
'error': 'invalid_grant',
'error_description': "%s is not a valid grant type" % (
self.cleaned_data.get('grant_type'))
})
"""
class ScopeChoiceField(forms.ChoiceField):
"""
Custom form field that seperates values on space
"""
widget = forms.SelectMultiple
def to_python(self, value):
if not value:
return []
# value may come in as a string.
# try to parse and
# ultimately return an empty list if nothing remains -- this will
# eventually raise an `OAuthValidationError` in `validate` where
# it should be anyways.
if not isinstance(value, (list, tuple)):
value = value.split(' ')
# Split values into list
return u' '.join([smart_text(val) for val in value]).split(u' ')
def validate(self, value):
"""
Validates that the input is a list or tuple.
"""
if self.required and not value:
raise OAuthValidationError({'error': 'invalid_request'})
# Validate that each value in the value list is in self.choices.
for val in value:
if not self.valid_value(val):
raise OAuthValidationError({
'error': 'invalid_request',
'error_description': _("'%s' is not a valid scope.") %
val})
class AccessTokenExchangeForm(forms.Form):
"""Form for access token exchange endpoint"""
access_token = CharField(required=False)
scope = ScopeChoiceField(choices=SCOPE_NAMES, required=False)
OAUTH2_PROVIDER = getattr(settings, "OAUTH2_PROVIDER", {})
scope_choices = ()
if 'SCOPES' in OAUTH2_PROVIDER:
scope_choices = OAUTH2_PROVIDER['SCOPES'].items()
scope = ScopeChoiceField(choices=scope_choices, required=False)
client_id = CharField(required=False)
def __init__(self, request, oauth2_adapter, *args, **kwargs):
@@ -29,6 +90,25 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
self.request = request
self.oauth2_adapter = oauth2_adapter
def _clean_fields(self):
"""
Overriding the default cleaning behaviour to exit early on errors
instead of validating each field.
"""
try:
super(AccessTokenExchangeForm, self)._clean_fields()
except OAuthValidationError as e:
self._errors.update(e.args[0])
def _clean_form(self):
"""
Overriding the default cleaning behaviour for a shallow error dict.
"""
try:
super(AccessTokenExchangeForm, self)._clean_form()
except OAuthValidationError as e:
self._errors.update(e.args[0])
def _require_oauth_field(self, field_name):
"""
Raise an appropriate OAuthValidationError error if the field is missing
@@ -55,6 +135,16 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
"""
return self._require_oauth_field("client_id")
def clean_scope(self):
"""
The scope is assembled by combining all the set flags into a single
integer value which we can later check again for set bits.
If *no* scope is set, we return the default scope which is the first
defined scope in :attr:`provider.constants.SCOPES`.
"""
flags = self.cleaned_data.get('scope', [])
return flags
def clean(self):
if self._errors:
return {}
@@ -73,14 +163,14 @@ class AccessTokenExchangeForm(ScopeMixin, OAuthForm):
client_id = self.cleaned_data["client_id"]
try:
client = self.oauth2_adapter.get_client(client_id=client_id)
except (Client.DoesNotExist, Application.DoesNotExist):
except Application.DoesNotExist:
raise OAuthValidationError(
{
"error": "invalid_client",
"error_description": u"{} is not a valid client_id".format(client_id),
}
)
if client.client_type not in [provider.constants.PUBLIC, Application.CLIENT_PUBLIC]:
if client.client_type != Application.CLIENT_PUBLIC:
raise OAuthValidationError(
{
# invalid_client isn't really the right code, but this mirrors

View File

@@ -11,13 +11,12 @@ import social_django.utils as social_utils
from django.contrib.sessions.middleware import SessionMiddleware
from django.test import TestCase
from django.test.client import RequestFactory
from provider import scope
from social_django.models import Partial
from third_party_auth.tests.utils import ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle
from ..forms import AccessTokenExchangeForm
from .mixins import DOPAdapterMixin, DOTAdapterMixin
from .mixins import DOTAdapterMixin
from .utils import TPA_FEATURE_ENABLED, TPA_FEATURES_KEY, AccessTokenExchangeTestMixin
@@ -50,23 +49,7 @@ class AccessTokenExchangeFormTest(AccessTokenExchangeTestMixin):
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data["user"], self.user)
self.assertEqual(form.cleaned_data["client"], self.oauth_client)
self.assertEqual(set(scope.to_names(form.cleaned_data["scope"])), set(expected_scopes))
# This is necessary because cms does not implement third party auth
@unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled")
@httpretty.activate
class DOPAccessTokenExchangeFormTestFacebook(
DOPAdapterMixin,
AccessTokenExchangeFormTest,
ThirdPartyOAuthTestMixinFacebook,
TestCase,
):
"""
Tests for AccessTokenExchangeForm used with Facebook, tested against
django-oauth2-provider (DOP).
"""
pass
self.assertEqual(set(form.cleaned_data["scope"]), set(expected_scopes))
# This is necessary because cms does not implement third party auth
@@ -84,23 +67,6 @@ class DOTAccessTokenExchangeFormTestFacebook(
"""
pass
# This is necessary because cms does not implement third party auth
@unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " 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(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled")
@httpretty.activate

View File

@@ -8,6 +8,7 @@ Tests for OAuth token exchange views
import json
import unittest
from datetime import timedelta
import pytest
import ddt
import httpretty
@@ -16,7 +17,7 @@ import provider.constants
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
from provider.oauth2.models import AccessToken, Client
from oauth2_provider.models import Application
from rest_framework.test import APIClient
from social_django.models import Partial
@@ -24,7 +25,7 @@ from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factor
from student.tests.factories import UserFactory
from third_party_auth.tests.utils import ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle
from .mixins import DOPAdapterMixin, DOTAdapterMixin
from .mixins import DOTAdapterMixin
from .utils import TPA_FEATURE_ENABLED, TPA_FEATURES_KEY, AccessTokenExchangeTestMixin
@@ -111,20 +112,21 @@ class AccessTokenExchangeViewTest(AccessTokenExchangeTestMixin):
response = self.client.post(url, self.data)
self.assertEqual(response.status_code, 404)
@pytest.mark.skip(reason="this is very entangled with dop use in third_party_auth")
def test_invalid_client(self):
"""TODO(jinder): this test overwrites function of same name in mixin
Remove when dop has been removed from third party auth
(currently underlying code used dop adapter, which is no longer supported by auth_exchange)
"""
pass
# This is necessary because cms does not implement third party auth
@unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled")
@httpretty.activate
class DOPAccessTokenExchangeViewTestFacebook(
DOPAdapterMixin,
AccessTokenExchangeViewTest,
ThirdPartyOAuthTestMixinFacebook,
TestCase,
):
"""
Tests for AccessTokenExchangeView used with Facebook
"""
pass
@pytest.mark.skip(reason="this is very entangled with dop use in third_party_auth")
def test_missing_fields(self):
"""TODO(jinder): this test overwrites function of same name in mixin
Remove when dop has been removed from third party auth
(currently underlying code used dop adapter, which is no longer supported by auth_exchange)
"""
pass
@unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled")
@@ -141,22 +143,6 @@ class DOTAccessTokenExchangeViewTestFacebook(
pass
# This is necessary because cms does not implement third party auth
@unittest.skipUnless(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled")
@httpretty.activate
class DOPAccessTokenExchangeViewTestGoogle(
DOPAdapterMixin,
AccessTokenExchangeViewTest,
ThirdPartyOAuthTestMixinGoogle,
TestCase,
):
"""
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(TPA_FEATURE_ENABLED, TPA_FEATURES_KEY + " not enabled")
@httpretty.activate
@@ -181,7 +167,7 @@ class TestLoginWithAccessTokenView(TestCase):
def setUp(self):
super(TestLoginWithAccessTokenView, self).setUp()
self.user = UserFactory()
self.oauth2_client = Client.objects.create(client_type=provider.constants.CONFIDENTIAL)
self.oauth2_client = Application.objects.create(client_type=Application.CLIENT_CONFIDENTIAL)
def _verify_response(self, access_token, expected_status_code, expected_cookie_name=None):
"""
@@ -200,20 +186,6 @@ class TestLoginWithAccessTokenView(TestCase):
dot_application = dot_factories.ApplicationFactory(user=self.user, authorization_grant_type=grant_type)
return dot_factories.AccessTokenFactory(user=self.user, application=dot_application)
def _create_dop_access_token(self):
"""
Create dop based access token
"""
return AccessToken.objects.create(
token="test_access_token",
client=self.oauth2_client,
user=self.user,
)
def test_dop_unsupported(self):
access_token = self._create_dop_access_token()
self._verify_response(access_token, expected_status_code=401)
def test_invalid_token(self):
self._verify_response("invalid_token", expected_status_code=401)
self.assertNotIn("session_key", self.client.session)

View File

@@ -77,7 +77,7 @@ class AccessTokenExchangeTestMixin(ThirdPartyOAuthTestMixin):
self._assert_error(
self.data,
"invalid_client",
u"{}_confidential is not a public client".format(self.client_id),
"{}_confidential is not a public client".format(self.client_id),
)
def test_inactive_user(self):

View File

@@ -7,9 +7,6 @@ The following are currently implemented:
1st party (open-edx) OAuth 2.0 access token -> session cookie
"""
# pylint: disable=abstract-method
import django.contrib.auth as auth
import social_django.utils as social_utils
from django.conf import settings
@@ -19,9 +16,6 @@ from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from oauth2_provider import models as dot_models
from oauth2_provider.views.base import TokenView as DOTAccessTokenView
from provider import constants
from provider import scope as dop_scope
from provider.oauth2.views import AccessTokenView as DOPAccessTokenView
from rest_framework import permissions
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.response import Response
@@ -40,7 +34,7 @@ class AccessTokenExchangeBase(APIView):
"""
@method_decorator(csrf_exempt)
@method_decorator(social_utils.psa("social:complete"))
def dispatch(self, *args, **kwargs):
def dispatch(self, *args, **kwargs): # pylint: disable=arguments-differ
return super(AccessTokenExchangeBase, self).dispatch(*args, **kwargs)
def get(self, request, _backend):
@@ -68,23 +62,11 @@ class AccessTokenExchangeBase(APIView):
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)
else:
edx_access_token = self.create_access_token(request, user, scope, client)
edx_access_token = self.create_access_token(request, user, scope, client)
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
@@ -100,18 +82,10 @@ class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView):
'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):
def create_access_token(self, request, user, scopes, client):
"""
Create and return a new access token.
"""
scopes = dop_scope.to_names(scope)
return create_dot_access_token(request, user, client, scopes=scopes)
def access_token_response(self, token):
@@ -120,7 +94,7 @@ class DOTAccessTokenExchangeView(AccessTokenExchangeBase, DOTAccessTokenView):
"""
return Response(data=token)
def error_response(self, form_errors, **kwargs):
def error_response(self, form_errors, **kwargs): # pylint: disable=arguments-differ
"""
Return an error response consisting of the errors in the form
"""

View File

@@ -387,7 +387,7 @@ class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAut
'access_token': self.access_token,
}
@ddt.data('dop_app', 'dot_app')
@ddt.data('dot_app')
def test_access_token_exchange_calls_dispatched_view(self, client_attr):
client = getattr(self, client_attr)
self.oauth_client = client

View File

@@ -100,7 +100,7 @@ class AccessTokenView(_DispatchingView):
dot_view = dot_views.TokenView
dop_view = dop_views.AccessTokenView
def dispatch(self, request, *args, **kwargs): # pylint: disable=arguments-differ
def dispatch(self, request, *args, **kwargs):
response = super(AccessTokenView, self).dispatch(request, *args, **kwargs)
token_type = request.POST.get('token_type',
@@ -136,9 +136,21 @@ class AccessTokenExchangeView(_DispatchingView):
"""
Exchange a third party auth token.
"""
dop_view = auth_exchange_views.DOPAccessTokenExchangeView
dot_view = auth_exchange_views.DOTAccessTokenExchangeView
def get_view_for_backend(self, backend):
"""
Return the appropriate view from the requested backend.
Since AccessTokenExchangeView no longer supports dop, this function needed to
be overwritten from _DispatchingView, it was decided that the dop path should not be removed
from _DispatchingView due to it still being used in other views(AuthorizationView, AccessTokenView)
"""
if backend == self.dot_adapter.backend:
monitoring_utils.set_custom_metric('oauth_view', 'dot')
return self.dot_view.as_view()
else:
raise KeyError('Failed to dispatch view. Invalid backend {}'.format(backend))
class RevokeTokenView(_DispatchingView):
"""