1
0
mirror of https://github.com/gryf/openstack.git synced 2025-12-17 11:30:24 +01:00

Publish keystone federation with athenz

This commit is contained in:
Arun S A G
2018-10-09 14:52:50 -07:00
parent 64de20915c
commit 425d83e3d2
10 changed files with 813 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
# Copyright 2018, Oath Inc
# Licensed under the terms of the Apache 2.0 license. See LICENSE file for terms.
import ast
import keystone.conf
import six
import sys
import uuid
from keystone.auth.plugins import base
from keystone.common import dependency
from keystone.common import driver_hints
from keystone import exception
from keystone.i18n import _
from oslo_log import log
from yahoo.contrib.ocata_openstack_yahoo_plugins.keystone.auth.plugins.athenz_token import AthenzToken # noqa
from yahoo.contrib.ocata_openstack_yahoo_plugins.keystone.auth.plugins.athenz_token import is_athenz_role_token # noqa
LOG = log.getLogger(__name__)
KEYSTONE_CONF = keystone.conf.CONF
METHOD_NAME = 'athenz_token'
class AthenzUserAuthInfo(object):
@classmethod
def create(cls, auth_payload, method_name):
user_auth_info = cls()
user_auth_info._validate_and_normalize_auth_data(auth_payload)
user_auth_info.METHOD_NAME = method_name
return user_auth_info
def __init__(self):
self.user_name = None
self.athenz_token = None
self.domain_id = None
self.domain_name = None
self.project_name = None
def _validate_and_normalize_auth_data(self, auth_payload):
if 'user' not in auth_payload:
raise exception.ValidationError(attribute='user',
target=self.METHOD_NAME)
user_info = auth_payload['user']
user_id = user_info.get('id')
self.user_name = user_info.get('name')
self.project_name = user_info.get('project_name')
if not user_id and not self.user_name:
raise exception.ValidationError(attribute='id or name',
target='user')
if not self.project_name:
raise exception.ValidationError(attribute='project name',
target='user')
if self.user_name:
if 'domain' not in user_info:
raise exception.ValidationError(attribute='domain',
target='user')
self.domain_id = user_info['domain'].get('id')
self.domain_name = user_info['domain'].get('name')
if not self.domain_id and not self.domain_name:
raise exception.ValidationError(attribute='domain id or name',
target='user')
if 'athenz_token' not in user_info:
LOG.error("athenz_token not found in user object: %s", user_info)
raise exception.ValidationError(attribute='athenz_token',
target='user')
athenz_token = ast.literal_eval(user_info['athenz_token'])['token']
self.athenz_token = athenz_token
@dependency.requires('identity_api', 'resource_api', 'role_api',
'assignment_api')
class AthenzAuthPlugin(base.AuthMethodHandler):
def generate_consistent_id(self, string):
"""Given string generate a consistent hash"""
return uuid.uuid3(uuid.NAMESPACE_OID, str(string)).hex
def _lookup_domain(self, domain_id):
try:
self.resource_api.assert_domain_enabled(domain_id)
except exception.DomainNotFound as e:
LOG.error("Domain not found: %s", domain_id)
LOG.warning(six.text_type(e))
raise exception.Unauthorized(e)
except AssertionError as e:
LOG.error("Domain is not enabled: %s", domain_id)
log.warning(six.text_type(e))
six.reraise(exception.Unauthorized, exception.Unauthorized(e),
sys.exc_info()[2])
def _create_project(self, request, tenant_ref):
try:
return self.resource_api.create_project(tenant_ref['id'],
tenant_ref,
request.audit_initiator)
except (exception.DomainNotFound, exception.ProjectNotFound) as e:
raise exception.ValidationError(e)
def _lookup_and_create_project(self, request, project_name, domain_id):
try:
return self.resource_api.get_project_by_name(project_name,
domain_id)
except exception.ProjectNotFound:
project_ref = {'id': self.generate_consistent_id(project_name),
'name': project_name,
'enabled': True,
'domain_id': domain_id,
'is_domain': False,
'parent_id': domain_id,
'description': 'Project created by athenz plugin',
}
return self._create_project(request, project_ref)
def _lookup_and_create_user(self, request, domain_id, uname):
try:
return self.identity_api.get_user_by_name(uname, domain_id)
except exception.UserNotFound:
user_ref = {'id': self.generate_consistent_id(uname),
'name': uname,
'enabled': True,
'domain_id': domain_id,
'description': 'User created by athenz plugin'
}
return self.identity_api.create_user(user_ref,
request.audit_initiator)
def _lookup_and_create_role(self, request, role_name, domain_id):
hints = driver_hints.Hints()
hints.add_filter("name", role_name, case_sensitive=True)
found_roles = self.role_api.list_roles(hints)
LOG.info("Found roles:", found_roles)
if not found_roles:
# Create the role
role_id = self.generate_consistent_id(role_name)
role_ref = {'id': role_id,
'name': role_name
}
return self.role_api.create_role(role_ref['id'],
role_ref,
initiator=request.audit_initiator)
elif len(found_roles) == 1:
return self.role_api.get_role(found_roles[0]['id'])
else:
raise exception.AmbiguityError(resource='role',
name=role_name)
def _create_project_and_assign_roles(self,
request,
atoken,
requested_project_name,
user_ref, domain_id):
""" If requested project has a role in athenz, then create the
requested project and role if necessary. Assign said role to user
on the created project
request: The request object
atoken: Athenz token object
requested_project_name: project_name from the request (OS_PROJECTNAME)
user_ref: User object
domain_id: ID of the domain
"""
created = False
for role, projects in atoken.projects.items():
# check requested_project_name is part of one of the roles
if requested_project_name.lower() in projects:
if not created:
project_ref = self._lookup_and_create_project(request,
requested_project_name, # noqa
domain_id)
created = True
role_ref = self._lookup_and_create_role(request,
role,
domain_id)
# Do grants
self.assignment_api.create_grant(role_ref['id'],
user_id=user_ref['id'],
project_id=project_ref['id'])
def authenticate(self, request, auth_payload):
"""Autenticate the athenz token
Validate the athenz token create projects/users if needed
"""
response_data = {}
user_info = AthenzUserAuthInfo.create(auth_payload, METHOD_NAME)
if not is_athenz_role_token(user_info.athenz_token):
LOG.error("Not a valid athenz role token")
raise exception.Unauthorized(_('Not a valid athenz role token'))
atoken = AthenzToken(user_info.athenz_token)
if atoken.validate(user_info.user_name) and atoken.user:
# We got a valid athenz token
LOG.debug("Athenz token is valid")
# Get domain ID from name if it is not part of the request
domain_id = user_info.domain_id
if not user_info.domain_id:
domain_ref = self.resource_api.get_domain_by_name(
user_info.domain_name)
domain_id = domain_ref['id']
# Assert domain isn't disabled
self._lookup_domain(domain_id)
# Create the user specified in athenz token if necessary
user_ref = self._lookup_and_create_user(request,
domain_id,
atoken.user)
# Create keystone project specified by the user in the request
# (user_info.project_name) after validating athenz role token has
# the same project name in its roles.
self._create_project_and_assign_roles(request,
atoken,
user_info.project_name,
user_ref,
domain_id)
response_data['user_id'] = user_ref['id']
response_data['athenz_token'] = user_info.athenz_token
return base.AuthHandlerResponse(status=True, response_body=None,
response_data=response_data)
msg = _('Invalid athenz token')
raise exception.Unauthorized(msg)

View File

@@ -0,0 +1,173 @@
# Copyright 2018, Oath Inc
# Licensed under the terms of the Apache 2.0 license. See LICENSE file in for terms.
import base64
import json
import time
import re
from collections import defaultdict
import asn1
import M2Crypto.m2 as m2
import M2Crypto.EVP as EVP
import M2Crypto.RSA as RSA
import M2Crypto.EC as EC
import M2Crypto.BIO as BIO
from oslo_log import log
ATHENZ_CONF = {}
# gap in seconds, to determine whether given token is about to expire
STALE_TOKEN_DURATION = 300
OWS_ATHENZ_DOMAIN = 'ows.projects'
OID_ALGORITHMS = {
"1.2.840.113549.1.1.1": RSA,
"1.2.840.10045.2.1": EC
}
LOG = log.getLogger(__name__)
def get_athenz_conf():
"""Return dict of Athenz public keys."""
if not ATHENZ_CONF:
with open('/etc/athenz/athenz.conf') as conf_file:
ATHENZ_CONF.update(json.load(conf_file))
ATHENZ_CONF['zmsPublicKeys'] = {x['id']: x['key'] for x in ATHENZ_CONF['zmsPublicKeys']}
ATHENZ_CONF['ztsPublicKeys'] = {x['id']: x['key'] for x in ATHENZ_CONF['ztsPublicKeys']}
return ATHENZ_CONF
def get_key_algorithm(public_key):
"""Given a public key, return the algorithm type it uses.
Currently only supports EC and RSA."""
key_data = '\n'.join(public_key.split('\n')[1:-1])
key_bytes = base64.b64decode(key_data)
decoder = asn1.Decoder()
decoder.start(key_bytes)
tag = decoder.peek()
while tag.nr != asn1.Numbers.ObjectIdentifier:
decoder.enter()
tag = decoder.peek()
_, oid = decoder.read()
return OID_ALGORITHMS[oid]
def decode_y64(b64_data):
"""Decode Yahoo's version of base64 and return result."""
return str(base64.b64decode(b64_data.replace('-', '=').replace('.', '+').replace('_', '/')))
def is_athenz_role_token(token):
"""Return True IFF token is a role token. Else False."""
return token.startswith('v=Z1;')
class YahooPKey(EVP.PKey):
def assign_key(self, key):
if hasattr(key, 'ec'):
self.assign_ec(key)
else:
self.assign_rsa(key)
def assign_ec(self, ec):
ret = m2.pkey_assign_ec(self.pkey, ec.ec)
if ret:
ec._pyfree = 0
return ret
class AthenzToken(object):
"""Wrapper around an Athenz Role Token."""
ROLE_PROJECT_REGEX = re.compile('^(.+?)\\.([^.]+?)$')
def __init__(self, token):
if not is_athenz_role_token(token):
raise ValueError("Must provide valid role token.")
self.raw_token = token
self.attrs = dict(a.split('=') for a in token.split(';'))
self._projects = defaultdict(set)
@property
def expire_time(self):
"""Returns int of the UTC Unix Time when the token will expire."""
return int(self.attrs['e'])
@property
def user(self):
"""Return the username if the principal is a user. Else return None."""
if self.attrs['p'].startswith('user.'):
return self.attrs['p'].split('.')[1]
else:
return self.attrs['p']
@property
def domain(self):
"""Return the Athenz Domain of the token."""
return self.attrs['d']
@property
def roles(self):
"""Return a list of Athenz Roles in this token."""
return self.attrs['r'].split(',')
@property
def signature(self):
"""Returns un-encoded signature of the token."""
return decode_y64(self.attrs['s'])
@property
def key_id(self):
"""Key ID for the athenz_config public key."""
return self.attrs['k']
@property
def projects(self):
"""Returns a defaultdict of type
`keystone role`->`set of keystone projects`."""
if not self._projects:
for role in self.roles:
matches = re.search(AthenzToken.ROLE_PROJECT_REGEX, role)
if matches:
keystone_project = matches.groups()[0]
keystone_role = matches.groups()[1]
self._projects[keystone_role].add(keystone_project)
return self._projects
@property
def unsigned_raw_token(self):
"""Return the raw unsigned athenz token."""
return str(self.raw_token[:self.raw_token.index(';s=')])
def validate(self, username):
"""Return True IFF this token has been signed by Athenz,
has not expired, and the given username matches. Else return False."""
# Ensure token hasn't expired
# We pad STALE_TOKEN_DURATION to the current time because we want to
# make sure that the token won't expire in the foreseable future
if self.expire_time < time.time() + STALE_TOKEN_DURATION:
return False
# Validate username matches
if self.user != username:
return False
# Only accept tokens in the ows.projects Athenz domain
if self.domain != OWS_ATHENZ_DOMAIN:
return False
# Retrieve proper pub key from athenz_config package
pub_keys = get_athenz_conf()
public_key = decode_y64(pub_keys['ztsPublicKeys'][self.key_id])
algorithm = get_key_algorithm(public_key)
loaded_pub_key = algorithm.load_pub_key_bio(BIO.MemoryBuffer(public_key))
# Verify cryptographic signature
unsigned_token = self.unsigned_raw_token
verifier = YahooPKey(md='sha256')
verifier.assign_key(loaded_pub_key)
verifier.verify_init()
verifier.verify_update(unsigned_token)
result = verifier.verify_final(self.signature)
return result == 1