blob: 5fc923fe6ba6344faab4fefdd629e55ffe87bdea [file] [log] [blame]
Andrea Frittoli8bbdb162014-01-06 11:06:13 +00001# Copyright 2014 Hewlett-Packard Development Company, L.P.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import copy
Masayuki Igawa1edf94f2014-03-04 18:34:16 +090017import datetime
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000018import exceptions
19import re
20import urlparse
21
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000022from tempest import config
23from tempest.services.identity.json import identity_client as json_id
24from tempest.services.identity.v3.json import identity_client as json_v3id
25from tempest.services.identity.v3.xml import identity_client as xml_v3id
26from tempest.services.identity.xml import identity_client as xml_id
27
28from tempest.openstack.common import log as logging
29
30CONF = config.CONF
31LOG = logging.getLogger(__name__)
32
33
34class AuthProvider(object):
35 """
36 Provide authentication
37 """
38
39 def __init__(self, credentials, client_type='tempest',
40 interface=None):
41 """
42 :param credentials: credentials for authentication
43 :param client_type: 'tempest' or 'official'
44 :param interface: 'json' or 'xml'. Applicable for tempest client only
45 """
46 if self.check_credentials(credentials):
47 self.credentials = credentials
48 else:
49 raise TypeError("Invalid credentials")
50 self.credentials = credentials
51 self.client_type = client_type
52 self.interface = interface
53 if self.client_type == 'tempest' and self.interface is None:
54 self.interface = 'json'
55 self.cache = None
56 self.alt_auth_data = None
57 self.alt_part = None
58
59 def __str__(self):
60 return "Creds :{creds}, client type: {client_type}, interface: " \
61 "{interface}, cached auth data: {cache}".format(
62 creds=self.credentials, client_type=self.client_type,
63 interface=self.interface, cache=self.cache
64 )
65
66 def _decorate_request(self, filters, method, url, headers=None, body=None,
67 auth_data=None):
68 """
69 Decorate request with authentication data
70 """
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +000071 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000072
73 def _get_auth(self):
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +000074 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +000075
76 @classmethod
77 def check_credentials(cls, credentials):
78 """
79 Verify credentials are valid. Subclasses can do a better check.
80 """
81 return isinstance(credentials, dict)
82
83 @property
84 def auth_data(self):
85 if self.cache is None or self.is_expired(self.cache):
86 self.cache = self._get_auth()
87 return self.cache
88
89 @auth_data.deleter
90 def auth_data(self):
91 self.clear_auth()
92
93 def clear_auth(self):
94 """
95 Can be called to clear the access cache so that next request
96 will fetch a new token and base_url.
97 """
98 self.cache = None
99
100 def is_expired(self, auth_data):
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000101 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000102
103 def auth_request(self, method, url, headers=None, body=None, filters=None):
104 """
105 Obtains auth data and decorates a request with that.
106 :param method: HTTP method of the request
107 :param url: relative URL of the request (path)
108 :param headers: HTTP headers of the request
109 :param body: HTTP body in case of POST / PUT
110 :param filters: select a base URL out of the catalog
111 :returns a Tuple (url, headers, body)
112 """
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000113 orig_req = dict(url=url, headers=headers, body=body)
114
115 auth_url, auth_headers, auth_body = self._decorate_request(
116 filters, method, url, headers, body)
117 auth_req = dict(url=auth_url, headers=auth_headers, body=auth_body)
118
119 # Overwrite part if the request if it has been requested
120 if self.alt_part is not None:
121 if self.alt_auth_data is not None:
122 alt_url, alt_headers, alt_body = self._decorate_request(
123 filters, method, url, headers, body,
124 auth_data=self.alt_auth_data)
125 alt_auth_req = dict(url=alt_url, headers=alt_headers,
126 body=alt_body)
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000127 auth_req[self.alt_part] = alt_auth_req[self.alt_part]
128
129 else:
130 # If alt auth data is None, skip auth in the requested part
131 auth_req[self.alt_part] = orig_req[self.alt_part]
132
133 # Next auth request will be normal, unless otherwise requested
134 self.reset_alt_auth_data()
135
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000136 return auth_req['url'], auth_req['headers'], auth_req['body']
137
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000138 def reset_alt_auth_data(self):
139 """
140 Configure auth provider to provide valid authentication data
141 """
142 self.alt_part = None
143 self.alt_auth_data = None
144
145 def set_alt_auth_data(self, request_part, auth_data):
146 """
147 Configure auth provider to provide alt authentication data
148 on a part of the *next* auth_request. If credentials are None,
149 set invalid data.
150 :param request_part: request part to contain invalid auth: url,
151 headers, body
152 :param auth_data: alternative auth_data from which to get the
153 invalid data to be injected
154 """
155 self.alt_part = request_part
156 self.alt_auth_data = auth_data
157
158 def base_url(self, filters, auth_data=None):
159 """
160 Extracts the base_url based on provided filters
161 """
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000162 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000163
164
165class KeystoneAuthProvider(AuthProvider):
166
Andrea Frittolidbd02512014-03-21 10:06:19 +0000167 token_expiry_threshold = datetime.timedelta(seconds=60)
168
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000169 def __init__(self, credentials, client_type='tempest', interface=None):
170 super(KeystoneAuthProvider, self).__init__(credentials, client_type,
171 interface)
172 self.auth_client = self._auth_client()
173
174 def _decorate_request(self, filters, method, url, headers=None, body=None,
175 auth_data=None):
176 if auth_data is None:
177 auth_data = self.auth_data
178 token, _ = auth_data
179 base_url = self.base_url(filters=filters, auth_data=auth_data)
180 # build authenticated request
181 # returns new request, it does not touch the original values
Mauro S. M. Rodriguesd3d0d582014-02-19 07:51:56 -0500182 _headers = copy.deepcopy(headers) if headers is not None else {}
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000183 _headers['X-Auth-Token'] = token
184 if url is None or url == "":
185 _url = base_url
186 else:
187 # Join base URL and url, and remove multiple contiguous slashes
188 _url = "/".join([base_url, url])
189 parts = [x for x in urlparse.urlparse(_url)]
190 parts[2] = re.sub("/{2,}", "/", parts[2])
191 _url = urlparse.urlunparse(parts)
192 # no change to method or body
193 return _url, _headers, body
194
195 def _auth_client(self):
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000196 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000197
198 def _auth_params(self):
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000199 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000200
201 def _get_auth(self):
202 # Bypasses the cache
203 if self.client_type == 'tempest':
204 auth_func = getattr(self.auth_client, 'get_token')
205 auth_params = self._auth_params()
206
207 # returns token, auth_data
208 token, auth_data = auth_func(**auth_params)
209 return token, auth_data
210 else:
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000211 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000212
213 def get_token(self):
214 return self.auth_data[0]
215
216
217class KeystoneV2AuthProvider(KeystoneAuthProvider):
218
219 EXPIRY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
220
221 @classmethod
222 def check_credentials(cls, credentials, scoped=True):
223 # tenant_name is optional if not scoped
224 valid = super(KeystoneV2AuthProvider, cls).check_credentials(
225 credentials) and 'username' in credentials and \
226 'password' in credentials
227 if scoped:
228 valid = valid and 'tenant_name' in credentials
229 return valid
230
231 def _auth_client(self):
232 if self.client_type == 'tempest':
233 if self.interface == 'json':
234 return json_id.TokenClientJSON()
235 else:
236 return xml_id.TokenClientXML()
237 else:
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000238 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000239
240 def _auth_params(self):
241 if self.client_type == 'tempest':
242 return dict(
243 user=self.credentials['username'],
244 password=self.credentials['password'],
245 tenant=self.credentials.get('tenant_name', None),
246 auth_data=True)
247 else:
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000248 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000249
250 def base_url(self, filters, auth_data=None):
251 """
252 Filters can be:
253 - service: compute, image, etc
254 - region: the service region
255 - endpoint_type: adminURL, publicURL, internalURL
256 - api_version: replace catalog version with this
257 - skip_path: take just the base URL
258 """
259 if auth_data is None:
260 auth_data = self.auth_data
261 token, _auth_data = auth_data
262 service = filters.get('service')
263 region = filters.get('region')
264 endpoint_type = filters.get('endpoint_type', 'publicURL')
265
266 if service is None:
267 raise exceptions.EndpointNotFound("No service provided")
268
269 _base_url = None
270 for ep in _auth_data['serviceCatalog']:
271 if ep["type"] == service:
272 for _ep in ep['endpoints']:
273 if region is not None and _ep['region'] == region:
274 _base_url = _ep.get(endpoint_type)
275 if not _base_url:
276 # No region matching, use the first
277 _base_url = ep['endpoints'][0].get(endpoint_type)
278 break
279 if _base_url is None:
280 raise exceptions.EndpointNotFound(service)
281
282 parts = urlparse.urlparse(_base_url)
283 if filters.get('api_version', None) is not None:
284 path = "/" + filters['api_version']
285 noversion_path = "/".join(parts.path.split("/")[2:])
286 if noversion_path != "":
Zhi Kun Liube8bdbc2014-02-08 11:40:57 +0800287 path += "/" + noversion_path
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000288 _base_url = _base_url.replace(parts.path, path)
289 if filters.get('skip_path', None) is not None:
290 _base_url = _base_url.replace(parts.path, "/")
291
292 return _base_url
293
294 def is_expired(self, auth_data):
295 _, access = auth_data
Masayuki Igawa1edf94f2014-03-04 18:34:16 +0900296 expiry = datetime.datetime.strptime(access['token']['expires'],
297 self.EXPIRY_DATE_FORMAT)
Andrea Frittolidbd02512014-03-21 10:06:19 +0000298 return expiry - self.token_expiry_threshold <= \
299 datetime.datetime.utcnow()
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000300
301
302class KeystoneV3AuthProvider(KeystoneAuthProvider):
303
304 EXPIRY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
305
306 @classmethod
307 def check_credentials(cls, credentials, scoped=True):
308 # tenant_name is optional if not scoped
309 valid = super(KeystoneV3AuthProvider, cls).check_credentials(
310 credentials) and 'username' in credentials and \
311 'password' in credentials and 'domain_name' in credentials
312 if scoped:
313 valid = valid and 'tenant_name' in credentials
314 return valid
315
316 def _auth_client(self):
317 if self.client_type == 'tempest':
318 if self.interface == 'json':
319 return json_v3id.V3TokenClientJSON()
320 else:
321 return xml_v3id.V3TokenClientXML()
322 else:
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000323 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000324
325 def _auth_params(self):
326 if self.client_type == 'tempest':
327 return dict(
328 user=self.credentials['username'],
329 password=self.credentials['password'],
330 tenant=self.credentials.get('tenant_name', None),
331 domain=self.credentials['domain_name'],
332 auth_data=True)
333 else:
Mauro S. M. Rodriguesd35386b2014-02-10 22:00:05 +0000334 raise NotImplementedError
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000335
336 def base_url(self, filters, auth_data=None):
337 """
338 Filters can be:
339 - service: compute, image, etc
340 - region: the service region
341 - endpoint_type: adminURL, publicURL, internalURL
342 - api_version: replace catalog version with this
343 - skip_path: take just the base URL
344 """
345 if auth_data is None:
346 auth_data = self.auth_data
347 token, _auth_data = auth_data
348 service = filters.get('service')
349 region = filters.get('region')
350 endpoint_type = filters.get('endpoint_type', 'public')
351
352 if service is None:
353 raise exceptions.EndpointNotFound("No service provided")
354
355 if 'URL' in endpoint_type:
356 endpoint_type = endpoint_type.replace('URL', '')
357 _base_url = None
358 catalog = _auth_data['catalog']
359 # Select entries with matching service type
360 service_catalog = [ep for ep in catalog if ep['type'] == service]
361 if len(service_catalog) > 0:
362 service_catalog = service_catalog[0]['endpoints']
363 else:
364 # No matching service
365 raise exceptions.EndpointNotFound(service)
366 # Filter by endpoint type (interface)
367 filtered_catalog = [ep for ep in service_catalog if
368 ep['interface'] == endpoint_type]
369 if len(filtered_catalog) == 0:
370 # No matching type, keep all and try matching by region at least
371 filtered_catalog = service_catalog
372 # Filter by region
373 filtered_catalog = [ep for ep in filtered_catalog if
374 ep['region'] == region]
375 if len(filtered_catalog) == 0:
376 # No matching region, take the first endpoint
Mauro S. M. Rodriguesd3d0d582014-02-19 07:51:56 -0500377 filtered_catalog = [service_catalog[0]]
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000378 # There should be only one match. If not take the first.
379 _base_url = filtered_catalog[0].get('url', None)
380 if _base_url is None:
381 raise exceptions.EndpointNotFound(service)
382
383 parts = urlparse.urlparse(_base_url)
384 if filters.get('api_version', None) is not None:
385 path = "/" + filters['api_version']
386 noversion_path = "/".join(parts.path.split("/")[2:])
387 if noversion_path != "":
Mauro S. M. Rodriguesb67129e2014-02-24 15:14:40 -0500388 path += "/" + noversion_path
Andrea Frittoli8bbdb162014-01-06 11:06:13 +0000389 _base_url = _base_url.replace(parts.path, path)
390 if filters.get('skip_path', None) is not None:
391 _base_url = _base_url.replace(parts.path, "/")
392
393 return _base_url
394
395 def is_expired(self, auth_data):
396 _, access = auth_data
Masayuki Igawa1edf94f2014-03-04 18:34:16 +0900397 expiry = datetime.datetime.strptime(access['expires_at'],
398 self.EXPIRY_DATE_FORMAT)
Andrea Frittolidbd02512014-03-21 10:06:19 +0000399 return expiry - self.token_expiry_threshold <= \
400 datetime.datetime.utcnow()