Authentication with Keystone.

auth_token Middleware tweaked and imported in Quantum tree
Developing Authorization middleware
This commit is contained in:
Salvatore Orlando 2011-08-11 17:53:43 +01:00
parent 5c97893079
commit 2e3931e7ba
6 changed files with 626 additions and 2 deletions

View File

@ -16,10 +16,23 @@ use = egg:Paste#urlmap
/: quantumversions
/v0.1: quantumapi
[filter:tokenauth]
paste.filter_factory = quantum.common.auth_token:filter_factory
auth_host = 127.0.0.1
auth_port = 5001
auth_protocol = http
# Not sure the admin token thing is right...
#admin_token = 9a82c95a-99e9-4c3a-b5ee-199f6ba7ff04
admin_user = admin
admin_password = secrete
[pipeline:quantumapi]
pipeline = tokenauth quantumapiapp
[app:quantumversions]
paste.app_factory = quantum.api.versions:Versions.factory
[app:quantumapi]
[app:quantumapiapp]
paste.app_factory = quantum.api:APIRouterV01.factory

View File

@ -104,7 +104,9 @@ def detail_net(manager, *args):
def api_detail_net(client, *args):
tid, nid = args
try:
res = client.list_network_details(nid)["networks"]["network"]
res = client.list_network_details(nid)["network"]
print "BLOODY MARY"
print res
except Exception, e:
LOG.error("Failed to get network details: %s" % e)
return

View File

@ -134,7 +134,9 @@ class Client(object):
c.request(method, action, body, headers)
res = c.getresponse()
print "RESPONSE RECEIVED"
status_code = self.get_status_code(res)
print "STATUS CODE:%s" %status_code
if status_code in (httplib.OK,
httplib.CREATED,
httplib.ACCEPTED,

358
quantum/common/auth_token.py Executable file
View File

@ -0,0 +1,358 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (c) 2010-2011 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.
"""
TOKEN-BASED AUTH MIDDLEWARE
This WSGI component performs multiple jobs:
- it verifies that incoming client requests have valid tokens by verifying
tokens with the auth service.
- it will reject unauthenticated requests UNLESS it is in 'delay_auth_decision'
mode, which means the final decision is delegated to the downstream WSGI
component (usually the OpenStack service)
- it will collect and forward identity information from a valid token
such as user name, groups, etc...
Refer to: http://wiki.openstack.org/openstack-authn
This WSGI component has been derived from Keystone's auth_token
middleware module. It contains some specialization for Quantum.
HEADERS
-------
Headers starting with HTTP_ is a standard http header
Headers starting with HTTP_X is an extended http header
> Coming in from initial call from client or customer
HTTP_X_AUTH_TOKEN : the client token being passed in
HTTP_X_STORAGE_TOKEN: the client token being passed in (legacy Rackspace use)
to support cloud files
> Used for communication between components
www-authenticate : only used if this component is being used remotely
HTTP_AUTHORIZATION : basic auth password used to validate the connection
> What we add to the request for use by the OpenStack service
HTTP_X_AUTHORIZATION: the client identity being passed in
"""
import eventlet
from eventlet import wsgi
import httplib
import json
import logging
import os
from paste.deploy import loadapp
from urlparse import urlparse
from webob.exc import HTTPUnauthorized, HTTPUseProxy
from webob.exc import Request, Response
from quantum.common.bufferedhttp import http_connect_raw as http_connect
PROTOCOL_NAME = "Token Authentication"
LOG = logging.getLogger('quantum.common.auth_token')
class AuthProtocol(object):
"""Auth Middleware that handles authenticating client calls"""
def _init_protocol_common(self, app, conf):
""" Common initialization code"""
LOG.info("Starting the %s component", PROTOCOL_NAME)
self.conf = conf
self.app = app
#if app is set, then we are in a WSGI pipeline and requests get passed
# on to app. If it is not set, this component should forward requests
# where to find the Quantum service (if not in local WSGI chain)
# these settings are only used if this component is acting as a proxy
# and the OpenSTack service is running remotely
if not self.app:
self.service_protocol = conf.get('quantum_protocol', 'https')
self.service_host = conf.get('quantum_host')
self.service_port = int(conf.get('quantum_port'))
self.service_url = '%s://%s:%s' % (self.service_protocol,
self.service_host,
self.service_port)
# delay_auth_decision means we still allow unauthenticated requests
# through and we let the downstream service make the final decision
self.delay_auth_decision = int(conf.get('delay_auth_decision', 0))
def _init_protocol(self, app, conf):
""" Protocol specific initialization """
# where to find the auth service (we use this to validate tokens)
self.auth_host = conf.get('auth_host')
self.auth_port = int(conf.get('auth_port'))
self.auth_protocol = conf.get('auth_protocol', 'https')
self.auth_location = "%s://%s:%s" % (self.auth_protocol,
self.auth_host,
self.auth_port)
LOG.debug("AUTH SERVICE LOCATION:%s", self.auth_location)
# Credentials used to verify this component with the Auth service since
# validating tokens is a priviledged call
self.admin_user = conf.get('admin_user')
self.admin_password = conf.get('admin_password')
self.admin_token = conf.get('admin_token')
def __init__(self, app, conf):
""" Common initialization code """
#TODO(ziad): maybe we rafactor this into a superclass
self._init_protocol_common(app, conf) # Applies to all protocols
self._init_protocol(app, conf) # Specific to this protocol
def __call__(self, env, start_response):
""" Handle incoming request. Authenticate. And send downstream. """
LOG.debug("entering AuthProtocol.__call__")
LOG.debug("start response:%s", start_response)
self.start_response = start_response
self.env = env
#Prep headers to forward request to local or remote downstream service
self.proxy_headers = env.copy()
for header in self.proxy_headers.iterkeys():
if header[0:5] == 'HTTP_':
self.proxy_headers[header[5:]] = self.proxy_headers[header]
del self.proxy_headers[header]
#Look for authentication claims
LOG.debug("Looking for authentication claims")
self.claims = self._get_claims(env)
if not self.claims:
#No claim(s) provided
LOG.debug("No claims provided")
if self.delay_auth_decision:
#Configured to allow downstream service to make final decision.
#So mark status as Invalid and forward the request downstream
self._decorate_request("X_IDENTITY_STATUS", "Invalid")
else:
#Respond to client as appropriate for this auth protocol
return self._reject_request()
else:
# this request is presenting claims. Let's validate them
LOG.debug("Claims found. Validating.")
valid = self._validate_claims(self.claims)
if not valid:
# Keystone rejected claim
if self.delay_auth_decision:
# Downstream service will receive call still and decide
self._decorate_request("X_IDENTITY_STATUS", "Invalid")
else:
#Respond to client as appropriate for this auth protocol
return self._reject_claims()
else:
self._decorate_request("X_IDENTITY_STATUS", "Confirmed")
#Collect information about valid claims
if valid:
LOG.debug("Validation successful")
claims = self._expound_claims()
# Store authentication data
if claims:
# TODO(Ziad): add additional details we may need,
# like tenant and group info
self._decorate_request('X_AUTHORIZATION', "Proxy %s" %
claims['user'])
self._decorate_request('X_TENANT', claims['tenant'])
self._decorate_request('X_USER', claims['user'])
if 'group' in claims:
self._decorate_request('X_GROUP', claims['group'])
if 'roles' in claims and len(claims['roles']) > 0:
if claims['roles'] != None:
roles = ''
for role in claims['roles']:
if len(roles) > 0:
roles += ','
roles += role
self._decorate_request('X_ROLE', roles)
# NOTE(todd): unused
self.expanded = True
LOG.debug("About to forward request")
#Send request downstream
return self._forward_request()
# NOTE(todd): unused
# NOTE(salvatore-orlando): temporarily used again
def get_admin_auth_token(self, username, password):
"""
This function gets an admin auth token to be used by this service to
validate a user's token. Validate_token is a priviledged call so
it needs to be authenticated by a service that is calling it
"""
headers = {"Content-type": "application/json", "Accept": "text/json"}
params = {"passwordCredentials": {"username": username,
"password": password}}
conn = httplib.HTTPConnection("%s:%s" \
% (self.auth_host, self.auth_port))
conn.request("POST", "/v2.0/tokens", json.dumps(params), \
headers=headers)
response = conn.getresponse()
data = response.read()
return data
def _get_claims(self, env):
"""Get claims from request"""
claims = env.get('HTTP_X_AUTH_TOKEN', env.get('HTTP_X_STORAGE_TOKEN'))
return claims
def _reject_request(self):
"""Redirect client to auth server"""
return HTTPUseProxy(location=self.auth_location)(self.env,
self.start_response)
def _reject_claims(self):
"""Client sent bad claims"""
return HTTPUnauthorized()(self.env,
self.start_response)
def _validate_claims(self, claims):
"""Validate claims, and provide identity information if applicable """
# Step 1: We need to auth with the keystone service, so get an
# admin token
#TODO(ziad): Need to properly implement this, where to store creds
# for now using token from ini
#TODO(salvatore-orlando): Temporarily restoring auth token retrieval,
# with credentials in configuration file
if not self.admin_token:
auth = self.get_admin_auth_token(self.admin_user,
self.admin_password)
LOG.debug("ADMIN TOKEN?%s",json.loads(auth))
self.admin_token = json.loads(auth)["auth"]["token"]["id"]
# Step 2: validate the user's token with the auth service
# since this is a priviledged op,m we need to auth ourselves
# by using an admin token
headers = {"Content-type": "application/json",
"Accept": "text/json",
"X-Auth-Token": self.admin_token}
##TODO(ziad):we need to figure out how to auth to keystone
#since validate_token is a priviledged call
#Khaled's version uses creds to get a token
# "X-Auth-Token": admin_token}
# we're using a test token from the ini file for now
conn = http_connect(self.auth_host, self.auth_port, 'GET',
'/v2.0/tokens/%s' % claims, headers=headers)
resp = conn.getresponse()
# data = resp.read()
conn.close()
if not str(resp.status).startswith('20'):
# Keystone rejected claim
return False
else:
#TODO(Ziad): there is an optimization we can do here. We have just
#received data from Keystone that we can use instead of making
#another call in _expound_claims
return True
def _expound_claims(self):
# Valid token. Get user data and put it in to the call
# so the downstream service can use it
headers = {"Content-type": "application/json",
"Accept": "text/json",
"X-Auth-Token": self.admin_token}
##TODO(ziad):we need to figure out how to auth to keystone
#since validate_token is a priviledged call
#Khaled's version uses creds to get a token
# "X-Auth-Token": admin_token}
# we're using a test token from the ini file for now
conn = http_connect(self.auth_host, self.auth_port, 'GET',
'/v2.0/tokens/%s' % self.claims, headers=headers)
resp = conn.getresponse()
data = resp.read()
conn.close()
if not str(resp.status).startswith('20'):
raise LookupError('Unable to locate claims: %s' % resp.status)
token_info = json.loads(data)
#TODO(Ziad): make this more robust
#first_group = token_info['auth']['user']['groups']['group'][0]
roles = []
role_refs = token_info["auth"]["user"]["roleRefs"]
if role_refs != None:
for role_ref in role_refs:
roles.append(role_ref["roleId"])
verified_claims = {'user': token_info['auth']['user']['username'],
'tenant': token_info['auth']['user']['tenantId'],
'roles': roles}
# TODO(Ziad): removed groups for now
# ,'group': '%s/%s' % (first_group['id'],
# first_group['tenantId'])}
return verified_claims
def _decorate_request(self, index, value):
"""Add headers to request"""
self.proxy_headers[index] = value
self.env["HTTP_%s" % index] = value
def _forward_request(self):
"""Token/Auth processed & claims added to headers"""
#now decide how to pass on the call
if self.app:
# Pass to downstream WSGI component
return self.app(self.env, self.start_response)
#.custom_start_response)
else:
# We are forwarding to a remote service (no downstream WSGI app)
req = Request(self.proxy_headers)
parsed = urlparse(req.url)
conn = http_connect(self.service_host,
self.service_port,
req.method,
parsed.path,
self.proxy_headers,
ssl=(self.service_protocol == 'https'))
resp = conn.getresponse()
data = resp.read()
#TODO(ziad): use a more sophisticated proxy
# we are rewriting the headers now
return Response(status=resp.status, body=data)(self.proxy_headers,
self.start_response)
def filter_factory(global_conf, **local_conf):
"""Returns a WSGI filter app for use with paste.deploy."""
conf = global_conf.copy()
conf.update(local_conf)
def auth_filter(app):
return AuthProtocol(app, conf)
return auth_filter
def app_factory(global_conf, **local_conf):
conf = global_conf.copy()
conf.update(local_conf)
return AuthProtocol(None, conf)
if __name__ == "__main__":
app = loadapp("config:" + \
os.path.join(os.path.abspath(os.path.dirname(__file__)),
os.pardir,
os.pardir,
"examples/paste/auth_token.ini"),
global_conf={"log_name": "auth_token.log"})
wsgi.server(eventlet.listen(('', 8090)), app)

View File

@ -0,0 +1,84 @@
#!/usr/bin/env python
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# Copyright (c) 2010-2011 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.
""" Middleware for authorizing Quantum Operations
This is a first and very trivial implementation of a middleware
for authorizing requests to Quantum API.
It only verifies that the tenant requesting the operation owns the
network (and thus the port) on which it is going to operate.
It also verifies, if the operation involves an interface, that
the tenant owns that interface by querying an API on the service
where the interface is defined.
"""
import logging
from webob.exc import HTTPUnauthorized, HTTPForbidden
from webob.exc import Request, Response
LOG = logging.getLogger('quantum.common.authorization')
class QuantumAuthorization(object):
""" Authorizes an operation before it reaches the API WSGI app"""
def __call__(self, req, start_response):
""" Handle incoming request. Authorize. And send downstream. """
LOG.debug("entering QuantumAuthorization.__call__")
self.start_response = start_response
self.req = req
# Retrieves TENANT ID from headers as the request
# should already have been authenticated with Keystone
self.headers = req.copy()
if not "X_TENANT" in self.headers:
# This is bad, very bad
self._reject()
auth_tenant_id = self.headers['X_TENANT']
path = self.req.path
parts=path.split('/')
#TODO (salvatore-orlando): need bound checking here
idx = parts.index('tenants') + 1
req_tenant_id = parts[idx]
if auth_tenant_id != req_tenant_id:
# This is bad, very bad
self._forbid()
# Okay, authorize it!
def _reject(self):
"""Apparently the request has not been authenticated """
return HTTPUnauthorized()(self.env,
self.start_response)
def _forbid(self):
"""Cannot authorize. Operating on non-owned resources"""
return HTTPForbidden()(self.env,
self.start_response)
def filter_factory(global_conf, **local_conf):
"""Returns a WSGI filter app for use with paste.deploy."""
conf = global_conf.copy()
conf.update(local_conf)
def authz_filter(app):
return QuantumAuthorization(app, conf)
return authz_filter

View File

@ -0,0 +1,165 @@
# Copyright (c) 2010-2011 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.
"""
Monkey Patch httplib.HTTPResponse to buffer reads of headers. This can improve
performance when making large numbers of small HTTP requests. This module
also provides helper functions to make HTTP connections using
BufferedHTTPResponse.
.. warning::
If you use this, be sure that the libraries you are using do not access
the socket directly (xmlrpclib, I'm looking at you :/), and instead
make all calls through httplib.
"""
from urllib import quote
import logging
import time
from eventlet.green.httplib import CONTINUE, HTTPConnection, HTTPMessage, \
HTTPResponse, HTTPSConnection, _UNKNOWN
class BufferedHTTPResponse(HTTPResponse):
"""HTTPResponse class that buffers reading of headers"""
def __init__(self, sock, debuglevel=0, strict=0,
method=None): # pragma: no cover
self.sock = sock
self.fp = sock.makefile('rb')
self.debuglevel = debuglevel
self.strict = strict
self._method = method
self.msg = None
# from the Status-Line of the response
self.version = _UNKNOWN # HTTP-Version
self.status = _UNKNOWN # Status-Code
self.reason = _UNKNOWN # Reason-Phrase
self.chunked = _UNKNOWN # is "chunked" being used?
self.chunk_left = _UNKNOWN # bytes left to read in current chunk
self.length = _UNKNOWN # number of bytes left in response
self.will_close = _UNKNOWN # conn will close at end of response
def expect_response(self):
self.fp = self.sock.makefile('rb', 0)
version, status, reason = self._read_status()
if status != CONTINUE:
self._read_status = lambda: (version, status, reason)
self.begin()
else:
self.status = status
self.reason = reason.strip()
self.version = 11
self.msg = HTTPMessage(self.fp, 0)
self.msg.fp = None
class BufferedHTTPConnection(HTTPConnection):
"""HTTPConnection class that uses BufferedHTTPResponse"""
response_class = BufferedHTTPResponse
def connect(self):
self._connected_time = time.time()
return HTTPConnection.connect(self)
def putrequest(self, method, url, skip_host=0, skip_accept_encoding=0):
self._method = method
self._path = url
return HTTPConnection.putrequest(self, method, url, skip_host,
skip_accept_encoding)
def getexpect(self):
response = BufferedHTTPResponse(self.sock, strict=self.strict,
method=self._method)
response.expect_response()
return response
def getresponse(self):
response = HTTPConnection.getresponse(self)
logging.debug(("HTTP PERF: %(time).5f seconds to %(method)s "
"%(host)s:%(port)s %(path)s)"),
{'time': time.time() - self._connected_time, 'method': self._method,
'host': self.host, 'port': self.port, 'path': self._path})
return response
def http_connect(ipaddr, port, device, partition, method, path,
headers=None, query_string=None, ssl=False):
"""
Helper function to create an HTTPConnection object. If ssl is set True,
HTTPSConnection will be used. However, if ssl=False, BufferedHTTPConnection
will be used, which is buffered for backend Swift services.
:param ipaddr: IPv4 address to connect to
:param port: port to connect to
:param device: device of the node to query
:param partition: partition on the device
:param method: HTTP method to request ('GET', 'PUT', 'POST', etc.)
:param path: request path
:param headers: dictionary of headers
:param query_string: request query string
:param ssl: set True if SSL should be used (default: False)
:returns: HTTPConnection object
"""
if ssl:
conn = HTTPSConnection('%s:%s' % (ipaddr, port))
else:
conn = BufferedHTTPConnection('%s:%s' % (ipaddr, port))
path = quote('/' + device + '/' + str(partition) + path)
if query_string:
path += '?' + query_string
conn.path = path
conn.putrequest(method, path)
if headers:
for header, value in headers.iteritems():
conn.putheader(header, value)
conn.endheaders()
return conn
def http_connect_raw(ipaddr, port, method, path, headers=None,
query_string=None, ssl=False):
"""
Helper function to create an HTTPConnection object. If ssl is set True,
HTTPSConnection will be used. However, if ssl=False, BufferedHTTPConnection
will be used, which is buffered for backend Swift services.
:param ipaddr: IPv4 address to connect to
:param port: port to connect to
:param method: HTTP method to request ('GET', 'PUT', 'POST', etc.)
:param path: request path
:param headers: dictionary of headers
:param query_string: request query string
:param ssl: set True if SSL should be used (default: False)
:returns: HTTPConnection object
"""
if ssl:
conn = HTTPSConnection('%s:%s' % (ipaddr, port))
else:
conn = BufferedHTTPConnection('%s:%s' % (ipaddr, port))
if query_string:
path += '?' + query_string
conn.path = path
conn.putrequest(method, path)
if headers:
for header, value in headers.iteritems():
conn.putheader(header, value)
conn.endheaders()
return conn