Commit 6d5bb9b1 by Greg Price

Merge pull request #5813 from edx/gprice/login-oauth-token

Add endpoint to log in with OAuth access token
parents 90b40e7d d2183c58
...@@ -12,8 +12,14 @@ from django.conf import settings ...@@ -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): ...@@ -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 ...@@ -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 ...@@ -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 ...@@ -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' ...@@ -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): ...@@ -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 ...@@ -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 ...@@ -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) ...@@ -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): ...@@ -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'): ...@@ -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 ...@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment