Merge pull request #8140 from edx/feature/shibboleth-tpa
Feature: Shibboleth/SAML SSO
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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 = {
|
||||
|
||||
78
common/djangoapps/third_party_auth/admin.py
Normal file
78
common/djangoapps/third_party_auth/admin.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# -*- 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)
|
||||
@@ -1,13 +1,11 @@
|
||||
"""
|
||||
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 = {}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# -*- 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))
|
||||
181
common/djangoapps/third_party_auth/migrations/0001_initial.py
Normal file
181
common/djangoapps/third_party_auth/migrations/0001_initial.py
Normal file
@@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'OAuth2ProviderConfig'
|
||||
db.create_table('third_party_auth_oauth2providerconfig', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
|
||||
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)),
|
||||
('name', self.gf('django.db.models.fields.CharField')(max_length=50)),
|
||||
('backend_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('key', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
('secret', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
))
|
||||
db.send_create_signal('third_party_auth', ['OAuth2ProviderConfig'])
|
||||
|
||||
# Adding model 'SAMLProviderConfig'
|
||||
db.create_table('third_party_auth_samlproviderconfig', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
|
||||
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('icon_class', self.gf('django.db.models.fields.CharField')(default='fa-sign-in', max_length=50)),
|
||||
('name', self.gf('django.db.models.fields.CharField')(max_length=50)),
|
||||
('backend_name', self.gf('django.db.models.fields.CharField')(default='tpa-saml', max_length=50)),
|
||||
('idp_slug', self.gf('django.db.models.fields.SlugField')(max_length=30)),
|
||||
('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('metadata_source', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('attr_user_permanent_id', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('attr_full_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('attr_first_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('attr_last_name', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('attr_username', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('attr_email', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)),
|
||||
('other_settings', self.gf('django.db.models.fields.TextField')(blank=True)),
|
||||
))
|
||||
db.send_create_signal('third_party_auth', ['SAMLProviderConfig'])
|
||||
|
||||
# Adding model 'SAMLConfiguration'
|
||||
db.create_table('third_party_auth_samlconfiguration', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
|
||||
('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)),
|
||||
('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
('private_key', self.gf('django.db.models.fields.TextField')()),
|
||||
('public_key', self.gf('django.db.models.fields.TextField')()),
|
||||
('entity_id', self.gf('django.db.models.fields.CharField')(default='http://saml.example.com', max_length=255)),
|
||||
('org_info_str', self.gf('django.db.models.fields.TextField')(default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}')),
|
||||
('other_config_str', self.gf('django.db.models.fields.TextField')(default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}')),
|
||||
))
|
||||
db.send_create_signal('third_party_auth', ['SAMLConfiguration'])
|
||||
|
||||
# Adding model 'SAMLProviderData'
|
||||
db.create_table('third_party_auth_samlproviderdata', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('fetched_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('expires_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)),
|
||||
('entity_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)),
|
||||
('sso_url', self.gf('django.db.models.fields.URLField')(max_length=200)),
|
||||
('public_key', self.gf('django.db.models.fields.TextField')()),
|
||||
))
|
||||
db.send_create_signal('third_party_auth', ['SAMLProviderData'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'OAuth2ProviderConfig'
|
||||
db.delete_table('third_party_auth_oauth2providerconfig')
|
||||
|
||||
# Deleting model 'SAMLProviderConfig'
|
||||
db.delete_table('third_party_auth_samlproviderconfig')
|
||||
|
||||
# Deleting model 'SAMLConfiguration'
|
||||
db.delete_table('third_party_auth_samlconfiguration')
|
||||
|
||||
# Deleting model 'SAMLProviderData'
|
||||
db.delete_table('third_party_auth_samlproviderdata')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'third_party_auth.oauth2providerconfig': {
|
||||
'Meta': {'object_name': 'OAuth2ProviderConfig'},
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'})
|
||||
},
|
||||
'third_party_auth.samlconfiguration': {
|
||||
'Meta': {'object_name': 'SAMLConfiguration'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
|
||||
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
|
||||
'private_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {})
|
||||
},
|
||||
'third_party_auth.samlproviderconfig': {
|
||||
'Meta': {'object_name': 'SAMLProviderConfig'},
|
||||
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
|
||||
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'})
|
||||
},
|
||||
'third_party_auth.samlproviderdata': {
|
||||
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['third_party_auth']
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.conf import settings
|
||||
import json
|
||||
from south.v2 import DataMigration
|
||||
|
||||
|
||||
class Migration(DataMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
""" Convert from the THIRD_PARTY_AUTH setting to OAuth2ProviderConfig """
|
||||
tpa = getattr(settings, 'THIRD_PARTY_AUTH_OLD_CONFIG', {})
|
||||
if tpa and not any(orm.OAuth2ProviderConfig.objects.all()):
|
||||
print("Migrating third party auth config to OAuth2ProviderConfig")
|
||||
providers = (
|
||||
# Name, backend, icon, prefix
|
||||
('Google', 'google-oauth2', 'fa-google-plus', 'SOCIAL_AUTH_GOOGLE_OAUTH2_'),
|
||||
('LinkedIn', 'linkedin-oauth2', 'fa-linkedin', 'SOCIAL_AUTH_LINKEDIN_OAUTH2_'),
|
||||
('Facebook', 'facebook', 'fa-facebook', 'SOCIAL_AUTH_FACEBOOK_'),
|
||||
)
|
||||
for name, backend, icon, prefix in providers:
|
||||
if name in tpa:
|
||||
conf = tpa[name]
|
||||
conf = {key.replace(prefix, ''): val for key, val in conf.items()}
|
||||
key = conf.pop('KEY', '')
|
||||
secret = conf.pop('SECRET', '')
|
||||
orm.OAuth2ProviderConfig.objects.create(
|
||||
enabled=True,
|
||||
name=name,
|
||||
backend_name=backend,
|
||||
icon_class=icon,
|
||||
key=key,
|
||||
secret=secret,
|
||||
other_settings=json.dumps(conf),
|
||||
changed_by=None,
|
||||
)
|
||||
print(
|
||||
"Done. Make changes via /admin/third_party_auth/oauth2providerconfig/ "
|
||||
"from now on. You can remove THIRD_PARTY_AUTH from ~/lms.auth.json"
|
||||
)
|
||||
else:
|
||||
print("Not migrating third party auth config to OAuth2ProviderConfig.")
|
||||
|
||||
def backwards(self, orm):
|
||||
""" No backwards migration necessary """
|
||||
pass
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'third_party_auth.oauth2providerconfig': {
|
||||
'Meta': {'object_name': 'OAuth2ProviderConfig'},
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'})
|
||||
},
|
||||
'third_party_auth.samlconfiguration': {
|
||||
'Meta': {'object_name': 'SAMLConfiguration'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
|
||||
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
|
||||
'private_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {})
|
||||
},
|
||||
'third_party_auth.samlproviderconfig': {
|
||||
'Meta': {'object_name': 'SAMLProviderConfig'},
|
||||
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
|
||||
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'})
|
||||
},
|
||||
'third_party_auth.samlproviderdata': {
|
||||
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['third_party_auth']
|
||||
symmetrical = True
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from south.utils import datetime_utils as datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding field 'SAMLProviderConfig.secondary'
|
||||
db.add_column('third_party_auth_samlproviderconfig', 'secondary',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'SAMLProviderConfig.skip_registration_form'
|
||||
db.add_column('third_party_auth_samlproviderconfig', 'skip_registration_form',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'SAMLProviderConfig.skip_email_verification'
|
||||
db.add_column('third_party_auth_samlproviderconfig', 'skip_email_verification',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'OAuth2ProviderConfig.secondary'
|
||||
db.add_column('third_party_auth_oauth2providerconfig', 'secondary',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'OAuth2ProviderConfig.skip_registration_form'
|
||||
db.add_column('third_party_auth_oauth2providerconfig', 'skip_registration_form',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'OAuth2ProviderConfig.skip_email_verification'
|
||||
db.add_column('third_party_auth_oauth2providerconfig', 'skip_email_verification',
|
||||
self.gf('django.db.models.fields.BooleanField')(default=False),
|
||||
keep_default=False)
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'SAMLProviderConfig.secondary'
|
||||
db.delete_column('third_party_auth_samlproviderconfig', 'secondary')
|
||||
|
||||
# Deleting field 'SAMLProviderConfig.skip_registration_form'
|
||||
db.delete_column('third_party_auth_samlproviderconfig', 'skip_registration_form')
|
||||
|
||||
# Deleting field 'SAMLProviderConfig.skip_email_verification'
|
||||
db.delete_column('third_party_auth_samlproviderconfig', 'skip_email_verification')
|
||||
|
||||
# Deleting field 'OAuth2ProviderConfig.secondary'
|
||||
db.delete_column('third_party_auth_oauth2providerconfig', 'secondary')
|
||||
|
||||
# Deleting field 'OAuth2ProviderConfig.skip_registration_form'
|
||||
db.delete_column('third_party_auth_oauth2providerconfig', 'skip_registration_form')
|
||||
|
||||
# Deleting field 'OAuth2ProviderConfig.skip_email_verification'
|
||||
db.delete_column('third_party_auth_oauth2providerconfig', 'skip_email_verification')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'third_party_auth.oauth2providerconfig': {
|
||||
'Meta': {'object_name': 'OAuth2ProviderConfig'},
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'key': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'secret': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
|
||||
},
|
||||
'third_party_auth.samlconfiguration': {
|
||||
'Meta': {'object_name': 'SAMLConfiguration'},
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'default': "'http://saml.example.com'", 'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'org_info_str': ('django.db.models.fields.TextField', [], {'default': '\'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}\''}),
|
||||
'other_config_str': ('django.db.models.fields.TextField', [], {'default': '\'{\\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\\n}\''}),
|
||||
'private_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {})
|
||||
},
|
||||
'third_party_auth.samlproviderconfig': {
|
||||
'Meta': {'object_name': 'SAMLProviderConfig'},
|
||||
'attr_email': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_first_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_full_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_last_name': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_user_permanent_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'attr_username': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}),
|
||||
'backend_name': ('django.db.models.fields.CharField', [], {'default': "'tpa-saml'", 'max_length': '50'}),
|
||||
'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
|
||||
'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}),
|
||||
'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'icon_class': ('django.db.models.fields.CharField', [], {'default': "'fa-sign-in'", 'max_length': '50'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'idp_slug': ('django.db.models.fields.SlugField', [], {'max_length': '30'}),
|
||||
'metadata_source': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}),
|
||||
'other_settings': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'secondary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_email_verification': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'skip_registration_form': ('django.db.models.fields.BooleanField', [], {'default': 'False'})
|
||||
},
|
||||
'third_party_auth.samlproviderdata': {
|
||||
'Meta': {'ordering': "('-fetched_at',)", 'object_name': 'SAMLProviderData'},
|
||||
'entity_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'expires_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'fetched_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'public_key': ('django.db.models.fields.TextField', [], {}),
|
||||
'sso_url': ('django.db.models.fields.URLField', [], {'max_length': '200'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['third_party_auth']
|
||||
420
common/djangoapps/third_party_auth/models.py
Normal file
420
common/djangoapps/third_party_auth/models.py
Normal file
@@ -0,0 +1,420 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Models used to implement SAML SSO support in third_party_auth
|
||||
(inlcuding Shibboleth support)
|
||||
"""
|
||||
from config_models.models import ConfigurationModel, cache
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
import json
|
||||
import logging
|
||||
from social.backends.base import BaseAuth
|
||||
from social.backends.oauth import BaseOAuth2
|
||||
from social.backends.saml import SAMLAuth, SAMLIdentityProvider
|
||||
from social.exceptions import SocialAuthBaseException
|
||||
from social.utils import module_member
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# A dictionary of {name: class} entries for each python-social-auth backend available.
|
||||
# Because this setting can specify arbitrary code to load and execute, it is set via
|
||||
# normal Django settings only and cannot be changed at runtime:
|
||||
def _load_backend_classes(base_class=BaseAuth):
|
||||
""" Load the list of python-social-auth backend classes from Django settings """
|
||||
for class_path in settings.AUTHENTICATION_BACKENDS:
|
||||
auth_class = module_member(class_path)
|
||||
if issubclass(auth_class, base_class):
|
||||
yield auth_class
|
||||
_PSA_BACKENDS = {backend_class.name: backend_class for backend_class in _load_backend_classes()}
|
||||
_PSA_OAUTH2_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(BaseOAuth2)]
|
||||
_PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)]
|
||||
|
||||
|
||||
def clean_json(value, of_type):
|
||||
""" Simple helper method to parse and clean JSON """
|
||||
if not value.strip():
|
||||
return json.dumps(of_type())
|
||||
try:
|
||||
value_python = json.loads(value)
|
||||
except ValueError as err:
|
||||
raise ValidationError("Invalid JSON: {}".format(err.message))
|
||||
if not isinstance(value_python, of_type):
|
||||
raise ValidationError("Expected a JSON {}".format(of_type))
|
||||
return json.dumps(value_python, indent=4)
|
||||
|
||||
|
||||
class AuthNotConfigured(SocialAuthBaseException):
|
||||
""" Exception when SAMLProviderData or other required info is missing """
|
||||
def __init__(self, provider_name):
|
||||
super(AuthNotConfigured, self).__init__()
|
||||
self.provider_name = provider_name
|
||||
|
||||
def __str__(self):
|
||||
return _('Authentication with {} is currently unavailable.').format( # pylint: disable=no-member
|
||||
self.provider_name
|
||||
)
|
||||
|
||||
|
||||
class ProviderConfig(ConfigurationModel):
|
||||
"""
|
||||
Abstract Base Class for configuring a third_party_auth provider
|
||||
"""
|
||||
icon_class = models.CharField(
|
||||
max_length=50, default='fa-sign-in',
|
||||
help_text=(
|
||||
'The Font Awesome (or custom) icon class to use on the login button for this provider. '
|
||||
'Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university'
|
||||
),
|
||||
)
|
||||
name = models.CharField(max_length=50, blank=False, help_text="Name of this provider (shown to users)")
|
||||
secondary = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
'Secondary providers are displayed less prominently, '
|
||||
'in a separate list of "Institution" login providers.'
|
||||
),
|
||||
)
|
||||
skip_registration_form = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"If this option is enabled, users will not be asked to confirm their details "
|
||||
"(name, email, etc.) during the registration process. Only select this option "
|
||||
"for trusted providers that are known to provide accurate user information."
|
||||
),
|
||||
)
|
||||
skip_email_verification = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"If this option is selected, users will not be required to confirm their "
|
||||
"email, and their account will be activated immediately upon registration."
|
||||
),
|
||||
)
|
||||
prefix = None # used for provider_id. Set to a string value in subclass
|
||||
backend_name = None # Set to a field or fixed value in subclass
|
||||
|
||||
# "enabled" field is inherited from ConfigurationModel
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def provider_id(self):
|
||||
""" Unique string key identifying this provider. Must be URL and css class friendly. """
|
||||
assert self.prefix is not None
|
||||
return "-".join((self.prefix, ) + tuple(getattr(self, field) for field in self.KEY_FIELDS))
|
||||
|
||||
@property
|
||||
def backend_class(self):
|
||||
""" Get the python-social-auth backend class used for this provider """
|
||||
return _PSA_BACKENDS[self.backend_name]
|
||||
|
||||
def get_url_params(self):
|
||||
""" Get a dict of GET parameters to append to login links for this provider """
|
||||
return {}
|
||||
|
||||
def is_active_for_pipeline(self, pipeline):
|
||||
""" Is this provider being used for the specified pipeline? """
|
||||
return self.backend_name == pipeline['backend']
|
||||
|
||||
def match_social_auth(self, social_auth):
|
||||
""" Is this provider being used for this UserSocialAuth entry? """
|
||||
return self.backend_name == social_auth.provider
|
||||
|
||||
@classmethod
|
||||
def get_register_form_data(cls, pipeline_kwargs):
|
||||
"""Gets dict of data to display on the register form.
|
||||
|
||||
common.djangoapps.student.views.register_user uses this to populate the
|
||||
new account creation form with values supplied by the user's chosen
|
||||
provider, preventing duplicate data entry.
|
||||
|
||||
Args:
|
||||
pipeline_kwargs: dict of string -> object. Keyword arguments
|
||||
accumulated by the pipeline thus far.
|
||||
|
||||
Returns:
|
||||
Dict of string -> string. Keys are names of form fields; values are
|
||||
values for that field. Where there is no value, the empty string
|
||||
must be used.
|
||||
"""
|
||||
# Details about the user sent back from the provider.
|
||||
details = pipeline_kwargs.get('details')
|
||||
|
||||
# Get the username separately to take advantage of the de-duping logic
|
||||
# built into the pipeline. The provider cannot de-dupe because it can't
|
||||
# check the state of taken usernames in our system. Note that there is
|
||||
# technically a data race between the creation of this value and the
|
||||
# creation of the user object, so it is still possible for users to get
|
||||
# an error on submit.
|
||||
suggested_username = pipeline_kwargs.get('username')
|
||||
|
||||
return {
|
||||
'email': details.get('email', ''),
|
||||
'name': details.get('fullname', ''),
|
||||
'username': suggested_username,
|
||||
}
|
||||
|
||||
def get_authentication_backend(self):
|
||||
"""Gets associated Django settings.AUTHENTICATION_BACKEND string."""
|
||||
return '{}.{}'.format(self.backend_class.__module__, self.backend_class.__name__)
|
||||
|
||||
|
||||
class OAuth2ProviderConfig(ProviderConfig):
|
||||
"""
|
||||
Configuration Entry for an OAuth2 based provider.
|
||||
"""
|
||||
prefix = 'oa2'
|
||||
KEY_FIELDS = ('backend_name', ) # Backend name is unique
|
||||
backend_name = models.CharField(
|
||||
max_length=50, choices=[(name, name) for name in _PSA_OAUTH2_BACKENDS], blank=False, db_index=True,
|
||||
help_text=(
|
||||
"Which python-social-auth OAuth2 provider backend to use. "
|
||||
"The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting."
|
||||
# To be precise, it's set by AUTHENTICATION_BACKENDS - which aws.py sets from THIRD_PARTY_AUTH_BACKENDS
|
||||
)
|
||||
)
|
||||
key = models.TextField(blank=True, verbose_name="Client ID")
|
||||
secret = models.TextField(blank=True, verbose_name="Client Secret")
|
||||
other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.")
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
verbose_name = "Provider Configuration (OAuth2)"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
def clean(self):
|
||||
""" Standardize and validate fields """
|
||||
super(OAuth2ProviderConfig, self).clean()
|
||||
self.other_settings = clean_json(self.other_settings, dict)
|
||||
|
||||
def get_setting(self, name):
|
||||
""" Get the value of a setting, or raise KeyError """
|
||||
if name in ("KEY", "SECRET"):
|
||||
return getattr(self, name.lower())
|
||||
if self.other_settings:
|
||||
other_settings = json.loads(self.other_settings)
|
||||
assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)"
|
||||
return other_settings[name]
|
||||
raise KeyError
|
||||
|
||||
|
||||
class SAMLProviderConfig(ProviderConfig):
|
||||
"""
|
||||
Configuration Entry for a SAML/Shibboleth provider.
|
||||
"""
|
||||
prefix = 'saml'
|
||||
KEY_FIELDS = ('idp_slug', )
|
||||
backend_name = models.CharField(
|
||||
max_length=50, default='tpa-saml', choices=[(name, name) for name in _PSA_SAML_BACKENDS], blank=False,
|
||||
help_text="Which python-social-auth provider backend to use. 'tpa-saml' is the standard edX SAML backend.")
|
||||
idp_slug = models.SlugField(
|
||||
max_length=30, db_index=True,
|
||||
help_text=(
|
||||
'A short string uniquely identifying this provider. '
|
||||
'Cannot contain spaces and should be a usable as a CSS class. Examples: "ubc", "mit-staging"'
|
||||
))
|
||||
entity_id = models.CharField(
|
||||
max_length=255, verbose_name="Entity ID", help_text="Example: https://idp.testshib.org/idp/shibboleth")
|
||||
metadata_source = models.CharField(
|
||||
max_length=255,
|
||||
help_text=(
|
||||
"URL to this provider's XML metadata. Should be an HTTPS URL. "
|
||||
"Example: https://www.testshib.org/metadata/testshib-providers.xml"
|
||||
))
|
||||
attr_user_permanent_id = models.CharField(
|
||||
max_length=128, blank=True, verbose_name="User ID Attribute",
|
||||
help_text="URN of the SAML attribute that we can use as a unique, persistent user ID. Leave blank for default.")
|
||||
attr_full_name = models.CharField(
|
||||
max_length=128, blank=True, verbose_name="Full Name Attribute",
|
||||
help_text="URN of SAML attribute containing the user's full name. Leave blank for default.")
|
||||
attr_first_name = models.CharField(
|
||||
max_length=128, blank=True, verbose_name="First Name Attribute",
|
||||
help_text="URN of SAML attribute containing the user's first name. Leave blank for default.")
|
||||
attr_last_name = models.CharField(
|
||||
max_length=128, blank=True, verbose_name="Last Name Attribute",
|
||||
help_text="URN of SAML attribute containing the user's last name. Leave blank for default.")
|
||||
attr_username = models.CharField(
|
||||
max_length=128, blank=True, verbose_name="Username Hint Attribute",
|
||||
help_text="URN of SAML attribute to use as a suggested username for this user. Leave blank for default.")
|
||||
attr_email = models.CharField(
|
||||
max_length=128, blank=True, verbose_name="Email Attribute",
|
||||
help_text="URN of SAML attribute containing the user's email address[es]. Leave blank for default.")
|
||||
other_settings = models.TextField(
|
||||
verbose_name="Advanced settings", blank=True,
|
||||
help_text=(
|
||||
'For advanced use cases, enter a JSON object with addtional configuration. '
|
||||
'The tpa-saml backend supports only {"requiredEntitlements": ["urn:..."]} '
|
||||
'which can be used to require the presence of a specific eduPersonEntitlement.'
|
||||
))
|
||||
|
||||
def clean(self):
|
||||
""" Standardize and validate fields """
|
||||
super(SAMLProviderConfig, self).clean()
|
||||
self.other_settings = clean_json(self.other_settings, dict)
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
verbose_name = "Provider Configuration (SAML IdP)"
|
||||
verbose_name_plural = "Provider Configuration (SAML IdPs)"
|
||||
|
||||
def get_url_params(self):
|
||||
""" Get a dict of GET parameters to append to login links for this provider """
|
||||
return {'idp': self.idp_slug}
|
||||
|
||||
def is_active_for_pipeline(self, pipeline):
|
||||
""" Is this provider being used for the specified pipeline? """
|
||||
return self.backend_name == pipeline['backend'] and self.idp_slug == pipeline['kwargs']['response']['idp_name']
|
||||
|
||||
def match_social_auth(self, social_auth):
|
||||
""" Is this provider being used for this UserSocialAuth entry? """
|
||||
prefix = self.idp_slug + ":"
|
||||
return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix)
|
||||
|
||||
def get_config(self):
|
||||
"""
|
||||
Return a SAMLIdentityProvider instance for use by SAMLAuthBackend.
|
||||
|
||||
Essentially this just returns the values of this object and its
|
||||
associated 'SAMLProviderData' entry.
|
||||
"""
|
||||
if self.other_settings:
|
||||
conf = json.loads(self.other_settings)
|
||||
else:
|
||||
conf = {}
|
||||
attrs = (
|
||||
'attr_user_permanent_id', 'attr_full_name', 'attr_first_name',
|
||||
'attr_last_name', 'attr_username', 'attr_email', 'entity_id')
|
||||
for field in attrs:
|
||||
val = getattr(self, field)
|
||||
if val:
|
||||
conf[field] = val
|
||||
# Now get the data fetched automatically from the metadata.xml:
|
||||
data = SAMLProviderData.current(self.entity_id)
|
||||
if not data or not data.is_valid():
|
||||
log.error("No SAMLProviderData found for %s. Run 'manage.py saml pull' to fix or debug.", self.entity_id)
|
||||
raise AuthNotConfigured(provider_name=self.name)
|
||||
conf['x509cert'] = data.public_key
|
||||
conf['url'] = data.sso_url
|
||||
return SAMLIdentityProvider(self.idp_slug, **conf)
|
||||
|
||||
|
||||
class SAMLConfiguration(ConfigurationModel):
|
||||
"""
|
||||
General configuration required for this edX instance to act as a SAML
|
||||
Service Provider and allow users to authenticate via third party SAML
|
||||
Identity Providers (IdPs)
|
||||
"""
|
||||
private_key = models.TextField(
|
||||
help_text=(
|
||||
'To generate a key pair as two files, run '
|
||||
'"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". '
|
||||
'Paste the contents of saml.key here.'
|
||||
)
|
||||
)
|
||||
public_key = models.TextField(help_text="Public key certificate.")
|
||||
entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID")
|
||||
org_info_str = models.TextField(
|
||||
verbose_name="Organization Info",
|
||||
default='{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}',
|
||||
help_text="JSON dictionary of 'url', 'displayname', and 'name' for each language",
|
||||
)
|
||||
other_config_str = models.TextField(
|
||||
default='{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}',
|
||||
help_text=(
|
||||
"JSON object defining advanced settings that are passed on to python-saml. "
|
||||
"Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA"
|
||||
),
|
||||
)
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
verbose_name = "SAML Configuration"
|
||||
verbose_name_plural = verbose_name
|
||||
|
||||
def clean(self):
|
||||
""" Standardize and validate fields """
|
||||
super(SAMLConfiguration, self).clean()
|
||||
self.org_info_str = clean_json(self.org_info_str, dict)
|
||||
self.other_config_str = clean_json(self.other_config_str, dict)
|
||||
|
||||
self.private_key = (
|
||||
self.private_key
|
||||
.replace("-----BEGIN RSA PRIVATE KEY-----", "")
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END RSA PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.strip()
|
||||
)
|
||||
self.public_key = (
|
||||
self.public_key
|
||||
.replace("-----BEGIN CERTIFICATE-----", "")
|
||||
.replace("-----END CERTIFICATE-----", "")
|
||||
.strip()
|
||||
)
|
||||
|
||||
def get_setting(self, name):
|
||||
""" Get the value of a setting, or raise KeyError """
|
||||
if name == "ORG_INFO":
|
||||
return json.loads(self.org_info_str)
|
||||
if name == "SP_ENTITY_ID":
|
||||
return self.entity_id
|
||||
if name == "SP_PUBLIC_CERT":
|
||||
return self.public_key
|
||||
if name == "SP_PRIVATE_KEY":
|
||||
return self.private_key
|
||||
if name == "TECHNICAL_CONTACT":
|
||||
return {"givenName": "Technical Support", "emailAddress": settings.TECH_SUPPORT_EMAIL}
|
||||
if name == "SUPPORT_CONTACT":
|
||||
return {"givenName": "SAML Support", "emailAddress": settings.TECH_SUPPORT_EMAIL}
|
||||
other_config = json.loads(self.other_config_str)
|
||||
return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings
|
||||
|
||||
|
||||
class SAMLProviderData(models.Model):
|
||||
"""
|
||||
Data about a SAML IdP that is fetched automatically by 'manage.py saml pull'
|
||||
|
||||
This data is only required during the actual authentication process.
|
||||
"""
|
||||
cache_timeout = 600
|
||||
fetched_at = models.DateTimeField(db_index=True, null=False)
|
||||
expires_at = models.DateTimeField(db_index=True, null=True)
|
||||
|
||||
entity_id = models.CharField(max_length=255, db_index=True) # This is the key for lookups in this table
|
||||
sso_url = models.URLField(verbose_name="SSO URL")
|
||||
public_key = models.TextField()
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
verbose_name = "SAML Provider Data"
|
||||
verbose_name_plural = verbose_name
|
||||
ordering = ('-fetched_at', )
|
||||
|
||||
def is_valid(self):
|
||||
""" Is this data valid? """
|
||||
if self.expires_at and timezone.now() > self.expires_at:
|
||||
return False
|
||||
return bool(self.entity_id and self.sso_url and self.public_key)
|
||||
is_valid.boolean = True
|
||||
|
||||
@classmethod
|
||||
def cache_key_name(cls, entity_id):
|
||||
""" Return the name of the key to use to cache the current data """
|
||||
return 'configuration/{}/current/{}'.format(cls.__name__, entity_id)
|
||||
|
||||
@classmethod
|
||||
def current(cls, entity_id):
|
||||
"""
|
||||
Return the active data entry, if any, otherwise None
|
||||
"""
|
||||
cached = cache.get(cls.cache_key_name(entity_id))
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
current = cls.objects.filter(entity_id=entity_id).order_by('-fetched_at')[0]
|
||||
except IndexError:
|
||||
current = None
|
||||
|
||||
cache.set(cls.cache_key_name(entity_id), current, cls.cache_timeout)
|
||||
return current
|
||||
@@ -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
|
||||
|
||||
@@ -1,234 +1,85 @@
|
||||
"""Third-party auth provider definitions.
|
||||
|
||||
Loaded by Django's settings mechanism. Consequently, this module must not
|
||||
invoke the Django armature.
|
||||
"""
|
||||
|
||||
from social.backends import google, linkedin, facebook
|
||||
|
||||
_DEFAULT_ICON_CLASS = 'fa-signin'
|
||||
|
||||
|
||||
class BaseProvider(object):
|
||||
"""Abstract base class for third-party auth providers.
|
||||
|
||||
All providers must subclass BaseProvider -- otherwise, they cannot be put
|
||||
in the provider Registry.
|
||||
"""
|
||||
|
||||
# Class. The provider's backing social.backends.base.BaseAuth child.
|
||||
BACKEND_CLASS = None
|
||||
# String. Name of the FontAwesome glyph to use for sign in buttons (or the
|
||||
# name of a user-supplied custom glyph that is present at runtime).
|
||||
ICON_CLASS = _DEFAULT_ICON_CLASS
|
||||
# String. User-facing name of the provider. Must be unique across all
|
||||
# enabled providers. Will be presented in the UI.
|
||||
NAME = None
|
||||
# Dict of string -> object. Settings that will be merged into Django's
|
||||
# settings instance. In most cases the value will be None, since real
|
||||
# values are merged from .json files (foo.auth.json; foo.env.json) onto the
|
||||
# settings instance during application initialization.
|
||||
SETTINGS = {}
|
||||
|
||||
@classmethod
|
||||
def get_authentication_backend(cls):
|
||||
"""Gets associated Django settings.AUTHENTICATION_BACKEND string."""
|
||||
return '%s.%s' % (cls.BACKEND_CLASS.__module__, cls.BACKEND_CLASS.__name__)
|
||||
|
||||
@classmethod
|
||||
def get_email(cls, provider_details):
|
||||
"""Gets user's email address.
|
||||
|
||||
Provider responses can contain arbitrary data. This method can be
|
||||
overridden to extract an email address from the provider details
|
||||
extracted by the social_details pipeline step.
|
||||
|
||||
Args:
|
||||
provider_details: dict of string -> string. Data about the
|
||||
user passed back by the provider.
|
||||
|
||||
Returns:
|
||||
String or None. The user's email address, if any.
|
||||
"""
|
||||
return provider_details.get('email')
|
||||
|
||||
@classmethod
|
||||
def get_name(cls, provider_details):
|
||||
"""Gets user's name.
|
||||
|
||||
Provider responses can contain arbitrary data. This method can be
|
||||
overridden to extract a full name for a user from the provider details
|
||||
extracted by the social_details pipeline step.
|
||||
|
||||
Args:
|
||||
provider_details: dict of string -> string. Data about the
|
||||
user passed back by the provider.
|
||||
|
||||
Returns:
|
||||
String or None. The user's full name, if any.
|
||||
"""
|
||||
return provider_details.get('fullname')
|
||||
|
||||
@classmethod
|
||||
def get_register_form_data(cls, pipeline_kwargs):
|
||||
"""Gets dict of data to display on the register form.
|
||||
|
||||
common.djangoapps.student.views.register_user uses this to populate the
|
||||
new account creation form with values supplied by the user's chosen
|
||||
provider, preventing duplicate data entry.
|
||||
|
||||
Args:
|
||||
pipeline_kwargs: dict of string -> object. Keyword arguments
|
||||
accumulated by the pipeline thus far.
|
||||
|
||||
Returns:
|
||||
Dict of string -> string. Keys are names of form fields; values are
|
||||
values for that field. Where there is no value, the empty string
|
||||
must be used.
|
||||
"""
|
||||
# Details about the user sent back from the provider.
|
||||
details = pipeline_kwargs.get('details')
|
||||
|
||||
# Get the username separately to take advantage of the de-duping logic
|
||||
# built into the pipeline. The provider cannot de-dupe because it can't
|
||||
# check the state of taken usernames in our system. Note that there is
|
||||
# technically a data race between the creation of this value and the
|
||||
# creation of the user object, so it is still possible for users to get
|
||||
# an error on submit.
|
||||
suggested_username = pipeline_kwargs.get('username')
|
||||
|
||||
return {
|
||||
'email': cls.get_email(details) or '',
|
||||
'name': cls.get_name(details) or '',
|
||||
'username': suggested_username,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def merge_onto(cls, settings):
|
||||
"""Merge class-level settings onto a django settings module."""
|
||||
for key, value in cls.SETTINGS.iteritems():
|
||||
setattr(settings, key, value)
|
||||
|
||||
|
||||
class GoogleOauth2(BaseProvider):
|
||||
"""Provider for Google's Oauth2 auth system."""
|
||||
|
||||
BACKEND_CLASS = google.GoogleOAuth2
|
||||
ICON_CLASS = 'fa-google-plus'
|
||||
NAME = 'Google'
|
||||
SETTINGS = {
|
||||
'SOCIAL_AUTH_GOOGLE_OAUTH2_KEY': None,
|
||||
'SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET': None,
|
||||
}
|
||||
|
||||
|
||||
class LinkedInOauth2(BaseProvider):
|
||||
"""Provider for LinkedIn's Oauth2 auth system."""
|
||||
|
||||
BACKEND_CLASS = linkedin.LinkedinOAuth2
|
||||
ICON_CLASS = 'fa-linkedin'
|
||||
NAME = 'LinkedIn'
|
||||
SETTINGS = {
|
||||
'SOCIAL_AUTH_LINKEDIN_OAUTH2_KEY': None,
|
||||
'SOCIAL_AUTH_LINKEDIN_OAUTH2_SECRET': None,
|
||||
}
|
||||
|
||||
|
||||
class FacebookOauth2(BaseProvider):
|
||||
"""Provider for LinkedIn's Oauth2 auth system."""
|
||||
|
||||
BACKEND_CLASS = facebook.FacebookOAuth2
|
||||
ICON_CLASS = 'fa-facebook'
|
||||
NAME = 'Facebook'
|
||||
SETTINGS = {
|
||||
'SOCIAL_AUTH_FACEBOOK_KEY': None,
|
||||
'SOCIAL_AUTH_FACEBOOK_SECRET': None,
|
||||
}
|
||||
Third-party auth provider configuration API.
|
||||
"""
|
||||
from .models import (
|
||||
OAuth2ProviderConfig, SAMLConfiguration, SAMLProviderConfig,
|
||||
_PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS
|
||||
)
|
||||
|
||||
|
||||
class Registry(object):
|
||||
"""Singleton registry of third-party auth providers.
|
||||
|
||||
Providers must subclass BaseProvider in order to be usable in the registry.
|
||||
"""
|
||||
API for querying third-party auth ProviderConfig objects.
|
||||
|
||||
_CONFIGURED = False
|
||||
_ENABLED = {}
|
||||
|
||||
Providers must subclass ProviderConfig in order to be usable in the registry.
|
||||
"""
|
||||
@classmethod
|
||||
def _check_configured(cls):
|
||||
"""Ensures registry is configured."""
|
||||
if not cls._CONFIGURED:
|
||||
raise RuntimeError('Registry not configured')
|
||||
|
||||
@classmethod
|
||||
def _get_all(cls):
|
||||
"""Gets all provider implementations loaded into the Python runtime."""
|
||||
# BaseProvider does so have __subclassess__. pylint: disable-msg=no-member
|
||||
return {klass.NAME: klass for klass in BaseProvider.__subclasses__()}
|
||||
|
||||
@classmethod
|
||||
def _enable(cls, provider):
|
||||
"""Enables a single provider."""
|
||||
if provider.NAME in cls._ENABLED:
|
||||
raise ValueError('Provider %s already enabled' % provider.NAME)
|
||||
cls._ENABLED[provider.NAME] = provider
|
||||
|
||||
@classmethod
|
||||
def configure_once(cls, provider_names):
|
||||
"""Configures providers.
|
||||
|
||||
Args:
|
||||
provider_names: list of string. The providers to configure.
|
||||
|
||||
Raises:
|
||||
ValueError: if the registry has already been configured, or if any
|
||||
of the passed provider_names does not have a corresponding
|
||||
BaseProvider child implementation.
|
||||
"""
|
||||
if cls._CONFIGURED:
|
||||
raise ValueError('Provider registry already configured')
|
||||
|
||||
# Flip the bit eagerly -- configure() should not be re-callable if one
|
||||
# _enable call fails.
|
||||
cls._CONFIGURED = True
|
||||
for name in provider_names:
|
||||
all_providers = cls._get_all()
|
||||
if name not in all_providers:
|
||||
raise ValueError('No implementation found for provider ' + name)
|
||||
cls._enable(all_providers.get(name))
|
||||
def _enabled_providers(cls):
|
||||
""" Helper method to iterate over all providers """
|
||||
for backend_name in _PSA_OAUTH2_BACKENDS:
|
||||
provider = OAuth2ProviderConfig.current(backend_name)
|
||||
if provider.enabled:
|
||||
yield provider
|
||||
if SAMLConfiguration.is_enabled():
|
||||
idp_slugs = SAMLProviderConfig.key_values('idp_slug', flat=True)
|
||||
for idp_slug in idp_slugs:
|
||||
provider = SAMLProviderConfig.current(idp_slug)
|
||||
if provider.enabled and provider.backend_name in _PSA_SAML_BACKENDS:
|
||||
yield provider
|
||||
|
||||
@classmethod
|
||||
def enabled(cls):
|
||||
"""Returns list of enabled providers."""
|
||||
cls._check_configured()
|
||||
return sorted(cls._ENABLED.values(), key=lambda provider: provider.NAME)
|
||||
return sorted(cls._enabled_providers(), key=lambda provider: provider.name)
|
||||
|
||||
@classmethod
|
||||
def get(cls, provider_name):
|
||||
"""Gets provider named provider_name string if enabled, else None."""
|
||||
cls._check_configured()
|
||||
return cls._ENABLED.get(provider_name)
|
||||
def get(cls, provider_id):
|
||||
"""Gets provider by provider_id string if enabled, else None."""
|
||||
if '-' not in provider_id: # Check format - see models.py:ProviderConfig
|
||||
raise ValueError("Invalid provider_id. Expect something like oa2-google")
|
||||
try:
|
||||
return next(provider for provider in cls._enabled_providers() if provider.provider_id == provider_id)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_backend_name(cls, backend_name):
|
||||
"""Gets provider (or None) by backend name.
|
||||
def get_from_pipeline(cls, running_pipeline):
|
||||
"""Gets the provider that is being used for the specified pipeline (or None).
|
||||
|
||||
Args:
|
||||
backend_name: string. The python-social-auth
|
||||
backends.base.BaseAuth.name (for example, 'google-oauth2') to
|
||||
try and get a provider for.
|
||||
running_pipeline: The python-social-auth pipeline being used to
|
||||
authenticate a user.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if the registry has not been configured.
|
||||
Returns:
|
||||
An instance of ProviderConfig or None.
|
||||
"""
|
||||
cls._check_configured()
|
||||
for enabled in cls._ENABLED.values():
|
||||
if enabled.BACKEND_CLASS.name == backend_name:
|
||||
for enabled in cls._enabled_providers():
|
||||
if enabled.is_active_for_pipeline(running_pipeline):
|
||||
return enabled
|
||||
|
||||
@classmethod
|
||||
def _reset(cls):
|
||||
"""Returns the registry to an unconfigured state; for tests only."""
|
||||
cls._CONFIGURED = False
|
||||
cls._ENABLED = {}
|
||||
def get_enabled_by_backend_name(cls, backend_name):
|
||||
"""Generator returning all enabled providers that use the specified
|
||||
backend.
|
||||
|
||||
Example:
|
||||
>>> list(get_enabled_by_backend_name("tpa-saml"))
|
||||
[<SAMLProviderConfig>, <SAMLProviderConfig>]
|
||||
|
||||
Args:
|
||||
backend_name: The name of a python-social-auth backend used by
|
||||
one or more providers.
|
||||
|
||||
Yields:
|
||||
Instances of ProviderConfig.
|
||||
"""
|
||||
if backend_name in _PSA_OAUTH2_BACKENDS:
|
||||
provider = OAuth2ProviderConfig.current(backend_name)
|
||||
if provider.enabled:
|
||||
yield provider
|
||||
elif backend_name in _PSA_SAML_BACKENDS and SAMLConfiguration.is_enabled():
|
||||
idp_names = SAMLProviderConfig.key_values('idp_slug', flat=True)
|
||||
for idp_name in idp_names:
|
||||
provider = SAMLProviderConfig.current(idp_name)
|
||||
if provider.backend_name == backend_name and provider.enabled:
|
||||
yield provider
|
||||
|
||||
49
common/djangoapps/third_party_auth/saml.py
Normal file
49
common/djangoapps/third_party_auth/saml.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,51 +1,15 @@
|
||||
"""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)
|
||||
|
||||
34
common/djangoapps/third_party_auth/strategy.py
Normal file
34
common/djangoapps/third_party_auth/strategy.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
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)
|
||||
157
common/djangoapps/third_party_auth/tasks.py
Normal file
157
common/djangoapps/third_party_auth/tasks.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# -*- 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
|
||||
15
common/djangoapps/third_party_auth/tests/data/saml_key.key
Normal file
15
common/djangoapps/third_party_auth/tests/data/saml_key.key
Normal file
@@ -0,0 +1,15 @@
|
||||
-----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-----
|
||||
17
common/djangoapps/third_party_auth/tests/data/saml_key.pub
Normal file
17
common/djangoapps/third_party_auth/tests/data/saml_key.pub
Normal file
@@ -0,0 +1,17 @@
|
||||
-----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-----
|
||||
@@ -0,0 +1,16 @@
|
||||
-----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-----
|
||||
@@ -0,0 +1,15 @@
|
||||
-----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-----
|
||||
@@ -0,0 +1,155 @@
|
||||
<!-- Cached and simplified copy of https://www.testshib.org/metadata/testshib-providers.xml -->
|
||||
<EntitiesDescriptor Name="urn:mace:shibboleth:testshib:two"
|
||||
xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
|
||||
xmlns:mdalg="urn:oasis:names:tc:SAML:metadata:algsupport" xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui"
|
||||
xmlns:shibmd="urn:mace:shibboleth:metadata:1.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
|
||||
<EntityDescriptor entityID="https://idp.testshib.org/idp/shibboleth">
|
||||
|
||||
<Extensions>
|
||||
<mdalg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha512" />
|
||||
<mdalg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#sha384" />
|
||||
<mdalg:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
|
||||
<mdalg:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
|
||||
<mdalg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha512" />
|
||||
<mdalg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha384" />
|
||||
<mdalg:SigningMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
|
||||
<mdalg:SigningMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
|
||||
</Extensions>
|
||||
|
||||
<IDPSSODescriptor
|
||||
protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:mace:shibboleth:1.0 urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<Extensions>
|
||||
<shibmd:Scope regexp="false">testshib.org</shibmd:Scope>
|
||||
<mdui:UIInfo>
|
||||
<mdui:DisplayName xml:lang="en">TestShib Test IdP</mdui:DisplayName>
|
||||
<mdui:Description xml:lang="en">TestShib IdP. Use this as a source of attributes
|
||||
for your test SP.</mdui:Description>
|
||||
<mdui:Logo height="88" width="253"
|
||||
>https://www.testshib.org/testshibtwo.jpg</mdui:Logo>
|
||||
</mdui:UIInfo>
|
||||
|
||||
</Extensions>
|
||||
<KeyDescriptor>
|
||||
<ds:KeyInfo>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>
|
||||
MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV
|
||||
MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD
|
||||
VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4
|
||||
MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI
|
||||
EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl
|
||||
c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C
|
||||
yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe
|
||||
3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT
|
||||
NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614
|
||||
kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH
|
||||
gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G
|
||||
A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86
|
||||
9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl
|
||||
bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo
|
||||
aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN
|
||||
BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL
|
||||
I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo
|
||||
93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4
|
||||
/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj
|
||||
Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr
|
||||
8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA==
|
||||
</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes192-cbc" />
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
|
||||
</KeyDescriptor>
|
||||
|
||||
<ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:1.0:bindings:SOAP-binding"
|
||||
Location="https://idp.testshib.org:8443/idp/profile/SAML1/SOAP/ArtifactResolution"
|
||||
index="1"/>
|
||||
<ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
|
||||
Location="https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution"
|
||||
index="2"/>
|
||||
|
||||
<NameIDFormat>urn:mace:shibboleth:1.0:nameIdentifier</NameIDFormat>
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
|
||||
|
||||
<SingleSignOnService Binding="urn:mace:shibboleth:1.0:profiles:AuthnRequest"
|
||||
Location="https://idp.testshib.org/idp/profile/Shibboleth/SSO"/>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
||||
Location="https://idp.testshib.org/idp/profile/SAML2/POST/SSO"/>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
||||
Location="https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO"/>
|
||||
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
|
||||
Location="https://idp.testshib.org/idp/profile/SAML2/SOAP/ECP"/>
|
||||
|
||||
</IDPSSODescriptor>
|
||||
|
||||
|
||||
<AttributeAuthorityDescriptor
|
||||
protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
|
||||
<KeyDescriptor>
|
||||
<ds:KeyInfo>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>
|
||||
MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEV
|
||||
MBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYD
|
||||
VQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4
|
||||
MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQI
|
||||
EwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRl
|
||||
c3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0B
|
||||
AQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7C
|
||||
yVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe
|
||||
3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aT
|
||||
NPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614
|
||||
kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWH
|
||||
gWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0G
|
||||
A1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ86
|
||||
9nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBl
|
||||
bm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNo
|
||||
aWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zAN
|
||||
BgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRL
|
||||
I4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo
|
||||
93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4
|
||||
/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAj
|
||||
Geka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr
|
||||
8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA==
|
||||
</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes192-cbc" />
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#tripledes-cbc"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"/>
|
||||
<EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5"/>
|
||||
</KeyDescriptor>
|
||||
|
||||
|
||||
<AttributeService Binding="urn:oasis:names:tc:SAML:1.0:bindings:SOAP-binding"
|
||||
Location="https://idp.testshib.org:8443/idp/profile/SAML1/SOAP/AttributeQuery"/>
|
||||
<AttributeService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
|
||||
Location="https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/AttributeQuery"/>
|
||||
|
||||
<NameIDFormat>urn:mace:shibboleth:1.0:nameIdentifier</NameIDFormat>
|
||||
<NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</NameIDFormat>
|
||||
|
||||
</AttributeAuthorityDescriptor>
|
||||
|
||||
<Organization>
|
||||
<OrganizationName xml:lang="en">TestShib Two Identity Provider</OrganizationName>
|
||||
<OrganizationDisplayName xml:lang="en">TestShib Two</OrganizationDisplayName>
|
||||
<OrganizationURL xml:lang="en">http://www.testshib.org/testshib-two/</OrganizationURL>
|
||||
</Organization>
|
||||
<ContactPerson contactType="technical">
|
||||
<GivenName>Nate</GivenName>
|
||||
<SurName>Klingenstein</SurName>
|
||||
<EmailAddress>ndk@internet2.edu</EmailAddress>
|
||||
</ContactPerson>
|
||||
</EntityDescriptor>
|
||||
|
||||
</EntitiesDescriptor>
|
||||
File diff suppressed because one or more lines are too long
@@ -32,15 +32,8 @@ from third_party_auth.tests import testutil
|
||||
class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
"""Abstract base class for provider integration tests."""
|
||||
|
||||
# Configuration. You will need to override these values in your test cases.
|
||||
|
||||
# Class. The third_party_auth.provider.BaseProvider child we are testing.
|
||||
PROVIDER_CLASS = None
|
||||
|
||||
# Dict of string -> object. Settings that will be merged onto Django's
|
||||
# settings object before test execution. In most cases, this is
|
||||
# PROVIDER_CLASS.SETTINGS with test values.
|
||||
PROVIDER_SETTINGS = {}
|
||||
# Override setUp and set this:
|
||||
provider = None
|
||||
|
||||
# Methods you must override in your children.
|
||||
|
||||
@@ -94,10 +87,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
"""
|
||||
self.assertEqual(200, response.status_code)
|
||||
# Check that the correct provider was selected.
|
||||
self.assertIn('successfully signed in with <strong>%s</strong>' % self.PROVIDER_CLASS.NAME, response.content)
|
||||
self.assertIn('successfully signed in with <strong>%s</strong>' % self.provider.name, response.content)
|
||||
# Expect that each truthy value we've prepopulated the register form
|
||||
# with is actually present.
|
||||
for prepopulated_form_value in self.PROVIDER_CLASS.get_register_form_data(pipeline_kwargs).values():
|
||||
for prepopulated_form_value in self.provider.get_register_form_data(pipeline_kwargs).values():
|
||||
if prepopulated_form_value:
|
||||
self.assertIn(prepopulated_form_value, response.content)
|
||||
|
||||
@@ -106,27 +99,30 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(IntegrationTest, self).setUp()
|
||||
self.configure_runtime()
|
||||
self.backend_name = self.PROVIDER_CLASS.BACKEND_CLASS.name
|
||||
self.client = test.Client()
|
||||
self.request_factory = test.RequestFactory()
|
||||
|
||||
def assert_account_settings_context_looks_correct(self, context, user, duplicate=False, linked=None):
|
||||
@property
|
||||
def backend_name(self):
|
||||
""" Shortcut for the backend name """
|
||||
return self.provider.backend_name
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def assert_account_settings_context_looks_correct(self, context, _user, duplicate=False, linked=None):
|
||||
"""Asserts the user's account settings page context is in the expected state.
|
||||
|
||||
If duplicate is True, we expect context['duplicate_provider'] to contain
|
||||
the duplicate provider object. If linked is passed, we conditionally
|
||||
the duplicate provider backend name. If linked is passed, we conditionally
|
||||
check that the provider is included in context['auth']['providers'] and
|
||||
its connected state is correct.
|
||||
"""
|
||||
if duplicate:
|
||||
self.assertEqual(context['duplicate_provider'].NAME, self.PROVIDER_CLASS.NAME)
|
||||
self.assertEqual(context['duplicate_provider'], self.provider.backend_name)
|
||||
else:
|
||||
self.assertIsNone(context['duplicate_provider'])
|
||||
|
||||
if linked is not None:
|
||||
expected_provider = [
|
||||
provider for provider in context['auth']['providers'] if provider['name'] == self.PROVIDER_CLASS.NAME
|
||||
provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name
|
||||
][0]
|
||||
self.assertIsNotNone(expected_provider)
|
||||
self.assertEqual(expected_provider['connected'], linked)
|
||||
@@ -197,7 +193,10 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
def assert_json_failure_response_is_missing_social_auth(self, response):
|
||||
"""Asserts failure on /login for missing social auth looks right."""
|
||||
self.assertEqual(403, response.status_code)
|
||||
self.assertIn("successfully logged into your %s account, but this account isn't linked" % self.PROVIDER_CLASS.NAME, response.content)
|
||||
self.assertIn(
|
||||
"successfully logged into your %s account, but this account isn't linked" % self.provider.name,
|
||||
response.content
|
||||
)
|
||||
|
||||
def assert_json_failure_response_is_username_collision(self, response):
|
||||
"""Asserts the json response indicates a username collision."""
|
||||
@@ -211,7 +210,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = json.loads(response.content)
|
||||
self.assertTrue(payload.get('success'))
|
||||
self.assertEqual(pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name), payload.get('redirect_url'))
|
||||
self.assertEqual(pipeline.get_complete_url(self.provider.backend_name), payload.get('redirect_url'))
|
||||
|
||||
def assert_login_response_before_pipeline_looks_correct(self, response):
|
||||
"""Asserts a GET of /login not in the pipeline looks correct."""
|
||||
@@ -219,7 +218,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# The combined login/registration page dynamically generates the login button,
|
||||
# but we can still check that the provider name is passed in the data attribute
|
||||
# for the container element.
|
||||
self.assertIn(self.PROVIDER_CLASS.NAME, response.content)
|
||||
self.assertIn(self.provider.name, response.content)
|
||||
|
||||
def assert_login_response_in_pipeline_looks_correct(self, response):
|
||||
"""Asserts a GET of /login in the pipeline looks correct."""
|
||||
@@ -258,28 +257,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# The combined login/registration page dynamically generates the register button,
|
||||
# but we can still check that the provider name is passed in the data attribute
|
||||
# for the container element.
|
||||
self.assertIn(self.PROVIDER_CLASS.NAME, response.content)
|
||||
self.assertIn(self.provider.name, response.content)
|
||||
|
||||
def assert_social_auth_does_not_exist_for_user(self, user, strategy):
|
||||
"""Asserts a user does not have an auth with the expected provider."""
|
||||
social_auths = strategy.storage.user.get_social_auth_for_user(
|
||||
user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name)
|
||||
user, provider=self.provider.backend_name)
|
||||
self.assertEqual(0, len(social_auths))
|
||||
|
||||
def assert_social_auth_exists_for_user(self, user, strategy):
|
||||
"""Asserts a user has a social auth with the expected provider."""
|
||||
social_auths = strategy.storage.user.get_social_auth_for_user(
|
||||
user, provider=self.PROVIDER_CLASS.BACKEND_CLASS.name)
|
||||
user, provider=self.provider.backend_name)
|
||||
self.assertEqual(1, len(social_auths))
|
||||
self.assertEqual(self.backend_name, social_auths[0].provider)
|
||||
|
||||
def configure_runtime(self):
|
||||
"""Configures settings details."""
|
||||
auth_settings.apply_settings({self.PROVIDER_CLASS.NAME: self.PROVIDER_SETTINGS}, django_settings)
|
||||
# Force settings to propagate into cached members on
|
||||
# social.apps.django_app.utils.
|
||||
reload(social_utils)
|
||||
|
||||
def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False):
|
||||
"""Creates user, profile, registration, and (usually) social auth.
|
||||
|
||||
@@ -296,7 +288,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
registration.save()
|
||||
|
||||
if not skip_social_auth:
|
||||
social_utils.Storage.user.create_social_auth(user, uid, self.PROVIDER_CLASS.BACKEND_CLASS.name)
|
||||
social_utils.Storage.user.create_social_auth(user, uid, self.provider.backend_name)
|
||||
|
||||
return user
|
||||
|
||||
@@ -370,7 +362,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response["Location"],
|
||||
pipeline.get_complete_url(self.PROVIDER_CLASS.BACKEND_CLASS.name)
|
||||
pipeline.get_complete_url(self.provider.backend_name)
|
||||
)
|
||||
self.assertEqual(response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value, 'true')
|
||||
self.assertIn(django_settings.EDXMKTG_USER_INFO_COOKIE_NAME, response.cookies)
|
||||
@@ -417,7 +409,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# Instrument the pipeline to get to the dashboard with the full
|
||||
# expected state.
|
||||
self.client.get(
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
|
||||
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
@@ -465,7 +457,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# Instrument the pipeline to get to the dashboard with the full
|
||||
# expected state.
|
||||
self.client.get(
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
|
||||
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
@@ -524,7 +516,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
self.assert_social_auth_exists_for_user(user, strategy)
|
||||
|
||||
self.client.get('/login')
|
||||
self.client.get(pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN))
|
||||
self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))
|
||||
actions.do_complete(request.backend, social_views._do_login) # pylint: disable=protected-access
|
||||
|
||||
mako_middleware_process_request(strategy.request)
|
||||
@@ -536,7 +528,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
request._messages = fallback.FallbackStorage(request)
|
||||
middleware.ExceptionMiddleware().process_exception(
|
||||
request,
|
||||
exceptions.AuthAlreadyAssociated(self.PROVIDER_CLASS.BACKEND_CLASS.name, 'account is already in use.'))
|
||||
exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.'))
|
||||
|
||||
self.assert_account_settings_context_looks_correct(
|
||||
account_settings_context(request), user, duplicate=True, linked=True)
|
||||
@@ -561,7 +553,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# Synthesize that request and check that it redirects to the correct
|
||||
# provider page.
|
||||
self.assert_redirect_to_provider_looks_correct(self.client.get(
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)))
|
||||
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)))
|
||||
|
||||
# Next, the provider makes a request against /auth/complete/<provider>
|
||||
# to resume the pipeline.
|
||||
@@ -641,7 +633,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
|
||||
# Synthesize that request and check that it redirects to the correct
|
||||
# provider page.
|
||||
self.assert_redirect_to_provider_looks_correct(self.client.get(
|
||||
pipeline.get_login_url(self.PROVIDER_CLASS.NAME, pipeline.AUTH_ENTRY_LOGIN)))
|
||||
pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)))
|
||||
|
||||
# Next, the provider makes a request against /auth/complete/<provider>.
|
||||
# pylint: disable=protected-access
|
||||
|
||||
@@ -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',
|
||||
|
||||
230
common/djangoapps/third_party_auth/tests/specs/test_testshib.py
Normal file
230
common/djangoapps/third_party_auth/tests/specs/test_testshib.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Third_party_auth integration tests using a mock version of the TestShib provider
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.urlresolvers import reverse
|
||||
import httpretty
|
||||
from mock import patch
|
||||
from student.tests.factories import UserFactory
|
||||
from third_party_auth.tasks import fetch_saml_metadata
|
||||
from third_party_auth.tests import testutil
|
||||
import unittest
|
||||
|
||||
TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth'
|
||||
TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml'
|
||||
TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO'
|
||||
|
||||
TPA_TESTSHIB_LOGIN_URL = '/auth/login/tpa-saml/?auth_entry=login&next=%2Fdashboard&idp=testshib'
|
||||
TPA_TESTSHIB_REGISTER_URL = '/auth/login/tpa-saml/?auth_entry=register&next=%2Fdashboard&idp=testshib'
|
||||
TPA_TESTSHIB_COMPLETE_URL = '/auth/complete/tpa-saml/'
|
||||
|
||||
|
||||
@unittest.skipUnless(testutil.AUTH_FEATURE_ENABLED, 'third_party_auth not enabled')
|
||||
class TestShibIntegrationTest(testutil.SAMLTestCase):
|
||||
"""
|
||||
TestShib provider Integration Test, to test SAML functionality
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestShibIntegrationTest, self).setUp()
|
||||
self.login_page_url = reverse('signin_user')
|
||||
self.register_page_url = reverse('register_user')
|
||||
self.enable_saml(
|
||||
private_key=self._get_private_key(),
|
||||
public_key=self._get_public_key(),
|
||||
entity_id="https://saml.example.none",
|
||||
)
|
||||
# Mock out HTTP requests that may be made to TestShib:
|
||||
httpretty.enable()
|
||||
|
||||
def metadata_callback(_request, _uri, headers):
|
||||
""" Return a cached copy of TestShib's metadata by reading it from disk """
|
||||
return (200, headers, self._read_data_file('testshib_metadata.xml'))
|
||||
httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback)
|
||||
self.addCleanup(httpretty.disable)
|
||||
self.addCleanup(httpretty.reset)
|
||||
|
||||
# Configure the SAML library to use the same request ID for every request.
|
||||
# Doing this and freezing the time allows us to play back recorded request/response pairs
|
||||
uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID')
|
||||
uid_patch.start()
|
||||
self.addCleanup(uid_patch.stop)
|
||||
|
||||
def test_login_before_metadata_fetched(self):
|
||||
self._configure_testshib_provider(fetch_metadata=False)
|
||||
# The user goes to the login page, and sees a button to login with TestShib:
|
||||
self._check_login_page()
|
||||
# The user clicks on the TestShib button:
|
||||
try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
|
||||
# The user should be redirected to back to the login page:
|
||||
self.assertEqual(try_login_response.status_code, 302)
|
||||
self.assertEqual(try_login_response['Location'], self.url_prefix + self.login_page_url)
|
||||
# When loading the login page, the user will see an error message:
|
||||
response = self.client.get(self.login_page_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Authentication with TestShib is currently unavailable.', response.content)
|
||||
|
||||
def test_register(self):
|
||||
self._configure_testshib_provider()
|
||||
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
|
||||
# The user goes to the register page, and sees a button to register with TestShib:
|
||||
self._check_register_page()
|
||||
# The user clicks on the TestShib button:
|
||||
try_login_response = self.client.get(TPA_TESTSHIB_REGISTER_URL)
|
||||
# The user should be redirected to TestShib:
|
||||
self.assertEqual(try_login_response.status_code, 302)
|
||||
self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
|
||||
# Now the user will authenticate with the SAML provider
|
||||
testshib_response = self._fake_testshib_login_and_return()
|
||||
# We should be redirected to the register screen since this account is not linked to an edX account:
|
||||
self.assertEqual(testshib_response.status_code, 302)
|
||||
self.assertEqual(testshib_response['Location'], self.url_prefix + self.register_page_url)
|
||||
register_response = self.client.get(self.register_page_url)
|
||||
# We'd now like to see if the "You've successfully signed into TestShib" message is
|
||||
# shown, but it's managed by a JavaScript runtime template, and we can't run JS in this
|
||||
# type of test, so we just check for the variable that triggers that message.
|
||||
self.assertIn('"currentProvider": "TestShib"', register_response.content)
|
||||
self.assertIn('"errorMessage": null', register_response.content)
|
||||
# Now do a crude check that the data (e.g. email) from the provider is displayed in the form:
|
||||
self.assertIn('"defaultValue": "myself@testshib.org"', register_response.content)
|
||||
self.assertIn('"defaultValue": "Me Myself And I"', register_response.content)
|
||||
# Now complete the form:
|
||||
ajax_register_response = self.client.post(
|
||||
reverse('user_api_registration'),
|
||||
{
|
||||
'email': 'myself@testshib.org',
|
||||
'name': 'Myself',
|
||||
'username': 'myself',
|
||||
'honor_code': True,
|
||||
}
|
||||
)
|
||||
self.assertEqual(ajax_register_response.status_code, 200)
|
||||
# Then the AJAX will finish the third party auth:
|
||||
continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
|
||||
# And we should be redirected to the dashboard:
|
||||
self.assertEqual(continue_response.status_code, 302)
|
||||
self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
|
||||
|
||||
# Now check that we can login again:
|
||||
self.client.logout()
|
||||
self._verify_user_email('myself@testshib.org')
|
||||
self._test_return_login()
|
||||
|
||||
def test_login(self):
|
||||
self._configure_testshib_provider()
|
||||
self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded.
|
||||
user = UserFactory.create()
|
||||
# The user goes to the login page, and sees a button to login with TestShib:
|
||||
self._check_login_page()
|
||||
# The user clicks on the TestShib button:
|
||||
try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
|
||||
# The user should be redirected to TestShib:
|
||||
self.assertEqual(try_login_response.status_code, 302)
|
||||
self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
|
||||
# Now the user will authenticate with the SAML provider
|
||||
testshib_response = self._fake_testshib_login_and_return()
|
||||
# We should be redirected to the login screen since this account is not linked to an edX account:
|
||||
self.assertEqual(testshib_response.status_code, 302)
|
||||
self.assertEqual(testshib_response['Location'], self.url_prefix + self.login_page_url)
|
||||
login_response = self.client.get(self.login_page_url)
|
||||
# We'd now like to see if the "You've successfully signed into TestShib" message is
|
||||
# shown, but it's managed by a JavaScript runtime template, and we can't run JS in this
|
||||
# type of test, so we just check for the variable that triggers that message.
|
||||
self.assertIn('"currentProvider": "TestShib"', login_response.content)
|
||||
self.assertIn('"errorMessage": null', login_response.content)
|
||||
# Now the user enters their username and password.
|
||||
# The AJAX on the page will log them in:
|
||||
ajax_login_response = self.client.post(
|
||||
reverse('user_api_login_session'),
|
||||
{'email': user.email, 'password': 'test'}
|
||||
)
|
||||
self.assertEqual(ajax_login_response.status_code, 200)
|
||||
# Then the AJAX will finish the third party auth:
|
||||
continue_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
|
||||
# And we should be redirected to the dashboard:
|
||||
self.assertEqual(continue_response.status_code, 302)
|
||||
self.assertEqual(continue_response['Location'], self.url_prefix + reverse('dashboard'))
|
||||
|
||||
# Now check that we can login again:
|
||||
self.client.logout()
|
||||
self._test_return_login()
|
||||
|
||||
def _test_return_login(self):
|
||||
""" Test logging in to an account that is already linked. """
|
||||
# Make sure we're not logged in:
|
||||
dashboard_response = self.client.get(reverse('dashboard'))
|
||||
self.assertEqual(dashboard_response.status_code, 302)
|
||||
# The user goes to the login page, and sees a button to login with TestShib:
|
||||
self._check_login_page()
|
||||
# The user clicks on the TestShib button:
|
||||
try_login_response = self.client.get(TPA_TESTSHIB_LOGIN_URL)
|
||||
# The user should be redirected to TestShib:
|
||||
self.assertEqual(try_login_response.status_code, 302)
|
||||
self.assertTrue(try_login_response['Location'].startswith(TESTSHIB_SSO_URL))
|
||||
# Now the user will authenticate with the SAML provider
|
||||
login_response = self._fake_testshib_login_and_return()
|
||||
# There will be one weird redirect required to set the login cookie:
|
||||
self.assertEqual(login_response.status_code, 302)
|
||||
self.assertEqual(login_response['Location'], self.url_prefix + TPA_TESTSHIB_COMPLETE_URL)
|
||||
# And then we should be redirected to the dashboard:
|
||||
login_response = self.client.get(TPA_TESTSHIB_COMPLETE_URL)
|
||||
self.assertEqual(login_response.status_code, 302)
|
||||
self.assertEqual(login_response['Location'], self.url_prefix + reverse('dashboard'))
|
||||
# Now we are logged in:
|
||||
dashboard_response = self.client.get(reverse('dashboard'))
|
||||
self.assertEqual(dashboard_response.status_code, 200)
|
||||
|
||||
def _freeze_time(self, timestamp):
|
||||
""" Mock the current time for SAML, so we can replay canned requests/responses """
|
||||
now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp)
|
||||
now_patch.start()
|
||||
self.addCleanup(now_patch.stop)
|
||||
|
||||
def _check_login_page(self):
|
||||
""" Load the login form and check that it contains a TestShib button """
|
||||
response = self.client.get(self.login_page_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("TestShib", response.content)
|
||||
self.assertIn(TPA_TESTSHIB_LOGIN_URL.replace('&', '&'), response.content)
|
||||
return response
|
||||
|
||||
def _check_register_page(self):
|
||||
""" Load the login form and check that it contains a TestShib button """
|
||||
response = self.client.get(self.register_page_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("TestShib", response.content)
|
||||
self.assertIn(TPA_TESTSHIB_REGISTER_URL.replace('&', '&'), response.content)
|
||||
return response
|
||||
|
||||
def _configure_testshib_provider(self, **kwargs):
|
||||
""" Enable and configure the TestShib SAML IdP as a third_party_auth provider """
|
||||
fetch_metadata = kwargs.pop('fetch_metadata', True)
|
||||
kwargs.setdefault('name', 'TestShib')
|
||||
kwargs.setdefault('enabled', True)
|
||||
kwargs.setdefault('idp_slug', 'testshib')
|
||||
kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID)
|
||||
kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL)
|
||||
kwargs.setdefault('icon_class', 'fa-university')
|
||||
kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName
|
||||
self.configure_saml_provider(**kwargs)
|
||||
|
||||
if fetch_metadata:
|
||||
self.assertTrue(httpretty.is_enabled())
|
||||
num_changed, num_failed, num_total = fetch_saml_metadata()
|
||||
self.assertEqual(num_failed, 0)
|
||||
self.assertEqual(num_changed, 1)
|
||||
self.assertEqual(num_total, 1)
|
||||
|
||||
def _fake_testshib_login_and_return(self):
|
||||
""" Mocked: the user logs in to TestShib and then gets redirected back """
|
||||
# The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response:
|
||||
return self.client.post(
|
||||
TPA_TESTSHIB_COMPLETE_URL,
|
||||
content_type='application/x-www-form-urlencoded',
|
||||
data=self._read_data_file('testshib_response.txt'),
|
||||
)
|
||||
|
||||
def _verify_user_email(self, email):
|
||||
""" Mark the user with the given email as verified """
|
||||
user = User.objects.get(email=email)
|
||||
user.is_active = True
|
||||
user.save()
|
||||
@@ -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())
|
||||
|
||||
@@ -21,9 +21,7 @@ class TestCase(testutil.TestCase, test.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestCase, self).setUp()
|
||||
self.enabled_provider_name = provider.GoogleOauth2.NAME
|
||||
provider.Registry.configure_once([self.enabled_provider_name])
|
||||
self.enabled_provider = provider.Registry.get(self.enabled_provider_name)
|
||||
self.enabled_provider = self.configure_google_provider(enabled=True)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
@@ -41,28 +39,28 @@ class GetAuthenticatedUserTestCase(TestCase):
|
||||
|
||||
def test_raises_does_not_exist_if_user_missing(self):
|
||||
with self.assertRaises(models.User.DoesNotExist):
|
||||
pipeline.get_authenticated_user('new_' + self.user.username, 'backend')
|
||||
pipeline.get_authenticated_user(self.enabled_provider, 'new_' + self.user.username, 'user@example.com')
|
||||
|
||||
def test_raises_does_not_exist_if_user_found_but_no_association(self):
|
||||
backend_name = 'backend'
|
||||
|
||||
self.assertIsNotNone(self.get_by_username(self.user.username))
|
||||
self.assertIsNone(provider.Registry.get_by_backend_name(backend_name))
|
||||
self.assertFalse(any(provider.Registry.get_enabled_by_backend_name(backend_name)))
|
||||
|
||||
with self.assertRaises(models.User.DoesNotExist):
|
||||
pipeline.get_authenticated_user(self.user.username, 'backend')
|
||||
pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'user@example.com')
|
||||
|
||||
def test_raises_does_not_exist_if_user_and_association_found_but_no_match(self):
|
||||
self.assertIsNotNone(self.get_by_username(self.user.username))
|
||||
social_models.DjangoStorage.user.create_social_auth(
|
||||
self.user, 'uid', 'other_' + self.enabled_provider.BACKEND_CLASS.name)
|
||||
self.user, 'uid', 'other_' + self.enabled_provider.backend_name)
|
||||
|
||||
with self.assertRaises(models.User.DoesNotExist):
|
||||
pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name)
|
||||
pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid')
|
||||
|
||||
def test_returns_user_with_is_authenticated_and_backend_set_if_match(self):
|
||||
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.BACKEND_CLASS.name)
|
||||
user = pipeline.get_authenticated_user(self.user.username, self.enabled_provider.BACKEND_CLASS.name)
|
||||
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', self.enabled_provider.backend_name)
|
||||
user = pipeline.get_authenticated_user(self.enabled_provider, self.user.username, 'uid')
|
||||
|
||||
self.assertEqual(self.user, user)
|
||||
self.assertEqual(self.enabled_provider.get_authentication_backend(), user.backend)
|
||||
@@ -78,55 +76,70 @@ class GetProviderUserStatesTestCase(testutil.TestCase, test.TestCase):
|
||||
self.user = social_models.DjangoStorage.user.create_user(username='username', password='password')
|
||||
|
||||
def test_returns_empty_list_if_no_enabled_providers(self):
|
||||
provider.Registry.configure_once([])
|
||||
self.assertFalse(provider.Registry.enabled())
|
||||
self.assertEquals([], pipeline.get_provider_user_states(self.user))
|
||||
|
||||
def test_state_not_returned_for_disabled_provider(self):
|
||||
disabled_provider = provider.GoogleOauth2
|
||||
enabled_provider = provider.LinkedInOauth2
|
||||
provider.Registry.configure_once([enabled_provider.NAME])
|
||||
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.BACKEND_CLASS.name)
|
||||
disabled_provider = self.configure_google_provider(enabled=False)
|
||||
enabled_provider = self.configure_facebook_provider(enabled=True)
|
||||
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', disabled_provider.backend_name)
|
||||
states = pipeline.get_provider_user_states(self.user)
|
||||
|
||||
self.assertEqual(1, len(states))
|
||||
self.assertNotIn(disabled_provider, (state.provider for state in states))
|
||||
self.assertNotIn(disabled_provider.provider_id, (state.provider.provider_id for state in states))
|
||||
self.assertIn(enabled_provider.provider_id, (state.provider.provider_id for state in states))
|
||||
|
||||
def test_states_for_enabled_providers_user_has_accounts_associated_with(self):
|
||||
provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME])
|
||||
social_models.DjangoStorage.user.create_social_auth(self.user, 'uid', provider.GoogleOauth2.BACKEND_CLASS.name)
|
||||
social_models.DjangoStorage.user.create_social_auth(
|
||||
self.user, 'uid', provider.LinkedInOauth2.BACKEND_CLASS.name)
|
||||
# Enable two providers - Google and LinkedIn:
|
||||
google_provider = self.configure_google_provider(enabled=True)
|
||||
linkedin_provider = self.configure_linkedin_provider(enabled=True)
|
||||
user_social_auth_google = social_models.DjangoStorage.user.create_social_auth(
|
||||
self.user, 'uid', google_provider.backend_name)
|
||||
user_social_auth_linkedin = social_models.DjangoStorage.user.create_social_auth(
|
||||
self.user, 'uid', linkedin_provider.backend_name)
|
||||
states = pipeline.get_provider_user_states(self.user)
|
||||
|
||||
self.assertEqual(2, len(states))
|
||||
|
||||
google_state = [state for state in states if state.provider == provider.GoogleOauth2][0]
|
||||
linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0]
|
||||
google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0]
|
||||
linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0]
|
||||
|
||||
self.assertTrue(google_state.has_account)
|
||||
self.assertEqual(provider.GoogleOauth2, google_state.provider)
|
||||
self.assertEqual(google_provider.provider_id, google_state.provider.provider_id)
|
||||
# Also check the row ID. Note this 'id' changes whenever the configuration does:
|
||||
self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member
|
||||
self.assertEqual(self.user, google_state.user)
|
||||
self.assertEqual(user_social_auth_google.id, google_state.association_id)
|
||||
|
||||
self.assertTrue(linkedin_state.has_account)
|
||||
self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider)
|
||||
self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id)
|
||||
self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member
|
||||
self.assertEqual(self.user, linkedin_state.user)
|
||||
self.assertEqual(user_social_auth_linkedin.id, linkedin_state.association_id)
|
||||
|
||||
def test_states_for_enabled_providers_user_has_no_account_associated_with(self):
|
||||
provider.Registry.configure_once([provider.GoogleOauth2.NAME, provider.LinkedInOauth2.NAME])
|
||||
# Enable two providers - Google and LinkedIn:
|
||||
google_provider = self.configure_google_provider(enabled=True)
|
||||
linkedin_provider = self.configure_linkedin_provider(enabled=True)
|
||||
self.assertEqual(len(provider.Registry.enabled()), 2)
|
||||
|
||||
states = pipeline.get_provider_user_states(self.user)
|
||||
|
||||
self.assertEqual([], [x for x in social_models.DjangoStorage.user.objects.all()])
|
||||
self.assertEqual(2, len(states))
|
||||
|
||||
google_state = [state for state in states if state.provider == provider.GoogleOauth2][0]
|
||||
linkedin_state = [state for state in states if state.provider == provider.LinkedInOauth2][0]
|
||||
google_state = [state for state in states if state.provider.provider_id == google_provider.provider_id][0]
|
||||
linkedin_state = [state for state in states if state.provider.provider_id == linkedin_provider.provider_id][0]
|
||||
|
||||
self.assertFalse(google_state.has_account)
|
||||
self.assertEqual(provider.GoogleOauth2, google_state.provider)
|
||||
self.assertEqual(google_provider.provider_id, google_state.provider.provider_id)
|
||||
# Also check the row ID. Note this 'id' changes whenever the configuration does:
|
||||
self.assertEqual(google_provider.id, google_state.provider.id) # pylint: disable=no-member
|
||||
self.assertEqual(self.user, google_state.user)
|
||||
|
||||
self.assertFalse(linkedin_state.has_account)
|
||||
self.assertEqual(provider.LinkedInOauth2, linkedin_state.provider)
|
||||
self.assertEqual(linkedin_provider.provider_id, linkedin_state.provider.provider_id)
|
||||
self.assertEqual(linkedin_provider.id, linkedin_state.provider.id) # pylint: disable=no-member
|
||||
self.assertEqual(self.user, linkedin_state.user)
|
||||
|
||||
|
||||
@@ -136,7 +149,7 @@ class UrlFormationTestCase(TestCase):
|
||||
"""Tests formation of URLs for pipeline hook points."""
|
||||
|
||||
def test_complete_url_raises_value_error_if_provider_not_enabled(self):
|
||||
provider_name = 'not_enabled'
|
||||
provider_name = 'oa2-not-enabled'
|
||||
|
||||
self.assertIsNone(provider.Registry.get(provider_name))
|
||||
|
||||
@@ -144,36 +157,54 @@ class UrlFormationTestCase(TestCase):
|
||||
pipeline.get_complete_url(provider_name)
|
||||
|
||||
def test_complete_url_returns_expected_format(self):
|
||||
complete_url = pipeline.get_complete_url(self.enabled_provider.BACKEND_CLASS.name)
|
||||
complete_url = pipeline.get_complete_url(self.enabled_provider.backend_name)
|
||||
|
||||
self.assertTrue(complete_url.startswith('/auth/complete'))
|
||||
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, complete_url)
|
||||
self.assertIn(self.enabled_provider.backend_name, complete_url)
|
||||
|
||||
def test_disconnect_url_raises_value_error_if_provider_not_enabled(self):
|
||||
provider_name = 'not_enabled'
|
||||
provider_name = 'oa2-not-enabled'
|
||||
|
||||
self.assertIsNone(provider.Registry.get(provider_name))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.get_disconnect_url(provider_name)
|
||||
pipeline.get_disconnect_url(provider_name, 1000)
|
||||
|
||||
def test_disconnect_url_returns_expected_format(self):
|
||||
disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.NAME)
|
||||
|
||||
self.assertTrue(disconnect_url.startswith('/auth/disconnect'))
|
||||
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, disconnect_url)
|
||||
disconnect_url = pipeline.get_disconnect_url(self.enabled_provider.provider_id, 1000)
|
||||
disconnect_url = disconnect_url.rstrip('?')
|
||||
self.assertEqual(
|
||||
disconnect_url,
|
||||
'/auth/disconnect/{backend}/{association_id}/'.format(
|
||||
backend=self.enabled_provider.backend_name, association_id=1000)
|
||||
)
|
||||
|
||||
def test_login_url_raises_value_error_if_provider_not_enabled(self):
|
||||
provider_name = 'not_enabled'
|
||||
provider_id = 'oa2-not-enabled'
|
||||
|
||||
self.assertIsNone(provider.Registry.get(provider_name))
|
||||
self.assertIsNone(provider.Registry.get(provider_id))
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.get_login_url(provider_name, pipeline.AUTH_ENTRY_LOGIN)
|
||||
pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN)
|
||||
|
||||
def test_login_url_returns_expected_format(self):
|
||||
login_url = pipeline.get_login_url(self.enabled_provider.NAME, pipeline.AUTH_ENTRY_LOGIN)
|
||||
login_url = pipeline.get_login_url(self.enabled_provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)
|
||||
|
||||
self.assertTrue(login_url.startswith('/auth/login'))
|
||||
self.assertIn(self.enabled_provider.BACKEND_CLASS.name, login_url)
|
||||
self.assertIn(self.enabled_provider.backend_name, login_url)
|
||||
self.assertTrue(login_url.endswith(pipeline.AUTH_ENTRY_LOGIN))
|
||||
|
||||
def test_for_value_error_if_provider_id_invalid(self):
|
||||
provider_id = 'invalid' # Format is normally "{prefix}-{identifier}"
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
provider.Registry.get(provider_id)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.get_login_url(provider_id, pipeline.AUTH_ENTRY_LOGIN)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.get_disconnect_url(provider_id, 1000)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
pipeline.get_complete_url(provider_id)
|
||||
|
||||
@@ -1,82 +1,84 @@
|
||||
"""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))
|
||||
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_configuring_provider_with_no_implementation_raises_value_error(self):
|
||||
with self.assertRaisesRegexp(ValueError, '^.*no_implementation$'):
|
||||
provider.Registry.configure_once(['no_implementation'])
|
||||
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_configuring_single_provider_twice_raises_value_error(self):
|
||||
provider.Registry._enable(provider.GoogleOauth2)
|
||||
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")
|
||||
|
||||
with self.assertRaisesRegexp(ValueError, '^.*already enabled'):
|
||||
provider.Registry.configure_once([provider.GoogleOauth2.NAME])
|
||||
self.configure_google_provider(enabled=False)
|
||||
enabled_providers = provider.Registry.enabled()
|
||||
self.assertEqual(len(enabled_providers), 0)
|
||||
|
||||
def test_custom_provider_can_be_enabled(self):
|
||||
name = 'CustomProvider'
|
||||
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")
|
||||
|
||||
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()
|
||||
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))
|
||||
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_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_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_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_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_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))
|
||||
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)
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""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)
|
||||
64
common/djangoapps/third_party_auth/tests/test_views.py
Normal file
64
common/djangoapps/third_party_auth/tests/test_views.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
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):
|
||||
"""Base class for auth test cases."""
|
||||
|
||||
# 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 ThirdPartyAuthTestMixin(object):
|
||||
""" Helper methods useful for testing third party auth functionality """
|
||||
|
||||
def tearDown(self):
|
||||
provider.Registry._reset()
|
||||
provider.Registry.configure_once(self._original_providers)
|
||||
super(TestCase, self).tearDown()
|
||||
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
|
||||
|
||||
|
||||
class SAMLTestCase(TestCase):
|
||||
"""
|
||||
Base class for SAML-related third_party_auth tests
|
||||
"""
|
||||
|
||||
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')),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""
|
||||
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-dummy"
|
||||
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-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)
|
||||
|
||||
47
common/test/db_fixtures/third_party_auth.json
Normal file
47
common/test/db_fixtures/third_party_auth.json
Normal file
@@ -0,0 +1,47 @@
|
||||
[
|
||||
{
|
||||
"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)
|
||||
# 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)
|
||||
|
||||
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',
|
||||
|
||||
71
lms/static/js/spec/student_account/hinted_login_spec.js
Normal file
71
lms/static/js/spec/student_account/hinted_login_spec.js
Normal file
@@ -0,0 +1,71 @@
|
||||
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' );
|
||||
});
|
||||
});
|
||||
});
|
||||
80
lms/static/js/spec/student_account/institution_login_spec.js
Normal file
80
lms/static/js/spec/student_account/institution_login_spec.js
Normal file
@@ -0,0 +1,80 @@
|
||||
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);
|
||||
|
||||
|
||||
52
lms/static/js/student_account/views/HintedLoginView.js
Normal file
52
lms/static/js/student_account/views/HintedLoginView.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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);
|
||||
30
lms/static/js/student_account/views/InstitutionLoginView.js
Normal file
30
lms/static/js/student_account/views/InstitutionLoginView.js
Normal file
@@ -0,0 +1,30 @@
|
||||
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;
|
||||
@extend %t-action4;
|
||||
|
||||
@include padding(0, 0, 0, $baseline*2);
|
||||
@include text-align(left);
|
||||
|
||||
position: relative;
|
||||
margin-right: ($baseline/4);
|
||||
margin-bottom: $baseline;
|
||||
border-color: $lightGrey1;
|
||||
width: $baseline*6.5;
|
||||
height: $third-party-button-height;
|
||||
text-shadow: none;
|
||||
text-transform: none;
|
||||
position: relative;
|
||||
font-size: 0.8em;
|
||||
border-color: $lightGrey1;
|
||||
|
||||
&:nth-of-type(odd) {
|
||||
margin-right: 13px;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
24
lms/templates/student_account/hinted_login.underscore
Normal file
24
lms/templates/student_account/hinted_login.underscore
Normal file
@@ -0,0 +1,24 @@
|
||||
<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>
|
||||
31
lms/templates/student_account/institution_login.underscore
Normal file
31
lms/templates/student_account/institution_login.underscore
Normal file
@@ -0,0 +1,31 @@
|
||||
<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>
|
||||
@@ -0,0 +1,31 @@
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user