IdP SAML Metadata generator
Keystone acting as a SAML2 Identity Provider needs to be able to generate and expose metadata that is consumed by federated Service Providers. Co-Authored-By: Steve Martinelli <stevemar@ca.ibm.com> Change-Id: I9e4b2f068a8190215749b95f31d634eb09c1e3f1 Implements: bp keystone-to-keystone-federation
This commit is contained in:
parent
6a3ad23805
commit
57ca6e2358
|
@ -1294,6 +1294,52 @@
|
|||
# contain a comma. (string value)
|
||||
#keyfile=/etc/keystone/ssl/private/signing_key.pem
|
||||
|
||||
# Entity ID value for unique Identity Provider identification.
|
||||
# Usually FQDN is set with a suffix. A value is required to
|
||||
# generate IDP Metadata. For example:
|
||||
# https://keystone.example.com/v3/OS-FEDERATION/saml2/idp
|
||||
# (string value)
|
||||
#idp_entity_id=<None>
|
||||
|
||||
# Identity Provider Single-Sign-On service value, required in
|
||||
# the Identity Provider's metadata. A value is required to
|
||||
# generate IDP Metadata. For example:
|
||||
# https://keystone.example.com/v3/OS-FEDERATION/saml2/sso
|
||||
# (string value)
|
||||
#idp_sso_endpoint=<None>
|
||||
|
||||
# Language used by the organization. (string value)
|
||||
#idp_lang=en
|
||||
|
||||
# Organization name the installation belongs to. (string
|
||||
# value)
|
||||
#idp_organization_name=<None>
|
||||
|
||||
# Organization name to be displayed. (string value)
|
||||
#idp_organization_display_name=<None>
|
||||
|
||||
# URL of the organization. (string value)
|
||||
#idp_organization_url=<None>
|
||||
|
||||
# Company of contact person. (string value)
|
||||
#idp_contact_company=<None>
|
||||
|
||||
# Given name of contact person (string value)
|
||||
#idp_contact_name=<None>
|
||||
|
||||
# Surname of contact person. (string value)
|
||||
#idp_contact_surname=<None>
|
||||
|
||||
# Email address of contact person. (string value)
|
||||
#idp_contact_email=<None>
|
||||
|
||||
# Telephone number of contact person. (string value)
|
||||
#idp_contact_telephone=<None>
|
||||
|
||||
# Contact type. Allowed values are: technical, support,
|
||||
# administrative billing, and other (string value)
|
||||
#idp_contact_type=other
|
||||
|
||||
|
||||
[signing]
|
||||
|
||||
|
|
|
@ -824,6 +824,40 @@ FILE_OPTIONS = {
|
|||
default=_KEYFILE,
|
||||
help='Path of the keyfile for SAML signing. Note, the path '
|
||||
'cannot contain a comma.'),
|
||||
cfg.StrOpt('idp_entity_id',
|
||||
help='Entity ID value for unique Identity Provider '
|
||||
'identification. Usually FQDN is set with a suffix. '
|
||||
'A value is required to generate IDP Metadata. '
|
||||
'For example: https://keystone.example.com/v3/'
|
||||
'OS-FEDERATION/saml2/idp'),
|
||||
cfg.StrOpt('idp_sso_endpoint',
|
||||
help='Identity Provider Single-Sign-On service value, '
|
||||
'required in the Identity Provider\'s metadata. '
|
||||
'A value is required to generate IDP Metadata. '
|
||||
'For example: https://keystone.example.com/v3/'
|
||||
'OS-FEDERATION/saml2/sso'),
|
||||
cfg.StrOpt('idp_lang', default='en',
|
||||
help='Language used by the organization.'),
|
||||
cfg.StrOpt('idp_organization_name',
|
||||
help='Organization name the installation belongs to.'),
|
||||
cfg.StrOpt('idp_organization_display_name',
|
||||
help='Organization name to be displayed.'),
|
||||
cfg.StrOpt('idp_organization_url',
|
||||
help='URL of the organization.'),
|
||||
cfg.StrOpt('idp_contact_company',
|
||||
help='Company of contact person.'),
|
||||
cfg.StrOpt('idp_contact_name',
|
||||
help='Given name of contact person'),
|
||||
cfg.StrOpt('idp_contact_surname',
|
||||
help='Surname of contact person.'),
|
||||
cfg.StrOpt('idp_contact_email',
|
||||
help='Email address of contact person.'),
|
||||
cfg.StrOpt('idp_contact_telephone',
|
||||
help='Telephone number of contact person.'),
|
||||
cfg.StrOpt('idp_contact_type', default='other',
|
||||
help='Contact type. Allowed values are: '
|
||||
'technical, support, administrative '
|
||||
'billing, and other'),
|
||||
],
|
||||
}
|
||||
|
||||
|
|
|
@ -16,13 +16,15 @@ import subprocess
|
|||
import uuid
|
||||
|
||||
import saml2
|
||||
from saml2 import md
|
||||
from saml2 import saml
|
||||
from saml2 import samlp
|
||||
from saml2 import sigver
|
||||
import xmldsig
|
||||
|
||||
from keystone.common import config
|
||||
from keystone import exception
|
||||
from keystone.i18n import _LE
|
||||
from keystone.i18n import _, _LE
|
||||
from keystone.openstack.common import fileutils
|
||||
from keystone.openstack.common import log
|
||||
from keystone.openstack.common import timeutils
|
||||
|
@ -413,3 +415,141 @@ def _sign_assertion(assertion):
|
|||
pass
|
||||
|
||||
return saml2.create_class_from_xml_string(saml.Assertion, stdout)
|
||||
|
||||
|
||||
class MetadataGenerator(object):
|
||||
"""A class for generating SAML IdP Metadata."""
|
||||
|
||||
def generate_metadata(self):
|
||||
"""Generate Identity Provider Metadata.
|
||||
|
||||
Generate and format metadata into XML that can be exposed and
|
||||
consumed by a federated Service Provider.
|
||||
|
||||
:return: XML <EntityDescriptor> object.
|
||||
:raises: keystone.exception.ValidationError: Raises if the required
|
||||
config options aren't set.
|
||||
|
||||
"""
|
||||
self._ensure_required_values_present()
|
||||
entity_descriptor = self._create_entity_descriptor()
|
||||
entity_descriptor.idpsso_descriptor = (
|
||||
self._create_idp_sso_descriptor())
|
||||
return entity_descriptor
|
||||
|
||||
def _create_entity_descriptor(self):
|
||||
ed = md.EntityDescriptor()
|
||||
ed.entity_id = CONF.saml.idp_entity_id
|
||||
return ed
|
||||
|
||||
def _create_idp_sso_descriptor(self):
|
||||
|
||||
def get_cert():
|
||||
try:
|
||||
return sigver.read_cert_from_file(CONF.saml.certfile, 'pem')
|
||||
except (IOError, sigver.CertificateError) as e:
|
||||
msg = _('Cannot open certificate %(cert_file)s. '
|
||||
'Reason: %(reason)s')
|
||||
msg = msg % {'cert_file': CONF.saml.certfile, 'reason': e}
|
||||
LOG.error(msg)
|
||||
raise IOError(msg)
|
||||
|
||||
def key_descriptor():
|
||||
cert = get_cert()
|
||||
return md.KeyDescriptor(
|
||||
key_info=xmldsig.KeyInfo(
|
||||
x509_data=xmldsig.X509Data(
|
||||
x509_certificate=xmldsig.X509Certificate(text=cert)
|
||||
)
|
||||
), use='signing'
|
||||
)
|
||||
|
||||
def single_sign_on_service():
|
||||
idp_sso_endpoint = CONF.saml.idp_sso_endpoint
|
||||
return md.SingleSignOnService(
|
||||
binding=saml2.BINDING_URI,
|
||||
location=idp_sso_endpoint)
|
||||
|
||||
def organization():
|
||||
name = md.OrganizationName(lang=CONF.saml.idp_lang,
|
||||
text=CONF.saml.idp_organization_name)
|
||||
display_name = md.OrganizationDisplayName(
|
||||
lang=CONF.saml.idp_lang,
|
||||
text=CONF.saml.idp_organization_display_name)
|
||||
url = md.OrganizationURL(lang=CONF.saml.idp_lang,
|
||||
text=CONF.saml.idp_organization_url)
|
||||
|
||||
return md.Organization(
|
||||
organization_display_name=display_name,
|
||||
organization_url=url, organization_name=name)
|
||||
|
||||
def contact_person():
|
||||
company = md.Company(text=CONF.saml.idp_contact_company)
|
||||
given_name = md.GivenName(text=CONF.saml.idp_contact_name)
|
||||
surname = md.SurName(text=CONF.saml.idp_contact_surname)
|
||||
email = md.EmailAddress(text=CONF.saml.idp_contact_email)
|
||||
telephone = md.TelephoneNumber(
|
||||
text=CONF.saml.idp_contact_telephone)
|
||||
contact_type = CONF.saml.idp_contact_type
|
||||
|
||||
return md.ContactPerson(
|
||||
company=company, given_name=given_name, sur_name=surname,
|
||||
email_address=email, telephone_number=telephone,
|
||||
contact_type=contact_type)
|
||||
|
||||
def name_id_format():
|
||||
return md.NameIDFormat(text=saml.NAMEID_FORMAT_TRANSIENT)
|
||||
|
||||
idpsso = md.IDPSSODescriptor()
|
||||
idpsso.protocol_support_enumeration = samlp.NAMESPACE
|
||||
idpsso.key_descriptor = key_descriptor()
|
||||
idpsso.single_sign_on_service = single_sign_on_service()
|
||||
idpsso.name_id_format = name_id_format()
|
||||
if self._check_organization_values():
|
||||
idpsso.organization = organization()
|
||||
if self._check_contact_person_values():
|
||||
idpsso.contact_person = contact_person()
|
||||
return idpsso
|
||||
|
||||
def _ensure_required_values_present(self):
|
||||
"""Ensure idp_sso_endpoint and idp_entity_id have values."""
|
||||
|
||||
if CONF.saml.idp_entity_id is None:
|
||||
msg = _('Ensure configuration option idp_entity_id is set.')
|
||||
raise exception.ValidationError(msg)
|
||||
if CONF.saml.idp_sso_endpoint is None:
|
||||
msg = _('Ensure configuration option idp_sso_endpoint is set.')
|
||||
raise exception.ValidationError(msg)
|
||||
|
||||
def _check_contact_person_values(self):
|
||||
"""Determine if contact information is included in metadata."""
|
||||
|
||||
# Check if we should include contact information
|
||||
params = [CONF.saml.idp_contact_company,
|
||||
CONF.saml.idp_contact_name,
|
||||
CONF.saml.idp_contact_surname,
|
||||
CONF.saml.idp_contact_email,
|
||||
CONF.saml.idp_contact_telephone]
|
||||
for value in params:
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
# Check if contact type is an invalid value
|
||||
valid_type_values = ['technical', 'other', 'support', 'administrative',
|
||||
'billing']
|
||||
if CONF.saml.idp_contact_type not in valid_type_values:
|
||||
msg = _('idp_contact_type must be one of: [technical, other, '
|
||||
'support, administrative or billing.')
|
||||
raise exception.ValidationError(msg)
|
||||
return True
|
||||
|
||||
def _check_organization_values(self):
|
||||
"""Determine if organization information is included in metadata."""
|
||||
|
||||
params = [CONF.saml.idp_organization_name,
|
||||
CONF.saml.idp_organization_display_name,
|
||||
CONF.saml.idp_organization_url]
|
||||
for value in params:
|
||||
if value is None:
|
||||
return False
|
||||
return True
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
IDP_ENTITY_ID = 'https://localhost/v3/OS-FEDERATION/saml2/idp'
|
||||
IDP_SSO_ENDPOINT = 'https://localhost/v3/OS-FEDERATION/saml2/SSO'
|
||||
|
||||
# Organization info
|
||||
IDP_ORGANIZATION_NAME = 'ACME INC'
|
||||
IDP_ORGANIZATION_DISPLAY_NAME = 'ACME'
|
||||
IDP_ORGANIZATION_URL = 'https://acme.example.com'
|
||||
|
||||
# Contact info
|
||||
IDP_CONTACT_COMPANY = 'ACME Sub'
|
||||
IDP_CONTACT_GIVEN_NAME = 'Joe'
|
||||
IDP_CONTACT_SURNAME = 'Hacker'
|
||||
IDP_CONTACT_EMAIL = 'joe@acme.example.com'
|
||||
IDP_CONTACT_TELEPHONE_NUMBER = '1234567890'
|
||||
IDP_CONTACT_TYPE = 'technical'
|
|
@ -34,6 +34,7 @@ from keystone import exception
|
|||
from keystone import notifications
|
||||
from keystone.openstack.common import jsonutils
|
||||
from keystone.openstack.common import log
|
||||
from keystone.tests import federation_fixtures
|
||||
from keystone.tests import mapping_fixtures
|
||||
from keystone.tests import test_v3
|
||||
|
||||
|
@ -1895,3 +1896,118 @@ class SAMLGenerationTests(FederationTests):
|
|||
token_id = uuid.uuid4().hex
|
||||
body = self._create_generate_saml_request(token_id, region_id)
|
||||
self.post(self.SAML_GENERATION_ROUTE, body=body, expected_status=404)
|
||||
|
||||
|
||||
class IdPMetadataGenerationTests(FederationTests):
|
||||
"""A class for testing Identity Provider Metadata generation."""
|
||||
|
||||
def setUp(self):
|
||||
super(IdPMetadataGenerationTests, self).setUp()
|
||||
self.generator = keystone_idp.MetadataGenerator()
|
||||
|
||||
def config_overrides(self):
|
||||
super(IdPMetadataGenerationTests, self).config_overrides()
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
idp_entity_id=federation_fixtures.IDP_ENTITY_ID,
|
||||
idp_sso_endpoint=federation_fixtures.IDP_SSO_ENDPOINT,
|
||||
idp_organization_name=federation_fixtures.IDP_ORGANIZATION_NAME,
|
||||
idp_organization_display_name=(
|
||||
federation_fixtures.IDP_ORGANIZATION_DISPLAY_NAME),
|
||||
idp_organization_url=federation_fixtures.IDP_ORGANIZATION_URL,
|
||||
idp_contact_company=federation_fixtures.IDP_CONTACT_COMPANY,
|
||||
idp_contact_name=federation_fixtures.IDP_CONTACT_GIVEN_NAME,
|
||||
idp_contact_surname=federation_fixtures.IDP_CONTACT_SURNAME,
|
||||
idp_contact_email=federation_fixtures.IDP_CONTACT_EMAIL,
|
||||
idp_contact_telephone=(
|
||||
federation_fixtures.IDP_CONTACT_TELEPHONE_NUMBER),
|
||||
idp_contact_type=federation_fixtures.IDP_CONTACT_TYPE)
|
||||
|
||||
def test_check_entity_id(self):
|
||||
metadata = self.generator.generate_metadata()
|
||||
self.assertEqual(federation_fixtures.IDP_ENTITY_ID, metadata.entity_id)
|
||||
|
||||
def test_metadata_validity(self):
|
||||
"""Call md.EntityDescriptor method that does internal verification."""
|
||||
self.generator.generate_metadata().verify()
|
||||
|
||||
def test_serialize_metadata_object(self):
|
||||
"""Check whether serialization doesn't raise any exceptions."""
|
||||
self.generator.generate_metadata().to_string()
|
||||
# TODO(marek-denis): Check values here
|
||||
|
||||
def test_check_idp_sso(self):
|
||||
metadata = self.generator.generate_metadata()
|
||||
idpsso_descriptor = metadata.idpsso_descriptor
|
||||
self.assertIsNotNone(metadata.idpsso_descriptor)
|
||||
self.assertEqual(federation_fixtures.IDP_SSO_ENDPOINT,
|
||||
idpsso_descriptor.single_sign_on_service.location)
|
||||
|
||||
self.assertIsNotNone(idpsso_descriptor.organization)
|
||||
organization = idpsso_descriptor.organization
|
||||
self.assertEqual(federation_fixtures.IDP_ORGANIZATION_DISPLAY_NAME,
|
||||
organization.organization_display_name.text)
|
||||
self.assertEqual(federation_fixtures.IDP_ORGANIZATION_NAME,
|
||||
organization.organization_name.text)
|
||||
self.assertEqual(federation_fixtures.IDP_ORGANIZATION_URL,
|
||||
organization.organization_url.text)
|
||||
|
||||
self.assertIsNotNone(idpsso_descriptor.contact_person)
|
||||
contact_person = idpsso_descriptor.contact_person
|
||||
|
||||
self.assertEqual(federation_fixtures.IDP_CONTACT_GIVEN_NAME,
|
||||
contact_person.given_name.text)
|
||||
self.assertEqual(federation_fixtures.IDP_CONTACT_SURNAME,
|
||||
contact_person.sur_name.text)
|
||||
self.assertEqual(federation_fixtures.IDP_CONTACT_EMAIL,
|
||||
contact_person.email_address.text)
|
||||
self.assertEqual(federation_fixtures.IDP_CONTACT_TELEPHONE_NUMBER,
|
||||
contact_person.telephone_number.text)
|
||||
self.assertEqual(federation_fixtures.IDP_CONTACT_TYPE,
|
||||
contact_person.contact_type)
|
||||
|
||||
def test_metadata_no_organization(self):
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
idp_organization_display_name=None,
|
||||
idp_organization_url=None,
|
||||
idp_organization_name=None)
|
||||
metadata = self.generator.generate_metadata()
|
||||
idpsso_descriptor = metadata.idpsso_descriptor
|
||||
self.assertIsNotNone(metadata.idpsso_descriptor)
|
||||
self.assertIsNone(idpsso_descriptor.organization)
|
||||
self.assertIsNotNone(idpsso_descriptor.contact_person)
|
||||
|
||||
def test_metadata_no_contact_person(self):
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
idp_contact_name=None,
|
||||
idp_contact_surname=None,
|
||||
idp_contact_email=None,
|
||||
idp_contact_telephone=None)
|
||||
metadata = self.generator.generate_metadata()
|
||||
idpsso_descriptor = metadata.idpsso_descriptor
|
||||
self.assertIsNotNone(metadata.idpsso_descriptor)
|
||||
self.assertIsNotNone(idpsso_descriptor.organization)
|
||||
self.assertEqual([], idpsso_descriptor.contact_person)
|
||||
|
||||
def test_metadata_invalid_contact_type(self):
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
idp_contact_type="invalid")
|
||||
self.assertRaises(exception.ValidationError,
|
||||
self.generator.generate_metadata)
|
||||
|
||||
def test_metadata_invalid_idp_sso_endpoint(self):
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
idp_sso_endpoint=None)
|
||||
self.assertRaises(exception.ValidationError,
|
||||
self.generator.generate_metadata)
|
||||
|
||||
def test_metadata_invalid_idp_entity_id(self):
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
idp_entity_id=None)
|
||||
self.assertRaises(exception.ValidationError,
|
||||
self.generator.generate_metadata)
|
||||
|
|
Loading…
Reference in New Issue