Merge pull request #17276 from edx/bexline/configure_saml_certs

ENT-839 Add ability to configure SP metadata for an IdP
This commit is contained in:
Brittney Exline
2018-01-30 11:18:09 -07:00
committed by GitHub
9 changed files with 265 additions and 120 deletions

View File

@@ -73,7 +73,7 @@ class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin):
""" Don't show every single field in the admin change list """
return (
'name_with_update_link', 'enabled', 'site', 'entity_id', 'metadata_source',
'has_data', 'mode', 'change_date', 'changed_by',
'has_data', 'mode', 'saml_configuration', 'change_date', 'changed_by',
)
list_display_links = None
@@ -142,7 +142,7 @@ class SAMLConfigurationAdmin(KeyedConfigurationModelAdmin):
def get_list_display(self, request):
""" Shorten the public/private keys in the change view """
return (
'site', 'change_date', 'changed_by', 'enabled', 'entity_id',
'site', 'slug', 'change_date', 'changed_by', 'enabled', 'entity_id',
'org_info_str', 'key_summary', 'edit_link',
)

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('third_party_auth', '0015_samlproviderconfig_archived'),
]
operations = [
migrations.AddField(
model_name='samlconfiguration',
name='slug',
field=models.SlugField(default=b'default', help_text=b'A short string uniquely identifying this configuration. Cannot contain spaces. Examples: "ubc", "mit-staging"', max_length=30),
),
migrations.AddField(
model_name='samlproviderconfig',
name='saml_configuration',
field=models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='third_party_auth.SAMLConfiguration', null=True),
),
]

View File

@@ -359,6 +359,149 @@ class OAuth2ProviderConfig(ProviderConfig):
raise KeyError
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)
"""
KEY_FIELDS = ('site_id', 'slug')
site = models.ForeignKey(
Site,
default=settings.SITE_ID,
related_name='%(class)ss',
help_text=_(
'The Site that this SAML configuration belongs to.'
),
)
slug = models.SlugField(
max_length=30,
default='default',
help_text=(
'A short string uniquely identifying this configuration. '
'Cannot contain spaces. Examples: "ubc", "mit-staging"'
),
)
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. '
'For increased security, you can avoid storing this in your database by leaving '
'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PRIVATE_KEY setting '
'in your instance\'s Django settings (or lms.auth.json).'
),
blank=True,
)
public_key = models.TextField(
help_text=(
'Public key certificate. '
'For increased security, you can avoid storing this in your database by leaving '
'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PUBLIC_CERT setting '
'in your instance\'s Django settings (or lms.auth.json).'
),
blank=True,
)
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):
app_label = "third_party_auth"
verbose_name = "SAML Configuration"
verbose_name_plural = verbose_name
def __str__(self):
"""
Return human-readable string representation.
"""
return "SAMLConfiguration {site}: {slug} on {date:%Y-%m-%d %H:%M:%S}".format(
site=self.site.name,
slug=self.slug,
date=self.change_date,
)
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 """
default_saml_contact = {
# Default contact information to put into the SAML metadata that gets generated by python-saml.
"givenName": _("{platform_name} Support").format(
platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
),
"emailAddress": configuration_helpers.get_value('TECH_SUPPORT_EMAIL', settings.TECH_SUPPORT_EMAIL),
}
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":
if self.public_key:
return self.public_key
# To allow instances to avoid storing keys in the DB, the key pair can also be set via Django:
if self.slug == 'default':
return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '')
else:
public_certs = getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT', {})
return public_certs.get(self.slug, '')
if name == "SP_PRIVATE_KEY":
if self.private_key:
return self.private_key
# To allow instances to avoid storing keys in the DB, the private key can also be set via Django:
if self.slug == 'default':
return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '')
else:
private_keys = getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT', {})
return private_keys.get(self.slug, '')
other_config = {
# These defaults can be overriden by self.other_config_str
"GET_ALL_EXTRA_DATA": True, # Save all attribute values the IdP sends into the UserSocialAuth table
"TECHNICAL_CONTACT": default_saml_contact,
"SUPPORT_CONTACT": default_saml_contact,
}
other_config.update(json.loads(self.other_config_str))
return other_config[name] # SECURITY_CONFIG, SP_EXTRA, or similar extra settings
def active_saml_configurations_filter():
"""
Returns a mapping to be used for the SAMLProviderConfig to limit the SAMLConfiguration choices to the current set.
"""
query_set = SAMLConfiguration.objects.current_set()
return {'id__in': query_set.values_list('id', flat=True)}
class SAMLProviderConfig(ProviderConfig):
"""
Configuration Entry for a SAML/Shibboleth provider.
@@ -402,7 +545,9 @@ class SAMLProviderConfig(ProviderConfig):
help_text="URN of SAML attribute containing the user's email address[es]. Leave blank for default.")
automatic_refresh_enabled = models.BooleanField(
default=True, verbose_name="Enable automatic metadata refresh",
help_text="When checked, the SAML provider's metadata will be included in the automatic refresh job, if configured.")
help_text="When checked, the SAML provider's metadata will be included "
"in the automatic refresh job, if configured."
)
identity_provider_type = models.CharField(
max_length=128, blank=False, verbose_name="Identity Provider Type", default=STANDARD_SAML_PROVIDER_KEY,
choices=get_saml_idp_choices(), help_text=(
@@ -431,6 +576,13 @@ class SAMLProviderConfig(ProviderConfig):
'in this field for additional configuration.'
))
archived = models.BooleanField(default=False)
saml_configuration = models.ForeignKey(
SAMLConfiguration,
on_delete=models.SET_NULL,
limit_choices_to=active_saml_configurations_filter,
null=True,
blank=True,
)
def clean(self):
""" Standardize and validate fields """
@@ -494,119 +646,16 @@ class SAMLProviderConfig(ProviderConfig):
raise AuthNotConfigured(provider_name=self.name)
conf['x509cert'] = data.public_key
conf['url'] = data.sso_url
# Add SAMLConfiguration appropriate for this IdP
conf['saml_sp_configuration'] = (
self.saml_configuration or
SAMLConfiguration.current(self.site.id, 'default')
)
idp_class = get_saml_idp_class(self.identity_provider_type)
return idp_class(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)
"""
KEY_FIELDS = ('site_id', )
site = models.ForeignKey(
Site,
default=settings.SITE_ID,
related_name='%(class)ss',
help_text=_(
'The Site that this SAML configuration belongs to.'
),
)
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. '
'For increased security, you can avoid storing this in your database by leaving '
'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PRIVATE_KEY setting '
'in your instance\'s Django settings (or lms.auth.json).'
),
blank=True,
)
public_key = models.TextField(
help_text=(
'Public key certificate. '
'For increased security, you can avoid storing this in your database by leaving '
'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PUBLIC_CERT setting '
'in your instance\'s Django settings (or lms.auth.json).'
),
blank=True,
)
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):
app_label = "third_party_auth"
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 """
default_saml_contact = {
# Default contact information to put into the SAML metadata that gets generated by python-saml.
"givenName": _("{platform_name} Support").format(
platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME)
),
"emailAddress": configuration_helpers.get_value('TECH_SUPPORT_EMAIL', settings.TECH_SUPPORT_EMAIL),
}
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":
if self.public_key:
return self.public_key
# To allow instances to avoid storing keys in the DB, the key pair can also be set via Django:
return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '')
if name == "SP_PRIVATE_KEY":
if self.private_key:
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', '')
other_config = {
# These defaults can be overriden by self.other_config_str
"GET_ALL_EXTRA_DATA": True, # Save all attribute values the IdP sends into the UserSocialAuth table
"TECHNICAL_CONTACT": default_saml_contact,
"SUPPORT_CONTACT": default_saml_contact,
}
other_config.update(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'

View File

@@ -33,7 +33,7 @@ class Registry(object):
provider = OAuth2ProviderConfig.current(oauth2_slug)
if provider.enabled_for_current_site and provider.backend_name in _PSA_OAUTH2_BACKENDS:
yield provider
if SAMLConfiguration.is_enabled(Site.objects.get_current(get_current_request())):
if SAMLConfiguration.is_enabled(Site.objects.get_current(get_current_request()), 'default'):
idp_slugs = SAMLProviderConfig.key_values('idp_slug', flat=True)
for idp_slug in idp_slugs:
provider = SAMLProviderConfig.current(idp_slug)
@@ -118,7 +118,7 @@ class Registry(object):
if provider.backend_name == backend_name and provider.enabled_for_current_site:
yield provider
elif backend_name in _PSA_SAML_BACKENDS and SAMLConfiguration.is_enabled(
Site.objects.get_current(get_current_request())):
Site.objects.get_current(get_current_request()), 'default'):
idp_names = SAMLProviderConfig.key_values('idp_slug', flat=True)
for idp_name in idp_names:
provider = SAMLProviderConfig.current(idp_name)

View File

@@ -9,6 +9,7 @@ from django.contrib.sites.models import Site
from django.http import Http404
from django.utils.functional import cached_property
from django_countries import countries
from onelogin.saml2.settings import OneLogin_Saml2_Settings
from social_core.backends.saml import OID_EDU_PERSON_ENTITLEMENT, SAMLAuth, SAMLIdentityProvider
from social_core.exceptions import AuthForbidden
@@ -38,6 +39,62 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method
except KeyError:
return self.strategy.setting(name, default, backend=self)
def get_idp_setting(self, idp, name, default=None):
try:
return idp.saml_sp_configuration.get_setting(name)
except KeyError:
return self.setting(name, default)
def generate_saml_config(self, idp=None):
"""
Override of SAMLAuth.generate_saml_config to use an idp's configured saml_sp_configuration if given.
"""
if idp:
abs_completion_url = self.redirect_uri
config = {
'contactPerson': {
'technical': self.get_idp_setting(idp, 'TECHNICAL_CONTACT'),
'support': self.get_idp_setting(idp, 'SUPPORT_CONTACT')
},
'debug': True,
'idp': idp.saml_config_dict if idp else {},
'organization': self.get_idp_setting(idp, 'ORG_INFO'),
'security': {
'metadataValidUntil': '',
'metadataCacheDuration': 'P10D', # metadata valid for ten days
},
'sp': {
'assertionConsumerService': {
'url': abs_completion_url,
# python-saml only supports HTTP-POST
'binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
},
'entityId': self.get_idp_setting(idp, 'SP_ENTITY_ID'),
'x509cert': self.get_idp_setting(idp, 'SP_PUBLIC_CERT'),
'privateKey': self.get_idp_setting(idp, 'SP_PRIVATE_KEY'),
},
'strict': True, # We must force strict mode - for security
}
config["security"].update(self.get_idp_setting(idp, "SECURITY_CONFIG", {}))
config["sp"].update(self.get_idp_setting(idp, "SP_EXTRA", {}))
return config
else:
return super(SAMLAuthBackend, self).generate_saml_config()
def generate_metadata_xml(self, idp_name=None): # pylint: disable=arguments-differ
"""
Override of SAMLAuth.generate_metadata_xml to accept an optional idp parameter.
"""
idp = self.get_idp(idp_name) if idp_name else None
config = self.generate_saml_config(idp)
saml_settings = OneLogin_Saml2_Settings(
config,
sp_validation_only=True
)
metadata = saml_settings.get_sp_metadata()
errors = saml_settings.validate_metadata(metadata)
return metadata, errors
def auth_url(self):
"""
Check that SAML is enabled and that the request includes an 'idp'
@@ -98,7 +155,7 @@ class SAMLAuthBackend(SAMLAuth): # pylint: disable=abstract-method
@cached_property
def _config(self):
from .models import SAMLConfiguration
return SAMLConfiguration.current(Site.objects.get_current(get_current_request()))
return SAMLConfiguration.current(Site.objects.get_current(get_current_request()), 'default')
class EdXSAMLIdentityProvider(SAMLIdentityProvider):
@@ -120,6 +177,11 @@ class EdXSAMLIdentityProvider(SAMLIdentityProvider):
})
return details
@property
def saml_sp_configuration(self):
"""Get the SAMLConfiguration for this IdP"""
return self.conf['saml_sp_configuration']
class SapSuccessFactorsIdentityProvider(EdXSAMLIdentityProvider):
"""

View File

@@ -52,12 +52,13 @@ def fetch_saml_metadata():
url_map = {}
for idp_slug in saml_providers:
config = SAMLProviderConfig.current(idp_slug)
saml_config_slug = config.saml_configuration.slug if config.saml_configuration else 'default'
# Skip SAML provider configurations which do not qualify for fetching
if any([
not config.enabled,
not config.automatic_refresh_enabled,
not SAMLConfiguration.is_enabled(config.site)
not SAMLConfiguration.is_enabled(config.site, saml_config_slug)
]):
num_skipped += 1
continue

View File

@@ -86,7 +86,7 @@ class ThirdPartyAuthTestMixin(object):
def configure_saml_provider(self, **kwargs):
""" Update the settings for a SAML-based third party auth provider """
self.assertTrue(
SAMLConfiguration.is_enabled(Site.objects.get_current()),
SAMLConfiguration.is_enabled(Site.objects.get_current(), 'default'),
"SAML Provider Configuration only works if SAML is enabled."
)
obj = SAMLProviderConfig(**kwargs)

View File

@@ -13,7 +13,7 @@ from social_core.utils import setting_name
from student.models import UserProfile
from student.views import compose_and_send_activation_email
from .models import SAMLConfiguration
from .models import SAMLConfiguration, SAMLProviderConfig
URL_NAMESPACE = getattr(settings, setting_name('URL_NAMESPACE'), None) or 'social'
@@ -41,13 +41,19 @@ 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(request.site):
idp_slug = request.GET.get('tpa_hint', None)
saml_config = 'default'
if idp_slug:
idp = SAMLProviderConfig.current(idp_slug)
if idp.saml_configuration:
saml_config = idp.saml_configuration.slug
if not SAMLConfiguration.is_enabled(request.site, saml_config):
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()
metadata, errors = saml_backend.generate_metadata_xml(idp_slug)
if not errors:
return HttpResponse(content=metadata, content_type='text/xml')

View File

@@ -711,6 +711,8 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
# if you want (though it's easier to format the key values as JSON without the delimiters).
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '')
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '')
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT', {})
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT', {})
SOCIAL_AUTH_OAUTH_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_OAUTH_SECRETS', {})
SOCIAL_AUTH_LTI_CONSUMER_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {})