Merge pull request #5813 from edx/gprice/login-oauth-token
Add endpoint to log in with OAuth access token
This commit is contained in:
@@ -12,8 +12,14 @@ from django.conf import settings
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.urlresolvers import reverse, NoReverseMatch
|
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||||
from django.http import HttpResponseBadRequest, HttpResponse
|
from django.http import HttpResponseBadRequest, HttpResponse
|
||||||
|
import httpretty
|
||||||
|
from social.apps.django_app.default.models import UserSocialAuth
|
||||||
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
|
from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory
|
||||||
from student.views import _parse_course_id_from_string, _get_course_enrollment_domain
|
from student.views import (
|
||||||
|
_parse_course_id_from_string,
|
||||||
|
_get_course_enrollment_domain,
|
||||||
|
login_oauth_token,
|
||||||
|
)
|
||||||
|
|
||||||
from xmodule.modulestore.tests.factories import CourseFactory
|
from xmodule.modulestore.tests.factories import CourseFactory
|
||||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config
|
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config
|
||||||
@@ -430,3 +436,89 @@ class ExternalAuthShibTest(ModuleStoreTestCase):
|
|||||||
self.assertEqual(shib_response.redirect_chain[-2],
|
self.assertEqual(shib_response.redirect_chain[-2],
|
||||||
('http://testserver{url}'.format(url=TARGET_URL_SHIB), 302))
|
('http://testserver{url}'.format(url=TARGET_URL_SHIB), 302))
|
||||||
self.assertEqual(shib_response.status_code, 200)
|
self.assertEqual(shib_response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
@httpretty.activate
|
||||||
|
class LoginOAuthTokenMixin(object):
|
||||||
|
"""
|
||||||
|
Mixin with tests for the login_oauth_token view. A TestCase that includes
|
||||||
|
this must define the following:
|
||||||
|
|
||||||
|
BACKEND: The name of the backend from python-social-auth
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.url = reverse(login_oauth_token, kwargs={"backend": self.BACKEND})
|
||||||
|
self.social_uid = "social_uid"
|
||||||
|
self.user = UserFactory()
|
||||||
|
UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid)
|
||||||
|
|
||||||
|
def _setup_user_response(self, success):
|
||||||
|
"""
|
||||||
|
Register a mock response for the third party user information endpoint;
|
||||||
|
success indicates whether the response status code should be 200 or 400
|
||||||
|
"""
|
||||||
|
if success:
|
||||||
|
status = 200
|
||||||
|
body = json.dumps({self.UID_FIELD: self.social_uid})
|
||||||
|
else:
|
||||||
|
status = 400
|
||||||
|
body = json.dumps({})
|
||||||
|
httpretty.register_uri(
|
||||||
|
httpretty.GET,
|
||||||
|
self.USER_URL,
|
||||||
|
body=body,
|
||||||
|
status=status,
|
||||||
|
content_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _assert_error(self, response, status_code, error):
|
||||||
|
"""Assert that the given response was a 400 with the given error code"""
|
||||||
|
self.assertEqual(response.status_code, status_code)
|
||||||
|
self.assertEqual(json.loads(response.content), {"error": error})
|
||||||
|
self.assertNotIn("partial_pipeline", self.client.session)
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
self._setup_user_response(success=True)
|
||||||
|
response = self.client.post(self.url, {"access_token": "dummy"})
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
def test_invalid_token(self):
|
||||||
|
self._setup_user_response(success=False)
|
||||||
|
response = self.client.post(self.url, {"access_token": "dummy"})
|
||||||
|
self._assert_error(response, 401, "invalid_token")
|
||||||
|
|
||||||
|
def test_missing_token(self):
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self._assert_error(response, 400, "invalid_request")
|
||||||
|
|
||||||
|
def test_unlinked_user(self):
|
||||||
|
UserSocialAuth.objects.all().delete()
|
||||||
|
self._setup_user_response(success=True)
|
||||||
|
response = self.client.post(self.url, {"access_token": "dummy"})
|
||||||
|
self._assert_error(response, 401, "invalid_token")
|
||||||
|
|
||||||
|
def test_get_method(self):
|
||||||
|
response = self.client.get(self.url, {"access_token": "dummy"})
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
class LoginOAuthTokenTestFacebook(LoginOAuthTokenMixin, TestCase):
|
||||||
|
"""Tests login_oauth_token with the Facebook backend"""
|
||||||
|
BACKEND = "facebook"
|
||||||
|
USER_URL = "https://graph.facebook.com/me"
|
||||||
|
UID_FIELD = "id"
|
||||||
|
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
class LoginOAuthTokenTestGoogle(LoginOAuthTokenMixin, TestCase):
|
||||||
|
"""Tests login_oauth_token with the Google backend"""
|
||||||
|
BACKEND = "google-oauth2"
|
||||||
|
USER_URL = "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||||
|
UID_FIELD = "email"
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ from django.template.response import TemplateResponse
|
|||||||
|
|
||||||
from ratelimitbackend.exceptions import RateLimitException
|
from ratelimitbackend.exceptions import RateLimitException
|
||||||
|
|
||||||
|
from requests import HTTPError
|
||||||
|
|
||||||
|
from social.apps.django_app import utils as social_utils
|
||||||
|
from social.backends import oauth as social_oauth
|
||||||
|
|
||||||
from edxmako.shortcuts import render_to_response, render_to_string
|
from edxmako.shortcuts import render_to_response, render_to_string
|
||||||
from mako.exceptions import TopLevelLookupException
|
from mako.exceptions import TopLevelLookupException
|
||||||
|
|
||||||
@@ -1109,6 +1114,35 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
|
|||||||
}) # TODO: this should be status code 400 # pylint: disable=fixme
|
}) # TODO: this should be status code 400 # pylint: disable=fixme
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@social_utils.strategy("social:complete")
|
||||||
|
def login_oauth_token(request, backend):
|
||||||
|
"""
|
||||||
|
Authenticate the client using an OAuth access token by using the token to
|
||||||
|
retrieve information from a third party and matching that information to an
|
||||||
|
existing user.
|
||||||
|
"""
|
||||||
|
backend = request.social_strategy.backend
|
||||||
|
if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2):
|
||||||
|
if "access_token" in request.POST:
|
||||||
|
# Tell third party auth pipeline that this is an API call
|
||||||
|
request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_API
|
||||||
|
user = None
|
||||||
|
try:
|
||||||
|
user = backend.do_auth(request.POST["access_token"])
|
||||||
|
except HTTPError:
|
||||||
|
pass
|
||||||
|
# do_auth can return a non-User object if it fails
|
||||||
|
if user and isinstance(user, User):
|
||||||
|
return JsonResponse(status=204)
|
||||||
|
else:
|
||||||
|
# Ensure user does not re-enter the pipeline
|
||||||
|
request.social_strategy.clean_partial_pipeline()
|
||||||
|
return JsonResponse({"error": "invalid_token"}, status=401)
|
||||||
|
else:
|
||||||
|
return JsonResponse({"error": "invalid_request"}, status=400)
|
||||||
|
raise Http404
|
||||||
|
|
||||||
|
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def logout_user(request):
|
def logout_user(request):
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from eventtracking import tracker
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.http import HttpResponseBadRequest
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from social.apps.django_app.default import models
|
from social.apps.django_app.default import models
|
||||||
from social.exceptions import AuthException
|
from social.exceptions import AuthException
|
||||||
@@ -109,11 +110,13 @@ AUTH_ENTRY_DASHBOARD = 'dashboard'
|
|||||||
AUTH_ENTRY_LOGIN = 'login'
|
AUTH_ENTRY_LOGIN = 'login'
|
||||||
AUTH_ENTRY_PROFILE = 'profile'
|
AUTH_ENTRY_PROFILE = 'profile'
|
||||||
AUTH_ENTRY_REGISTER = 'register'
|
AUTH_ENTRY_REGISTER = 'register'
|
||||||
|
AUTH_ENTRY_API = 'api'
|
||||||
_AUTH_ENTRY_CHOICES = frozenset([
|
_AUTH_ENTRY_CHOICES = frozenset([
|
||||||
AUTH_ENTRY_DASHBOARD,
|
AUTH_ENTRY_DASHBOARD,
|
||||||
AUTH_ENTRY_LOGIN,
|
AUTH_ENTRY_LOGIN,
|
||||||
AUTH_ENTRY_PROFILE,
|
AUTH_ENTRY_PROFILE,
|
||||||
AUTH_ENTRY_REGISTER
|
AUTH_ENTRY_REGISTER,
|
||||||
|
AUTH_ENTRY_API,
|
||||||
])
|
])
|
||||||
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
|
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
|
||||||
_PASSWORD_CHARSET = string.letters + string.digits
|
_PASSWORD_CHARSET = string.letters + string.digits
|
||||||
@@ -396,15 +399,33 @@ def parse_query_params(strategy, response, *args, **kwargs):
|
|||||||
'is_register': auth_entry == AUTH_ENTRY_REGISTER,
|
'is_register': auth_entry == AUTH_ENTRY_REGISTER,
|
||||||
# Whether the auth pipeline entered from /profile.
|
# Whether the auth pipeline entered from /profile.
|
||||||
'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
|
'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
|
||||||
|
# Whether the auth pipeline entered from an API
|
||||||
|
'is_api': auth_entry == AUTH_ENTRY_API,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@partial.partial
|
@partial.partial
|
||||||
def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_profile=None, is_register=None, user=None, *args, **kwargs):
|
def ensure_user_information(
|
||||||
"""Dispatches user to views outside the pipeline if necessary."""
|
strategy,
|
||||||
|
details,
|
||||||
|
response,
|
||||||
|
uid,
|
||||||
|
is_dashboard=None,
|
||||||
|
is_login=None,
|
||||||
|
is_profile=None,
|
||||||
|
is_register=None,
|
||||||
|
is_api=None,
|
||||||
|
user=None,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Ensure that we have the necessary information about a user (either an
|
||||||
|
existing account or registration data) to proceed with the pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
# We're deliberately verbose here to make it clear what the intended
|
# We're deliberately verbose here to make it clear what the intended
|
||||||
# dispatch behavior is for the four pipeline entry points, given the
|
# dispatch behavior is for the various pipeline entry points, given the
|
||||||
# current state of the pipeline. Keep in mind the pipeline is re-entrant
|
# current state of the pipeline. Keep in mind the pipeline is re-entrant
|
||||||
# and values will change on repeated invocations (for example, the first
|
# and values will change on repeated invocations (for example, the first
|
||||||
# time through the login flow the user will be None so we dispatch to the
|
# time through the login flow the user will be None so we dispatch to the
|
||||||
@@ -418,6 +439,11 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
|
|||||||
user_inactive = user and not user.is_active
|
user_inactive = user and not user.is_active
|
||||||
user_unset = user is None
|
user_unset = user is None
|
||||||
dispatch_to_login = is_login and (user_unset or user_inactive)
|
dispatch_to_login = is_login and (user_unset or user_inactive)
|
||||||
|
reject_api_request = is_api and (user_unset or user_inactive)
|
||||||
|
|
||||||
|
if reject_api_request:
|
||||||
|
# Content doesn't matter; we just want to exit the pipeline
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
if is_dashboard or is_profile:
|
if is_dashboard or is_profile:
|
||||||
return
|
return
|
||||||
@@ -430,7 +456,7 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
|
|||||||
|
|
||||||
|
|
||||||
@partial.partial
|
@partial.partial
|
||||||
def set_logged_in_cookie(backend=None, user=None, request=None, *args, **kwargs):
|
def set_logged_in_cookie(backend=None, user=None, request=None, is_api=None, *args, **kwargs):
|
||||||
"""This pipeline step sets the "logged in" cookie for authenticated users.
|
"""This pipeline step sets the "logged in" cookie for authenticated users.
|
||||||
|
|
||||||
Some installations have a marketing site front-end separate from
|
Some installations have a marketing site front-end separate from
|
||||||
@@ -455,7 +481,7 @@ def set_logged_in_cookie(backend=None, user=None, request=None, *args, **kwargs)
|
|||||||
to the next pipeline step.
|
to the next pipeline step.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if user is not None and user.is_authenticated():
|
if user is not None and user.is_authenticated() and not is_api:
|
||||||
if request is not None:
|
if request is not None:
|
||||||
# Check that the cookie isn't already set.
|
# Check that the cookie isn't already set.
|
||||||
# This ensures that we allow the user to continue to the next
|
# This ensures that we allow the user to continue to the next
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ def _set_global_settings(django_settings):
|
|||||||
'social.pipeline.social_auth.auth_allowed',
|
'social.pipeline.social_auth.auth_allowed',
|
||||||
'social.pipeline.social_auth.social_user',
|
'social.pipeline.social_auth.social_user',
|
||||||
'social.pipeline.user.get_username',
|
'social.pipeline.user.get_username',
|
||||||
'third_party_auth.pipeline.redirect_to_supplementary_form',
|
'third_party_auth.pipeline.ensure_user_information',
|
||||||
'social.pipeline.user.create_user',
|
'social.pipeline.user.create_user',
|
||||||
'social.pipeline.social_auth.associate_user',
|
'social.pipeline.social_auth.associate_user',
|
||||||
'social.pipeline.social_auth.load_extra_data',
|
'social.pipeline.social_auth.load_extra_data',
|
||||||
|
|||||||
@@ -534,6 +534,7 @@ if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
|
|||||||
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
|
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
|
||||||
urlpatterns += (
|
urlpatterns += (
|
||||||
url(r'', include('third_party_auth.urls')),
|
url(r'', include('third_party_auth.urls')),
|
||||||
|
url(r'^login_oauth_token/(?P<backend>[^/]+)/$', 'student.views.login_oauth_token'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# If enabled, expose the URLs for the new dashboard, account, and profile pages
|
# If enabled, expose the URLs for the new dashboard, account, and profile pages
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ git+https://github.com/pmitros/pyfs.git@96e1922348bfe6d99201b9512a9ed946c87b7e0b
|
|||||||
GitPython==0.3.2.RC1
|
GitPython==0.3.2.RC1
|
||||||
glob2==0.3
|
glob2==0.3
|
||||||
gunicorn==0.17.4
|
gunicorn==0.17.4
|
||||||
|
httpretty==0.8.3
|
||||||
lazy==1.1
|
lazy==1.1
|
||||||
lxml==3.3.6
|
lxml==3.3.6
|
||||||
mako==0.9.1
|
mako==0.9.1
|
||||||
|
|||||||
Reference in New Issue
Block a user