authentic/src/authentic2_auth_saml/models.py

273 lines
10 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/>.
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
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
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_path = models.CharField(
_('Metadata file path'),
max_length=300,
help_text=_('Absolute path to the IdP metadata file.'),
blank=True,
)
metadata = models.TextField(_('Metadata (XML)'), blank=True)
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, 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'
description_fields = ['show_condition', 'metadata_url', 'metadata_path', '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_PATH', '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_path or self.metadata_url):
raise ValidationError(_('One of the metadata fields must be filled.'))
def autorun(self, request, block_id):
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']}
)
def has_signing_key(self):
return bool(
getattr(settings, 'MELLON_PRIVATE_KEY', '') and getattr(settings, 'MELLON_PUBLIC_KEYS', '')
)
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)
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(_('Mandatory'), default=False, help_text=_('Deny login if action fails.'))
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)