Move auth_exchange from common to openedx/core.

Move oauth_dispatch from lms to openedx/core.
This commit is contained in:
Nimisha Asthagiri
2016-10-05 21:08:11 -04:00
parent ce1eb237d1
commit 4c0f85b4d9
34 changed files with 47 additions and 35 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,78 @@
"""
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,
authorization_grant_type=models.Application.GRANT_AUTHORIZATION_CODE):
"""
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=authorization_grant_type,
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)

View File

@@ -0,0 +1,73 @@
"""
Override admin configuration for django-oauth-toolkit
"""
from django.contrib.admin import ModelAdmin, site
from oauth2_provider import models
def reregister(model_class):
"""
Remove the existing admin, and register it anew with the given ModelAdmin
Usage:
@reregister(ModelClass)
class ModelClassAdmin(ModelAdmin):
pass
"""
def decorator(cls):
"""
The actual decorator that does the work.
"""
site.unregister(model_class)
site.register(model_class, cls)
return cls
return decorator
@reregister(models.AccessToken)
class DOTAccessTokenAdmin(ModelAdmin):
"""
Custom AccessToken Admin
"""
date_hierarchy = u'expires'
list_display = [u'token', u'user', u'application', u'expires']
list_filter = [u'application']
raw_id_fields = [u'user']
search_fields = [u'token', u'user__username']
@reregister(models.RefreshToken)
class DOTRefreshTokenAdmin(ModelAdmin):
"""
Custom AccessToken Admin
"""
list_display = [u'token', u'user', u'application', u'access_token']
list_filter = [u'application']
raw_id_fields = [u'user', u'access_token']
search_fields = [u'token', u'user__username', u'access_token__token']
@reregister(models.Application)
class DOTApplicationAdmin(ModelAdmin):
"""
Custom Application Admin
"""
list_display = [u'name', u'user', u'client_type', u'authorization_grant_type', u'client_id']
list_filter = [u'client_type', u'authorization_grant_type']
raw_id_fields = [u'user']
search_fields = [u'name', u'user__username', u'client_id']
@reregister(models.Grant)
class DOTGrantAdmin(ModelAdmin):
"""
Custom Grant Admin
"""
date_hierarchy = u'expires'
list_display = [u'code', u'user', u'application', u'expires']
list_filter = [u'application']
raw_id_fields = [u'user']
search_fields = [u'code', u'user__username']

View File

@@ -0,0 +1,14 @@
"""
Configure OAuthDispatch App
"""
from __future__ import absolute_import
from django.apps import AppConfig
class OAuthDispatchAppConfig(AppConfig):
"""
OAuthDispatch Configuration
"""
name = u'openedx.core.djangoapps.oauth_dispatch'

View File

@@ -0,0 +1,66 @@
"""
Classes that override default django-oauth-toolkit behavior
"""
from __future__ import unicode_literals
from django.contrib.auth import authenticate, get_user_model
from oauth2_provider.oauth2_validators import OAuth2Validator
class EdxOAuth2Validator(OAuth2Validator):
"""
Validator class that implements edX-specific custom behavior:
* It allows users to log in with their email or username.
* It does not require users to be active before logging in.
"""
def validate_user(self, username, password, client, request, *args, **kwargs):
"""
Authenticate users, but allow inactive users (with u.is_active == False)
to authenticate.
"""
user = self._authenticate(username=username, password=password)
if user is not None:
request.user = user
return True
return False
def _authenticate(self, username, password):
"""
Authenticate the user, allowing the user to identify themself either by
username or email
"""
authenticated_user = authenticate(username=username, password=password)
if authenticated_user is None:
UserModel = get_user_model() # pylint: disable=invalid-name
try:
email_user = UserModel.objects.get(email=username)
except UserModel.DoesNotExist:
authenticated_user = None
else:
authenticated_user = authenticate(username=email_user.username, password=password)
return authenticated_user
def save_bearer_token(self, token, request, *args, **kwargs):
"""
Ensure that access tokens issued via client credentials grant are associated with the owner of the
``Application``.
"""
grant_type = request.grant_type
user = request.user
if grant_type == 'client_credentials':
# Temporarily remove the grant type to avoid triggering the super method's code that removes request.user.
request.grant_type = None
# Ensure the tokens get associated with the correct user since DOT does not normally
# associate access tokens issued with the client_credentials grant to users.
request.user = request.client.user
super(EdxOAuth2Validator, self).save_bearer_token(token, request, *args, **kwargs)
# Restore the original request attributes
request.grant_type = grant_type
request.user = user

View File

@@ -0,0 +1,5 @@
"""
Constants for testing purposes
"""
DUMMY_REDIRECT_URL = u'https://example.com/edx/redirect'

View File

@@ -0,0 +1,40 @@
# pylint: disable=missing-docstring
from datetime import datetime, timedelta
import factory
from factory.django import DjangoModelFactory
from factory.fuzzy import FuzzyText
import pytz
from oauth2_provider.models import Application, AccessToken, RefreshToken
from student.tests.factories import UserFactory
class ApplicationFactory(DjangoModelFactory):
class Meta(object):
model = Application
user = factory.SubFactory(UserFactory)
client_id = factory.Sequence(u'client_{0}'.format)
client_secret = 'some_secret'
client_type = 'confidential'
authorization_grant_type = 'Client credentials'
class AccessTokenFactory(DjangoModelFactory):
class Meta(object):
model = AccessToken
django_get_or_create = ('user', 'application')
token = FuzzyText(length=32)
expires = datetime.now(pytz.UTC) + timedelta(days=1)
class RefreshTokenFactory(DjangoModelFactory):
class Meta(object):
model = RefreshToken
django_get_or_create = ('user', 'application')
token = FuzzyText(length=32)

View File

@@ -0,0 +1,57 @@
"""
OAuth Dispatch test mixins
"""
import jwt
from django.conf import settings
from student.models import UserProfile, anonymous_id_for_user
class AccessTokenMixin(object):
""" Mixin for tests dealing with OAuth 2 access tokens. """
def assert_valid_jwt_access_token(self, access_token, user, scopes=None):
"""
Verify the specified JWT access token is valid, and belongs to the specified user.
Args:
access_token (str): JWT
user (User): User whose information is contained in the JWT payload.
Returns:
dict: Decoded JWT payload
"""
scopes = scopes or []
audience = settings.JWT_AUTH['JWT_AUDIENCE']
issuer = settings.JWT_AUTH['JWT_ISSUER']
payload = jwt.decode(
access_token,
settings.JWT_AUTH['JWT_SECRET_KEY'],
algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']],
audience=audience,
issuer=issuer
)
expected = {
'aud': audience,
'iss': issuer,
'preferred_username': user.username,
'scopes': scopes,
'sub': anonymous_id_for_user(user, None),
}
if 'email' in scopes:
expected['email'] = user.email
if 'profile' in scopes:
try:
name = UserProfile.objects.get(user=user).name
except UserProfile.DoesNotExist:
name = None
expected['name'] = name
expected['administrator'] = user.is_staff
self.assertDictContainsSubset(expected, payload)
return payload

View File

@@ -0,0 +1,75 @@
""" Tests for OAuth 2.0 client credentials support. """
from __future__ import unicode_literals
import json
import unittest
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
from edx_oauth2_provider.tests.factories import ClientFactory
from oauth2_provider.models import Application
from provider.oauth2.models import AccessToken
from student.tests.factories import UserFactory
from . import mixins
from .constants import DUMMY_REDIRECT_URL
from ..adapters import DOTAdapter
@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled")
class ClientCredentialsTest(mixins.AccessTokenMixin, TestCase):
""" Tests validating the client credentials grant behavior. """
def setUp(self):
super(ClientCredentialsTest, self).setUp()
self.user = UserFactory()
def test_access_token(self):
""" Verify the client credentials grant can be used to obtain an access token whose default scopes allow access
to the user info endpoint.
"""
oauth_client = ClientFactory(user=self.user)
data = {
'grant_type': 'client_credentials',
'client_id': oauth_client.client_id,
'client_secret': oauth_client.client_secret
}
response = self.client.post(reverse('oauth2:access_token'), data)
self.assertEqual(response.status_code, 200)
access_token = json.loads(response.content)['access_token']
expected = AccessToken.objects.filter(client=oauth_client, user=self.user).first().token
self.assertEqual(access_token, expected)
headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + access_token
}
response = self.client.get(reverse('oauth2:user_info'), **headers)
self.assertEqual(response.status_code, 200)
def test_jwt_access_token(self):
""" Verify the client credentials grant can be used to obtain a JWT access token. """
application = DOTAdapter().create_confidential_client(
name='test dot application',
user=self.user,
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
redirect_uri=DUMMY_REDIRECT_URL,
client_id='dot-app-client-id',
)
scopes = ['read', 'write', 'email']
data = {
'grant_type': 'client_credentials',
'client_id': application.client_id,
'client_secret': application.client_secret,
'scope': ' '.join(scopes),
'token_type': 'jwt'
}
response = self.client.post(reverse('access_token'), data)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content)
access_token = content['access_token']
self.assertEqual(content['scope'], data['scope'])
self.assert_valid_jwt_access_token(access_token, self.user, scopes)

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,66 @@
"""
Test of custom django-oauth-toolkit behavior
"""
# pylint: disable=protected-access
from django.contrib.auth.models import User
from django.test import TestCase, RequestFactory
from ..dot_overrides import EdxOAuth2Validator
class AuthenticateTestCase(TestCase):
"""
Test that users can authenticate with either username or email
"""
def setUp(self):
super(AuthenticateTestCase, self).setUp()
self.user = User.objects.create_user(
username='darkhelmet',
password='12345',
email='darkhelmet@spaceball_one.org',
)
self.validator = EdxOAuth2Validator()
def test_authenticate_with_username(self):
user = self.validator._authenticate(username='darkhelmet', password='12345')
self.assertEqual(
self.user,
user
)
def test_authenticate_with_email(self):
user = self.validator._authenticate(username='darkhelmet@spaceball_one.org', password='12345')
self.assertEqual(
self.user,
user
)
class CustomValidationTestCase(TestCase):
"""
Test custom user validation works.
In particular, inactive users should be able to validate.
"""
def setUp(self):
super(CustomValidationTestCase, self).setUp()
self.user = User.objects.create_user(
username='darkhelmet',
password='12345',
email='darkhelmet@spaceball_one.org',
)
self.validator = EdxOAuth2Validator()
self.request_factory = RequestFactory()
def test_active_user_validates(self):
self.assertTrue(self.user.is_active)
request = self.request_factory.get('/')
self.assertTrue(self.validator.validate_user('darkhelmet', '12345', client=None, request=request))
def test_inactive_user_validates(self):
self.user.is_active = False
self.user.save()
request = self.request_factory.get('/')
self.assertTrue(self.validator.validate_user('darkhelmet', '12345', client=None, request=request))

View File

@@ -0,0 +1,45 @@
# pylint: disable=missing-docstring
from django.test import TestCase
from oauth2_provider.models import Application, AccessToken, RefreshToken
from openedx.core.djangoapps.oauth_dispatch.tests import factories
from student.tests.factories import UserFactory
class TestClientFactory(TestCase):
def setUp(self):
super(TestClientFactory, self).setUp()
self.user = UserFactory.create()
def test_client_factory(self):
actual_application = factories.ApplicationFactory(user=self.user)
expected_application = Application.objects.get(user=self.user)
self.assertEqual(actual_application, expected_application)
class TestAccessTokenFactory(TestCase):
def setUp(self):
super(TestAccessTokenFactory, self).setUp()
self.user = UserFactory.create()
def test_access_token_client_factory(self):
application = factories.ApplicationFactory(user=self.user)
actual_access_token = factories.AccessTokenFactory(user=self.user, application=application)
expected_access_token = AccessToken.objects.get(user=self.user)
self.assertEqual(actual_access_token, expected_access_token)
class TestRefreshTokenFactory(TestCase):
def setUp(self):
super(TestRefreshTokenFactory, self).setUp()
self.user = UserFactory.create()
def test_refresh_token_factory(self):
application = factories.ApplicationFactory(user=self.user)
access_token = factories.AccessTokenFactory(user=self.user, application=application)
actual_refresh_token = factories.RefreshTokenFactory(
user=self.user, application=application, access_token=access_token
)
expected_refresh_token = RefreshToken.objects.get(user=self.user, access_token=access_token)
self.assertEqual(actual_refresh_token, expected_refresh_token)

View File

@@ -0,0 +1,430 @@
"""
Tests for Blocks Views
"""
import json
import ddt
from django.conf import settings
from django.test import RequestFactory, TestCase
from django.core.urlresolvers import reverse
import httpretty
from provider import constants
import unittest
from student.tests.factories import UserFactory
from third_party_auth.tests.utils import ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinGoogle
from .constants import DUMMY_REDIRECT_URL
from .. import adapters
if settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"):
from .. import views
from . import mixins
@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled")
class _DispatchingViewTestCase(TestCase):
"""
Base class for tests that exercise DispatchingViews.
Subclasses need to define self.url.
"""
dop_adapter = adapters.DOPAdapter()
dot_adapter = adapters.DOTAdapter()
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_app = 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, token_type=None):
"""
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, token_type)) # pylint: disable=no-member
def _post_body(self, user, client, token_type=None):
"""
Return a dictionary to be used as the body of the POST request
"""
raise NotImplementedError()
@ddt.ddt
class TestAccessTokenView(mixins.AccessTokenMixin, _DispatchingViewTestCase):
"""
Test class for AccessTokenView
"""
def setUp(self):
self.url = reverse('access_token')
self.view_class = views.AccessTokenView
super(TestAccessTokenView, self).setUp()
def _post_body(self, user, client, token_type=None):
"""
Return a dictionary to be used as the body of the POST request
"""
body = {
'client_id': client.client_id,
'grant_type': 'password',
'username': user.username,
'password': 'test',
}
if token_type:
body['token_type'] = token_type
return body
@ddt.data('dop_app', '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)
@ddt.data('dop_app', 'dot_app')
def test_jwt_access_token(self, client_attr):
client = getattr(self, client_attr)
response = self._post_request(self.user, client, token_type='jwt')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertIn('expires_in', data)
self.assertEqual(data['token_type'], 'JWT')
self.assert_valid_jwt_access_token(data['access_token'], self.user, data['scope'].split(' '))
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_app)
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
"""
def setUp(self):
self.url = reverse('exchange_access_token', kwargs={'backend': 'google-oauth2'})
self.view_class = views.AccessTokenExchangeView
super(TestAccessTokenExchangeView, self).setUp()
def _post_body(self, user, client, token_type=None):
return {
'client_id': client.client_id,
'access_token': self.access_token,
}
@ddt.data('dop_app', '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)
# pylint: disable=abstract-method
@ddt.ddt
class TestAuthorizationView(_DispatchingViewTestCase):
"""
Test class for AuthorizationView
"""
dop_adapter = adapters.DOPAdapter()
def setUp(self):
super(TestAuthorizationView, self).setUp()
self.user = UserFactory()
self.dot_app = self.dot_adapter.create_confidential_client(
name='test dot application',
user=self.user,
redirect_uri=DUMMY_REDIRECT_URL,
client_id='confidential-dot-app-client-id',
)
self.dop_app = self.dop_adapter.create_confidential_client(
name='test dop client',
user=self.user,
redirect_uri=DUMMY_REDIRECT_URL,
client_id='confidential-dop-app-client-id',
)
@ddt.data(
('dop', 'authorize'),
('dot', 'allow')
)
@ddt.unpack
def test_post_authorization_view(self, client_type, allow_field):
oauth_application = getattr(self, '{}_app'.format(client_type))
self.client.login(username=self.user.username, password='test')
response = self.client.post(
'/oauth2/authorize/',
{
'client_id': oauth_application.client_id,
'response_type': 'code',
'state': 'random_state_string',
'redirect_uri': DUMMY_REDIRECT_URL,
'scope': 'profile email',
allow_field: True,
},
follow=True,
)
check_response = getattr(self, '_check_{}_response'.format(client_type))
check_response(response)
def _check_dot_response(self, response):
"""
Check that django-oauth-toolkit gives an appropriate authorization response.
"""
# django-oauth-toolkit tries to redirect to the user's redirect URL
self.assertEqual(response.status_code, 404) # We used a non-existent redirect url.
expected_redirect_prefix = u'{}?'.format(DUMMY_REDIRECT_URL)
self._assert_startswith(self._redirect_destination(response), expected_redirect_prefix)
def _check_dop_response(self, response):
"""
Check that django-oauth2-provider gives an appropriate authorization response.
"""
# django-oauth-provider redirects to a confirmation page
self.assertRedirects(response, u'http://testserver/oauth2/authorize/confirm', target_status_code=200)
context = response.context_data
form = context['form']
self.assertIsNone(form['authorize'].value())
oauth_data = context['oauth_data']
self.assertEqual(oauth_data['redirect_uri'], DUMMY_REDIRECT_URL)
self.assertEqual(oauth_data['state'], 'random_state_string')
# TODO: figure out why it chooses this scope.
self.assertEqual(oauth_data['scope'], constants.READ_WRITE)
def _assert_startswith(self, string, prefix):
"""
Assert that the string starts with the specified prefix.
"""
self.assertTrue(string.startswith(prefix), u'{} does not start with {}'.format(string, prefix))
@staticmethod
def _redirect_destination(response):
"""
Return the final destination of the redirect chain in the response object
"""
return response.redirect_chain[-1][0]
@unittest.skipUnless(settings.FEATURES.get("ENABLE_OAUTH2_PROVIDER"), "OAuth2 not enabled")
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 _post_request(self, client_id):
"""
Return a request with the specified client_id in the body
"""
return RequestFactory().post('/', {'client_id': client_id})
def _get_request(self, client_id):
"""
Return a request with the specified client_id in the get parameters
"""
return RequestFactory().get('/?client_id={}'.format(client_id))
def test_dispatching_post_to_dot(self):
request = self._post_request('dot-id')
self.assertEqual(self.view.select_backend(request), self.dot_adapter.backend)
def test_dispatching_post_to_dop(self):
request = self._post_request('dop-id')
self.assertEqual(self.view.select_backend(request), self.dop_adapter.backend)
def test_dispatching_get_to_dot(self):
request = self._get_request('dot-id')
self.assertEqual(self.view.select_backend(request), self.dot_adapter.backend)
def test_dispatching_get_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._post_request(None)
self.assertEqual(self.view.select_backend(request), self.dop_adapter.backend)
def test_dispatching_with_invalid_client(self):
request = self._post_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)
class TestRevokeTokenView(_DispatchingViewTestCase): # pylint: disable=abstract-method
"""
Test class for RevokeTokenView
"""
def setUp(self):
self.login_with_access_token_url = reverse("login_with_access_token")
self.revoke_token_url = reverse('revoke_token')
self.access_token_url = reverse('access_token')
super(TestRevokeTokenView, self).setUp()
response = self.client.post(self.access_token_url, self.access_token_post_body_with_password())
access_token_data = json.loads(response.content)
self.access_token = access_token_data['access_token']
self.refresh_token = access_token_data['refresh_token']
def access_token_post_body_with_password(self):
"""
Returns a dictionary to be used as the body of the access_token
POST request with 'password' grant
"""
return {
'client_id': self.dot_app.client_id,
'grant_type': 'password',
'username': self.user.username,
'password': 'test',
}
def access_token_post_body_with_refresh_token(self, refresh_token):
"""
Returns a dictionary to be used as the body of the access_token
POST request with 'refresh_token' grant
"""
return {
'client_id': self.dot_app.client_id,
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
}
def revoke_token_post_body(self, token):
"""
Returns a dictionary to be used as the body of the revoke_token POST request
"""
return {
'client_id': self.dot_app.client_id,
'token': token,
}
def login_with_access_token(self):
"""
Login with access token and return response
"""
return self.client.post(
self.login_with_access_token_url,
HTTP_AUTHORIZATION="Bearer {0}".format(self.access_token)
)
def _assert_access_token_is_valid(self):
"""
Asserts that oauth assigned access_token is valid and usable
"""
self.assertEqual(self.login_with_access_token().status_code, 204)
def _assert_access_token_invalidated(self):
"""
Asserts that oauth assigned access_token is not valid
"""
self.assertEqual(self.login_with_access_token().status_code, 401)
def _assert_refresh_token_invalidated(self):
"""
Asserts that oauth assigned refresh_token is not valid
"""
response = self.client.post(
self.access_token_url,
self.access_token_post_body_with_refresh_token(self.refresh_token)
)
self.assertEqual(response.status_code, 401)
def verify_revoke_token(self, token):
"""
Verifies access of token before and after revoking
"""
self._assert_access_token_is_valid()
response = self.client.post(self.revoke_token_url, self.revoke_token_post_body(token))
self.assertEqual(response.status_code, 200)
self._assert_access_token_invalidated()
self._assert_refresh_token_invalidated()
def test_revoke_refresh_token_dot(self):
"""
Tests invalidation/revoke of user tokens against refresh token for django-oauth-toolkit
"""
self.verify_revoke_token(self.refresh_token)
def test_revoke_access_token_dot(self):
"""
Tests invalidation/revoke of user access token for django-oauth-toolkit
"""
self.verify_revoke_token(self.access_token)

View File

@@ -0,0 +1,26 @@
"""
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(
'',
url(r'^authorize/?$', csrf_exempt(views.AuthorizationView.as_view()), name='authorize'),
url(r'^access_token/?$', csrf_exempt(views.AccessTokenView.as_view()), name='access_token'),
url(r'^revoke_token/?$', csrf_exempt(views.RevokeTokenView.as_view()), name="revoke_token"),
)
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
urlpatterns += (
url(
r'^exchange_access_token/(?P<backend>[^/]+)/$',
csrf_exempt(views.AccessTokenExchangeView.as_view()),
name='exchange_access_token',
),
)

View File

@@ -0,0 +1,134 @@
"""
Views that dispatch processing of OAuth requests to django-oauth2-provider or
django-oauth-toolkit as appropriate.
"""
from __future__ import unicode_literals
import json
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 openedx.core.djangoapps.auth_exchange import views as auth_exchange_views
from openedx.core.lib.token_utils import JwtBuilder
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 get_adapter(self, request):
"""
Returns the appropriate adapter based on the OAuth client linked to the request.
"""
if dot_models.Application.objects.filter(client_id=self._get_client_id(request)).exists():
return self.dot_adapter
else:
return self.dop_adapter
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.
"""
return self.get_adapter(request).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
"""
if request.method == u'GET':
return request.GET.get('client_id')
else:
return request.POST.get('client_id')
class AccessTokenView(_DispatchingView):
"""
Handle access token requests.
"""
dot_view = dot_views.TokenView
dop_view = dop_views.AccessTokenView
def dispatch(self, request, *args, **kwargs):
response = super(AccessTokenView, self).dispatch(request, *args, **kwargs)
if response.status_code == 200 and request.POST.get('token_type', '').lower() == 'jwt':
expires_in, scopes, user = self._decompose_access_token_response(request, response)
content = {
'access_token': JwtBuilder(user).build_token(scopes, expires_in),
'expires_in': expires_in,
'token_type': 'JWT',
'scope': ' '.join(scopes),
}
response.content = json.dumps(content)
return response
def _decompose_access_token_response(self, request, response):
""" Decomposes the access token in the request to an expiration date, scopes, and User. """
content = json.loads(response.content)
access_token = content['access_token']
scope = content['scope']
access_token_obj = self.get_adapter(request).get_access_token(access_token)
user = access_token_obj.user
scopes = scope.split(' ')
expires_in = content['expires_in']
return expires_in, scopes, user
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
class RevokeTokenView(_DispatchingView):
"""
Dispatch to the RevokeTokenView of django-oauth-toolkit
"""
dot_view = dot_views.RevokeTokenView