PKI Token revocation

Co-authored-by: Adam Young <ayoung@redhat.com>

Token revocations are captured in the backends,

During upgrade, all previous tickets are defaulted to valid.

Revocation list returned as a signed document and can be fetched in an admin context via HTTP

Change config values for enable diable PKI

In the auth_token middleware,  the revocation list is fetched prior
to validating tokens. Any tokens that are on the revocation list
will be treated as invalid.

Added in PKI token tests that check the same logic as the UUID tests.
Sample data for the tests is read out of the signing directory.

dropped number on sql scripts to pass tests.

Also fixes 1031373

Bug 1037683

Change-Id: Icef2f173e50fe3cce4273c161f69d41259bf5d23
changes/83/11483/3
Maru Newby 10 years ago committed by Adam Young
parent bf5ce27fb2
commit 7b70818954
  1. 5
      keystone/common/cms.py
  2. 1
      keystone/common/sql/core.py
  3. 1
      keystone/common/sql/migrate_repo/versions/003_sqlite_downgrade.sql
  4. 3
      keystone/common/sql/migrate_repo/versions/003_sqlite_upgrade.sql
  5. 40
      keystone/common/sql/migrate_repo/versions/003_token_valid.py
  6. 6
      keystone/common/utils.py
  7. 4
      keystone/config.py
  8. 99
      keystone/middleware/auth_token.py
  9. 36
      keystone/service.py
  10. 16
      keystone/token/backends/kvs.py
  11. 25
      keystone/token/backends/memcache.py
  12. 35
      keystone/token/backends/sql.py
  13. 8
      keystone/token/core.py
  14. 34
      tests/signing/Makefile
  15. 11
      tests/signing/README
  16. 1
      tests/signing/auth_token_revoked.json
  17. 40
      tests/signing/auth_token_revoked.pem
  18. 0
      tests/signing/auth_token_scoped.json
  19. 0
      tests/signing/auth_token_scoped.pem
  20. 1
      tests/signing/auth_token_unscoped.json
  21. 14
      tests/signing/auth_token_unscoped.pem
  22. 1
      tests/signing/revocation_list.json
  23. 11
      tests/signing/revocation_list.pem
  24. 268
      tests/test_auth_token_middleware.py
  25. 23
      tests/test_backend.py
  26. 14
      tests/test_backend_memcache.py

@ -76,6 +76,11 @@ def cms_sign_text(text, signing_cert_file_name, signing_key_file_name):
LOG.error('Signing error: %s' % err)
raise subprocess.CalledProcessError(retcode,
"openssl", output=output)
return output
def cms_sign_token(text, signing_cert_file_name, signing_key_file_name):
output = cms_sign_text(text, signing_cert_file_name, signing_key_file_name)
return cms_to_token(output)

@ -41,6 +41,7 @@ String = sql.String
ForeignKey = sql.ForeignKey
DateTime = sql.DateTime
IntegrityError = sql.exc.IntegrityError
Boolean = sql.Boolean
# Special Fields

@ -0,0 +1,3 @@
alter TABLE token ADD valid integer;
update token set valid = 1;

@ -0,0 +1,40 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 OpenStack LLC
#
# 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.
from migrate import *
from sqlalchemy import *
from keystone.common import sql
def upgrade(migrate_engine):
# Upgrade operations go here. Don't create your own engine; bind
meta = MetaData()
meta.bind = migrate_engine
dialect = migrate_engine.url.get_dialect().name
token = Table('token', meta, autoload=True)
valid = Column("valid", Boolean(), ColumnDefault(True), nullable=False)
token.create_column(valid)
def downgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
token = Table('token', meta, autoload=True)
token.drop_column('valid')

@ -263,3 +263,9 @@ def auth_str_equal(provided, known):
b = ord(known[i]) if i < k_len else 0
result |= a ^ b
return (p_len == k_len) & (result == 0)
def hash_signed_token(signed_text):
hash_ = hashlib.md5()
hash_.update(signed_text)
return hash_.hexdigest()

@ -125,8 +125,8 @@ register_str('keyfile', group='ssl', default=None)
register_str('ca_certs', group='ssl', default=None)
register_bool('cert_required', group='ssl', default=False)
#signing options
register_bool('disable_pki', group='signing',
default=True)
register_str('token_format', group='signing',
default="UUID")
register_str('certfile', group='signing',
default="/etc/keystone/ssl/certs/signing_cert.pem")
register_str('keyfile', group='signing',

@ -93,6 +93,7 @@ HTTP_X_ROLE
"""
import datetime
import httplib
import json
import logging
@ -105,6 +106,8 @@ import webob.exc
from keystone.openstack.common import jsonutils
from keystone.common import cms
from keystone.common import utils
from keystone.openstack.common import timeutils
LOG = logging.getLogger(__name__)
@ -172,6 +175,8 @@ class AuthProtocol(object):
self.signing_cert_file_name = val
val = '%s/cacert.pem' % self.signing_dirname
self.ca_file_name = val
val = '%s/revoked.pem' % self.signing_dirname
self.revoked_file_name = val
# Credentials used to verify this component with the Auth service since
# validating tokens is a privileged call
@ -186,6 +191,10 @@ class AuthProtocol(object):
memcache_servers = conf.get('memcache_servers')
# By default the token will be cached for 5 minutes
self.token_cache_time = conf.get('token_cache_time', 300)
self._token_revocation_list = None
self._token_revocation_list_fetched_time = None
self.token_revocation_list_cache_timeout = \
datetime.timedelta(seconds=0)
if memcache_servers:
try:
import memcache
@ -418,6 +427,7 @@ class AuthProtocol(object):
self._cache_put(user_token, data)
return data
except Exception as e:
LOG.debug('Token validation failure.', exc_info=True)
self._cache_store_invalid(user_token)
LOG.warn("Authorization failed for token %s", user_token)
raise InvalidUserToken('Token authorization failed')
@ -618,19 +628,30 @@ class AuthProtocol(object):
raise InvalidUserToken()
def verify_signed_token(self, signed_text):
"""
Converts a block of Base64 encoding to strict PEM format
and verifies the signature of the contensts IAW CMS syntax
If either of the certificate files are missing, fetch them
and retry
def is_signed_token_revoked(self, signed_text):
"""Indicate whether the token appears in the revocation list."""
revocation_list = self.token_revocation_list
revoked_tokens = revocation_list.get('revoked', [])
if not revoked_tokens:
return
revoked_ids = (x['id'] for x in revoked_tokens)
token_id = utils.hash_signed_token(signed_text)
for revoked_id in revoked_ids:
if token_id == revoked_id:
LOG.debug('Token %s is marked as having been revoked',
token_id)
return True
return False
def cms_verify(self, data):
"""Verifies the signature of the provided data's IAW CMS syntax.
If either of the certificate files are missing, fetch them and
retry.
"""
formatted = cms.token_to_cms(signed_text)
while True:
try:
output = cms.cms_verify(formatted, self.signing_cert_file_name,
output = cms.cms_verify(data, self.signing_cert_file_name,
self.ca_file_name)
except subprocess.CalledProcessError as err:
if self.cert_file_missing(err, self.signing_cert_file_name):
@ -642,6 +663,64 @@ class AuthProtocol(object):
raise err
return output
def verify_signed_token(self, signed_text):
"""Check that the token is unrevoked and has a valid signature."""
if self.is_signed_token_revoked(signed_text):
raise InvalidUserToken('Token has been revoked')
formatted = cms.token_to_cms(signed_text)
return self.cms_verify(formatted)
@property
def token_revocation_list_fetched_time(self):
if not self._token_revocation_list_fetched_time:
# If the fetched list has been written to disk, use its
# modification time.
if os.path.exists(self.revoked_file_name):
mtime = os.path.getmtime(self.revoked_file_name)
fetched_time = datetime.datetime.fromtimestamp(mtime)
# Otherwise the list will need to be fetched.
else:
fetched_time = datetime.datetime.min
self._token_revocation_list_fetched_time = fetched_time
return self._token_revocation_list_fetched_time
@token_revocation_list_fetched_time.setter
def token_revocation_list_fetched_time(self, value):
self._token_revocation_list_fetched_time = value
@property
def token_revocation_list(self):
timeout = self.token_revocation_list_fetched_time +\
self.token_revocation_list_cache_timeout
list_is_current = timeutils.utcnow() < timeout
if list_is_current:
# Load the list from disk if required
if not self._token_revocation_list:
with open(self.revoked_file_name, 'r') as f:
self._token_revocation_list = jsonutils.loads(f.read())
else:
self.token_revocation_list = self.fetch_revocation_list()
return self._token_revocation_list
@token_revocation_list.setter
def token_revocation_list(self, value):
"""Save a revocation list to memory and to disk.
:param value: A json-encoded revocation list
"""
self._token_revocation_list = jsonutils.loads(value)
self.token_revocation_list_fetched_time = timeutils.utcnow()
with open(self.revoked_file_name, 'w') as f:
f.write(value)
def fetch_revocation_list(self):
response, data = self._http_request('GET', '/v2.0/tokens/revoked')
if response.status != 200:
raise ServiceError('Unable to fetch token revocation list.')
return self.cms_verify(data)
def fetch_signing_cert(self):
response, data = self._http_request('GET',
'/v2.0/certificates/signing')

@ -48,6 +48,10 @@ class AdminRouter(wsgi.ComposingRouter):
controller=auth_controller,
action='authenticate',
conditions=dict(method=['POST']))
mapper.connect('/tokens/revoked',
controller=auth_controller,
action='revocation_list',
conditions=dict(method=['GET']))
mapper.connect('/tokens/{token_id}',
controller=auth_controller,
action='validate_token',
@ -429,13 +433,18 @@ class TokenController(wsgi.Application):
service_catalog = self._format_catalog(catalog_ref)
token_data['access']['serviceCatalog'] = service_catalog
if config.CONF.signing.disable_pki:
if config.CONF.signing.token_format == "UUID":
token_id = uuid.uuid4().hex
else:
token_id = cms.cms_sign_text(json.dumps(token_data),
config.CONF.signing.certfile,
config.CONF.signing.keyfile)
elif config.CONF.signing.token_format == "PKI":
token_id = cms.cms_sign_token(json.dumps(token_data),
config.CONF.signing.certfile,
config.CONF.signing.keyfile)
else:
raise exception.UnexpectedError(
"Invalid value for token_format: %s."
" Allowed values are PKI or UUID." %
config.CONF.signing.token_format)
try:
self.token_api.create_token(
context, token_id, dict(key=token_id,
@ -526,9 +535,24 @@ class TokenController(wsgi.Application):
"""Delete a token, effectively invalidating it for authz."""
# TODO(termie): this stuff should probably be moved to middleware
self.assert_admin(context)
self.token_api.delete_token(context=context, token_id=token_id)
def revocation_list(self, context, auth=None):
self.assert_admin(context)
tokens = self.token_api.list_revoked_tokens(context)
for t in tokens:
expires = t['expires']
if not (expires and isinstance(expires, unicode)):
t['expires'] = timeutils.isotime(expires)
data = {'revoked': tokens}
json_data = json.dumps(data)
signed_text = cms.cms_sign_text(json_data,
config.CONF.signing.certfile,
config.CONF.signing.keyfile)
return signed_text
def endpoints(self, context, token_id):
"""Return a list of endpoints available to the token."""
raise exception.NotImplemented()

@ -23,6 +23,7 @@ from keystone import token
class Token(kvs.Base, token.Driver):
# Public interface
def get_token(self, token_id):
try:
@ -30,7 +31,7 @@ class Token(kvs.Base, token.Driver):
except exception.NotFound:
raise exception.TokenNotFound(token_id=token_id)
if token['expires'] is None or token['expires'] > timeutils.utcnow():
return token
return copy.deepcopy(token)
else:
raise exception.TokenNotFound(token_id=token_id)
@ -43,7 +44,9 @@ class Token(kvs.Base, token.Driver):
def delete_token(self, token_id):
try:
token_ref = self.get_token(token_id)
self.db.delete('token-%s' % token_id)
self.db.set('revoked-token-%s' % token_id, token_ref)
except exception.NotFound:
raise exception.TokenNotFound(token_id=token_id)
@ -61,3 +64,14 @@ class Token(kvs.Base, token.Driver):
continue
tokens.append(token.split('-', 1)[1])
return tokens
def list_revoked_tokens(self):
tokens = []
for token, token_ref in self.db.items():
if not token.startswith('revoked-token-'):
continue
record = {}
record['id'] = token_ref['id']
record['expires'] = token_ref['expires']
tokens.append(record)
return tokens

@ -22,6 +22,7 @@ import memcache
from keystone.common import utils
from keystone import config
from keystone import exception
from keystone.openstack.common import jsonutils
from keystone import token
@ -30,6 +31,9 @@ config.register_str('servers', group='memcache', default='localhost:11211')
class Token(token.Driver):
revocation_key = 'revocation-list'
def __init__(self, client=None):
self._memcache_client = client
@ -65,8 +69,25 @@ class Token(token.Driver):
self.client.set(ptk, data_copy, **kwargs)
return copy.deepcopy(data_copy)
def _add_to_revocation_list(self, data):
data_json = jsonutils.dumps(data)
if not self.client.append(self.revocation_key, ',%s' % data_json):
if not self.client.add(self.revocation_key, data_json):
if not self.client.append(self.revocation_key,
',%s' % data_json):
msg = _('Unable to add token to revocation list.')
raise exception.UnexpectedError(msg)
def delete_token(self, token_id):
# Test for existence
self.get_token(token_id)
data = self.get_token(token_id)
ptk = self._prefix_token_id(token_id)
return self.client.delete(ptk)
result = self.client.delete(ptk)
self._add_to_revocation_list(data)
return result
def list_revoked_tokens(self):
list_json = self.client.get(self.revocation_key)
if list_json:
return jsonutils.loads('[%s]' % list_json)
return []

@ -31,6 +31,7 @@ class TokenModel(sql.ModelBase, sql.DictBase):
id = sql.Column(sql.String(1024))
expires = sql.Column(sql.DateTime(), default=None)
extra = sql.Column(sql.JsonBlob())
valid = sql.Column(sql.Boolean(), default=True)
@classmethod
def from_dict(cls, token_dict):
@ -55,7 +56,8 @@ class Token(sql.Base, token.Driver):
def get_token(self, token_id):
session = self.get_session()
token_ref = session.query(TokenModel)\
.filter_by(id_hash=self.token_to_key(token_id)).first()
.filter_by(id_hash=self.token_to_key(token_id),
valid=True).first()
now = datetime.datetime.utcnow()
if token_ref and (not token_ref.expires or now < token_ref.expires):
return token_ref.to_dict()
@ -77,6 +79,7 @@ class Token(sql.Base, token.Driver):
token_ref = TokenModel.from_dict(data_copy)
token_ref.id_hash = self.token_to_key(token_id)
token_ref.valid = True
session = self.get_session()
with session.begin():
session.add(token_ref)
@ -85,15 +88,13 @@ class Token(sql.Base, token.Driver):
def delete_token(self, token_id):
session = self.get_session()
token_ref = session.query(TokenModel)\
.filter_by(id_hash=self.token_to_key(token_id))\
.first()
if not token_ref:
raise exception.TokenNotFound(token_id=token_id)
key = self.token_to_key(token_id)
with session.begin():
if not session.query(TokenModel).filter_by(id=token_id).delete():
token_ref = session.query(TokenModel).filter_by(id=key,
valid=True).first()
if not token_ref:
raise exception.TokenNotFound(token_id=token_id)
token_ref.valid = False
session.flush()
def list_tokens(self, user_id):
@ -101,7 +102,8 @@ class Token(sql.Base, token.Driver):
tokens = []
now = timeutils.utcnow()
for token_ref in session.query(TokenModel)\
.filter(TokenModel.expires > now):
.filter(TokenModel.expires > now)\
.filter_by(valid=True):
token_ref_dict = token_ref.to_dict()
if 'user' not in token_ref_dict:
continue
@ -109,3 +111,18 @@ class Token(sql.Base, token.Driver):
continue
tokens.append(token_ref['id'])
return tokens
def list_revoked_tokens(self):
session = self.get_session()
tokens = []
now = timeutils.utcnow()
for token_ref in session.query(TokenModel)\
.filter(TokenModel.expires > now)\
.filter_by(valid=False):
token_ref_dict = token_ref.to_dict()
record = {
'id': token_ref['id'],
'expires': token_ref['expires'],
}
tokens.append(record)
return tokens

@ -98,6 +98,14 @@ class Driver(object):
"""
raise exception.NotImplemented()
def list_revoked_tokens(self):
"""Returns a list of all revoked tokens
:returns: list of token_id's
"""
raise exception.NotImplemented()
def _get_default_expire_time(self):
"""Determine when a token should expire based on the config.

@ -0,0 +1,34 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2012 Red Hat,. Inc
#
# 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.
.SUFFIXES: .json .pem
SOURCES=auth_token_unscoped.json auth_token_scoped.json revocation_list.json
SIGNED=$(SOURCES:.json=.pem)
TARGETS=$(SIGNED)
all: $(TARGETS)
clean:
rm -f $(TARGETS) *~
.json.pem :
openssl cms -sign -in $< -nosmimecap -signer signing_cert.pem -inkey private_key.pem -outform PEM -nodetach -nocerts -noattr -out $@

@ -1,4 +1,11 @@
auth_token.pem was constructed using the following command
The commands to create the various pem files for the signed tokens and
revocation list were generated by the associated make file.
openssl cms -sign -in auth_token.json -nosmimecap -signer signing_cert.pem -inkey private_key.pem -outform PEM -nodetach -nocerts -noattr -out auth_token.pem
The hashed value in the revocation list was generated using the revoked token using
the following python code
from keystone.common import cms,utils
f=open("tests/signing/auth_token_revoked.pem","r")
r=f.read()
utils.hash_signed_token(cms.cms_to_token(r))
f.close()

@ -0,0 +1 @@
{"access": {"serviceCatalog": [{"endpoints": [{"adminURL": "http://127.0.0.1:8776/v1/64b6f3fbcc53435e8a60fcf89bb6617a", "region": "regionOne", "internalURL": "http://127.0.0.1:8776/v1/64b6f3fbcc53435e8a60fcf89bb6617a", "publicURL": "http://127.0.0.1:8776/v1/64b6f3fbcc53435e8a60fcf89bb6617a"}], "endpoints_links": [], "type": "volume", "name": "volume"}, {"endpoints": [{"adminURL": "http://127.0.0.1:9292/v1", "region": "regionOne", "internalURL": "http://127.0.0.1:9292/v1", "publicURL": "http://127.0.0.1:9292/v1"}], "endpoints_links": [], "type": "image", "name": "glance"}, {"endpoints": [{"adminURL": "http://127.0.0.1:8774/v1.1/64b6f3fbcc53435e8a60fcf89bb6617a", "region": "regionOne", "internalURL": "http://127.0.0.1:8774/v1.1/64b6f3fbcc53435e8a60fcf89bb6617a", "publicURL": "http://127.0.0.1:8774/v1.1/64b6f3fbcc53435e8a60fcf89bb6617a"}], "endpoints_links": [], "type": "compute", "name": "nova"}, {"endpoints": [{"adminURL": "http://127.0.0.1:35357/v2.0", "region": "RegionOne", "internalURL": "http://127.0.0.1:35357/v2.0", "publicURL": "http://127.0.0.1:5000/v2.0"}], "endpoints_links": [], "type": "identity", "name": "keystone"}],"token": {"expires": "2012-06-02T14:47:34Z", "id": "placeholder", "tenant": {"enabled": true, "description": null, "name": "tenant_name1", "id": "tenant_id1"}}, "user": {"username": "revoked_username1", "roles_links": ["role1","role2"], "id": "revoked_user_id1", "roles": [{"name": "role1"}, {"name": "role2"}], "name": "revoked_username1"}}}

@ -0,0 +1,40 @@
-----BEGIN CMS-----
MIIHAwYJKoZIhvcNAQcCoIIG9DCCBvACAQExCTAHBgUrDgMCGjCCBeQGCSqGSIb3
DQEHAaCCBdUEggXReyJhY2Nlc3MiOiB7InNlcnZpY2VDYXRhbG9nIjogW3siZW5k
cG9pbnRzIjogW3siYWRtaW5VUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo4Nzc2L3Yx
LzY0YjZmM2ZiY2M1MzQzNWU4YTYwZmNmODliYjY2MTdhIiwgInJlZ2lvbiI6ICJy
ZWdpb25PbmUiLCAiaW50ZXJuYWxVUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo4Nzc2
L3YxLzY0YjZmM2ZiY2M1MzQzNWU4YTYwZmNmODliYjY2MTdhIiwgInB1YmxpY1VS
TCI6ICJodHRwOi8vMTI3LjAuMC4xOjg3NzYvdjEvNjRiNmYzZmJjYzUzNDM1ZThh
NjBmY2Y4OWJiNjYxN2EifV0sICJlbmRwb2ludHNfbGlua3MiOiBbXSwgInR5cGUi
OiAidm9sdW1lIiwgIm5hbWUiOiAidm9sdW1lIn0sIHsiZW5kcG9pbnRzIjogW3si
YWRtaW5VUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo5MjkyL3YxIiwgInJlZ2lvbiI6
ICJyZWdpb25PbmUiLCAiaW50ZXJuYWxVUkwiOiAiaHR0cDovLzEyNy4wLjAuMTo5
MjkyL3YxIiwgInB1YmxpY1VSTCI6ICJodHRwOi8vMTI3LjAuMC4xOjkyOTIvdjEi
fV0sICJlbmRwb2ludHNfbGlua3MiOiBbXSwgInR5cGUiOiAiaW1hZ2UiLCAibmFt
ZSI6ICJnbGFuY2UifSwgeyJlbmRwb2ludHMiOiBbeyJhZG1pblVSTCI6ICJodHRw
Oi8vMTI3LjAuMC4xOjg3NzQvdjEuMS82NGI2ZjNmYmNjNTM0MzVlOGE2MGZjZjg5
YmI2NjE3YSIsICJyZWdpb24iOiAicmVnaW9uT25lIiwgImludGVybmFsVVJMIjog
Imh0dHA6Ly8xMjcuMC4wLjE6ODc3NC92MS4xLzY0YjZmM2ZiY2M1MzQzNWU4YTYw
ZmNmODliYjY2MTdhIiwgInB1YmxpY1VSTCI6ICJodHRwOi8vMTI3LjAuMC4xOjg3
NzQvdjEuMS82NGI2ZjNmYmNjNTM0MzVlOGE2MGZjZjg5YmI2NjE3YSJ9XSwgImVu
ZHBvaW50c19saW5rcyI6IFtdLCAidHlwZSI6ICJjb21wdXRlIiwgIm5hbWUiOiAi
bm92YSJ9LCB7ImVuZHBvaW50cyI6IFt7ImFkbWluVVJMIjogImh0dHA6Ly8xMjcu
MC4wLjE6MzUzNTcvdjIuMCIsICJyZWdpb24iOiAiUmVnaW9uT25lIiwgImludGVy
bmFsVVJMIjogImh0dHA6Ly8xMjcuMC4wLjE6MzUzNTcvdjIuMCIsICJwdWJsaWNV
UkwiOiAiaHR0cDovLzEyNy4wLjAuMTo1MDAwL3YyLjAifV0sICJlbmRwb2ludHNf
bGlua3MiOiBbXSwgInR5cGUiOiAiaWRlbnRpdHkiLCAibmFtZSI6ICJrZXlzdG9u
ZSJ9XSwidG9rZW4iOiB7ImV4cGlyZXMiOiAiMjAxMi0wNi0wMlQxNDo0NzozNFoi
LCAiaWQiOiAicGxhY2Vob2xkZXIiLCAidGVuYW50IjogeyJlbmFibGVkIjogdHJ1
ZSwgImRlc2NyaXB0aW9uIjogbnVsbCwgIm5hbWUiOiAidGVuYW50X25hbWUxIiwg
ImlkIjogInRlbmFudF9pZDEifX0sICJ1c2VyIjogeyJ1c2VybmFtZSI6ICJyZXZv
a2VkX3VzZXJuYW1lMSIsICJyb2xlc19saW5rcyI6IFsicm9sZTEiLCJyb2xlMiJd
LCAiaWQiOiAicmV2b2tlZF91c2VyX2lkMSIsICJyb2xlcyI6IFt7Im5hbWUiOiAi
cm9sZTEifSwgeyJuYW1lIjogInJvbGUyIn1dLCAibmFtZSI6ICJyZXZva2VkX3Vz
ZXJuYW1lMSJ9fX0NCjGB9zCB9AIBATBUME8xFTATBgNVBAoTDFJlZCBIYXQsIElu
YzERMA8GA1UEBxMIV2VzdGZvcmQxFjAUBgNVBAgTDU1hc3NhY2h1c2V0dHMxCzAJ
BgNVBAYTAlVTAgEBMAcGBSsOAwIaMA0GCSqGSIb3DQEBAQUABIGAXstA+yZ5N/cS
+i7Mmlhi585cckvwSVAGj9huPTpqBItpbO44+U3yUojEwcghomtpygI/wzUa8Z40
UW/L3nGlATlOG833zhGvLKrp76GIitYMgk1e0OEmzGXeAWLnQZFev8ooMPs9rwYW
MgEdAfDMWWqX+Tb7exdboLpRUiCQx1c=
-----END CMS-----

@ -0,0 +1 @@
{"access": {"token": {"expires": "2012-08-17T15:35:34Z", "id": "01e032c996ef4406b144335915a41e79"}, "serviceCatalog": {}, "user": {"username": "user_name1", "roles_links": [], "id": "c9c89e3be3ee453fbf00c7966f6d3fbd", "roles": [{'name': 'role1'},{'name': 'role2'},], "name": "user_name1"}}}

@ -0,0 +1,14 @@
-----BEGIN CMS-----
MIICLwYJKoZIhvcNAQcCoIICIDCCAhwCAQExCTAHBgUrDgMCGjCCARAGCSqGSIb3
DQEHAaCCAQEEgf57ImFjY2VzcyI6IHsidG9rZW4iOiB7ImV4cGlyZXMiOiAiMjAx
Mi0wOC0xN1QxNTozNTozNFoiLCAiaWQiOiAiMDFlMDMyYzk5NmVmNDQwNmIxNDQz
MzU5MTVhNDFlNzkifSwgInNlcnZpY2VDYXRhbG9nIjoge30sICJ1c2VyIjogeyJ1
c2VybmFtZSI6ICJ1c2VyX25hbWUxIiwgInJvbGVzX2xpbmtzIjogW10sICJpZCI6
ICJjOWM4OWUzYmUzZWU0NTNmYmYwMGM3OTY2ZjZkM2ZiZCIsICJyb2xlcyI6IFtd
LCAibmFtZSI6ICJ1c2VyX25hbWUxIn19fTGB9zCB9AIBATBUME8xFTATBgNVBAoT
DFJlZCBIYXQsIEluYzERMA8GA1UEBxMIV2VzdGZvcmQxFjAUBgNVBAgTDU1hc3Nh
Y2h1c2V0dHMxCzAJBgNVBAYTAlVTAgEBMAcGBSsOAwIaMA0GCSqGSIb3DQEBAQUA
BIGAisEcxeNzNYbZPuWEEL+0SRAHjfaSFuhDHAAZ67P6LkoSN8IAio+2fqH2d1Ix
qfUYBW/cVEYdEZ3itbR0KdDucemHFpows+eZVUe6nsV7hgMqXBmfrKyEC4PBuIoI
/nofrwbV/R88v1jAIyrB3IbPUydXDK79lThL47rcGCeOuwI=
-----END CMS-----

@ -0,0 +1 @@
{"revoked":[{"id":"7acfcfdaf6a14aebe97c61c5947bc4d3","expires":"2012-08-14T17:58:48Z"}]}

@ -0,0 +1,11 @@
-----BEGIN CMS-----
MIIBhgYJKoZIhvcNAQcCoIIBdzCCAXMCAQExCTAHBgUrDgMCGjBpBgkqhkiG9w0B
BwGgXARaeyJyZXZva2VkIjpbeyJpZCI6IjdhY2ZjZmRhZjZhMTRhZWJlOTdjNjFj
NTk0N2JjNGQzIiwiZXhwaXJlcyI6IjIwMTItMDgtMTRUMTc6NTg6NDhaIn1dfQ0K
MYH3MIH0AgEBMFQwTzEVMBMGA1UEChMMUmVkIEhhdCwgSW5jMREwDwYDVQQHEwhX
ZXN0Zm9yZDEWMBQGA1UECBMNTWFzc2FjaHVzZXR0czELMAkGA1UEBhMCVVMCAQEw
BwYFKw4DAhowDQYJKoZIhvcNAQEBBQAEgYCVDgl1puOfsn2BNliKnHNsSucYI3xn
aJvZ8UM2hg+TGgshMPhNjo1/p1VBqwyIb0+AAUnFj7fikCNE6dypvT+xX/vUgGnv
4EJ2cqG/0PFB/8B6Tz3FSsFMhXUIRnXKKxLxMCkge1b072BapJ1FJm8sXSem5ecO
adoOjW3kjFJk/A==
-----END CMS-----

@ -15,25 +15,62 @@
# under the License.
import datetime
import hashlib
import iso8601
import os
import string
import tempfile
import webob
from keystone.common import cms
from keystone.common import utils
from keystone.middleware import auth_token
from keystone.openstack.common import jsonutils
from keystone.openstack.common import timeutils
from keystone import config
from keystone import test
# JSON responses keyed by token ID
TOKEN_RESPONSES = {
'valid-token': {
#The data for these tests are signed using openssl and are stored in files
# in the signing subdirectory. IN order to keep the values consistent between
# the tests and the signed documents, we read them in for use in the tests.
def setUpModule(self):
signing_path = os.path.join(os.path.dirname(__file__), 'signing')
with open(os.path.join(signing_path, 'auth_token_scoped.pem')) as f:
self.SIGNED_TOKEN_SCOPED = cms.cms_to_token(f.read())
with open(os.path.join(signing_path, 'auth_token_unscoped.pem')) as f:
self.SIGNED_TOKEN_UNSCOPED = cms.cms_to_token(f.read())
with open(os.path.join(signing_path, 'auth_token_revoked.pem')) as f:
self.REVOKED_TOKEN = cms.cms_to_token(f.read())
self.REVOKED_TOKEN_HASH = utils.hash_signed_token(self.REVOKED_TOKEN)
with open(os.path.join(signing_path, 'revocation_list.json')) as f:
self.REVOCATION_LIST = jsonutils.loads(f.read())
with open(os.path.join(signing_path, 'revocation_list.pem')) as f:
self.SIGNED_REVOCATION_LIST = f.read()
self.TOKEN_RESPONSES[self.SIGNED_TOKEN_SCOPED] = {
'access': {
'token': {
'id': 'valid-token',
'tenant': {
'id': 'tenant_id1',
'name': 'tenant_name1',
},
'id': SIGNED_TOKEN_SCOPED,
},
'user': {
'id': 'user_id1',
'name': 'user_name1',
'tenantId': 'tenant_id1',
'tenantName': 'tenant_name1',
'roles': [
{'name': 'role1'},
{'name': 'role2'},
],
},
},
}
self.TOKEN_RESPONSES[self.SIGNED_TOKEN_UNSCOPED] = {
'access': {
'token': {
'id': self.SIGNED_TOKEN_UNSCOPED,
},
'user': {
'id': 'user_id1',
@ -43,30 +80,65 @@ TOKEN_RESPONSES = {
{'name': 'role2'},
],
},
'serviceCatalog': {}
},
},
'default-tenant-token': {
INVALID_SIGNED_TOKEN = string.replace(
"""AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC
DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD
EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE
FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
0000000000000000000000000000000000000000000000000000000000000000
1111111111111111111111111111111111111111111111111111111111111111
2222222222222222222222222222222222222222222222222222222222222222
3333333333333333333333333333333333333333333333333333333333333333
4444444444444444444444444444444444444444444444444444444444444444
5555555555555555555555555555555555555555555555555555555555555555
6666666666666666666666666666666666666666666666666666666666666666
7777777777777777777777777777777777777777777777777777777777777777
8888888888888888888888888888888888888888888888888888888888888888
9999999999999999999999999999999999999999999999999999999999999999
0000000000000000000000000000000000000000000000000000000000000000
xg==""", "\n", "")
UUID_TOKEN_DEFAULT = "ec6c0710ec2f471498484c1b53ab4f9d"
VALID_DIABLO_TOKEN = 'b0cf19b55dbb4f20a6ee18e6c6cf1726'
UUID_TOKEN_UNSCOPED = '731f903721c14827be7b2dc912af7776'
UUID_TOKEN_NO_SERVICE_CATALOG = '8286720fbe4941e69fa8241723bb02df'
# JSON responses keyed by token ID
TOKEN_RESPONSES = {
UUID_TOKEN_DEFAULT: {
'access': {
'token': {
'id': 'default-tenant-token',
'id': UUID_TOKEN_DEFAULT,
'tenant': {
'id': 'tenant_id1',
'name': 'tenant_name1',
},
},
'user': {
'id': 'user_id1',
'name': 'user_name1',
'tenantId': 'tenant_id1',
'tenantName': 'tenant_name1',
'roles': [
{'name': 'role1'},
{'name': 'role2'},
],
},
'serviceCatalog': {}
},
},
'valid-diablo-token': {
VALID_DIABLO_TOKEN: {
'access': {
'token': {
'id': 'valid-diablo-token',
'id': VALID_DIABLO_TOKEN,
'tenantId': 'tenant_id1',
},
'user': {
@ -79,10 +151,10 @@ TOKEN_RESPONSES = {
},
},
},
'unscoped-token': {
UUID_TOKEN_UNSCOPED: {
'access': {
'token': {
'id': 'unscoped-token',
'id': UUID_TOKEN_UNSCOPED,
},
'user': {
'id': 'user_id1',
@ -94,7 +166,7 @@ TOKEN_RESPONSES = {
},
},
},
'valid-token-no-service-catalog': {
UUID_TOKEN_NO_SERVICE_CATALOG: {
'access': {
'token': {
'id': 'valid-token',
@ -123,7 +195,7 @@ class FakeMemcache(object):
self.token_expiration = None
def get(self, key):
data = TOKEN_RESPONSES['valid-token'].copy()
data = TOKEN_RESPONSES[SIGNED_TOKEN_SCOPED].copy()
if not data or key != "tokens/%s" % (data['access']['token']['id']):
return
if not self.token_expiration:
@ -180,6 +252,9 @@ class FakeHTTPConnection(object):
if token_id in TOKEN_RESPONSES.keys():
status = 200
body = jsonutils.dumps(TOKEN_RESPONSES[token_id])
elif token_id == "revoked":
status = 200
body = SIGNED_REVOCATION_LIST
else:
status = 404
body = str()
@ -220,6 +295,7 @@ class FakeApp(object):
class BaseAuthTokenMiddlewareTest(test.TestCase):
def setUp(self, expected_env=None):
expected_env = expected_env or {}
@ -228,6 +304,7 @@ class BaseAuthTokenMiddlewareTest(test.TestCase):
'auth_host': 'keystone.example.com',
'auth_port': 1234,
'auth_admin_prefix': '/testadmin',
'signing_dir': 'signing',
}
self.middleware = auth_token.AuthProtocol(FakeApp(expected_env), conf)
@ -236,8 +313,21 @@ class BaseAuthTokenMiddlewareTest(test.TestCase):
self.response_status = None
self.response_headers = None
self.middleware.revoked_file_name = tempfile.mkstemp()[1]
self.middleware.token_revocation_list_cache_timeout =\
datetime.timedelta(days=1)
self.middleware.token_revocation_list = jsonutils.dumps(
{"revoked": [], "extra": "success"})
super(BaseAuthTokenMiddlewareTest, self).setUp()
def tearDown(self):
super(BaseAuthTokenMiddlewareTest, self).tearDown()
try:
os.remove(self.middleware.revoked_file_name)
except OSError:
pass
def start_fake_response(self, status, headers):
self.response_status = int(status.split(' ', 1)[0])
self.response_headers = dict(headers)
@ -250,53 +340,149 @@ class DiabloAuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest):
expected_env = {
'HTTP_X_TENANT_ID': 'tenant_id1',
'HTTP_X_TENANT_NAME': 'tenant_id1',
'HTTP_X_TENANT': 'tenant_id1', # now deprecated (diablo-compat)
# now deprecated (diablo-compat)
'HTTP_X_TENANT': 'tenant_id1',
}
super(DiabloAuthTokenMiddlewareTest, self).setUp(expected_env)
def test_diablo_response(self):
def test_valid_diablo_response(self):
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'valid-diablo-token'
req.headers['X-Auth-Token'] = VALID_DIABLO_TOKEN
body = self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 200)
self.assertEqual(body, ['SUCCESS'])
class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest):
def test_valid_request(self):
def assert_valid_request_200(self, token):
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'valid-token'
req.headers['X-Auth-Token'] = token
body = self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.middleware.conf['auth_admin_prefix'],
"/testadmin")
self.assertEqual("/testadmin/v2.0/tokens/valid-token",
FakeHTTPConnection.last_requested_url)
self.assertEqual(self.response_status, 200)
catalog = req.headers.get('X-Service-Catalog')
self.assertTrue(req.headers.get('X-Service-Catalog'))
self.assertEqual(body, ['SUCCESS'])
def test_default_tenant_token(self):
def test_valid_uuid_request(self):
self.assert_valid_request_200(UUID_TOKEN_DEFAULT)
self.assertEqual("/testadmin/v2.0/tokens/%s" % UUID_TOKEN_DEFAULT,
FakeHTTPConnection.last_requested_url)
def test_valid_signed_request(self):
FakeHTTPConnection.last_requested_url = ''
self.assert_valid_request_200(SIGNED_TOKEN_SCOPED)
self.assertEqual(self.middleware.conf['auth_admin_prefix'],
"/testadmin")
#ensure that signed requests do not generate HTTP traffic
self.assertEqual('', FakeHTTPConnection.last_requested_url)
def assert_unscoped_default_tenant_auto_scopes(self, token):
"""Unscoped requests with a default tenant should "auto-scope."
The implied scope is the user's tenant ID.
"""
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'default-tenant-token'
req.headers['X-Auth-Token'] = token
body = self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 200)
self.assertEqual(body, ['SUCCESS'])
def test_unscoped_token(self):
def test_default_tenant_uuid_token(self):
self.assert_unscoped_default_tenant_auto_scopes(UUID_TOKEN_SCOPED)
def test_default_tenant_uuid_token(self):
self.assert_unscoped_default_tenant_auto_scopes(SIGNED_TOKEN_SCOPED)
def assert_unscoped_token_receives_401(self, token):
"""Unscoped requests with no default tenant ID should be rejected."""
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'unscoped-token'
req.headers['X-Auth-Token'] = token
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 401)
self.assertEqual(self.response_headers['WWW-Authenticate'],
'Keystone uri=\'https://keystone.example.com:1234\'')
def test_request_invalid_token(self):
def test_unscoped_uuid_token_receives_401(self):
self.assert_unscoped_token_receives_401(UUID_TOKEN_UNSCOPED)
def test_unscoped_pki_token_receives_401(self):
self.assert_unscoped_token_receives_401(SIGNED_TOKEN_UNSCOPED)
def test_revoked_token_receives_401(self):
self.middleware.token_revocation_list = self.get_revocation_list_json()
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = REVOKED_TOKEN
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 401)
def get_revocation_list_json(self, token_ids=None):
if token_ids is None:
token_ids = [REVOKED_TOKEN_HASH]
revocation_list = {'revoked': [{'id': x, 'expires': timeutils.utcnow()}
for x in token_ids]}
return jsonutils.dumps(revocation_list)
def test_is_signed_token_revoked_returns_false(self):
#explicitly setting an empty revocation list here to document intent
self.middleware.token_revocation_list = jsonutils.dumps(
{"revoked": [], "extra": "success"})
result = self.middleware.is_signed_token_revoked(REVOKED_TOKEN)
self.assertFalse(result)
def test_is_signed_token_revoked_returns_true(self):
self.middleware.token_revocation_list = self.get_revocation_list_json()
result = self.middleware.is_signed_token_revoked(REVOKED_TOKEN)
self.assertTrue(result)
def test_verify_signed_token_raises_exception_for_revoked_token(self):
self.middleware.token_revocation_list = self.get_revocation_list_json()
with self.assertRaises(auth_token.InvalidUserToken):
self.middleware.verify_signed_token(REVOKED_TOKEN)
def test_verify_signed_token_succeeds_for_unrevoked_token(self):
self.middleware.token_revocation_list = self.get_revocation_list_json()
self.middleware.verify_signed_token(SIGNED_TOKEN_SCOPED)
def test_get_token_revocation_list_fetched_time_returns_min(self):
self.middleware.token_revocation_list_fetched_time = None
self.middleware.revoked_file_name = ''
self.assertEqual(self.middleware.token_revocation_list_fetched_time,
datetime.datetime.min)
def test_get_token_revocation_list_fetched_time_returns_mtime(self):
self.middleware.token_revocation_list_fetched_time = None
mtime = os.path.getmtime(self.middleware.revoked_file_name)
fetched_time = datetime.datetime.fromtimestamp(mtime)
self.assertEqual(self.middleware.token_revocation_list_fetched_time,
fetched_time)
def test_get_token_revocation_list_fetched_time_returns_value(self):
expected = self.middleware._token_revocation_list_fetched_time
self.assertEqual(self.middleware.token_revocation_list_fetched_time,
expected)
def test_get_revocation_list_returns_fetched_list(self):
self.middleware.token_revocation_list_fetched_time = None
os.remove(self.middleware.revoked_file_name)
self.assertEqual(self.middleware.token_revocation_list,
REVOCATION_LIST)
def test_get_revocation_list_returns_current_list_from_memory(self):
self.assertEqual(self.middleware.token_revocation_list,
self.middleware._token_revocation_list)
def test_get_revocation_list_returns_current_list_from_disk(self):
in_memory_list = self.middleware.token_revocation_list
self.middleware._token_revocation_list = None
self.assertEqual(self.middleware.token_revocation_list, in_memory_list)
def test_fetch_revocation_list(self):
fetched_list = jsonutils.loads(self.middleware.fetch_revocation_list())
self.assertEqual(fetched_list, REVOCATION_LIST)
def test_request_invalid_uuid_token(self):
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'invalid-token'
self.middleware(req.environ, self.start_fake_response)
@ -304,6 +490,14 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest):
self.assertEqual(self.response_headers['WWW-Authenticate'],
'Keystone uri=\'https://keystone.example.com:1234\'')
def test_request_invalid_signed_token(self):
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = INVALID_SIGNED_TOKEN
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 401)
self.assertEqual(self.response_headers['WWW-Authenticate'],
'Keystone uri=\'https://keystone.example.com:1234\'')
def test_request_no_token(self):
req = webob.Request.blank('/')
self.middleware(req.environ, self.start_fake_response)
@ -321,7 +515,7 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest):
def test_memcache(self):
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'valid-token'
req.headers['X-Auth-Token'] = SIGNED_TOKEN_SCOPED
self.middleware._cache = FakeMemcache()
self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.middleware._cache.set_value, None)
@ -335,7 +529,7 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest):
def test_memcache_set_expired(self):
req = webob.Request.blank('/')
req.headers['X-Auth-Token'] = 'valid-token'
req.headers['X-Auth-Token'] = SIGNED_TOKEN_SCOPED
self.middleware._cache = FakeMemcache()
expired = datetime.datetime.now() - datetime.timedelta(minutes=1)
self.middleware._cache.token_expiration = float(expired.strftime("%s"))
@ -357,7 +551,7 @@ class AuthTokenMiddlewareTest(BaseAuthTokenMiddlewareTest):
def test_request_prevent_service_catalog_injection(self):
req = webob.Request.blank('/')
req.headers['X-Service-Catalog'] = '[]'
req.headers['X-Auth-Token'] = 'valid-token-no-service-catalog'
req.headers['X-Auth-Token'] = UUID_TOKEN_NO_SERVICE_CATALOG
body = self.middleware(req.environ, self.start_fake_response)
self.assertEqual(self.response_status, 200)
self.assertFalse(req.headers.get('X-Service-Catalog'))

@ -647,6 +647,29 @@ class TokenTests(object):
new_data_ref = self.token_api.get_token(token_id)
self.assertEqual(data_ref, new_data_ref)
def check_list_revoked_tokens(self, token_ids):
revoked_ids = [x['id'] for x in self.token_api.list_revoked_tokens()]
for token_id in token_ids:
self.assertIn(token_id, revoked_ids)
def delete_token(self):
token_id = uuid.uuid4().hex
data = {'id_hash': token_id, 'id': token_id, 'a': 'b'}
data_ref = self.token_api.create_token(token_id, data)
self.token_api.delete_token(token_id)
return token_id
def test_list_revoked_tokens_returns_empty_list(self):
revoked_ids = [x['id'] for x in self.token_api.list_revoked_tokens()]
self.assertEqual(revoked_ids, [])
def test_list_revoked_tokens_for_single_token(self):
self.check_list_revoked_tokens([self.delete_token()])
def test_list_revoked_tokens_for_multiple_tokens(self):
self.check_list_revoked_tokens([self.delete_token()
for x in xrange(2)])
class CatalogTests(object):
def test_service_crud(self):

@ -34,6 +34,18 @@ class MemcacheClient(object):
"""Ignores the passed in args."""
self.cache = {}
def add(self, key, value):
if self.get(key):
return False
self.set(key, value)
def append(self, key, value):
existing_value = self.get(key)
if existing_value:
self.set(key, existing_value + value)
return True
return False
def check_key(self, key):
if not isinstance(key, str):
raise memcache.Client.MemcachedStringEncodingError()
@ -45,8 +57,6 @@ class MemcacheClient(object):
now = utils.unixtime(timeutils.utcnow())
if obj and (obj[1] == 0 or obj[1] > now):
return obj[0]
else:
raise exception.TokenNotFound(token_id=key)
def set(self, key, value, time=0):
"""Sets the value for a key."""

Loading…
Cancel
Save