authentic/src/authentic2/saml/models.py

685 lines
24 KiB
Python

# authentic2 - versatile identity manager
# Copyright (C) 2010-2019 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import collections
import hashlib
import xml.etree.ElementTree as etree
import requests
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models
from django.db.models import Q
from django.db.models.query import QuerySet
from django.utils import six
from django.utils.encoding import force_str, force_text
from django.utils.translation import ugettext_lazy as _
from authentic2.compat_lasso import lasso
try:
from django.contrib.contenttypes.fields import GenericForeignKey
except ImportError:
from django.contrib.contenttypes.generic import GenericForeignKey
try:
from django.contrib.contenttypes.fields import GenericRelation
except ImportError:
from django.contrib.contenttypes.generic import GenericRelation
from authentic2.saml.fields import MultiSelectField, PickledObjectField
from .. import managers as a2_managers
from ..models import Service
from . import app_settings, managers
def metadata_validator(meta):
provider = lasso.Provider.newFromBuffer(lasso.PROVIDER_ROLE_ANY, force_str(meta.encode('utf8')))
if not provider:
raise ValidationError(_('Invalid metadata file'))
XML_NS = 'http://www.w3.org/XML/1998/namespace'
def get_lang(etree):
return etree.get('{%s}lang' % XML_NS)
def ls_find(ls, value):
try:
return ls.index(value)
except ValueError:
return -1
def get_prefered_content(etrees, languages=[None, 'en']):
"""Sort XML nodes by their xml:lang attribute using languages as the
ascending partial order of language identifiers
Default is to prefer english, then no lang declaration, to anything
else.
"""
best = None
best_score = -1
for tree in etrees:
if best is not None:
i = ls_find(languages, get_lang(tree))
if i > best_score:
best = tree
best_score = ls_find(languages, get_lang(tree))
else:
best = tree
best_score = ls_find(languages, get_lang(tree))
return best.text
def organization_name(provider):
"""Extract an organization name from a SAMLv2 metadata organization XML
fragment.
"""
try:
organization_xml = provider.organization
organization = etree.XML(organization_xml)
o_display_name = organization.findall('{%s}OrganizationDisplayName' % lasso.SAML2_METADATA_HREF)
if o_display_name:
return get_prefered_content(o_display_name)
o_name = organization.findall('{%s}OrganizationName' % lasso.SAML2_METADATA_HREF)
if o_name:
return get_prefered_content(o_name)
except Exception:
return provider.providerId
else:
return provider.providerId
# TODO: Remove this in LibertyServiceProvider
ASSERTION_CONSUMER_PROFILES = (
('meta', _('Use the default from the metadata file')),
('art', _('Artifact binding')),
('post', _('POST binding')),
)
DEFAULT_NAME_ID_FORMAT = 'none'
# Supported name id formats
NAME_ID_FORMATS = collections.OrderedDict(
[
(
'none',
{
'caption': _('None'),
'samlv2': None,
},
),
(
'persistent',
{
'caption': _('Persistent'),
'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,
},
),
(
'transient',
{
'caption': _("Transient"),
'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT,
},
),
(
'email',
{
'caption': _("Email"),
'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL,
},
),
(
'username',
{
'caption': _("Username (use with Google Apps)"),
'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,
},
),
(
'uuid',
{
'caption': _("UUID"),
'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,
},
),
(
'edupersontargetedid',
{
'caption': _("Use eduPersonTargetedID attribute"),
'samlv2': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,
},
),
]
)
NAME_ID_FORMATS_CHOICES = [(force_text(x), y['caption']) for x, y in NAME_ID_FORMATS.items()]
ACCEPTED_NAME_ID_FORMAT_LENGTH = sum([len(x) for x, y in NAME_ID_FORMATS.items()]) + len(NAME_ID_FORMATS) - 1
def saml2_urn_to_nidformat(urn, accepted=()):
for x, y in NAME_ID_FORMATS.items():
if accepted and x not in accepted:
continue
if y['samlv2'] == urn:
return x
return None
def nidformat_to_saml2_urn(key):
return NAME_ID_FORMATS.get(key, {}).get('samlv2')
# According to: saml-profiles-2.0-os
# The HTTP Redirect binding MUST NOT be used, as the response will typically
# exceed the URL length permitted by most user agents.
BINDING_SSO_IDP = (
(lasso.SAML2_METADATA_BINDING_ARTIFACT, _('Artifact binding')),
(lasso.SAML2_METADATA_BINDING_POST, _('POST binding')),
)
HTTP_METHOD = (
(lasso.HTTP_METHOD_REDIRECT, _('Redirect binding')),
(lasso.HTTP_METHOD_SOAP, _('SOAP binding')),
)
SIGNATURE_VERIFY_HINT = {
lasso.PROFILE_SIGNATURE_VERIFY_HINT_MAYBE: _('Let authentic decides which signatures to check'),
lasso.PROFILE_SIGNATURE_VERIFY_HINT_FORCE: _('Always check signatures'),
lasso.PROFILE_SIGNATURE_VERIFY_HINT_IGNORE: _('Does not check signatures'),
}
AUTHSAML2_UNAUTH_PERSISTENT = (
('AUTHSAML2_UNAUTH_PERSISTENT_ACCOUNT_LINKING_BY_AUTH', _('Account linking by authentication')),
('AUTHSAML2_UNAUTH_PERSISTENT_CREATE_USER_PSEUDONYMOUS', _('Create new account')),
)
AUTHSAML2_UNAUTH_TRANSIENT = (
('AUTHSAML2_UNAUTH_TRANSIENT_ASK_AUTH', _('Ask authentication')),
('AUTHSAML2_UNAUTH_TRANSIENT_OPEN_SESSION', _('Open a session')),
)
class SPOptionsIdPPolicy(models.Model):
"""
Policies configured as a SAML2 identity provider.
Used to define SAML2 parameters employed with service providers.
"""
name = models.CharField(_('name'), max_length=80, unique=True)
enabled = models.BooleanField(verbose_name=_('Enabled'), default=False, db_index=True)
prefered_assertion_consumer_binding = models.CharField(
verbose_name=_("Prefered assertion consumer binding"),
default='meta',
max_length=4,
choices=ASSERTION_CONSUMER_PROFILES,
)
encrypt_nameid = models.BooleanField(verbose_name=_("Encrypt NameID"), default=False)
encrypt_assertion = models.BooleanField(verbose_name=_("Encrypt Assertion"), default=False)
authn_request_signed = models.BooleanField(verbose_name=_("Authentication request signed"), default=False)
idp_initiated_sso = models.BooleanField(
verbose_name=_("Allow IdP initiated SSO"), default=False, db_index=True
)
# XXX: format in the metadata file, should be suffixed with a star to mark
# them as special
default_name_id_format = models.CharField(
max_length=256, default=DEFAULT_NAME_ID_FORMAT, choices=NAME_ID_FORMATS_CHOICES
)
accepted_name_id_format = MultiSelectField(
verbose_name=_("NameID formats accepted"),
max_length=1024,
blank=True,
choices=NAME_ID_FORMATS_CHOICES,
)
# TODO: add clean method which checks that the LassoProvider we can create
# with the metadata file support the SP role
# i.e. provider.roles & lasso.PROVIDER_ROLE_SP != 0
ask_user_consent = models.BooleanField(
verbose_name=_('Ask user for consent when creating a federation'), default=False
)
accept_slo = models.BooleanField(
verbose_name=_("Accept to receive Single Logout requests"), default=True, db_index=True
)
forward_slo = models.BooleanField(verbose_name=_("Forward Single Logout requests"), default=True)
needs_iframe_logout = models.BooleanField(
verbose_name=_('needs iframe logout'),
help_text=_(
'logout URL are normally loaded inside an <img> HTML tag, some service provider need to use an iframe'
),
default=False,
)
iframe_logout_timeout = models.PositiveIntegerField(
verbose_name=_('iframe logout timeout'),
help_text=_(
'if iframe logout is used, it\'s the time between the '
'onload event for this iframe and the moment we consider its '
'loading to be really finished'
),
default=300,
)
http_method_for_slo_request = models.IntegerField(
verbose_name=_("HTTP binding for the SLO requests"),
choices=HTTP_METHOD,
default=lasso.HTTP_METHOD_REDIRECT,
)
federation_mode = models.PositiveIntegerField(
_('federation mode'),
choices=app_settings.FEDERATION_MODE.get_choices(app_settings),
default=app_settings.FEDERATION_MODE.get_default(app_settings),
)
attributes = GenericRelation('SAMLAttribute')
objects = a2_managers.GetByNameManager()
def natural_key(self):
return (self.name,)
class Meta:
verbose_name = _('service provider options policy')
verbose_name_plural = _('service provider options policies')
def __str__(self):
return self.name
class SAMLAttribute(models.Model):
ATTRIBUTE_NAME_FORMATS = (
('basic', 'Basic'),
('uri', 'URI'),
('unspecified', 'Unspecified'),
)
objects = managers.SAMLAttributeManager()
content_type = models.ForeignKey(ContentType, verbose_name=_('content type'), on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(verbose_name=_('object identifier'))
provider = GenericForeignKey('content_type', 'object_id')
name_format = models.CharField(
max_length=64, verbose_name=_('name format'), default='basic', choices=ATTRIBUTE_NAME_FORMATS
)
name = models.CharField(
max_length=128,
verbose_name=_('name'),
blank=True,
help_text=_('the local attribute name is used if left blank'),
)
friendly_name = models.CharField(max_length=64, verbose_name=_('friendly name'), blank=True)
attribute_name = models.CharField(max_length=64, verbose_name=_('attribute name'))
enabled = models.BooleanField(verbose_name=_('enabled'), default=True, blank=True)
def clean(self):
super(SAMLAttribute, self).clean()
if self.attribute_name and not self.name:
self.name = self.attribute_name
def name_format_uri(self):
if self.name_format == 'basic':
return lasso.SAML2_ATTRIBUTE_NAME_FORMAT_BASIC
elif self.name_format == 'uri':
return lasso.SAML2_ATTRIBUTE_NAME_FORMAT_URI
elif self.name_format == 'unspecified':
return lasso.SAML2_ATTRIBUTE_NAME_FORMAT_UNSPECIFIED
else:
raise NotImplementedError
def to_tuples(self, ctx):
if self.attribute_name not in ctx:
return
name_format = self.name_format_uri()
name = self.name
friendly_name = self.friendly_name or None
values = ctx.get(self.attribute_name)
if isinstance(values, QuerySet):
values = list(values)
if not isinstance(values, (list, tuple, set)):
values = [values]
for value in values:
yield (name, name_format, friendly_name, value)
def __str__(self):
return u'%s %s %s' % (self.name, self.name_format_uri(), self.attribute_name)
def natural_key(self):
if not hasattr(self.provider, 'natural_key'):
return self.id
return (
self.content_type.natural_key(),
self.provider.natural_key(),
self.name_format,
self.name,
self.friendly_name,
self.attribute_name,
)
class Meta:
unique_together = (
('content_type', 'object_id', 'name_format', 'name', 'friendly_name', 'attribute_name'),
)
class LibertyProvider(Service):
entity_id = models.URLField(max_length=256, unique=True, verbose_name=_('Entity ID'))
entity_id_sha1 = models.CharField(max_length=40, blank=True, verbose_name=_('Entity ID SHA1'))
metadata_url = models.URLField(max_length=256, blank=True, verbose_name=_('Metadata URL'))
protocol_conformance = models.IntegerField(
choices=((lasso.PROTOCOL_SAML_2_0, 'SAML 2.0'),), verbose_name=_('Protocol conformance')
)
metadata = models.TextField(validators=[metadata_validator])
# All following field must be PEM formatted textual data
federation_source = models.CharField(
max_length=64, blank=True, null=True, verbose_name=_('Federation source')
)
attributes = GenericRelation(SAMLAttribute)
objects = managers.LibertyProviderManager()
def __str__(self):
return self.name
def save(self, *args, **kwargs):
'''Update the SHA1 hash of the entity_id when saving'''
if self.protocol_conformance == 3:
self.entity_id_sha1 = hashlib.sha1(self.entity_id.encode('ascii')).hexdigest()
super(LibertyProvider, self).save(*args, **kwargs)
def clean(self):
super(LibertyProvider, self).clean()
p = lasso.Provider.newFromBuffer(lasso.PROVIDER_ROLE_ANY, force_text(self.metadata.encode('utf8')))
if p is None:
raise ValidationError(_('Invalid metadata file'))
self.entity_id = p.providerId
if not self.name:
self.name = organization_name(p)
self.protocol_conformance = p.protocolConformance
if self.protocol_conformance != lasso.PROTOCOL_SAML_2_0:
raise ValidationError(_('Protocol other than SAML 2.0 are unsupported'))
def natural_key(self):
return (self.slug,)
def update_metadata(self):
try:
if not self.metadata_url:
raise ValidationError(_('No metadata URL'))
response = requests.get(self.metadata_url)
except requests.RequestException as e:
raise ValidationError(_('Retrieval of metadata failed: %s') % e)
else:
self.metadata = response.text
self.clean()
self.save()
class Meta:
ordering = ('service_ptr__name',)
verbose_name = _('SAML provider')
verbose_name_plural = _('SAML providers')
def get_all_custom_or_default(instance, name):
model = instance._meta.get_field(name).rel.to
try:
return model.objects.get(name='All')
except ObjectDoesNotExist:
pass
custom = getattr(instance, name, None)
if custom is not None:
return custom
try:
return models.objects.get(name='Default')
except ObjectDoesNotExist:
raise RuntimeError('Default %s is missing' % model)
# TODO: The IdP must look to the preferred binding order for sso in the SP metadata (AssertionConsumerService)
# expect if the protocol for response is defined in the request (ProtocolBinding attribute)
class LibertyServiceProvider(models.Model):
liberty_provider = models.OneToOneField(
LibertyProvider, primary_key=True, related_name='service_provider', on_delete=models.CASCADE
)
enabled = models.BooleanField(verbose_name=_('Enabled'), default=False, db_index=True)
enable_following_sp_options_policy = models.BooleanField(
verbose_name=_(
'The following options policy will apply except '
'if a policy for all service provider is defined.'
),
default=False,
)
sp_options_policy = models.ForeignKey(
SPOptionsIdPPolicy,
related_name="sp_options_policy",
verbose_name=_('service provider options policy'),
blank=True,
null=True,
on_delete=models.SET_NULL,
)
users_can_manage_federations = models.BooleanField(
verbose_name=_('users can manage federation'), default=True, blank=True, db_index=True
)
objects = managers.GetByLibertyProviderManager()
def natural_key(self):
return (self.liberty_provider.slug,)
def __str__(self):
return six.text_type(self.liberty_provider)
class Meta:
verbose_name = _('SAML service provider')
verbose_name_plural = _('SAML service providers')
LIBERTY_SESSION_DUMP_KIND_SP = 0
LIBERTY_SESSION_DUMP_KIND_IDP = 1
LIBERTY_SESSION_DUMP_KIND = {
LIBERTY_SESSION_DUMP_KIND_SP: 'sp',
LIBERTY_SESSION_DUMP_KIND_IDP: 'idp',
}
class LibertySessionDump(models.Model):
"""Store lasso session object dump.
Should be replaced in the future by direct references to known
assertions through the LibertySession object"""
django_session_key = models.CharField(max_length=128)
session_dump = models.TextField(blank=True)
kind = models.IntegerField(choices=LIBERTY_SESSION_DUMP_KIND.items())
objects = managers.SessionLinkedManager()
class Meta:
verbose_name = _('SAML session dump')
verbose_name_plural = _('SAML session dumps')
unique_together = (('django_session_key', 'kind'),)
class LibertyArtifact(models.Model):
"""Store an artifact and the associated XML content"""
creation = models.DateTimeField(auto_now_add=True)
artifact = models.CharField(max_length=128, primary_key=True)
content = models.TextField()
provider_id = models.CharField(max_length=256)
objects = managers.LibertyArtifactManager()
class Meta:
verbose_name = _('SAML artifact')
verbose_name_plural = _('SAML artifacts')
def nameid2kwargs(name_id):
return {
'name_id_qualifier': name_id.nameQualifier,
'name_id_sp_name_qualifier': name_id.spNameQualifier,
'name_id_content': name_id.content,
'name_id_format': name_id.format,
}
# XXX: for retrocompatibility
federation_delete = managers.federation_delete
class LibertyFederation(models.Model):
"""Store a federation, i.e. an identifier shared with another provider, be
it IdP or SP"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL)
sp = models.ForeignKey('LibertyServiceProvider', null=True, blank=True, on_delete=models.CASCADE)
name_id_format = models.CharField(max_length=100, verbose_name="NameIDFormat", blank=True, null=True)
name_id_content = models.CharField(max_length=100, verbose_name="NameID")
name_id_qualifier = models.CharField(max_length=256, verbose_name="NameQualifier", blank=True, null=True)
name_id_sp_name_qualifier = models.CharField(
max_length=256, verbose_name="SPNameQualifier", blank=True, null=True
)
termination_notified = models.BooleanField(blank=True, default=False)
creation = models.DateTimeField(auto_now_add=True)
last_modification = models.DateTimeField(auto_now=True)
objects = managers.LibertyFederationManager()
def __init__(self, *args, **kwargs):
saml2_assertion = kwargs.pop('saml2_assertion', None)
if saml2_assertion:
name_id = saml2_assertion.subject.nameID
kwargs.update(nameid2kwargs(name_id))
models.Model.__init__(self, *args, **kwargs)
def natural_key(self):
key = self.user.natural_key()
if self.sp:
key += self.sp.natural_key()
else:
key += (None,)
if self.idp:
key += self.idp.natural_key()
else:
key += (None,)
return key
def is_unique(self, for_format=True):
"""Return whether a federation already exist for this user and this provider.
By default the check is made by name_id_format, if you want to check
whatever the format, set for_format to False.
"""
qs = LibertyFederation.objects.exclude(id=self.id).filter(user=self.user, idp=self.idp, sp=self.sp)
if for_format:
qs = qs.filter(name_id_format=self.name_id_format)
return not qs.exists()
class Meta:
verbose_name = _("SAML federation")
verbose_name_plural = _("SAML federations")
def __str__(self):
return self.name_id_content
class LibertySession(models.Model):
"""Store the link between a Django session and a SAML session"""
django_session_key = models.CharField(max_length=128)
session_index = models.CharField(max_length=80)
provider_id = models.CharField(max_length=256)
federation = models.ForeignKey(LibertyFederation, blank=True, null=True, on_delete=models.CASCADE)
name_id_qualifier = models.CharField(max_length=256, verbose_name=_("Qualifier"), null=True)
name_id_format = models.CharField(max_length=100, verbose_name=_("NameIDFormat"), null=True)
name_id_content = models.CharField(max_length=100, verbose_name=_("NameID"))
name_id_sp_name_qualifier = models.CharField(max_length=256, verbose_name=_("SPNameQualifier"), null=True)
creation = models.DateTimeField(auto_now_add=True)
objects = managers.LibertySessionManager()
def __init__(self, *args, **kwargs):
saml2_assertion = kwargs.pop('saml2_assertion', None)
if saml2_assertion:
kwargs['session_index'] = saml2_assertion.authnStatement[0].sessionIndex
name_id = saml2_assertion.subject.nameID
kwargs.update(nameid2kwargs(name_id))
models.Model.__init__(self, *args, **kwargs)
def set_nid(self, name_id):
self.__dict__.update(nameid2kwargs(name_id))
@classmethod
def get_for_nameid_and_session_indexes(cls, issuer_id, provider_id, name_id, session_indexes):
if not name_id:
# logout request did not contain any NameID, bad !
return LibertySession.objects.none()
kwargs = nameid2kwargs(name_id)
name_id_qualifier = kwargs['name_id_qualifier']
qs = LibertySession.objects.filter(provider_id=provider_id, session_index__in=session_indexes)
if name_id_qualifier and name_id_qualifier != issuer_id:
qs = qs.filter(**kwargs)
else:
kwargs.pop('name_id_qualifier')
qs = qs.filter(**kwargs).filter(
Q(name_id_qualifier__isnull=True) | Q(name_id_qualifier=issuer_id)
)
qs = qs.filter(Q(name_id_sp_name_qualifier__isnull=True) | Q(name_id_sp_name_qualifier=provider_id))
return qs
def __str__(self):
return '<LibertySession %s>' % self.__dict__
class Meta:
verbose_name = _("SAML session")
verbose_name_plural = _("SAML sessions")
class KeyValue(models.Model):
key = models.CharField(max_length=128, primary_key=True)
value = PickledObjectField()
created = models.DateTimeField(auto_now_add=True)
objects = a2_managers.ExpireManager()
def __str__(self):
return self.key
class Meta:
verbose_name = _("key value association")
verbose_name_plural = _("key value associations")
def save_key_values(key, *values):
# never update an existing key, key are nonces
kv, created = KeyValue.objects.get_or_create(key=key, defaults={'value': values})
if not created:
kv.value = values
kv.save()
def get_and_delete_key_values(key):
try:
kv = KeyValue.objects.get(key=key)
return kv.value
except ObjectDoesNotExist:
raise KeyError