1
Description: Backport improvements to auth_token middleware.
2
Author: Chuck Short <zulcss@ubuntu.com>
4
diff -Naurp keystone-2012.1.orig/keystone/middleware/auth_token.py keystone-2012.1/keystone/middleware/auth_token.py
5
--- keystone-2012.1.orig/keystone/middleware/auth_token.py 2012-02-29 05:16:06.000000000 -0500
6
+++ keystone-2012.1/keystone/middleware/auth_token.py 2012-03-02 09:44:16.498651385 -0500
9
TOKEN-BASED AUTH MIDDLEWARE
11
-This WSGI component performs multiple jobs:
14
-* it verifies that incoming client requests have valid tokens by verifying
15
+* Verifies that incoming client requests have valid tokens by validating
16
tokens with the auth service.
17
-* it will reject unauthenticated requests UNLESS it is in 'delay_auth_decision'
18
+* Rejects unauthenticated requests UNLESS it is in 'delay_auth_decision'
19
mode, which means the final decision is delegated to the downstream WSGI
20
component (usually the OpenStack service)
21
-* it will collect and forward identity information from a valid token
22
- such as user name etc...
24
-Refer to: http://wiki.openstack.org/openstack-authn
25
+* Collects and forwards identity information based on a valid token
26
+ such as user name, tenant, etc
28
+Refer to: http://keystone.openstack.org/middleware_architecture.html
32
@@ -41,17 +40,18 @@ Coming in from initial call from client
33
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
36
- the client token being passed in
37
+ The client token being passed in.
40
- the client token being passed in (legacy Rackspace use) to support
42
+ The client token being passed in (legacy Rackspace use) to support
45
Used for communication between components
46
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
49
- only used if this component is being used remotely
51
+ HTTP header returned to a user indicating which endpoint to use
52
+ to retrieve a new token
55
basic auth password used to validate the connection
56
@@ -60,368 +60,370 @@ What we add to the request for use by th
57
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
60
- the client identity being passed in
61
+ The client identity being passed in
63
+HTTP_X_IDENTITY_STATUS
64
+ 'Confirmed' or 'Invalid'
65
+ The underlying service will only see a value of 'Invalid' if the Middleware
66
+ is configured to run in 'delay_auth_decision' mode
69
+ Identity service managed unique identifier, string
72
+ Unique tenant identifier, string
75
+ Identity-service managed unique identifier, string
78
+ Unique user identifier, string
81
+ Comma delimited list of case-sensitive Roles
84
+ *Deprecated* in favor of HTTP_X_TENANT_ID and HTTP_X_TENANT_NAME
85
+ Keystone-assigned unique identifier, deprecated
88
+ *Deprecated* in favor of HTTP_X_USER_ID and HTTP_X_USER_NAME
89
+ Unique user name, string
92
+ *Deprecated* in favor of HTTP_X_ROLES
93
+ This is being renamed, and the new header contains the same data.
103
-from eventlet import wsgi
104
-from paste import deploy
105
-from urlparse import urlparse
108
-from webob.exc import HTTPUnauthorized
110
-from keystone.common.bufferedhttp import http_connect_raw as http_connect
112
-ADMIN_TENANTNAME = 'admin'
113
-PROTOCOL_NAME = 'Token Authentication'
114
+logger = logging.getLogger('keystone.middleware.auth_token')
117
-class AuthProtocol(object):
118
- """Auth Middleware that handles authenticating client calls"""
119
+class InvalidUserToken(Exception):
123
+class ServiceError(Exception):
126
- def _init_protocol_common(self, app, conf):
127
- """ Common initialization code"""
128
- print 'Starting the %s component' % PROTOCOL_NAME
130
+class AuthProtocol(object):
131
+ """Auth Middleware that handles authenticating client calls."""
133
+ def __init__(self, app, conf):
134
+ logger.info('Starting keystone auth_token middleware')
137
- #if app is set, then we are in a WSGI pipeline and requests get passed
138
- # on to app. If it is not set, this component should forward requests
140
- # where to find the OpenStack service (if not in local WSGI chain)
141
- # these settings are only used if this component is acting as a proxy
142
- # and the OpenSTack service is running remotely
143
- self.service_protocol = conf.get('service_protocol', 'https')
144
- self.service_host = conf.get('service_host')
145
- self.service_port = int(conf.get('service_port'))
146
- self.service_url = '%s://%s:%s' % (self.service_protocol,
149
- # used to verify this component with the OpenStack service or PAPIAuth
150
- self.service_pass = conf.get('service_pass')
152
# delay_auth_decision means we still allow unauthenticated requests
153
# through and we let the downstream service make the final decision
154
self.delay_auth_decision = int(conf.get('delay_auth_decision', 0))
156
- def _init_protocol(self, conf):
157
- """ Protocol specific initialization """
159
# where to find the auth service (we use this to validate tokens)
160
self.auth_host = conf.get('auth_host')
161
self.auth_port = int(conf.get('auth_port'))
162
- self.auth_protocol = conf.get('auth_protocol', 'https')
164
- # where to tell clients to find the auth service (default to url
165
- # constructed based on endpoint we have for the service to use)
166
- self.auth_location = conf.get('auth_uri',
167
- '%s://%s:%s' % (self.auth_protocol,
170
+ auth_protocol = conf.get('auth_protocol', 'https')
171
+ if auth_protocol == 'http':
172
+ self.http_client_class = httplib.HTTPConnection
174
+ self.http_client_class = httplib.HTTPSConnection
176
+ default_auth_uri = '%s://%s:%s' % (auth_protocol,
179
+ self.auth_uri = conf.get('auth_uri', default_auth_uri)
181
# Credentials used to verify this component with the Auth service since
182
# validating tokens is a privileged call
183
self.admin_token = conf.get('admin_token')
184
self.admin_user = conf.get('admin_user')
185
self.admin_password = conf.get('admin_password')
186
+ self.admin_tenant_name = conf.get('admin_tenant_name', 'admin')
188
- def __init__(self, app, conf):
189
- """ Common initialization code """
190
+ def __call__(self, env, start_response):
191
+ """Handle incoming request.
193
- #TODO(ziad): maybe we refactor this into a superclass
194
- self._init_protocol_common(app, conf) # Applies to all protocols
195
- self._init_protocol(conf) # Specific to this protocol
196
+ Authenticate send downstream on success. Reject request if
197
+ we can't authenticate.
199
- def __call__(self, env, start_response):
200
- """ Handle incoming request. Authenticate. And send downstream. """
202
+ logger.debug('Authenticating user token')
204
+ self._remove_auth_headers(env)
205
+ user_token = self._get_user_token_from_header(env)
206
+ token_info = self._validate_user_token(user_token)
207
+ user_headers = self._build_user_headers(token_info)
208
+ self._add_headers(env, user_headers)
209
+ return self.app(env, start_response)
211
- #Prep headers to forward request to local or remote downstream service
212
- proxy_headers = env.copy()
213
- for header in proxy_headers.iterkeys():
214
- if header.startswith('HTTP_'):
215
- proxy_headers[header[5:]] = proxy_headers[header]
216
- del proxy_headers[header]
218
- #Look for authentication claims
219
- claims = self._get_claims(env)
221
- #No claim(s) provided
222
+ except InvalidUserToken:
223
if self.delay_auth_decision:
224
- #Configured to allow downstream service to make final decision.
225
- #So mark status as Invalid and forward the request downstream
226
- self._decorate_request('X_IDENTITY_STATUS',
230
+ logger.info('Invalid user token - deferring reject downstream')
231
+ self._add_headers(env, {'X-Identity-Status': 'Invalid'})
232
+ return self.app(env, start_response)
234
- #Respond to client as appropriate for this auth protocol
235
+ logger.info('Invalid user token - rejecting request')
236
return self._reject_request(env, start_response)
238
+ except ServiceError, e:
239
+ logger.critical('Unable to obtain admin token: %s' % e)
240
+ resp = webob.exc.HTTPServiceUnavailable()
241
+ return resp(env, start_response)
243
+ def _remove_auth_headers(self, env):
244
+ """Remove headers so a user can't fake authentication.
246
+ :param env: wsgi request environment
250
+ 'X-Identity-Status',
261
+ logger.debug('Removing headers from request environment: %s' %
262
+ ','.join(auth_headers))
263
+ self._remove_headers(env, auth_headers)
265
+ def _get_user_token_from_header(self, env):
266
+ """Get token id from request.
268
+ :param env: wsgi request environment
270
+ :raises InvalidUserToken if no token is provided in request
273
+ token = self._get_header(env, 'X-Auth-Token',
274
+ self._get_header(env, 'X-Storage-Token'))
278
- # this request is presenting claims. Let's validate them
279
- valid = self._validate_claims(claims)
281
- # Keystone rejected claim
282
- if self.delay_auth_decision:
283
- # Downstream service will receive call still and decide
284
- self._decorate_request('X_IDENTITY_STATUS',
289
- #Respond to client as appropriate for this auth protocol
290
- return self._reject_claims(env, start_response)
292
- self._decorate_request('X_IDENTITY_STATUS',
297
- #Collect information about valid claims
299
- claims = self._expound_claims(claims)
301
- # Store authentication data
303
- self._decorate_request('X_AUTHORIZATION',
304
- 'Proxy %s' % claims['user'],
308
- # For legacy compatibility before we had ID and Name
309
- self._decorate_request('X_TENANT',
314
- # Services should use these
315
- self._decorate_request('X_TENANT_NAME',
316
- claims.get('tenantName',
320
- self._decorate_request('X_TENANT_ID',
325
- self._decorate_request('X_USER',
326
- claims['userName'],
329
- self._decorate_request('X_USER_ID',
334
- # NOTE(lzyeval): claims has a key 'roles' which is
335
- # guaranteed to be a list (see note below)
336
- roles = ','.join(filter(lambda x: x, claims['roles']))
337
- self._decorate_request('X_ROLE',
342
- # NOTE(todd): unused
343
- self.expanded = True
345
- #Send request downstream
346
- return self._forward_request(env, start_response, proxy_headers)
348
- def _get_claims(self, env):
349
- """Get claims from request"""
350
- claims = env.get('HTTP_X_AUTH_TOKEN', env.get('HTTP_X_STORAGE_TOKEN'))
352
+ raise InvalidUserToken('Unable to find token in headers')
354
def _reject_request(self, env, start_response):
355
- """Redirect client to auth server"""
356
- headers = [('WWW-Authenticate',
357
- "Keystone uri='%s'" % self.auth_location)]
358
+ """Redirect client to auth server.
360
+ :param env: wsgi request environment
361
+ :param start_response: wsgi response callback
362
+ :returns HTTPUnauthorized http response
365
+ headers = [('WWW-Authenticate', 'Keystone uri=\'%s\'' % self.auth_uri)]
366
resp = webob.exc.HTTPUnauthorized('Authentication required', headers)
367
return resp(env, start_response)
369
- def _reject_claims(self, env, start_response):
370
- """Client sent bad claims"""
371
- resp = webob.exc.HTTPUnauthorized()
372
- return resp(env, start_response)
373
+ def get_admin_token(self):
374
+ """Return admin token, possibly fetching a new one.
376
+ :return admin token id
377
+ :raise ServiceError when unable to retrieve token from keystone
379
- def _get_admin_auth_token(self, username, password):
381
- This function gets an admin auth token to be used by this service to
382
- validate a user's token. Validate_token is a priviledged call so
383
- it needs to be authenticated by a service that is calling it
384
+ if not self.admin_token:
385
+ self.admin_token = self._request_admin_token()
387
+ return self.admin_token
389
+ def _get_http_connection(self):
390
+ return self.http_client_class(self.auth_host, self.auth_port)
392
+ def _json_request(self, method, path, body=None, additional_headers=None):
393
+ """HTTP request helper used to make json requests.
395
+ :param method: http method
396
+ :param path: relative request url
397
+ :param body: dict to encode to json as request body. Optional.
398
+ :param additional_headers: dict of additional headers to send with
399
+ http request. Optional.
400
+ :return (http response object, response body parsed as json)
401
+ :raise ServerError when unable to communicate with keystone
405
- "Content-type": "application/json",
406
- "Accept": "application/json",
410
- "passwordCredentials": {
411
- "username": username,
412
- "password": password,
414
- "tenantName": ADMIN_TENANTNAME,
417
- if self.auth_protocol == "http":
418
- conn = httplib.HTTPConnection(self.auth_host, self.auth_port)
420
- conn = httplib.HTTPSConnection(self.auth_host,
422
- cert_file=self.cert_file)
423
- conn.request("POST",
425
- json.dumps(params),
427
- response = conn.getresponse()
428
- data = response.read()
430
+ conn = self._get_http_connection()
434
+ 'Content-type': 'application/json',
435
+ 'Accept': 'application/json',
439
+ if additional_headers:
440
+ kwargs['headers'].update(additional_headers)
443
+ kwargs['body'] = json.dumps(body)
446
- return json.loads(data)["access"]["token"]["id"]
449
+ conn.request(method, path, **kwargs)
450
+ response = conn.getresponse()
451
+ body = response.read()
452
+ data = json.loads(body)
453
+ except Exception, e:
454
+ logger.error('HTTP connection exception: %s' % e)
455
+ raise ServiceError('Unable to communicate with keystone')
459
- def _validate_claims(self, claims, retry=True):
460
- """Validate claims, and provide identity information isf applicable """
461
+ return response, data
463
- # Step 1: We need to auth with the keystone service, so get an
465
- if not self.admin_token:
466
- self.admin_token = self._get_admin_auth_token(self.admin_user,
467
- self.admin_password)
468
+ def _request_admin_token(self):
469
+ """Retrieve new token as admin user from keystone.
471
- # Step 2: validate the user's token with the auth service
472
- # since this is a priviledged op,m we need to auth ourselves
473
- # by using an admin token
475
- 'Content-type': 'application/json',
476
- 'Accept': 'application/json',
477
- 'X-Auth-Token': self.admin_token,
478
+ :return token id upon success
479
+ :raises ServerError when unable to communicate with keystone
484
+ 'passwordCredentials': {
485
+ 'username': self.admin_user,
486
+ 'password': self.admin_password,
488
+ 'tenantName': self.admin_tenant_name,
490
- ##TODO(ziad):we need to figure out how to auth to keystone
491
- #since validate_token is a priviledged call
492
- #Khaled's version uses creds to get a token
493
- # 'X-Auth-Token': admin_token}
494
- # we're using a test token from the ini file for now
495
- conn = http_connect(self.auth_host,
498
- '/v2.0/tokens/%s' % claims,
500
- resp = conn.getresponse()
501
- # data = resp.read()
504
- if not str(resp.status).startswith('20'):
506
- self.admin_token = None
507
- return self._validate_claims(claims, False)
512
+ response, data = self._json_request('POST',
517
+ token = data['access']['token']['id']
520
+ except (AssertionError, KeyError):
521
+ raise ServiceError('invalid json response')
523
+ def _validate_user_token(self, user_token, retry=True):
524
+ """Authenticate user token with keystone.
526
+ :param user_token: user's token id
527
+ :param retry: flag that forces the middleware to retry
528
+ user authentication when an indeterminate
529
+ response is received. Optional.
530
+ :return token object received from keystone on success
531
+ :raise InvalidUserToken if token is rejected
532
+ :raise ServiceError if unable to authenticate token
535
+ headers = {'X-Auth-Token': self.get_admin_token()}
536
+ response, data = self._json_request('GET',
537
+ '/v2.0/tokens/%s' % user_token,
538
+ additional_headers=headers)
540
+ if response.status == 200:
542
+ if response.status == 404:
543
+ # FIXME(ja): I'm assuming the 404 status means that user_token is
544
+ # invalid - not that the admin_token is invalid
545
+ raise InvalidUserToken('Token authorization failed')
546
+ if response.status == 401:
547
+ logger.info('Keystone rejected admin token, resetting')
548
+ self.admin_token = None
550
- #TODO(Ziad): there is an optimization we can do here. We have just
551
- #received data from Keystone that we can use instead of making
552
- #another call in _expound_claims
555
- def _expound_claims(self, claims):
556
- # Valid token. Get user data and put it in to the call
557
- # so the downstream service can use it
559
- 'Content-type': 'application/json',
560
- 'Accept': 'application/json',
561
- 'X-Auth-Token': self.admin_token,
563
- ##TODO(ziad):we need to figure out how to auth to keystone
564
- #since validate_token is a priviledged call
565
- #Khaled's version uses creds to get a token
566
- # 'X-Auth-Token': admin_token}
567
- # we're using a test token from the ini file for now
568
- conn = http_connect(self.auth_host,
571
- '/v2.0/tokens/%s' % claims,
573
- resp = conn.getresponse()
577
- if not str(resp.status).startswith('20'):
578
- raise LookupError('Unable to locate claims: %s' % resp.status)
580
- token_info = json.loads(data)
581
- access_user = token_info['access']['user']
582
- access_token = token_info['access']['token']
583
- # Nova looks for the non case-sensitive role 'admin'
584
- # to determine admin-ness
585
- # NOTE(lzyeval): roles is always a list
586
- roles = map(lambda y: y['name'], access_user.get('roles', []))
587
+ logger.error('Bad response code while validating token: %s' %
590
+ logger.info('Retrying validation')
591
+ return self._validate_user_token(user_token, False)
593
+ raise InvalidUserToken()
595
+ def _build_user_headers(self, token_info):
596
+ """Convert token object into headers.
598
+ Build headers that represent authenticated user:
599
+ * X_IDENTITY_STATUS: Confirmed or Invalid
600
+ * X_TENANT_ID: id of tenant if tenant is present
601
+ * X_TENANT_NAME: name of tenant if tenant is present
602
+ * X_USER_ID: id of user
603
+ * X_USER_NAME: name of user
604
+ * X_ROLES: list of roles
606
+ Additional (deprecated) headers include:
607
+ * X_USER: name of user
608
+ * X_TENANT: For legacy compatibility before we had ID and Name
609
+ * X_ROLE: list of roles
611
+ :param token_info: token object returned by keystone on authentication
612
+ :raise InvalidUserToken when unable to parse token object
615
+ user = token_info['access']['user']
616
+ token = token_info['access']['token']
617
+ roles = ','.join([role['name'] for role in user.get('roles', [])])
619
+ # FIXME(ja): I think we are checking in both places because:
620
+ # tenant might not be returned, and there was a pre-release
621
+ # that put tenant objects inside the user object?
623
- tenant = access_token['tenant']['id']
624
- tenant_name = access_token['tenant']['name']
625
+ tenant_id = token['tenant']['id']
626
+ tenant_name = token['tenant']['name']
631
- tenant = access_user.get('tenantId')
632
- tenant_name = access_user.get('tenantName')
633
- verified_claims = {
634
- 'user': access_user['id'],
635
- 'userName': access_user['username'],
640
- verified_claims['tenantName'] = tenant_name
641
- return verified_claims
643
- def _decorate_request(self, index, value, env, proxy_headers):
644
- """Add headers to request"""
645
- proxy_headers[index] = value
646
- env['HTTP_%s' % index] = value
648
- def _forward_request(self, env, start_response, proxy_headers):
649
- """Token/Auth processed & claims added to headers"""
650
- self._decorate_request('AUTHORIZATION',
651
- 'Basic %s' % self.service_pass, env, proxy_headers)
652
- #now decide how to pass on the call
654
- # Pass to downstream WSGI component
655
- return self.app(env, start_response)
656
- #.custom_start_response)
658
- # We are forwarding to a remote service (no downstream WSGI app)
659
- req = webob.Request(proxy_headers)
660
- parsed = urlparse(req.url)
662
- conn = http_connect(self.service_host,
667
- ssl=(self.service_protocol == 'https'))
668
- resp = conn.getresponse()
671
- #TODO(ziad): use a more sophisticated proxy
672
- # we are rewriting the headers now
674
- if resp.status == 401 or resp.status == 305:
675
- # Add our own headers to the list
676
- headers = [('WWW_AUTHENTICATE',
677
- "Keystone uri='%s'" % self.auth_location)]
678
- return webob.Response(status=resp.status,
680
- headerlist=headers)(env, start_response)
682
- return webob.Response(status=resp.status,
683
- body=data)(env, start_response)
684
+ tenant_id = user.get('tenantId')
685
+ tenant_name = user.get('tenantName')
687
+ user_id = user['id']
688
+ user_name = user['username']
691
+ 'X-Identity-Status': 'Confirmed',
692
+ 'X-Tenant-Id': tenant_id,
693
+ 'X-Tenant-Name': tenant_name,
694
+ 'X-User-Id': user_id,
695
+ 'X-User-Name': user_name,
698
+ 'X-User': user_name,
699
+ 'X-Tenant': tenant_name,
703
+ def _header_to_env_var(self, key):
704
+ """Convert header to wsgi env variable.
706
+ :param key: http header name (ex. 'X-Auth-Token')
707
+ :return wsgi env variable name (ex. 'HTTP_X_AUTH_TOKEN')
710
+ return 'HTTP_%s' % key.replace('-', '_').upper()
712
+ def _add_headers(self, env, headers):
713
+ """Add http headers to environment."""
714
+ for (k, v) in headers.iteritems():
715
+ env_key = self._header_to_env_var(k)
718
+ def _remove_headers(self, env, keys):
719
+ """Remove http headers from environment."""
721
+ env_key = self._header_to_env_var(k)
727
+ def _get_header(self, env, key, default=None):
728
+ """Get http header from environment."""
729
+ env_key = self._header_to_env_var(key)
730
+ return env.get(env_key, default)
733
def filter_factory(global_conf, **local_conf):
734
@@ -438,12 +440,3 @@ def app_factory(global_conf, **local_con
735
conf = global_conf.copy()
736
conf.update(local_conf)
737
return AuthProtocol(None, conf)
739
-if __name__ == '__main__':
740
- app_path = os.path.join(os.path.abspath(os.path.dirname(__file__)),
743
- 'examples/paste/auth_token.ini')
744
- app = deploy.loadapp('config:%s' % app_path,
745
- global_conf={'log_name': 'auth_token.log'})
746
- wsgi.server(eventlet.listen(('', 8090)), app)