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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user