Commit a8d1d663 by Fred Smith

Merge pull request #538 from edx-solutions/rc/2015-10-07

Rc/2015 10 07
parents 341fdf46 8e7a6def
......@@ -1073,6 +1073,7 @@ NOTIFICATION_CLICK_LINK_URL_MAPS = {
'open-edx.lms.leaderboard.*': '/courses/{course_id}/cohort',
'open-edx.lms.discussions.*': '/courses/{course_id}/discussion/{commentable_id}/threads/{thread_id}',
'open-edx.xblock.group-project.*': '/courses/{course_id}/group_work?seqid={activity_location}',
'open-edx.xblock.group-project-v2.*': '/courses/{course_id}/group_work?activate_block_id={location}',
}
# list all known channel providers
......
"""
Tests for the Third Party Auth REST API
"""
import json
import unittest
import ddt
from mock import patch
from django.test import Client
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from django.conf import settings
from django.test.utils import override_settings
from util.testing import UrlResetMixin
from openedx.core.lib.django_test_client_utils import get_absolute_url
from social.apps.django_app.default.models import UserSocialAuth
from student.tests.factories import UserFactory
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
VALID_API_KEY = "i am a key"
@override_settings(EDX_API_KEY=VALID_API_KEY)
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ThirdPartyAuthAPITests(ThirdPartyAuthTestMixin, APITestCase):
"""
Test the Third Party Auth REST API
"""
ALICE_USERNAME = "alice"
CARL_USERNAME = "carl"
STAFF_USERNAME = "staff"
ADMIN_USERNAME = "admin"
# These users will be created and linked to third party accounts:
LINKED_USERS = (ALICE_USERNAME, STAFF_USERNAME, ADMIN_USERNAME)
PASSWORD = "edx"
def setUp(self):
""" Create users for use in the tests """
super(ThirdPartyAuthAPITests, self).setUp()
google = self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
self.configure_linkedin_provider(enabled=False)
self.enable_saml()
testshib = self.configure_saml_provider(name='TestShib', enabled=True, idp_slug='testshib')
# Create several users and link each user to Google and TestShib
for username in self.LINKED_USERS:
make_superuser = (username == self.ADMIN_USERNAME)
make_staff = (username == self.STAFF_USERNAME) or make_superuser
user = UserFactory.create(
username=username,
password=self.PASSWORD,
is_staff=make_staff,
is_superuser=make_superuser
)
UserSocialAuth.objects.create(
user=user,
provider=google.backend_name,
uid='{}@gmail.com'.format(username),
)
UserSocialAuth.objects.create(
user=user,
provider=testshib.backend_name,
uid='{}:{}'.format(testshib.idp_slug, username),
)
# Create another user not linked to any providers:
UserFactory.create(username=self.CARL_USERNAME, password=self.PASSWORD)
def expected_active(self, username):
""" The JSON active providers list response expected for the given user """
if username not in self.LINKED_USERS:
return []
return [
{
"provider_id": "oa2-google-oauth2",
"name": "Google",
"remote_id": "{}@gmail.com".format(username),
},
{
"provider_id": "saml-testshib",
"name": "TestShib",
# The "testshib:" prefix is stored in the UserSocialAuth.uid field but should
# not be present in the 'remote_id', since that's an implementation detail:
"remote_id": username,
},
]
@ddt.data(
# Any user can query their own list of providers
(ALICE_USERNAME, ALICE_USERNAME, 200),
(CARL_USERNAME, CARL_USERNAME, 200),
# A regular user cannot query another user nor deduce the existence of users based on the status code
(ALICE_USERNAME, STAFF_USERNAME, 403),
(ALICE_USERNAME, "nonexistent_user", 403),
# Even Staff cannot query other users
(STAFF_USERNAME, ALICE_USERNAME, 403),
# But admins can
(ADMIN_USERNAME, ALICE_USERNAME, 200),
(ADMIN_USERNAME, CARL_USERNAME, 200),
(ADMIN_USERNAME, "invalid_username", 404),
)
@ddt.unpack
def test_list_connected_providers(self, request_user, target_user, expect_result):
self.client.login(username=request_user, password=self.PASSWORD)
url = reverse('third_party_auth_users_api', kwargs={'username': target_user})
response = self.client.get(url)
self.assertEqual(response.status_code, expect_result)
if expect_result == 200:
self.assertIn("active", response.data)
self.assertItemsEqual(response.data["active"], self.expected_active(target_user))
@ddt.data(
# A server with a valid API key can query any user's list of providers
(VALID_API_KEY, ALICE_USERNAME, 200),
(VALID_API_KEY, "invalid_username", 404),
("i am an invalid key", ALICE_USERNAME, 403),
(None, ALICE_USERNAME, 403),
)
@ddt.unpack
def test_list_connected_providers__withapi_key(self, api_key, target_user, expect_result):
url = reverse('third_party_auth_users_api', kwargs={'username': target_user})
response = self.client.get(url, HTTP_X_EDX_API_KEY=api_key)
self.assertEqual(response.status_code, expect_result)
if expect_result == 200:
self.assertIn("active", response.data)
self.assertItemsEqual(response.data["active"], self.expected_active(target_user))
""" URL configuration for the third party auth API """
from django.conf.urls import patterns, url
from .views import UserView
USERNAME_PATTERN = r'(?P<username>[\w.+-]+)'
urlpatterns = patterns(
'',
url(r'^v0/users/' + USERNAME_PATTERN + '$', UserView.as_view(), name='third_party_auth_users_api'),
)
"""
Third Party Auth REST API views
"""
from django.contrib.auth.models import User
from openedx.core.lib.api.authentication import (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
from openedx.core.lib.api.permissions import (
ApiKeyHeaderPermission,
)
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from third_party_auth import pipeline
class UserView(APIView):
"""
List the third party auth accounts linked to the specified user account.
**Example Request**
GET /api/third_party_auth/v0/users/{username}
**Response Values**
If the request for information about the user is successful, an HTTP 200 "OK" response
is returned.
The HTTP 200 response has the following values.
* active: A list of all the third party auth providers currently linked
to the given user's account. Each object in this list has the
following attributes:
* provider_id: The unique identifier of this provider (string)
* name: The name of this provider (string)
* remote_id: The ID of the user according to the provider. This ID
is what is used to link the user to their edX account during
login.
"""
authentication_classes = (
# Users may want to view/edit the providers used for authentication before they've
# activated their account, so we allow inactive users.
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
def get(self, request, username):
"""Create, read, or update enrollment information for a user.
HTTP Endpoint for all CRUD operations for a user course enrollment. Allows creation, reading, and
updates of the current enrollment for a particular course.
Args:
request (Request): The HTTP GET request
username (str): Fetch the list of providers linked to this user
Return:
JSON serialized list of the providers linked to this user.
"""
if request.user.username != username:
# We are querying permissions for a user other than the current user.
if not request.user.is_superuser and not ApiKeyHeaderPermission().has_permission(request, self):
# Return a 403 (Unauthorized) without validating 'username', so that we
# do not let users probe the existence of other user accounts.
return Response(status=status.HTTP_403_FORBIDDEN)
try:
user = User.objects.get(username=username)
except User.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
providers = pipeline.get_provider_user_states(user)
active_providers = [
{
"provider_id": assoc.provider.provider_id,
"name": assoc.provider.name,
"remote_id": assoc.remote_id,
}
for assoc in providers if assoc.has_account
]
# In the future this can be trivially modified to return the inactive/disconnected providers as well.
return Response({
"active": active_providers
})
......@@ -133,6 +133,12 @@ class ProviderConfig(ConfigurationModel):
""" Is this provider being used for this UserSocialAuth entry? """
return self.backend_name == social_auth.provider
def get_remote_id_from_social_auth(self, social_auth):
""" Given a UserSocialAuth object, return the remote ID used by this provider. """
# This is generally the same thing as the UID, expect when one backend is used for multiple providers
assert self.match_social_auth(social_auth)
return social_auth.uid
@classmethod
def get_register_form_data(cls, pipeline_kwargs):
"""Gets dict of data to display on the register form.
......@@ -281,6 +287,12 @@ class SAMLProviderConfig(ProviderConfig):
prefix = self.idp_slug + ":"
return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix)
def get_remote_id_from_social_auth(self, social_auth):
""" Given a UserSocialAuth object, return the remote ID used by this provider. """
assert self.match_social_auth(social_auth)
# Remove the prefix from the UID
return social_auth.uid[len(self.idp_slug) + 1:]
def get_config(self):
"""
Return a SAMLIdentityProvider instance for use by SAMLAuthBackend.
......
......@@ -56,6 +56,10 @@ rather than spreading them across two functions in the pipeline.
See http://psa.matiasaguirre.net/docs/pipeline.html for more docs.
"""
import base64
import hashlib
import hmac
import json
import random
import string # pylint: disable-msg=deprecated-module
from collections import OrderedDict
......@@ -111,6 +115,9 @@ AUTH_ENTRY_REGISTER_2 = 'account_register'
AUTH_ENTRY_LOGIN_API = 'login_api'
AUTH_ENTRY_REGISTER_API = 'register_api'
# Custom auth entry point used by external software
AUTH_ENTRY_CUSTOM = getattr(settings, 'THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {})
def is_api(auth_entry):
"""Returns whether the auth entry point is via an API call."""
......@@ -151,7 +158,7 @@ _AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_LOGIN_API,
AUTH_ENTRY_REGISTER_API,
])
] + AUTH_ENTRY_CUSTOM.keys())
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
_PASSWORD_CHARSET = string.letters + string.digits
......@@ -208,11 +215,17 @@ class ProviderUserState(object):
lms/templates/dashboard.html.
"""
def __init__(self, enabled_provider, user, association_id=None):
# UserSocialAuth row ID
self.association_id = association_id
def __init__(self, enabled_provider, user, association):
# Boolean. Whether the user has an account associated with the provider
self.has_account = association_id is not None
self.has_account = association is not None
if self.has_account:
# UserSocialAuth row ID
self.association_id = association.id
# Identifier of this user according to the remote provider:
self.remote_id = enabled_provider.get_remote_id_from_social_auth(association)
else:
self.association_id = None
self.remote_id = None
# provider.BaseProvider child. Callers must verify that the provider is
# enabled.
self.provider = enabled_provider
......@@ -405,13 +418,13 @@ def get_provider_user_states(user):
found_user_auths = list(models.DjangoStorage.user.get_social_auth_for_user(user))
for enabled_provider in provider.Registry.enabled():
association_id = None
association = None
for auth in found_user_auths:
if enabled_provider.match_social_auth(auth):
association_id = auth.id
association = auth
break
states.append(
ProviderUserState(enabled_provider, user, association_id)
ProviderUserState(enabled_provider, user, association)
)
return states
......@@ -488,6 +501,33 @@ def set_pipeline_timeout(strategy, user, *args, **kwargs):
# choice of the user.
def redirect_to_custom_form(request, auth_entry, user_details):
"""
If auth_entry is found in AUTH_ENTRY_CUSTOM, this is used to send provider
data to an external server's registration/login page.
The data is sent as a base64-encoded values in a POST request and includes
a cryptographic checksum in case the integrity of the data is important.
"""
form_info = AUTH_ENTRY_CUSTOM[auth_entry]
secret_key = form_info['secret_key']
if isinstance(secret_key, unicode):
secret_key = secret_key.encode('utf-8')
custom_form_url = form_info['url']
data_str = json.dumps({
"user_details": user_details
})
digest = hmac.new(secret_key, msg=data_str, digestmod=hashlib.sha256).digest()
# Store the data in the session temporarily, then redirect to a page that will POST it to
# the custom login/register page.
request.session['tpa_custom_auth_entry_data'] = {
'data': base64.b64encode(data_str),
'hmac': base64.b64encode(digest),
'post_url': custom_form_url,
}
return redirect(reverse('tpa_post_to_custom_auth_form'))
@partial.partial
def ensure_user_information(strategy, auth_entry, backend=None, user=None, social=None,
allow_inactive_user=False, *args, **kwargs):
......@@ -551,6 +591,9 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
return dispatch_to_register()
elif auth_entry == AUTH_ENTRY_ACCOUNT_SETTINGS:
raise AuthEntryError(backend, 'auth_entry is wrong. Settings requires a user.')
elif auth_entry in AUTH_ENTRY_CUSTOM:
# Pass the username, email, etc. via query params to the custom entry page:
return redirect_to_custom_form(strategy.request, auth_entry, kwargs['details'])
else:
raise AuthEntryError(backend, 'auth_entry invalid')
......
......@@ -93,3 +93,6 @@ def apply_settings(django_settings):
django_settings.SOCIAL_AUTH_USER_FIELDS = getattr(
django_settings, 'USER_FIELDS', ['username', 'email', 'first_name', 'last_name', 'fullname']
)
if not hasattr(django_settings, 'THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS'):
django_settings.THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = {}
......@@ -4,6 +4,7 @@ ConfigurationModels rather than django.settings
"""
import logging
from .models import OAuth2ProviderConfig
from .pipeline import AUTH_ENTRY_CUSTOM
from social.backends.oauth import BaseOAuth2
from social.strategies.django_strategy import DjangoStrategy
......@@ -33,6 +34,15 @@ class ConfigurationModelStrategy(DjangoStrategy):
return provider_config.get_setting(name)
except KeyError:
pass
# special case handling of login error URL if we're using a custom auth entry point:
if name == 'LOGIN_ERROR_URL':
auth_entry = self.request.session.get('auth_entry')
if auth_entry and auth_entry in AUTH_ENTRY_CUSTOM:
error_url = AUTH_ENTRY_CUSTOM[auth_entry].get('error_url')
if error_url:
return error_url
# At this point, we know 'name' is not set in a [OAuth2|SAML]ProviderConfig row.
# It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION':
return super(ConfigurationModelStrategy, self).setting(name, default, backend)
......@@ -77,3 +87,29 @@ class ConfigurationModelStrategy(DjangoStrategy):
# when autoprovisioning we need to skip email activation, hence skip_email is True
return create_account_with_params(self.request, user_fields, skip_email=True)
def request_host(self):
"""
Host in use for this request
"""
# TODO: this override is a temporary measure until upstream python-social-auth patch is merged:
# https://github.com/omab/python-social-auth/pull/741
if self.setting('RESPECT_X_FORWARDED_HEADERS', False):
forwarded_host = self.request.META.get('HTTP_X_FORWARDED_HOST')
if forwarded_host:
return forwarded_host
return super(ConfigurationModelStrategy, self).request_host()
def request_port(self):
"""
Port in use for this request
"""
# TODO: this override is a temporary measure until upstream python-social-auth patch is merged:
# https://github.com/omab/python-social-auth/pull/741
if self.setting('RESPECT_X_FORWARDED_HEADERS', False):
forwarded_port = self.request.META.get('HTTP_X_FORWARDED_PORT')
if forwarded_port:
return forwarded_port
return super(ConfigurationModelStrategy, self).request_port()
{% load i18n %}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% trans "Please wait" %}</title>
<style type="text/css">
#djDebug {display:none;}
</style>
</head>
<body>
<form id="sso-data-form" action="{{post_url}}" method="post">
{% csrf_token %}
<input type="hidden" name="sso_data" value="{{data}}">
<input type="hidden" name="sso_data_hmac" value="{{hmac}}">
<noscript>
<input id="submit-button" type="submit" value="Click to continue" autofocus>
</noscript>
</form>
<script>
document.getElementById('sso-data-form').submit();
</script>
</body>
</html>
"""Integration tests for Google providers."""
from third_party_auth import provider
import base64
import hashlib
import hmac
from django.conf import settings
from django.core.urlresolvers import reverse
import json
from mock import patch
from social.exceptions import AuthException
from student.tests.factories import UserFactory
from third_party_auth import pipeline
from third_party_auth.tests.specs import base
......@@ -35,3 +44,90 @@ class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest):
def get_username(self):
return self.get_response_data().get('email').split('@')[0]
def assert_redirect_to_provider_looks_correct(self, response):
super(GoogleOauth2IntegrationTest, self).assert_redirect_to_provider_looks_correct(response)
self.assertIn('google.com', response['Location'])
def test_custom_form(self):
"""
Use the Google provider to test the custom login/register form feature.
"""
# The pipeline starts by a user GETting /auth/login/google-oauth2/?auth_entry=custom1
# Synthesize that request and check that it redirects to the correct
# provider page.
auth_entry = 'custom1' # See definition in lms/envs/test.py
login_url = pipeline.get_login_url(self.provider.provider_id, auth_entry)
login_url += "&next=/misc/final-destination"
self.assert_redirect_to_provider_looks_correct(self.client.get(login_url))
def fake_auth_complete(inst, *args, **kwargs):
""" Mock the backend's auth_complete() method """
kwargs.update({'response': self.get_response_data(), 'backend': inst})
return inst.strategy.authenticate(*args, **kwargs)
# Next, the provider makes a request against /auth/complete/<provider>.
complete_url = pipeline.get_complete_url(self.provider.backend_name)
with patch.object(self.provider.backend_class, 'auth_complete', fake_auth_complete):
response = self.client.get(complete_url)
# This should redirect to the custom login/register form:
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/auth/custom_auth_entry')
response = self.client.get(response['Location'])
self.assertEqual(response.status_code, 200)
self.assertIn('action="/misc/my-custom-registration-form" method="post"', response.content)
data_decoded = base64.b64decode(response.context['data']) # pylint: disable=no-member
data_parsed = json.loads(data_decoded)
# The user's details get passed to the custom page as a base64 encoded query parameter:
self.assertEqual(data_parsed, {
'user_details': {
'username': 'email_value',
'email': 'email_value@example.com',
'fullname': 'name_value',
'first_name': 'given_name_value',
'last_name': 'family_name_value',
}
})
# Check the hash that is used to confirm the user's data in the GET parameter is correct
secret_key = settings.THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS['custom1']['secret_key']
hmac_expected = hmac.new(secret_key, msg=data_decoded, digestmod=hashlib.sha256).digest()
self.assertEqual(base64.b64decode(response.context['hmac']), hmac_expected) # pylint: disable=no-member
# Now our custom registration form creates or logs in the user:
email, password = data_parsed['user_details']['email'], 'random_password'
created_user = UserFactory(email=email, password=password)
login_response = self.client.post(reverse('login'), {'email': email, 'password': password})
self.assertEqual(login_response.status_code, 200)
# Now our custom login/registration page must resume the pipeline:
response = self.client.get(complete_url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/misc/final-destination')
_, strategy = self.get_request_and_strategy()
self.assert_social_auth_exists_for_user(created_user, strategy)
def test_custom_form_error(self):
"""
Use the Google provider to test the custom login/register failure redirects.
"""
# The pipeline starts by a user GETting /auth/login/google-oauth2/?auth_entry=custom1
# Synthesize that request and check that it redirects to the correct
# provider page.
auth_entry = 'custom1' # See definition in lms/envs/test.py
login_url = pipeline.get_login_url(self.provider.provider_id, auth_entry)
login_url += "&next=/misc/final-destination"
self.assert_redirect_to_provider_looks_correct(self.client.get(login_url))
def fake_auth_complete_error(_inst, *_args, **_kwargs):
""" Mock the backend's auth_complete() method """
raise AuthException("Mock login failed")
# Next, the provider makes a request against /auth/complete/<provider>.
complete_url = pipeline.get_complete_url(self.provider.backend_name)
with patch.object(self.provider.backend_class, 'auth_complete', fake_auth_complete_error):
response = self.client.get(complete_url)
# This should redirect to the custom error URL
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/misc/my-custom-sso-error-page')
......@@ -43,7 +43,7 @@ class ProviderUserStateTestCase(testutil.TestCase):
def test_get_unlink_form_name(self):
google_provider = self.configure_google_provider(enabled=True)
state = pipeline.ProviderUserState(google_provider, object(), 1000)
state = pipeline.ProviderUserState(google_provider, object(), None)
self.assertEqual(google_provider.provider_id + '_unlink_form', state.get_unlink_form_name())
......
......@@ -2,7 +2,9 @@
import unittest
import ddt
import mock
from unittest import TestCase
from django.test import TestCase
from third_party_auth.strategy import ConfigurationModelStrategy
from third_party_auth.tests import testutil
......@@ -95,3 +97,42 @@ class TestStrategy(TestCase):
_, user_data = self._get_last_call_args(patched_create_account)
self.assertEqual(user_data['email'], expected_email)
@ddt.data(
(True, None, 'host', 'host'),
(True, "", 'other_host', 'other_host'),
(True, 'x_forwarded_host', 'irrelevant', 'x_forwarded_host'),
(True, 'other_x_forwarded_host', 'still_irrelevant', 'other_x_forwarded_host'),
(False, None, 'host', 'host'),
(False, "", 'other_host', 'other_host'),
(False, 'x_forwarded_host', 'normal_host', 'normal_host'),
(False, 'other_x_forwarded_host', 'other_normal_host', 'other_normal_host'),
)
@ddt.unpack
def test_request_host(self, respect_x_headers, x_forwarded_value, get_host_value, expected_value, unused_patch):
self.request_mock.META = {}
self.request_mock.get_host.return_value = get_host_value
if x_forwarded_value is not None:
self.request_mock.META['HTTP_X_FORWARDED_HOST'] = x_forwarded_value
with self.settings(RESPECT_X_FORWARDED_HEADERS=respect_x_headers):
self.assertEqual(self.strategy.request_host(), expected_value)
@ddt.data(
(True, None, 'port', 'port'),
(True, "", 'other_port', 'other_port'),
(True, 'x_forwarded_port', 'irrelevant', 'x_forwarded_port'),
(True, 'other_x_forwarded_port', 'still_irrelevant', 'other_x_forwarded_port'),
(False, None, 'port', 'port'),
(False, "", 'other_port', 'other_port'),
(False, 'x_forwarded_port', 'normal_port', 'normal_port'),
(False, 'other_x_forwarded_port', 'other_normal_port', 'other_normal_port'),
)
@ddt.unpack
def test_request_port(self, respect_x_headers, x_forwarded_value, server_port_value, expected_value, unused_patch):
self.request_mock.META = {'SERVER_PORT': server_port_value}
if x_forwarded_value is not None:
self.request_mock.META['HTTP_X_FORWARDED_PORT'] = x_forwarded_value
with self.settings(RESPECT_X_FORWARDED_HEADERS=respect_x_headers):
self.assertEqual(self.strategy.request_port(), expected_value)
......@@ -2,11 +2,12 @@
from django.conf.urls import include, patterns, url
from .views import inactive_user_view, saml_metadata_view
from .views import inactive_user_view, saml_metadata_view, post_to_custom_auth_form
urlpatterns = patterns(
'',
url(r'^auth/inactive', inactive_user_view),
url(r'^auth/custom_auth_entry', post_to_custom_auth_form, name='tpa_post_to_custom_auth_form'),
url(r'^auth/saml/metadata.xml', saml_metadata_view),
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
)
......@@ -4,7 +4,7 @@ Extra views required for SSO
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseServerError, Http404
from django.shortcuts import redirect
from django.shortcuts import redirect, render
from social.apps.django_app.utils import load_strategy, load_backend
from .models import SAMLConfiguration
......@@ -36,3 +36,26 @@ def saml_metadata_view(request):
if not errors:
return HttpResponse(content=metadata, content_type='text/xml')
return HttpResponseServerError(content=', '.join(errors))
def post_to_custom_auth_form(request):
"""
Redirect to a custom login/register page.
Since we can't do a redirect-to-POST, this view is used to pass SSO data from
the third_party_auth pipeline to a custom login/register form (possibly on another server).
"""
pipeline_data = request.session.pop('tpa_custom_auth_entry_data', None)
if not pipeline_data:
raise Http404
# Verify the format of pipeline_data:
data = {
'post_url': pipeline_data['post_url'],
# The user's name, email, etc. as base64 encoded JSON
# It's base64 encoded because it's signed cryptographically and we don't want whitespace
# or ordering issues affecting the hash/signature.
'data': pipeline_data['data'],
# The cryptographic hash of user_data:
'hmac': pipeline_data['hmac'],
}
return render(request, 'third_party_auth/post_custom_auth_entry.html', data)
......@@ -55,6 +55,12 @@ class SessionsList(SecureAPIView):
"""
def post(self, request):
return self.login_user(request)
# pylint: disable=too-many-statements
@staticmethod
def login_user(request, session_id=None):
""" Create a new session and login the user, or upgrade an existing session """
response_data = {}
# Add some rate limiting here by re-using the RateLimitMixin as a helper class
limiter = BadRequestRateLimiter()
......@@ -105,21 +111,34 @@ class SessionsList(SecureAPIView):
# violate our RESTfulness
#
engine = import_module(settings.SESSION_ENGINE)
new_session = engine.SessionStore()
new_session.create()
if session_id is None:
session = engine.SessionStore()
session.create()
success_status = status.HTTP_201_CREATED
else:
session = engine.SessionStore(session_id)
success_status = status.HTTP_200_OK
if SESSION_KEY in session:
# Someone is already logged in. The user ID of whoever is logged in
# now might be different than the user ID we've been asked to login,
# which would be bad. But even if it is the same user, we should not
# be asked to login a user who is already logged in. This likely
# indicates some sort of programming/validation error and possibly
# even a potential security issue - so return 403.
return Response({}, status=status.HTTP_403_FORBIDDEN)
# These values are expected to be set in any new session
new_session[SESSION_KEY] = user.id
new_session[BACKEND_SESSION_KEY] = user.backend
session[SESSION_KEY] = user.id
session[BACKEND_SESSION_KEY] = user.backend
new_session.save()
session.save()
response_data['token'] = new_session.session_key
response_data['expires'] = new_session.get_expiry_age()
response_data['token'] = session.session_key
response_data['expires'] = session.get_expiry_age()
user_dto = UserSerializer(user)
response_data['user'] = user_dto.data
response_data['uri'] = '{}/{}'.format(base_uri, new_session.session_key)
response_status = status.HTTP_201_CREATED
response_data['uri'] = '{}/{}'.format(base_uri, session.session_key)
response_status = success_status
# generate a CSRF tokens for any web clients that may need to
# call into the LMS via Ajax (for example Notifications)
......@@ -152,13 +171,16 @@ class SessionsDetail(SecureAPIView):
**Use Case**
SessionsDetail gets a details about a specific API session, as well as
enables you to delete an API session.
enables you to delete an API session or "upgrade" a session by logging
in the user.
**Example Requests**
GET /api/session/{session_id}
POST /api/session/{session_id}
DELETE /api/session/{session_id}/delete
**GET Response Values**
......@@ -190,6 +212,10 @@ class SessionsDetail(SecureAPIView):
else:
return Response(response_data, status=status.HTTP_404_NOT_FOUND)
def post(self, request, session_id):
""" Login and upgrade an existing session from anonymous to authenticated. """
return SessionsList.login_user(request, session_id)
def delete(self, request, session_id):
engine = import_module(settings.SESSION_ENGINE)
session = engine.SessionStore(session_id)
......
......@@ -377,9 +377,11 @@ class UsersApiTests(ModuleStoreTestCase):
data = {'email': self.test_email, 'username': local_username, 'password':
self.test_password, 'first_name': self.test_first_name, 'last_name': self.test_last_name}
response = self.do_post(test_uri, data)
response = self.do_post(test_uri, data)
expected_message = "Username '{username}' or email '{email}' already exists".format(
username=local_username, email=self.test_email
)
self.assertEqual(response.status_code, 409)
self.assertGreater(response.data['message'], 0)
self.assertEqual(response.data['message'], expected_message)
self.assertEqual(response.data['field_conflict'], 'username or email')
@mock.patch.dict("student.models.settings.FEATURES", {"ENABLE_DISCUSSION_EMAIL_DIGEST": True})
......
......@@ -17,10 +17,6 @@ urlpatterns = patterns(
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}/metrics/social/$'.format(COURSE_ID_PATTERN), users_views.UsersSocialMetrics.as_view(), name='users-social-metrics'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}/completions/$'.format(COURSE_ID_PATTERN), users_views.UsersCoursesCompletionsList.as_view(), name='users-courses-completions-list'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}$'.format(COURSE_ID_PATTERN), users_views.UsersCoursesDetail.as_view(), name='users-courses-detail'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}/grades$'.format(COURSE_ID_PATTERN), users_views.UsersCoursesGradesDetail.as_view(), name='users-courses-grades-detail'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}/metrics/social/$'.format(COURSE_ID_PATTERN), users_views.UsersSocialMetrics.as_view(), name='users-social-metrics'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}/completions/$'.format(COURSE_ID_PATTERN), users_views.UsersCoursesCompletionsList.as_view(), name='users-courses-completions-list'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/{0}$'.format(COURSE_ID_PATTERN), users_views.UsersCoursesDetail.as_view(), name='users-courses-detail'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/courses/*$', users_views.UsersCoursesList.as_view(), name='users-courses-list'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/groups/*$', users_views.UsersGroupsList.as_view(), name='users-groups-list'),
url(r'^(?P<user_id>[a-zA-Z0-9]+)/groups/(?P<group_id>[0-9]+)$', users_views.UsersGroupsDetail.as_view(), name='users-groups-detail'),
......
......@@ -325,7 +325,9 @@ class UsersList(SecureListAPIView):
try:
user = User.objects.create(email=email, username=username, is_staff=is_staff)
except IntegrityError:
response_data['message'] = "User '%s' already exists" % (username)
response_data['message'] = _("Username '{username}' or email '{email}' already exists").format(
username=username, email=email
)
response_data['field_conflict'] = "username or email"
return Response(response_data, status=status.HTTP_409_CONFLICT)
......
......@@ -26,7 +26,7 @@ from edx_notifications.data import NotificationMessage
log = logging.getLogger(__name__)
@receiver(score_changed)
@receiver(score_changed, dispatch_uid="lms.courseware.score_changed")
def on_score_changed(sender, **kwargs):
"""
Listens for a 'score_changed' signal and when observed
......
......@@ -590,6 +590,8 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)),
}
SOCIAL_AUTH_RESPECT_X_FORWARDED_HEADERS = ENV_TOKENS.get('SOCIAL_AUTH_RESPECT_X_FORWARDED_HEADERS')
# FAKE EMAIL DOMAIN setting is used to generate an email for an automatically provisioned account in case
# it is not provided by IdP (which should'nt normally be the case for providers with automatic provisioning)
FAKE_EMAIL_DOMAIN = ENV_TOKENS.get('FAKE_EMAIL_DOMAIN', 'fake-email-domain.foo')
......@@ -598,6 +600,11 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
# IdP provided name is empty, missing or does not pass minimal length check
THIRD_PARTY_AUTH_FALLBACK_FULL_NAME = ENV_TOKENS.get('THIRD_PARTY_AUTH_FALLBACK_FULL_NAME', "Unknown")
# The following can be used to integrate a custom login form with third_party_auth.
# It should be a dict where the key is a word passed via ?auth_entry=, and the value is a
# dict with an arbitrary 'secret_key' and a 'url'.
THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = AUTH_TOKENS.get('THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {})
##### OAUTH2 Provider ##############
if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
OAUTH_OIDC_ISSUER = ENV_TOKENS['OAUTH_OIDC_ISSUER']
......
......@@ -2692,6 +2692,7 @@ NOTIFICATION_CLICK_LINK_URL_MAPS = {
'open-edx.lms.leaderboard.*': '/courses/{course_id}/cohort',
'open-edx.lms.discussions.*': '/courses/{course_id}/discussion/{commentable_id}/threads/{thread_id}',
'open-edx.xblock.group-project.*': '/courses/{course_id}/group_work?seqid={activity_location}',
'open-edx.xblock.group-project-v2.*': '/courses/{course_id}/group_work?activate_block_id={location}',
}
# list all known channel providers
......
......@@ -261,6 +261,14 @@ AUTHENTICATION_BACKENDS = (
FAKE_EMAIL_DOMAIN = 'fake-email-domain.foo'
THIRD_PARTY_AUTH_FALLBACK_FULL_NAME = "Unknown"
THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = {
'custom1': {
'secret_key': 'opensesame',
'url': '/misc/my-custom-registration-form',
'error_url': '/misc/my-custom-sso-error-page'
},
}
################################## OPENID #####################################
FEATURES['AUTH_USE_OPENID'] = True
FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
......
......@@ -652,6 +652,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'api/third_party_auth/', include('third_party_auth.api.urls')),
# NOTE: The following login_oauth_token endpoint is DEPRECATED.
# Please use the exchange_access_token endpoint instead.
url(r'^login_oauth_token/(?P<backend>[^/]+)/$', 'student.views.login_oauth_token'),
......
......@@ -30,7 +30,7 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
log.info(u'Added task to update credit requirements for course "%s" to the task queue', course_key)
@receiver(GRADES_UPDATED)
@receiver(GRADES_UPDATED, dispatch_uid="edxapp.credit.grades_updated")
def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument
"""Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade
requirement status.
......
# Custom requirements to be customized by individual OpenEdX instances
-e git+https://github.com/edx/xblock-utils.git@25f15734ec8d29fde0e114bbd199fd48638865ae#egg=xblock-utils
# When updating a hash of an XBlock that uses xblock-utils, please update xblock-utils version hash here and in
# github.txt as well. Deployments install custom.txt before github.txt and github.txt installs xblock-utils. This might
# lead to installing an outdated version of xblock-utils and causing regressions. A note in github.txt is added to
# keep xblock-utils version there in sync with this one.
-e git+https://github.com/edx/xblock-utils.git@3b58c757f06943072b170654d676e95b9adb37b0#egg=xblock-utils
-e git+https://github.com/edx-solutions/xblock-mentoring.git@bd0b3f413ae7e8274985555adfd7de7af3eca84c#egg=xblock-mentoring
-e git+https://github.com/edx-solutions/xblock-image-explorer.git@21b9bcc4f2c7917463ab18a596161ac6c58c9c4a#egg=xblock-image-explorer
-e git+https://github.com/edx-solutions/xblock-drag-and-drop.git@92ee2055a16899090a073e1df81e35d5293ad767#egg=xblock-drag-and-drop
-e git+https://github.com/edx-solutions/xblock-drag-and-drop-v2.git@5736ed8774b92c8b8396b5bd455f8a8fb80295fb#egg=xblock-drag-and-drop-v2
-e git+https://github.com/edx-solutions/xblock-drag-and-drop-v2.git@8dbe34cfb33ff72252ec66051108a2d2e757a498#egg=xblock-drag-and-drop-v2
-e git+https://github.com/edx-solutions/xblock-ooyala.git@42f769d422850df81bcbd2dbcc344f86b6a17d8e#egg=xblock-ooyala
-e git+https://github.com/edx-solutions/xblock-group-project.git@6b3393a1a5eb76224ecd3311e870ab8adf4badbf#egg=xblock-group-project
-e git+https://github.com/edx-solutions/xblock-adventure.git@effa22006bb6528bc6d3788787466eb4e74e1161#egg=xblock-adventure
-e git+https://github.com/mckinseyacademy/xblock-poll.git@ca0e6eb4ef10c128d573c3cec015dcfee7984730#egg=xblock-poll
-e git+https://github.com/edx/edx-notifications.git@8038452f6fbb2b95ad46f8fe7a2f80b145b45b9c#egg=edx-notifications
-e git+https://github.com/open-craft/problem-builder.git@cd2304e1add8a1a1c7d0eec08a27550e753ca9ae#egg=problem-builder
-e git+https://github.com/open-craft/xblock-group-project-v2.git@efa6a82c50ee8e78737b2488cbf0a77efe499c00#egg=xblock-group-project-v2
-e git+https://github.com/edx/edx-notifications.git@275b8354593048ecae3e06642985b702b81140cc#egg=edx-notifications
-e git+https://github.com/open-craft/problem-builder.git@fa5d5e59133b2fd95ebea1aabcaa36578775eb21#egg=problem-builder
-e git+https://github.com/open-craft/xblock-group-project-v2.git@648c357c2b57fe6fa5ff68a0c29e6e72f309b9ca#egg=xblock-group-project-v2
-e git+https://github.com/OfficeDev/xblock-officemix.git@86238f5968a08db005717dbddc346808f1ed3716#egg=xblock-officemix
-e git+https://github.com/edx-solutions/xblock.git@80d11e883cb0f4b554e1e566294cb7de383cffed#egg=xblock
......@@ -50,7 +50,8 @@ git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3
-e git+https://github.com/edx/edx-search.git@release-2015-07-03#egg=edx-search
-e git+https://github.com/edx/edx-milestones.git@release-2015-06-17#egg=edx-milestones
git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b#egg=edx_lint==0.2.3
-e git+https://github.com/edx/xblock-utils.git@213a97a50276d6a2504d8133650b2930ead357a0#egg=xblock-utils
# Note for the next rebase: custom.txt or one of XBlocks installed there might require a newer version of xblock-utils - please check versions
-e git+https://github.com/edx/xblock-utils.git@3b58c757f06943072b170654d676e95b9adb37b0#egg=xblock-utils
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
-e git+https://github.com/edx/edx-reverification-block.git@a286e89c73e1b788e35ac5b08a54b71a9fa63cfd#egg=edx-reverification-block
git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0
......
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