Skip to content

Commit

Permalink
PKI Token revocation
Browse files Browse the repository at this point in the history
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
  • Loading branch information
Maru Newby authored and Adam Young committed Aug 16, 2012
1 parent bf5ce27 commit 7b70818
Show file tree
Hide file tree
Showing 26 changed files with 626 additions and 71 deletions.
5 changes: 5 additions & 0 deletions keystone/common/cms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
1 change: 1 addition & 0 deletions keystone/common/sql/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ForeignKey = sql.ForeignKey
DateTime = sql.DateTime
IntegrityError = sql.exc.IntegrityError
Boolean = sql.Boolean


# Special Fields
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alter TABLE token drop column valid;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
alter TABLE token ADD valid integer;
update token set valid = 1;

40 changes: 40 additions & 0 deletions keystone/common/sql/migrate_repo/versions/003_token_valid.py
Original file line number Diff line number Diff line change
@@ -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')
6 changes: 6 additions & 0 deletions keystone/common/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
4 changes: 2 additions & 2 deletions keystone/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ def register_cli_int(*args, **kw):
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',
Expand Down
99 changes: 89 additions & 10 deletions keystone/middleware/auth_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"""

import datetime
import httplib
import json
import logging
Expand All @@ -105,6 +106,8 @@

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__)

Expand Down Expand Up @@ -172,6 +175,8 @@ def __init__(self, app, conf):
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
Expand All @@ -186,6 +191,10 @@ def __init__(self, app, conf):
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
Expand Down Expand Up @@ -418,6 +427,7 @@ def _validate_user_token(self, user_token, retry=True):
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')
Expand Down Expand Up @@ -618,19 +628,30 @@ def verify_uuid_token(self, user_token, retry=True):

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):
Expand All @@ -642,6 +663,64 @@ def verify_signed_token(self, signed_text):
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')
Expand Down
36 changes: 30 additions & 6 deletions keystone/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ def __init__(self):
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',
Expand Down Expand Up @@ -429,13 +433,18 @@ def authenticate(self, context, auth=None):
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,
Expand Down Expand Up @@ -526,9 +535,24 @@ def delete_token(self, context, token_id):
"""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()
Expand Down
16 changes: 15 additions & 1 deletion keystone/token/backends/kvs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@


class Token(kvs.Base, token.Driver):

# Public interface
def get_token(self, token_id):
try:
token = self.db.get('token-%s' % token_id)
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)

Expand All @@ -43,7 +44,9 @@ def create_token(self, token_id, data):

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)

Expand All @@ -61,3 +64,14 @@ def list_tokens(self, user_id):
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
25 changes: 23 additions & 2 deletions keystone/token/backends/memcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from keystone.common import utils
from keystone import config
from keystone import exception
from keystone.openstack.common import jsonutils
from keystone import token


Expand All @@ -30,6 +31,9 @@


class Token(token.Driver):

revocation_key = 'revocation-list'

def __init__(self, client=None):
self._memcache_client = client

Expand Down Expand Up @@ -65,8 +69,25 @@ def create_token(self, token_id, data):
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 []

0 comments on commit 7b70818

Please sign in to comment.