Merge "Refactor auth_token, move identity server members to class"
This commit is contained in:
commit
ae9bddba83
@ -466,55 +466,34 @@ class AuthProtocol(object):
|
|||||||
(True, 'true', 't', '1', 'on', 'yes', 'y')
|
(True, 'true', 't', '1', 'on', 'yes', 'y')
|
||||||
)
|
)
|
||||||
|
|
||||||
# where to find the auth service (we use this to validate tokens)
|
self._include_service_catalog = self._conf_get(
|
||||||
self._identity_uri = self._conf_get('identity_uri')
|
'include_service_catalog')
|
||||||
self._auth_uri = self._conf_get('auth_uri')
|
|
||||||
|
|
||||||
# NOTE(jamielennox): it does appear here that our defaults arguments
|
http_connect_timeout_cfg = self._conf_get('http_connect_timeout')
|
||||||
# are backwards. We need to do it this way so that we can handle the
|
http_connect_timeout = (http_connect_timeout_cfg and
|
||||||
# same deprecation strategy for CONF and the conf variable.
|
int(http_connect_timeout_cfg))
|
||||||
if not self._identity_uri:
|
|
||||||
self._LOG.warning('Configuring admin URI using auth fragments. '
|
|
||||||
'This is deprecated, use \'identity_uri\''
|
|
||||||
' instead.')
|
|
||||||
|
|
||||||
auth_host = self._conf_get('auth_host')
|
http_request_max_retries = self._conf_get('http_request_max_retries')
|
||||||
auth_port = int(self._conf_get('auth_port'))
|
|
||||||
auth_protocol = self._conf_get('auth_protocol')
|
|
||||||
auth_admin_prefix = self._conf_get('auth_admin_prefix')
|
|
||||||
|
|
||||||
if netaddr.valid_ipv6(auth_host):
|
self._identity_server = _IdentityServer(
|
||||||
# Note(dzyu) it is an IPv6 address, so it needs to be wrapped
|
self._LOG, include_service_catalog=self._include_service_catalog,
|
||||||
# with '[]' to generate a valid IPv6 URL, based on
|
identity_uri=self._conf_get('identity_uri'),
|
||||||
# http://www.ietf.org/rfc/rfc2732.txt
|
auth_uri=self._conf_get('auth_uri'),
|
||||||
auth_host = '[%s]' % auth_host
|
auth_host=self._conf_get('auth_host'),
|
||||||
|
auth_port=int(self._conf_get('auth_port')),
|
||||||
self._identity_uri = '%s://%s:%s' % (auth_protocol, auth_host,
|
auth_protocol=self._conf_get('auth_protocol'),
|
||||||
auth_port)
|
auth_admin_prefix=self._conf_get('auth_admin_prefix'),
|
||||||
if auth_admin_prefix:
|
cert_file=self._conf_get('certfile'),
|
||||||
self._identity_uri = '%s/%s' % (self._identity_uri,
|
key_file=self._conf_get('keyfile'),
|
||||||
auth_admin_prefix.strip('/'))
|
ssl_ca_file=self._conf_get('cafile'),
|
||||||
else:
|
ssl_insecure=self._conf_get('insecure'),
|
||||||
self._identity_uri = self._identity_uri.rstrip('/')
|
admin_token=self._conf_get('admin_token'),
|
||||||
|
admin_user=self._conf_get('admin_user'),
|
||||||
if self._auth_uri is None:
|
admin_password=self._conf_get('admin_password'),
|
||||||
self._LOG.warning(
|
admin_tenant_name=self._conf_get('admin_tenant_name'),
|
||||||
'Configuring auth_uri to point to the public identity '
|
http_connect_timeout=http_connect_timeout,
|
||||||
'endpoint is required; clients may not be able to '
|
http_request_max_retries=http_request_max_retries,
|
||||||
'authenticate against an admin endpoint')
|
auth_version=self._conf_get('auth_version'))
|
||||||
|
|
||||||
# FIXME(dolph): drop support for this fallback behavior as
|
|
||||||
# documented in bug 1207517.
|
|
||||||
# NOTE(jamielennox): we urljoin '/' to get just the base URI as
|
|
||||||
# this is the original behaviour.
|
|
||||||
self._auth_uri = urllib.parse.urljoin(self._identity_uri, '/')
|
|
||||||
self._auth_uri = self._auth_uri.rstrip('/')
|
|
||||||
|
|
||||||
# SSL
|
|
||||||
self._cert_file = self._conf_get('certfile')
|
|
||||||
self._key_file = self._conf_get('keyfile')
|
|
||||||
self._ssl_ca_file = self._conf_get('cafile')
|
|
||||||
self._ssl_insecure = self._conf_get('insecure')
|
|
||||||
|
|
||||||
# signing
|
# signing
|
||||||
self._signing_dirname = self._conf_get('signing_dir')
|
self._signing_dirname = self._conf_get('signing_dir')
|
||||||
@ -532,20 +511,6 @@ class AuthProtocol(object):
|
|||||||
val = '%s/revoked.pem' % self._signing_dirname
|
val = '%s/revoked.pem' % self._signing_dirname
|
||||||
self._revoked_file_name = val
|
self._revoked_file_name = val
|
||||||
|
|
||||||
# Credentials used to verify this component with the Auth service since
|
|
||||||
# validating tokens is a privileged call
|
|
||||||
self._admin_token = self._conf_get('admin_token')
|
|
||||||
if self._admin_token:
|
|
||||||
self._LOG.warning(
|
|
||||||
"The admin_token option in the auth_token middleware is "
|
|
||||||
"deprecated and should not be used. The admin_user and "
|
|
||||||
"admin_password options should be used instead. The "
|
|
||||||
"admin_token option may be removed in a future release.")
|
|
||||||
self._admin_token_expiry = None
|
|
||||||
self._admin_user = self._conf_get('admin_user')
|
|
||||||
self._admin_password = self._conf_get('admin_password')
|
|
||||||
self._admin_tenant_name = self._conf_get('admin_tenant_name')
|
|
||||||
|
|
||||||
memcache_security_strategy = (
|
memcache_security_strategy = (
|
||||||
self._conf_get('memcache_security_strategy'))
|
self._conf_get('memcache_security_strategy'))
|
||||||
|
|
||||||
@ -562,15 +527,6 @@ class AuthProtocol(object):
|
|||||||
self._token_revocation_list_fetched_time_prop = None
|
self._token_revocation_list_fetched_time_prop = None
|
||||||
self._token_revocation_list_cache_timeout = datetime.timedelta(
|
self._token_revocation_list_cache_timeout = datetime.timedelta(
|
||||||
seconds=self._conf_get('revocation_cache_time'))
|
seconds=self._conf_get('revocation_cache_time'))
|
||||||
http_connect_timeout_cfg = self._conf_get('http_connect_timeout')
|
|
||||||
self._http_connect_timeout = (http_connect_timeout_cfg and
|
|
||||||
int(http_connect_timeout_cfg))
|
|
||||||
self._auth_version = None
|
|
||||||
self._http_request_max_retries = (
|
|
||||||
self._conf_get('http_request_max_retries'))
|
|
||||||
|
|
||||||
self._include_service_catalog = self._conf_get(
|
|
||||||
'include_service_catalog')
|
|
||||||
|
|
||||||
self._check_revocations_for_cached = self._conf_get(
|
self._check_revocations_for_cached = self._conf_get(
|
||||||
'check_revocations_for_cached')
|
'check_revocations_for_cached')
|
||||||
@ -582,64 +538,6 @@ class AuthProtocol(object):
|
|||||||
else:
|
else:
|
||||||
return CONF.keystone_authtoken[name]
|
return CONF.keystone_authtoken[name]
|
||||||
|
|
||||||
def _choose_api_version(self):
|
|
||||||
"""Determine the api version that we should use."""
|
|
||||||
|
|
||||||
# If the configuration specifies an auth_version we will just
|
|
||||||
# assume that is correct and use it. We could, of course, check
|
|
||||||
# that this version is supported by the server, but in case
|
|
||||||
# there are some problems in the field, we want as little code
|
|
||||||
# as possible in the way of letting auth_token talk to the
|
|
||||||
# server.
|
|
||||||
if self._conf_get('auth_version'):
|
|
||||||
version_to_use = self._conf_get('auth_version')
|
|
||||||
self._LOG.info('Auth Token proceeding with requested %s apis',
|
|
||||||
version_to_use)
|
|
||||||
else:
|
|
||||||
version_to_use = None
|
|
||||||
versions_supported_by_server = self._get_supported_versions()
|
|
||||||
if versions_supported_by_server:
|
|
||||||
for version in _LIST_OF_VERSIONS_TO_ATTEMPT:
|
|
||||||
if version in versions_supported_by_server:
|
|
||||||
version_to_use = version
|
|
||||||
break
|
|
||||||
if version_to_use:
|
|
||||||
self._LOG.info('Auth Token confirmed use of %s apis',
|
|
||||||
version_to_use)
|
|
||||||
else:
|
|
||||||
self._LOG.error(
|
|
||||||
'Attempted versions [%s] not in list supported by '
|
|
||||||
'server [%s]',
|
|
||||||
', '.join(_LIST_OF_VERSIONS_TO_ATTEMPT),
|
|
||||||
', '.join(versions_supported_by_server))
|
|
||||||
raise ServiceError('No compatible apis supported by server')
|
|
||||||
return version_to_use
|
|
||||||
|
|
||||||
def _get_supported_versions(self):
|
|
||||||
versions = []
|
|
||||||
response, data = self._json_request('GET', '/')
|
|
||||||
if response.status_code == 501:
|
|
||||||
msg = 'Old keystone installation found...assuming v2.0'
|
|
||||||
self._LOG.warning(msg)
|
|
||||||
versions.append('v2.0')
|
|
||||||
elif response.status_code != 300:
|
|
||||||
self._LOG.error('Unable to get version info from keystone: %s',
|
|
||||||
response.status_code)
|
|
||||||
raise ServiceError('Unable to get version info from keystone')
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
for version in data['versions']['values']:
|
|
||||||
versions.append(version['id'])
|
|
||||||
except KeyError:
|
|
||||||
self._LOG.error(
|
|
||||||
'Invalid version response format from server')
|
|
||||||
raise ServiceError('Unable to parse version response '
|
|
||||||
'from keystone')
|
|
||||||
|
|
||||||
self._LOG.debug('Server reports support for api versions: %s',
|
|
||||||
', '.join(versions))
|
|
||||||
return versions
|
|
||||||
|
|
||||||
def __call__(self, env, start_response):
|
def __call__(self, env, start_response):
|
||||||
"""Handle incoming request.
|
"""Handle incoming request.
|
||||||
|
|
||||||
@ -734,149 +632,12 @@ class AuthProtocol(object):
|
|||||||
:returns HTTPUnauthorized http response
|
:returns HTTPUnauthorized http response
|
||||||
|
|
||||||
"""
|
"""
|
||||||
header_val = 'Keystone uri=\'%s\'' % self._auth_uri
|
header_val = 'Keystone uri=\'%s\'' % self._identity_server.auth_uri
|
||||||
headers = [('WWW-Authenticate', header_val)]
|
headers = [('WWW-Authenticate', header_val)]
|
||||||
resp = _MiniResp('Authentication required', env, headers)
|
resp = _MiniResp('Authentication required', env, headers)
|
||||||
start_response('401 Unauthorized', resp.headers)
|
start_response('401 Unauthorized', resp.headers)
|
||||||
return resp.body
|
return resp.body
|
||||||
|
|
||||||
def _get_admin_token(self):
|
|
||||||
"""Return admin token, possibly fetching a new one.
|
|
||||||
|
|
||||||
if self.admin_token_expiry is set from fetching an admin token, check
|
|
||||||
it for expiration, and request a new token is the existing token
|
|
||||||
is about to expire.
|
|
||||||
|
|
||||||
:return admin token id
|
|
||||||
:raise ServiceError when unable to retrieve token from keystone
|
|
||||||
|
|
||||||
"""
|
|
||||||
if self._admin_token_expiry:
|
|
||||||
if _will_expire_soon(self._admin_token_expiry):
|
|
||||||
self._admin_token = None
|
|
||||||
|
|
||||||
if not self._admin_token:
|
|
||||||
(self._admin_token,
|
|
||||||
self._admin_token_expiry) = self._request_admin_token()
|
|
||||||
|
|
||||||
return self._admin_token
|
|
||||||
|
|
||||||
def _http_request(self, method, path, **kwargs):
|
|
||||||
"""HTTP request helper used to make unspecified content type requests.
|
|
||||||
|
|
||||||
:param method: http method
|
|
||||||
:param path: relative request url
|
|
||||||
:return (http response object, response body)
|
|
||||||
:raise ServerError when unable to communicate with keystone
|
|
||||||
|
|
||||||
"""
|
|
||||||
url = '%s/%s' % (self._identity_uri, path.lstrip('/'))
|
|
||||||
|
|
||||||
kwargs.setdefault('timeout', self._http_connect_timeout)
|
|
||||||
if self._cert_file and self._key_file:
|
|
||||||
kwargs['cert'] = (self._cert_file, self._key_file)
|
|
||||||
elif self._cert_file or self._key_file:
|
|
||||||
self._LOG.warn('Cannot use only a cert or key file. '
|
|
||||||
'Please provide both. Ignoring.')
|
|
||||||
|
|
||||||
kwargs['verify'] = self._ssl_ca_file or True
|
|
||||||
if self._ssl_insecure:
|
|
||||||
kwargs['verify'] = False
|
|
||||||
|
|
||||||
RETRIES = self._http_request_max_retries
|
|
||||||
retry = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
response = requests.request(method, url, **kwargs)
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
if retry >= RETRIES:
|
|
||||||
self._LOG.error('HTTP connection exception: %s', e)
|
|
||||||
raise NetworkError('Unable to communicate with keystone')
|
|
||||||
# NOTE(vish): sleep 0.5, 1, 2
|
|
||||||
self._LOG.warn('Retrying on HTTP connection exception: %s', e)
|
|
||||||
time.sleep(2.0 ** retry / 2)
|
|
||||||
retry += 1
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _json_request(self, method, path, body=None, additional_headers=None):
|
|
||||||
"""HTTP request helper used to make json requests.
|
|
||||||
|
|
||||||
:param method: http method
|
|
||||||
:param path: relative request url
|
|
||||||
:param body: dict to encode to json as request body. Optional.
|
|
||||||
:param additional_headers: dict of additional headers to send with
|
|
||||||
http request. Optional.
|
|
||||||
:return (http response object, response body parsed as json)
|
|
||||||
:raise ServerError when unable to communicate with keystone
|
|
||||||
|
|
||||||
"""
|
|
||||||
kwargs = {
|
|
||||||
'headers': {
|
|
||||||
'Content-type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if additional_headers:
|
|
||||||
kwargs['headers'].update(additional_headers)
|
|
||||||
|
|
||||||
if body:
|
|
||||||
kwargs['data'] = jsonutils.dumps(body)
|
|
||||||
|
|
||||||
response = self._http_request(method, path, **kwargs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = jsonutils.loads(response.text)
|
|
||||||
except ValueError:
|
|
||||||
self._LOG.debug('Keystone did not return json-encoded body')
|
|
||||||
data = {}
|
|
||||||
|
|
||||||
return response, data
|
|
||||||
|
|
||||||
def _request_admin_token(self):
|
|
||||||
"""Retrieve new token as admin user from keystone.
|
|
||||||
|
|
||||||
:return token id upon success
|
|
||||||
:raises ServerError when unable to communicate with keystone
|
|
||||||
|
|
||||||
Irrespective of the auth version we are going to use for the
|
|
||||||
user token, for simplicity we always use a v2 admin token to
|
|
||||||
validate the user token.
|
|
||||||
|
|
||||||
"""
|
|
||||||
params = {
|
|
||||||
'auth': {
|
|
||||||
'passwordCredentials': {
|
|
||||||
'username': self._admin_user,
|
|
||||||
'password': self._admin_password,
|
|
||||||
},
|
|
||||||
'tenantName': self._admin_tenant_name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response, data = self._json_request('POST',
|
|
||||||
'/v2.0/tokens',
|
|
||||||
body=params)
|
|
||||||
|
|
||||||
try:
|
|
||||||
token = data['access']['token']['id']
|
|
||||||
expiry = data['access']['token']['expires']
|
|
||||||
if not (token and expiry):
|
|
||||||
raise AssertionError('invalid token or expire')
|
|
||||||
datetime_expiry = timeutils.parse_isotime(expiry)
|
|
||||||
return (token, timeutils.normalize_time(datetime_expiry))
|
|
||||||
except (AssertionError, KeyError):
|
|
||||||
self._LOG.warn(
|
|
||||||
'Unexpected response from keystone service: %s', data)
|
|
||||||
raise ServiceError('invalid json response')
|
|
||||||
except (ValueError):
|
|
||||||
data['access']['token']['id'] = '<SANITIZED>'
|
|
||||||
self._LOG.warn(
|
|
||||||
'Unable to parse expiration time from token: %s', data)
|
|
||||||
raise ServiceError('invalid json response')
|
|
||||||
|
|
||||||
def _validate_user_token(self, user_token, env, retry=True):
|
def _validate_user_token(self, user_token, env, retry=True):
|
||||||
"""Authenticate user token
|
"""Authenticate user token
|
||||||
|
|
||||||
@ -913,7 +674,7 @@ class AuthProtocol(object):
|
|||||||
verified = self._verify_signed_token(user_token, token_ids)
|
verified = self._verify_signed_token(user_token, token_ids)
|
||||||
data = jsonutils.loads(verified)
|
data = jsonutils.loads(verified)
|
||||||
else:
|
else:
|
||||||
data = self._verify_uuid_token(user_token, retry)
|
data = self._identity_server.verify_token(user_token, retry)
|
||||||
expires = _confirm_token_not_expired(data)
|
expires = _confirm_token_not_expired(data)
|
||||||
self._confirm_token_bind(data, env)
|
self._confirm_token_bind(data, env)
|
||||||
self._token_cache.store(token_id, data, expires)
|
self._token_cache.store(token_id, data, expires)
|
||||||
@ -1078,59 +839,6 @@ class AuthProtocol(object):
|
|||||||
'identifier': identifier})
|
'identifier': identifier})
|
||||||
self._invalid_user_token()
|
self._invalid_user_token()
|
||||||
|
|
||||||
def _verify_uuid_token(self, user_token, retry=True):
|
|
||||||
"""Authenticate user token with keystone.
|
|
||||||
|
|
||||||
:param user_token: user's token id
|
|
||||||
:param retry: flag that forces the middleware to retry
|
|
||||||
user authentication when an indeterminate
|
|
||||||
response is received. Optional.
|
|
||||||
:return: token object received from keystone on success
|
|
||||||
:raise InvalidUserToken: if token is rejected
|
|
||||||
:raise ServiceError: if unable to authenticate token
|
|
||||||
|
|
||||||
"""
|
|
||||||
# Determine the highest api version we can use.
|
|
||||||
if not self._auth_version:
|
|
||||||
self._auth_version = self._choose_api_version()
|
|
||||||
|
|
||||||
if self._auth_version == 'v3.0':
|
|
||||||
headers = {'X-Auth-Token': self._get_admin_token(),
|
|
||||||
'X-Subject-Token': _safe_quote(user_token)}
|
|
||||||
path = '/v3/auth/tokens'
|
|
||||||
if not self._include_service_catalog:
|
|
||||||
# NOTE(gyee): only v3 API support this option
|
|
||||||
path = path + '?nocatalog'
|
|
||||||
response, data = self._json_request(
|
|
||||||
'GET',
|
|
||||||
path,
|
|
||||||
additional_headers=headers)
|
|
||||||
else:
|
|
||||||
headers = {'X-Auth-Token': self._get_admin_token()}
|
|
||||||
response, data = self._json_request(
|
|
||||||
'GET',
|
|
||||||
'/v2.0/tokens/%s' % _safe_quote(user_token),
|
|
||||||
additional_headers=headers)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
return data
|
|
||||||
if response.status_code == 404:
|
|
||||||
self._LOG.warn('Authorization failed for token')
|
|
||||||
raise InvalidUserToken('Token authorization failed')
|
|
||||||
if response.status_code == 401:
|
|
||||||
self._LOG.info('Keystone rejected admin token, resetting')
|
|
||||||
self.admin_token = None
|
|
||||||
else:
|
|
||||||
self._LOG.error('Bad response code while validating token: %s',
|
|
||||||
response.status_code)
|
|
||||||
if retry:
|
|
||||||
self._LOG.info('Retrying validation')
|
|
||||||
return self._verify_uuid_token(user_token, False)
|
|
||||||
else:
|
|
||||||
self._LOG.warn('Invalid user token. Keystone response: %s', data)
|
|
||||||
|
|
||||||
raise InvalidUserToken()
|
|
||||||
|
|
||||||
def _is_signed_token_revoked(self, token_ids):
|
def _is_signed_token_revoked(self, token_ids):
|
||||||
"""Indicate whether the token appears in the revocation list."""
|
"""Indicate whether the token appears in the revocation list."""
|
||||||
for token_id in token_ids:
|
for token_id in token_ids:
|
||||||
@ -1280,42 +988,19 @@ class AuthProtocol(object):
|
|||||||
self._token_revocation_list_fetched_time = timeutils.utcnow()
|
self._token_revocation_list_fetched_time = timeutils.utcnow()
|
||||||
self._atomic_write_to_signing_dir(self._revoked_file_name, value)
|
self._atomic_write_to_signing_dir(self._revoked_file_name, value)
|
||||||
|
|
||||||
def _fetch_revocation_list(self, retry=True):
|
def _fetch_revocation_list(self):
|
||||||
headers = {'X-Auth-Token': self._get_admin_token()}
|
revocation_list_data = self._identity_server.fetch_revocation_list()
|
||||||
response, data = self._json_request('GET', '/v2.0/tokens/revoked',
|
return self._cms_verify(revocation_list_data)
|
||||||
additional_headers=headers)
|
|
||||||
if response.status_code == 401:
|
|
||||||
if retry:
|
|
||||||
self._LOG.info(
|
|
||||||
'Keystone rejected admin token, resetting admin token')
|
|
||||||
self._admin_token = None
|
|
||||||
return self._fetch_revocation_list(retry=False)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise ServiceError('Unable to fetch token revocation list.')
|
|
||||||
if 'signed' not in data:
|
|
||||||
raise ServiceError('Revocation list improperly formatted.')
|
|
||||||
return self._cms_verify(data['signed'])
|
|
||||||
|
|
||||||
def _fetch_cert_file(self, cert_file_name, cert_type):
|
|
||||||
if not self._auth_version:
|
|
||||||
self._auth_version = self._choose_api_version()
|
|
||||||
|
|
||||||
if self._auth_version == 'v3.0':
|
|
||||||
if cert_type == 'signing':
|
|
||||||
cert_type = 'certificates'
|
|
||||||
path = '/v3/OS-SIMPLE-CERT/' + cert_type
|
|
||||||
else:
|
|
||||||
path = '/v2.0/certificates/' + cert_type
|
|
||||||
response = self._http_request('GET', path)
|
|
||||||
if response.status_code != 200:
|
|
||||||
raise exceptions.CertificateConfigError(response.text)
|
|
||||||
self._atomic_write_to_signing_dir(cert_file_name, response.text)
|
|
||||||
|
|
||||||
def _fetch_signing_cert(self):
|
def _fetch_signing_cert(self):
|
||||||
self._fetch_cert_file(self._signing_cert_file_name, 'signing')
|
self._atomic_write_to_signing_dir(
|
||||||
|
self._signing_cert_file_name,
|
||||||
|
self._identity_server.fetch_signing_cert())
|
||||||
|
|
||||||
def _fetch_ca_cert(self):
|
def _fetch_ca_cert(self):
|
||||||
self._fetch_cert_file(self._signing_ca_file_name, 'ca')
|
self._atomic_write_to_signing_dir(
|
||||||
|
self._signing_ca_file_name,
|
||||||
|
self._identity_server.fetch_ca_cert())
|
||||||
|
|
||||||
|
|
||||||
class _CachePool(list):
|
class _CachePool(list):
|
||||||
@ -1345,6 +1030,378 @@ class _CachePool(list):
|
|||||||
self.append(c)
|
self.append(c)
|
||||||
|
|
||||||
|
|
||||||
|
class _IdentityServer(object):
|
||||||
|
"""Operations on the Identity API server.
|
||||||
|
|
||||||
|
The auth_token middleware needs to communicate with the Identity API server
|
||||||
|
to validate UUID tokens, fetch the revocation list, signing certificates,
|
||||||
|
etc. This class encapsulates the data and methods to perform these
|
||||||
|
operations.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, log, include_service_catalog=None, identity_uri=None,
|
||||||
|
auth_uri=None, auth_host=None, auth_port=None,
|
||||||
|
auth_protocol=None, auth_admin_prefix=None, cert_file=None,
|
||||||
|
key_file=None, ssl_ca_file=None, ssl_insecure=None,
|
||||||
|
admin_token=None, admin_user=None, admin_password=None,
|
||||||
|
admin_tenant_name=None, http_connect_timeout=None,
|
||||||
|
http_request_max_retries=None, auth_version=None):
|
||||||
|
self._LOG = log
|
||||||
|
self._include_service_catalog = include_service_catalog
|
||||||
|
self._req_auth_version = auth_version
|
||||||
|
|
||||||
|
# where to find the auth service (we use this to validate tokens)
|
||||||
|
self._identity_uri = identity_uri
|
||||||
|
self.auth_uri = auth_uri
|
||||||
|
|
||||||
|
# NOTE(jamielennox): it does appear here that our defaults arguments
|
||||||
|
# are backwards. We need to do it this way so that we can handle the
|
||||||
|
# same deprecation strategy for CONF and the conf variable.
|
||||||
|
if not self._identity_uri:
|
||||||
|
self._LOG.warning('Configuring admin URI using auth fragments. '
|
||||||
|
'This is deprecated, use \'identity_uri\''
|
||||||
|
' instead.')
|
||||||
|
|
||||||
|
if netaddr.valid_ipv6(auth_host):
|
||||||
|
# Note(dzyu) it is an IPv6 address, so it needs to be wrapped
|
||||||
|
# with '[]' to generate a valid IPv6 URL, based on
|
||||||
|
# http://www.ietf.org/rfc/rfc2732.txt
|
||||||
|
auth_host = '[%s]' % auth_host
|
||||||
|
|
||||||
|
self._identity_uri = '%s://%s:%s' % (auth_protocol, auth_host,
|
||||||
|
auth_port)
|
||||||
|
if auth_admin_prefix:
|
||||||
|
self._identity_uri = '%s/%s' % (self._identity_uri,
|
||||||
|
auth_admin_prefix.strip('/'))
|
||||||
|
else:
|
||||||
|
self._identity_uri = self._identity_uri.rstrip('/')
|
||||||
|
|
||||||
|
if self.auth_uri is None:
|
||||||
|
self._LOG.warning(
|
||||||
|
'Configuring auth_uri to point to the public identity '
|
||||||
|
'endpoint is required; clients may not be able to '
|
||||||
|
'authenticate against an admin endpoint')
|
||||||
|
|
||||||
|
# FIXME(dolph): drop support for this fallback behavior as
|
||||||
|
# documented in bug 1207517.
|
||||||
|
# NOTE(jamielennox): we urljoin '/' to get just the base URI as
|
||||||
|
# this is the original behaviour.
|
||||||
|
self.auth_uri = urllib.parse.urljoin(self._identity_uri, '/')
|
||||||
|
self.auth_uri = self.auth_uri.rstrip('/')
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
self._cert_file = cert_file
|
||||||
|
self._key_file = key_file
|
||||||
|
self._ssl_ca_file = ssl_ca_file
|
||||||
|
self._ssl_insecure = ssl_insecure
|
||||||
|
|
||||||
|
# Credentials used to verify this component with the Auth service since
|
||||||
|
# validating tokens is a privileged call
|
||||||
|
self._admin_token = admin_token
|
||||||
|
if self._admin_token:
|
||||||
|
self._LOG.warning(
|
||||||
|
"The admin_token option in the auth_token middleware is "
|
||||||
|
"deprecated and should not be used. The admin_user and "
|
||||||
|
"admin_password options should be used instead. The "
|
||||||
|
"admin_token option may be removed in a future release.")
|
||||||
|
self._admin_token_expiry = None
|
||||||
|
self._admin_user = admin_user
|
||||||
|
self._admin_password = admin_password
|
||||||
|
self._admin_tenant_name = admin_tenant_name
|
||||||
|
|
||||||
|
self._http_connect_timeout = http_connect_timeout
|
||||||
|
|
||||||
|
self._auth_version = None
|
||||||
|
self._http_request_max_retries = http_request_max_retries
|
||||||
|
|
||||||
|
def verify_token(self, user_token, retry=True):
|
||||||
|
"""Authenticate user token with keystone.
|
||||||
|
|
||||||
|
:param user_token: user's token id
|
||||||
|
:param retry: flag that forces the middleware to retry
|
||||||
|
user authentication when an indeterminate
|
||||||
|
response is received. Optional.
|
||||||
|
:return: token object received from keystone on success
|
||||||
|
:raise InvalidUserToken: if token is rejected
|
||||||
|
:raise ServiceError: if unable to authenticate token
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Determine the highest api version we can use.
|
||||||
|
if not self._auth_version:
|
||||||
|
self._auth_version = self._choose_api_version()
|
||||||
|
|
||||||
|
if self._auth_version == 'v3.0':
|
||||||
|
headers = {'X-Auth-Token': self.get_admin_token(),
|
||||||
|
'X-Subject-Token': _safe_quote(user_token)}
|
||||||
|
path = '/v3/auth/tokens'
|
||||||
|
if not self._include_service_catalog:
|
||||||
|
# NOTE(gyee): only v3 API support this option
|
||||||
|
path = path + '?nocatalog'
|
||||||
|
response, data = self._json_request(
|
||||||
|
'GET',
|
||||||
|
path,
|
||||||
|
additional_headers=headers)
|
||||||
|
else:
|
||||||
|
headers = {'X-Auth-Token': self.get_admin_token()}
|
||||||
|
response, data = self._json_request(
|
||||||
|
'GET',
|
||||||
|
'/v2.0/tokens/%s' % _safe_quote(user_token),
|
||||||
|
additional_headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return data
|
||||||
|
if response.status_code == 404:
|
||||||
|
self._LOG.warn('Authorization failed for token')
|
||||||
|
raise InvalidUserToken('Token authorization failed')
|
||||||
|
if response.status_code == 401:
|
||||||
|
self._LOG.info(
|
||||||
|
'Keystone rejected admin token, resetting')
|
||||||
|
self._admin_token = None
|
||||||
|
else:
|
||||||
|
self._LOG.error('Bad response code while validating token: %s',
|
||||||
|
response.status_code)
|
||||||
|
if retry:
|
||||||
|
self._LOG.info('Retrying validation')
|
||||||
|
return self.verify_token(user_token, False)
|
||||||
|
else:
|
||||||
|
self._LOG.warn('Invalid user token. Keystone response: %s', data)
|
||||||
|
|
||||||
|
raise InvalidUserToken()
|
||||||
|
|
||||||
|
def fetch_revocation_list(self, retry=True):
|
||||||
|
headers = {'X-Auth-Token': self.get_admin_token()}
|
||||||
|
response, data = self._json_request('GET', '/v2.0/tokens/revoked',
|
||||||
|
additional_headers=headers)
|
||||||
|
if response.status_code == 401:
|
||||||
|
if retry:
|
||||||
|
self._LOG.info(
|
||||||
|
'Keystone rejected admin token, resetting admin token')
|
||||||
|
self._admin_token = None
|
||||||
|
return self.fetch_revocation_list(retry=False)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ServiceError('Unable to fetch token revocation list.')
|
||||||
|
if 'signed' not in data:
|
||||||
|
raise ServiceError('Revocation list improperly formatted.')
|
||||||
|
return data['signed']
|
||||||
|
|
||||||
|
def fetch_signing_cert(self):
|
||||||
|
return self._fetch_cert_file('signing')
|
||||||
|
|
||||||
|
def fetch_ca_cert(self):
|
||||||
|
return self._fetch_cert_file('ca')
|
||||||
|
|
||||||
|
def _choose_api_version(self):
|
||||||
|
"""Determine the api version that we should use."""
|
||||||
|
|
||||||
|
# If the configuration specifies an auth_version we will just
|
||||||
|
# assume that is correct and use it. We could, of course, check
|
||||||
|
# that this version is supported by the server, but in case
|
||||||
|
# there are some problems in the field, we want as little code
|
||||||
|
# as possible in the way of letting auth_token talk to the
|
||||||
|
# server.
|
||||||
|
if self._req_auth_version:
|
||||||
|
version_to_use = self._req_auth_version
|
||||||
|
self._LOG.info('Auth Token proceeding with requested %s apis',
|
||||||
|
version_to_use)
|
||||||
|
else:
|
||||||
|
version_to_use = None
|
||||||
|
versions_supported_by_server = self._get_supported_versions()
|
||||||
|
if versions_supported_by_server:
|
||||||
|
for version in _LIST_OF_VERSIONS_TO_ATTEMPT:
|
||||||
|
if version in versions_supported_by_server:
|
||||||
|
version_to_use = version
|
||||||
|
break
|
||||||
|
if version_to_use:
|
||||||
|
self._LOG.info('Auth Token confirmed use of %s apis',
|
||||||
|
version_to_use)
|
||||||
|
else:
|
||||||
|
self._LOG.error(
|
||||||
|
'Attempted versions [%s] not in list supported by '
|
||||||
|
'server [%s]',
|
||||||
|
', '.join(_LIST_OF_VERSIONS_TO_ATTEMPT),
|
||||||
|
', '.join(versions_supported_by_server))
|
||||||
|
raise ServiceError('No compatible apis supported by server')
|
||||||
|
return version_to_use
|
||||||
|
|
||||||
|
def _get_supported_versions(self):
|
||||||
|
versions = []
|
||||||
|
response, data = self._json_request('GET', '/')
|
||||||
|
if response.status_code == 501:
|
||||||
|
self._LOG.warning(
|
||||||
|
'Old keystone installation found...assuming v2.0')
|
||||||
|
versions.append('v2.0')
|
||||||
|
elif response.status_code != 300:
|
||||||
|
self._LOG.error('Unable to get version info from keystone: %s',
|
||||||
|
response.status_code)
|
||||||
|
raise ServiceError('Unable to get version info from keystone')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
for version in data['versions']['values']:
|
||||||
|
versions.append(version['id'])
|
||||||
|
except KeyError:
|
||||||
|
self._LOG.error(
|
||||||
|
'Invalid version response format from server')
|
||||||
|
raise ServiceError('Unable to parse version response '
|
||||||
|
'from keystone')
|
||||||
|
|
||||||
|
self._LOG.debug('Server reports support for api versions: %s',
|
||||||
|
', '.join(versions))
|
||||||
|
return versions
|
||||||
|
|
||||||
|
def get_admin_token(self):
|
||||||
|
"""Return admin token, possibly fetching a new one.
|
||||||
|
|
||||||
|
if self._admin_token_expiry is set from fetching an admin token, check
|
||||||
|
it for expiration, and request a new token is the existing token
|
||||||
|
is about to expire.
|
||||||
|
|
||||||
|
:return admin token id
|
||||||
|
:raise ServiceError when unable to retrieve token from keystone
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self._admin_token_expiry:
|
||||||
|
if _will_expire_soon(self._admin_token_expiry):
|
||||||
|
self._admin_token = None
|
||||||
|
|
||||||
|
if not self._admin_token:
|
||||||
|
(self._admin_token,
|
||||||
|
self._admin_token_expiry) = self._request_admin_token()
|
||||||
|
|
||||||
|
return self._admin_token
|
||||||
|
|
||||||
|
def _http_request(self, method, path, **kwargs):
|
||||||
|
"""HTTP request helper used to make unspecified content type requests.
|
||||||
|
|
||||||
|
:param method: http method
|
||||||
|
:param path: relative request url
|
||||||
|
:return (http response object, response body)
|
||||||
|
:raise ServerError when unable to communicate with keystone
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = '%s/%s' % (self._identity_uri, path.lstrip('/'))
|
||||||
|
|
||||||
|
kwargs.setdefault('timeout', self._http_connect_timeout)
|
||||||
|
if self._cert_file and self._key_file:
|
||||||
|
kwargs['cert'] = (self._cert_file, self._key_file)
|
||||||
|
elif self._cert_file or self._key_file:
|
||||||
|
self._LOG.warn('Cannot use only a cert or key file. '
|
||||||
|
'Please provide both. Ignoring.')
|
||||||
|
|
||||||
|
kwargs['verify'] = self._ssl_ca_file or True
|
||||||
|
if self._ssl_insecure:
|
||||||
|
kwargs['verify'] = False
|
||||||
|
|
||||||
|
RETRIES = self._http_request_max_retries
|
||||||
|
retry = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
response = requests.request(method, url, **kwargs)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
if retry >= RETRIES:
|
||||||
|
self._LOG.error('HTTP connection exception: %s', e)
|
||||||
|
raise NetworkError('Unable to communicate with keystone')
|
||||||
|
# NOTE(vish): sleep 0.5, 1, 2
|
||||||
|
self._LOG.warn('Retrying on HTTP connection exception: %s', e)
|
||||||
|
time.sleep(2.0 ** retry / 2)
|
||||||
|
retry += 1
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _json_request(self, method, path, body=None, additional_headers=None):
|
||||||
|
"""HTTP request helper used to make json requests.
|
||||||
|
|
||||||
|
:param method: http method
|
||||||
|
:param path: relative request url
|
||||||
|
:param body: dict to encode to json as request body. Optional.
|
||||||
|
:param additional_headers: dict of additional headers to send with
|
||||||
|
http request. Optional.
|
||||||
|
:return (http response object, response body parsed as json)
|
||||||
|
:raise ServerError when unable to communicate with keystone
|
||||||
|
|
||||||
|
"""
|
||||||
|
kwargs = {
|
||||||
|
'headers': {
|
||||||
|
'Content-type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if additional_headers:
|
||||||
|
kwargs['headers'].update(additional_headers)
|
||||||
|
|
||||||
|
if body:
|
||||||
|
kwargs['data'] = jsonutils.dumps(body)
|
||||||
|
|
||||||
|
response = self._http_request(method, path, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = jsonutils.loads(response.text)
|
||||||
|
except ValueError:
|
||||||
|
self._LOG.debug('Keystone did not return json-encoded body')
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
return response, data
|
||||||
|
|
||||||
|
def _request_admin_token(self):
|
||||||
|
"""Retrieve new token as admin user from keystone.
|
||||||
|
|
||||||
|
:return token id upon success
|
||||||
|
:raises ServerError when unable to communicate with keystone
|
||||||
|
|
||||||
|
Irrespective of the auth version we are going to use for the
|
||||||
|
user token, for simplicity we always use a v2 admin token to
|
||||||
|
validate the user token.
|
||||||
|
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
'auth': {
|
||||||
|
'passwordCredentials': {
|
||||||
|
'username': self._admin_user,
|
||||||
|
'password': self._admin_password,
|
||||||
|
},
|
||||||
|
'tenantName': self._admin_tenant_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response, data = self._json_request('POST',
|
||||||
|
'/v2.0/tokens',
|
||||||
|
body=params)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = data['access']['token']['id']
|
||||||
|
expiry = data['access']['token']['expires']
|
||||||
|
if not (token and expiry):
|
||||||
|
raise AssertionError('invalid token or expire')
|
||||||
|
datetime_expiry = timeutils.parse_isotime(expiry)
|
||||||
|
return (token, timeutils.normalize_time(datetime_expiry))
|
||||||
|
except (AssertionError, KeyError):
|
||||||
|
self._LOG.warn(
|
||||||
|
'Unexpected response from keystone service: %s', data)
|
||||||
|
raise ServiceError('invalid json response')
|
||||||
|
except (ValueError):
|
||||||
|
data['access']['token']['id'] = '<SANITIZED>'
|
||||||
|
self._LOG.warn(
|
||||||
|
'Unable to parse expiration time from token: %s', data)
|
||||||
|
raise ServiceError('invalid json response')
|
||||||
|
|
||||||
|
def _fetch_cert_file(self, cert_type):
|
||||||
|
if not self._auth_version:
|
||||||
|
self._auth_version = self._choose_api_version()
|
||||||
|
|
||||||
|
if self._auth_version == 'v3.0':
|
||||||
|
if cert_type == 'signing':
|
||||||
|
cert_type = 'certificates'
|
||||||
|
path = '/v3/OS-SIMPLE-CERT/' + cert_type
|
||||||
|
else:
|
||||||
|
path = '/v2.0/certificates/' + cert_type
|
||||||
|
response = self._http_request('GET', path)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise exceptions.CertificateConfigError(response.text)
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
class _TokenCache(object):
|
class _TokenCache(object):
|
||||||
"""Encapsulates the auth_token token cache functionality.
|
"""Encapsulates the auth_token token cache functionality.
|
||||||
|
|
||||||
|
@ -581,7 +581,8 @@ class CommonAuthTokenMiddlewareTest(object):
|
|||||||
}
|
}
|
||||||
self.set_middleware(conf=conf)
|
self.set_middleware(conf=conf)
|
||||||
expected_auth_uri = 'http://[2001:2013:1:f101::1]:1234'
|
expected_auth_uri = 'http://[2001:2013:1:f101::1]:1234'
|
||||||
self.assertEqual(expected_auth_uri, self.middleware._auth_uri)
|
self.assertEqual(expected_auth_uri,
|
||||||
|
self.middleware._identity_server.auth_uri)
|
||||||
|
|
||||||
def assert_valid_request_200(self, token, with_catalog=True):
|
def assert_valid_request_200(self, token, with_catalog=True):
|
||||||
req = webob.Request.blank('/')
|
req = webob.Request.blank('/')
|
||||||
|
Loading…
Reference in New Issue
Block a user