OAuth: support for auto_even_if_expired REQUEST_APPROVAL_PROMPT
This commit is contained in:
@@ -521,7 +521,7 @@ OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS = 30
|
||||
################################## DJANGO OAUTH TOOLKIT #######################################
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
'OAUTH2_VALIDATOR_CLASS': 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.EdxOAuth2Validator',
|
||||
'OAUTH2_VALIDATOR_CLASS': 'openedx.core.djangoapps.oauth_dispatch.dot_overrides.validators.EdxOAuth2Validator',
|
||||
'REFRESH_TOKEN_EXPIRE_SECONDS': 20160,
|
||||
'SCOPES': {
|
||||
'read': 'Read access',
|
||||
@@ -531,6 +531,7 @@ OAUTH2_PROVIDER = {
|
||||
# to lms/templates/provider/authorize.html. This may be revised later.
|
||||
'profile': 'Know your name and username',
|
||||
},
|
||||
'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired',
|
||||
}
|
||||
# This is required for the migrations in oauth_dispatch.models
|
||||
# otherwise it fails saying this attribute is not present in Settings
|
||||
|
||||
@@ -12,7 +12,7 @@ from oauth2_provider.models import AccessToken
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
from pytz import utc
|
||||
|
||||
from .models import RestrictedApplication
|
||||
from ..models import RestrictedApplication
|
||||
|
||||
|
||||
@receiver(pre_save, sender=AccessToken)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Classes that override default django-oauth-toolkit behavior
|
||||
"""
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from oauth2_provider.exceptions import OAuthToolkitError
|
||||
from oauth2_provider.http import HttpResponseUriRedirect
|
||||
from oauth2_provider.models import get_application_model
|
||||
from oauth2_provider.scopes import get_scopes_backend
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
from oauth2_provider.views import AuthorizationView
|
||||
|
||||
|
||||
# TODO (ARCH-83) remove once we have full support of OAuth Scopes
|
||||
class EdxOAuth2AuthorizationView(AuthorizationView):
|
||||
"""
|
||||
Override the AuthorizationView's GET method so the user isn't
|
||||
prompted to approve the application if they have already in
|
||||
the past, even if their access token is expired.
|
||||
|
||||
This is a temporary override of the base implementation
|
||||
in order to accommodate our Restricted Applications support
|
||||
until OAuth Scopes are fully supported.
|
||||
"""
|
||||
def get(self, request, *args, **kwargs):
|
||||
# Note: This code is copied from https://github.com/evonove/django-oauth-toolkit/blob/34f3b7b3511c15686039079026165feaadb1b87d/oauth2_provider/views/base.py#L111
|
||||
# Places that we have changed are noted with ***.
|
||||
try:
|
||||
# *** Moved code to get the require_approval value earlier on so we can
|
||||
# circumvent our custom code in the case when auto_even_if_expired
|
||||
# isn't required.
|
||||
require_approval = request.GET.get(
|
||||
"approval_prompt",
|
||||
oauth2_settings.REQUEST_APPROVAL_PROMPT,
|
||||
)
|
||||
if require_approval != 'auto_even_if_expired':
|
||||
return super(EdxOAuth2AuthorizationView, self).get(request, *args, **kwargs)
|
||||
|
||||
scopes, credentials = self.validate_authorization_request(request)
|
||||
all_scopes = get_scopes_backend().get_all_scopes()
|
||||
kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes]
|
||||
kwargs['scopes'] = scopes
|
||||
|
||||
# at this point we know an Application instance with such client_id exists in the database
|
||||
application = get_application_model().objects.get(client_id=credentials['client_id'])
|
||||
kwargs['application'] = application
|
||||
kwargs['client_id'] = credentials['client_id']
|
||||
kwargs['redirect_uri'] = credentials['redirect_uri']
|
||||
kwargs['response_type'] = credentials['response_type']
|
||||
kwargs['state'] = credentials['state']
|
||||
|
||||
self.oauth2_data = kwargs
|
||||
# following two loc are here only because of https://code.djangoproject.com/ticket/17795
|
||||
form = self.get_form(self.get_form_class())
|
||||
kwargs['form'] = form
|
||||
|
||||
# If skip_authorization field is True, skip the authorization screen even
|
||||
# if this is the first use of the application and there was no previous authorization.
|
||||
# This is useful for in-house applications-> assume an in-house applications
|
||||
# are already approved.
|
||||
if application.skip_authorization:
|
||||
uri, headers, body, status = self.create_authorization_response(
|
||||
request=self.request, scopes=" ".join(scopes),
|
||||
credentials=credentials, allow=True)
|
||||
return HttpResponseUriRedirect(uri)
|
||||
|
||||
# *** Changed the if statement that checked for require_approval to an assert.
|
||||
assert require_approval == 'auto_even_if_expired'
|
||||
tokens = request.user.accesstoken_set.filter(
|
||||
application=kwargs['application'],
|
||||
# *** Purposefully keeping this commented out code to highlight that
|
||||
# our version of the implementation does NOT filter by expiration date.
|
||||
# expires__gt=timezone.now(),
|
||||
).all()
|
||||
|
||||
# check past authorizations regarded the same scopes as the current one
|
||||
for token in tokens:
|
||||
if token.allow_scopes(scopes):
|
||||
uri, headers, body, status = self.create_authorization_response(
|
||||
request=self.request, scopes=" ".join(scopes),
|
||||
credentials=credentials, allow=True)
|
||||
return HttpResponseUriRedirect(uri)
|
||||
|
||||
# render an authorization prompt so the user can approve
|
||||
# the application's requested scopes
|
||||
return self.render_to_response(self.get_context_data(**kwargs))
|
||||
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error)
|
||||
@@ -3,15 +3,23 @@ Test of custom django-oauth-toolkit behavior
|
||||
"""
|
||||
|
||||
# pylint: disable=protected-access
|
||||
import datetime
|
||||
import unittest
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase, RequestFactory
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
# oauth_dispatch is not in CMS' INSTALLED_APPS so these imports will error during test collection
|
||||
if settings.ROOT_URLCONF == 'lms.urls':
|
||||
from ..dot_overrides import EdxOAuth2Validator
|
||||
from oauth2_provider import models as dot_models
|
||||
|
||||
from .. import adapters
|
||||
from .. import models
|
||||
from ..dot_overrides.validators import EdxOAuth2Validator
|
||||
from .constants import DUMMY_REDIRECT_URL
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@@ -71,3 +79,66 @@ class CustomValidationTestCase(TestCase):
|
||||
self.user.save()
|
||||
request = self.request_factory.get('/')
|
||||
self.assertTrue(self.validator.validate_user('darkhelmet', '12345', client=None, request=request))
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class CustomAuthorizationViewTestCase(TestCase):
|
||||
"""
|
||||
Test custom authorization view works.
|
||||
|
||||
In particular, users should not be re-prompted to approve
|
||||
an application even if the access token is expired.
|
||||
(This is a temporary override until Auth Scopes is implemented.)
|
||||
"""
|
||||
def setUp(self):
|
||||
super(CustomAuthorizationViewTestCase, self).setUp()
|
||||
self.dot_adapter = adapters.DOTAdapter()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password='test')
|
||||
|
||||
self.restricted_dot_app = self._create_restricted_app()
|
||||
self._create_expired_token(self.restricted_dot_app)
|
||||
|
||||
def _create_restricted_app(self):
|
||||
restricted_app = self.dot_adapter.create_confidential_client(
|
||||
name='test restricted dot application',
|
||||
user=self.user,
|
||||
redirect_uri=DUMMY_REDIRECT_URL,
|
||||
client_id='dot-restricted-app-client-id',
|
||||
)
|
||||
models.RestrictedApplication.objects.create(application=restricted_app)
|
||||
return restricted_app
|
||||
|
||||
def _create_expired_token(self, application):
|
||||
date_in_the_past = timezone.now() + datetime.timedelta(days=-100)
|
||||
dot_models.AccessToken.objects.create(
|
||||
user=self.user,
|
||||
token='1234567890',
|
||||
application=application,
|
||||
expires=date_in_the_past,
|
||||
scope='profile',
|
||||
)
|
||||
|
||||
def _get_authorize(self, scope):
|
||||
authorize_url = '/oauth2/authorize/'
|
||||
return self.client.get(
|
||||
authorize_url,
|
||||
{
|
||||
'client_id': self.restricted_dot_app.client_id,
|
||||
'response_type': 'code',
|
||||
'state': 'random_state_string',
|
||||
'redirect_uri': DUMMY_REDIRECT_URL,
|
||||
'scope': scope,
|
||||
},
|
||||
)
|
||||
|
||||
def test_no_reprompting(self):
|
||||
response = self._get_authorize(scope='profile')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.url.startswith(DUMMY_REDIRECT_URL))
|
||||
|
||||
def test_prompting_with_new_scope(self):
|
||||
response = self._get_authorize(scope='email')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, settings.OAUTH2_PROVIDER['SCOPES']['email'])
|
||||
self.assertNotContains(response, settings.OAUTH2_PROVIDER['SCOPES']['profile'])
|
||||
|
||||
@@ -24,6 +24,7 @@ from openedx.core.djangoapps.auth_exchange import views as auth_exchange_views
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
|
||||
from . import adapters
|
||||
from .dot_overrides import views as dot_overrides_views
|
||||
|
||||
|
||||
class _DispatchingView(View):
|
||||
@@ -129,7 +130,7 @@ class AuthorizationView(_DispatchingView):
|
||||
Part of the authorization flow.
|
||||
"""
|
||||
dop_view = dop_views.Capture
|
||||
dot_view = dot_views.AuthorizationView
|
||||
dot_view = dot_overrides_views.EdxOAuth2AuthorizationView
|
||||
|
||||
|
||||
class AccessTokenExchangeView(_DispatchingView):
|
||||
|
||||
Reference in New Issue
Block a user