896 lines
37 KiB
Python
896 lines
37 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Models used to implement SAML SSO support in third_party_auth
|
|
(inlcuding Shibboleth support)
|
|
"""
|
|
from __future__ import absolute_import
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
|
|
from config_models.models import ConfigurationModel, cache
|
|
from django.conf import settings
|
|
from django.contrib.sites.models import Site
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.utils.translation import ugettext_lazy as _
|
|
from organizations.models import Organization
|
|
from provider.oauth2.models import Client
|
|
from provider.utils import long_token
|
|
from social_core.backends.base import BaseAuth
|
|
from social_core.backends.oauth import OAuthAuth
|
|
from social_core.backends.saml import SAMLAuth
|
|
from social_core.exceptions import SocialAuthBaseException
|
|
from social_core.utils import module_member
|
|
|
|
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
|
from openedx.core.djangoapps.theming.helpers import get_current_request
|
|
|
|
from .lti import LTI_PARAMS_KEY, LTIAuthBackend
|
|
from .saml import STANDARD_SAML_PROVIDER_KEY, get_saml_idp_choices, get_saml_idp_class
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
REGISTRATION_FORM_FIELD_BLACKLIST = [
|
|
'name',
|
|
'username'
|
|
]
|
|
|
|
|
|
# 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(OAuthAuth)]
|
|
_PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)]
|
|
_LTI_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(LTIAuthBackend)]
|
|
|
|
|
|
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(u"Invalid JSON: {}".format(err))
|
|
if not isinstance(value_python, of_type):
|
|
raise ValidationError(u"Expected a JSON {}".format(of_type))
|
|
return json.dumps(value_python, indent=4)
|
|
|
|
|
|
def clean_username(username=''):
|
|
""" Simple helper method to ensure a username is compatible with our system requirements. """
|
|
return re.sub(r'[^-\w]+', '_', username)[:30]
|
|
|
|
|
|
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(
|
|
self.provider_name
|
|
)
|
|
|
|
|
|
class ProviderConfig(ConfigurationModel):
|
|
"""
|
|
Abstract Base Class for configuring a third_party_auth provider
|
|
|
|
.. no_pii:
|
|
"""
|
|
KEY_FIELDS = ('slug',)
|
|
|
|
icon_class = models.CharField(
|
|
max_length=50,
|
|
blank=True,
|
|
default=u'fa-sign-in',
|
|
help_text=(
|
|
u'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'
|
|
),
|
|
)
|
|
# We use a FileField instead of an ImageField here because ImageField
|
|
# doesn't support SVG. This means we don't get any image validation, but
|
|
# that should be fine because only trusted users should be uploading these
|
|
# anyway.
|
|
icon_image = models.FileField(
|
|
blank=True,
|
|
help_text=(
|
|
u'If there is no Font Awesome icon available for this provider, upload a custom image. '
|
|
'SVG images are recommended as they can scale to any size.'
|
|
),
|
|
)
|
|
name = models.CharField(max_length=50, blank=False, help_text=u"Name of this provider (shown to users)")
|
|
slug = models.SlugField(
|
|
max_length=30, db_index=True, default=u'default',
|
|
help_text=(
|
|
u'A short string uniquely identifying this provider. '
|
|
'Cannot contain spaces and should be a usable as a CSS class. Examples: "ubc", "mit-staging"'
|
|
))
|
|
secondary = models.BooleanField(
|
|
default=False,
|
|
help_text=_(
|
|
'Secondary providers are displayed less prominently, '
|
|
'in a separate list of "Institution" login providers.'
|
|
),
|
|
)
|
|
organization = models.ForeignKey(
|
|
Organization,
|
|
blank=True,
|
|
null=True,
|
|
on_delete=models.CASCADE,
|
|
help_text=_(
|
|
'optional. If this provider is an Organization, this attribute '
|
|
'can be used reference users in that Organization'
|
|
)
|
|
)
|
|
site = models.ForeignKey(
|
|
Site,
|
|
default=settings.SITE_ID,
|
|
related_name='%(class)ss',
|
|
help_text=_(
|
|
'The Site that this provider configuration belongs to.'
|
|
),
|
|
on_delete=models.CASCADE,
|
|
)
|
|
skip_hinted_login_dialog = models.BooleanField(
|
|
default=False,
|
|
help_text=_(
|
|
"If this option is enabled, users that visit a \"TPA hinted\" URL for this provider "
|
|
"(e.g. a URL ending with `?tpa_hint=[provider_name]`) will be forwarded directly to "
|
|
"the login URL of the provider instead of being first prompted with a login dialog."
|
|
),
|
|
)
|
|
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."
|
|
),
|
|
)
|
|
send_welcome_email = models.BooleanField(
|
|
default=False,
|
|
help_text=_(
|
|
"If this option is selected, users will be sent a welcome email upon registration."
|
|
),
|
|
)
|
|
visible = models.BooleanField(
|
|
default=False,
|
|
help_text=_(
|
|
"If this option is not selected, users will not be presented with the provider "
|
|
"as an option to authenticate with on the login screen, but manual "
|
|
"authentication using the correct link is still possible."
|
|
),
|
|
)
|
|
max_session_length = models.PositiveIntegerField(
|
|
null=True,
|
|
blank=True,
|
|
default=None,
|
|
verbose_name=u'Max session length (seconds)',
|
|
help_text=_(
|
|
"If this option is set, then users logging in using this SSO provider will have "
|
|
"their session length limited to no longer than this value. If set to 0 (zero), "
|
|
"the session will expire upon the user closing their browser. If left blank, the "
|
|
"Django platform session default length will be used."
|
|
)
|
|
)
|
|
send_to_registration_first = models.BooleanField(
|
|
default=False,
|
|
help_text=_(
|
|
"If this option is selected, users will be directed to the registration page "
|
|
"immediately after authenticating with the third party instead of the login page."
|
|
),
|
|
)
|
|
sync_learner_profile_data = models.BooleanField(
|
|
default=False,
|
|
help_text=_(
|
|
"Synchronize user profile data received from the identity provider with the edX user "
|
|
"account on each SSO login. The user will be notified if the email address associated "
|
|
"with their account is changed as a part of this synchronization."
|
|
)
|
|
)
|
|
enable_sso_id_verification = models.BooleanField(
|
|
default=False,
|
|
help_text=u"Use the presence of a profile from a trusted third party as proof of identity verification.",
|
|
)
|
|
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
|
|
accepts_logins = True # Whether to display a sign-in button when the provider is enabled
|
|
|
|
# "enabled" field is inherited from ConfigurationModel
|
|
|
|
class Meta(object):
|
|
app_label = "third_party_auth"
|
|
abstract = True
|
|
|
|
def clean(self):
|
|
""" Ensure that either `icon_class` or `icon_image` is set """
|
|
super(ProviderConfig, self).clean()
|
|
if bool(self.icon_class) == bool(self.icon_image):
|
|
raise ValidationError('Either an icon class or an icon image must be given (but not both)')
|
|
|
|
@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]
|
|
|
|
@property
|
|
def full_class_name(self):
|
|
""" Get the fully qualified class name of this provider. """
|
|
return '{}.{}'.format(self.__module__, self.__class__.__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
|
|
|
|
def get_remote_id_from_social_auth(self, social_auth):
|
|
""" Given a UserSocialAuth object, return the remote ID used by this provider. """
|
|
# This is generally the same thing as the UID, expect when one backend is used for multiple providers
|
|
assert self.match_social_auth(social_auth)
|
|
return social_auth.uid
|
|
|
|
def get_social_auth_uid(self, remote_id):
|
|
"""
|
|
Return the uid in social auth.
|
|
|
|
This is default implementation. Subclass may override with a different one.
|
|
"""
|
|
return remote_id
|
|
|
|
@classmethod
|
|
def get_register_form_data(cls, pipeline_kwargs):
|
|
"""Gets dict of data to display on the register form.
|
|
|
|
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.
|
|
"""
|
|
registration_form_data = {}
|
|
|
|
# Details about the user sent back from the provider.
|
|
details = pipeline_kwargs.get('details').copy()
|
|
|
|
# Set the registration form to use the `fullname` detail for the `name` field.
|
|
registration_form_data['name'] = details.get('fullname', '')
|
|
|
|
# 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.
|
|
registration_form_data['username'] = clean_username(pipeline_kwargs.get('username') or '')
|
|
|
|
# Any other values that are present in the details dict should be copied
|
|
# into the registration form details. This may include details that do
|
|
# not map to a value that exists in the registration form. However,
|
|
# because the fields that are actually rendered are not based on this
|
|
# list, only those values that map to a valid registration form field
|
|
# will actually be sent to the form as default values.
|
|
for blacklisted_field in REGISTRATION_FORM_FIELD_BLACKLIST:
|
|
details.pop(blacklisted_field, None)
|
|
registration_form_data.update(details)
|
|
|
|
return registration_form_data
|
|
|
|
def get_authentication_backend(self):
|
|
"""Gets associated Django settings.AUTHENTICATION_BACKEND string."""
|
|
return '{}.{}'.format(self.backend_class.__module__, self.backend_class.__name__)
|
|
|
|
@property
|
|
def display_for_login(self):
|
|
"""
|
|
Determines whether the provider ought to be shown as an option with
|
|
which to authenticate on the login screen, registration screen, and elsewhere.
|
|
"""
|
|
return bool(self.enabled_for_current_site and self.accepts_logins and self.visible)
|
|
|
|
@property
|
|
def enabled_for_current_site(self):
|
|
"""
|
|
Determines if the provider is able to be used with the current site.
|
|
"""
|
|
return self.enabled and self.site == Site.objects.get_current(get_current_request())
|
|
|
|
|
|
class OAuth2ProviderConfig(ProviderConfig):
|
|
"""
|
|
Configuration Entry for an OAuth2 based provider.
|
|
Also works for OAuth1 providers.
|
|
|
|
.. no_pii:
|
|
"""
|
|
# We are keying the provider config by backend_name here as suggested in the python social
|
|
# auth documentation. In order to reuse a backend for a second provider, a subclass can be
|
|
# created with seperate name.
|
|
# example:
|
|
# class SecondOpenIDProvider(OpenIDAuth):
|
|
# name = "second-openId-provider"
|
|
KEY_FIELDS = ('backend_name',)
|
|
prefix = 'oa2'
|
|
backend_name = models.CharField(
|
|
max_length=50, blank=False, db_index=True,
|
|
help_text=(
|
|
u"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 production.py sets from THIRD_PARTY_AUTH_BACKENDS
|
|
)
|
|
)
|
|
key = models.TextField(blank=True, verbose_name=u"Client ID")
|
|
secret = models.TextField(
|
|
blank=True,
|
|
verbose_name=u"Client Secret",
|
|
help_text=(
|
|
u'For increased security, you can avoid storing this in your database by leaving '
|
|
' this field blank and setting '
|
|
'SOCIAL_AUTH_OAUTH_SECRETS = {"(backend name)": "secret", ...} ' # pylint: disable=unicode-format-string
|
|
'in your instance\'s Django settings (or lms.auth.json)'
|
|
)
|
|
)
|
|
other_settings = models.TextField(blank=True, help_text=u"Optional JSON object with advanced settings, if any.")
|
|
|
|
class Meta(object):
|
|
app_label = "third_party_auth"
|
|
verbose_name = u"Provider Configuration (OAuth)"
|
|
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 == "KEY":
|
|
return self.key
|
|
if name == "SECRET":
|
|
if self.secret:
|
|
return self.secret
|
|
# To allow instances to avoid storing secrets in the DB, the secret can also be set via Django:
|
|
return getattr(settings, 'SOCIAL_AUTH_OAUTH_SECRETS', {}).get(self.backend_name, '')
|
|
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 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)
|
|
|
|
.. no_pii:
|
|
"""
|
|
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.'
|
|
),
|
|
on_delete=models.CASCADE,
|
|
)
|
|
slug = models.SlugField(
|
|
max_length=30,
|
|
default=u'default',
|
|
help_text=(
|
|
u'A short string uniquely identifying this configuration. '
|
|
'Cannot contain spaces. Examples: "ubc", "mit-staging"'
|
|
),
|
|
)
|
|
private_key = models.TextField(
|
|
help_text=(
|
|
u'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=(
|
|
u'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=u"Entity ID")
|
|
org_info_str = models.TextField(
|
|
verbose_name=u"Organization Info",
|
|
default=u'{"en-US": {"url": "http://www.example.com", "displayname": "Example Inc.", "name": "example"}}',
|
|
help_text=u"JSON dictionary of 'url', 'displayname', and 'name' for each language",
|
|
)
|
|
other_config_str = models.TextField(
|
|
default=u'{\n"SECURITY_CONFIG": {"metadataCacheDuration": 604800, "signMetadata": false}\n}',
|
|
help_text=(
|
|
u"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 = u"SAML Configuration"
|
|
verbose_name_plural = verbose_name
|
|
|
|
def __str__(self):
|
|
"""
|
|
Return human-readable string representation.
|
|
"""
|
|
return u"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": _(u"{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.
|
|
|
|
.. no_pii:
|
|
"""
|
|
prefix = 'saml'
|
|
backend_name = models.CharField(
|
|
max_length=50, default=u'tpa-saml', blank=False,
|
|
help_text=u"Which python-social-auth provider backend to use. 'tpa-saml' is the standard edX SAML backend.")
|
|
entity_id = models.CharField(
|
|
max_length=255, verbose_name=u"Entity ID", help_text=u"Example: https://idp.testshib.org/idp/shibboleth")
|
|
metadata_source = models.CharField(
|
|
max_length=255,
|
|
help_text=(
|
|
u"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=u"User ID Attribute",
|
|
help_text=(
|
|
u"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=u"Full Name Attribute",
|
|
help_text=u"URN of SAML attribute containing the user's full name. Leave blank for default.")
|
|
default_full_name = models.CharField(
|
|
max_length=255, blank=True, verbose_name=u"Default Value for Full Name",
|
|
help_text=u"Default value for full name to be used if not present in SAML response.")
|
|
attr_first_name = models.CharField(
|
|
max_length=128, blank=True, verbose_name=u"First Name Attribute",
|
|
help_text=u"URN of SAML attribute containing the user's first name. Leave blank for default.")
|
|
default_first_name = models.CharField(
|
|
max_length=255, blank=True, verbose_name=u"Default Value for First Name",
|
|
help_text=u"Default value for first name to be used if not present in SAML response.")
|
|
attr_last_name = models.CharField(
|
|
max_length=128, blank=True, verbose_name=u"Last Name Attribute",
|
|
help_text=u"URN of SAML attribute containing the user's last name. Leave blank for default.")
|
|
default_last_name = models.CharField(
|
|
max_length=255, blank=True, verbose_name=u"Default Value for Last Name",
|
|
help_text=u"Default value for last name to be used if not present in SAML response.")
|
|
attr_username = models.CharField(
|
|
max_length=128, blank=True, verbose_name=u"Username Hint Attribute",
|
|
help_text=u"URN of SAML attribute to use as a suggested username for this user. Leave blank for default.")
|
|
default_username = models.CharField(
|
|
max_length=255, blank=True, verbose_name=u"Default Value for Username",
|
|
help_text=u"Default value for username to be used if not present in SAML response.")
|
|
attr_email = models.CharField(
|
|
max_length=128, blank=True, verbose_name=u"Email Attribute",
|
|
help_text=u"URN of SAML attribute containing the user's email address[es]. Leave blank for default.")
|
|
default_email = models.CharField(
|
|
max_length=255, blank=True, verbose_name=u"Default Value for Email",
|
|
help_text=u"Default value for email to be used if not present in SAML response.")
|
|
automatic_refresh_enabled = models.BooleanField(
|
|
default=True, verbose_name=u"Enable automatic metadata refresh",
|
|
help_text=u"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=u"Identity Provider Type", default=STANDARD_SAML_PROVIDER_KEY,
|
|
choices=get_saml_idp_choices(), help_text=(
|
|
u"Some SAML providers require special behavior. For example, SAP SuccessFactors SAML providers require an "
|
|
"additional API call to retrieve user metadata not provided in the SAML response. Select the provider type "
|
|
"which best matches your use case. If in doubt, choose the Standard SAML Provider type."
|
|
)
|
|
)
|
|
debug_mode = models.BooleanField(
|
|
default=False, verbose_name=u"Debug Mode",
|
|
help_text=(
|
|
u"In debug mode, all SAML XML requests and responses will be logged. "
|
|
"This is helpful for testing/setup but should always be disabled before users start using this provider."
|
|
),
|
|
)
|
|
other_settings = models.TextField(
|
|
verbose_name=u"Advanced settings", blank=True,
|
|
help_text=(
|
|
u'For advanced use cases, enter a JSON object with addtional configuration. '
|
|
'The tpa-saml backend supports {"requiredEntitlements": ["urn:..."]}, ' # pylint: disable=unicode-format-string
|
|
'which can be used to require the presence of a specific eduPersonEntitlement, '
|
|
'and {"extra_field_definitions": [{"name": "...", "urn": "..."},...]}, which can be ' # pylint: disable=unicode-format-string
|
|
'used to define registration form fields and the URNs that can be used to retrieve '
|
|
'the relevant values from the SAML response. Custom provider types, as selected '
|
|
'in the "Identity Provider Type" field, may make use of the information stored '
|
|
'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 """
|
|
super(SAMLProviderConfig, self).clean()
|
|
self.other_settings = clean_json(self.other_settings, dict)
|
|
|
|
class Meta(object):
|
|
app_label = "third_party_auth"
|
|
verbose_name = u"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.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.slug == pipeline['kwargs']['response']['idp_name']
|
|
|
|
def match_social_auth(self, social_auth):
|
|
""" Is this provider being used for this UserSocialAuth entry? """
|
|
prefix = self.slug + ":"
|
|
return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix)
|
|
|
|
def get_remote_id_from_social_auth(self, social_auth):
|
|
""" Given a UserSocialAuth object, return the remote ID used by this provider. """
|
|
assert self.match_social_auth(social_auth)
|
|
# Remove the prefix from the UID
|
|
return social_auth.uid[len(self.slug) + 1:]
|
|
|
|
def get_social_auth_uid(self, remote_id):
|
|
""" Get social auth uid from remote id by prepending idp_slug to the remote id """
|
|
return '{}:{}'.format(self.slug, remote_id)
|
|
|
|
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')
|
|
attr_defaults = {
|
|
'attr_full_name': 'default_full_name',
|
|
'attr_first_name': 'default_first_name',
|
|
'attr_last_name': 'default_last_name',
|
|
'attr_username': 'default_username',
|
|
'attr_email': 'default_email',
|
|
}
|
|
|
|
# Defaults for missing attributes in SAML Response
|
|
conf['attr_defaults'] = {}
|
|
|
|
for field in attrs:
|
|
field_name = attr_defaults.get(field)
|
|
val = getattr(self, field)
|
|
if val:
|
|
conf[field] = val
|
|
|
|
# Default values for SAML attributes
|
|
default = getattr(self, field_name) if field_name else None
|
|
conf['attr_defaults'][field] = default
|
|
|
|
# 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 provider "%s" with entity id "%s" and IdP slug "%s". ' # pylint: disable=unicode-format-string
|
|
'Run "manage.py saml pull" to fix or debug.',
|
|
self.name, self.entity_id, self.slug
|
|
)
|
|
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.slug, **conf)
|
|
|
|
|
|
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.
|
|
|
|
.. no_pii:
|
|
"""
|
|
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=u"SSO URL")
|
|
public_key = models.TextField()
|
|
|
|
class Meta(object):
|
|
app_label = "third_party_auth"
|
|
verbose_name = u"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
|
|
|
|
|
|
class LTIProviderConfig(ProviderConfig):
|
|
"""
|
|
Configuration required for this edX instance to act as a LTI
|
|
Tool Provider and allow users to authenticate and be enrolled in a
|
|
course via third party LTI Tool Consumers.
|
|
|
|
.. no_pii:
|
|
"""
|
|
prefix = 'lti'
|
|
backend_name = 'lti'
|
|
|
|
# This provider is not visible to users
|
|
icon_class = None
|
|
icon_image = None
|
|
secondary = False
|
|
|
|
# LTI login cannot be initiated by the tool provider
|
|
accepts_logins = False
|
|
|
|
KEY_FIELDS = ('lti_consumer_key', )
|
|
|
|
lti_consumer_key = models.CharField(
|
|
max_length=255,
|
|
help_text=(
|
|
u'The name that the LTI Tool Consumer will use to identify itself'
|
|
)
|
|
)
|
|
|
|
lti_hostname = models.CharField(
|
|
default=u'localhost',
|
|
max_length=255,
|
|
help_text=(
|
|
u'The domain that will be acting as the LTI consumer.'
|
|
),
|
|
db_index=True
|
|
)
|
|
|
|
lti_consumer_secret = models.CharField(
|
|
default=long_token,
|
|
max_length=255,
|
|
help_text=(
|
|
u'The shared secret that the LTI Tool Consumer will use to '
|
|
'authenticate requests. Only this edX instance and this '
|
|
'tool consumer instance should know this value. '
|
|
'For increased security, you can avoid storing this in '
|
|
'your database by leaving this field blank and setting '
|
|
'SOCIAL_AUTH_LTI_CONSUMER_SECRETS = {"consumer key": "secret", ...} ' # pylint: disable=unicode-format-string
|
|
'in your instance\'s Django setttigs (or lms.auth.json)'
|
|
),
|
|
blank=True,
|
|
)
|
|
|
|
lti_max_timestamp_age = models.IntegerField(
|
|
default=10,
|
|
help_text=(
|
|
u'The maximum age of oauth_timestamp values, in seconds.'
|
|
)
|
|
)
|
|
|
|
def match_social_auth(self, social_auth):
|
|
""" Is this provider being used for this UserSocialAuth entry? """
|
|
prefix = self.lti_consumer_key + ":"
|
|
return self.backend_name == social_auth.provider and social_auth.uid.startswith(prefix)
|
|
|
|
def get_remote_id_from_social_auth(self, social_auth):
|
|
""" Given a UserSocialAuth object, return the remote ID used by this provider. """
|
|
assert self.match_social_auth(social_auth)
|
|
# Remove the prefix from the UID
|
|
return social_auth.uid[len(self.lti_consumer_key) + 1:]
|
|
|
|
def is_active_for_pipeline(self, pipeline):
|
|
""" Is this provider being used for the specified pipeline? """
|
|
try:
|
|
return (
|
|
self.backend_name == pipeline['backend'] and
|
|
self.lti_consumer_key == pipeline['kwargs']['response'][LTI_PARAMS_KEY]['oauth_consumer_key']
|
|
)
|
|
except KeyError:
|
|
return False
|
|
|
|
def get_lti_consumer_secret(self):
|
|
""" If the LTI consumer secret is not stored in the database, check Django settings instead """
|
|
if self.lti_consumer_secret:
|
|
return self.lti_consumer_secret
|
|
return getattr(settings, 'SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}).get(self.lti_consumer_key, '')
|
|
|
|
class Meta(object):
|
|
app_label = "third_party_auth"
|
|
verbose_name = u"Provider Configuration (LTI)"
|
|
verbose_name_plural = verbose_name
|
|
|
|
|
|
class ProviderApiPermissions(models.Model):
|
|
"""
|
|
This model links OAuth2 client with provider Id.
|
|
|
|
It gives permission for a OAuth2 client to access the information under certain IdPs.
|
|
|
|
.. no_pii:
|
|
"""
|
|
client = models.ForeignKey(Client, on_delete=models.CASCADE)
|
|
provider_id = models.CharField(
|
|
max_length=255,
|
|
help_text=(
|
|
u'Uniquely identify a provider. This is different from backend_name.'
|
|
)
|
|
)
|
|
|
|
class Meta(object):
|
|
app_label = "third_party_auth"
|
|
verbose_name = u"Provider API Permission"
|
|
verbose_name_plural = verbose_name + 's'
|