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.urlresolvers import reverse, NoReverseMatch
|
||||
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.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.django_utils import ModuleStoreTestCase, mixed_store_config
|
||||
@@ -430,3 +436,89 @@ class ExternalAuthShibTest(ModuleStoreTestCase):
|
||||
self.assertEqual(shib_response.redirect_chain[-2],
|
||||
('http://testserver{url}'.format(url=TARGET_URL_SHIB), 302))
|
||||
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 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 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
|
||||
|
||||
|
||||
@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
|
||||
def logout_user(request):
|
||||
|
||||
@@ -66,6 +66,7 @@ from eventtracking import tracker
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.shortcuts import redirect
|
||||
from social.apps.django_app.default import models
|
||||
from social.exceptions import AuthException
|
||||
@@ -109,11 +110,13 @@ AUTH_ENTRY_DASHBOARD = 'dashboard'
|
||||
AUTH_ENTRY_LOGIN = 'login'
|
||||
AUTH_ENTRY_PROFILE = 'profile'
|
||||
AUTH_ENTRY_REGISTER = 'register'
|
||||
AUTH_ENTRY_API = 'api'
|
||||
_AUTH_ENTRY_CHOICES = frozenset([
|
||||
AUTH_ENTRY_DASHBOARD,
|
||||
AUTH_ENTRY_LOGIN,
|
||||
AUTH_ENTRY_PROFILE,
|
||||
AUTH_ENTRY_REGISTER
|
||||
AUTH_ENTRY_REGISTER,
|
||||
AUTH_ENTRY_API,
|
||||
])
|
||||
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
|
||||
_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,
|
||||
# Whether the auth pipeline entered from /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
|
||||
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):
|
||||
"""Dispatches user to views outside the pipeline if necessary."""
|
||||
def ensure_user_information(
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
@@ -418,6 +439,11 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
|
||||
user_inactive = user and not user.is_active
|
||||
user_unset = user is None
|
||||
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:
|
||||
return
|
||||
@@ -430,7 +456,7 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
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.
|
||||
|
||||
"""
|
||||
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:
|
||||
# Check that the cookie isn't already set.
|
||||
# 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.social_user',
|
||||
'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.social_auth.associate_user',
|
||||
'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'):
|
||||
urlpatterns += (
|
||||
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
|
||||
|
||||
@@ -44,6 +44,7 @@ git+https://github.com/pmitros/pyfs.git@96e1922348bfe6d99201b9512a9ed946c87b7e0b
|
||||
GitPython==0.3.2.RC1
|
||||
glob2==0.3
|
||||
gunicorn==0.17.4
|
||||
httpretty==0.8.3
|
||||
lazy==1.1
|
||||
lxml==3.3.6
|
||||
mako==0.9.1
|
||||
|
||||
Reference in New Issue
Block a user