diff options
Diffstat (limited to 'keystone-moon/keystone/federation/controllers.py')
-rw-r--r-- | keystone-moon/keystone/federation/controllers.py | 519 |
1 files changed, 519 insertions, 0 deletions
diff --git a/keystone-moon/keystone/federation/controllers.py b/keystone-moon/keystone/federation/controllers.py new file mode 100644 index 00000000..b9e2d883 --- /dev/null +++ b/keystone-moon/keystone/federation/controllers.py @@ -0,0 +1,519 @@ +# 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. + +"""Workflow logic for the Federation service.""" + +import string + +from oslo_config import cfg +from oslo_log import log +import six +from six.moves import urllib +import webob + +from keystone.auth import controllers as auth_controllers +from keystone.common import authorization +from keystone.common import controller +from keystone.common import dependency +from keystone.common import utils as k_utils +from keystone.common import validation +from keystone.common import wsgi +from keystone import exception +from keystone.federation import idp as keystone_idp +from keystone.federation import schema +from keystone.federation import utils +from keystone.i18n import _ +from keystone.models import token_model + + +CONF = cfg.CONF +LOG = log.getLogger(__name__) + + +class _ControllerBase(controller.V3Controller): + """Base behaviors for federation controllers.""" + + @classmethod + def base_url(cls, context, path=None): + """Construct a path and pass it to V3Controller.base_url method.""" + path = '/OS-FEDERATION/' + cls.collection_name + return super(_ControllerBase, cls).base_url(context, path=path) + + +@dependency.requires('federation_api') +class IdentityProvider(_ControllerBase): + """Identity Provider representation.""" + + collection_name = 'identity_providers' + member_name = 'identity_provider' + + _public_parameters = frozenset(['id', 'enabled', 'description', + 'remote_ids', 'links' + ]) + + @classmethod + def _add_related_links(cls, context, ref): + """Add URLs for entities related with Identity Provider. + + Add URLs pointing to: + - protocols tied to the Identity Provider + + """ + ref.setdefault('links', {}) + base_path = ref['links'].get('self') + if base_path is None: + base_path = '/'.join([IdentityProvider.base_url(context), + ref['id']]) + for name in ['protocols']: + ref['links'][name] = '/'.join([base_path, name]) + + @classmethod + def _add_self_referential_link(cls, context, ref): + id = ref['id'] + self_path = '/'.join([cls.base_url(context), id]) + ref.setdefault('links', {}) + ref['links']['self'] = self_path + + @classmethod + def wrap_member(cls, context, ref): + cls._add_self_referential_link(context, ref) + cls._add_related_links(context, ref) + ref = cls.filter_params(ref) + return {cls.member_name: ref} + + @controller.protected() + @validation.validated(schema.identity_provider_create, 'identity_provider') + def create_identity_provider(self, context, idp_id, identity_provider): + identity_provider = self._normalize_dict(identity_provider) + identity_provider.setdefault('enabled', False) + idp_ref = self.federation_api.create_idp(idp_id, identity_provider) + response = IdentityProvider.wrap_member(context, idp_ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.filterprotected('id', 'enabled') + def list_identity_providers(self, context, filters): + hints = self.build_driver_hints(context, filters) + ref = self.federation_api.list_idps(hints=hints) + ref = [self.filter_params(x) for x in ref] + return IdentityProvider.wrap_collection(context, ref, hints=hints) + + @controller.protected() + def get_identity_provider(self, context, idp_id): + ref = self.federation_api.get_idp(idp_id) + return IdentityProvider.wrap_member(context, ref) + + @controller.protected() + def delete_identity_provider(self, context, idp_id): + self.federation_api.delete_idp(idp_id) + + @controller.protected() + @validation.validated(schema.identity_provider_update, 'identity_provider') + def update_identity_provider(self, context, idp_id, identity_provider): + identity_provider = self._normalize_dict(identity_provider) + idp_ref = self.federation_api.update_idp(idp_id, identity_provider) + return IdentityProvider.wrap_member(context, idp_ref) + + +@dependency.requires('federation_api') +class FederationProtocol(_ControllerBase): + """A federation protocol representation. + + See keystone.common.controller.V3Controller docstring for explanation + on _public_parameters class attributes. + + """ + + collection_name = 'protocols' + member_name = 'protocol' + + _public_parameters = frozenset(['id', 'mapping_id', 'links']) + + @classmethod + def _add_self_referential_link(cls, context, ref): + """Add 'links' entry to the response dictionary. + + Calls IdentityProvider.base_url() class method, as it constructs + proper URL along with the 'identity providers' part included. + + :param ref: response dictionary + + """ + ref.setdefault('links', {}) + base_path = ref['links'].get('identity_provider') + if base_path is None: + base_path = [IdentityProvider.base_url(context), ref['idp_id']] + base_path = '/'.join(base_path) + self_path = [base_path, 'protocols', ref['id']] + self_path = '/'.join(self_path) + ref['links']['self'] = self_path + + @classmethod + def _add_related_links(cls, context, ref): + """Add new entries to the 'links' subdictionary in the response. + + Adds 'identity_provider' key with URL pointing to related identity + provider as a value. + + :param ref: response dictionary + + """ + ref.setdefault('links', {}) + base_path = '/'.join([IdentityProvider.base_url(context), + ref['idp_id']]) + ref['links']['identity_provider'] = base_path + + @classmethod + def wrap_member(cls, context, ref): + cls._add_related_links(context, ref) + cls._add_self_referential_link(context, ref) + ref = cls.filter_params(ref) + return {cls.member_name: ref} + + @controller.protected() + @validation.validated(schema.federation_protocol_schema, 'protocol') + def create_protocol(self, context, idp_id, protocol_id, protocol): + ref = self._normalize_dict(protocol) + ref = self.federation_api.create_protocol(idp_id, protocol_id, ref) + response = FederationProtocol.wrap_member(context, ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.protected() + @validation.validated(schema.federation_protocol_schema, 'protocol') + def update_protocol(self, context, idp_id, protocol_id, protocol): + ref = self._normalize_dict(protocol) + ref = self.federation_api.update_protocol(idp_id, protocol_id, + protocol) + return FederationProtocol.wrap_member(context, ref) + + @controller.protected() + def get_protocol(self, context, idp_id, protocol_id): + ref = self.federation_api.get_protocol(idp_id, protocol_id) + return FederationProtocol.wrap_member(context, ref) + + @controller.protected() + def list_protocols(self, context, idp_id): + protocols_ref = self.federation_api.list_protocols(idp_id) + protocols = list(protocols_ref) + return FederationProtocol.wrap_collection(context, protocols) + + @controller.protected() + def delete_protocol(self, context, idp_id, protocol_id): + self.federation_api.delete_protocol(idp_id, protocol_id) + + +@dependency.requires('federation_api') +class MappingController(_ControllerBase): + collection_name = 'mappings' + member_name = 'mapping' + + @controller.protected() + def create_mapping(self, context, mapping_id, mapping): + ref = self._normalize_dict(mapping) + utils.validate_mapping_structure(ref) + mapping_ref = self.federation_api.create_mapping(mapping_id, ref) + response = MappingController.wrap_member(context, mapping_ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.protected() + def list_mappings(self, context): + ref = self.federation_api.list_mappings() + return MappingController.wrap_collection(context, ref) + + @controller.protected() + def get_mapping(self, context, mapping_id): + ref = self.federation_api.get_mapping(mapping_id) + return MappingController.wrap_member(context, ref) + + @controller.protected() + def delete_mapping(self, context, mapping_id): + self.federation_api.delete_mapping(mapping_id) + + @controller.protected() + def update_mapping(self, context, mapping_id, mapping): + mapping = self._normalize_dict(mapping) + utils.validate_mapping_structure(mapping) + mapping_ref = self.federation_api.update_mapping(mapping_id, mapping) + return MappingController.wrap_member(context, mapping_ref) + + +@dependency.requires('federation_api') +class Auth(auth_controllers.Auth): + + def _get_sso_origin_host(self, context): + """Validate and return originating dashboard URL. + + Make sure the parameter is specified in the request's URL as well its + value belongs to a list of trusted dashboards. + + :param context: request's context + :raises keystone.exception.ValidationError: ``origin`` query parameter + was not specified. The URL is deemed invalid. + :raises keystone.exception.Unauthorized: URL specified in origin query + parameter does not exist in list of websso trusted dashboards. + :returns: URL with the originating dashboard + + """ + if 'origin' in context['query_string']: + origin = context['query_string']['origin'] + host = urllib.parse.unquote_plus(origin) + else: + msg = _('Request must have an origin query parameter') + LOG.error(msg) + raise exception.ValidationError(msg) + + # change trusted_dashboard hostnames to lowercase before comparison + trusted_dashboards = [k_utils.lower_case_hostname(trusted) + for trusted in CONF.federation.trusted_dashboard] + + if host not in trusted_dashboards: + msg = _('%(host)s is not a trusted dashboard host') + msg = msg % {'host': host} + LOG.error(msg) + raise exception.Unauthorized(msg) + + return host + + def federated_authentication(self, context, idp_id, protocol_id): + """Authenticate from dedicated url endpoint. + + Build HTTP request body for federated authentication and inject + it into the ``authenticate_for_token`` function. + + """ + auth = { + 'identity': { + 'methods': [protocol_id], + protocol_id: { + 'identity_provider': idp_id, + 'protocol': protocol_id + } + } + } + + return self.authenticate_for_token(context, auth=auth) + + def federated_sso_auth(self, context, protocol_id): + try: + remote_id_name = utils.get_remote_id_parameter(protocol_id) + remote_id = context['environment'][remote_id_name] + except KeyError: + msg = _('Missing entity ID from environment') + LOG.error(msg) + raise exception.Unauthorized(msg) + + host = self._get_sso_origin_host(context) + + ref = self.federation_api.get_idp_from_remote_id(remote_id) + # NOTE(stevemar): the returned object is a simple dict that + # contains the idp_id and remote_id. + identity_provider = ref['idp_id'] + res = self.federated_authentication(context, identity_provider, + protocol_id) + token_id = res.headers['X-Subject-Token'] + return self.render_html_response(host, token_id) + + def federated_idp_specific_sso_auth(self, context, idp_id, protocol_id): + host = self._get_sso_origin_host(context) + + # NOTE(lbragstad): We validate that the Identity Provider actually + # exists in the Mapped authentication plugin. + res = self.federated_authentication(context, idp_id, protocol_id) + token_id = res.headers['X-Subject-Token'] + return self.render_html_response(host, token_id) + + def render_html_response(self, host, token_id): + """Forms an HTML Form from a template with autosubmit.""" + headers = [('Content-Type', 'text/html')] + + with open(CONF.federation.sso_callback_template) as template: + src = string.Template(template.read()) + + subs = {'host': host, 'token': token_id} + body = src.substitute(subs) + return webob.Response(body=body, status='200', + headerlist=headers) + + def _create_base_saml_assertion(self, context, auth): + issuer = CONF.saml.idp_entity_id + sp_id = auth['scope']['service_provider']['id'] + service_provider = self.federation_api.get_sp(sp_id) + utils.assert_enabled_service_provider_object(service_provider) + sp_url = service_provider['sp_url'] + + token_id = auth['identity']['token']['id'] + token_data = self.token_provider_api.validate_token(token_id) + token_ref = token_model.KeystoneToken(token_id, token_data) + + if not token_ref.project_scoped: + action = _('Use a project scoped token when attempting to create ' + 'a SAML assertion') + raise exception.ForbiddenAction(action=action) + + subject = token_ref.user_name + roles = token_ref.role_names + project = token_ref.project_name + # NOTE(rodrigods): the domain name is necessary in order to distinguish + # between projects and users with the same name in different domains. + project_domain_name = token_ref.project_domain_name + subject_domain_name = token_ref.user_domain_name + + generator = keystone_idp.SAMLGenerator() + response = generator.samlize_token( + issuer, sp_url, subject, subject_domain_name, + roles, project, project_domain_name) + return (response, service_provider) + + def _build_response_headers(self, service_provider): + return [('Content-Type', 'text/xml'), + ('X-sp-url', six.binary_type(service_provider['sp_url'])), + ('X-auth-url', six.binary_type(service_provider['auth_url']))] + + @validation.validated(schema.saml_create, 'auth') + def create_saml_assertion(self, context, auth): + """Exchange a scoped token for a SAML assertion. + + :param auth: Dictionary that contains a token and service provider ID + :returns: SAML Assertion based on properties from the token + """ + t = self._create_base_saml_assertion(context, auth) + (response, service_provider) = t + + headers = self._build_response_headers(service_provider) + return wsgi.render_response(body=response.to_string(), + status=('200', 'OK'), + headers=headers) + + @validation.validated(schema.saml_create, 'auth') + def create_ecp_assertion(self, context, auth): + """Exchange a scoped token for an ECP assertion. + + :param auth: Dictionary that contains a token and service provider ID + :returns: ECP Assertion based on properties from the token + """ + t = self._create_base_saml_assertion(context, auth) + (saml_assertion, service_provider) = t + relay_state_prefix = service_provider['relay_state_prefix'] + + generator = keystone_idp.ECPGenerator() + ecp_assertion = generator.generate_ecp(saml_assertion, + relay_state_prefix) + + headers = self._build_response_headers(service_provider) + return wsgi.render_response(body=ecp_assertion.to_string(), + status=('200', 'OK'), + headers=headers) + + +@dependency.requires('assignment_api', 'resource_api') +class DomainV3(controller.V3Controller): + collection_name = 'domains' + member_name = 'domain' + + def __init__(self): + super(DomainV3, self).__init__() + self.get_member_from_driver = self.resource_api.get_domain + + @controller.protected() + def list_domains_for_groups(self, context): + """List all domains available to an authenticated user's groups. + + :param context: request context + :returns: list of accessible domains + + """ + auth_context = context['environment'][authorization.AUTH_CONTEXT_ENV] + domains = self.assignment_api.list_domains_for_groups( + auth_context['group_ids']) + return DomainV3.wrap_collection(context, domains) + + +@dependency.requires('assignment_api', 'resource_api') +class ProjectAssignmentV3(controller.V3Controller): + collection_name = 'projects' + member_name = 'project' + + def __init__(self): + super(ProjectAssignmentV3, self).__init__() + self.get_member_from_driver = self.resource_api.get_project + + @controller.protected() + def list_projects_for_groups(self, context): + """List all projects available to an authenticated user's groups. + + :param context: request context + :returns: list of accessible projects + + """ + auth_context = context['environment'][authorization.AUTH_CONTEXT_ENV] + projects = self.assignment_api.list_projects_for_groups( + auth_context['group_ids']) + return ProjectAssignmentV3.wrap_collection(context, projects) + + +@dependency.requires('federation_api') +class ServiceProvider(_ControllerBase): + """Service Provider representation.""" + + collection_name = 'service_providers' + member_name = 'service_provider' + + _public_parameters = frozenset(['auth_url', 'id', 'enabled', 'description', + 'links', 'relay_state_prefix', 'sp_url']) + + @controller.protected() + @validation.validated(schema.service_provider_create, 'service_provider') + def create_service_provider(self, context, sp_id, service_provider): + service_provider = self._normalize_dict(service_provider) + service_provider.setdefault('enabled', False) + service_provider.setdefault('relay_state_prefix', + CONF.saml.relay_state_prefix) + sp_ref = self.federation_api.create_sp(sp_id, service_provider) + response = ServiceProvider.wrap_member(context, sp_ref) + return wsgi.render_response(body=response, status=('201', 'Created')) + + @controller.filterprotected('id', 'enabled') + def list_service_providers(self, context, filters): + hints = self.build_driver_hints(context, filters) + ref = self.federation_api.list_sps(hints=hints) + ref = [self.filter_params(x) for x in ref] + return ServiceProvider.wrap_collection(context, ref, hints=hints) + + @controller.protected() + def get_service_provider(self, context, sp_id): + ref = self.federation_api.get_sp(sp_id) + return ServiceProvider.wrap_member(context, ref) + + @controller.protected() + def delete_service_provider(self, context, sp_id): + self.federation_api.delete_sp(sp_id) + + @controller.protected() + @validation.validated(schema.service_provider_update, 'service_provider') + def update_service_provider(self, context, sp_id, service_provider): + service_provider = self._normalize_dict(service_provider) + sp_ref = self.federation_api.update_sp(sp_id, service_provider) + return ServiceProvider.wrap_member(context, sp_ref) + + +class SAMLMetadataV3(_ControllerBase): + member_name = 'metadata' + + def get_metadata(self, context): + metadata_path = CONF.saml.idp_metadata_path + try: + with open(metadata_path, 'r') as metadata_handler: + metadata = metadata_handler.read() + except IOError as e: + # Raise HTTP 500 in case Metadata file cannot be read. + raise exception.MetadataFileError(reason=e) + return wsgi.render_response(body=metadata, status=('200', 'OK'), + headers=[('Content-Type', 'text/xml')]) |