049d9bcbe4
This change adds application credential access rules to the token model and ensures that only clients (that is, keystonemiddleware) that support access rule enforcement are allowed to validate tokens containing access rules. Depends-on: https://review.openstack.org/633369 bp whitelist-extension-for-app-creds Change-Id: I301651369cf03e06550bc29eb534506674e56a1f
557 lines
19 KiB
Python
557 lines
19 KiB
Python
# 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.
|
|
|
|
# This file handles all flask-restful resources for /v3/auth
|
|
import string
|
|
|
|
import flask
|
|
import flask_restful
|
|
from oslo_log import log
|
|
from oslo_serialization import jsonutils
|
|
from oslo_utils import strutils
|
|
from six.moves import http_client
|
|
from six.moves import urllib
|
|
import werkzeug.exceptions
|
|
|
|
from keystone.api._shared import authentication
|
|
from keystone.api._shared import json_home_relations
|
|
from keystone.api._shared import saml
|
|
from keystone.auth import schema as auth_schema
|
|
from keystone.common import authorization
|
|
from keystone.common import provider_api
|
|
from keystone.common import rbac_enforcer
|
|
from keystone.common import render_token
|
|
from keystone.common import utils as k_utils
|
|
from keystone.common import validation
|
|
import keystone.conf
|
|
from keystone import exception
|
|
from keystone.federation import idp as keystone_idp
|
|
from keystone.federation import schema as federation_schema
|
|
from keystone.federation import utils as federation_utils
|
|
from keystone.i18n import _
|
|
from keystone.server import flask as ks_flask
|
|
|
|
|
|
CONF = keystone.conf.CONF
|
|
ENFORCER = rbac_enforcer.RBACEnforcer
|
|
LOG = log.getLogger(__name__)
|
|
PROVIDERS = provider_api.ProviderAPIs
|
|
|
|
|
|
def _combine_lists_uniquely(a, b):
|
|
# it's most likely that only one of these will be filled so avoid
|
|
# the combination if possible.
|
|
if a and b:
|
|
return {x['id']: x for x in a + b}.values()
|
|
else:
|
|
return a or b
|
|
|
|
|
|
def _build_response_headers(service_provider):
|
|
# URLs in header are encoded into bytes
|
|
return [('Content-Type', 'text/xml'),
|
|
('X-sp-url', service_provider['sp_url'].encode('utf-8')),
|
|
('X-auth-url', service_provider['auth_url'].encode('utf-8'))]
|
|
|
|
|
|
def _get_sso_origin_host():
|
|
"""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.
|
|
|
|
: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
|
|
|
|
"""
|
|
origin = flask.request.args.get('origin')
|
|
|
|
if not origin:
|
|
msg = 'Request must have an origin query parameter'
|
|
tr_msg = _('Request must have an origin query parameter')
|
|
LOG.error(msg)
|
|
raise exception.ValidationError(tr_msg)
|
|
|
|
host = urllib.parse.unquote_plus(origin)
|
|
|
|
# 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' % {'host': host}
|
|
tr_msg = _('%(host)s is not a trusted dashboard host') % {
|
|
'host': host}
|
|
LOG.error(msg)
|
|
raise exception.Unauthorized(tr_msg)
|
|
|
|
return host
|
|
|
|
|
|
class _AuthFederationWebSSOBase(ks_flask.ResourceBase):
|
|
@staticmethod
|
|
def _render_template_response(host, token_id):
|
|
with open(CONF.federation.sso_callback_template) as template:
|
|
src = string.Template(template.read())
|
|
subs = {'host': host, 'token': token_id}
|
|
body = src.substitute(subs)
|
|
resp = flask.make_response(body, http_client.OK)
|
|
resp.charset = 'utf-8'
|
|
resp.headers['Content-Type'] = 'text/html'
|
|
return resp
|
|
|
|
|
|
class AuthProjectsResource(ks_flask.ResourceBase):
|
|
collection_key = 'projects'
|
|
member_key = 'project'
|
|
|
|
def get(self):
|
|
"""Get possible project scopes for token.
|
|
|
|
GET/HEAD /v3/auth/projects
|
|
GET/HEAD /v3/OS-FEDERATION/projects
|
|
"""
|
|
ENFORCER.enforce_call(action='identity:get_auth_projects')
|
|
user_id = self.auth_context.get('user_id')
|
|
group_ids = self.auth_context.get('group_ids')
|
|
|
|
user_p_refs = []
|
|
grp_p_refs = []
|
|
|
|
if user_id:
|
|
try:
|
|
user_p_refs = PROVIDERS.assignment_api.list_projects_for_user(
|
|
user_id)
|
|
except exception.UserNotFound: # nosec
|
|
# federated users have an id but they don't link to anything
|
|
pass
|
|
|
|
if group_ids:
|
|
grp_p_refs = PROVIDERS.assignment_api.list_projects_for_groups(
|
|
group_ids)
|
|
refs = _combine_lists_uniquely(user_p_refs, grp_p_refs)
|
|
return self.wrap_collection(refs)
|
|
|
|
|
|
class AuthDomainsResource(ks_flask.ResourceBase):
|
|
collection_key = 'domains'
|
|
member_key = 'domain'
|
|
|
|
def get(self):
|
|
"""Get possible domain scopes for token.
|
|
|
|
GET/HEAD /v3/auth/domains
|
|
GET/HEAD /v3/OS-FEDERATION/domains
|
|
"""
|
|
ENFORCER.enforce_call(action='identity:get_auth_domains')
|
|
user_id = self.auth_context.get('user_id')
|
|
group_ids = self.auth_context.get('group_ids')
|
|
|
|
user_d_refs = []
|
|
grp_d_refs = []
|
|
|
|
if user_id:
|
|
try:
|
|
user_d_refs = PROVIDERS.assignment_api.list_domains_for_user(
|
|
user_id)
|
|
except exception.UserNotFound: # nosec
|
|
# federated users have an id but they don't link to anything
|
|
pass
|
|
|
|
if group_ids:
|
|
grp_d_refs = PROVIDERS.assignment_api.list_domains_for_groups(
|
|
group_ids)
|
|
|
|
refs = _combine_lists_uniquely(user_d_refs, grp_d_refs)
|
|
return self.wrap_collection(refs)
|
|
|
|
|
|
class AuthSystemResource(_AuthFederationWebSSOBase):
|
|
def get(self):
|
|
"""Get possible system scopes for token.
|
|
|
|
GET/HEAD /v3/auth/system
|
|
"""
|
|
ENFORCER.enforce_call(action='identity:get_auth_system')
|
|
user_id = self.auth_context.get('user_id')
|
|
group_ids = self.auth_context.get('group_ids')
|
|
|
|
user_assignments = []
|
|
group_assignments = []
|
|
|
|
if user_id:
|
|
try:
|
|
user_assignments = (
|
|
PROVIDERS.assignment_api.list_system_grants_for_user(
|
|
user_id)
|
|
)
|
|
except exception.UserNotFound: # nosec
|
|
# federated users have an id but they don't link to anything
|
|
pass
|
|
|
|
if group_ids:
|
|
group_assignments = (
|
|
PROVIDERS.assignment_api.list_system_grants_for_groups(
|
|
group_ids)
|
|
)
|
|
|
|
assignments = _combine_lists_uniquely(
|
|
user_assignments, group_assignments)
|
|
|
|
if assignments:
|
|
response = {
|
|
'system': [{'all': True}],
|
|
'links': {
|
|
'self': ks_flask.base_url(path='auth/system')
|
|
}
|
|
}
|
|
else:
|
|
response = {
|
|
'system': [],
|
|
'links': {
|
|
'self': ks_flask.base_url(path='auth/system')
|
|
}
|
|
}
|
|
return response
|
|
|
|
|
|
class AuthCatalogResource(_AuthFederationWebSSOBase):
|
|
def get(self):
|
|
"""Get service catalog for token.
|
|
|
|
GET/HEAD /v3/auth/catalog
|
|
"""
|
|
ENFORCER.enforce_call(action='identity:get_auth_catalog')
|
|
user_id = self.auth_context.get('user_id')
|
|
project_id = self.auth_context.get('project_id')
|
|
|
|
if not project_id:
|
|
raise exception.Forbidden(
|
|
_('A project-scoped token is required to produce a '
|
|
'service catalog.'))
|
|
|
|
return {
|
|
'catalog': PROVIDERS.catalog_api.get_v3_catalog(
|
|
user_id, project_id
|
|
),
|
|
'links': {
|
|
'self': ks_flask.base_url(path='auth/catalog')
|
|
}
|
|
}
|
|
|
|
|
|
class AuthTokenOSPKIResource(flask_restful.Resource):
|
|
@ks_flask.unenforced_api
|
|
def get(self):
|
|
"""Deprecated; get revoked token list.
|
|
|
|
GET/HEAD /v3/auth/tokens/OS-PKI/revoked
|
|
"""
|
|
if not CONF.token.revoke_by_id:
|
|
raise exception.Gone()
|
|
# NOTE(lbragstad): This API is deprecated and isn't supported. Keystone
|
|
# also doesn't store tokens, so returning a list of revoked tokens
|
|
# would require keystone to write invalid tokens to disk, which defeats
|
|
# the purpose. Return a 403 instead of removing the API altogether.
|
|
raise exception.Forbidden()
|
|
|
|
|
|
class AuthTokenResource(_AuthFederationWebSSOBase):
|
|
def get(self):
|
|
"""Validate a token.
|
|
|
|
HEAD/GET /v3/auth/tokens
|
|
"""
|
|
# TODO(morgan): eliminate the check_token action only use validate
|
|
# NOTE(morgan): Well lookie here, we have different enforcements
|
|
# for no good reason (historical), because the methods previouslly
|
|
# had to be named different names. Check which method and do the
|
|
# correct enforcement.
|
|
if flask.request.method == 'HEAD':
|
|
ENFORCER.enforce_call(action='identity:check_token')
|
|
else:
|
|
ENFORCER.enforce_call(action='identity:validate_token')
|
|
|
|
token_id = flask.request.headers.get(
|
|
authorization.SUBJECT_TOKEN_HEADER)
|
|
access_rules_support = flask.request.headers.get(
|
|
authorization.ACCESS_RULES_HEADER)
|
|
allow_expired = strutils.bool_from_string(
|
|
flask.request.args.get('allow_expired'))
|
|
window_secs = CONF.token.allow_expired_window if allow_expired else 0
|
|
include_catalog = 'nocatalog' not in flask.request.args
|
|
token = PROVIDERS.token_provider_api.validate_token(
|
|
token_id, window_seconds=window_secs,
|
|
access_rules_support=access_rules_support)
|
|
token_resp = render_token.render_token_response_from_model(
|
|
token, include_catalog=include_catalog)
|
|
resp_body = jsonutils.dumps(token_resp)
|
|
response = flask.make_response(resp_body, http_client.OK)
|
|
response.headers['X-Subject-Token'] = token_id
|
|
response.headers['Content-Type'] = 'application/json'
|
|
return response
|
|
|
|
@ks_flask.unenforced_api
|
|
def post(self):
|
|
"""Issue a token.
|
|
|
|
POST /v3/auth/tokens
|
|
"""
|
|
include_catalog = 'nocatalog' not in flask.request.args
|
|
auth_data = self.request_body_json.get('auth')
|
|
auth_schema.validate_issue_token_auth(auth_data)
|
|
token = authentication.authenticate_for_token(auth_data)
|
|
resp_data = render_token.render_token_response_from_model(
|
|
token, include_catalog=include_catalog
|
|
)
|
|
resp_body = jsonutils.dumps(resp_data)
|
|
response = flask.make_response(resp_body, http_client.CREATED)
|
|
response.headers['X-Subject-Token'] = token.id
|
|
response.headers['Content-Type'] = 'application/json'
|
|
return response
|
|
|
|
def delete(self):
|
|
"""Revoke a token.
|
|
|
|
DELETE /v3/auth/tokens
|
|
"""
|
|
ENFORCER.enforce_call(action='identity:revoke_token')
|
|
token_id = flask.request.headers.get(
|
|
authorization.SUBJECT_TOKEN_HEADER)
|
|
PROVIDERS.token_provider_api.revoke_token(token_id)
|
|
return None, http_client.NO_CONTENT
|
|
|
|
|
|
class AuthFederationWebSSOResource(_AuthFederationWebSSOBase):
|
|
@classmethod
|
|
def _perform_auth(cls, protocol_id):
|
|
idps = PROVIDERS.federation_api.list_idps()
|
|
for idp in idps:
|
|
try:
|
|
remote_id_name = federation_utils.get_remote_id_parameter(
|
|
idp, protocol_id)
|
|
except exception.FederatedProtocolNotFound:
|
|
# no protocol for this IdP, so this can't be the IdP we're
|
|
# looking for
|
|
continue
|
|
remote_id = flask.request.environ.get(remote_id_name)
|
|
if remote_id:
|
|
break
|
|
if not remote_id:
|
|
msg = 'Missing entity ID from environment'
|
|
tr_msg = _('Missing entity ID from environment')
|
|
LOG.error(msg)
|
|
raise exception.Unauthorized(tr_msg)
|
|
|
|
host = _get_sso_origin_host()
|
|
ref = PROVIDERS.federation_api.get_idp_from_remote_id(remote_id)
|
|
identity_provider = ref['idp_id']
|
|
token = authentication.federated_authenticate_for_token(
|
|
identity_provider=identity_provider, protocol_id=protocol_id)
|
|
return cls._render_template_response(host, token.id)
|
|
|
|
@ks_flask.unenforced_api
|
|
def get(self, protocol_id):
|
|
return self._perform_auth(protocol_id)
|
|
|
|
@ks_flask.unenforced_api
|
|
def post(self, protocol_id):
|
|
return self._perform_auth(protocol_id)
|
|
|
|
|
|
class AuthFederationWebSSOIDPsResource(_AuthFederationWebSSOBase):
|
|
@classmethod
|
|
def _perform_auth(cls, idp_id, protocol_id):
|
|
host = _get_sso_origin_host()
|
|
|
|
token = authentication.federated_authenticate_for_token(
|
|
identity_provider=idp_id, protocol_id=protocol_id)
|
|
return cls._render_template_response(host, token.id)
|
|
|
|
@ks_flask.unenforced_api
|
|
def get(self, idp_id, protocol_id):
|
|
return self._perform_auth(idp_id, protocol_id)
|
|
|
|
@ks_flask.unenforced_api
|
|
def post(self, idp_id, protocol_id):
|
|
return self._perform_auth(idp_id, protocol_id)
|
|
|
|
|
|
class AuthFederationSaml2Resource(_AuthFederationWebSSOBase):
|
|
def get(self):
|
|
raise werkzeug.exceptions.MethodNotAllowed(valid_methods=['POST'])
|
|
|
|
@ks_flask.unenforced_api
|
|
def post(self):
|
|
"""Exchange a scoped token for a SAML assertion.
|
|
|
|
POST /v3/auth/OS-FEDERATION/saml2
|
|
"""
|
|
auth = self.request_body_json.get('auth')
|
|
validation.lazy_validate(federation_schema.saml_create, auth)
|
|
response, service_provider = saml.create_base_saml_assertion(auth)
|
|
headers = _build_response_headers(service_provider)
|
|
response = flask.make_response(response.to_string(), http_client.OK)
|
|
for header, value in headers:
|
|
response.headers[header] = value
|
|
return response
|
|
|
|
|
|
class AuthFederationSaml2ECPResource(_AuthFederationWebSSOBase):
|
|
def get(self):
|
|
raise werkzeug.exceptions.MethodNotAllowed(valid_methods=['POST'])
|
|
|
|
@ks_flask.unenforced_api
|
|
def post(self):
|
|
"""Exchange a scoped token for an ECP assertion.
|
|
|
|
POST /v3/auth/OS-FEDERATION/saml2/ecp
|
|
"""
|
|
auth = self.request_body_json.get('auth')
|
|
validation.lazy_validate(federation_schema.saml_create, auth)
|
|
saml_assertion, service_provider = saml.create_base_saml_assertion(
|
|
auth)
|
|
relay_state_prefix = service_provider['relay_state_prefix']
|
|
|
|
generator = keystone_idp.ECPGenerator()
|
|
ecp_assertion = generator.generate_ecp(
|
|
saml_assertion, relay_state_prefix)
|
|
headers = _build_response_headers(service_provider)
|
|
response = flask.make_response(
|
|
ecp_assertion.to_string(), http_client.OK)
|
|
for header, value in headers:
|
|
response.headers[header] = value
|
|
return response
|
|
|
|
|
|
class AuthAPI(ks_flask.APIBase):
|
|
_name = 'auth'
|
|
_import_name = __name__
|
|
resources = []
|
|
resource_mapping = [
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthProjectsResource,
|
|
url='/auth/projects',
|
|
alternate_urls=[dict(
|
|
url='/OS-FEDERATION/projects',
|
|
json_home=ks_flask.construct_json_home_data(
|
|
rel='projects',
|
|
resource_relation_func=(
|
|
json_home_relations.os_federation_resource_rel_func)
|
|
)
|
|
)],
|
|
|
|
rel='auth_projects',
|
|
resource_kwargs={}
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthDomainsResource,
|
|
url='/auth/domains',
|
|
alternate_urls=[dict(
|
|
url='/OS-FEDERATION/domains',
|
|
json_home=ks_flask.construct_json_home_data(
|
|
rel='domains',
|
|
resource_relation_func=(
|
|
json_home_relations.os_federation_resource_rel_func)
|
|
)
|
|
)],
|
|
rel='auth_domains',
|
|
resource_kwargs={},
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthSystemResource,
|
|
url='/auth/system',
|
|
resource_kwargs={},
|
|
rel='auth_system'
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthCatalogResource,
|
|
url='/auth/catalog',
|
|
resource_kwargs={},
|
|
rel='auth_catalog'
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthTokenOSPKIResource,
|
|
url='/auth/tokens/OS-PKI/revoked',
|
|
resource_kwargs={},
|
|
rel='revocations',
|
|
resource_relation_func=json_home_relations.os_pki_resource_rel_func
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthTokenResource,
|
|
url='/auth/tokens',
|
|
resource_kwargs={},
|
|
rel='auth_tokens'
|
|
)
|
|
]
|
|
|
|
|
|
class AuthFederationAPI(ks_flask.APIBase):
|
|
_name = 'auth/OS-FEDERATION'
|
|
_import_name = __name__
|
|
resources = []
|
|
resource_mapping = [
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthFederationSaml2Resource,
|
|
url='/auth/OS-FEDERATION/saml2',
|
|
resource_kwargs={},
|
|
resource_relation_func=(
|
|
json_home_relations.os_federation_resource_rel_func),
|
|
rel='saml2'
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthFederationSaml2ECPResource,
|
|
url='/auth/OS-FEDERATION/saml2/ecp',
|
|
resource_kwargs={},
|
|
resource_relation_func=(
|
|
json_home_relations.os_federation_resource_rel_func),
|
|
rel='ecp'
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthFederationWebSSOResource,
|
|
url='/auth/OS-FEDERATION/websso/<string:protocol_id>',
|
|
resource_kwargs={},
|
|
rel='websso',
|
|
resource_relation_func=(
|
|
json_home_relations.os_federation_resource_rel_func),
|
|
path_vars={
|
|
'protocol_id': (
|
|
json_home_relations.os_federation_parameter_rel_func(
|
|
parameter_name='protocol_id'))}
|
|
),
|
|
ks_flask.construct_resource_map(
|
|
resource=AuthFederationWebSSOIDPsResource,
|
|
url=('/auth/OS-FEDERATION/identity_providers/<string:idp_id>/'
|
|
'protocols/<string:protocol_id>/websso'),
|
|
resource_kwargs={},
|
|
rel='identity_providers_websso',
|
|
resource_relation_func=(
|
|
json_home_relations.os_federation_resource_rel_func),
|
|
path_vars={
|
|
'idp_id': (
|
|
json_home_relations.os_federation_parameter_rel_func(
|
|
parameter_name='idp_id')),
|
|
'protocol_id': (
|
|
json_home_relations.os_federation_parameter_rel_func(
|
|
parameter_name='protocol_id'))}
|
|
)
|
|
]
|
|
|
|
|
|
APIs = (
|
|
AuthAPI,
|
|
AuthFederationAPI,
|
|
)
|