import logging
import re
from enum import Enum
import six
from flask_babel import lazy_gettext as _
from api.admin.problem_details import INCOMPLETE_CONFIGURATION
from api.admin.validator import Validator
from api.saml.configuration.model import SAMLConfiguration
from api.saml.metadata.filter import SAMLSubjectFilter, SAMLSubjectFilterError
from api.saml.metadata.model import SAMLSubjectPatronIDExtractor
from api.saml.metadata.parser import SAMLMetadataParser, SAMLMetadataParsingError
from core.problem_details import *
from core.util.problem_detail import ProblemDetail
SAML_INCORRECT_METADATA = pd(
"http://librarysimplified.org/terms/problem/saml/incorrect-metadata-format",
status_code=400,
title=_("SAML metadata has an incorrect format."),
detail=_("SAML metadata has an incorrect format."),
)
SAML_GENERIC_PARSING_ERROR = pd(
"http://librarysimplified.org/terms/problem/saml/generic-parsing-error",
status_code=500,
title=_("Unexpected error."),
detail=_(
"An unexpected error occurred during validation of SAML authentication settings."
),
)
SAML_INCORRECT_FILTRATION_EXPRESSION = pd(
"http://librarysimplified.org/terms/problem/saml/incorrect-filtration-expression-format",
status_code=400,
title=_("SAML filtration expression has an incorrect format."),
detail=_("SAML filtration expression has an incorrect format."),
)
SAML_INCORRECT_PATRON_ID_REGULAR_EXPRESSION = pd(
"http://librarysimplified.org/terms/problem/saml/incorrect-patron-id-regex",
status_code=400,
title=_("SAML patron ID regular expression has an incorrect format."),
detail=_("SAML patron ID regular expression has an incorrect format."),
)
[docs]class ProviderType(Enum):
ServiceProvider = "SP"
IdentityProvider = "IdP"
[docs]class SAMLSettingsValidator(Validator):
"""Validates SAMLAuthenticationProvider's settings submitted by a user"""
def __init__(self, metadata_parser, subject_filter):
"""Initializes a new instance of SAMLAuthenticationProviderSettingsValidator class
:param metadata_parser: SAML metadata parser
:type metadata_parser: api.saml.metadata.parser.SAMLMetadataParser
:param subject_filter: SAML subject filter
:type subject_filter: api.saml.metadata.filter.SAMLSubjectFilter
"""
if not isinstance(metadata_parser, SAMLMetadataParser):
raise ValueError(
"Argument 'metadata_parser' must be an instance of {0} class".format(
SAMLMetadataParser
)
)
if not isinstance(subject_filter, SAMLSubjectFilter):
raise ValueError(
"Argument 'subject_filter' must be an instance of {0} class".format(
SAMLSubjectFilter
)
)
self._metadata_parser = metadata_parser
self._subject_filter = subject_filter
self._logger = logging.getLogger(__name__)
def _get_setting_value(
self, settings, content, setting_key, setting_name, required
):
"""Selects a setting's value from the form submitted by a user
:param settings: Dictionary containing provider's settings (SAMLAuthenticationProvider.SETTINGS)
:type: Dict
:param content: Dictionary containing submitted form's metadata
:type content: werkzeug.datastructures.MultiDict
:param setting_key: Setting's key
:param setting_key: str
:param setting_name: Setting's name
:param setting_name: str
:param required: Boolean value indicating whether the setting is required
:param required: bool
:return: Setting's value set by the user or a ProblemDetail instance in the case of any error
:rtype: Union[str, core.util.problem_detail.ProblemDetail]
"""
submitted_form = content.get("form")
setting_values = self._extract_inputs(
settings, setting_key, submitted_form, "key"
)
if required and not setting_values:
return INCOMPLETE_CONFIGURATION.detailed(
_("Required field '{0}' is missing".format(setting_name))
)
return setting_values[0] if setting_values else None
def _parse_metadata(self, xml_metadata, provider_type):
"""Parses SAML XML metadata
:param xml_metadata: SAML XML metadata
:type xml_metadata: string
:param provider_type: Type of the metadata: SP or IdP
:type provider_type: ProviderType
:return: List of IdentityProviderMetadata/ServiceProvider instances or a ProblemDetail instance
in the case of any errors
:rtype: Union[List[api.saml.metadata.model.SAMLProviderMetadata], core.util.problem_detail.ProblemDetail]
"""
try:
result = self._metadata_parser.parse(xml_metadata)
return result
except SAMLMetadataParsingError as exception:
self._logger.exception(
"An unexpected exception occurred during parsing of SAML metadata"
)
if provider_type == ProviderType.ServiceProvider:
message = (
"Service Provider's metadata has incorrect format: {0}".format(
six.ensure_text(str(exception))
)
)
else:
message = (
"Identity Provider's metadata has incorrect format: {0}".format(
six.ensure_text(str(exception))
)
)
return SAML_INCORRECT_METADATA.detailed(message)
except Exception as exception:
self._logger.exception(
"An unexpected exception occurred duing parsing SAML metadata"
)
return SAML_GENERIC_PARSING_ERROR.detailed(str(exception))
def _get_providers(
self, settings, content, setting_key, setting_name, provider_type
):
"""Fetches provider definition from the SAML metadata submitted by the user
:param settings: Dictionary containing provider's settings (SAMLAuthenticationProvider.SETTINGS)
:type: Dict
:param content: Dictionary containing submitted form's metadata
:type content: werkzeug.datastructures.MultiDict
:param setting_key: Setting's key
:param setting_key: str
:param setting_name: Setting's name
:param setting_name: str
:param provider_type: Type of the metadata: SP or IdP
:type provider_type: ProviderType
:return: List of IdentityProviderMetadata/ServiceProvider instances or a ProblemDetail instance
in the case of any errors
:rtype: Union[List[api.saml.metadata.model.SAMLProviderMetadata], core.util.problem_detail.ProblemDetail]
"""
provider_xml_metadata = self._get_setting_value(
settings, content, setting_key, setting_name, True
)
if isinstance(provider_xml_metadata, ProblemDetail):
return provider_xml_metadata
providers = self._parse_metadata(provider_xml_metadata, provider_type)
return providers
def _process_sp_providers(self, settings, content, setting_key, setting_name):
"""Fetches SP provider definition from the SAML metadata submitted by the user
:param settings: Dictionary containing provider's settings (SAMLAuthenticationProvider.SETTINGS)
:type: Dict
:param content: Dictionary containing submitted form's metadata
:type content: werkzeug.datastructures.MultiDict
:param setting_key: Setting's key
:param setting_key: str
:param setting_name: Setting's name
:param setting_name: str
:return: SP provider definition or a ProblemDetail instance in the case of any errors
:rtype: Union[api.saml.metadata.model.SAMLServiceProviderMetadata, core.util.problem_detail.ProblemDetail]
"""
sp_providers = self._get_providers(
settings, content, setting_key, setting_name, ProviderType.ServiceProvider
)
if isinstance(sp_providers, ProblemDetail):
return sp_providers
if len(sp_providers) != 1:
return SAML_INCORRECT_METADATA.detailed(
"Service Provider's XML metadata must contain exactly one declaration of SPSSODescriptor"
)
return sp_providers[0]
def _process_idp_providers(self, settings, content, setting_key, setting_name):
"""Fetches IdP provider definitions from the SAML metadata submitted by the user
:param settings: Dictionary containing provider's settings (SAMLAuthenticationProvider.SETTINGS)
:type: Dict
:param content: Dictionary containing submitted form's metadata
:type content: werkzeug.datastructures.MultiDict
:param setting_key: Setting's key
:param setting_key: str
:param setting_name: Setting's name
:param setting_name: str
:return: List of IdP provider definitions or a ProblemDetail instance in the case of any errors
:rtype: Union[
List[api.saml.metadata.model.SAMLIdentityProviderMetadata],
core.util.problem_detail.ProblemDetail
]
"""
idp_providers = self._get_providers(
settings, content, setting_key, setting_name, ProviderType.IdentityProvider
)
if isinstance(idp_providers, ProblemDetail):
return idp_providers
if len(idp_providers) < 0:
return SAML_INCORRECT_METADATA.detailed(
"Identity Provider's XML metadata must contain at least one declaration of IDPSSODescriptor"
)
return idp_providers
def _process_filtration_expression(
self, settings, content, setting_key, setting_name
):
filtration_expression = self._get_setting_value(
settings, content, setting_key, setting_name, False
)
if filtration_expression:
try:
self._subject_filter.validate(filtration_expression)
except SAMLSubjectFilterError as exception:
self._logger.exception("Validation of the filtration expression failed")
return SAML_INCORRECT_FILTRATION_EXPRESSION.detailed(
_(
"SAML filtration expression has an incorrect format: {0}".format(
six.ensure_text(str(exception))
)
)
)
def _validate_patron_id_regular_expression(
self, settings, content, setting_key, setting_name
):
"""Validate a regular expression used to extract a unique patron ID from SAML attributes.
:param settings: Dictionary containing provider's settings (SAMLAuthenticationProvider.SETTINGS)
:type: Dict
:param content: Dictionary containing submitted form's metadata
:type content: werkzeug.datastructures.MultiDict
:param setting_key: Setting's key
:param setting_key: str
:param setting_name: Setting's name
:param setting_name: str
:return: ProblemDetail object if the regular expression is invalid
:rtype: Optional[ProblemDetail]
"""
patron_id_regular_expression = self._get_setting_value(
settings, content, setting_key, setting_name, False
)
if patron_id_regular_expression:
try:
regex = re.compile(patron_id_regular_expression)
if (
SAMLSubjectPatronIDExtractor.PATRON_ID_REGULAR_EXPRESSION_NAMED_GROUP
not in regex.groupindex
):
return SAML_INCORRECT_PATRON_ID_REGULAR_EXPRESSION.detailed(
_(
"SAML patron ID regular expression '{0}' does not have mandatory named group '{1}'".format(
six.ensure_text(patron_id_regular_expression),
six.ensure_text(
SAMLSubjectPatronIDExtractor.PATRON_ID_REGULAR_EXPRESSION_NAMED_GROUP
),
)
)
)
except re.error as exception:
error_message = "SAML patron ID regular expression '{0}' has an incorrect format: {1}".format(
six.ensure_text(patron_id_regular_expression), exception
)
self._logger.exception(error_message)
return SAML_INCORRECT_PATRON_ID_REGULAR_EXPRESSION.detailed(
_(error_message)
)
return None
[docs] def validate(self, settings, content):
"""Validates provider's setting values submitted by the user
:param settings: Dictionary containing provider's settings (SAMLAuthenticationProvider.SETTINGS)
:type settings: Optional[ProblemDetail]
:param content: Dictionary containing submitted form's metadata
:type content: werkzeug.datastructures.MultiDict
:return: ProblemDetail in the case of any errors, None if validation succeeded
:rtype: Optional[core.util.problem_detail.ProblemDetail]
"""
validation_result = super(SAMLSettingsValidator, self).validate(
settings, content
)
if isinstance(validation_result, ProblemDetail):
return validation_result
validation_result = self._process_sp_providers(
settings,
content,
SAMLConfiguration.service_provider_xml_metadata.key,
SAMLConfiguration.service_provider_xml_metadata.label,
)
if isinstance(validation_result, ProblemDetail):
return validation_result
validation_result = self._get_setting_value(
settings,
content,
SAMLConfiguration.non_federated_identity_provider_xml_metadata.key,
SAMLConfiguration.non_federated_identity_provider_xml_metadata.label,
False,
)
if validation_result and not isinstance(validation_result, ProblemDetail):
validation_result = self._process_idp_providers(
settings,
content,
SAMLConfiguration.non_federated_identity_provider_xml_metadata.key,
SAMLConfiguration.non_federated_identity_provider_xml_metadata.label,
)
if isinstance(validation_result, ProblemDetail):
return validation_result
validation_result = self._process_filtration_expression(
settings,
content,
SAMLConfiguration.filter_expression.key,
SAMLConfiguration.filter_expression.label,
)
if isinstance(validation_result, ProblemDetail):
return validation_result
validation_result = self._validate_patron_id_regular_expression(
settings,
content,
SAMLConfiguration.patron_id_regular_expression.key,
SAMLConfiguration.patron_id_regular_expression.label,
)
return validation_result