326 lines
12 KiB
Python
326 lines
12 KiB
Python
# authentic2 - versatile identity manager
|
|
# Copyright (C) 2010-2022 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 xml.etree.ElementTree as ET
|
|
|
|
import lasso
|
|
from django.conf import settings
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.db.models import JSONField
|
|
from django.urls import reverse
|
|
from django.utils.safestring import mark_safe
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
from authentic2.apps.authenticators.models import (
|
|
AddRoleAction,
|
|
AuthenticatorRelatedObjectBase,
|
|
BaseAuthenticator,
|
|
)
|
|
from authentic2.utils.misc import redirect_to_login
|
|
|
|
NAME_ID_FORMAT_CHOICES = (
|
|
('', _('None')),
|
|
(
|
|
lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,
|
|
_('Persistent (%s)') % lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,
|
|
),
|
|
(
|
|
lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT,
|
|
_('Transient (%s)') % lasso.SAML2_NAME_IDENTIFIER_FORMAT_TRANSIENT,
|
|
),
|
|
(lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL, _('Email (%s)') % lasso.SAML2_NAME_IDENTIFIER_FORMAT_EMAIL),
|
|
(
|
|
lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,
|
|
_('Unspecified (%s)') % lasso.SAML2_NAME_IDENTIFIER_FORMAT_UNSPECIFIED,
|
|
),
|
|
)
|
|
|
|
|
|
def validate_metadata(metadata):
|
|
try:
|
|
doc = ET.fromstring(metadata)
|
|
except (TypeError, ET.ParseError) as e:
|
|
raise ValidationError(_('Cannot parse metadata, %s') % e)
|
|
|
|
tag_name = '{%s}EntityDescriptor' % lasso.SAML2_METADATA_HREF
|
|
if doc.tag != tag_name:
|
|
raise ValidationError(_('Invalid metadata, missing tag %s') % tag_name)
|
|
|
|
if 'entityID' not in doc.attrib:
|
|
raise ValidationError(_('Invalid metadata, missing entityID'))
|
|
|
|
|
|
class SAMLAuthenticator(BaseAuthenticator):
|
|
metadata_url = models.URLField(_('Metadata URL'), max_length=300, blank=True)
|
|
metadata_cache_time = models.PositiveSmallIntegerField(_('Metadata cache time'), default=3600)
|
|
metadata_http_timeout = models.PositiveSmallIntegerField(_('Metadata HTTP timeout'), default=10)
|
|
|
|
metadata = models.TextField(_('Metadata (XML)'), blank=True, validators=[validate_metadata])
|
|
|
|
provision = models.BooleanField(_('Create user if their username does not already exists'), default=True)
|
|
verify_ssl_certificate = models.BooleanField(
|
|
_('Verify SSL certificate'),
|
|
default=True,
|
|
help_text=_('Verify SSL certificate when doing HTTP requests, used when resolving artifacts.'),
|
|
)
|
|
transient_federation_attribute = models.CharField(
|
|
_('Transient federation attribute'),
|
|
max_length=64,
|
|
help_text=_(
|
|
'Name of an attribute to use in replacement of the NameID content when the NameID format is transient.'
|
|
),
|
|
blank=True,
|
|
)
|
|
realm = models.CharField(
|
|
_('Realm (realm)'),
|
|
max_length=32,
|
|
help_text=_('The default realm to associate to user, can be used in username template.'),
|
|
default='saml',
|
|
)
|
|
username_template = models.CharField(
|
|
_('Username template'),
|
|
max_length=128,
|
|
help_text=_(
|
|
'The template to build and/or retrieve a user from its username based '
|
|
'on received attributes, the syntax is the one from the str.format() '
|
|
'method of Python. Available variables are realm, idp (current settings '
|
|
'for the idp issuing the assertion), attributes. The default value is '
|
|
'{attributes[name_id_content]}@{realm}. Another example could be {atttributes[uid][0]} '
|
|
'to set the passed username as the username of the newly created user.'
|
|
),
|
|
default='{attributes[name_id_content]}@{realm}',
|
|
)
|
|
name_id_policy_format = models.CharField(
|
|
_('NameID policy format'),
|
|
max_length=64,
|
|
choices=NAME_ID_FORMAT_CHOICES,
|
|
help_text=_('The NameID format to request.'),
|
|
blank=True,
|
|
)
|
|
name_id_policy_allow_create = models.BooleanField(_('NameID policy allow create'), default=True)
|
|
force_authn = models.BooleanField(
|
|
_('Force authn'), default=False, help_text=_('Force authentication on each authentication request.')
|
|
)
|
|
add_authnrequest_next_url_extension = models.BooleanField(
|
|
_('Add authnrequest next url extension'), default=False
|
|
)
|
|
group_attribute = models.CharField(
|
|
_('Group attribute'),
|
|
max_length=32,
|
|
help_text=_('Name of the SAML attribute to map to Django group names (for example "role").'),
|
|
blank=True,
|
|
)
|
|
create_group = models.BooleanField(
|
|
_('Create group'), default=True, help_text=_('Create group or only assign existing groups.')
|
|
)
|
|
error_url = models.URLField(
|
|
_('Error URL'),
|
|
help_text=_(
|
|
'URL for the continue link when authentication fails. If not set, the RelayState is '
|
|
'used. If there is no RelayState, application default login redirect URL is used.'
|
|
),
|
|
blank=True,
|
|
)
|
|
error_redirect_after_timeout = models.PositiveSmallIntegerField(
|
|
_('Error redirect after timeout'),
|
|
default=120,
|
|
help_text=_(
|
|
'Timeout in seconds before automatically redirecting the user to the '
|
|
'continue URL when authentication has failed.'
|
|
),
|
|
)
|
|
authn_classref = models.CharField(
|
|
_('Authn classref'),
|
|
max_length=512,
|
|
help_text=_(
|
|
'Authorized authentication class references, separated by commas. '
|
|
'Empty value means everything is authorized. Authentication class reference '
|
|
'must be obtained from the identity provider but should come from the '
|
|
'SAML 2.0 specification.'
|
|
),
|
|
blank=True,
|
|
)
|
|
attribute_mapping = JSONField(
|
|
_('Attribute mapping (deprecated)'),
|
|
default=dict,
|
|
help_text=_(
|
|
'Maps templates based on SAML attributes to field of the user model, '
|
|
'for example {"email": "attributes[mail][0]"}.'
|
|
),
|
|
blank=True,
|
|
)
|
|
superuser_mapping = JSONField(
|
|
_('Superuser mapping'),
|
|
default=dict,
|
|
editable=False,
|
|
help_text=_(
|
|
'Gives superuser flags to user if a SAML attribute contains a given value, '
|
|
'for example {"roles": "Admin"}.'
|
|
),
|
|
blank=True,
|
|
)
|
|
|
|
type = 'saml'
|
|
how = ['saml']
|
|
manager_view_template_name = 'authentic2_auth_saml/authenticator_detail.html'
|
|
manager_idp_info_template_name = 'authentic2_auth_saml/idp_configuration_info.html'
|
|
description_fields = ['show_condition', 'metadata_url', 'metadata', 'provision']
|
|
|
|
class Meta:
|
|
verbose_name = _('SAML')
|
|
|
|
@property
|
|
def settings(self):
|
|
settings = {k.upper(): v for k, v in self.__dict__.items()}
|
|
|
|
settings['AUTHN_CLASSREF'] = [x.strip() for x in settings['AUTHN_CLASSREF'].split(',') if x.strip()]
|
|
|
|
for setting in ('METADATA', 'METADATA_URL'):
|
|
if not settings[setting]:
|
|
del settings[setting]
|
|
|
|
settings['LOOKUP_BY_ATTRIBUTES'] = [lookup.as_dict() for lookup in self.attribute_lookups.all()]
|
|
|
|
settings['authenticator'] = self
|
|
return settings
|
|
|
|
@property
|
|
def manager_form_classes(self):
|
|
from .forms import SAMLAuthenticatorAdvancedForm, SAMLAuthenticatorForm
|
|
|
|
return [
|
|
(_('General'), SAMLAuthenticatorForm),
|
|
(_('Advanced'), SAMLAuthenticatorAdvancedForm),
|
|
]
|
|
|
|
@property
|
|
def related_object_form_class(self):
|
|
from .forms import SAMLRelatedObjectForm
|
|
|
|
return SAMLRelatedObjectForm
|
|
|
|
@property
|
|
def related_models(self):
|
|
return {
|
|
SAMLAttributeLookup: self.attribute_lookups.all(),
|
|
SetAttributeAction: self.set_attribute_actions.all(),
|
|
AddRoleAction: self.add_role_actions.all(),
|
|
}
|
|
|
|
def clean(self):
|
|
if not (self.metadata or self.metadata_url):
|
|
raise ValidationError(_('One of the metadata fields must be filled.'))
|
|
|
|
def autorun(self, request, block_id, next_url):
|
|
from .adapters import AuthenticAdapter
|
|
|
|
settings = self.settings
|
|
AuthenticAdapter().load_idp(settings, self.order)
|
|
return redirect_to_login(
|
|
request, login_url='mellon_login', params={'entityID': settings['ENTITY_ID'], 'next': next_url}
|
|
)
|
|
|
|
def has_signing_key(self):
|
|
return bool(
|
|
getattr(settings, 'MELLON_PRIVATE_KEY', '') and getattr(settings, 'MELLON_PUBLIC_KEYS', '')
|
|
)
|
|
|
|
def get_metadata_display(self):
|
|
if not self.metadata:
|
|
return ''
|
|
|
|
url = reverse('a2-manager-saml-authenticator-metadata', kwargs={'pk': self.pk})
|
|
return mark_safe('<a href=%s>%s</a>' % (url, _('View metadata')))
|
|
|
|
def login(self, request, *args, **kwargs):
|
|
from . import views
|
|
|
|
return views.login(request, self, *args, **kwargs)
|
|
|
|
def profile(self, request, *args, **kwargs):
|
|
from . import views
|
|
|
|
return views.profile(request, *args, **kwargs)
|
|
|
|
|
|
class SAMLAttributeLookup(AuthenticatorRelatedObjectBase):
|
|
user_field = models.CharField(_('User field'), max_length=256)
|
|
saml_attribute = models.CharField(_('SAML attribute'), max_length=1024)
|
|
ignore_case = models.BooleanField(_('Ignore case'), default=False)
|
|
|
|
description = _(
|
|
'Define which attributes are used to establish the link with an identity provider account. '
|
|
'They are tried successively until one matches.'
|
|
)
|
|
|
|
class Meta:
|
|
default_related_name = 'attribute_lookups'
|
|
verbose_name = _('Attribute lookup')
|
|
verbose_name_plural = _('Lookup by attributes')
|
|
|
|
def __str__(self):
|
|
label = _('"%(saml_attribute)s" (from "%(user_field)s")') % {
|
|
'saml_attribute': self.saml_attribute,
|
|
'user_field': self.get_user_field_display(),
|
|
}
|
|
if self.ignore_case:
|
|
label = '%s, %s' % (label, _('case insensitive'))
|
|
return label
|
|
|
|
def as_dict(self):
|
|
return {
|
|
'user_field': self.user_field,
|
|
'saml_attribute': self.saml_attribute,
|
|
'ignore-case': self.ignore_case,
|
|
}
|
|
|
|
def get_user_field_display(self):
|
|
from authentic2.forms.widgets import SelectAttributeWidget
|
|
|
|
return SelectAttributeWidget.get_options().get(self.user_field, self.user_field)
|
|
|
|
|
|
class SetAttributeAction(AuthenticatorRelatedObjectBase):
|
|
user_field = models.CharField(_('User field'), max_length=256)
|
|
saml_attribute = models.CharField(_('SAML attribute name'), max_length=1024)
|
|
mandatory = models.BooleanField(
|
|
_('Deny login if attribute is missing'),
|
|
default=False,
|
|
help_text=_('Login will also be denied if attribute has more than one value.'),
|
|
)
|
|
|
|
description = _('Set user fields using received SAML attributes.')
|
|
|
|
class Meta:
|
|
default_related_name = 'set_attribute_actions'
|
|
verbose_name = _('Set an attribute')
|
|
verbose_name_plural = _('Set attributes')
|
|
|
|
def __str__(self):
|
|
label = _('"%(attribute)s" from "%(saml_attribute)s"') % {
|
|
'attribute': self.get_user_field_display(),
|
|
'saml_attribute': self.saml_attribute,
|
|
}
|
|
if self.mandatory:
|
|
label = '%s (%s)' % (label, _('mandatory'))
|
|
return label
|
|
|
|
def get_user_field_display(self):
|
|
from authentic2.forms.widgets import SelectAttributeWidget
|
|
|
|
return SelectAttributeWidget.get_options().get(self.user_field, self.user_field)
|