Commit 82bd46d5 by Ned Batchelder

Merge pull request #3450 from johncox-google/johncox/feature/buttons

Change signin control from text list to buttons.
parents 5edae3ed 441055d8
...@@ -141,3 +141,4 @@ William Desloge <william.desloge@ionis-group.com> ...@@ -141,3 +141,4 @@ William Desloge <william.desloge@ionis-group.com>
Marco Re <mrc.re@tiscali.it> Marco Re <mrc.re@tiscali.it>
Jonas Jelten <jelten@in.tum.de> Jonas Jelten <jelten@in.tum.de>
Christine Lytwynec <clytwynec@edx.org> Christine Lytwynec <clytwynec@edx.org>
John Cox <johncox@google.com>
...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes, ...@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
in roughly chronological order, most recent first. Add your entries at or near in roughly chronological order, most recent first. Add your entries at or near
the top. Include a label indicating the component affected. the top. Include a label indicating the component affected.
Common: Add extensible third-party auth module.
Blades: Handle situation if no response were sent from XQueue to LMS in Matlab Blades: Handle situation if no response were sent from XQueue to LMS in Matlab
problem after Run Code button press. BLD-994. problem after Run Code button press. BLD-994.
......
"""Middleware classes for third_party_auth."""
from social.apps.django_app.middleware import SocialAuthExceptionMiddleware
from . import pipeline
class ExceptionMiddleware(SocialAuthExceptionMiddleware):
"""Custom middleware that handles conditional redirection."""
def get_redirect_uri(self, request, exception):
# Safe because it's already been validated by
# pipeline.parse_query_params. If that pipeline step ever moves later
# in the pipeline stack, we'd need to validate this value because it
# would be an injection point for attacker data.
auth_entry = request.session.get(pipeline.AUTH_ENTRY_KEY)
# Fall back to django settings's SOCIAL_AUTH_LOGIN_ERROR_URL.
return '/' + auth_entry if auth_entry else super(ExceptionMiddleware, self).get_redirect_uri(request, exception)
...@@ -4,6 +4,10 @@ Loaded by Django's settings mechanism. Consequently, this module must not ...@@ -4,6 +4,10 @@ Loaded by Django's settings mechanism. Consequently, this module must not
invoke the Django armature. invoke the Django armature.
""" """
from social.backends import google, linkedin
_DEFAULT_ICON_CLASS = 'icon-signin'
class BaseProvider(object): class BaseProvider(object):
"""Abstract base class for third-party auth providers. """Abstract base class for third-party auth providers.
...@@ -12,13 +16,14 @@ class BaseProvider(object): ...@@ -12,13 +16,14 @@ class BaseProvider(object):
in the provider Registry. in the provider Registry.
""" """
# String. Dot-delimited module.Class. The name of the backend # Class. The provider's backing social.backends.base.BaseAuth child.
# implementation to load. BACKEND_CLASS = None
AUTHENTICATION_BACKEND = None # String. Name of the FontAwesome glyph to use for sign in buttons (or the
# name of a user-supplied custom glyph that is present at runtime).
ICON_CLASS = _DEFAULT_ICON_CLASS
# String. User-facing name of the provider. Must be unique across all # String. User-facing name of the provider. Must be unique across all
# enabled providers. # enabled providers. Will be presented in the UI.
NAME = None NAME = None
# Dict of string -> object. Settings that will be merged into Django's # Dict of string -> object. Settings that will be merged into Django's
# settings instance. In most cases the value will be None, since real # settings instance. In most cases the value will be None, since real
# values are merged from .json files (foo.auth.json; foo.env.json) onto the # values are merged from .json files (foo.auth.json; foo.env.json) onto the
...@@ -26,8 +31,81 @@ class BaseProvider(object): ...@@ -26,8 +31,81 @@ class BaseProvider(object):
SETTINGS = {} SETTINGS = {}
@classmethod @classmethod
def get_authentication_backend(cls):
"""Gets associated Django settings.AUTHENTICATION_BACKEND string."""
return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__)
@classmethod
def get_email(cls, unused_provider_details):
"""Gets user's email address.
Provider responses can contain arbitrary data. This method can be
overridden to extract an email address from the provider details
extracted by the social_details pipeline step.
Args:
unused_provider_details: dict of string -> string. Data about the
user passed back by the provider.
Returns:
String or None. The user's email address, if any.
"""
return None
@classmethod
def get_name(cls, unused_provider_details):
"""Gets user's name.
Provider responses can contain arbitrary data. This method can be
overridden to extract a full name for a user from the provider details
extracted by the social_details pipeline step.
Args:
unused_provider_details: dict of string -> string. Data about the
user passed back by the provider.
Returns:
String or None. The user's full name, if any.
"""
return None
@classmethod
def get_register_form_data(cls, pipeline_kwargs):
"""Gets dict of data to display on the register form.
common.djangoapps.student.views.register_user uses this to populate the
new account creation form with values supplied by the user's chosen
provider, preventing duplicate data entry.
Args:
pipeline_kwargs: dict of string -> object. Keyword arguments
accumulated by the pipeline thus far.
Returns:
Dict of string -> string. Keys are names of form fields; values are
values for that field. Where there is no value, the empty string
must be used.
"""
# Details about the user sent back from the provider.
details = pipeline_kwargs.get('details')
# Get the username separately to take advantage of the de-duping logic
# built into the pipeline. The provider cannot de-dupe because it can't
# check the state of taken usernames in our system. Note that there is
# technically a data race between the creation of this value and the
# creation of the user object, so it is still possible for users to get
# an error on submit.
suggested_username = pipeline_kwargs.get('username')
return {
'email': cls.get_email(details) or '',
'name': cls.get_name(details) or '',
'username': suggested_username,
}
@classmethod
def merge_onto(cls, settings): def merge_onto(cls, settings):
"""Merge class-level settings onto a django `settings` module.""" """Merge class-level settings onto a django settings module."""
for key, value in cls.SETTINGS.iteritems(): for key, value in cls.SETTINGS.iteritems():
setattr(settings, key, value) setattr(settings, key, value)
...@@ -35,30 +113,41 @@ class BaseProvider(object): ...@@ -35,30 +113,41 @@ class BaseProvider(object):
class GoogleOauth2(BaseProvider): class GoogleOauth2(BaseProvider):
"""Provider for Google's Oauth2 auth system.""" """Provider for Google's Oauth2 auth system."""
AUTHENTICATION_BACKEND = 'social.backends.google.GoogleOAuth2' BACKEND_CLASS = google.GoogleOAuth2
ICON_CLASS = 'icon-google-plus'
NAME = 'Google' NAME = 'Google'
SETTINGS = { SETTINGS = {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None, 'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None,
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None, 'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None,
} }
@classmethod
def get_email(cls, provider_details):
return provider_details.get('email')
@classmethod
def get_name(cls, provider_details):
return provider_details.get('fullname')
class LinkedInOauth2(BaseProvider): class LinkedInOauth2(BaseProvider):
"""Provider for LinkedIn's Oauth2 auth system.""" """Provider for LinkedIn's Oauth2 auth system."""
AUTHENTICATION_BACKEND = 'social.backends.linkedin.LinkedinOAuth2' BACKEND_CLASS = linkedin.LinkedinOAuth2
ICON_CLASS = 'icon-linkedin'
NAME = 'LinkedIn' NAME = 'LinkedIn'
SETTINGS = { SETTINGS = {
'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None, 'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None,
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None, 'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None,
} }
@classmethod
def get_email(cls, provider_details):
return provider_details.get('email')
class MozillaPersona(BaseProvider): @classmethod
"""Provider for Mozilla's Persona auth system.""" def get_name(cls, provider_details):
return provider_details.get('fullname')
AUTHENTICATION_BACKEND = 'social.backends.persona.PersonaAuth'
NAME = 'Mozilla Persona'
class Registry(object): class Registry(object):
...@@ -84,7 +173,7 @@ class Registry(object): ...@@ -84,7 +173,7 @@ class Registry(object):
@classmethod @classmethod
def _enable(cls, provider): def _enable(cls, provider):
"""Enables a single `provider`.""" """Enables a single provider."""
if provider.NAME in cls._ENABLED: if provider.NAME in cls._ENABLED:
raise ValueError('Provider %s already enabled' % provider.NAME) raise ValueError('Provider %s already enabled' % provider.NAME)
cls._ENABLED[provider.NAME] = provider cls._ENABLED[provider.NAME] = provider
...@@ -93,10 +182,17 @@ class Registry(object): ...@@ -93,10 +182,17 @@ class Registry(object):
def configure_once(cls, provider_names): def configure_once(cls, provider_names):
"""Configures providers. """Configures providers.
Takes `provider_names`, a list of string. Args:
provider_names: list of string. The providers to configure.
Raises:
ValueError: if the registry has already been configured, or if any
of the passed provider_names does not have a corresponding
BaseProvider child implementation.
""" """
if cls._CONFIGURED: if cls._CONFIGURED:
raise ValueError('Provider registry already configured') raise ValueError('Provider registry already configured')
# Flip the bit eagerly -- configure() should not be re-callable if one # Flip the bit eagerly -- configure() should not be re-callable if one
# _enable call fails. # _enable call fails.
cls._CONFIGURED = True cls._CONFIGURED = True
...@@ -114,11 +210,28 @@ class Registry(object): ...@@ -114,11 +210,28 @@ class Registry(object):
@classmethod @classmethod
def get(cls, provider_name): def get(cls, provider_name):
"""Gets provider named `provider_name` string if enabled, else None.""" """Gets provider named provider_name string if enabled, else None."""
cls._check_configured() cls._check_configured()
return cls._ENABLED.get(provider_name) return cls._ENABLED.get(provider_name)
@classmethod @classmethod
def get_by_backend_name(cls, backend_name):
"""Gets provider (or None) by backend name.
Args:
backend_name: string. The python-social-auth
backends.base.BaseAuth.name (for example, 'google-oauth2') to
try and get a provider for.
Raises:
RuntimeError: if the registry has not been configured.
"""
cls._check_configured()
for enabled in cls._ENABLED.values():
if enabled.BACKEND_CLASS.name == backend_name:
return enabled
@classmethod
def _reset(cls): def _reset(cls):
"""Returns the registry to an unconfigured state; for tests only.""" """Returns the registry to an unconfigured state; for tests only."""
cls._CONFIGURED = False cls._CONFIGURED = False
......
...@@ -46,8 +46,15 @@ If true, it: ...@@ -46,8 +46,15 @@ If true, it:
from . import provider from . import provider
_FIELDS_STORED_IN_SESSION = ['auth_entry']
_MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
)
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
def _merge_auth_info(django_settings, auth_info): def _merge_auth_info(django_settings, auth_info):
"""Merge `auth_info` dict onto `django_settings` module.""" """Merge auth_info dict onto django_settings module."""
enabled_provider_names = [] enabled_provider_names = []
to_merge = [] to_merge = []
...@@ -66,39 +73,77 @@ def _merge_auth_info(django_settings, auth_info): ...@@ -66,39 +73,77 @@ def _merge_auth_info(django_settings, auth_info):
def _set_global_settings(django_settings): def _set_global_settings(django_settings):
"""Set provider-independent settings.""" """Set provider-independent settings."""
# Whitelisted URL query parameters retrained in the pipeline session.
# Params not in this whitelist will be silently dropped.
django_settings.FIELDS_STORED_IN_SESSION = _FIELDS_STORED_IN_SESSION
# Register and configure python-social-auth with Django. # Register and configure python-social-auth with Django.
django_settings.INSTALLED_APPS += ( django_settings.INSTALLED_APPS += (
'social.apps.django_app.default', 'social.apps.django_app.default',
'third_party_auth', 'third_party_auth',
) )
django_settings.TEMPLATE_CONTEXT_PROCESSORS += (
'social.apps.django_app.context_processors.backends', # Inject exception middleware to make redirects fire.
'social.apps.django_app.context_processors.login_redirect', django_settings.MIDDLEWARE_CLASSES += _MIDDLEWARE_CLASSES
)
# Where to send the user if there's an error during social authentication
# and we cannot send them to a more specific URL
# (see middleware.ExceptionMiddleware).
django_settings.SOCIAL_AUTH_LOGIN_ERROR_URL = '/'
# Where to send the user once social authentication is successful.
django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL = _SOCIAL_AUTH_LOGIN_REDIRECT_URL
# Inject our customized auth pipeline. All auth backends must work with # Inject our customized auth pipeline. All auth backends must work with
# this pipeline. # this pipeline.
django_settings.SOCIAL_AUTH_PIPELINE = ( django_settings.SOCIAL_AUTH_PIPELINE = (
'third_party_auth.pipeline.step', 'third_party_auth.pipeline.parse_query_params',
'social.pipeline.social_auth.social_details',
'social.pipeline.social_auth.social_uid',
'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',
'social.pipeline.user.create_user',
'social.pipeline.social_auth.associate_user',
'social.pipeline.social_auth.load_extra_data',
'social.pipeline.user.user_details',
)
# We let the user specify their email address during signup.
django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email']
# Disable exceptions by default for prod so you get redirect behavior
# instead of a Django error page. During development you may want to
# enable this when you want to get stack traces rather than redirections.
django_settings.SOCIAL_AUTH_RAISE_EXCEPTIONS = False
# Context processors required under Django.
django_settings.SOCIAL_AUTH_UUID_LENGTH = 4
django_settings.TEMPLATE_CONTEXT_PROCESSORS += (
'social.apps.django_app.context_processors.backends',
'social.apps.django_app.context_processors.login_redirect',
) )
def _set_provider_settings(django_settings, enabled_providers, auth_info): def _set_provider_settings(django_settings, enabled_providers, auth_info):
"""Set provider-specific settings.""" """Sets provider-specific settings."""
# Must prepend here so we get called first. # Must prepend here so we get called first.
django_settings.AUTHENTICATION_BACKENDS = ( django_settings.AUTHENTICATION_BACKENDS = (
tuple(enabled_provider.AUTHENTICATION_BACKEND for enabled_provider in enabled_providers) + tuple(enabled_provider.get_authentication_backend() for enabled_provider in enabled_providers) +
django_settings.AUTHENTICATION_BACKENDS) django_settings.AUTHENTICATION_BACKENDS)
# Merge settings from provider classes, and configure all placeholders. # Merge settings from provider classes, and configure all placeholders.
for enabled_provider in enabled_providers: for enabled_provider in enabled_providers:
enabled_provider.merge_onto(django_settings) enabled_provider.merge_onto(django_settings)
# Merge settings from <deployment>.auth.json. # Merge settings from <deployment>.auth.json, overwriting placeholders.
_merge_auth_info(django_settings, auth_info) _merge_auth_info(django_settings, auth_info)
def apply_settings(auth_info, django_settings): def apply_settings(auth_info, django_settings):
"""Apply settings from `auth_info` dict to `django_settings` module.""" """Applies settings from auth_info dict to django_settings module."""
provider_names = auth_info.keys() provider_names = auth_info.keys()
provider.Registry.configure_once(provider_names) provider.Registry.configure_once(provider_names)
enabled_providers = provider.Registry.enabled() enabled_providers = provider.Registry.enabled()
......
"""Integration tests for Google providers."""
from third_party_auth import provider
from third_party_auth.tests.specs import base
class GoogleOauth2IntegrationTest(base.Oauth2IntegrationTest):
"""Integration tests for provider.GoogleOauth2."""
PROVIDER_CLASS = provider.GoogleOauth2
PROVIDER_SETTINGS = {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key',
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': 'google_oauth2_secret',
}
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
'id_token': 'id_token_value',
'token_type': 'token_type_value',
}
USER_RESPONSE_DATA = {
'email': 'email_value@example.com',
'family_name': 'family_name_value',
'given_name': 'given_name_value',
'id': 'id_value',
'link': 'link_value',
'locale': 'locale_value',
'name': 'name_value',
'picture': 'picture_value',
'verified_email': 'verified_email_value',
}
def get_username(self):
return self.get_response_data().get('email').split('@')[0]
"""Integration tests for LinkedIn providers."""
from third_party_auth import provider
from third_party_auth.tests.specs import base
class LinkedInOauth2IntegrationTest(base.Oauth2IntegrationTest):
"""Integration tests for provider.LinkedInOauth2."""
PROVIDER_CLASS = provider.LinkedInOauth2
PROVIDER_SETTINGS = {
'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_oauth2_key',
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': 'linkedin_oauth2_secret',
}
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
}
USER_RESPONSE_DATA = {
'lastName': 'lastName_value',
'id': 'id_value',
'firstName': 'firstName_value',
}
def get_username(self):
response_data = self.get_response_data()
return response_data.get('firstName') + response_data.get('lastName')
"""Unit tests for third_party_auth/pipeline.py."""
import random
from third_party_auth import pipeline, provider
from third_party_auth.tests import testutil
# Allow tests access to protected methods (or module-protected methods) under
# test. pylint: disable-msg=protected-access
class MakeRandomPasswordTest(testutil.TestCase):
"""Tests formation of random placeholder passwords."""
def setUp(self):
super(MakeRandomPasswordTest, self).setUp()
self.seed = 1
def test_default_args(self):
self.assertEqual(pipeline._DEFAULT_RANDOM_PASSWORD_LENGTH, len(pipeline.make_random_password()))
def test_probably_only_uses_charset(self):
# This is ultimately probablistic since we could randomly select a good character 100000 consecutive times.
for char in pipeline.make_random_password(length=100000):
self.assertIn(char, pipeline._PASSWORD_CHARSET)
def test_pseudorandomly_picks_chars_from_charset(self):
random_instance = random.Random(self.seed)
expected = ''.join(
random_instance.choice(pipeline._PASSWORD_CHARSET)
for _ in xrange(pipeline._DEFAULT_RANDOM_PASSWORD_LENGTH))
random_instance.seed(self.seed)
self.assertEqual(expected, pipeline.make_random_password(choice_fn=random_instance.choice))
class ProviderUserStateTestCase(testutil.TestCase):
"""Tests ProviderUserState behavior."""
def test_get_unlink_form_name(self):
state = pipeline.ProviderUserState(provider.GoogleOauth2, object(), False)
self.assertEqual(provider.GoogleOauth2.NAME + '_unlink_form', state.get_unlink_form_name())
"""Integration tests for pipeline.py."""
import unittest
from django.conf import settings
from django import test
from django.contrib.auth import models
from third_party_auth import pipeline, provider
from third_party_auth.tests import testutil
from social.apps.django_app.default import models as social_models
# Get Django User model by reference from python-social-auth. Not a type
# constant, pylint.
User = social_models.DjangoStorage.user.user_model() # pylint: disable-msg=invalid-name
class TestCase(testutil.TestCase, test.TestCase):
"""Base test case."""
def setUp(self):
super(TestCase, self).setUp()
self.enabled_provider_name = provider.GoogleOauth2.NAME
provider.Registry.configure_once([self.enabled_provider_name])
self.enabled_provider = provider.Registry.get(self.enabled_provider_name)
@unittest.skipUnless(
testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES')
class GetAuthenticatedUserTestCase(TestCase):
"""Tests for get_authenticated_user."""
def setUp(self):
super(GetAuthenticatedUserTestCase, self).setUp()
self.user = social_models.DjangoStorage.user.create_user(username='username', password='password')
def get_by_username(self, username):
"""Gets a User by username."""
return social_models.DjangoStorage.user.user_model().objects.get(username=username)
def test_raises_does_not_exist_if_user_missing(self):
with self.assertRaises(models.User.DoesNotExist):
pipeline.get_authenticated_user('new_' + self.user.username, 'backend')
def test_raises_does_not_exist_if_user_found_but_no_association(self):
backend_name = 'backend'
self.assertIsNotNone(self.get_by_username(self.user.username))
self.assertIsNone(provider.Registry.get_by_backend_name(backend_name))
with self.assertRaises(models.User.DoesNotExist):
pipeline.get_authenticated_user(self.user.username, 'backend')
def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self):
self.assertIsNotNone(self.get_by_username(self.user.username))
social_models.DjangoStorage.user.create_social_auth(
self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name)
with self.assertRaises(models.User.DoesNotExist):
pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name)
def test_returns_user_with_is_authenticated_and_backend_set_if_match(self):
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name)
user = pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name)
self.assertEqual(self.user, user)
self.assertEqual(self.enabled_provider.get_authentication_backend(), user.backend)
@unittest.skipUnless(
testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES')
class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase):
"""Tests generation of ProviderUserStates."""
def setUp(self):
super(GetProviderUserStatesTestCase, self).setUp()
self.user = social_models.DjangoStorage.user.create_user(username='username', password='password')
def test_returns_empty_list_if_no_enabled_providers(self):
provider.Registry.configure_once([])
self.assertEquals([], pipeline.get_provider_user_states(self.user))
def test_state_not_returned_for_disabled_provider(self):
disabled_provider = provider.GoogleOauth2
enabled_provider = provider.LinkedInOauth2
provider.Registry.configure_once([enabled_provider.NAME])
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.BACKEND_CLASS.name)
states = pipeline.get_provider_user_states(self.user)
self.assertEqual(1, len(states))
self.assertNotIn(disabled_provider, (state.provider for state in states))
def test_states_for_enabled_providers_user_has_accounts_associated_with(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME])
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name)
social_models.DjangoStorage.user.create_social_auth(
self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name)
states = pipeline.get_provider_user_states(self.user)
self.assertEqual(2, len(states))
google_state = [state for state in states if state.provider == provider.GoogleOauth2][0]
linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0]
self.assertTrue(google_state.has_account)
self.assertEqual(provider.GoogleOauth2, google_state.provider)
self.assertEqual(self.user, google_state.user)
self.assertTrue(linkedin_state.has_account)
self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider)
self.assertEqual(self.user, linkedin_state.user)
def test_states_for_enabled_providers_user_has_no_account_associated_with(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME])
states = pipeline.get_provider_user_states(self.user)
self.assertEqual([], [x for x in social_models.DjangoStorage.user.objects.all()])
self.assertEqual(2, len(states))
google_state = [state for state in states if state.provider == provider.GoogleOauth2][0]
linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0]
self.assertFalse(google_state.has_account)
self.assertEqual(provider.GoogleOauth2, google_state.provider)
self.assertEqual(self.user, google_state.user)
self.assertFalse(linkedin_state.has_account)
self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider)
self.assertEqual(self.user, linkedin_state.user)
@unittest.skipUnless(
testutil.AUTH_FEATURES_KEY in settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES')
class UrlFormationTestCase(TestCase):
"""Tests formation of URLs for pipeline hook points."""
def test_complete_url_raises_value_error_if_provider_not_enabled(self):
provider_name = 'not_enabled'
self.assertIsNone(provider.Registry.get(provider_name))
with self.assertRaises(ValueError):
pipeline.get_complete_url(provider_name)
def test_complete_url_returns_expected_format(self):
complete_url = pipeline.get_complete_url(self.enabled_provider.BACKEND_CLASS.name)
self.assertTrue(complete_url.startswith('/auth/complete'))
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, complete_url)
def test_disconnect_url_raises_value_error_if_provider_not_enabled(self):
provider_name = 'not_enabled'
self.assertIsNone(provider.Registry.get(provider_name))
with self.assertRaises(ValueError):
pipeline.get_disconnect_url(provider_name)
def test_disconnect_url_returns_expected_format(self):
disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME)
self.assertTrue(disconnect_url.startswith('/auth/disconnect'))
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, disconnect_url)
def test_login_url_raises_value_error_if_provider_not_enabled(self):
provider_name = 'not_enabled'
self.assertIsNone(provider.Registry.get(provider_name))
with self.assertRaises(ValueError):
pipeline.get_login_url(provider_name, pipeline.AUTH_ENTRY_LOGIN)
def test_login_url_returns_expected_format(self):
login_url = pipeline.get_login_url(self.enabled_provider.NAME, pipeline.AUTH_ENTRY_LOGIN)
self.assertTrue(login_url.startswith('/auth/login'))
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, login_url)
self.assertTrue(login_url.endswith(pipeline.AUTH_ENTRY_LOGIN))
""" """Unit tests for provider.py."""
Test configuration of providers.
"""
from third_party_auth import provider from third_party_auth import provider
from third_party_auth.tests import testutil from third_party_auth.tests import testutil
...@@ -10,8 +8,7 @@ class RegistryTest(testutil.TestCase): ...@@ -10,8 +8,7 @@ class RegistryTest(testutil.TestCase):
"""Tests registry discovery and operation.""" """Tests registry discovery and operation."""
# Allow access to protected methods (or module-protected methods) under # Allow access to protected methods (or module-protected methods) under
# test. # test. pylint: disable-msg=protected-access
# pylint: disable-msg=protected-access
def test_calling_configure_once_twice_raises_value_error(self): def test_calling_configure_once_twice_raises_value_error(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME]) provider.Registry.configure_once([provider.GoogleOauth2.NAME])
...@@ -68,4 +65,18 @@ class RegistryTest(testutil.TestCase): ...@@ -68,4 +65,18 @@ class RegistryTest(testutil.TestCase):
def test_get_returns_none_if_provider_not_enabled(self): def test_get_returns_none_if_provider_not_enabled(self):
provider.Registry.configure_once([]) provider.Registry.configure_once([])
self.assertIsNone(provider.Registry.get(provider.MozillaPersona.NAME)) self.assertIsNone(provider.Registry.get(provider.LinkedInOauth2.NAME))
def test_get_by_backend_name_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.get_by_backend_name('')
def test_get_by_backend_name_returns_enabled_provider(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(
provider.GoogleOauth2,
provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name))
def test_get_by_backend_name_returns_none_if_provider_not_enabled(self):
provider.Registry.configure_once([])
self.assertIsNone(provider.Registry.get_by_backend_name(provider.GoogleOauth2.BACKEND_CLASS.name))
""" """Unit tests for settings.py."""
Unit tests for settings code.
"""
from third_party_auth import provider from third_party_auth import provider, settings
from third_party_auth import settings
from third_party_auth.tests import testutil from third_party_auth.tests import testutil
_ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',) _ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',)
_ORIGINAL_INSTALLED_APPS = ('first_installed_app',) _ORIGINAL_INSTALLED_APPS = ('first_installed_app',)
_ORIGINAL_MIDDLEWARE_CLASSES = ('first_middleware_class',)
_ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS = ('first_template_context_preprocessor',) _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS = ('first_template_context_preprocessor',)
_SETTINGS_MAP = { _SETTINGS_MAP = {
'AUTHENTICATION_BACKENDS': _ORIGINAL_AUTHENTICATION_BACKENDS, 'AUTHENTICATION_BACKENDS': _ORIGINAL_AUTHENTICATION_BACKENDS,
'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS, 'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS,
'MIDDLEWARE_CLASSES': _ORIGINAL_MIDDLEWARE_CLASSES,
'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS, 'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS,
} }
...@@ -20,6 +19,8 @@ _SETTINGS_MAP = { ...@@ -20,6 +19,8 @@ _SETTINGS_MAP = {
class SettingsUnitTest(testutil.TestCase): class SettingsUnitTest(testutil.TestCase):
"""Unit tests for settings management code.""" """Unit tests for settings management code."""
# Allow access to protected methods (or module-protected methods) under
# test. pylint: disable-msg=protected-access
# Suppress sprurious no-member warning on fakes. # Suppress sprurious no-member warning on fakes.
# pylint: disable-msg=no-member # pylint: disable-msg=no-member
...@@ -27,6 +28,15 @@ class SettingsUnitTest(testutil.TestCase): ...@@ -27,6 +28,15 @@ class SettingsUnitTest(testutil.TestCase):
super(SettingsUnitTest, self).setUp() super(SettingsUnitTest, self).setUp()
self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP) self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP)
def test_apply_settings_adds_exception_middleware(self):
settings.apply_settings({}, self.settings)
for middleware_name in settings._MIDDLEWARE_CLASSES:
self.assertIn(middleware_name, self.settings.MIDDLEWARE_CLASSES)
def test_apply_settings_adds_fields_stored_in_session(self):
settings.apply_settings({}, self.settings)
self.assertEqual(settings._FIELDS_STORED_IN_SESSION, self.settings.FIELDS_STORED_IN_SESSION)
def test_apply_settings_adds_third_party_auth_to_installed_apps(self): def test_apply_settings_adds_third_party_auth_to_installed_apps(self):
settings.apply_settings({}, self.settings) settings.apply_settings({}, self.settings)
self.assertIn('third_party_auth', self.settings.INSTALLED_APPS) self.assertIn('third_party_auth', self.settings.INSTALLED_APPS)
...@@ -50,9 +60,9 @@ class SettingsUnitTest(testutil.TestCase): ...@@ -50,9 +60,9 @@ class SettingsUnitTest(testutil.TestCase):
def test_apply_settings_prepends_auth_backends(self): def test_apply_settings_prepends_auth_backends(self):
self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS) self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS)
settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.MozillaPersona.NAME: {}}, self.settings) settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.LinkedInOauth2.NAME: {}}, self.settings)
self.assertEqual(( self.assertEqual((
provider.GoogleOauth2.AUTHENTICATION_BACKEND, provider.MozillaPersona.AUTHENTICATION_BACKEND) + provider.GoogleOauth2.get_authentication_backend(), provider.LinkedInOauth2.get_authentication_backend()) +
_ORIGINAL_AUTHENTICATION_BACKENDS, _ORIGINAL_AUTHENTICATION_BACKENDS,
self.settings.AUTHENTICATION_BACKENDS) self.settings.AUTHENTICATION_BACKENDS)
...@@ -66,3 +76,9 @@ class SettingsUnitTest(testutil.TestCase): ...@@ -66,3 +76,9 @@ class SettingsUnitTest(testutil.TestCase):
} }
with self.assertRaisesRegexp(ValueError, '^.*not initialized$'): with self.assertRaisesRegexp(ValueError, '^.*not initialized$'):
settings.apply_settings(auth_info, self.settings) settings.apply_settings(auth_info, self.settings)
def test_apply_settings_turns_off_raising_social_exceptions(self):
# Guard against submitting a conf change that's convenient in dev but
# bad in prod.
settings.apply_settings({}, self.settings)
self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS)
""" """Integration tests for settings.py."""
Integration tests for settings code.
"""
import mock
import unittest
from django.conf import settings from django.conf import settings
...@@ -11,29 +6,22 @@ from third_party_auth import provider ...@@ -11,29 +6,22 @@ from third_party_auth import provider
from third_party_auth import settings as auth_settings from third_party_auth import settings as auth_settings
from third_party_auth.tests import testutil from third_party_auth.tests import testutil
_AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
class SettingsIntegrationTest(testutil.TestCase): class SettingsIntegrationTest(testutil.TestCase):
"""Integration tests of auth settings pipeline.""" """Integration tests of auth settings pipeline.
@unittest.skipUnless(_AUTH_FEATURES_KEY in settings.FEATURES, _AUTH_FEATURES_KEY + ' not in settings.FEATURES') Note that ENABLE_THIRD_PARTY_AUTH is True in lms/envs/test.py and False in
def test_enable_third_party_auth_is_disabled_by_default(self): cms/envs/test.py. This implicitly gives us coverage of the full settings
self.assertIs(False, settings.FEATURES.get(_AUTH_FEATURES_KEY)) mechanism with both values, so we do not have explicit test methods as they
are superfluous.
"""
@mock.patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_AUTH': True})
def test_can_enable_google_oauth2(self): def test_can_enable_google_oauth2(self):
auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings) auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings)
self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled()) self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled())
self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY) self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY)
@mock.patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_AUTH': True})
def test_can_enable_linkedin_oauth2(self): def test_can_enable_linkedin_oauth2(self):
auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings) auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings)
self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled()) self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled())
self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY) self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY)
@mock.patch.dict(settings.FEATURES, {'ENABLE_THIRD_PARTY_ATUH': True})
def test_can_enable_mozilla_persona(self):
auth_settings.apply_settings({'Mozilla Persona': {}}, settings)
self.assertEqual([provider.MozillaPersona], provider.Registry.enabled())
...@@ -9,11 +9,14 @@ import unittest ...@@ -9,11 +9,14 @@ import unittest
from third_party_auth import provider from third_party_auth import provider
AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
class FakeDjangoSettings(object): class FakeDjangoSettings(object):
"""A fake for Django settings.""" """A fake for Django settings."""
def __init__(self, mappings): def __init__(self, mappings):
"""Initializes the fake from `mappings`, a dict.""" """Initializes the fake from mappings dict."""
for key, value in mappings.iteritems(): for key, value in mappings.iteritems():
setattr(self, key, value) setattr(self, key, value)
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
from django.conf.urls import include, patterns, url from django.conf.urls import include, patterns, url
urlpatterns = patterns( urlpatterns = patterns(
'', url(r'^auth/', include('social.apps.django_app.urls', namespace='social')), '',
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
) )
...@@ -107,6 +107,10 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False ...@@ -107,6 +107,10 @@ FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Use the auto_auth workflow for creating users and logging them in # Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
# Third-party auth is enabled in lms/envs/test.py for unittests, but we don't
# yet want it for acceptance tests.
FEATURES['ENABLE_THIRD_PARTY_AUTH'] = False
# Enable fake payment processing page # Enable fake payment processing page
FEATURES['ENABLE_PAYMENT_FAKE'] = True FEATURES['ENABLE_PAYMENT_FAKE'] = True
......
...@@ -180,6 +180,9 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' ...@@ -180,6 +180,9 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
# hide ratelimit warnings while running tests # hide ratelimit warnings while running tests
filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit') filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit')
######### Third-party auth ##########
FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True
################################## OPENID ##################################### ################################## OPENID #####################################
FEATURES['AUTH_USE_OPENID'] = True FEATURES['AUTH_USE_OPENID'] = True
FEATURES['AUTH_USE_OPENID_PROVIDER'] = True FEATURES['AUTH_USE_OPENID_PROVIDER'] = True
......
...@@ -450,6 +450,20 @@ ...@@ -450,6 +450,20 @@
} }
} }
// forms - third-party auth
.form-third-party-auth {
margin-bottom: $baseline;
button {
margin-right: $baseline;
.icon {
color: inherit;
margin-right: $baseline/2;
}
}
}
// forms - messages/status // forms - messages/status
.status { .status {
@include box-sizing(border-box); @include box-sizing(border-box);
......
...@@ -130,6 +130,23 @@ ...@@ -130,6 +130,23 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
.third-party-auth {
color: inherit;
font-weight: inherit;
.control {
float: right;
}
.icon {
margin-top: 4px;
}
.provider {
display: inline;
}
}
} }
} }
} }
......
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from django.template import RequestContext %> <%! from django.template import RequestContext %>
<%! from third_party_auth import pipeline %>
<%! <%!
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -194,6 +195,13 @@ ...@@ -194,6 +195,13 @@
</section> </section>
%endif %endif
% if duplicate_provider:
<section class="dashboard-banner third-party-auth">
## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment.
${_('The selected {provider_name} account is already linked to another {platform_name} account. Please {link_start}log out{link_end}, then log in with your {provider_name} account.').format(link_end='</a>', link_start='<a href="%s">' % logout_url, provider_name='<strong>%s</strong>' % duplicate_provider.NAME, platform_name=platform_name)}
</section>
% endif
<section class="profile-sidebar"> <section class="profile-sidebar">
<header class="profile"> <header class="profile">
<h1 class="user-name">${ user.username }</h1> <h1 class="user-name">${ user.username }</h1>
...@@ -215,6 +223,53 @@ ...@@ -215,6 +223,53 @@
<%include file='dashboard/_dashboard_info_language.html' /> <%include file='dashboard/_dashboard_info_language.html' />
%endif %endif
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
<li class="controls--account">
<span class="title">
<div class="icon icon-gears"></div>
## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account.
${_("Account Links")}
</span>
<span class="data">
<span class="third-party-auth">
% for state in provider_user_states:
<div>
% if state.has_account:
<span class="icon icon-link pull-left"></span>
% else:
<span class="icon icon-unlink pull-left"></span>
% endif
<span class="provider">${state.provider.NAME}</span>
<span class="control">
% if state.has_account:
<form
action="${pipeline.get_disconnect_url(state.provider.NAME)}"
method="post"
name="${state.get_unlink_form_name()}">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
</form>
<a href="#" onclick="document.${state.get_unlink_form_name()}.submit()">
## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
${_("unlink")}
</a>
% else:
<a href="${pipeline.get_login_url(state.provider.NAME, pipeline.AUTH_ENTRY_DASHBOARD)}">
## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
${_("link")}
</a>
% endif
</span>
</div>
% endfor
</span>
</li>
% endif
% if external_auth_map is None or 'shib' not in external_auth_map.external_domain: % if external_auth_map is None or 'shib' not in external_auth_map.external_domain:
<li class="controls--account"> <li class="controls--account">
<span class="title"><div class="icon"></div><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">${_("Reset Password")}</a></span> <span class="title"><div class="icon"></div><a href="#password_reset_complete" rel="leanModal" id="pwd_reset_button">${_("Reset Password")}</a></span>
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
<%! from django.core.urlresolvers import reverse %> <%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from third_party_auth import provider, pipeline %>
<%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)}</%block> <%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)}</%block>
...@@ -93,6 +94,22 @@ ...@@ -93,6 +94,22 @@
text("${_(u'Processing your account information…')}"); text("${_(u'Processing your account information…')}");
} }
} }
function thirdPartySignin(event, url) {
event.preventDefault();
window.location.href = url;
}
(function post_form_if_pipeline_running(pipeline_running) {
// If the pipeline is running, the user has already authenticated via a
// third-party provider. We want to invoke /login_ajax to loop in the
// code that does logging and sets cookies on the request. It is most
// consistent to do that by using the same mechanism that is used when
// the use does first-party sign-in: POSTing the sign-in form.
if (pipeline_running) {
$('#login-form').submit();
}
})(${pipeline_running})
</script> </script>
</%block> </%block>
...@@ -164,6 +181,28 @@ ...@@ -164,6 +181,28 @@
<button name="submit" type="submit" id="submit" class="action action-primary action-update"></button> <button name="submit" type="submit" id="submit" class="action action-primary action-update"></button>
</div> </div>
</form> </form>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
<hr />
<p class="instructions">
## Developers: this is a sentence fragment, which is usually frowned upon. The design of the pags uses this fragment to provide an "else" clause underneath a number of choices. It's OK to leave it.
## Translators: this is the last choice of a number of choices of how to log in to the site.
${_('or, if you have connected one of these providers, log in below.')}
</p>
<div class="form-actions form-third-party-auth">
% for enabled in provider.Registry.enabled():
## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn).
<button type="submit" class="button button-primary" onclick="thirdPartySignin(event, '${pipeline.get_login_url(enabled.NAME, pipeline.AUTH_ENTRY_LOGIN)}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button>
% endfor
</div>
% endif
</section> </section>
<aside role="complementary"> <aside role="complementary">
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
<%! from django.utils.translation import ugettext as _ %> <%! from django.utils.translation import ugettext as _ %>
<%! from student.models import UserProfile %> <%! from student.models import UserProfile %>
<%! from datetime import date %> <%! from datetime import date %>
<%! from third_party_auth import pipeline, provider %>
<%! import calendar %> <%! import calendar %>
<%block name="pagetitle">${_("Register for {platform_name}").format(platform_name=platform_name)}</%block> <%block name="pagetitle">${_("Register for {platform_name}").format(platform_name=platform_name)}</%block>
...@@ -67,6 +68,11 @@ ...@@ -67,6 +68,11 @@
}); });
})(this); })(this);
function thirdPartySignin(event, url) {
event.preventDefault();
window.location.href = url;
}
function toggleSubmitButton(enable) { function toggleSubmitButton(enable) {
var $submitButton = $('form .form-actions #submit'); var $submitButton = $('form .form-actions #submit');
...@@ -110,11 +116,46 @@ ...@@ -110,11 +116,46 @@
<ul class="message-copy"> </ul> <ul class="message-copy"> </ul>
</div> </div>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
% if not running_pipeline:
<p class="instructions">
${_("Register to start learning today!")}
</p>
<div class="form-actions form-third-party-auth">
% for enabled in provider.Registry.enabled():
## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn).
<button type="submit" class="button button-primary" onclick="thirdPartySignin(event, '${pipeline.get_login_url(enabled.NAME, pipeline.AUTH_ENTRY_REGISTER)}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button>
% endfor
</div>
<p class="instructions">
${_('or create your own {platform_name} account by completing all <strong>required*</strong> fields below.').format(platform_name=platform_name)}
</p>
% else:
<p class="instructions">
## Translators: selected_provider is the name of an external, third-party user authentication service (like Google or LinkedIn).
${_("You've successfully signed in with {selected_provider}.").format(selected_provider='<strong>%s</strong>' % selected_provider)}<br />
${_("Finish your account registration below to start learning.")}
</p>
% endif
% else:
<p class="instructions"> <p class="instructions">
${_("Please complete the following fields to register for an account. ")}<br /> ${_("Please complete the following fields to register for an account. ")}<br />
${_('Required fields are noted by <strong class="indicator">bold text and an asterisk (*)</strong>.')} ${_('Required fields are noted by <strong class="indicator">bold text and an asterisk (*)</strong>.')}
</p> </p>
% endif
<div class="group group-form group-form-requiredinformation"> <div class="group group-form group-form-requiredinformation">
<h2 class="sr">${_('Required Information')}</h2> <h2 class="sr">${_('Required Information')}</h2>
...@@ -123,20 +164,33 @@ ...@@ -123,20 +164,33 @@
<ol class="list-input"> <ol class="list-input">
<li class="field required text" id="field-email"> <li class="field required text" id="field-email">
<label for="email">${_('E-mail')}</label> <label for="email">${_('E-mail')}</label>
<input class="" id="email" type="email" name="email" value="" placeholder="${_('example: username@domain.com')}" required aria-required="true" /> <input class="" id="email" type="email" name="email" value="${email}" placeholder="${_('example: username@domain.com')}" required aria-required="true" />
</li> </li>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and running_pipeline:
<li class="is-disabled field optional password" id="field-password" hidden>
<label for="password">${_('Password')}</label>
<input id="password" type="password" name="password" value="" disabled required aria-required="true" />
</li>
% else:
<li class="field required password" id="field-password"> <li class="field required password" id="field-password">
<label for="password">${_('Password')}</label> <label for="password">${_('Password')}</label>
<input id="password" type="password" name="password" value="" required aria-required="true" /> <input id="password" type="password" name="password" value="" required aria-required="true" />
</li> </li>
% endif
<li class="field required text" id="field-username"> <li class="field required text" id="field-username">
<label for="username">${_('Public Username')}</label> <label for="username">${_('Public Username')}</label>
<input id="username" type="text" name="username" value="" placeholder="${_('example: JaneDoe')}" required aria-required="true" aria-describedby="username-tip"/> <input id="username" type="text" name="username" value="${username}" placeholder="${_('example: JaneDoe')}" required aria-required="true" aria-describedby="username-tip"/>
<span class="tip tip-input" id="username-tip">${_('Will be shown in any discussions or forums you participate in')} <strong>(${_('cannot be changed later')})</strong></span> <span class="tip tip-input" id="username-tip">${_('Will be shown in any discussions or forums you participate in')} <strong>(${_('cannot be changed later')})</strong></span>
</li> </li>
<li class="field required text" id="field-name"> <li class="field required text" id="field-name">
<label for="name">${_('Full Name')}</label> <label for="name">${_('Full Name')}</label>
<input id="name" type="text" name="name" value="" placeholder="${_('example: Jane Doe')}" required aria-required="true" aria-describedby="name-tip" /> <input id="name" type="text" name="name" value="${name}" placeholder="${_('example: Jane Doe')}" required aria-required="true" aria-describedby="name-tip" />
<span class="tip tip-input" id="name-tip">${_("Needed for any certificates you may earn")}</span> <span class="tip tip-input" id="name-tip">${_("Needed for any certificates you may earn")}</span>
</li> </li>
</ol> </ol>
......
...@@ -60,7 +60,7 @@ pyparsing==2.0.1 ...@@ -60,7 +60,7 @@ pyparsing==2.0.1
python-memcached==1.48 python-memcached==1.48
python-openid==2.2.5 python-openid==2.2.5
python-dateutil==2.1 python-dateutil==2.1
python-social-auth==0.1.21 python-social-auth==0.1.23
pytz==2012h pytz==2012h
pysrt==0.4.7 pysrt==0.4.7
PyYAML==3.10 PyYAML==3.10
......
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