Skip to content

Commit b0aea93

Browse files
sorrisontipabu
authored andcommitted
s3 secret caching
To increase performance of the s3 API retrieve and cache s3 secret from keystone to allow for local validation. Disabled by default, to use set 'secret_cache_duration' to a number greater than 0. You will also need to configure keystone auth credentials in the s3token configuration group too. These credentials will need to be able to view all projects credentials in keystone. Change-Id: Id0c01da6aa6ca804c8f49a307b5171b87ec92228
1 parent 6edd70b commit b0aea93

File tree

3 files changed

+216
-57
lines changed

3 files changed

+216
-57
lines changed

doc/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ sphinx>=1.6.2 # BSD
66
openstackdocstheme>=1.11.0 # Apache-2.0
77
reno>=1.8.0 # Apache-2.0
88
os-api-ref>=1.0.0 # Apache-2.0
9+
python-keystoneclient!=2.1.0,>=2.0.0 # Apache-2.0

swift/common/middleware/s3api/s3token.py

Lines changed: 123 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,25 @@
3030
access key.
3131
* Validates s3 token with Keystone.
3232
* Transforms the account name to AUTH_%(tenant_name).
33+
* Optionally can retrieve and cache secret from keystone
34+
to validate signature locally
3335
3436
"""
3537

3638
import base64
3739
import json
3840

41+
from keystoneclient.v3 import client as keystone_client
42+
from keystoneauth1 import session as keystone_session
43+
from keystoneauth1 import loading as keystone_loading
3944
import requests
4045
import six
4146
from six.moves import urllib
4247

4348
from swift.common.swob import Request, HTTPBadRequest, HTTPUnauthorized, \
4449
HTTPException
45-
from swift.common.utils import config_true_value, split_path, get_logger
50+
from swift.common.utils import config_true_value, split_path, get_logger, \
51+
cache_from_env
4652
from swift.common.wsgi import ConfigFileError
4753

4854

@@ -155,6 +161,31 @@ def __init__(self, app, conf):
155161
else:
156162
self._verify = None
157163

164+
self._secret_cache_duration = int(conf.get('secret_cache_duration', 0))
165+
if self._secret_cache_duration > 0:
166+
try:
167+
auth_plugin = keystone_loading.get_plugin_loader(
168+
conf.get('auth_type'))
169+
available_auth_options = auth_plugin.get_options()
170+
auth_options = {}
171+
for option in available_auth_options:
172+
name = option.name.replace('-', '_')
173+
value = conf.get(name)
174+
if value:
175+
auth_options[name] = value
176+
177+
auth = auth_plugin.load_from_options(**auth_options)
178+
session = keystone_session.Session(auth=auth)
179+
self.keystoneclient = keystone_client.Client(session=session)
180+
self._logger.info("Caching s3tokens for %s seconds",
181+
self._secret_cache_duration)
182+
except Exception:
183+
self._logger.warning("Unable to load keystone auth_plugin. "
184+
"Secret caching will be unavailable.",
185+
exc_info=True)
186+
self.keystoneclient = None
187+
self._secret_cache_duration = 0
188+
158189
def _deny_request(self, code):
159190
error_cls, message = {
160191
'AccessDenied': (HTTPUnauthorized, 'Access denied'),
@@ -245,64 +276,99 @@ def __call__(self, environ, start_response):
245276
creds = {'credentials': {'access': access,
246277
'token': token,
247278
'signature': signature}}
248-
creds_json = json.dumps(creds)
249-
self._logger.debug('Connecting to Keystone sending this JSON: %s',
250-
creds_json)
251-
# NOTE(vish): We could save a call to keystone by having
252-
# keystone return token, tenant, user, and roles
253-
# from this call.
254-
#
255-
# NOTE(chmou): We still have the same problem we would need to
256-
# change token_auth to detect if we already
257-
# identified and not doing a second query and just
258-
# pass it through to swiftauth in this case.
259-
try:
260-
# NB: requests.Response, not swob.Response
261-
resp = self._json_request(creds_json)
262-
except HTTPException as e_resp:
263-
if self._delay_auth_decision:
264-
msg = 'Received error, deferring rejection based on error: %s'
265-
self._logger.debug(msg, e_resp.status)
266-
return self._app(environ, start_response)
267-
else:
268-
msg = 'Received error, rejecting request with error: %s'
269-
self._logger.debug(msg, e_resp.status)
270-
# NB: swob.Response, not requests.Response
271-
return e_resp(environ, start_response)
272-
273-
self._logger.debug('Keystone Reply: Status: %d, Output: %s',
274-
resp.status_code, resp.content)
275279

276-
try:
277-
token = resp.json()
278-
if 'access' in token:
279-
headers, token_id, tenant = parse_v2_response(token)
280-
elif 'token' in token:
281-
headers, token_id, tenant = parse_v3_response(token)
282-
else:
283-
raise ValueError
284-
285-
# Populate the environment similar to auth_token,
286-
# so we don't have to contact Keystone again.
280+
memcache_client = None
281+
memcache_token_key = 's3secret/%s' % access
282+
if self._secret_cache_duration > 0:
283+
memcache_client = cache_from_env(environ)
284+
cached_auth_data = None
285+
286+
if memcache_client:
287+
cached_auth_data = memcache_client.get(memcache_token_key)
288+
if cached_auth_data:
289+
headers, token_id, tenant, secret = cached_auth_data
290+
if s3_auth_details['check_signature'](secret):
291+
self._logger.debug("Cached creds valid")
292+
else:
293+
self._logger.debug("Cached creds invalid")
294+
cached_auth_data = None
295+
296+
if not cached_auth_data:
297+
creds_json = json.dumps(creds)
298+
self._logger.debug('Connecting to Keystone sending this JSON: %s',
299+
creds_json)
300+
# NOTE(vish): We could save a call to keystone by having
301+
# keystone return token, tenant, user, and roles
302+
# from this call.
287303
#
288-
# Note that although the strings are unicode following json
289-
# deserialization, Swift's HeaderEnvironProxy handles ensuring
290-
# they're stored as native strings
291-
req.headers.update(headers)
292-
req.environ['keystone.token_info'] = token
293-
except (ValueError, KeyError, TypeError):
294-
if self._delay_auth_decision:
295-
error = ('Error on keystone reply: %d %s - '
296-
'deferring rejection downstream')
297-
self._logger.debug(error, resp.status_code, resp.content)
298-
return self._app(environ, start_response)
299-
else:
300-
error = ('Error on keystone reply: %d %s - '
301-
'rejecting request')
302-
self._logger.debug(error, resp.status_code, resp.content)
303-
return self._deny_request('InvalidURI')(
304-
environ, start_response)
305-
304+
# NOTE(chmou): We still have the same problem we would need to
305+
# change token_auth to detect if we already
306+
# identified and not doing a second query and just
307+
# pass it through to swiftauth in this case.
308+
try:
309+
# NB: requests.Response, not swob.Response
310+
resp = self._json_request(creds_json)
311+
except HTTPException as e_resp:
312+
if self._delay_auth_decision:
313+
msg = ('Received error, deferring rejection based on '
314+
'error: %s')
315+
self._logger.debug(msg, e_resp.status)
316+
return self._app(environ, start_response)
317+
else:
318+
msg = 'Received error, rejecting request with error: %s'
319+
self._logger.debug(msg, e_resp.status)
320+
# NB: swob.Response, not requests.Response
321+
return e_resp(environ, start_response)
322+
323+
self._logger.debug('Keystone Reply: Status: %d, Output: %s',
324+
resp.status_code, resp.content)
325+
326+
try:
327+
token = resp.json()
328+
if 'access' in token:
329+
headers, token_id, tenant = parse_v2_response(token)
330+
elif 'token' in token:
331+
headers, token_id, tenant = parse_v3_response(token)
332+
else:
333+
raise ValueError
334+
if memcache_client:
335+
user_id = headers.get('X-User-Id')
336+
if not user_id:
337+
raise ValueError
338+
try:
339+
cred_ref = self.keystoneclient.ec2.get(
340+
user_id=user_id,
341+
access=access)
342+
memcache_client.set(
343+
memcache_token_key,
344+
(headers, token_id, tenant, cred_ref.secret),
345+
time=self._secret_cache_duration)
346+
self._logger.debug("Cached keystone credentials")
347+
except Exception:
348+
self._logger.warning("Unable to cache secret",
349+
exc_info=True)
350+
351+
# Populate the environment similar to auth_token,
352+
# so we don't have to contact Keystone again.
353+
#
354+
# Note that although the strings are unicode following json
355+
# deserialization, Swift's HeaderEnvironProxy handles ensuring
356+
# they're stored as native strings
357+
req.environ['keystone.token_info'] = token
358+
except (ValueError, KeyError, TypeError):
359+
if self._delay_auth_decision:
360+
error = ('Error on keystone reply: %d %s - '
361+
'deferring rejection downstream')
362+
self._logger.debug(error, resp.status_code, resp.content)
363+
return self._app(environ, start_response)
364+
else:
365+
error = ('Error on keystone reply: %d %s - '
366+
'rejecting request')
367+
self._logger.debug(error, resp.status_code, resp.content)
368+
return self._deny_request('InvalidURI')(
369+
environ, start_response)
370+
371+
req.headers.update(headers)
306372
req.headers['X-Auth-Token'] = token_id
307373
tenant_to_connect = force_tenant or tenant['id']
308374
if six.PY2 and isinstance(tenant_to_connect, six.text_type):

test/unit/common/middleware/s3api/test_s3token.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,98 @@ def test_authorize_with_access_key_and_unquote_chars(self):
504504
self._assert_authorized(req, account_path='/v1/')
505505
self.assertEqual(req.environ['PATH_INFO'], '/v1/AUTH_TENANT_ID/c/o')
506506

507+
@mock.patch('swift.common.middleware.s3api.s3token.cache_from_env')
508+
@mock.patch('keystoneclient.v3.client.Client')
509+
@mock.patch.object(requests, 'post')
510+
def test_secret_is_cached(self, MOCK_REQUEST, MOCK_KEYSTONE,
511+
MOCK_CACHE_FROM_ENV):
512+
self.middleware = s3token.filter_factory({
513+
'auth_uri': 'http://example.com',
514+
'secret_cache_duration': '20',
515+
'auth_type': 'v3password',
516+
'auth_url': 'http://example.com:5000/v3',
517+
'username': 'swift',
518+
'password': 'secret',
519+
'project_name': 'service',
520+
'user_domain_name': 'default',
521+
'project_domain_name': 'default',
522+
})(FakeApp())
523+
self.assertEqual(20, self.middleware._secret_cache_duration)
524+
525+
cache = MOCK_CACHE_FROM_ENV.return_value
526+
527+
fake_cache_response = ({}, 'token_id', {'id': 'tenant_id'}, 'secret')
528+
cache.get.return_value = fake_cache_response
529+
530+
MOCK_REQUEST.return_value = TestResponse({
531+
'status_code': 201,
532+
'text': json.dumps(GOOD_RESPONSE_V2)})
533+
534+
req = Request.blank('/v1/AUTH_cfa/c/o')
535+
req.environ['s3api.auth_details'] = {
536+
'access_key': u'access',
537+
'signature': u'signature',
538+
'string_to_sign': u'token',
539+
'check_signature': lambda x: True
540+
}
541+
req.get_response(self.middleware)
542+
# Ensure we don't request auth from keystone
543+
self.assertFalse(MOCK_REQUEST.called)
544+
545+
@mock.patch('swift.common.middleware.s3api.s3token.cache_from_env')
546+
@mock.patch('keystoneclient.v3.client.Client')
547+
@mock.patch.object(requests, 'post')
548+
def test_secret_sets_cache(self, MOCK_REQUEST, MOCK_KEYSTONE,
549+
MOCK_CACHE_FROM_ENV):
550+
self.middleware = s3token.filter_factory({
551+
'auth_uri': 'http://example.com',
552+
'secret_cache_duration': '20',
553+
'auth_type': 'v3password',
554+
'auth_url': 'http://example.com:5000/v3',
555+
'username': 'swift',
556+
'password': 'secret',
557+
'project_name': 'service',
558+
'user_domain_name': 'default',
559+
'project_domain_name': 'default',
560+
})(FakeApp())
561+
self.assertEqual(20, self.middleware._secret_cache_duration)
562+
563+
cache = MOCK_CACHE_FROM_ENV.return_value
564+
cache.get.return_value = None
565+
566+
keystone_client = MOCK_KEYSTONE.return_value
567+
keystone_client.ec2.get.return_value = mock.Mock(secret='secret')
568+
569+
MOCK_REQUEST.return_value = TestResponse({
570+
'status_code': 201,
571+
'text': json.dumps(GOOD_RESPONSE_V2)})
572+
573+
req = Request.blank('/v1/AUTH_cfa/c/o')
574+
req.environ['s3api.auth_details'] = {
575+
'access_key': u'access',
576+
'signature': u'signature',
577+
'string_to_sign': u'token',
578+
'check_signature': lambda x: True
579+
}
580+
req.get_response(self.middleware)
581+
expected_headers = {
582+
'X-Identity-Status': u'Confirmed',
583+
'X-Roles': u'swift-user,_member_',
584+
'X-User-Id': u'USER_ID',
585+
'X-User-Name': u'S3_USER',
586+
'X-Tenant-Id': u'TENANT_ID',
587+
'X-Tenant-Name': u'TENANT_NAME',
588+
'X-Project-Id': u'TENANT_ID',
589+
'X-Project-Name': u'TENANT_NAME',
590+
}
591+
592+
self.assertTrue(MOCK_REQUEST.called)
593+
tenant = GOOD_RESPONSE_V2['access']['token']['tenant']
594+
token = GOOD_RESPONSE_V2['access']['token']['id']
595+
expected_cache = (expected_headers, token, tenant, 'secret')
596+
cache.set.assert_called_once_with('s3secret/access', expected_cache,
597+
time=20)
598+
507599

508600
class S3TokenMiddlewareTestBad(S3TokenMiddlewareTestBase):
509601
def test_unauthorized_token(self):

0 commit comments

Comments
 (0)