Skip to content
Snippets Groups Projects
Commit d2183c58 authored by Greg Price's avatar Greg Price
Browse files

Add endpoint to log in with OAuth access token

parent eaa63da4
Branches
Tags
No related merge requests found
......@@ -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
......
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