keystone/keystone/api/auth.py

555 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)
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)
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()
remote_id = None
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,
)