973 lines
43 KiB
Python
973 lines
43 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.
|
|
|
|
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.auth_token import _cache
|
|
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.StrOpt('mapping_system_scope',
|
|
help='Specifies the method for obtaining the scope information '
|
|
'indicating whether a token is system-scoped.'),
|
|
cfg.StrOpt('mapping_expires_at',
|
|
help='Specifies the method for obtaining the token expiration '
|
|
'time.'),
|
|
cfg.ListOpt('memcached_servers',
|
|
deprecated_name='memcache_servers',
|
|
help='Optionally specify a list of memcached server(s) to '
|
|
'use for caching. If left undefined, tokens will '
|
|
'instead be cached in-process.'),
|
|
cfg.IntOpt('token_cache_time',
|
|
default=300,
|
|
help='In order to prevent excessive effort spent validating '
|
|
'tokens, the middleware caches previously-seen tokens '
|
|
'for a configurable duration (in seconds). Set to -1 to '
|
|
'disable caching completely.'),
|
|
cfg.StrOpt('memcache_security_strategy',
|
|
default='None',
|
|
choices=('None', 'MAC', 'ENCRYPT'),
|
|
ignore_case=True,
|
|
help='(Optional) If defined, indicate whether token data '
|
|
'should be authenticated or authenticated and encrypted. '
|
|
'If MAC, token data is authenticated (with HMAC) in the '
|
|
'cache. If ENCRYPT, token data is encrypted and '
|
|
'authenticated in the cache. If the value is not one of '
|
|
'these options or empty, auth_token will raise an '
|
|
'exception on initialization.'),
|
|
cfg.StrOpt('memcache_secret_key',
|
|
secret=True,
|
|
help='(Optional, mandatory if memcache_security_strategy is '
|
|
'defined) This string is used for key derivation.'),
|
|
cfg.IntOpt('memcache_pool_dead_retry',
|
|
default=5 * 60,
|
|
help='(Optional) Number of seconds memcached server is '
|
|
'considered dead before it is tried again.'),
|
|
cfg.IntOpt('memcache_pool_maxsize',
|
|
default=10,
|
|
help='(Optional) Maximum total number of open connections to '
|
|
'every memcached server.'),
|
|
cfg.IntOpt('memcache_pool_socket_timeout',
|
|
default=3,
|
|
help='(Optional) Socket timeout in seconds for communicating '
|
|
'with a memcached server.'),
|
|
cfg.IntOpt('memcache_pool_unused_timeout',
|
|
default=60,
|
|
help='(Optional) Number of seconds a connection to memcached '
|
|
'is held unused in the pool before it is closed.'),
|
|
cfg.IntOpt('memcache_pool_conn_get_timeout',
|
|
default=10,
|
|
help='(Optional) Number of seconds that an operation will wait '
|
|
'to get a memcached client connection from the pool.'),
|
|
cfg.BoolOpt('memcache_use_advanced_pool',
|
|
default=True,
|
|
help='(Optional) Use the advanced (eventlet safe) memcached '
|
|
'client pool.')
|
|
]
|
|
|
|
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.'))
|
|
|
|
iat = round(time.time())
|
|
try:
|
|
client_assertion = jwt.encode(
|
|
payload={
|
|
'jti': str(uuid.uuid4()),
|
|
'iat': str(iat),
|
|
'exp': str(iat + 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._token_cache = self._token_cache_factory()
|
|
|
|
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)
|
|
|
|
def _token_cache_factory(self):
|
|
security_strategy = self._conf.get('memcache_security_strategy')
|
|
cache_kwargs = dict(
|
|
cache_time=int(self._conf.get('token_cache_time')),
|
|
memcached_servers=self._conf.get('memcached_servers'),
|
|
use_advanced_pool=self._conf.get(
|
|
'memcache_use_advanced_pool'),
|
|
dead_retry=self._conf.get('memcache_pool_dead_retry'),
|
|
maxsize=self._conf.get('memcache_pool_maxsize'),
|
|
unused_timeout=self._conf.get(
|
|
'memcache_pool_unused_timeout'),
|
|
conn_get_timeout=self._conf.get(
|
|
'memcache_pool_conn_get_timeout'),
|
|
socket_timeout=self._conf.get(
|
|
'memcache_pool_socket_timeout'),
|
|
)
|
|
if security_strategy.lower() != 'none':
|
|
secret_key = self._conf.get('memcache_secret_key')
|
|
return _cache.SecureTokenCache(self._log,
|
|
security_strategy,
|
|
secret_key,
|
|
**cache_kwargs)
|
|
return _cache.TokenCache(self._log, **cache_kwargs)
|
|
|
|
@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.'))
|
|
|
|
self._token_cache.initialize(request.environ)
|
|
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:
|
|
cached = self._token_cache.get(access_token)
|
|
if cached:
|
|
self._log.debug('The cached token: %s' % cached)
|
|
if (not isinstance(cached, dict)
|
|
or 'origin_token_metadata' not in cached):
|
|
self._log.warning('The cached data is invalid. %s' %
|
|
cached)
|
|
raise InvalidToken(_('The token is invalid.'))
|
|
origin_token_metadata = cached.get('origin_token_metadata')
|
|
if not origin_token_metadata.get('active'):
|
|
self._log.warning('The cached data is invalid. %s' %
|
|
cached)
|
|
raise InvalidToken(_('The token is invalid.'))
|
|
expire_at = self._read_data_from_token(
|
|
origin_token_metadata, 'mapping_expires_at',
|
|
is_required=False, value_type=int)
|
|
if expire_at:
|
|
if int(expire_at) < int(time.time()):
|
|
cached['origin_token_metadata']['active'] = False
|
|
self._token_cache.set(access_token, cached)
|
|
self._log.warning(
|
|
'The cached data is invalid. %s' % cached)
|
|
raise InvalidToken(_('The token is invalid.'))
|
|
return cached
|
|
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._token_cache.set(
|
|
access_token,
|
|
{'origin_token_metadata': origin_token_metadata})
|
|
self._log.info('The token is invalid. response: %s' %
|
|
origin_token_metadata)
|
|
raise InvalidToken(_('The token is invalid.'))
|
|
token_data = self._parse_necessary_info(origin_token_metadata)
|
|
self._token_cache.set(access_token, token_data)
|
|
return token_data
|
|
|
|
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=None):
|
|
"""Read value from token metadata.
|
|
|
|
Read the necessary information from the token metadata with the
|
|
config key.
|
|
"""
|
|
if not value_type:
|
|
value_type = str
|
|
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
|
|
|
|
system_scope = self._read_data_from_token(
|
|
token_metadata, 'mapping_system_scope',
|
|
is_required=False, value_type=bool)
|
|
if system_scope:
|
|
token_data['system_scope'] = 'all'
|
|
else:
|
|
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('system_scope'):
|
|
request.environ['HTTP_OPENSTACK_SYSTEM_SCOPE'] = token_data.get(
|
|
'system_scope'
|
|
)
|
|
elif 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
|