External OAuth2.0 Authorization Server Support
The external_oauth2_token filter has been added for accepting or denying incoming requests containing OAuth 2.0 access tokens that are obtained from an External Authentication Server. Depends-On: https://review.opendev.org/c/openstack/keystoneauth/+/860614 Implements: blueprint enhance-oauth2-interoperability Change-Id: I529c5b0c89933395b126e86651ef09368dd7e6b4
This commit is contained in:
parent
a59020fdab
commit
de15a610e1
@ -20,6 +20,7 @@ python-memcached>=1.56 # PSF
|
||||
WebTest>=2.0.27 # MIT
|
||||
oslo.messaging>=5.29.0 # Apache-2.0
|
||||
pycadf!=2.0.0,>=1.1.0 # Apache-2.0
|
||||
PyJWT>=2.4.0 # MIT
|
||||
keystoneauth1>=3.12.0 # Apache-2.0
|
||||
oslo.cache>=1.26.0 # Apache-2.0
|
||||
python-keystoneclient>=3.20.0 # Apache-2.0
|
||||
|
846
keystonemiddleware/external_oauth2_token.py
Normal file
846
keystonemiddleware/external_oauth2_token.py
Normal file
@ -0,0 +1,846 @@
|
||||
# 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.
|
||||
|
||||
import abc
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
import ssl
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import jwt.utils
|
||||
import oslo_cache
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log as logging
|
||||
from oslo_serialization import jsonutils
|
||||
import requests.auth
|
||||
import webob.dec
|
||||
import webob.exc
|
||||
|
||||
from keystoneauth1 import exceptions as ksa_exceptions
|
||||
from keystoneauth1 import loading
|
||||
from keystoneauth1.loading import session as session_loading
|
||||
|
||||
from keystonemiddleware._common import config
|
||||
from keystonemiddleware.exceptions import ConfigurationError
|
||||
from keystonemiddleware.exceptions import KeystoneMiddlewareException
|
||||
from keystonemiddleware.i18n import _
|
||||
|
||||
oslo_cache.configure(cfg.CONF)
|
||||
_EXT_AUTH_CONFIG_GROUP_NAME = 'ext_oauth2_auth'
|
||||
_EXTERNAL_AUTH2_OPTS = [
|
||||
cfg.StrOpt('certfile',
|
||||
help='Required if identity server requires client '
|
||||
'certificate.'),
|
||||
cfg.StrOpt('keyfile',
|
||||
help='Required if identity server requires client '
|
||||
'private key.'),
|
||||
cfg.StrOpt('cafile',
|
||||
help='A PEM encoded Certificate Authority to use when '
|
||||
'verifying HTTPs connections. Defaults to system CAs.'),
|
||||
cfg.BoolOpt('insecure', default=False, help='Verify HTTPS connections.'),
|
||||
cfg.IntOpt('http_connect_timeout',
|
||||
help='Request timeout value for communicating with Identity '
|
||||
'API server.'),
|
||||
cfg.StrOpt('introspect_endpoint',
|
||||
help='The endpoint for introspect API, it is used to verify '
|
||||
'that the OAuth 2.0 access token is valid.'),
|
||||
cfg.StrOpt('audience',
|
||||
help='The Audience should be the URL of the Authorization '
|
||||
'Server\'s Token Endpoint. The Authorization Server will '
|
||||
'verify that it is an intended audience for the token.'),
|
||||
cfg.StrOpt('auth_method',
|
||||
default='client_secret_basic',
|
||||
choices=('client_secret_basic', 'client_secret_post',
|
||||
'tls_client_auth', 'private_key_jwt',
|
||||
'client_secret_jwt'),
|
||||
help='The auth_method must use the authentication method '
|
||||
'specified by the Authorization Server. The system '
|
||||
'supports 5 authentication methods such as '
|
||||
'tls_client_auth, client_secret_basic, '
|
||||
'client_secret_post, client_secret_jwt, private_key_jwt.'),
|
||||
cfg.StrOpt('client_id',
|
||||
help='The OAuth 2.0 Client Identifier valid at the '
|
||||
'Authorization Server.'),
|
||||
cfg.StrOpt('client_secret',
|
||||
help='The OAuth 2.0 client secret. When the auth_method is '
|
||||
'client_secret_basic, client_secret_post, or '
|
||||
'client_secret_jwt, the value is used, and otherwise the '
|
||||
'value is ignored.'),
|
||||
cfg.BoolOpt('thumbprint_verify', default=False,
|
||||
help='If the access token generated by the Authorization '
|
||||
'Server is bound to the OAuth 2.0 certificate '
|
||||
'thumbprint, the value can be set to true, and then the '
|
||||
'keystone middleware will verify the thumbprint.'),
|
||||
cfg.StrOpt('jwt_key_file',
|
||||
help='The jwt_key_file must use the certificate key file which '
|
||||
'has been registered with the Authorization Server. '
|
||||
'When the auth_method is private_key_jwt, the value is '
|
||||
'used, and otherwise the value is ignored.'),
|
||||
cfg.StrOpt('jwt_algorithm',
|
||||
help='The jwt_algorithm must use the algorithm specified by '
|
||||
'the Authorization Server. When the auth_method is '
|
||||
'client_secret_jwt, this value is often set to HS256, '
|
||||
'when the auth_method is private_key_jwt, the value is '
|
||||
'often set to RS256, and otherwise the value is ignored.'),
|
||||
cfg.IntOpt('jwt_bearer_time_out', default=3600,
|
||||
help='This value is used to calculate the expiration time. If '
|
||||
'after the expiration time, the access token can not be '
|
||||
'accepted. When the auth_method is client_secret_jwt or '
|
||||
'private_key_jwt, the value is used, and otherwise the '
|
||||
'value is ignored.'),
|
||||
cfg.StrOpt('mapping_project_id',
|
||||
help='Specifies the method for obtaining the project ID that '
|
||||
'currently needs to be accessed. '),
|
||||
cfg.StrOpt('mapping_project_name',
|
||||
help='Specifies the method for obtaining the project name that '
|
||||
'currently needs to be accessed.'),
|
||||
cfg.StrOpt('mapping_project_domain_id',
|
||||
help='Specifies the method for obtaining the project domain ID '
|
||||
'that currently needs to be accessed.'),
|
||||
cfg.StrOpt('mapping_project_domain_name',
|
||||
help='Specifies the method for obtaining the project domain '
|
||||
'name that currently needs to be accessed.'),
|
||||
cfg.StrOpt('mapping_user_id', default='client_id',
|
||||
help='Specifies the method for obtaining the user ID.'),
|
||||
cfg.StrOpt('mapping_user_name', default='username',
|
||||
help='Specifies the method for obtaining the user name.'),
|
||||
cfg.StrOpt('mapping_user_domain_id',
|
||||
help='Specifies the method for obtaining the domain ID which '
|
||||
'the user belongs.'),
|
||||
cfg.StrOpt('mapping_user_domain_name',
|
||||
help='Specifies the method for obtaining the domain name which '
|
||||
'the user belongs.'),
|
||||
cfg.StrOpt('mapping_roles',
|
||||
help='Specifies the method for obtaining the list of roles in '
|
||||
'a project or domain owned by the user.'),
|
||||
]
|
||||
|
||||
cfg.CONF.register_opts(_EXTERNAL_AUTH2_OPTS,
|
||||
group=_EXT_AUTH_CONFIG_GROUP_NAME)
|
||||
|
||||
|
||||
class InvalidToken(KeystoneMiddlewareException):
|
||||
"""Raise an InvalidToken Error.
|
||||
|
||||
When can not get necessary information from the token,
|
||||
this error will be thrown.
|
||||
"""
|
||||
|
||||
|
||||
class ForbiddenToken(KeystoneMiddlewareException):
|
||||
"""Raise a ForbiddenToken Error.
|
||||
|
||||
When can not get necessary information from the token,
|
||||
this error will be thrown.
|
||||
"""
|
||||
|
||||
|
||||
class ServiceError(KeystoneMiddlewareException):
|
||||
"""Raise a ServiceError.
|
||||
|
||||
When can not verify any tokens, this error will be thrown.
|
||||
"""
|
||||
|
||||
|
||||
class AbstractAuthClient(object, metaclass=abc.ABCMeta):
|
||||
"""Abstract http client using to access the OAuth2.0 Server."""
|
||||
|
||||
def __init__(self, session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger):
|
||||
self.session = session
|
||||
self.introspect_endpoint = introspect_endpoint
|
||||
self.audience = audience
|
||||
self.client_id = client_id
|
||||
self.get_config_option = func_get_config_option
|
||||
self.logger = logger
|
||||
|
||||
@abc.abstractmethod
|
||||
def introspect(self, access_token):
|
||||
"""Access the introspect API."""
|
||||
pass
|
||||
|
||||
|
||||
class ClientSecretBasicAuthClient(AbstractAuthClient):
|
||||
"""Http client with the auth method 'client_secret_basic'."""
|
||||
|
||||
def __init__(self, session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger):
|
||||
super(ClientSecretBasicAuthClient, self).__init__(
|
||||
session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger)
|
||||
self.client_secret = self.get_config_option(
|
||||
'client_secret', is_required=True)
|
||||
|
||||
def introspect(self, access_token):
|
||||
"""Access the introspect API.
|
||||
|
||||
Access the Introspect API to verify the access token by
|
||||
the auth method 'client_secret_basic'.
|
||||
"""
|
||||
req_data = {'token': access_token,
|
||||
'token_type_hint': 'access_token'}
|
||||
auth = requests.auth.HTTPBasicAuth(self.client_id,
|
||||
self.client_secret)
|
||||
http_response = self.session.request(
|
||||
self.introspect_endpoint,
|
||||
'POST',
|
||||
authenticated=False,
|
||||
data=req_data,
|
||||
requests_auth=auth)
|
||||
return http_response
|
||||
|
||||
|
||||
class ClientSecretPostAuthClient(AbstractAuthClient):
|
||||
"""Http client with the auth method 'client_secret_post'."""
|
||||
|
||||
def __init__(self, session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger):
|
||||
super(ClientSecretPostAuthClient, self).__init__(
|
||||
session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger)
|
||||
self.client_secret = self.get_config_option(
|
||||
'client_secret', is_required=True)
|
||||
|
||||
def introspect(self, access_token):
|
||||
"""Access the introspect API.
|
||||
|
||||
Access the Introspect API to verify the access token by
|
||||
the auth method 'client_secret_post'.
|
||||
"""
|
||||
req_data = {
|
||||
'client_id': self.client_id,
|
||||
'client_secret': self.client_secret,
|
||||
'token': access_token,
|
||||
'token_type_hint': 'access_token'
|
||||
}
|
||||
http_response = self.session.request(
|
||||
self.introspect_endpoint,
|
||||
'POST',
|
||||
authenticated=False,
|
||||
data=req_data)
|
||||
return http_response
|
||||
|
||||
|
||||
class TlsClientAuthClient(AbstractAuthClient):
|
||||
"""Http client with the auth method 'tls_client_auth'."""
|
||||
|
||||
def introspect(self, access_token):
|
||||
"""Access the introspect API.
|
||||
|
||||
Access the Introspect API to verify the access token by
|
||||
the auth method 'tls_client_auth'.
|
||||
"""
|
||||
req_data = {
|
||||
'client_id': self.client_id,
|
||||
'token': access_token,
|
||||
'token_type_hint': 'access_token'
|
||||
}
|
||||
http_response = self.session.request(
|
||||
self.introspect_endpoint,
|
||||
'POST',
|
||||
authenticated=False,
|
||||
data=req_data)
|
||||
return http_response
|
||||
|
||||
|
||||
class PrivateKeyJwtAuthClient(AbstractAuthClient):
|
||||
"""Http client with the auth method 'private_key_jwt'."""
|
||||
|
||||
def __init__(self, session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger):
|
||||
super(PrivateKeyJwtAuthClient, self).__init__(
|
||||
session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger)
|
||||
self.jwt_key_file = self.get_config_option(
|
||||
'jwt_key_file', is_required=True)
|
||||
self.jwt_bearer_time_out = self.get_config_option(
|
||||
'jwt_bearer_time_out', is_required=True)
|
||||
self.jwt_algorithm = self.get_config_option(
|
||||
'jwt_algorithm', is_required=True)
|
||||
self.logger = logger
|
||||
|
||||
def introspect(self, access_token):
|
||||
"""Access the introspect API.
|
||||
|
||||
Access the Introspect API to verify the access token by
|
||||
the auth method 'private_key_jwt'.
|
||||
"""
|
||||
if not os.path.isfile(self.jwt_key_file):
|
||||
self.logger.critical('Configuration error. JWT key file is '
|
||||
'not a file. path: %s' % self.jwt_key_file)
|
||||
raise ConfigurationError(_('Configuration error. '
|
||||
'JWT key file is not a file.'))
|
||||
try:
|
||||
with open(self.jwt_key_file, 'r') as jwt_file:
|
||||
jwt_key = jwt_file.read()
|
||||
except Exception as e:
|
||||
self.logger.critical('Configuration error. Failed to read '
|
||||
'the JWT key file. %s', e)
|
||||
raise ConfigurationError(_('Configuration error. '
|
||||
'Failed to read the JWT key file.'))
|
||||
if not jwt_key:
|
||||
self.logger.critical('Configuration error. The JWT key file '
|
||||
'content is empty. path: %s'
|
||||
% self.jwt_key_file)
|
||||
raise ConfigurationError(_('Configuration error. The JWT key file '
|
||||
'content is empty.'))
|
||||
|
||||
ita = round(time.time())
|
||||
try:
|
||||
client_assertion = jwt.encode(
|
||||
payload={
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': str(ita),
|
||||
'exp': str(ita + self.jwt_bearer_time_out),
|
||||
'iss': self.client_id,
|
||||
'sub': self.client_id,
|
||||
'aud': self.audience},
|
||||
headers={
|
||||
'typ': 'JWT',
|
||||
'alg': self.jwt_algorithm},
|
||||
key=jwt_key,
|
||||
algorithm=self.jwt_algorithm)
|
||||
except Exception as e:
|
||||
self.logger.critical('Configuration error. JWT encoding with '
|
||||
'the specified JWT key file and algorithm '
|
||||
'failed. path: %s, algorithm: %s, error: %s' %
|
||||
(self.jwt_key_file, self.jwt_algorithm, e))
|
||||
raise ConfigurationError(_('Configuration error. JWT encoding '
|
||||
'with the specified JWT key file '
|
||||
'and algorithm failed.'))
|
||||
req_data = {
|
||||
'client_id': self.client_id,
|
||||
'client_assertion_type':
|
||||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
'client_assertion': client_assertion,
|
||||
'token': access_token,
|
||||
'token_type_hint': 'access_token'
|
||||
}
|
||||
http_response = self.session.request(
|
||||
self.introspect_endpoint,
|
||||
'POST',
|
||||
authenticated=False,
|
||||
data=req_data)
|
||||
return http_response
|
||||
|
||||
|
||||
class ClientSecretJwtAuthClient(AbstractAuthClient):
|
||||
"""Http client with the auth method 'client_secret_jwt'."""
|
||||
|
||||
def __init__(self, session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger):
|
||||
super(ClientSecretJwtAuthClient, self).__init__(
|
||||
session, introspect_endpoint, audience, client_id,
|
||||
func_get_config_option, logger)
|
||||
self.client_secret = self.get_config_option(
|
||||
'client_secret', is_required=True)
|
||||
self.jwt_bearer_time_out = self.get_config_option(
|
||||
'jwt_bearer_time_out', is_required=True)
|
||||
self.jwt_algorithm = self.get_config_option(
|
||||
'jwt_algorithm', is_required=True)
|
||||
|
||||
def introspect(self, access_token):
|
||||
"""Access the introspect API.
|
||||
|
||||
Access the Introspect API to verify the access token by
|
||||
the auth method 'client_secret_jwt'.
|
||||
"""
|
||||
ita = round(time.time())
|
||||
try:
|
||||
client_assertion = jwt.encode(
|
||||
payload={
|
||||
'jti': str(uuid.uuid4()),
|
||||
'iat': str(ita),
|
||||
'exp': str(ita + self.jwt_bearer_time_out),
|
||||
'iss': self.client_id,
|
||||
'sub': self.client_id,
|
||||
'aud': self.audience},
|
||||
headers={
|
||||
'typ': 'JWT',
|
||||
'alg': self.jwt_algorithm},
|
||||
key=self.client_secret,
|
||||
algorithm=self.jwt_algorithm)
|
||||
except Exception as e:
|
||||
self.logger.critical('Configuration error. JWT encoding with '
|
||||
'the specified client_secret and algorithm '
|
||||
'failed. algorithm: %s, error: %s'
|
||||
% (self.jwt_algorithm, e))
|
||||
raise ConfigurationError(_('Configuration error. JWT encoding '
|
||||
'with the specified client_secret '
|
||||
'and algorithm failed.'))
|
||||
req_data = {
|
||||
'client_id': self.client_id,
|
||||
'client_assertion_type':
|
||||
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
'client_assertion': client_assertion,
|
||||
'token': access_token,
|
||||
'token_type_hint': 'access_token'
|
||||
}
|
||||
http_response = self.session.request(
|
||||
self.introspect_endpoint,
|
||||
'POST',
|
||||
authenticated=False,
|
||||
data=req_data)
|
||||
return http_response
|
||||
|
||||
|
||||
_ALL_AUTH_CLIENTS = {
|
||||
'client_secret_basic': ClientSecretBasicAuthClient,
|
||||
'client_secret_post': ClientSecretPostAuthClient,
|
||||
'tls_client_auth': TlsClientAuthClient,
|
||||
'private_key_jwt': PrivateKeyJwtAuthClient,
|
||||
'client_secret_jwt': ClientSecretJwtAuthClient
|
||||
}
|
||||
|
||||
|
||||
def _get_http_client(auth_method, session, introspect_endpoint, audience,
|
||||
client_id, func_get_config_option, logger):
|
||||
"""Get an auth HTTP Client to access the OAuth2.0 Server."""
|
||||
if auth_method in _ALL_AUTH_CLIENTS:
|
||||
return _ALL_AUTH_CLIENTS.get(auth_method)(
|
||||
session, introspect_endpoint, audience,
|
||||
client_id, func_get_config_option, logger)
|
||||
logger.critical('The value is incorrect for option '
|
||||
'auth_method in group [%s]' %
|
||||
_EXT_AUTH_CONFIG_GROUP_NAME)
|
||||
raise ConfigurationError(_('The configuration parameter for '
|
||||
'key "auth_method" in group [%s] '
|
||||
'is incorrect.') %
|
||||
_EXT_AUTH_CONFIG_GROUP_NAME)
|
||||
|
||||
|
||||
class ExternalAuth2Protocol(object):
|
||||
"""Middleware that handles External Server OAuth2.0 authentication."""
|
||||
|
||||
def __init__(self, application, conf):
|
||||
super(ExternalAuth2Protocol, self).__init__()
|
||||
self._application = application
|
||||
self._log = logging.getLogger(conf.get('log_name', __name__))
|
||||
self._log.info('Starting Keystone external_oauth2_token middleware')
|
||||
|
||||
config_opts = [
|
||||
(_EXT_AUTH_CONFIG_GROUP_NAME, _EXTERNAL_AUTH2_OPTS
|
||||
+ loading.get_auth_common_conf_options())
|
||||
]
|
||||
all_opts = [(g, copy.deepcopy(o)) for g, o in config_opts]
|
||||
self._conf = config.Config('external_oauth2_token',
|
||||
_EXT_AUTH_CONFIG_GROUP_NAME,
|
||||
all_opts,
|
||||
conf)
|
||||
|
||||
self._session = self._create_session()
|
||||
self._audience = self._get_config_option('audience', is_required=True)
|
||||
self._introspect_endpoint = self._get_config_option(
|
||||
'introspect_endpoint', is_required=True)
|
||||
self._auth_method = self._get_config_option(
|
||||
'auth_method', is_required=True)
|
||||
self._client_id = self._get_config_option(
|
||||
'client_id', is_required=True)
|
||||
self._http_client = _get_http_client(
|
||||
self._auth_method, self._session, self._introspect_endpoint,
|
||||
self._audience, self._client_id,
|
||||
self._get_config_option, self._log)
|
||||
|
||||
@webob.dec.wsgify()
|
||||
def __call__(self, req):
|
||||
"""Handle incoming request."""
|
||||
self.process_request(req)
|
||||
response = req.get_response(self._application)
|
||||
return self.process_response(response)
|
||||
|
||||
def process_request(self, request):
|
||||
"""Process request.
|
||||
|
||||
:param request: Incoming request
|
||||
:type request: _request.AuthTokenRequest
|
||||
"""
|
||||
access_token = None
|
||||
if (request.authorization and
|
||||
request.authorization.authtype == 'Bearer'):
|
||||
access_token = request.authorization.params
|
||||
|
||||
try:
|
||||
if not access_token:
|
||||
self._log.info('Unable to obtain the access token.')
|
||||
raise InvalidToken(_('Unable to obtain the access token.'))
|
||||
|
||||
token_data = self._fetch_token(access_token)
|
||||
|
||||
if (self._get_config_option('thumbprint_verify',
|
||||
is_required=False)):
|
||||
self._confirm_certificate_thumbprint(
|
||||
request, token_data.get('origin_token_metadata'))
|
||||
|
||||
self._set_request_env(request, token_data)
|
||||
|
||||
except InvalidToken as error:
|
||||
self._log.info('Rejecting request. '
|
||||
'Need a valid OAuth 2.0 access token. '
|
||||
'error: %s', error)
|
||||
message = _('The request you have made is denied, '
|
||||
'because the token is invalid.')
|
||||
body = {'error': {
|
||||
'code': 401,
|
||||
'title': 'Unauthorized',
|
||||
'message': message,
|
||||
}}
|
||||
raise webob.exc.HTTPUnauthorized(
|
||||
body=jsonutils.dumps(body),
|
||||
headers=self._reject_headers,
|
||||
charset='UTF-8',
|
||||
content_type='application/json')
|
||||
except ForbiddenToken as error:
|
||||
self._log.warning('Rejecting request. '
|
||||
'The necessary information is required.'
|
||||
'error: %s', error)
|
||||
message = _('The request you have made is denied, '
|
||||
'because the necessary information '
|
||||
'could not be parsed.')
|
||||
body = {'error': {
|
||||
'code': 403,
|
||||
'title': 'Forbidden',
|
||||
'message': message,
|
||||
}}
|
||||
raise webob.exc.HTTPForbidden(
|
||||
body=jsonutils.dumps(body),
|
||||
charset='UTF-8',
|
||||
content_type='application/json')
|
||||
except ConfigurationError as error:
|
||||
self._log.critical('Rejecting request. '
|
||||
'The configuration parameters are incorrect. '
|
||||
'error: %s', error)
|
||||
message = _('The request you have made is denied, '
|
||||
'because the configuration parameters are incorrect '
|
||||
'and the token can not be verified.')
|
||||
body = {'error': {
|
||||
'code': 500,
|
||||
'title': 'Internal Server Error',
|
||||
'message': message,
|
||||
}}
|
||||
raise webob.exc.HTTPServerError(
|
||||
body=jsonutils.dumps(body),
|
||||
charset='UTF-8',
|
||||
content_type='application/json')
|
||||
except ServiceError as error:
|
||||
self._log.warning('Rejecting request. An exception occurred and '
|
||||
'the OAuth 2.0 access token can not be '
|
||||
'verified. error: %s', error)
|
||||
message = _('The request you have made is denied, '
|
||||
'because an exception occurred while accessing '
|
||||
'the external authentication server '
|
||||
'for token validation.')
|
||||
body = {'error': {
|
||||
'code': 500,
|
||||
'title': 'Internal Server Error',
|
||||
'message': message,
|
||||
}}
|
||||
raise webob.exc.HTTPServerError(
|
||||
body=jsonutils.dumps(body),
|
||||
charset='UTF-8',
|
||||
content_type='application/json')
|
||||
|
||||
def process_response(self, response):
|
||||
"""Process Response.
|
||||
|
||||
Add ``WWW-Authenticate`` headers to requests that failed with
|
||||
``401 Unauthenticated`` so users know where to authenticate for future
|
||||
requests.
|
||||
"""
|
||||
if response.status_int == 401:
|
||||
response.headers.extend(self._reject_headers)
|
||||
return response
|
||||
|
||||
def _create_session(self, **kwargs):
|
||||
"""Create session for HTTP access."""
|
||||
kwargs.setdefault('cert', self._get_config_option(
|
||||
'certfile', is_required=False))
|
||||
kwargs.setdefault('key', self._get_config_option(
|
||||
'keyfile', is_required=False))
|
||||
kwargs.setdefault('cacert', self._get_config_option(
|
||||
'cafile', is_required=False))
|
||||
kwargs.setdefault('insecure', self._get_config_option(
|
||||
'insecure', is_required=False))
|
||||
kwargs.setdefault('timeout', self._get_config_option(
|
||||
'http_connect_timeout', is_required=False))
|
||||
kwargs.setdefault('user_agent', self._conf.user_agent)
|
||||
return session_loading.Session().load_from_options(**kwargs)
|
||||
|
||||
def _get_config_option(self, key, is_required):
|
||||
"""Read the value from config file by the config key."""
|
||||
value = self._conf.get(key)
|
||||
if not value:
|
||||
if is_required:
|
||||
self._log.critical('The value is required for option %s '
|
||||
'in group [%s]' % (
|
||||
key, _EXT_AUTH_CONFIG_GROUP_NAME))
|
||||
raise ConfigurationError(
|
||||
_('Configuration error. The parameter '
|
||||
'is not set for "%s" in group [%s].') % (
|
||||
key, _EXT_AUTH_CONFIG_GROUP_NAME))
|
||||
else:
|
||||
return None
|
||||
else:
|
||||
return value
|
||||
|
||||
@property
|
||||
def _reject_headers(self):
|
||||
"""Generate WWW-Authenticate Header.
|
||||
|
||||
When response status is 401, this method will be called to add
|
||||
the 'WWW-Authenticate' header to the response.
|
||||
"""
|
||||
header_val = 'Authorization OAuth 2.0 uri="%s"' % self._audience
|
||||
return [('WWW-Authenticate', header_val)]
|
||||
|
||||
def _fetch_token(self, access_token):
|
||||
"""Use access_token to get the valid token meta_data.
|
||||
|
||||
Verify the access token through accessing the external
|
||||
authorization server.
|
||||
"""
|
||||
try:
|
||||
http_response = self._http_client.introspect(access_token)
|
||||
if http_response.status_code != 200:
|
||||
self._log.critical('The introspect API returns an '
|
||||
'incorrect response. '
|
||||
'response_status: %s, response_text: %s' %
|
||||
(http_response.status_code,
|
||||
http_response.text))
|
||||
raise ServiceError(_('The token cannot be verified '
|
||||
'for validity.'))
|
||||
|
||||
origin_token_metadata = http_response.json()
|
||||
self._log.debug('The introspect API response: %s' %
|
||||
origin_token_metadata)
|
||||
if not origin_token_metadata.get('active'):
|
||||
self._log.info('The token is invalid. response: %s' %
|
||||
origin_token_metadata)
|
||||
raise InvalidToken(_('The token is invalid.'))
|
||||
|
||||
return self._parse_necessary_info(origin_token_metadata)
|
||||
except (ConfigurationError, ForbiddenToken,
|
||||
ServiceError, InvalidToken):
|
||||
raise
|
||||
except (ksa_exceptions.ConnectFailure,
|
||||
ksa_exceptions.DiscoveryFailure,
|
||||
ksa_exceptions.RequestTimeout) as error:
|
||||
self._log.critical('Unable to validate token: %s', error)
|
||||
raise ServiceError(
|
||||
_('The Introspect API service is temporarily unavailable.'))
|
||||
except Exception as error:
|
||||
self._log.critical('Unable to validate token: %s', error)
|
||||
raise ServiceError(_('An exception occurred during the token '
|
||||
'verification process.'))
|
||||
|
||||
def _read_data_from_token(self, token_metadata, config_key,
|
||||
is_required=False, value_type=str):
|
||||
"""Read value from token metadata.
|
||||
|
||||
Read the necessary information from the token metadata with the
|
||||
config key.
|
||||
"""
|
||||
meta_key = self._get_config_option(config_key, is_required=is_required)
|
||||
if not meta_key:
|
||||
return None
|
||||
|
||||
if meta_key.find('.') >= 0:
|
||||
meta_value = None
|
||||
for temp_key in meta_key.split('.'):
|
||||
if not temp_key:
|
||||
self._log.critical('Configuration error. '
|
||||
'config_key: %s , meta_key: %s ' %
|
||||
(config_key, meta_key))
|
||||
raise ConfigurationError(
|
||||
_('Failed to parse the necessary information '
|
||||
'for the field "%s".') % meta_key)
|
||||
if not meta_value:
|
||||
meta_value = token_metadata.get(temp_key)
|
||||
else:
|
||||
if not isinstance(meta_value, dict):
|
||||
self._log.warning(
|
||||
'Failed to parse the necessary information. '
|
||||
'The meta_value is not of type dict.'
|
||||
'config_key: %s , meta_key: %s, value: %s' %
|
||||
(config_key, meta_key, meta_value))
|
||||
raise ForbiddenToken(
|
||||
_('Failed to parse the necessary information '
|
||||
'for the field "%s".') % meta_key)
|
||||
meta_value = meta_value.get(temp_key)
|
||||
else:
|
||||
meta_value = token_metadata.get(meta_key)
|
||||
|
||||
if not meta_value:
|
||||
if is_required:
|
||||
self._log.warning(
|
||||
'Failed to parse the necessary information. '
|
||||
'The meta value is required.'
|
||||
'config_key: %s , meta_key: %s, value: %s, need_type: %s' %
|
||||
(config_key, meta_key, meta_value, value_type))
|
||||
raise ForbiddenToken(_('Failed to parse the necessary '
|
||||
'information for the field "%s".') %
|
||||
meta_key)
|
||||
else:
|
||||
meta_value = None
|
||||
else:
|
||||
if not isinstance(meta_value, value_type):
|
||||
self._log.warning(
|
||||
'Failed to parse the necessary information. '
|
||||
'The meta value is of an incorrect type.'
|
||||
'config_key: %s , meta_key: %s, value: %s, need_type: %s'
|
||||
% (config_key, meta_key, meta_value, value_type))
|
||||
raise ForbiddenToken(_('Failed to parse the necessary '
|
||||
'information for the field "%s".') %
|
||||
meta_key)
|
||||
return meta_value
|
||||
|
||||
def _parse_necessary_info(self, token_metadata):
|
||||
"""Parse the necessary information from the token metadata."""
|
||||
token_data = dict()
|
||||
token_data['origin_token_metadata'] = token_metadata
|
||||
|
||||
roles = self._read_data_from_token(token_metadata,
|
||||
'mapping_roles',
|
||||
is_required=True)
|
||||
is_admin = 'false'
|
||||
if 'admin' in roles.lower().split(','):
|
||||
is_admin = 'true'
|
||||
token_data['roles'] = roles
|
||||
token_data['is_admin'] = is_admin
|
||||
|
||||
project_id = self._read_data_from_token(
|
||||
token_metadata, 'mapping_project_id', is_required=False)
|
||||
if project_id:
|
||||
token_data['project_id'] = project_id
|
||||
token_data['project_name'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_project_name', is_required=True)
|
||||
token_data['project_domain_id'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_project_domain_id', is_required=True)
|
||||
token_data['project_domain_name'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_project_domain_name',
|
||||
is_required=True)
|
||||
else:
|
||||
token_data['domain_id'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_project_domain_id', is_required=True)
|
||||
token_data['domain_name'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_project_domain_name',
|
||||
is_required=True)
|
||||
|
||||
token_data['user_id'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_user_id', is_required=True)
|
||||
token_data['user_name'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_user_name', is_required=True)
|
||||
token_data['user_domain_id'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_user_domain_id', is_required=True)
|
||||
token_data['user_domain_name'] = self._read_data_from_token(
|
||||
token_metadata, 'mapping_user_domain_name', is_required=True)
|
||||
|
||||
return token_data
|
||||
|
||||
def _get_client_certificate(self, request):
|
||||
"""Get the client certificate from request environ or socket."""
|
||||
try:
|
||||
pem_client_cert = request.environ.get('SSL_CLIENT_CERT')
|
||||
if pem_client_cert:
|
||||
peer_cert = ssl.PEM_cert_to_DER_cert(pem_client_cert)
|
||||
else:
|
||||
wsgi_input = request.environ.get('wsgi.input')
|
||||
if not wsgi_input:
|
||||
self._log.warn('Unable to obtain the client certificate. '
|
||||
'The object for wsgi_input is none.')
|
||||
raise InvalidToken(_('Unable to obtain the client '
|
||||
'certificate.'))
|
||||
socket = wsgi_input.get_socket()
|
||||
if not socket:
|
||||
self._log.warn('Unable to obtain the client certificate. '
|
||||
'The object for socket is none.')
|
||||
raise InvalidToken(_('Unable to obtain the client '
|
||||
'certificate.'))
|
||||
peer_cert = socket.getpeercert(binary_form=True)
|
||||
if not peer_cert:
|
||||
self._log.warn('Unable to obtain the client certificate. '
|
||||
'The object for peer_cert is none.')
|
||||
raise InvalidToken(_('Unable to obtain the client '
|
||||
'certificate.'))
|
||||
return peer_cert
|
||||
except InvalidToken:
|
||||
raise
|
||||
except Exception as error:
|
||||
self._log.warn('Unable to obtain the client certificate. %s' %
|
||||
error)
|
||||
raise InvalidToken(_('Unable to obtain the client certificate.'))
|
||||
|
||||
def _confirm_certificate_thumbprint(self, request, origin_token_metadata):
|
||||
"""Check if the thumbprint in the token is valid."""
|
||||
peer_cert = self._get_client_certificate(request)
|
||||
try:
|
||||
thumb_sha256 = hashlib.sha256(peer_cert).digest()
|
||||
cert_thumb = jwt.utils.base64url_encode(thumb_sha256).decode(
|
||||
'ascii')
|
||||
except Exception as error:
|
||||
self._log.warn('An Exception occurred. %s' % error)
|
||||
raise InvalidToken(_('Can not generate the thumbprint.'))
|
||||
|
||||
token_thumb = origin_token_metadata.get('cnf', {}).get('x5t#S256')
|
||||
if cert_thumb != token_thumb:
|
||||
self._log.warn('The two thumbprints do not match. '
|
||||
'token_thumbprint: %s, certificate_thumbprint %s' %
|
||||
(token_thumb, cert_thumb))
|
||||
raise InvalidToken(_('The two thumbprints do not match.'))
|
||||
|
||||
def _set_request_env(self, request, token_data):
|
||||
"""Set request.environ with the necessary information."""
|
||||
request.environ['external.token_info'] = token_data
|
||||
request.environ['HTTP_X_IDENTITY_STATUS'] = 'Confirmed'
|
||||
request.environ['HTTP_X_ROLES'] = token_data.get('roles')
|
||||
request.environ['HTTP_X_ROLE'] = token_data.get('roles')
|
||||
request.environ['HTTP_X_USER_ID'] = token_data.get('user_id')
|
||||
request.environ['HTTP_X_USER_NAME'] = token_data.get('user_name')
|
||||
request.environ['HTTP_X_USER_DOMAIN_ID'] = token_data.get(
|
||||
'user_domain_id')
|
||||
request.environ['HTTP_X_USER_DOMAIN_NAME'] = token_data.get(
|
||||
'user_domain_name')
|
||||
if token_data.get('is_admin') == 'true':
|
||||
request.environ['HTTP_X_IS_ADMIN_PROJECT'] = token_data.get(
|
||||
'is_admin')
|
||||
request.environ['HTTP_X_USER'] = token_data.get('user_name')
|
||||
|
||||
if token_data.get('project_id'):
|
||||
request.environ['HTTP_X_PROJECT_ID'] = token_data.get('project_id')
|
||||
request.environ['HTTP_X_PROJECT_NAME'] = token_data.get(
|
||||
'project_name')
|
||||
request.environ['HTTP_X_PROJECT_DOMAIN_ID'] = token_data.get(
|
||||
'project_domain_id')
|
||||
request.environ['HTTP_X_PROJECT_DOMAIN_NAME'] = token_data.get(
|
||||
'project_domain_name')
|
||||
request.environ['HTTP_X_TENANT_ID'] = token_data.get('project_id')
|
||||
request.environ['HTTP_X_TENANT_NAME'] = token_data.get(
|
||||
'project_name')
|
||||
request.environ['HTTP_X_TENANT'] = token_data.get('project_id')
|
||||
else:
|
||||
request.environ['HTTP_X_DOMAIN_ID'] = token_data.get('domain_id')
|
||||
request.environ['HTTP_X_DOMAIN_NAME'] = token_data.get(
|
||||
'domain_name')
|
||||
self._log.debug('The access token data is %s.' % jsonutils.dumps(
|
||||
token_data))
|
||||
|
||||
|
||||
def filter_factory(global_conf, **local_conf):
|
||||
"""Return a WSGI filter app for use with paste.deploy."""
|
||||
conf = global_conf.copy()
|
||||
conf.update(local_conf)
|
||||
|
||||
def auth_filter(app):
|
||||
return ExternalAuth2Protocol(app, conf)
|
||||
|
||||
return auth_filter
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,8 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
[`blueprint enhance-oauth2-interoperability <https://blueprints.launchpad.net/keystone/+spec/enhance-oauth2-interoperability>`_]
|
||||
The external_oauth2_token filter has been added for accepting or denying
|
||||
incoming requests containing OAuth 2.0 access tokens that are obtained
|
||||
from an external authorization server by users through their OAuth 2.0
|
||||
credentials.
|
@ -16,6 +16,7 @@ oslo.serialization!=2.19.1,>=2.18.0 # Apache-2.0
|
||||
oslo.utils>=3.33.0 # Apache-2.0
|
||||
pbr!=2.1.0,>=2.0.0 # Apache-2.0
|
||||
pycadf!=2.0.0,>=1.1.0 # Apache-2.0
|
||||
PyJWT>=2.4.0 # MIT
|
||||
python-keystoneclient>=3.20.0 # Apache-2.0
|
||||
requests>=2.14.2 # Apache-2.0
|
||||
six>=1.10.0 # MIT
|
||||
|
@ -44,3 +44,4 @@ paste.filter_factory =
|
||||
s3_token = keystonemiddleware.s3_token:filter_factory
|
||||
oauth2_token = keystonemiddleware.oauth2_token:filter_factory
|
||||
oauth2_mtls_token = keystonemiddleware.oauth2_mtls_token:filter_factory
|
||||
external_oauth2_token = keystonemiddleware.external_oauth2_token:filter_factory
|
||||
|
@ -19,6 +19,7 @@ testtools>=2.2.0 # MIT
|
||||
python-memcached>=1.59 # PSF
|
||||
WebTest>=2.0.27 # MIT
|
||||
oslo.messaging>=5.29.0 # Apache-2.0
|
||||
PyJWT>=2.4.0 # MIT
|
||||
|
||||
# Bandit security code scanner
|
||||
bandit!=1.6.0,>=1.1.0 # Apache-2.0
|
||||
|
Loading…
Reference in New Issue
Block a user