Commit 072ae99d by Braden MacDonald

Merge pull request #8140 from edx/feature/shibboleth-tpa

Feature: Shibboleth/SAML SSO
parents 93625671 cf308d87
......@@ -91,6 +91,9 @@ logs
chromedriver.log
ghostdriver.log
### Celery artifacts ###
celerybeat-schedule
### Unknown artifacts
database.sqlite
courseware/static/js/mathjax/*
......
......@@ -196,8 +196,9 @@ def auth_pipeline_urls(auth_entry, redirect_url=None):
return {}
return {
provider.NAME: third_party_auth.pipeline.get_login_url(provider.NAME, auth_entry, redirect_url=redirect_url)
for provider in third_party_auth.provider.Registry.enabled()
provider.provider_id: third_party_auth.pipeline.get_login_url(
provider.provider_id, auth_entry, redirect_url=redirect_url
) for provider in third_party_auth.provider.Registry.enabled()
}
......
......@@ -11,12 +11,12 @@ from django.core.urlresolvers import reverse
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import CourseModeFactory
from third_party_auth.tests.testutil import ThirdPartyAuthTestMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
# This relies on third party auth being enabled and configured
# in the test settings. See the setting `THIRD_PARTY_AUTH`
# and the feature flag `ENABLE_THIRD_PARTY_AUTH`
# This relies on third party auth being enabled in the test
# settings with the feature flag `ENABLE_THIRD_PARTY_AUTH`
THIRD_PARTY_AUTH_BACKENDS = ["google-oauth2", "facebook"]
THIRD_PARTY_AUTH_PROVIDERS = ["Google", "Facebook"]
......@@ -40,7 +40,7 @@ def _finish_auth_url(params):
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
class LoginFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
"""Test rendering of the login form. """
@patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False})
def setUp(self):
......@@ -50,6 +50,8 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
self.course = CourseFactory.create()
self.course_id = unicode(self.course.id)
self.courseware_url = reverse("courseware", args=[self.course_id])
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(THIRD_PARTY_AUTH_PROVIDERS)
......@@ -148,7 +150,7 @@ class LoginFormTest(UrlResetMixin, ModuleStoreTestCase):
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
class RegisterFormTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
"""Test rendering of the registration form. """
@patch.dict(settings.FEATURES, {"ENABLE_COMBINED_LOGIN_REGISTRATION": False})
def setUp(self):
......@@ -157,6 +159,8 @@ class RegisterFormTest(UrlResetMixin, ModuleStoreTestCase):
self.url = reverse("register_user")
self.course = CourseFactory.create()
self.course_id = unicode(self.course.id)
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
@patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data(*THIRD_PARTY_AUTH_PROVIDERS)
......
......@@ -368,7 +368,7 @@ def signin_user(request):
for msg in messages.get_messages(request):
if msg.extra_tags.split()[0] == "social-auth":
# msg may or may not be translated. Try translating [again] in case we are able to:
third_party_auth_error = _(msg) # pylint: disable=translation-of-non-string
third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string
break
context = {
......@@ -424,10 +424,10 @@ def register_user(request, extra_context=None):
# selected provider.
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
current_provider = provider.Registry.get_from_pipeline(running_pipeline)
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
overrides['running_pipeline'] = running_pipeline
overrides['selected_provider'] = current_provider.NAME
overrides['selected_provider'] = current_provider.name
context.update(overrides)
return render_to_response('register.html', context)
......@@ -952,10 +952,11 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
running_pipeline = pipeline.get(request)
username = running_pipeline['kwargs'].get('username')
backend_name = running_pipeline['backend']
requested_provider = provider.Registry.get_by_backend_name(backend_name)
third_party_uid = running_pipeline['kwargs']['uid']
requested_provider = provider.Registry.get_from_pipeline(running_pipeline)
try:
user = pipeline.get_authenticated_user(username, backend_name)
user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid)
third_party_auth_successful = True
except User.DoesNotExist:
AUDIT_LOG.warning(
......@@ -963,12 +964,12 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
username=username, backend_name=backend_name))
return HttpResponse(
_("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format(
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name
)
+ "<br/><br/>" +
_("Use your {platform_name} username and password to log into {platform_name} below, "
"and then link your {platform_name} account with {provider_name} from your dashboard.").format(
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.name
)
+ "<br/><br/>" +
_("If you don't have an {platform_name} account yet, click <strong>Register Now</strong> at the top of the page.").format(
......@@ -1497,6 +1498,13 @@ def create_account_with_params(request, params):
dog_stats_api.increment("common.student.account_created")
# If the user is registering via 3rd party auth, track which provider they use
third_party_provider = None
running_pipeline = None
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)
# Track the user's registration
if settings.FEATURES.get('SEGMENT_IO_LMS') and hasattr(settings, 'SEGMENT_IO_LMS_KEY'):
tracking_context = tracker.get_tracker().resolve_context()
......@@ -1505,20 +1513,13 @@ def create_account_with_params(request, params):
'username': user.username,
})
# If the user is registering via 3rd party auth, track which provider they use
provider_name = None
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
provider_name = current_provider.NAME
analytics.track(
user.id,
"edx.bi.user.account.registered",
{
'category': 'conversion',
'label': params.get('course_id'),
'provider': provider_name
'provider': third_party_provider.name if third_party_provider else None
},
context={
'Google Analytics': {
......@@ -1535,6 +1536,7 @@ def create_account_with_params(request, params):
# 2. Random user generation for other forms of testing.
# 3. External auth bypassing activation.
# 4. Have the platform configured to not require e-mail activation.
# 5. Registering a new user using a trusted third party provider (with skip_email_verification=True)
#
# Note that this feature is only tested as a flag set one way or
# the other for *new* systems. we need to be careful about
......@@ -1543,7 +1545,11 @@ def create_account_with_params(request, params):
send_email = (
not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and
not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and
not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'))
not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and
not (
third_party_provider and third_party_provider.skip_email_verification and
user.email == running_pipeline['kwargs'].get('details', {}).get('email')
)
)
if send_email:
context = {
......
# -*- coding: utf-8 -*-
"""
Admin site configuration for third party authentication
"""
from django.contrib import admin
from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModelAdmin
from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData
from .tasks import fetch_saml_metadata
admin.site.register(OAuth2ProviderConfig, KeyedConfigurationModelAdmin)
class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin):
""" Django Admin class for SAMLProviderConfig """
def get_list_display(self, request):
""" Don't show every single field in the admin change list """
return (
'name', 'enabled', 'backend_name', 'entity_id', 'metadata_source',
'has_data', 'icon_class', 'change_date', 'changed_by', 'edit_link'
)
def has_data(self, inst):
""" Do we have cached metadata for this SAML provider? """
if not inst.is_active:
return None # N/A
data = SAMLProviderData.current(inst.entity_id)
return bool(data and data.is_valid())
has_data.short_description = u'Metadata Ready'
has_data.boolean = True
def save_model(self, request, obj, form, change):
"""
Post save: Queue an asynchronous metadata fetch to update SAMLProviderData.
We only want to do this for manual edits done using the admin interface.
Note: This only works if the celery worker and the app worker are using the
same 'configuration' cache.
"""
super(SAMLProviderConfigAdmin, self).save_model(request, obj, form, change)
fetch_saml_metadata.apply_async((), countdown=2)
admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin)
class SAMLConfigurationAdmin(ConfigurationModelAdmin):
""" Django Admin class for SAMLConfiguration """
def get_list_display(self, request):
""" Shorten the public/private keys in the change view """
return (
'change_date', 'changed_by', 'enabled', 'entity_id',
'org_info_str', 'key_summary',
)
def key_summary(self, inst):
""" Short summary of the key pairs configured """
if not inst.public_key or not inst.private_key:
return u'<em>Key pair incomplete/missing</em>'
pub1, pub2 = inst.public_key[0:10], inst.public_key[-10:]
priv1, priv2 = inst.private_key[0:10], inst.private_key[-10:]
return u'Public: {}…{}<br>Private: {}…{}'.format(pub1, pub2, priv1, priv2)
key_summary.allow_tags = True
admin.site.register(SAMLConfiguration, SAMLConfigurationAdmin)
class SAMLProviderDataAdmin(admin.ModelAdmin):
""" Django Admin class for SAMLProviderData (Read Only) """
list_display = ('entity_id', 'is_valid', 'fetched_at', 'expires_at', 'sso_url')
readonly_fields = ('is_valid', )
def get_readonly_fields(self, request, obj=None):
if obj: # editing an existing object
return self.model._meta.get_all_field_names() # pylint: disable=protected-access
return self.readonly_fields
admin.site.register(SAMLProviderData, SAMLProviderDataAdmin)
"""
DummyProvider: A fake Third Party Auth provider for testing & development purposes.
DummyBackend: A fake Third Party Auth provider for testing & development purposes.
"""
from social.backends.base import BaseAuth
from social.backends.oauth import BaseOAuth2
from social.exceptions import AuthFailed
from .provider import BaseProvider
class DummyBackend(BaseAuth): # pylint: disable=abstract-method
class DummyBackend(BaseOAuth2): # pylint: disable=abstract-method
"""
python-social-auth backend that doesn't actually go to any third party site
"""
......@@ -47,12 +45,3 @@ class DummyBackend(BaseAuth): # pylint: disable=abstract-method
kwargs.update({'response': response, 'backend': self})
return self.strategy.authenticate(*args, **kwargs)
class DummyProvider(BaseProvider):
""" Dummy Provider for testing and development """
BACKEND_CLASS = DummyBackend
ICON_CLASS = 'fa-cube'
NAME = 'Dummy'
SETTINGS = {}
# -*- coding: utf-8 -*-
"""
Management commands for third_party_auth
"""
from django.core.management.base import BaseCommand, CommandError
import logging
from third_party_auth.models import SAMLConfiguration
from third_party_auth.tasks import fetch_saml_metadata
class Command(BaseCommand):
""" manage.py commands to manage SAML/Shibboleth SSO """
help = '''Configure/maintain/update SAML-based SSO'''
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("saml requires one argument: pull")
if not SAMLConfiguration.is_enabled():
raise CommandError("SAML support is disabled via SAMLConfiguration.")
subcommand = args[0]
if subcommand == "pull":
log_handler = logging.StreamHandler(self.stdout)
log_handler.setLevel(logging.DEBUG)
log = logging.getLogger('third_party_auth.tasks')
log.propagate = False
log.addHandler(log_handler)
num_changed, num_failed, num_total = fetch_saml_metadata()
self.stdout.write(
"\nDone. Fetched {num_total} total. {num_changed} were updated and {num_failed} failed.\n".format(
num_changed=num_changed, num_failed=num_failed, num_total=num_total
)
)
else:
raise CommandError("Unknown argment: {}".format(subcommand))
......@@ -196,9 +196,11 @@ class ProviderUserState(object):
lms/templates/dashboard.html.
"""
def __init__(self, enabled_provider, user, state):
def __init__(self, enabled_provider, user, association_id=None):
# UserSocialAuth row ID
self.association_id = association_id
# Boolean. Whether the user has an account associated with the provider
self.has_account = state
self.has_account = association_id is not None
# provider.BaseProvider child. Callers must verify that the provider is
# enabled.
self.provider = enabled_provider
......@@ -207,7 +209,7 @@ class ProviderUserState(object):
def get_unlink_form_name(self):
"""Gets the name used in HTML forms that unlink a provider account."""
return self.provider.NAME + '_unlink_form'
return self.provider.provider_id + '_unlink_form'
def get(request):
......@@ -215,7 +217,7 @@ def get(request):
return request.session.get('partial_pipeline')
def get_authenticated_user(username, backend_name):
def get_authenticated_user(auth_provider, username, uid):
"""Gets a saved user authenticated by a particular backend.
Between pipeline steps User objects are not saved. We need to reconstitute
......@@ -224,43 +226,45 @@ def get_authenticated_user(username, backend_name):
authenticate().
Args:
auth_provider: the third_party_auth provider in use for the current pipeline.
username: string. Username of user to get.
backend_name: string. The name of the third-party auth backend from
the running pipeline.
uid: string. The user ID according to the third party.
Returns:
User if user is found and has a social auth from the passed
backend_name.
provider.
Raises:
User.DoesNotExist: if no user matching user is found, or the matching
user has no social auth associated with the given backend.
AssertionError: if the user is not authenticated.
"""
user = models.DjangoStorage.user.user_model().objects.get(username=username)
match = models.DjangoStorage.user.get_social_auth_for_user(user, provider=backend_name)
match = models.DjangoStorage.user.get_social_auth(provider=auth_provider.backend_name, uid=uid)
if not match:
if not match or match.user.username != username:
raise User.DoesNotExist
user.backend = provider.Registry.get_by_backend_name(backend_name).get_authentication_backend()
user = match.user
user.backend = auth_provider.get_authentication_backend()
return user
def _get_enabled_provider_by_name(provider_name):
"""Gets an enabled provider by its NAME member or throws."""
enabled_provider = provider.Registry.get(provider_name)
def _get_enabled_provider(provider_id):
"""Gets an enabled provider by its provider_id member or throws."""
enabled_provider = provider.Registry.get(provider_id)
if not enabled_provider:
raise ValueError('Provider %s not enabled' % provider_name)
raise ValueError('Provider %s not enabled' % provider_id)
return enabled_provider
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None,
extra_params=None, url_params=None):
"""Creates a URL to hook into social auth endpoints."""
kwargs = {'backend': backend_name}
url = reverse(view_name, kwargs=kwargs)
url_params = url_params or {}
url_params['backend'] = backend_name
url = reverse(view_name, kwargs=url_params)
query_params = OrderedDict()
if auth_entry:
......@@ -269,6 +273,9 @@ def _get_url(view_name, backend_name, auth_entry=None, redirect_url=None):
if redirect_url:
query_params[AUTH_REDIRECT_KEY] = redirect_url
if extra_params:
query_params.update(extra_params)
return u"{url}?{params}".format(
url=url,
params=urllib.urlencode(query_params)
......@@ -288,37 +295,40 @@ def get_complete_url(backend_name):
Raises:
ValueError: if no provider is enabled with the given backend_name.
"""
enabled_provider = provider.Registry.get_by_backend_name(backend_name)
if not enabled_provider:
if not any(provider.Registry.get_enabled_by_backend_name(backend_name)):
raise ValueError('Provider with backend %s not enabled' % backend_name)
return _get_url('social:complete', backend_name)
def get_disconnect_url(provider_name):
def get_disconnect_url(provider_id, association_id):
"""Gets URL for the endpoint that starts the disconnect pipeline.
Args:
provider_name: string. Name of the provider.BaseProvider child you want
provider_id: string identifier of the models.ProviderConfig child you want
to disconnect from.
association_id: int. Optional ID of a specific row in the UserSocialAuth
table to disconnect (useful if multiple providers use a common backend)
Returns:
String. URL that starts the disconnection pipeline.
Raises:
ValueError: if no provider is enabled with the given backend_name.
ValueError: if no provider is enabled with the given ID.
"""
enabled_provider = _get_enabled_provider_by_name(provider_name)
return _get_url('social:disconnect', enabled_provider.BACKEND_CLASS.name)
backend_name = _get_enabled_provider(provider_id).backend_name
if association_id:
return _get_url('social:disconnect_individual', backend_name, url_params={'association_id': association_id})
else:
return _get_url('social:disconnect', backend_name)
def get_login_url(provider_name, auth_entry, redirect_url=None):
def get_login_url(provider_id, auth_entry, redirect_url=None):
"""Gets the login URL for the endpoint that kicks off auth with a provider.
Args:
provider_name: string. The name of the provider.Provider that has been
enabled.
provider_id: string identifier of the models.ProviderConfig child you want
to disconnect from.
auth_entry: string. Query argument specifying the desired entry point
for the auth pipeline. Used by the pipeline for later branching.
Must be one of _AUTH_ENTRY_CHOICES.
......@@ -331,15 +341,16 @@ def get_login_url(provider_name, auth_entry, redirect_url=None):
String. URL that starts the auth pipeline for a provider.
Raises:
ValueError: if no provider is enabled with the given provider_name.
ValueError: if no provider is enabled with the given provider_id.
"""
assert auth_entry in _AUTH_ENTRY_CHOICES
enabled_provider = _get_enabled_provider_by_name(provider_name)
enabled_provider = _get_enabled_provider(provider_id)
return _get_url(
'social:begin',
enabled_provider.BACKEND_CLASS.name,
enabled_provider.backend_name,
auth_entry=auth_entry,
redirect_url=redirect_url,
extra_params=enabled_provider.get_url_params(),
)
......@@ -355,7 +366,7 @@ def get_duplicate_provider(messages):
unfortunately not in a reusable constant.
Returns:
provider.BaseProvider child instance. The provider of the duplicate
string name of the python-social-auth backend that has the duplicate
account, or None if there is no duplicate (and hence no error).
"""
social_auth_messages = [m for m in messages if m.message.endswith('is already in use.')]
......@@ -364,7 +375,8 @@ def get_duplicate_provider(messages):
return
assert len(social_auth_messages) == 1
return provider.Registry.get_by_backend_name(social_auth_messages[0].extra_tags.split()[1])
backend_name = social_auth_messages[0].extra_tags.split()[1]
return backend_name
def get_provider_user_states(user):
......@@ -378,13 +390,16 @@ def get_provider_user_states(user):
each enabled provider.
"""
states = []
found_user_backends = [
social_auth.provider for social_auth in models.DjangoStorage.user.get_social_auth_for_user(user)
]
found_user_auths = list(models.DjangoStorage.user.get_social_auth_for_user(user))
for enabled_provider in provider.Registry.enabled():
association_id = None
for auth in found_user_auths:
if enabled_provider.match_social_auth(auth):
association_id = auth.id
break
states.append(
ProviderUserState(enabled_provider, user, enabled_provider.BACKEND_CLASS.name in found_user_backends)
ProviderUserState(enabled_provider, user, association_id)
)
return states
......@@ -488,12 +503,19 @@ def ensure_user_information(strategy, auth_entry, backend=None, user=None, socia
"""Redirects to the registration page."""
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER])
def should_force_account_creation():
""" For some third party providers, we auto-create user accounts """
current_provider = provider.Registry.get_from_pipeline({'backend': backend.name, 'kwargs': kwargs})
return current_provider and current_provider.skip_email_verification
if not user:
if auth_entry in [AUTH_ENTRY_LOGIN_API, AUTH_ENTRY_REGISTER_API]:
return HttpResponseBadRequest()
elif auth_entry in [AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN_2]:
# User has authenticated with the third party provider but we don't know which edX
# account corresponds to them yet, if any.
if should_force_account_creation():
return dispatch_to_register()
return dispatch_to_login()
elif auth_entry in [AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER_2]:
# User has authenticated with the third party provider and now wants to finish
......
"""
Slightly customized python-social-auth backend for SAML 2.0 support
"""
import logging
from social.backends.saml import SAMLAuth, OID_EDU_PERSON_ENTITLEMENT
from social.exceptions import AuthForbidden
log = logging.getLogger(__name__)
class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method
"""
Customized version of SAMLAuth that gets the list of IdPs from third_party_auth's list of
enabled providers.
"""
name = "tpa-saml"
def get_idp(self, idp_name):
""" Given the name of an IdP, get a SAMLIdentityProvider instance """
from .models import SAMLProviderConfig
return SAMLProviderConfig.current(idp_name).get_config()
def setting(self, name, default=None):
""" Get a setting, from SAMLConfiguration """
if not hasattr(self, '_config'):
from .models import SAMLConfiguration
self._config = SAMLConfiguration.current() # pylint: disable=attribute-defined-outside-init
if not self._config.enabled:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured("SAML Authentication is not enabled.")
try:
return self._config.get_setting(name)
except KeyError:
return self.strategy.setting(name, default)
def _check_entitlements(self, idp, attributes):
"""
Check if we require the presence of any specific eduPersonEntitlement.
raise AuthForbidden if the user should not be authenticated, or do nothing
to allow the login pipeline to continue.
"""
if "requiredEntitlements" in idp.conf:
entitlements = attributes.get(OID_EDU_PERSON_ENTITLEMENT, [])
for expected in idp.conf['requiredEntitlements']:
if expected not in entitlements:
log.warning(
"SAML user from IdP %s rejected due to missing eduPersonEntitlement %s", idp.name, expected)
raise AuthForbidden(self)
"""Settings for the third-party auth module.
Defers configuration of settings so we can inspect the provider registry and
create settings placeholders for only those values actually needed by a given
deployment. Required by Django; consequently, this file must not invoke the
Django armature.
The flow for settings registration is:
The base settings file contains a boolean, ENABLE_THIRD_PARTY_AUTH, indicating
whether this module is enabled. Ancillary settings files (aws.py, dev.py) put
options in THIRD_PARTY_SETTINGS. startup.py probes the ENABLE_THIRD_PARTY_AUTH.
whether this module is enabled. startup.py probes the ENABLE_THIRD_PARTY_AUTH.
If true, it:
a) loads this module.
b) calls apply_settings(), passing in settings.THIRD_PARTY_AUTH.
THIRD_PARTY AUTH is a dict of the form
'THIRD_PARTY_AUTH': {
'<PROVIDER_NAME>': {
'<PROVIDER_SETTING_NAME>': '<PROVIDER_SETTING_VALUE>',
[...]
},
[...]
}
If you are using a dev settings file, your settings dict starts at the
level of <PROVIDER_NAME> and is a map of provider name string to
settings dict. If you are using an auth.json file, it should contain a
THIRD_PARTY_AUTH entry as above.
c) apply_settings() builds a list of <PROVIDER_NAMES>. These are the
enabled third party auth providers for the deployment. These are enabled
in provider.Registry, the canonical list of enabled providers.
d) then, it sets global, provider-independent settings.
e) then, it sets provider-specific settings. For each enabled provider, we
read its SETTINGS member. These are merged onto the Django settings
object. In most cases these are stubs and the real values are set from
THIRD_PARTY_AUTH. All values that are set from this dict must first be
initialized from SETTINGS. This allows us to validate the dict and
ensure that the values match expected configuration options on the
provider.
f) finally, the (key, value) pairs from the dict file are merged onto the
django settings object.
b) calls apply_settings(), passing in the Django settings
"""
from . import provider
_FIELDS_STORED_IN_SESSION = ['auth_entry', 'next']
_MIDDLEWARE_CLASSES = (
'third_party_auth.middleware.ExceptionMiddleware',
......@@ -53,25 +17,7 @@ _MIDDLEWARE_CLASSES = (
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
def _merge_auth_info(django_settings, auth_info):
"""Merge auth_info dict onto django_settings module."""
enabled_provider_names = []
to_merge = []
for provider_name, provider_dict in auth_info.items():
enabled_provider_names.append(provider_name)
# Merge iff all settings have been intialized.
for key in provider_dict:
if key not in dir(django_settings):
raise ValueError('Auth setting %s not initialized' % key)
to_merge.append(provider_dict)
for passed_validation in to_merge:
for key, value in passed_validation.iteritems():
setattr(django_settings, key, value)
def _set_global_settings(django_settings):
def apply_settings(django_settings):
"""Set provider-independent settings."""
# Whitelisted URL query parameters retrained in the pipeline session.
......@@ -115,6 +61,9 @@ def _set_global_settings(django_settings):
'third_party_auth.pipeline.login_analytics',
)
# Required so that we can use unmodified PSA OAuth2 backends:
django_settings.SOCIAL_AUTH_STRATEGY = 'third_party_auth.strategy.ConfigurationModelStrategy'
# We let the user specify their email address during signup.
django_settings.SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['email']
......@@ -136,30 +85,3 @@ def _set_global_settings(django_settings):
'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):
"""Sets provider-specific settings."""
# Must prepend here so we get called first.
django_settings.AUTHENTICATION_BACKENDS = (
tuple(enabled_provider.get_authentication_backend() for enabled_provider in enabled_providers) +
django_settings.AUTHENTICATION_BACKENDS)
# Merge settings from provider classes, and configure all placeholders.
for enabled_provider in enabled_providers:
enabled_provider.merge_onto(django_settings)
# Merge settings from <deployment>.auth.json, overwriting placeholders.
_merge_auth_info(django_settings, auth_info)
def apply_settings(auth_info, django_settings):
"""Applies settings from auth_info dict to django_settings module."""
if django_settings.FEATURES.get('ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'):
# The Dummy provider is handy for testing and development.
from .dummy import DummyProvider # pylint: disable=unused-variable
provider_names = auth_info.keys()
provider.Registry.configure_once(provider_names)
enabled_providers = provider.Registry.enabled()
_set_global_settings(django_settings)
_set_provider_settings(django_settings, enabled_providers, auth_info)
"""
A custom Strategy for python-social-auth that allows us to fetch configuration from
ConfigurationModels rather than django.settings
"""
from .models import OAuth2ProviderConfig
from social.backends.oauth import BaseOAuth2
from social.strategies.django_strategy import DjangoStrategy
class ConfigurationModelStrategy(DjangoStrategy):
"""
A DjangoStrategy customized to load settings from ConfigurationModels
for upstream python-social-auth backends that we cannot otherwise modify.
"""
def setting(self, name, default=None, backend=None):
"""
Load the setting from a ConfigurationModel if possible, or fall back to the normal
Django settings lookup.
BaseOAuth2 subclasses will call this method for every setting they want to look up.
SAMLAuthBackend subclasses will call this method only after first checking if the
setting 'name' is configured via SAMLProviderConfig.
"""
if isinstance(backend, BaseOAuth2):
provider_config = OAuth2ProviderConfig.current(backend.name)
if not provider_config.enabled:
raise Exception("Can't fetch setting of a disabled backend/provider.")
try:
return provider_config.get_setting(name)
except KeyError:
pass
# 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)
# -*- coding: utf-8 -*-
"""
Code to manage fetching and storing the metadata of IdPs.
"""
#pylint: disable=no-member
from celery.task import task # pylint: disable=import-error,no-name-in-module
import datetime
import dateutil.parser
import logging
from lxml import etree
import requests
from onelogin.saml2.utils import OneLogin_Saml2_Utils
from third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData
log = logging.getLogger(__name__)
SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' # The SAML Metadata XML namespace
class MetadataParseError(Exception):
""" An error occurred while parsing the SAML metadata from an IdP """
pass
@task(name='third_party_auth.fetch_saml_metadata')
def fetch_saml_metadata():
"""
Fetch and store/update the metadata of all IdPs
This task should be run on a daily basis.
It's OK to run this whether or not SAML is enabled.
Return value:
tuple(num_changed, num_failed, num_total)
num_changed: Number of providers that are either new or whose metadata has changed
num_failed: Number of providers that could not be updated
num_total: Total number of providers whose metadata was fetched
"""
if not SAMLConfiguration.is_enabled():
return (0, 0, 0) # Nothing to do until SAML is enabled.
num_changed, num_failed = 0, 0
# First make a list of all the metadata XML URLs:
url_map = {}
for idp_slug in SAMLProviderConfig.key_values('idp_slug', flat=True):
config = SAMLProviderConfig.current(idp_slug)
if not config.enabled:
continue
url = config.metadata_source
if url not in url_map:
url_map[url] = []
if config.entity_id not in url_map[url]:
url_map[url].append(config.entity_id)
# Now fetch the metadata:
for url, entity_ids in url_map.items():
try:
log.info("Fetching %s", url)
if not url.lower().startswith('https'):
log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)
response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError
response.raise_for_status() # May raise an HTTPError
try:
parser = etree.XMLParser(remove_comments=True)
xml = etree.fromstring(response.text, parser)
except etree.XMLSyntaxError:
raise
# TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that
for entity_id in entity_ids:
log.info(u"Processing IdP with entityID %s", entity_id)
public_key, sso_url, expires_at = _parse_metadata_xml(xml, entity_id)
changed = _update_data(entity_id, public_key, sso_url, expires_at)
if changed:
log.info(u"→ Created new record for SAMLProviderData")
num_changed += 1
else:
log.info(u"→ Updated existing SAMLProviderData. Nothing has changed.")
except Exception as err: # pylint: disable=broad-except
log.exception(err.message)
num_failed += 1
return (num_changed, num_failed, len(url_map))
def _parse_metadata_xml(xml, entity_id):
"""
Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of
(public_key, sso_url, expires_at) for the specified entityID.
Raises MetadataParseError if anything is wrong.
"""
if xml.tag == etree.QName(SAML_XML_NS, 'EntityDescriptor'):
entity_desc = xml
else:
if xml.tag != etree.QName(SAML_XML_NS, 'EntitiesDescriptor'):
raise MetadataParseError("Expected root element to be <EntitiesDescriptor>, not {}".format(xml.tag))
entity_desc = xml.find(
".//{}[@entityID='{}']".format(etree.QName(SAML_XML_NS, 'EntityDescriptor'), entity_id)
)
if not entity_desc:
raise MetadataParseError("Can't find EntityDescriptor for entityID {}".format(entity_id))
expires_at = None
if "validUntil" in xml.attrib:
expires_at = dateutil.parser.parse(xml.attrib["validUntil"])
if "cacheDuration" in xml.attrib:
cache_expires = OneLogin_Saml2_Utils.parse_duration(xml.attrib["cacheDuration"])
if expires_at is None or cache_expires < expires_at:
expires_at = cache_expires
sso_desc = entity_desc.find(etree.QName(SAML_XML_NS, "IDPSSODescriptor"))
if not sso_desc:
raise MetadataParseError("IDPSSODescriptor missing")
if 'urn:oasis:names:tc:SAML:2.0:protocol' not in sso_desc.get("protocolSupportEnumeration"):
raise MetadataParseError("This IdP does not support SAML 2.0")
# Now we just need to get the public_key and sso_url
public_key = sso_desc.findtext("./{}//{}".format(
etree.QName(SAML_XML_NS, "KeyDescriptor"), "{http://www.w3.org/2000/09/xmldsig#}X509Certificate"
))
if not public_key:
raise MetadataParseError("Public Key missing. Expected an <X509Certificate>")
public_key = public_key.replace(" ", "")
binding_elements = sso_desc.iterfind("./{}".format(etree.QName(SAML_XML_NS, "SingleSignOnService")))
sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements}
try:
# The only binding supported by python-saml and python-social-auth is HTTP-Redirect:
sso_url = sso_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']
except KeyError:
raise MetadataParseError("Unable to find SSO URL with HTTP-Redirect binding.")
return public_key, sso_url, expires_at
def _update_data(entity_id, public_key, sso_url, expires_at):
"""
Update/Create the SAMLProviderData for the given entity ID.
Return value:
False if nothing has changed and existing data's "fetched at" timestamp is just updated.
True if a new record was created. (Either this is a new provider or something changed.)
"""
data_obj = SAMLProviderData.current(entity_id)
fetched_at = datetime.datetime.now()
if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url):
data_obj.expires_at = expires_at
data_obj.fetched_at = fetched_at
data_obj.save()
return False
else:
SAMLProviderData.objects.create(
entity_id=entity_id,
fetched_at=fetched_at,
expires_at=expires_at,
sso_url=sso_url,
public_key=public_key,
)
return True
-----BEGIN RSA PRIVATE KEY-----
MIICWwIBAAKBgQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9
kJF0RB3h3Q2VJ3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu
9Qvjg6YOtYP6PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQAB
AoGADWBsD/qdQaqe1x9/iOKINhuuPRNKw2n9nzT2iIW4nhzaDHB689VceL79SEE5
4rMJmQomkBtGZVxBeHgd5/dQxNy3bC9lPN1uoMuzjQs7UMk+lvy0MoHfiJcuIxPX
RdyZTV9LKN8vq+ZpVykVu6pBdDlne4psPZeQ76ynxke/24ECQQD3NX7JeluZ64la
tC6b3VHzA4Hd1qTXDWtEekh2WaR2xuKzcLyOWhqPIWprylBqVc1m+FA/LRRWQ9y6
vJMiXMk7AkEA1ELWj9DtZzk9BV1JxsDUUP0/IMAiYliVac3YrvQfys8APCY1xr9q
BAGurH4VWXuEnbx1yNXK89HqFI7kDrMtwQJAVTXtVAmHFZEosUk2X6d0He3xj8Py
4eXQObRk0daoaAC6F9weQnsweHGuOyVrfpvAx2OEVaJ2Rh3yMbPai5esDQJAS9Yh
gLqdx26M3bjJ3igQ82q3vkTHRCnwICA6la+FGFnC9LqWJg9HmmzbcqeNiy31YMHv
tzSjUV+jaXrwAkyEQQJAK/SCIVsWRhFe/ssr8hS//V+hZC4kvCv4b3NqzZK1x+Xm
7GaGMV0xEWN7shqVSRBU4O2vn/RWD6/6x3sHkU57qg==
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIICsDCCAhmgAwIBAgIJAJrENr8EPgpcMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEwNTE0WhcNMjUwNjEyMDEwNTE0WjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
gQDM+Nf7IeRdIIgYUke6sR3n7osHVYXwH6pb+Ovq8j3hUoy8kzT9kJF0RB3h3Q2V
J3ZWiQtT94fZX2YYorVdoGVK2NWzjLwgpHUsgfeJq5pCjP0d2OQu9Qvjg6YOtYP6
PN3j7eK7pUcxQvIcaY9APDF57ua/zPsm3UzbjhRlJZQUewIDAQABo4GnMIGkMB0G
A1UdDgQWBBTjOyPvAuej5q4C80jlFrQmOlszmzB1BgNVHSMEbjBsgBTjOyPvAuej
5q4C80jlFrQmOlszm6FJpEcwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgTClNvbWUt
U3RhdGUxITAfBgNVBAoTGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZIIJAJrENr8E
PgpcMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAV5w0SxjUTFWfL3ZG
6sgA0gKf8aV8w3AlihLt9tKCRgrK4sBK9xmfwp/fnbdxkHU58iozI894HqmrRzCi
aRLWmy3W8640E/XCa6P+i8ET7RksgNJ5cD9WtISHkGc2dnW76+2nv8d24JKeIx2w
oJAtspMywzr0SoxDIJr42N6Kvjk=
-----END CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAMoR8CP+HlvsPRwi
VCCuWxZOdNjYa4Qre3JEWPkqlUwpci1XGTBqH7DK9b2hmBXMjYoDKOnF5pL7Y453
3JSJ2+AG7D4AJGSotA3boKF18EDgeMzAWjAhDVhTprGz+/1G+W0R4SSyY5QGyBhL
Z36xF2w5HyeiqN/Iiq3QKGl2CFORAgMBAAECgYEAwH2CAudqSCqstAZHmbI99uva
B09ybD93owxUrVbRTfIVX/eeeS4+7g0JNxGebPWkxxnneXoaAV4UIn0v1RfWKMs3
QGiBsOSup1DWWwkBfvQ1hNlJfVCqgZH1QVbhPpw9M9gxhLZQaSZoI/qY/8n/54L0
zU4S6VYBH6hnkgZZmiECQQDpYUS8HgnkMUX/qcDNBJT23qHewHsZOe6uqC+7+YxQ
xKT8iCxybDbZU7hmZ1Av8Ns4iF7EvZ0faFM8Ls76wFX1AkEA3afLUMLHfTx40XwO
oU7GWrYFyLNCc3/7JeWi6ZKzwzQqiGvFderRf/QGQsCtpLQ8VoLz/knF9TkQdSh6
yuIprQJATfcmxUmruEYVwnFtbZBoS4jYvtfCyAyohkS9naiijaEEFTFQ1/D66eOk
KOG+0iU+t0YnksZdpU5u8B4bG34BuQJAXv6FhTQk+MhM40KupnUzTzcJXY1t4kAs
K36yBjZoMjWOMO83LiUX6iVz9XHMOXVBEraGySlm3IS7R+q0TXUF9QJAQ69wautf
8q1OQiLcg5WTFmSFBEXqAvVwX6FcDSxor9UnI0iHwyKBss3a2IXY9LoTPTjR5SHh
GDq2lXmP+kmbnQ==
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIICWDCCAcGgAwIBAgIJAMlM2wrOvplkMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQwHhcNMTUwNjEzMDEyMTAwWhcNMjUwNjEyMDEyMTAwWjBF
MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50
ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKB
gQDKEfAj/h5b7D0cIlQgrlsWTnTY2GuEK3tyRFj5KpVMKXItVxkwah+wyvW9oZgV
zI2KAyjpxeaS+2OOd9yUidvgBuw+ACRkqLQN26ChdfBA4HjMwFowIQ1YU6axs/v9
RvltEeEksmOUBsgYS2d+sRdsOR8noqjfyIqt0ChpdghTkQIDAQABo1AwTjAdBgNV
HQ4EFgQUU0TNPc1yGas/W4HJl/Hgtrmdu6MwHwYDVR0jBBgwFoAUU0TNPc1yGas/
W4HJl/Hgtrmdu6MwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOBgQCE4BqJ
v2s99DS16NbZtR7tpqXDxiDaCg59VtgcHQwxN4qXcixZi5N4yRvzjYschAQN5tQ6
bofXdIK3tJY9Ynm0KPO+5l0RCv7CkhNgftTww0bWC91xaHJ/y66AqONuLpaP6s43
SZYG2D6ric57ZY4kQ6ZlUv854TPzmvapnGG7Hw==
-----END CERTIFICATE-----
......@@ -7,11 +7,14 @@ 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',
}
def setUp(self):
super(GoogleOauth2IntegrationTest, self).setUp()
self.provider = self.configure_google_provider(
enabled=True,
key='google_oauth2_key',
secret='google_oauth2_secret',
)
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
......
......@@ -7,11 +7,14 @@ 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',
}
def setUp(self):
super(LinkedInOauth2IntegrationTest, self).setUp()
self.provider = self.configure_linkedin_provider(
enabled=True,
key='linkedin_oauth2_key',
secret='linkedin_oauth2_secret',
)
TOKEN_RESPONSE_DATA = {
'access_token': 'access_token_value',
'expires_in': 'expires_in_value',
......
......@@ -4,6 +4,7 @@ import random
from third_party_auth import pipeline, provider
from third_party_auth.tests import testutil
import unittest
# Allow tests access to protected methods (or module-protected methods) under
......@@ -34,9 +35,11 @@ class MakeRandomPasswordTest(testutil.TestCase):
self.assertEqual(expected, pipeline.make_random_password(choice_fn=random_instance.choice))
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
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())
google_provider = self.configure_google_provider(enabled=True)
state = pipeline.ProviderUserState(google_provider, object(), 1000)
self.assertEqual(google_provider.provider_id + '_unlink_form', state.get_unlink_form_name())
"""Unit tests for provider.py."""
from mock import Mock, patch
from third_party_auth import provider
from third_party_auth.tests import testutil
import unittest
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
class RegistryTest(testutil.TestCase):
"""Tests registry discovery and operation."""
# Allow access to protected methods (or module-protected methods) under
# test. pylint: disable-msg=protected-access
def test_calling_configure_once_twice_raises_value_error(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
with self.assertRaisesRegexp(ValueError, '^.*already configured$'):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
def test_configure_once_adds_gettable_providers(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME))
def test_configuring_provider_with_no_implementation_raises_value_error(self):
with self.assertRaisesRegexp(ValueError, '^.*no_implementation$'):
provider.Registry.configure_once(['no_implementation'])
def test_configuring_single_provider_twice_raises_value_error(self):
provider.Registry._enable(provider.GoogleOauth2)
with self.assertRaisesRegexp(ValueError, '^.*already enabled'):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
def test_custom_provider_can_be_enabled(self):
name = 'CustomProvider'
with self.assertRaisesRegexp(ValueError, '^No implementation.*$'):
provider.Registry.configure_once([name])
class CustomProvider(provider.BaseProvider):
"""Custom class to ensure BaseProvider children outside provider can be enabled."""
NAME = name
provider.Registry._reset()
provider.Registry.configure_once([CustomProvider.NAME])
self.assertEqual([CustomProvider], provider.Registry.enabled())
def test_enabled_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.enabled()
facebook_provider = self.configure_facebook_provider(enabled=True)
# pylint: disable=no-member
self.assertEqual(facebook_provider.id, provider.Registry.get(facebook_provider.provider_id).id)
def test_no_providers_by_default(self):
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 0, "By default, no providers are enabled.")
def test_runtime_configuration(self):
self.configure_google_provider(enabled=True)
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].name, "Google")
self.assertEqual(enabled_providers[0].secret, "opensesame")
self.configure_google_provider(enabled=False)
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 0)
self.configure_google_provider(enabled=True, secret="alohomora")
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].secret, "alohomora")
def test_cannot_load_arbitrary_backends(self):
""" Test that only backend_names listed in settings.AUTHENTICATION_BACKENDS can be used """
self.configure_oauth_provider(enabled=True, name="Disallowed", backend_name="disallowed")
self.enable_saml()
self.configure_saml_provider(enabled=True, name="Disallowed", idp_slug="test", backend_name="disallowed")
self.assertEqual(len(provider.Registry.enabled()), 0)
def test_enabled_returns_list_of_enabled_providers_sorted_by_name(self):
all_providers = provider.Registry._get_all()
provider.Registry.configure_once(all_providers.keys())
self.assertEqual(
sorted(all_providers.values(), key=lambda provider: provider.NAME), provider.Registry.enabled())
provider_names = ["Stack Overflow", "Google", "LinkedIn", "GitHub"]
backend_names = []
for name in provider_names:
backend_name = name.lower().replace(' ', '')
backend_names.append(backend_name)
self.configure_oauth_provider(enabled=True, name=name, backend_name=backend_name)
def test_get_raises_runtime_error_if_not_configured(self):
with self.assertRaisesRegexp(RuntimeError, '^.*not configured$'):
provider.Registry.get('anything')
with patch('third_party_auth.provider._PSA_OAUTH2_BACKENDS', backend_names):
self.assertEqual(sorted(provider_names), [prov.name for prov in provider.Registry.enabled()])
def test_get_returns_enabled_provider(self):
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
self.assertIs(provider.GoogleOauth2, provider.Registry.get(provider.GoogleOauth2.NAME))
google_provider = self.configure_google_provider(enabled=True)
# pylint: disable=no-member
self.assertEqual(google_provider.id, provider.Registry.get(google_provider.provider_id).id)
def test_get_returns_none_if_provider_not_enabled(self):
provider.Registry.configure_once([])
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))
linkedin_provider_id = "oa2-linkedin-oauth2"
# At this point there should be no configuration entries at all so no providers should be enabled
self.assertEqual(provider.Registry.enabled(), [])
self.assertIsNone(provider.Registry.get(linkedin_provider_id))
# Now explicitly disabled this provider:
self.configure_linkedin_provider(enabled=False)
self.assertIsNone(provider.Registry.get(linkedin_provider_id))
self.configure_linkedin_provider(enabled=True)
self.assertEqual(provider.Registry.get(linkedin_provider_id).provider_id, linkedin_provider_id)
def test_get_from_pipeline_returns_none_if_provider_not_enabled(self):
self.assertEqual(provider.Registry.enabled(), [], "By default, no providers are enabled.")
self.assertIsNone(provider.Registry.get_from_pipeline(Mock()))
def test_get_enabled_by_backend_name_returns_enabled_provider(self):
google_provider = self.configure_google_provider(enabled=True)
found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name))
self.assertEqual(found, [google_provider])
def test_get_enabled_by_backend_name_returns_none_if_provider_not_enabled(self):
google_provider = self.configure_google_provider(enabled=False)
found = list(provider.Registry.get_enabled_by_backend_name(google_provider.backend_name))
self.assertEqual(found, [])
......@@ -2,6 +2,7 @@
from third_party_auth import provider, settings
from third_party_auth.tests import testutil
import unittest
_ORIGINAL_AUTHENTICATION_BACKENDS = ('first_authentication_backend',)
......@@ -30,56 +31,26 @@ class SettingsUnitTest(testutil.TestCase):
self.settings = testutil.FakeDjangoSettings(_SETTINGS_MAP)
def test_apply_settings_adds_exception_middleware(self):
settings.apply_settings({}, self.settings)
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)
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):
settings.apply_settings({}, self.settings)
settings.apply_settings(self.settings)
self.assertIn('third_party_auth', self.settings.INSTALLED_APPS)
def test_apply_settings_enables_no_providers_and_completes_when_app_info_empty(self):
settings.apply_settings({}, self.settings)
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
def test_apply_settings_enables_no_providers_by_default(self):
# Providers are only enabled via ConfigurationModels in the database
settings.apply_settings(self.settings)
self.assertEqual([], provider.Registry.enabled())
def test_apply_settings_initializes_stubs_and_merges_settings_from_auth_info(self):
for key in provider.GoogleOauth2.SETTINGS:
self.assertFalse(hasattr(self.settings, key))
auth_info = {
provider.GoogleOauth2.NAME: {
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_oauth2_key',
},
}
settings.apply_settings(auth_info, self.settings)
self.assertEqual('google_oauth2_key', self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY)
self.assertIsNone(self.settings.SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET)
def test_apply_settings_prepends_auth_backends(self):
self.assertEqual(_ORIGINAL_AUTHENTICATION_BACKENDS, self.settings.AUTHENTICATION_BACKENDS)
settings.apply_settings({provider.GoogleOauth2.NAME: {}, provider.LinkedInOauth2.NAME: {}}, self.settings)
self.assertEqual((
provider.GoogleOauth2.get_authentication_backend(), provider.LinkedInOauth2.get_authentication_backend()) +
_ORIGINAL_AUTHENTICATION_BACKENDS,
self.settings.AUTHENTICATION_BACKENDS)
def test_apply_settings_raises_value_error_if_provider_contains_uninitialized_setting(self):
bad_setting_name = 'bad_setting'
self.assertNotIn('bad_setting_name', provider.GoogleOauth2.SETTINGS)
auth_info = {
provider.GoogleOauth2.NAME: {
bad_setting_name: None,
},
}
with self.assertRaisesRegexp(ValueError, '^.*not initialized$'):
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)
settings.apply_settings(self.settings)
self.assertFalse(self.settings.SOCIAL_AUTH_RAISE_EXCEPTIONS)
"""Integration tests for settings.py."""
from django.conf import settings
from third_party_auth import provider
from third_party_auth import settings as auth_settings
from third_party_auth.tests import testutil
class SettingsIntegrationTest(testutil.TestCase):
"""Integration tests of auth settings pipeline.
Note that ENABLE_THIRD_PARTY_AUTH is True in lms/envs/test.py and False in
cms/envs/test.py. This implicitly gives us coverage of the full settings
mechanism with both values, so we do not have explicit test methods as they
are superfluous.
"""
def test_can_enable_google_oauth2(self):
auth_settings.apply_settings({'Google': {'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': 'google_key'}}, settings)
self.assertEqual([provider.GoogleOauth2], provider.Registry.enabled())
self.assertEqual('google_key', settings.SOCIAL_AUTH_GOOGLE_OAUTH2_KEY)
def test_can_enable_linkedin_oauth2(self):
auth_settings.apply_settings({'LinkedIn': {'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': 'linkedin_key'}}, settings)
self.assertEqual([provider.LinkedInOauth2], provider.Registry.enabled())
self.assertEqual('linkedin_key', settings.SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY)
"""
Test the views served by third_party_auth.
"""
# pylint: disable=no-member
import ddt
from lxml import etree
import unittest
from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase
# Define some XML namespaces:
from third_party_auth.tasks import SAML_XML_NS
XMLDSIG_XML_NS = 'http://www.w3.org/2000/09/xmldsig#'
@unittest.skipUnless(AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
@ddt.ddt
class SAMLMetadataTest(SAMLTestCase):
"""
Test the SAML metadata view
"""
METADATA_URL = '/auth/saml/metadata.xml'
def test_saml_disabled(self):
""" When SAML is not enabled, the metadata view should return 404 """
self.enable_saml(enabled=False)
response = self.client.get(self.METADATA_URL)
self.assertEqual(response.status_code, 404)
@ddt.data('saml_key', 'saml_key_alt') # Test two slightly different key pair export formats
def test_metadata(self, key_name):
self.enable_saml(
private_key=self._get_private_key(key_name),
public_key=self._get_public_key(key_name),
entity_id="https://saml.example.none",
)
doc = self._fetch_metadata()
# Check the ACS URL:
acs_node = doc.find(".//{}".format(etree.QName(SAML_XML_NS, 'AssertionConsumerService')))
self.assertIsNotNone(acs_node)
self.assertEqual(acs_node.attrib['Location'], 'http://example.none/auth/complete/tpa-saml/')
def test_signed_metadata(self):
self.enable_saml(
private_key=self._get_private_key(),
public_key=self._get_public_key(),
entity_id="https://saml.example.none",
other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }',
)
doc = self._fetch_metadata()
sig_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'SignatureValue')))
self.assertIsNotNone(sig_node)
def _fetch_metadata(self):
""" Fetch and parse the metadata XML at self.METADATA_URL """
response = self.client.get(self.METADATA_URL)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/xml')
# The result should be valid XML:
try:
metadata_doc = etree.fromstring(response.content)
except etree.LxmlError:
self.fail('SAML metadata must be valid XML')
self.assertEqual(metadata_doc.tag, etree.QName(SAML_XML_NS, 'EntityDescriptor'))
return metadata_doc
......@@ -5,13 +5,16 @@ Used by Django and non-Django tests; must not have Django deps.
"""
from contextlib import contextmanager
import unittest
from django.conf import settings
import django.test
import mock
import os.path
from third_party_auth import provider
from third_party_auth.models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, cache as config_cache
AUTH_FEATURES_KEY = 'ENABLE_THIRD_PARTY_AUTH'
AUTH_FEATURE_ENABLED = AUTH_FEATURES_KEY in settings.FEATURES
class FakeDjangoSettings(object):
......@@ -23,22 +26,93 @@ class FakeDjangoSettings(object):
setattr(self, key, value)
class TestCase(unittest.TestCase):
class ThirdPartyAuthTestMixin(object):
""" Helper methods useful for testing third party auth functionality """
def tearDown(self):
config_cache.clear()
super(ThirdPartyAuthTestMixin, self).tearDown()
def enable_saml(self, **kwargs):
""" Enable SAML support (via SAMLConfiguration, not for any particular provider) """
kwargs.setdefault('enabled', True)
SAMLConfiguration(**kwargs).save()
@staticmethod
def configure_oauth_provider(**kwargs):
""" Update the settings for an OAuth2-based third party auth provider """
obj = OAuth2ProviderConfig(**kwargs)
obj.save()
return obj
def configure_saml_provider(self, **kwargs):
""" Update the settings for a SAML-based third party auth provider """
self.assertTrue(SAMLConfiguration.is_enabled(), "SAML Provider Configuration only works if SAML is enabled.")
obj = SAMLProviderConfig(**kwargs)
obj.save()
return obj
@classmethod
def configure_google_provider(cls, **kwargs):
""" Update the settings for the Google third party auth provider/backend """
kwargs.setdefault("name", "Google")
kwargs.setdefault("backend_name", "google-oauth2")
kwargs.setdefault("icon_class", "fa-google-plus")
kwargs.setdefault("key", "test-fake-key.apps.googleusercontent.com")
kwargs.setdefault("secret", "opensesame")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def configure_facebook_provider(cls, **kwargs):
""" Update the settings for the Facebook third party auth provider/backend """
kwargs.setdefault("name", "Facebook")
kwargs.setdefault("backend_name", "facebook")
kwargs.setdefault("icon_class", "fa-facebook")
kwargs.setdefault("key", "FB_TEST_APP")
kwargs.setdefault("secret", "opensesame")
return cls.configure_oauth_provider(**kwargs)
@classmethod
def configure_linkedin_provider(cls, **kwargs):
""" Update the settings for the LinkedIn third party auth provider/backend """
kwargs.setdefault("name", "LinkedIn")
kwargs.setdefault("backend_name", "linkedin-oauth2")
kwargs.setdefault("icon_class", "fa-linkedin")
kwargs.setdefault("key", "test")
kwargs.setdefault("secret", "test")
return cls.configure_oauth_provider(**kwargs)
class TestCase(ThirdPartyAuthTestMixin, django.test.TestCase):
"""Base class for auth test cases."""
pass
# Allow access to protected methods (or module-protected methods) under
# test.
# pylint: disable-msg=protected-access
def setUp(self):
super(TestCase, self).setUp()
self._original_providers = provider.Registry._get_all()
provider.Registry._reset()
class SAMLTestCase(TestCase):
"""
Base class for SAML-related third_party_auth tests
"""
def tearDown(self):
provider.Registry._reset()
provider.Registry.configure_once(self._original_providers)
super(TestCase, self).tearDown()
def setUp(self):
super(SAMLTestCase, self).setUp()
self.client.defaults['SERVER_NAME'] = 'example.none' # The SAML lib we use doesn't like testserver' as a domain
self.url_prefix = 'http://example.none'
@classmethod
def _get_public_key(cls, key_name='saml_key'):
""" Get a public key for use in the test. """
return cls._read_data_file('{}.pub'.format(key_name))
@classmethod
def _get_private_key(cls, key_name='saml_key'):
""" Get a private key for use in the test. """
return cls._read_data_file('{}.key'.format(key_name))
@staticmethod
def _read_data_file(filename):
""" Read the contents of a file in the data folder """
with open(os.path.join(os.path.dirname(__file__), 'data', filename)) as f:
return f.read()
@contextmanager
......
......@@ -9,9 +9,11 @@ from social.apps.django_app.default.models import UserSocialAuth
from student.tests.factories import UserFactory
from .testutil import ThirdPartyAuthTestMixin
@httpretty.activate
class ThirdPartyOAuthTestMixin(object):
class ThirdPartyOAuthTestMixin(ThirdPartyAuthTestMixin):
"""
Mixin with tests for third party oauth views. A TestCase that includes
this must define the following:
......@@ -32,6 +34,10 @@ class ThirdPartyOAuthTestMixin(object):
if create_user:
self.user = UserFactory()
UserSocialAuth.objects.create(user=self.user, provider=self.BACKEND, uid=self.social_uid)
if self.BACKEND == 'google-oauth2':
self.configure_google_provider(enabled=True)
elif self.BACKEND == 'facebook':
self.configure_facebook_provider(enabled=True)
def _setup_provider_response(self, success=False, email=''):
"""
......
......@@ -2,10 +2,11 @@
from django.conf.urls import include, patterns, url
from .views import inactive_user_view
from .views import inactive_user_view, saml_metadata_view
urlpatterns = patterns(
'',
url(r'^auth/inactive', inactive_user_view),
url(r'^auth/saml/metadata.xml', saml_metadata_view),
url(r'^auth/', include('social.apps.django_app.urls', namespace='social')),
)
"""
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 social.apps.django_app.utils import load_strategy, load_backend
from .models import SAMLConfiguration
def inactive_user_view(request):
......@@ -13,3 +18,21 @@ def inactive_user_view(request):
# in a course. Otherwise, just redirect them to the dashboard, which displays a message
# about activating their account.
return redirect(request.GET.get('next', 'dashboard'))
def saml_metadata_view(request):
"""
Get the Service Provider metadata for this edx-platform instance.
You must send this XML to any Shibboleth Identity Provider that you wish to use.
"""
if not SAMLConfiguration.is_enabled():
raise Http404
complete_url = reverse('social:complete', args=("tpa-saml", ))
if settings.APPEND_SLASH and not complete_url.endswith('/'):
complete_url = complete_url + '/' # Required for consistency
saml_backend = load_backend(load_strategy(request), "tpa-saml", redirect_uri=complete_url)
metadata, errors = saml_backend.generate_metadata_xml()
if not errors:
return HttpResponse(content=metadata, content_type='text/xml')
return HttpResponseServerError(content=', '.join(errors))
......@@ -9,7 +9,7 @@ For processing xml always prefer this over using lxml.etree directly.
from lxml.etree import * # pylint: disable=wildcard-import, unused-wildcard-import
from lxml.etree import XMLParser as _XMLParser
from lxml.etree import _ElementTree # pylint: disable=unused-import
from lxml.etree import _Element, _ElementTree # pylint: disable=unused-import, no-name-in-module
# This should be imported after lxml.etree so that it overrides the following attributes.
from defusedxml.lxml import parse, fromstring, XML
......
......@@ -1754,6 +1754,7 @@ class CombinedSystem(object):
integrate it into a larger whole.
"""
context = context or {}
if view_name in PREVIEW_VIEWS:
block = self._get_student_block(block)
......
......@@ -211,6 +211,15 @@ class FieldsMixin(object):
query = self.q(css='.u-field-link-title-{}'.format(field_id))
return query.text[0] if query.present else None
def wait_for_link_title_for_link_field(self, field_id, expected_title):
"""
Wait until the title of the specified link field equals expected_title.
"""
return EmptyPromise(
lambda: self.link_title_for_link_field(field_id) == expected_title,
"Link field with link title \"{0}\" is visible.".format(expected_title)
).fulfill()
def click_on_link_in_link_field(self, field_id):
"""
Click the link in a link field.
......
......@@ -232,7 +232,7 @@ class CombinedLoginAndRegisterPage(PageObject):
Only the "Dummy" provider is used for bok choy because it is the only
one that doesn't send traffic to external servers.
"""
self.q(css="button.{}-Dummy".format(self.current_form)).click()
self.q(css="button.{}-oa2-dummy".format(self.current_form)).click()
def password_reset(self, email):
"""Navigates to, fills in, and submits the password reset form.
......@@ -281,6 +281,8 @@ class CombinedLoginAndRegisterPage(PageObject):
return "login"
elif self.q(css=".js-reset").visible:
return "password-reset"
elif self.q(css=".proceed-button").visible:
return "hinted-login"
@property
def email_value(self):
......@@ -335,3 +337,9 @@ class CombinedLoginAndRegisterPage(PageObject):
return (True, msg_element.text[0])
return (False, None)
return Promise(_check_func, "Result of third party auth is visible").fulfill()
@property
def hinted_login_prompt(self):
"""Get the message displayed to the user on the hinted-login form"""
if self.q(css=".wrapper-other-login .instructions").visible:
return self.q(css=".wrapper-other-login .instructions").text[0]
......@@ -437,9 +437,10 @@ class AccountSettingsPageTest(AccountSettingsTestMixin, WebAppTest):
Currently there is no way to test the whole authentication process
because that would require accounts with the providers.
"""
for field_id, title, link_title in [
['auth-facebook', 'Facebook', 'Link'],
['auth-google', 'Google', 'Link'],
]:
providers = (
['auth-oa2-facebook', 'Facebook', 'Link'],
['auth-oa2-google-oauth2', 'Google', 'Link'],
)
for field_id, title, link_title in providers:
self.assertEqual(self.account_settings_page.title_for_field(field_id), title)
self.assertEqual(self.account_settings_page.link_title_for_link_field(field_id), link_title)
......@@ -164,9 +164,43 @@ class LoginFromCombinedPageTest(UniqueCourseTest):
self.dashboard_page.wait_for_page()
# Now unlink the account (To test the account settings view and also to prevent cross-test side effects)
self._unlink_dummy_account()
def test_hinted_login(self):
""" Test the login page when coming from course URL that specified which third party provider to use """
# Create a user account and link it to third party auth with the dummy provider:
AutoAuthPage(self.browser, course_id=self.course_id).visit()
self._link_dummy_account()
LogoutPage(self.browser).visit()
# When not logged in, try to load a course URL that includes the provider hint ?tpa_hint=...
course_page = CoursewarePage(self.browser, self.course_id)
self.browser.get(course_page.url + '?tpa_hint=oa2-dummy')
# We should now be redirected to the login page
self.login_page.wait_for_page()
self.assertIn("Would you like to sign in using your Dummy credentials?", self.login_page.hinted_login_prompt)
self.login_page.click_third_party_dummy_provider()
# We should now be redirected to the course page
course_page.wait_for_page()
self._unlink_dummy_account()
def _link_dummy_account(self):
""" Go to Account Settings page and link the user's account to the Dummy provider """
account_settings = AccountSettingsPage(self.browser).visit()
field_id = "auth-oa2-dummy"
account_settings.wait_for_field(field_id)
self.assertEqual("Link", account_settings.link_title_for_link_field(field_id))
account_settings.click_on_link_in_link_field(field_id)
account_settings.wait_for_link_title_for_link_field(field_id, "Unlink")
def _unlink_dummy_account(self):
""" Verify that the 'Dummy' third party auth provider is linked, then unlink it """
# This must be done after linking the account, or we'll get cross-test side effects
account_settings = AccountSettingsPage(self.browser).visit()
field_id = "auth-dummy"
field_id = "auth-oa2-dummy"
account_settings.wait_for_field(field_id)
self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id))
account_settings.click_on_link_in_link_field(field_id)
......@@ -305,7 +339,7 @@ class RegisterFromCombinedPageTest(UniqueCourseTest):
# Now unlink the account (To test the account settings view and also to prevent cross-test side effects)
account_settings = AccountSettingsPage(self.browser).visit()
field_id = "auth-dummy"
field_id = "auth-oa2-dummy"
account_settings.wait_for_field(field_id)
self.assertEqual("Unlink", account_settings.link_title_for_link_field(field_id))
account_settings.click_on_link_in_link_field(field_id)
......
[
{
"pk": 1,
"model": "third_party_auth.oauth2providerconfig",
"fields": {
"enabled": true,
"change_date": "2001-02-03T04:05:06Z",
"changed_by": null,
"name": "Google",
"icon_class": "fa-google-plus",
"backend_name": "google-oauth2",
"key": "test",
"secret": "test",
"other_settings": "{}"
}
},
{
"pk": 2,
"model": "third_party_auth.oauth2providerconfig",
"fields": {
"enabled": true,
"change_date": "2001-02-03T04:05:06Z",
"changed_by": null,
"name": "Facebook",
"icon_class": "fa-facebook",
"backend_name": "facebook",
"key": "test",
"secret": "test",
"other_settings": "{}"
}
},
{
"pk": 3,
"model": "third_party_auth.oauth2providerconfig",
"fields": {
"enabled": true,
"change_date": "2001-02-03T04:05:06Z",
"changed_by": null,
"name": "Dummy",
"icon_class": "fa-sign-in",
"backend_name": "dummy",
"key": "",
"secret": "",
"other_settings": "{}"
}
}
]
......@@ -23,7 +23,7 @@ from openedx.core.djangoapps.user_api.accounts.api import activate_account, crea
from openedx.core.djangoapps.user_api.accounts import EMAIL_MAX_LENGTH
from student.tests.factories import CourseModeFactory, UserFactory
from student_account.views import account_settings_context
from third_party_auth.tests.testutil import simulate_running_pipeline
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
from util.testing import UrlResetMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
......@@ -204,7 +204,7 @@ class StudentAccountUpdateTest(UrlResetMixin, TestCase):
@ddt.ddt
class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase):
class StudentAccountLoginAndRegistrationTest(ThirdPartyAuthTestMixin, UrlResetMixin, ModuleStoreTestCase):
""" Tests for the student account views that update the user's account information. """
USERNAME = "bob"
......@@ -214,6 +214,9 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
@mock.patch.dict(settings.FEATURES, {'EMBARGO': True})
def setUp(self):
super(StudentAccountLoginAndRegistrationTest, self).setUp('embargo')
# For these tests, two third party auth providers are enabled by default:
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
@ddt.data(
("account_login", "login"),
......@@ -290,7 +293,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
@ddt.unpack
def test_third_party_auth(self, url_name, current_backend, current_provider):
params = [
('course_id', 'edX/DemoX/Demo_Course'),
('course_id', 'course-v1:Org+Course+Run'),
('enrollment_action', 'enroll'),
('course_mode', 'honor'),
('email_opt_in', 'true'),
......@@ -310,12 +313,14 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
expected_providers = [
{
"id": "oa2-facebook",
"name": "Facebook",
"iconClass": "fa-facebook",
"loginUrl": self._third_party_login_url("facebook", "login", params),
"registerUrl": self._third_party_login_url("facebook", "register", params)
},
{
"id": "oa2-google-oauth2",
"name": "Google",
"iconClass": "fa-google-plus",
"loginUrl": self._third_party_login_url("google-oauth2", "login", params),
......@@ -324,6 +329,11 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
]
self._assert_third_party_auth_data(response, current_backend, current_provider, expected_providers)
def test_hinted_login(self):
params = [("next", "/courses/something/?tpa_hint=oa2-google-oauth2")]
response = self.client.get(reverse('account_login'), params)
self.assertContains(response, "data-third-party-auth-hint='oa2-google-oauth2'")
@override_settings(SITE_NAME=settings.MICROSITE_TEST_HOSTNAME)
def test_microsite_uses_old_login_page(self):
# Retrieve the login page from a microsite domain
......@@ -347,11 +357,15 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
def _assert_third_party_auth_data(self, response, current_backend, current_provider, providers):
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """
finish_auth_url = None
if current_backend:
finish_auth_url = reverse("social:complete", kwargs={"backend": current_backend}) + "?"
auth_info = markupsafe.escape(
json.dumps({
"currentProvider": current_provider,
"providers": providers,
"finishAuthUrl": "/auth/complete/{}?".format(current_backend) if current_backend else None,
"secondaryProviders": [],
"finishAuthUrl": finish_auth_url,
"errorMessage": None,
})
)
......@@ -382,7 +396,7 @@ class StudentAccountLoginAndRegistrationTest(UrlResetMixin, ModuleStoreTestCase)
})
class AccountSettingsViewTest(TestCase):
class AccountSettingsViewTest(ThirdPartyAuthTestMixin, TestCase):
""" Tests for the account settings view. """
USERNAME = 'student'
......@@ -406,6 +420,10 @@ class AccountSettingsViewTest(TestCase):
self.request = RequestFactory()
self.request.user = self.user
# For these tests, two third party auth providers are enabled by default:
self.configure_google_provider(enabled=True)
self.configure_facebook_provider(enabled=True)
# Python-social saves auth failure notifcations in Django messages.
# See pipeline.get_duplicate_provider() for details.
self.request.COOKIES = {}
......@@ -432,7 +450,7 @@ class AccountSettingsViewTest(TestCase):
context['user_preferences_api_url'], reverse('preferences_api', kwargs={'username': self.user.username})
)
self.assertEqual(context['duplicate_provider'].BACKEND_CLASS.name, 'facebook')
self.assertEqual(context['duplicate_provider'], 'facebook')
self.assertEqual(context['auth']['providers'][0]['name'], 'Facebook')
self.assertEqual(context['auth']['providers'][1]['name'], 'Google')
......
......@@ -2,6 +2,7 @@
import logging
import json
import urlparse
from django.conf import settings
from django.contrib import messages
......@@ -77,12 +78,26 @@ def login_and_registration_form(request, initial_mode="login"):
if ext_auth_response is not None:
return ext_auth_response
# Our ?next= URL may itself contain a parameter 'tpa_hint=x' that we need to check.
# If present, we display a login page focused on third-party auth with that provider.
third_party_auth_hint = None
if '?' in redirect_to:
try:
next_args = urlparse.parse_qs(urlparse.urlparse(redirect_to).query)
provider_id = next_args['tpa_hint'][0]
if third_party_auth.provider.Registry.get(provider_id=provider_id):
third_party_auth_hint = provider_id
initial_mode = "hinted_login"
except (KeyError, ValueError, IndexError):
pass
# Otherwise, render the combined login/registration page
context = {
'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header
'disable_courseware_js': True,
'initial_mode': initial_mode,
'third_party_auth': json.dumps(_third_party_auth_context(request, redirect_to)),
'third_party_auth_hint': third_party_auth_hint or '',
'platform_name': settings.PLATFORM_NAME,
'responsive': True,
......@@ -164,41 +179,45 @@ def _third_party_auth_context(request, redirect_to):
context = {
"currentProvider": None,
"providers": [],
"secondaryProviders": [],
"finishAuthUrl": None,
"errorMessage": None,
}
if third_party_auth.is_enabled():
context["providers"] = [
{
"name": enabled.NAME,
"iconClass": enabled.ICON_CLASS,
for enabled in third_party_auth.provider.Registry.enabled():
info = {
"id": enabled.provider_id,
"name": enabled.name,
"iconClass": enabled.icon_class,
"loginUrl": pipeline.get_login_url(
enabled.NAME,
enabled.provider_id,
pipeline.AUTH_ENTRY_LOGIN,
redirect_url=redirect_to,
),
"registerUrl": pipeline.get_login_url(
enabled.NAME,
enabled.provider_id,
pipeline.AUTH_ENTRY_REGISTER,
redirect_url=redirect_to,
),
}
for enabled in third_party_auth.provider.Registry.enabled()
]
context["providers" if not enabled.secondary else "secondaryProviders"].append(info)
running_pipeline = pipeline.get(request)
if running_pipeline is not None:
current_provider = third_party_auth.provider.Registry.get_by_backend_name(
running_pipeline.get('backend')
)
context["currentProvider"] = current_provider.NAME
context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.BACKEND_CLASS.name)
current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline)
context["currentProvider"] = current_provider.name
context["finishAuthUrl"] = pipeline.get_complete_url(current_provider.backend_name)
if current_provider.skip_registration_form:
# As a reliable way of "skipping" the registration form, we just submit it automatically
context["autoSubmitRegForm"] = True
# Check for any error messages we may want to display:
for msg in messages.get_messages(request):
if msg.extra_tags.split()[0] == "social-auth":
context['errorMessage'] = unicode(msg)
# msg may or may not be translated. Try translating [again] in case we are able to:
context['errorMessage'] = _(unicode(msg)) # pylint: disable=translation-of-non-string
break
return context
......@@ -370,19 +389,20 @@ def account_settings_context(request):
auth_states = pipeline.get_provider_user_states(user)
context['auth']['providers'] = [{
'name': state.provider.NAME, # The name of the provider e.g. Facebook
'id': state.provider.provider_id,
'name': state.provider.name, # The name of the provider e.g. Facebook
'connected': state.has_account, # Whether the user's edX account is connected with the provider.
# If the user is not connected, they should be directed to this page to authenticate
# with the particular provider.
'connect_url': pipeline.get_login_url(
state.provider.NAME,
state.provider.provider_id,
pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS,
# The url the user should be directed to after the auth process has completed.
redirect_url=reverse('account_settings'),
),
# If the user is connected, sending a POST request to this url removes the connection
# information for this provider from their edX account.
'disconnect_url': pipeline.get_disconnect_url(state.provider.NAME),
'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id),
} for state in auth_states]
return context
......@@ -16,6 +16,7 @@ Common traits:
# and throws spurious errors. Therefore, we disable invalid-name checking.
# pylint: disable=invalid-name
import datetime
import json
from .common import *
......@@ -107,6 +108,7 @@ CELERY_QUEUES = {
if os.environ.get('QUEUE') == 'high_mem':
CELERYD_MAX_TASKS_PER_CHILD = 1
CELERYBEAT_SCHEDULE = {} # For scheduling tasks, entries can be added to this dict
########################## NON-SECURE ENV CONFIG ##############################
# Things like server locations, ports, etc.
......@@ -536,10 +538,27 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD
X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS)
##### Third-party auth options ################################################
THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH)
if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
AUTHENTICATION_BACKENDS = (
ENV_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [
'social.backends.google.GoogleOAuth2',
'social.backends.linkedin.LinkedinOAuth2',
'social.backends.facebook.FacebookOAuth2',
'third_party_auth.saml.SAMLAuthBackend',
]) + list(AUTHENTICATION_BACKENDS)
)
# The reduced session expiry time during the third party login pipeline. (Value in seconds)
SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600)
# third_party_auth config moved to ConfigurationModels. This is for data migration only:
THIRD_PARTY_AUTH_OLD_CONFIG = AUTH_TOKENS.get('THIRD_PARTY_AUTH', None)
# The reduced session expiry time during the third party login pipeline. (Value in seconds)
SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600)
if ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24) is not None:
CELERYBEAT_SCHEDULE['refresh-saml-metadata'] = {
'task': 'third_party_auth.fetch_saml_metadata',
'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)),
}
##### OAUTH2 Provider ##############
if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
......
......@@ -117,17 +117,6 @@
"username": "lms"
},
"SECRET_KEY": "",
"THIRD_PARTY_AUTH": {
"Dummy": {},
"Google": {
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test",
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test"
},
"Facebook": {
"SOCIAL_AUTH_FACEBOOK_KEY": "test",
"SOCIAL_AUTH_FACEBOOK_SECRET": "test"
}
},
"DJFS": {
"type": "s3fs",
"bucket": "test",
......
......@@ -79,7 +79,6 @@
"ENABLE_INSTRUCTOR_ANALYTICS": true,
"ENABLE_S3_GRADE_DOWNLOADS": true,
"ENABLE_THIRD_PARTY_AUTH": true,
"ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER": true,
"ENABLE_COMBINED_LOGIN_REGISTRATION": true,
"PREVIEW_LMS_BASE": "localhost:8003",
"SUBDOMAIN_BRANDING": false,
......@@ -119,6 +118,13 @@
"SYSLOG_SERVER": "",
"TECH_SUPPORT_EMAIL": "technical@example.com",
"THEME_NAME": "",
"THIRD_PARTY_AUTH_BACKENDS": [
"social.backends.google.GoogleOAuth2",
"social.backends.linkedin.LinkedinOAuth2",
"social.backends.facebook.FacebookOAuth2",
"third_party_auth.dummy.DummyBackend",
"third_party_auth.saml.SAMLAuthBackend"
],
"TIME_ZONE": "America/New_York",
"WIKI_ENABLED": true
}
......@@ -1271,9 +1271,11 @@ student_account_js = [
'js/student_account/models/PasswordResetModel.js',
'js/student_account/views/FormView.js',
'js/student_account/views/LoginView.js',
'js/student_account/views/HintedLoginView.js',
'js/student_account/views/RegisterView.js',
'js/student_account/views/PasswordResetView.js',
'js/student_account/views/AccessView.js',
'js/student_account/views/InstitutionLoginView.js',
'js/student_account/accessApp.js',
]
......@@ -2385,10 +2387,6 @@ for app_name in OPTIONAL_APPS:
continue
INSTALLED_APPS += (app_name,)
# Stub for third_party_auth options.
# See common/djangoapps/third_party_auth/settings.py for configuration details.
THIRD_PARTY_AUTH = {}
### ADVANCED_SECURITY_CONFIG
# Empty by default
ADVANCED_SECURITY_CONFIG = {}
......
......@@ -170,6 +170,10 @@ FEATURES['STORE_BILLING_INFO'] = True
FEATURES['ENABLE_PAID_COURSE_REGISTRATION'] = True
FEATURES['ENABLE_COSMETIC_DISPLAY_PRICE'] = True
########################## Third Party Auth #######################
if FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and 'third_party_auth.dummy.DummyBackend' not in AUTHENTICATION_BACKENDS:
AUTHENTICATION_BACKENDS = ['third_party_auth.dummy.DummyBackend'] + list(AUTHENTICATION_BACKENDS)
#####################################################################
# See if the developer has any local overrides.
......
......@@ -238,18 +238,13 @@ PASSWORD_COMPLEXITY = {}
######### Third-party auth ##########
FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True
THIRD_PARTY_AUTH = {
"Google": {
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test",
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test",
},
"Facebook": {
"SOCIAL_AUTH_FACEBOOK_KEY": "test",
"SOCIAL_AUTH_FACEBOOK_SECRET": "test",
},
}
FEATURES['ENABLE_DUMMY_THIRD_PARTY_AUTH_PROVIDER'] = True
AUTHENTICATION_BACKENDS = (
'social.backends.google.GoogleOAuth2',
'social.backends.linkedin.LinkedinOAuth2',
'social.backends.facebook.FacebookOAuth2',
'third_party_auth.dummy.DummyBackend',
'third_party_auth.saml.SAMLAuthBackend',
) + AUTHENTICATION_BACKENDS
################################## OPENID #####################################
FEATURES['AUTH_USE_OPENID'] = True
......
......@@ -141,4 +141,4 @@ def enable_third_party_auth():
"""
from third_party_auth import settings as auth_settings
auth_settings.apply_settings(settings.THIRD_PARTY_AUTH, settings)
auth_settings.apply_settings(settings)
......@@ -84,11 +84,13 @@
'js/student_account/views/FormView': 'js/student_account/views/FormView',
'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel',
'js/student_account/views/LoginView': 'js/student_account/views/LoginView',
'js/student_account/views/InstitutionLoginView': 'js/student_account/views/InstitutionLoginView',
'js/student_account/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel',
'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView',
'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel',
'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView',
'js/student_account/views/AccessView': 'js/student_account/views/AccessView',
'js/student_account/views/HintedLoginView': 'js/student_account/views/HintedLoginView',
'js/student_profile/profile': 'js/student_profile/profile',
'js/student_profile/views/learner_profile_fields': 'js/student_profile/views/learner_profile_fields',
'js/student_profile/views/learner_profile_factory': 'js/student_profile/views/learner_profile_factory',
......@@ -410,6 +412,14 @@
'js/student_account/views/FormView'
]
},
'js/student_account/views/InstitutionLoginView': {
exports: 'edx.student.account.InstitutionLoginView',
deps: [
'jquery',
'underscore',
'backbone'
]
},
'js/student_account/models/PasswordResetModel': {
exports: 'edx.student.account.PasswordResetModel',
deps: ['jquery', 'jquery.cookie', 'backbone']
......@@ -439,6 +449,15 @@
'js/student_account/views/FormView'
]
},
'js/student_account/views/HintedLoginView': {
exports: 'edx.student.account.HintedLoginView',
deps: [
'jquery',
'underscore',
'backbone',
'gettext'
]
},
'js/student_account/views/AccessView': {
exports: 'edx.student.account.AccessView',
deps: [
......@@ -450,6 +469,7 @@
'js/student_account/views/LoginView',
'js/student_account/views/PasswordResetView',
'js/student_account/views/RegisterView',
'js/student_account/views/InstitutionLoginView',
'js/student_account/models/LoginModel',
'js/student_account/models/PasswordResetModel',
'js/student_account/models/RegisterModel',
......@@ -612,7 +632,9 @@
'lms/include/js/spec/student_account/account_spec.js',
'lms/include/js/spec/student_account/access_spec.js',
'lms/include/js/spec/student_account/finish_auth_spec.js',
'lms/include/js/spec/student_account/hinted_login_spec.js',
'lms/include/js/spec/student_account/login_spec.js',
'lms/include/js/spec/student_account/institution_login_spec.js',
'lms/include/js/spec/student_account/register_spec.js',
'lms/include/js/spec/student_account/password_reset_spec.js',
'lms/include/js/spec/student_account/enrollment_spec.js',
......
......@@ -58,6 +58,7 @@ define([
thirdPartyAuth: {
currentProvider: null,
providers: [],
secondaryProviders: [{name: "provider"}],
finishAuthUrl: finishAuthUrl
},
nextUrl: nextUrl, // undefined for default
......@@ -97,6 +98,8 @@ define([
TemplateHelpers.installTemplate('templates/student_account/register');
TemplateHelpers.installTemplate('templates/student_account/password_reset');
TemplateHelpers.installTemplate('templates/student_account/form_field');
TemplateHelpers.installTemplate('templates/student_account/institution_login');
TemplateHelpers.installTemplate('templates/student_account/institution_register');
// Stub analytics tracking
window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'pageview', 'trackLink']);
......@@ -135,6 +138,30 @@ define([
assertForms('#login-form', '#register-form');
});
it('toggles between the login and institution login view', function() {
ajaxSpyAndInitialize(this, 'login');
// Simulate clicking on institution login button
$('#login-form .button-secondary-login[data-type="institution_login"]').click();
assertForms('#institution_login-form', '#login-form');
// Simulate selection of the login form
selectForm('login');
assertForms('#login-form', '#institution_login-form');
});
it('toggles between the register and institution register view', function() {
ajaxSpyAndInitialize(this, 'register');
// Simulate clicking on institution login button
$('#register-form .button-secondary-login[data-type="institution_login"]').click();
assertForms('#institution_login-form', '#register-form');
// Simulate selection of the login form
selectForm('register');
assertForms('#register-form', '#institution_login-form');
});
it('displays the reset password form', function() {
ajaxSpyAndInitialize(this, 'login');
......
......@@ -32,12 +32,14 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers
var AUTH_DATA = {
'providers': [
{
'id': 'oa2-network1',
'name': "Network1",
'connected': true,
'connect_url': 'yetanother1.com/auth/connect',
'disconnect_url': 'yetanother1.com/auth/disconnect'
},
{
'id': 'oa2-network2',
'name': "Network2",
'connected': true,
'connect_url': 'yetanother2.com/auth/connect',
......
define([
'jquery',
'underscore',
'common/js/spec_helpers/template_helpers',
'common/js/spec_helpers/ajax_helpers',
'js/student_account/views/HintedLoginView',
], function($, _, TemplateHelpers, AjaxHelpers, HintedLoginView) {
'use strict';
describe('edx.student.account.HintedLoginView', function() {
var view = null,
requests = null,
PLATFORM_NAME = 'edX',
THIRD_PARTY_AUTH = {
currentProvider: null,
providers: [
{
id: 'oa2-google-oauth2',
name: 'Google',
iconClass: 'fa-google-plus',
loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login',
registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register'
},
{
id: 'oa2-facebook',
name: 'Facebook',
iconClass: 'fa-facebook',
loginUrl: '/auth/login/facebook/?auth_entry=account_login',
registerUrl: '/auth/login/facebook/?auth_entry=account_register'
}
]
},
HINTED_PROVIDER = "oa2-google-oauth2";
var createHintedLoginView = function(test) {
// Initialize the login view
view = new HintedLoginView({
thirdPartyAuth: THIRD_PARTY_AUTH,
hintedProvider: HINTED_PROVIDER,
platformName: PLATFORM_NAME
});
// Mock the redirect call
spyOn( view, 'redirect' ).andCallFake( function() {} );
view.render();
};
beforeEach(function() {
setFixtures('<div id="hinted-login-form"></div>');
TemplateHelpers.installTemplate('templates/student_account/hinted_login');
});
it('displays a choice as two buttons', function() {
createHintedLoginView(this);
expect($('.proceed-button.button-oa2-google-oauth2')).toBeVisible();
expect($('.form-toggle')).toBeVisible();
expect($('.proceed-button.button-oa2-facebook')).not.toBeVisible();
});
it('redirects the user to the hinted provider if the user clicks the proceed button', function() {
createHintedLoginView(this);
// Click the "Yes, proceed" button
$('.proceed-button').click();
expect(view.redirect).toHaveBeenCalledWith( '/auth/login/google-oauth2/?auth_entry=account_login' );
});
});
});
define([
'jquery',
'underscore',
'common/js/spec_helpers/template_helpers',
'js/student_account/views/InstitutionLoginView',
], function($, _, TemplateHelpers, InstitutionLoginView) {
'use strict';
describe('edx.student.account.InstitutionLoginView', function() {
var view = null,
PLATFORM_NAME = 'edX',
THIRD_PARTY_AUTH = {
currentProvider: null,
providers: [],
secondaryProviders: [
{
id: 'oa2-google-oauth2',
name: 'Google',
iconClass: 'fa-google-plus',
loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login',
registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register'
},
{
id: 'oa2-facebook',
name: 'Facebook',
iconClass: 'fa-facebook',
loginUrl: '/auth/login/facebook/?auth_entry=account_login',
registerUrl: '/auth/login/facebook/?auth_entry=account_register'
}
]
};
var createInstLoginView = function(mode) {
// Initialize the login view
view = new InstitutionLoginView({
mode: mode,
thirdPartyAuth: THIRD_PARTY_AUTH,
platformName: PLATFORM_NAME
});
view.render();
};
beforeEach(function() {
setFixtures('<div id="institution_login-form"></div>');
TemplateHelpers.installTemplate('templates/student_account/institution_login');
TemplateHelpers.installTemplate('templates/student_account/institution_register');
});
it('displays a list of providers', function() {
createInstLoginView('login');
expect($('#institution_login-form').html()).not.toBe("");
var $google = $('li a:contains("Google")');
expect($google).toBeVisible();
expect($google).toHaveAttr(
'href', '/auth/login/google-oauth2/?auth_entry=account_login'
);
var $facebook = $('li a:contains("Facebook")');
expect($facebook).toBeVisible();
expect($facebook).toHaveAttr(
'href', '/auth/login/facebook/?auth_entry=account_login'
);
});
it('displays a list of providers', function() {
createInstLoginView('register');
expect($('#institution_login-form').html()).not.toBe("");
var $google = $('li a:contains("Google")');
expect($google).toBeVisible();
expect($google).toHaveAttr(
'href', '/auth/login/google-oauth2/?auth_entry=account_register'
);
var $facebook = $('li a:contains("Facebook")');
expect($facebook).toBeVisible();
expect($facebook).toHaveAttr(
'href', '/auth/login/facebook/?auth_entry=account_register'
);
});
});
});
......@@ -25,12 +25,14 @@ define([
currentProvider: null,
providers: [
{
id: 'oa2-google-oauth2',
name: 'Google',
iconClass: 'fa-google-plus',
loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login',
registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register'
},
{
id: 'oa2-facebook',
name: 'Facebook',
iconClass: 'fa-facebook',
loginUrl: '/auth/login/facebook/?auth_entry=account_login',
......@@ -195,8 +197,8 @@ define([
createLoginView(this);
// Verify that Google and Facebook registration buttons are displayed
expect($('.button-Google')).toBeVisible();
expect($('.button-Facebook')).toBeVisible();
expect($('.button-oa2-google-oauth2')).toBeVisible();
expect($('.button-oa2-facebook')).toBeVisible();
});
it('displays a link to the password reset form', function() {
......
......@@ -32,12 +32,14 @@ define([
currentProvider: null,
providers: [
{
id: 'oa2-google-oauth2',
name: 'Google',
iconClass: 'fa-google-plus',
loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login',
registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register'
},
{
id: 'oa2-facebook',
name: 'Facebook',
iconClass: 'fa-facebook',
loginUrl: '/auth/login/facebook/?auth_entry=account_login',
......@@ -284,8 +286,8 @@ define([
createRegisterView(this);
// Verify that Google and Facebook registration buttons are displayed
expect($('.button-Google')).toBeVisible();
expect($('.button-Facebook')).toBeVisible();
expect($('.button-oa2-google-oauth2')).toBeVisible();
expect($('.button-oa2-facebook')).toBeVisible();
});
it('validates registration form fields', function() {
......
......@@ -11,6 +11,7 @@ var edx = edx || {};
return new edx.student.account.AccessView({
mode: container.data('initial-mode'),
thirdPartyAuth: container.data('third-party-auth'),
thirdPartyAuthHint: container.data('third-party-auth-hint'),
nextUrl: container.data('next-url'),
platformName: container.data('platform-name'),
loginFormDesc: container.data('login-form-desc'),
......
......@@ -18,7 +18,9 @@ var edx = edx || {};
subview: {
login: {},
register: {},
passwordHelp: {}
passwordHelp: {},
institutionLogin: {},
hintedLogin: {}
},
nextUrl: '/dashboard',
......@@ -42,6 +44,8 @@ var edx = edx || {};
providers: []
};
this.thirdPartyAuthHint = obj.thirdPartyAuthHint || null;
if (obj.nextUrl) {
// Ensure that the next URL is internal for security reasons
if ( ! window.isExternal( obj.nextUrl ) ) {
......@@ -52,7 +56,9 @@ var edx = edx || {};
this.formDescriptions = {
login: obj.loginFormDesc,
register: obj.registrationFormDesc,
reset: obj.passwordResetFormDesc
reset: obj.passwordResetFormDesc,
institution_login: null,
hinted_login: null
};
this.platformName = obj.platformName;
......@@ -148,6 +154,26 @@ var edx = edx || {};
// Listen for 'auth-complete' event so we can enroll/redirect the user appropriately.
this.listenTo( this.subview.register, 'auth-complete', this.authComplete );
},
institution_login: function ( unused ) {
this.subview.institutionLogin = new edx.student.account.InstitutionLoginView({
thirdPartyAuth: this.thirdPartyAuth,
platformName: this.platformName,
mode: this.activeForm
});
this.subview.institutionLogin.render();
},
hinted_login: function ( unused ) {
this.subview.hintedLogin = new edx.student.account.HintedLoginView({
thirdPartyAuth: this.thirdPartyAuth,
hintedProvider: this.thirdPartyAuthHint,
platformName: this.platformName
});
this.subview.hintedLogin.render();
}
},
......@@ -180,9 +206,11 @@ var edx = edx || {};
category: 'user-engagement'
});
if ( !this.form.isLoaded( $form ) ) {
// Load the form. Institution login is always refreshed since it changes based on the previous form.
if ( !this.form.isLoaded( $form ) || type == "institution_login") {
this.loadForm( type );
}
this.activeForm = type;
this.element.hide( $(this.el).find('.submission-success') );
this.element.hide( $(this.el).find('.form-wrapper') );
......@@ -190,11 +218,13 @@ var edx = edx || {};
this.element.scrollTop( $anchor );
// Update url without reloading page
History.pushState( null, document.title, '/' + type + queryStr );
if (type != "institution_login") {
History.pushState( null, document.title, '/' + type + queryStr );
}
analytics.page( 'login_and_registration', type );
// Focus on the form
document.getElementById(type).focus();
$("#" + type).focus();
},
/**
......
......@@ -215,7 +215,9 @@ var edx = edx || {};
submitForm: function( event ) {
var data = this.getFormData();
event.preventDefault();
if (!_.isUndefined(event)) {
event.preventDefault();
}
this.toggleDisableButton(true);
......
var edx = edx || {};
(function($, _, gettext) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.HintedLoginView = Backbone.View.extend({
el: '#hinted-login-form',
tpl: '#hinted_login-tpl',
events: {
'click .proceed-button': 'proceedWithHintedAuth'
},
formType: 'hinted-login',
initialize: function( data ) {
this.tpl = $(this.tpl).html();
this.providers = data.thirdPartyAuth.providers || [];
this.hintedProvider = _.findWhere(this.providers, {id: data.hintedProvider})
this.platformName = data.platformName;
},
render: function() {
$(this.el).html( _.template( this.tpl, {
// We pass the context object to the template so that
// we can perform variable interpolation using sprintf
providers: this.providers,
platformName: this.platformName,
hintedProvider: this.hintedProvider
}));
return this;
},
proceedWithHintedAuth: function( event ) {
this.redirect(this.hintedProvider.loginUrl);
},
/**
* Redirect to a URL. Mainly useful for mocking out in tests.
* @param {string} url The URL to redirect to.
*/
redirect: function( url ) {
window.location.href = url;
}
});
})(jQuery, _, gettext);
var edx = edx || {};
(function($, _, Backbone) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.InstitutionLoginView = Backbone.View.extend({
el: '#institution_login-form',
initialize: function( data ) {
var tpl = data.mode == "register" ? '#institution_register-tpl' : '#institution_login-tpl';
this.tpl = $(tpl).html();
this.providers = data.thirdPartyAuth.secondaryProviders || [];
this.platformName = data.platformName;
},
render: function() {
$(this.el).html( _.template( this.tpl, {
// We pass the context object to the template so that
// we can perform variable interpolation using sprintf
providers: this.providers,
platformName: this.platformName
}));
return this;
}
});
})(jQuery, _, Backbone);
......@@ -25,6 +25,9 @@ var edx = edx || {};
preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || [];
this.hasSecondaryProviders = (
data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length
);
this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName;
......@@ -45,6 +48,7 @@ var edx = edx || {};
currentProvider: this.currentProvider,
errorMessage: this.errorMessage,
providers: this.providers,
hasSecondaryProviders: this.hasSecondaryProviders,
platformName: this.platformName
}
}));
......
......@@ -22,9 +22,13 @@ var edx = edx || {};
preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || [];
this.hasSecondaryProviders = (
data.thirdPartyAuth.secondaryProviders && data.thirdPartyAuth.secondaryProviders.length
);
this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
this.platformName = data.platformName;
this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm;
this.listenTo( this.model, 'sync', this.saveSuccess );
},
......@@ -41,12 +45,19 @@ var edx = edx || {};
currentProvider: this.currentProvider,
errorMessage: this.errorMessage,
providers: this.providers,
hasSecondaryProviders: this.hasSecondaryProviders,
platformName: this.platformName
}
}));
this.postRender();
if (this.autoSubmit) {
$(this.el).hide();
$('#register-honor_code').prop('checked', true);
this.submitForm();
}
return this;
},
......@@ -63,6 +74,7 @@ var edx = edx || {};
},
saveError: function( error ) {
$(this.el).show(); // Show in case the form was hidden for auto-submission
this.errors = _.flatten(
_.map(
JSON.parse(error.responseText),
......@@ -76,6 +88,13 @@ var edx = edx || {};
);
this.setErrors();
this.toggleDisableButton(false);
}
},
postFormSubmission: function() {
if (_.compact(this.errors).length) {
// The form did not get submitted due to validation errors.
$(this.el).show(); // Show in case the form was hidden for auto-submission
}
},
});
})(jQuery, _, gettext);
......@@ -137,7 +137,7 @@
screenReaderTitle: interpolate_text(
gettext("Connect your {accountName} account"), {accountName: provider['name']}
),
valueAttribute: 'auth-' + provider.name.toLowerCase(),
valueAttribute: 'auth-' + provider.id,
helpMessage: '',
connected: provider.connected,
connectUrl: provider.connect_url,
......
......@@ -532,30 +532,30 @@
margin-right: 0;
}
&.button-Google:hover, &.button-Google:focus {
&.button-oa2-google-oauth2:hover, &.button-oa2-google-oauth2:focus {
background-color: #dd4b39;
border: 1px solid #A5382B;
}
&.button-Google:hover {
&.button-oa2-google-oauth2:hover {
box-shadow: 0 2px 1px 0 #8D3024;
}
&.button-Facebook:hover, &.button-Facebook:focus {
&.button-oa2-facebook:hover, &.button-oa2-facebook:focus {
background-color: #3b5998;
border: 1px solid #263A62;
}
&.button-Facebook:hover {
&.button-oa2-facebook:hover {
box-shadow: 0 2px 1px 0 #30487C;
}
&.button-LinkedIn:hover , &.button-LinkedIn:focus {
&.button-oa2-linkedin-oauth2:hover , &.button-oa2-linkedin-oauth2:focus {
background-color: #0077b5;
border: 1px solid #06527D;
}
&.button-LinkedIn:hover {
&.button-oa2-linkedin-oauth2:hover {
box-shadow: 0 2px 1px 0 #005D8E;
}
......
......@@ -14,6 +14,7 @@ $sm-btn-linkedin: #0077b5;
background: $white;
min-height: 100%;
width: 100%;
$third-party-button-height: ($baseline*1.75);
h2 {
@extend %t-title5;
......@@ -22,6 +23,10 @@ $sm-btn-linkedin: #0077b5;
font-family: $sans-serif;
}
.instructions {
@extend %t-copy-base;
}
/* Temp. fix until applied globally */
> {
@include box-sizing(border-box);
......@@ -67,10 +72,11 @@ $sm-btn-linkedin: #0077b5;
}
}
form {
form,
.wrapper-other-login {
border: 1px solid $gray-l4;
border-radius: 5px;
padding: 0px 25px 20px 25px;
border-radius: ($baseline/4);
padding: 0 ($baseline*1.25) $baseline ($baseline*1.25);
}
.section-title {
......@@ -106,16 +112,20 @@ $sm-btn-linkedin: #0077b5;
}
}
.nav-btn {
%nav-btn-base {
@extend %btn-secondary-blue-outline;
width: 100%;
height: ($baseline*2);
text-transform: none;
text-shadow: none;
font-weight: 600;
letter-spacing: normal;
}
.nav-btn {
@extend %nav-btn-base;
@extend %t-strong;
}
.form-type,
.toggle-form {
@include box-sizing(border-box);
......@@ -348,29 +358,31 @@ $sm-btn-linkedin: #0077b5;
.login-provider {
@extend %btn-secondary-grey-outline;
width: 130px;
padding: 0 0 0 ($baseline*2);
height: 34px;
text-align: left;
text-shadow: none;
text-transform: none;
@extend %t-action4;
@include padding(0, 0, 0, $baseline*2);
@include text-align(left);
position: relative;
font-size: 0.8em;
margin-right: ($baseline/4);
margin-bottom: $baseline;
border-color: $lightGrey1;
&:nth-of-type(odd) {
margin-right: 13px;
}
width: $baseline*6.5;
height: $third-party-button-height;
text-shadow: none;
text-transform: none;
.icon {
color: white;
@include left(0);
position: absolute;
top: -1px;
left: 0;
width: 30px;
height: 34px;
line-height: 34px;
bottom: -1px;
background: $m-blue-d3;
line-height: $third-party-button-height;
text-align: center;
color: $white;
}
&:hover,
......@@ -378,17 +390,13 @@ $sm-btn-linkedin: #0077b5;
background-image: none;
.icon {
height: 32px;
line-height: 32px;
top: 0;
bottom: 0;
line-height: ($third-party-button-height - 2px);
}
}
&:last-child {
margin-bottom: $baseline;
}
&.button-Google {
&.button-oa2-google-oauth2 {
color: $sm-btn-google;
.icon {
......@@ -407,7 +415,7 @@ $sm-btn-linkedin: #0077b5;
}
}
&.button-Facebook {
&.button-oa2-facebook {
color: $sm-btn-facebook;
.icon {
......@@ -426,7 +434,7 @@ $sm-btn-linkedin: #0077b5;
}
}
&.button-LinkedIn {
&.button-oa2-linkedin-oauth2 {
color: $sm-btn-linkedin;
.icon {
......@@ -447,6 +455,19 @@ $sm-btn-linkedin: #0077b5;
}
.button-secondary-login {
@extend %nav-btn-base;
@extend %t-action4;
@extend %t-regular;
border-color: $lightGrey1;
padding: 0;
height: $third-party-button-height;
&:hover {
border-color: $m-blue-d3;
}
}
/** Error Container - from _account.scss **/
.status {
@include box-sizing(border-box);
......@@ -503,6 +524,13 @@ $sm-btn-linkedin: #0077b5;
}
}
.institution-list {
.institution {
@extend %t-copy-base;
}
}
@include media( max-width 330px) {
.form-type {
width: 98%;
......
......@@ -5,7 +5,7 @@
<h2 class="sr">${_("Could Not Link Accounts")}</h2>
<div class="copy">
## 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.
<p>${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name='<strong>{duplicate_provider}</strong>'.format(duplicate_provider=duplicate_provider.NAME), platform_name=platform_name)}</p>
<p>${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}</p>
</div>
</div>
</div>
......
......@@ -221,7 +221,7 @@ from microsite_configuration import microsite
% 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 button-${enabled.NAME} login-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.NAME]}');"><span class="icon fa ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button>
<button type="submit" class="button button-primary button-${enabled.provider_id} login-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.name)}</button>
% endfor
</div>
......
......@@ -132,7 +132,7 @@ import calendar
% 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 button-${enabled.NAME} register-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.NAME]}');"><span class="icon fa ${enabled.ICON_CLASS}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.NAME)}</button>
<button type="submit" class="button button-primary button-${enabled.provider_id} register-${enabled.provider_id}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.provider_id]}');"><span class="icon fa ${enabled.icon_class}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.name)}</button>
% endfor
</div>
......
......@@ -9,3 +9,11 @@
<section id="password-reset-anchor" class="form-type">
<div id="password-reset-form" class="form-wrapper hidden" aria-hidden="true"></div>
</section>
<section id="institution_login-anchor" class="form-type">
<div id="institution_login-form" class="form-wrapper hidden" aria-hidden="true"></div>
</section>
<section id="hinted-login-anchor" class="form-type">
<div id="hinted-login-form" class="form-wrapper <% if ( mode !== 'hinted_login' ) { %>hidden<% } %>"></div>
</section>
<div class="wrapper-other-login">
<div class="section-title lines">
<h2>
<span class="text"><%- gettext("Sign in") %></span>
</h2>
</div>
<p class="instructions"><%- _.sprintf( gettext("Would you like to sign in using your %(providerName)s credentials?"), { providerName: hintedProvider.name } ) %></p>
<button class="action action-primary action-update proceed-button button-<%- hintedProvider.id %> hinted-login-<%- hintedProvider.id %>">
<div class="icon fa <%- hintedProvider.iconClass %>" aria-hidden="true"></div>
<%- _.sprintf( gettext("Sign in using %(providerName)s"), { providerName: hintedProvider.name } ) %>
</button>
<div class="section-title lines">
<h2>
<span class="text"><%- gettext("or") %></span>
</h2>
</div>
<div class="toggle-form">
<button class="nav-btn form-toggle" data-type="login"><%- gettext("Show me other ways to sign in or register") %></button>
</div>
</div>
<div class="wrapper-other-login">
<div class="section-title lines">
<h2>
<span class="text">
<%- gettext("Sign in with Institution/Campus Credentials") %>
</span>
</h2>
</div>
<p class="instructions"><%- gettext("Choose your institution from the list below:") %></p>
<ul class="institution-list">
<% _.each( _.sortBy(providers, "name"), function( provider ) {
if ( provider.loginUrl ) { %>
<li class="institution">
<a class="institution-login-link" href="<%- provider.loginUrl %>"><%- provider.name %></a>
</li>
<% }
}); %>
</ul>
<div class="section-title lines">
<h2>
<span class="text"><%- gettext("or") %></span>
</h2>
</div>
<div class="toggle-form">
<button class="nav-btn form-toggle" data-type="login"><%- gettext("Back to sign in") %></button>
</div>
</div>
<div class="wrapper-other-login">
<div class="section-title lines">
<h2>
<span class="text">
<%- gettext("Register with Institution/Campus Credentials") %>
</span>
</h2>
</div>
<p class="instructions"><%- gettext("Choose your institution from the list below:") %></p>
<ul class="institution-list">
<% _.each( _.sortBy(providers, "name"), function( provider ) {
if ( provider.registerUrl ) { %>
<li class="institution">
<a class="institution-login-link" href="<%- provider.registerUrl %>"><%- provider.name %></a>
</li>
<% }
}); %>
</ul>
<div class="section-title lines">
<h2>
<span class="text"><%- gettext("or") %></span>
</h2>
</div>
<div class="toggle-form">
<button class="nav-btn form-toggle" data-type="register"><%- gettext("Register through edX") %></button>
</div>
</div>
......@@ -39,7 +39,7 @@
<button type="submit" class="action action-primary action-update js-login login-button"><%- gettext("Sign in") %></button>
<% if ( context.providers.length > 0 && !context.currentProvider ) { %>
<% if ( context.providers.length > 0 && !context.currentProvider || context.hasSecondaryProviders ) { %>
<div class="login-providers">
<div class="section-title lines">
<h2>
......@@ -49,12 +49,18 @@
<% _.each( context.providers, function( provider ) {
if ( provider.loginUrl ) { %>
<button type="button" class="button button-primary button-<%- provider.name %> login-provider login-<%- provider.name %>" data-provider-url="<%- provider.loginUrl %>">
<button type="button" class="button button-primary button-<%- provider.id %> login-provider login-<%- provider.id %>" data-provider-url="<%- provider.loginUrl %>">
<div class="icon fa <%- provider.iconClass %>" aria-hidden="true"></div>
<%- provider.name %>
</button>
<% }
}); %>
<% if ( context.hasSecondaryProviders ) { %>
<button type="button" class="button-secondary-login form-toggle" data-type="institution_login">
<%- gettext("Use my institution/campus credentials") %>
</button>
<% } %>
</div>
<% } %>
</form>
......
......@@ -15,7 +15,7 @@
</%block>
<%block name="header_extras">
% for template_name in ["account", "access", "form_field", "login", "register", "password_reset"]:
% for template_name in ["account", "access", "form_field", "login", "register", "institution_login", "institution_register", "password_reset", "hinted_login"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="student_account/${template_name}.underscore" />
</script>
......@@ -27,6 +27,7 @@
class="login-register"
data-initial-mode="${initial_mode}"
data-third-party-auth='${third_party_auth|h}'
data-third-party-auth-hint='${third_party_auth_hint}'
data-next-url='${login_redirect_url|h}'
data-platform-name='${platform_name}'
data-login-form-desc='${login_form_desc|h}'
......
......@@ -19,7 +19,7 @@
<%- _.sprintf( gettext("We just need a little more information before you start learning with %(platformName)s."), context ) %>
</p>
</div>
<% } else if ( context.providers.length > 0 ) { %>
<% } else if ( context.providers.length > 0 || context.hasSecondaryProviders ) { %>
<div class="login-providers">
<div class="section-title lines">
<h2>
......@@ -29,12 +29,18 @@
<%
_.each( context.providers, function( provider) {
if ( provider.registerUrl ) { %>
<button type="button" class="button button-primary button-<%- provider.name %> login-provider register-<%- provider.name %>" data-provider-url="<%- provider.registerUrl %>">
<button type="button" class="button button-primary button-<%- provider.id %> login-provider register-<%- provider.id %>" data-provider-url="<%- provider.registerUrl %>">
<span class="icon fa <%- provider.iconClass %>" aria-hidden="true"></span>
<%- provider.name %>
</button>
<% }
}); %>
<% if ( context.hasSecondaryProviders ) { %>
<button type="button" class="button-secondary-login form-toggle" data-type="institution_login">
<%- gettext("Use my institution/campus credentials") %>
</button>
<% } %>
</div>
<div class="section-title lines">
<h2>
......
......@@ -19,10 +19,10 @@ from third_party_auth import pipeline
<i class="icon fa fa-unlink"></i><span class="copy">${_('Not Linked')}</span>
% endif
</div>
<span class="provider">${state.provider.NAME}</span>
<span class="provider">${state.provider.name}</span>
<span class="control">
<form
action="${pipeline.get_disconnect_url(state.provider.NAME)}"
action="${pipeline.get_disconnect_url(state.provider.provider_id, state.association_id)}"
method="post"
name="${state.get_unlink_form_name()}">
% if state.has_account:
......@@ -33,7 +33,7 @@ from third_party_auth import pipeline
${_("Unlink")}
</a>
% else:
<a href="${pipeline.get_login_url(state.provider.NAME, pipeline.AUTH_ENTRY_PROFILE)}">
<a href="${pipeline.get_login_url(state.provider.provider_id, pipeline.AUTH_ENTRY_PROFILE)}">
## 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>
......
......@@ -25,7 +25,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from django_comment_common import models
from student.tests.factories import UserFactory
from third_party_auth.tests.testutil import simulate_running_pipeline
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
from third_party_auth.tests.utils import (
ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle
)
......@@ -800,7 +800,7 @@ class PasswordResetViewTest(ApiTestCase):
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegistrationViewTest(ApiTestCase):
class RegistrationViewTest(ThirdPartyAuthTestMixin, ApiTestCase):
"""Tests for the registration end-points of the User API. """
maxDiff = None
......@@ -907,6 +907,7 @@ class RegistrationViewTest(ApiTestCase):
def test_register_form_third_party_auth_running(self):
no_extra_fields_setting = {}
self.configure_google_provider(enabled=True)
with simulate_running_pipeline(
"openedx.core.djangoapps.user_api.views.third_party_auth.pipeline",
"google-oauth2", email="bob@example.com",
......
......@@ -720,7 +720,7 @@ class RegistrationView(APIView):
if third_party_auth.is_enabled():
running_pipeline = third_party_auth.pipeline.get(request)
if running_pipeline:
current_provider = third_party_auth.provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline)
# Override username / email / full name
field_overrides = current_provider.get_register_form_data(
......
......@@ -109,7 +109,7 @@ def celery(options):
Runs Celery workers.
"""
settings = getattr(options, 'settings', 'dev_with_worker')
run_process(django_cmd('lms', settings, 'celery', 'worker', '--loglevel=INFO', '--pythonpath=.'))
run_process(django_cmd('lms', settings, 'celery', 'worker', '--beat', '--loglevel=INFO', '--pythonpath=.'))
@task
......@@ -142,7 +142,7 @@ def run_all_servers(options):
run_multi_processes([
django_cmd('lms', settings_lms, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['lms'])),
django_cmd('studio', settings_cms, 'runserver', '--traceback', '--pythonpath=.', "0.0.0.0:{}".format(DEFAULT_PORT['studio'])),
django_cmd('lms', worker_settings, 'celery', 'worker', '--loglevel=INFO', '--pythonpath=.')
django_cmd('lms', worker_settings, 'celery', 'worker', '--beat', '--loglevel=INFO', '--pythonpath=.')
])
......
......@@ -69,7 +69,8 @@ pyparsing==2.0.1
python-memcached==1.48
python-openid==2.2.5
python-dateutil==2.1
python-social-auth==0.2.7
python-social-auth==0.2.11
python-saml==2.1.3
pytz==2015.2
pysrt==0.4.7
PyYAML==3.10
......
......@@ -36,3 +36,5 @@ mysql-client
virtualenvwrapper
libgeos-ruby1.8
lynx-cur
libxmlsec1-dev
swig
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