diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index ad715a8dff..b35be740bf 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -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" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ab33ba9fea..c51f995012 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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): diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index c4730d6dc3..bc4a5b35c5 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -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 diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index 421d335b2c..a94cb10da3 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -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', diff --git a/lms/urls.py b/lms/urls.py index 721c3a2803..61e73e1001 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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[^/]+)/$', 'student.views.login_oauth_token'), ) # If enabled, expose the URLs for the new dashboard, account, and profile pages diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index b4324a5241..ebe6ac6e24 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -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