diff --git a/cms/envs/aws_appsembler.py b/cms/envs/aws_appsembler.py index 3fbf2b16f290..751a3fffe2d8 100644 --- a/cms/envs/aws_appsembler.py +++ b/cms/envs/aws_appsembler.py @@ -87,3 +87,5 @@ # if the AppsemblerUsageRouter isn't enabled, then avoid mistakes by # removing the database alias del DATABASES['appsembler_usage'] + +CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) diff --git a/cms/envs/devstack_appsembler.py b/cms/envs/devstack_appsembler.py index 4e9f9a2d88d6..3e6574bf8f77 100644 --- a/cms/envs/devstack_appsembler.py +++ b/cms/envs/devstack_appsembler.py @@ -76,3 +76,7 @@ # if the AppsemblerUsageRouter isn't enabled, then avoid mistakes by # removing the database alias del DATABASES['appsembler_usage'] + +CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) +# to allow to run python-saml with custom port +SP_SAML_RESTRICT_MODE = False diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index f291d196e3c3..c30efb88ecb5 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1829,7 +1829,8 @@ def create_account_with_params(request, params): not ( third_party_provider and third_party_provider.skip_email_verification and user.email == running_pipeline['kwargs'].get('details', {}).get('email') - ) + ) and + params.get('send_activation_email', True) == True ) if send_email: dest_addr = user.email @@ -2596,8 +2597,16 @@ class LogoutView(TemplateView): oauth_client_ids = [] template_name = 'logout.html' - # Keep track of the page to which the user should ultimately be redirected. - target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' + def get_target(self): + # Keep track of the page to which the user should ultimately be redirected. + if settings.FEATURES.get('AUTH_USE_CAS'): + target = reverse_lazy('cas-logout') + elif configuration_helpers.get_value('CUSTOM_LOGOUT_REDIRECT_URL', settings.CUSTOM_LOGOUT_REDIRECT_URL): + target = configuration_helpers.get_value('CUSTOM_LOGOUT_REDIRECT_URL', settings.CUSTOM_LOGOUT_REDIRECT_URL) + else: + target = '/' + + return target def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring # We do not log here, because we have a handler registered to perform logging on successful logouts. @@ -2612,7 +2621,7 @@ def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstri if LogoutViewConfiguration.current().enabled and self.oauth_client_ids: response = super(LogoutView, self).dispatch(request, *args, **kwargs) else: - response = redirect(self.target) + response = redirect(self.get_target()) # Clear the cookie used by the edx.org marketing site delete_logged_in_cookies(response) diff --git a/common/djangoapps/third_party_auth/migrations/0003_samlproviderdata_slo_url.py b/common/djangoapps/third_party_auth/migrations/0003_samlproviderdata_slo_url.py new file mode 100644 index 000000000000..7cccbe9518d3 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0003_samlproviderdata_slo_url.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0002_schema__provider_icon_image'), + ] + + operations = [ + migrations.AddField( + model_name='samlproviderdata', + name='slo_url', + field=models.URLField(null=True, verbose_name=b'SLO URL'), + ), + ] diff --git a/common/djangoapps/third_party_auth/migrations/0006_merge.py b/common/djangoapps/third_party_auth/migrations/0006_merge.py new file mode 100644 index 000000000000..25b4a432d718 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0006_merge.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0003_samlproviderdata_slo_url'), + ('third_party_auth', '0005_add_site_field'), + ] + + operations = [ + ] diff --git a/common/djangoapps/third_party_auth/migrations/0007_samlconfiguration_slo_redirect_url.py b/common/djangoapps/third_party_auth/migrations/0007_samlconfiguration_slo_redirect_url.py new file mode 100644 index 000000000000..4db038f364cd --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0007_samlconfiguration_slo_redirect_url.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0006_merge'), + ] + + operations = [ + migrations.AddField( + model_name='samlconfiguration', + name='slo_redirect_url', + field=models.CharField(default=b'/logout', help_text=b'The url to redirect the user after process the SLO response', max_length=255, verbose_name=b'SLO post redirect URL', blank=True), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 6d65c361c2b0..3a4189037d92 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -5,6 +5,9 @@ """ from __future__ import absolute_import +import re +from random import randrange + from config_models.models import ConfigurationModel, cache from django.conf import settings from django.contrib.sites.models import Site @@ -251,12 +254,18 @@ def get_register_form_data(cls, pipeline_kwargs): else: suggester_personal_name = details.get('fullname', '') - return { + registration_sso_overrides = { 'email': details.get('email', ''), 'name': suggester_personal_name, 'username': suggested_username, } + if settings.CUSTOM_SSO_FIELDS_SYNC: + for field in settings.CUSTOM_SSO_FIELDS_SYNC: + registration_sso_overrides[field] = details.get(field) + + return registration_sso_overrides + def get_authentication_backend(self): """Gets associated Django settings.AUTHENTICATION_BACKEND string.""" return '{}.{}'.format(self.backend_class.__module__, self.backend_class.__name__) @@ -451,6 +460,7 @@ def get_config(self): raise AuthNotConfigured(provider_name=self.name) conf['x509cert'] = data.public_key conf['url'] = data.sso_url + conf['slo_url'] = data.slo_url return SAMLIdentityProvider(self.idp_slug, **conf) @@ -502,6 +512,13 @@ class SAMLConfiguration(ConfigurationModel): "Valid keys that can be set here include: SECURITY_CONFIG and SP_EXTRA" ), ) + slo_redirect_url = models.CharField( + max_length=255, + default='/logout', + verbose_name="SLO post redirect URL", + help_text="The url to redirect the user after process the SLO response", + blank=True + ) class Meta(object): app_label = "third_party_auth" @@ -545,6 +562,10 @@ def get_setting(self, name): return self.private_key # To allow instances to avoid storing keys in the DB, the private key can also be set via Django: return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') + if name == "LOGOUT_REDIRECT_URL": + return self.slo_redirect_url + if name == "SP_SAML_RESTRICT_MODE": + return getattr(settings, 'SP_SAML_RESTRICT_MODE', True) other_config = { # These defaults can be overriden by self.other_config_str "EXTRA_DATA": ["attributes"], # Save all attribute values the IdP sends into the UserSocialAuth table @@ -567,6 +588,7 @@ class SAMLProviderData(models.Model): 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") + slo_url = models.URLField(verbose_name="SLO URL", null=True) public_key = models.TextField() class Meta(object): diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index afc58478b9f5..daba7f51be92 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -369,6 +369,34 @@ def get_login_url(provider_id, auth_entry, redirect_url=None): extra_params=enabled_provider.get_url_params(), ) +def get_logout_url(provider_id, auth_entry, redirect_url=None): + """Gets URL for the endpoint that starts the SLO process. + + Args: + 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. + + Keyword Args: + redirect_url (string): If provided, redirect to this URL at the end + of the authentication process. + + Returns: + String. URL that starts the SLO process. + + Raises: + ValueError: if no provider is enabled with the given ID. + """ + enabled_provider = _get_enabled_provider(provider_id) + return _get_url( + 'social:end', + enabled_provider.backend_name, + auth_entry=auth_entry, + redirect_url=redirect_url, + extra_params=enabled_provider.get_url_params(), + ) def get_duplicate_provider(messages): """Gets provider from message about social account already in use. diff --git a/common/djangoapps/third_party_auth/tasks.py b/common/djangoapps/third_party_auth/tasks.py index 2678bb78c57f..a1a2a9833212 100644 --- a/common/djangoapps/third_party_auth/tasks.py +++ b/common/djangoapps/third_party_auth/tasks.py @@ -71,8 +71,8 @@ def fetch_saml_metadata(): 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) + public_key, sso_url, slo_url, expires_at = _parse_metadata_xml(xml, entity_id) + changed = _update_data(entity_id, public_key, sso_url, slo_url, expires_at) if changed: log.info(u"→ Created new record for SAMLProviderData") num_changed += 1 @@ -153,16 +153,24 @@ def _parse_metadata_xml(xml, entity_id): raise MetadataParseError("Public Key missing. Expected an ") public_key = public_key.replace(" ", "") binding_elements = sso_desc.iterfind("./{}".format(etree.QName(SAML_XML_NS, "SingleSignOnService"))) + binding_elements_slo = sso_desc.iterfind("./{}".format(etree.QName(SAML_XML_NS, "SingleLogoutService"))) sso_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements} + slo_bindings = {element.get('Binding'): element.get('Location') for element in binding_elements_slo} 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 + try: + # The only binding supported by python-saml and python-social-auth is HTTP-Redirect: + slo_url = slo_bindings['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'] + except KeyError: + slo_url = "" + log.info("Unable to find SLO URL with HTTP-Redirect binding.") + return public_key, sso_url, slo_url, expires_at -def _update_data(entity_id, public_key, sso_url, expires_at): +def _update_data(entity_id, public_key, sso_url, slo_url, expires_at): """ Update/Create the SAMLProviderData for the given entity ID. Return value: @@ -171,7 +179,7 @@ def _update_data(entity_id, public_key, sso_url, expires_at): """ 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): + if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url and data_obj.slo_url == slo_url): data_obj.expires_at = expires_at data_obj.fetched_at = fetched_at data_obj.save() @@ -182,6 +190,7 @@ def _update_data(entity_id, public_key, sso_url, expires_at): fetched_at=fetched_at, expires_at=expires_at, sso_url=sso_url, + slo_url=slo_url, public_key=public_key, ) return True diff --git a/lms/envs/aws_appsembler.py b/lms/envs/aws_appsembler.py index a2a4436fa228..8c3709a6bfc9 100644 --- a/lms/envs/aws_appsembler.py +++ b/lms/envs/aws_appsembler.py @@ -104,3 +104,13 @@ # if the AppsemblerUsageRouter isn't enabled, then avoid mistakes by # removing the database alias del DATABASES['appsembler_usage'] + +if FEATURES.get('ENABLE_CORS_HEADERS', False): + # This middleware class and setting allows to run cross requests when we are + # under https, CORS headers requests external referers are blocked under + # https, there is a new setting in Django 1.9, but until we upgrade to that + # version we need to use this. + # Docs: https://github.com/ottoyiu/django-cors-headers#cors_replace_https_referer + CORS_REPLACE_HTTPS_REFERER = True + +CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) diff --git a/lms/envs/devstack_appsembler.py b/lms/envs/devstack_appsembler.py index b579c269a72a..8c092bd5ad1d 100644 --- a/lms/envs/devstack_appsembler.py +++ b/lms/envs/devstack_appsembler.py @@ -101,3 +101,7 @@ # if the AppsemblerUsageRouter isn't enabled, then avoid mistakes by # removing the database alias del DATABASES['appsembler_usage'] + +CUSTOM_SSO_FIELDS_SYNC = ENV_TOKENS.get('CUSTOM_SSO_FIELDS_SYNC', {}) +# to allow to run python-saml with custom port +SP_SAML_RESTRICT_MODE = False diff --git a/lms/templates/emails/activation_email.txt b/lms/templates/emails/activation_email.txt index 66670f9d47dd..a7d9aeb8ec35 100644 --- a/lms/templates/emails/activation_email.txt +++ b/lms/templates/emails/activation_email.txt @@ -1,5 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> <%! from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers %> + ${_("Thank you for creating an account with {platform_name}!").format( platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) )} @@ -11,10 +12,11 @@ ${_("There's just one more step before you can enroll in a course: " platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) )} +<% base_url=configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME) %> % if is_secure: - https://${ site }/activate/${ key } + https://${ base_url }/activate/${ key } % else: - http://${ site }/activate/${ key } + http://${ base_url }/activate/${ key } % endif ${_("If you didn't create an account, you don't need to do anything; you " "won't receive any more email from us. If you need assistance, please " diff --git a/lms/templates/student_account/form_field.underscore b/lms/templates/student_account/form_field.underscore index 4ea63bebba36..bef247305787 100644 --- a/lms/templates/student_account/form_field.underscore +++ b/lms/templates/student_account/form_field.underscore @@ -25,7 +25,7 @@ } %> <% if ( required ) { %> aria-required="true" required<% } %>> <% _.each(options, function(el) { %> - + <% }); %> <% if ( instructions ) { %> <%= instructions %><% } %> diff --git a/openedx/core/djangoapps/appsembler/external_courses/admin.py b/openedx/core/djangoapps/appsembler/external_courses/admin.py new file mode 100644 index 000000000000..8d6b6db9f30c --- /dev/null +++ b/openedx/core/djangoapps/appsembler/external_courses/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from openedx.core.djangoapps.appsembler.external_courses.models import ExternalCourseTile + +@admin.register(ExternalCourseTile) +class ExternalCourseTileAdmin(admin.ModelAdmin): + + fields = ('course_duration', 'course_key', 'title', 'org', 'course_link', 'image_url', 'starts', 'ends', 'pacing_type', 'is_credit_eligible', 'is_verified_eligible') + readonly_fields = ('course_key', 'title', 'org', 'course_link', 'image_url', 'starts', 'ends', 'pacing_type', 'is_credit_eligible', 'is_verified_eligible') + + + class Meta: + verbose_name = "External Course" + verbose_name_plural = "External Courses" + diff --git a/openedx/core/djangoapps/appsembler/external_courses/migrations/0002_externalcoursetile_course_duration.py b/openedx/core/djangoapps/appsembler/external_courses/migrations/0002_externalcoursetile_course_duration.py new file mode 100644 index 000000000000..0d8cb9ba2c38 --- /dev/null +++ b/openedx/core/djangoapps/appsembler/external_courses/migrations/0002_externalcoursetile_course_duration.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('external_courses', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='externalcoursetile', + name='course_duration', + field=models.CharField(max_length=255, null=True, blank=True), + ), + ] diff --git a/openedx/core/djangoapps/appsembler/external_courses/models.py b/openedx/core/djangoapps/appsembler/external_courses/models.py index cae40df1bd7d..f01e4a6b4744 100644 --- a/openedx/core/djangoapps/appsembler/external_courses/models.py +++ b/openedx/core/djangoapps/appsembler/external_courses/models.py @@ -18,6 +18,7 @@ class ExternalCourseTile(models.Model): pacing_type = models.CharField(max_length=255, null=False, blank=False) is_credit_eligible = models.BooleanField(default=False) is_verified_eligible = models.BooleanField(default=False) + course_duration = models.CharField(max_length=255, null=True, blank=True) def __unicode__(self): return "%s (%s)" % (self.title, self.org) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 68d1e89f2ca6..d7406b31d68c 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -880,6 +880,14 @@ def _apply_third_party_auth_overrides(self, request, form_desc): field_name, default=field_overrides[field_name] ) + if settings.CUSTOM_SSO_FIELDS_SYNC: + for field_name in settings.CUSTOM_SSO_FIELDS_SYNC: + if field_name in field_overrides: + form_desc.override_field_properties( + field_name, + default=field_overrides[field_name] + ) + # Hide the password field form_desc.override_field_properties( "password",