1# Licensed to the Apache Software Foundation (ASF) under one or more 2# contributor license agreements. See the NOTICE file distributed with 3# this work for additional information regarding copyright ownership. 4# The ASF licenses this file to You under the Apache License, Version 2.0 5# (the "License"); you may not use this file except in compliance with 6# the License. You may obtain 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, 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13# See the License for the specific language governing permissions and 14# limitations under the License. 15 16""" 17Common / shared code for handling authentication against OpenStack identity 18service (Keystone). 19""" 20 21from collections import namedtuple 22import datetime 23 24from libcloud.utils.py3 import httplib 25from libcloud.utils.iso8601 import parse_date 26 27from libcloud.common.base import (ConnectionUserAndKey, Response, 28 CertificateConnection) 29from libcloud.compute.types import (LibcloudError, InvalidCredsError, 30 MalformedResponseError) 31 32try: 33 import simplejson as json 34except ImportError: 35 import json # type: ignore 36 37AUTH_API_VERSION = '1.1' 38AUTH_TOKEN_HEADER = 'X-Auth-Token' 39 40# Auth versions which contain token expiration information. 41AUTH_VERSIONS_WITH_EXPIRES = [ 42 '1.1', 43 '2.0', 44 '2.0_apikey', 45 '2.0_password', 46 '2.0_voms', 47 '3.0', 48 '3.x_password', 49 '3.x_appcred', 50 '3.x_oidc_access_token' 51] 52 53# How many seconds to subtract from the auth token expiration time before 54# testing if the token is still valid. 55# The time is subtracted to account for the HTTP request latency and prevent 56# user from getting "InvalidCredsError" if token is about to expire. 57AUTH_TOKEN_EXPIRES_GRACE_SECONDS = 5 58 59 60__all__ = [ 61 'OpenStackAuthenticationCache', 62 'OpenStackAuthenticationCacheKey', 63 'OpenStackAuthenticationContext', 64 65 'OpenStackIdentityVersion', 66 'OpenStackIdentityDomain', 67 'OpenStackIdentityProject', 68 'OpenStackIdentityUser', 69 'OpenStackIdentityRole', 70 71 'OpenStackServiceCatalog', 72 'OpenStackServiceCatalogEntry', 73 'OpenStackServiceCatalogEntryEndpoint', 74 'OpenStackIdentityEndpointType', 75 76 'OpenStackIdentityConnection', 77 'OpenStackIdentity_1_0_Connection', 78 'OpenStackIdentity_1_1_Connection', 79 'OpenStackIdentity_2_0_Connection', 80 'OpenStackIdentity_2_0_Connection_VOMS', 81 'OpenStackIdentity_3_0_Connection', 82 'OpenStackIdentity_3_0_Connection_AppCred', 83 'OpenStackIdentity_3_0_Connection_OIDC_access_token', 84 85 'get_class_for_auth_version' 86] 87 88 89class OpenStackAuthenticationCache: 90 """ 91 Base class for external OpenStack authentication caches. 92 93 Authentication tokens are always cached in memory in 94 :class:`OpenStackIdentityConnection`.auth_token and related fields. These 95 tokens are lost when the driver is garbage collected. To share tokens 96 among multiple drivers, processes, or systems, use an 97 :class:`OpenStackAuthenticationCache` in 98 OpenStackIdentityConnection.auth_cache. 99 100 Cache implementors should inherit this class and define the methods below. 101 """ 102 def get(self, key): 103 """ 104 Get an authentication context from the cache. 105 106 :param key: Key to fetch. 107 :type key: :class:`.OpenStackAuthenticationCacheKey` 108 109 :return: The cached context for the given key, if present; None if not. 110 :rtype: :class:`OpenStackAuthenticationContext` 111 """ 112 raise NotImplementedError 113 114 def put(self, key, context): 115 """ 116 Put an authentication context into the cache. 117 118 :param key: Key where the context will be stored. 119 :type key: :class:`.OpenStackAuthenticationCacheKey` 120 121 :param context: The context to cache. 122 :type context: :class:`.OpenStackAuthenticationContext` 123 """ 124 raise NotImplementedError 125 126 def clear(self, key): 127 """ 128 Clear an authentication context from the cache. 129 130 :param key: Key to clear. 131 :type key: :class:`.OpenStackAuthenticationCacheKey` 132 """ 133 raise NotImplementedError 134 135 136OpenStackAuthenticationCacheKey = namedtuple( 137 'OpenStackAuthenticationCacheKey', 138 ['auth_url', 'user_id', 'token_scope', 'tenant_name', 'domain_name', 139 'tenant_domain_id']) 140 141 142class OpenStackAuthenticationContext: 143 """ 144 An authentication token and related context. 145 """ 146 def __init__(self, token, expiration=None, user=None, roles=None, 147 urls=None): 148 self.token = token 149 self.expiration = expiration 150 self.user = user 151 self.roles = roles 152 self.urls = urls 153 154 155class OpenStackIdentityEndpointType(object): 156 """ 157 Enum class for openstack identity endpoint type. 158 """ 159 INTERNAL = 'internal' 160 EXTERNAL = 'external' 161 ADMIN = 'admin' 162 163 164class OpenStackIdentityTokenScope(object): 165 """ 166 Enum class for openstack identity token scope. 167 """ 168 PROJECT = 'project' 169 DOMAIN = 'domain' 170 UNSCOPED = 'unscoped' 171 172 173class OpenStackIdentityVersion(object): 174 def __init__(self, version, status, updated, url): 175 self.version = version 176 self.status = status 177 self.updated = updated 178 self.url = url 179 180 def __repr__(self): 181 return (('<OpenStackIdentityVersion version=%s, status=%s, ' 182 'updated=%s, url=%s>' % 183 (self.version, self.status, self.updated, self.url))) 184 185 186class OpenStackIdentityDomain(object): 187 def __init__(self, id, name, enabled): 188 self.id = id 189 self.name = name 190 self.enabled = enabled 191 192 def __repr__(self): 193 return (('<OpenStackIdentityDomain id=%s, name=%s, enabled=%s>' % 194 (self.id, self.name, self.enabled))) 195 196 197class OpenStackIdentityProject(object): 198 def __init__(self, id, name, description, enabled, domain_id=None): 199 self.id = id 200 self.name = name 201 self.description = description 202 self.enabled = enabled 203 self.domain_id = domain_id 204 205 def __repr__(self): 206 return (('<OpenStackIdentityProject id=%s, domain_id=%s, name=%s, ' 207 'enabled=%s>' % 208 (self.id, self.domain_id, self.name, self.enabled))) 209 210 211class OpenStackIdentityRole(object): 212 def __init__(self, id, name, description, enabled): 213 self.id = id 214 self.name = name 215 self.description = description 216 self.enabled = enabled 217 218 def __repr__(self): 219 return (('<OpenStackIdentityRole id=%s, name=%s, description=%s, ' 220 'enabled=%s>' % (self.id, self.name, self.description, 221 self.enabled))) 222 223 224class OpenStackIdentityUser(object): 225 def __init__(self, id, domain_id, name, email, description, enabled): 226 self.id = id 227 self.domain_id = domain_id 228 self.name = name 229 self.email = email 230 self.description = description 231 self.enabled = enabled 232 233 def __repr__(self): 234 return (('<OpenStackIdentityUser id=%s, domain_id=%s, name=%s, ' 235 'email=%s, enabled=%s>' % (self.id, self.domain_id, self.name, 236 self.email, self.enabled))) 237 238 239class OpenStackServiceCatalog(object): 240 """ 241 http://docs.openstack.org/api/openstack-identity-service/2.0/content/ 242 243 This class should be instantiated with the contents of the 244 'serviceCatalog' in the auth response. This will do the work of figuring 245 out which services actually exist in the catalog as well as split them up 246 by type, name, and region if available 247 """ 248 249 _auth_version = None 250 _service_catalog = None 251 252 def __init__(self, service_catalog, auth_version=AUTH_API_VERSION): 253 self._auth_version = auth_version 254 255 # Check this way because there are a couple of different 2.0_* 256 # auth types. 257 if '3.x' in self._auth_version: 258 entries = self._parse_service_catalog_auth_v3( 259 service_catalog=service_catalog) 260 elif '2.0' in self._auth_version: 261 entries = self._parse_service_catalog_auth_v2( 262 service_catalog=service_catalog) 263 elif ('1.1' in self._auth_version) or ('1.0' in self._auth_version): 264 entries = self._parse_service_catalog_auth_v1( 265 service_catalog=service_catalog) 266 else: 267 raise LibcloudError('auth version "%s" not supported' 268 % (self._auth_version)) 269 270 # Force consistent ordering by sorting the entries 271 entries = sorted(entries, 272 key=lambda x: x.service_type + (x.service_name or '')) 273 self._entries = entries # stories all the service catalog entries 274 275 def get_entries(self): 276 """ 277 Return all the entries for this service catalog. 278 279 :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntry` 280 """ 281 return self._entries 282 283 def get_catalog(self): 284 """ 285 Deprecated in the favor of ``get_entries`` method. 286 """ 287 return self.get_entries() 288 289 def get_public_urls(self, service_type=None, name=None): 290 """ 291 Retrieve all the available public (external) URLs for the provided 292 service type and name. 293 """ 294 endpoints = self.get_endpoints(service_type=service_type, 295 name=name) 296 297 result = [] 298 for endpoint in endpoints: 299 endpoint_type = endpoint.endpoint_type 300 if endpoint_type == OpenStackIdentityEndpointType.EXTERNAL: 301 result.append(endpoint.url) 302 303 return result 304 305 def get_endpoints(self, service_type=None, name=None): 306 """ 307 Retrieve all the endpoints for the provided service type and name. 308 309 :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntryEndpoint` 310 """ 311 endpoints = [] 312 313 for entry in self._entries: 314 # Note: "if XXX and YYY != XXX" comparison is used to support 315 # partial lookups. 316 # This allows user to pass in only one argument to the method (only 317 # service_type or name), both of them or neither. 318 if service_type and entry.service_type != service_type: 319 continue 320 321 if name and entry.service_name != name: 322 continue 323 324 for endpoint in entry.endpoints: 325 endpoints.append(endpoint) 326 327 return endpoints 328 329 def get_endpoint(self, service_type=None, name=None, region=None, 330 endpoint_type=OpenStackIdentityEndpointType.EXTERNAL): 331 """ 332 Retrieve a single endpoint using the provided criteria. 333 334 Note: If no or more than one matching endpoint is found, an exception 335 is thrown. 336 """ 337 endpoints = [] 338 339 for entry in self._entries: 340 if service_type and entry.service_type != service_type: 341 continue 342 343 if name and entry.service_name != name: 344 continue 345 346 for endpoint in entry.endpoints: 347 if region and endpoint.region != region: 348 continue 349 350 if endpoint_type and endpoint.endpoint_type != endpoint_type: 351 continue 352 353 endpoints.append(endpoint) 354 355 if len(endpoints) == 1: 356 return endpoints[0] 357 elif len(endpoints) > 1: 358 raise ValueError('Found more than 1 matching endpoint') 359 else: 360 raise LibcloudError('Could not find specified endpoint') 361 362 def get_regions(self, service_type=None): 363 """ 364 Retrieve a list of all the available regions. 365 366 :param service_type: If specified, only return regions for this 367 service type. 368 :type service_type: ``str`` 369 370 :rtype: ``list`` of ``str`` 371 """ 372 regions = set() 373 374 for entry in self._entries: 375 if service_type and entry.service_type != service_type: 376 continue 377 378 for endpoint in entry.endpoints: 379 if endpoint.region: 380 regions.add(endpoint.region) 381 382 return sorted(list(regions)) 383 384 def get_service_types(self, region=None): 385 """ 386 Retrieve all the available service types. 387 388 :param region: Optional region to retrieve service types for. 389 :type region: ``str`` 390 391 :rtype: ``list`` of ``str`` 392 """ 393 service_types = set() 394 395 for entry in self._entries: 396 include = True 397 398 for endpoint in entry.endpoints: 399 if region and endpoint.region != region: 400 include = False 401 break 402 403 if include: 404 service_types.add(entry.service_type) 405 406 return sorted(list(service_types)) 407 408 def get_service_names(self, service_type=None, region=None): 409 """ 410 Retrieve list of service names that match service type and region. 411 412 :type service_type: ``str`` 413 :type region: ``str`` 414 415 :rtype: ``list`` of ``str`` 416 """ 417 names = set() 418 419 if '2.0' not in self._auth_version: 420 raise ValueError('Unsupported version: %s' % (self._auth_version)) 421 422 for entry in self._entries: 423 if service_type and entry.service_type != service_type: 424 continue 425 426 include = True 427 for endpoint in entry.endpoints: 428 if region and endpoint.region != region: 429 include = False 430 break 431 432 if include and entry.service_name: 433 names.add(entry.service_name) 434 435 return sorted(list(names)) 436 437 def _parse_service_catalog_auth_v1(self, service_catalog): 438 entries = [] 439 440 for service, endpoints in service_catalog.items(): 441 entry_endpoints = [] 442 for endpoint in endpoints: 443 region = endpoint.get('region', None) 444 445 public_url = endpoint.get('publicURL', None) 446 private_url = endpoint.get('internalURL', None) 447 448 if public_url: 449 entry_endpoint = OpenStackServiceCatalogEntryEndpoint( 450 region=region, url=public_url, 451 endpoint_type=OpenStackIdentityEndpointType.EXTERNAL) 452 entry_endpoints.append(entry_endpoint) 453 454 if private_url: 455 entry_endpoint = OpenStackServiceCatalogEntryEndpoint( 456 region=region, url=private_url, 457 endpoint_type=OpenStackIdentityEndpointType.INTERNAL) 458 entry_endpoints.append(entry_endpoint) 459 460 entry = OpenStackServiceCatalogEntry(service_type=service, 461 endpoints=entry_endpoints) 462 entries.append(entry) 463 464 return entries 465 466 def _parse_service_catalog_auth_v2(self, service_catalog): 467 entries = [] 468 469 for service in service_catalog: 470 service_type = service['type'] 471 service_name = service.get('name', None) 472 473 entry_endpoints = [] 474 for endpoint in service.get('endpoints', []): 475 region = endpoint.get('region', None) 476 477 public_url = endpoint.get('publicURL', None) 478 private_url = endpoint.get('internalURL', None) 479 480 if public_url: 481 entry_endpoint = OpenStackServiceCatalogEntryEndpoint( 482 region=region, url=public_url, 483 endpoint_type=OpenStackIdentityEndpointType.EXTERNAL) 484 entry_endpoints.append(entry_endpoint) 485 486 if private_url: 487 entry_endpoint = OpenStackServiceCatalogEntryEndpoint( 488 region=region, url=private_url, 489 endpoint_type=OpenStackIdentityEndpointType.INTERNAL) 490 entry_endpoints.append(entry_endpoint) 491 492 entry = OpenStackServiceCatalogEntry(service_type=service_type, 493 endpoints=entry_endpoints, 494 service_name=service_name) 495 entries.append(entry) 496 497 return entries 498 499 def _parse_service_catalog_auth_v3(self, service_catalog): 500 entries = [] 501 502 for item in service_catalog: 503 service_type = item['type'] 504 service_name = item.get('name', None) 505 506 entry_endpoints = [] 507 for endpoint in item['endpoints']: 508 region = endpoint.get('region', None) 509 url = endpoint['url'] 510 endpoint_type = endpoint['interface'] 511 512 if endpoint_type == 'internal': 513 endpoint_type = OpenStackIdentityEndpointType.INTERNAL 514 elif endpoint_type == 'public': 515 endpoint_type = OpenStackIdentityEndpointType.EXTERNAL 516 elif endpoint_type == 'admin': 517 endpoint_type = OpenStackIdentityEndpointType.ADMIN 518 519 entry_endpoint = OpenStackServiceCatalogEntryEndpoint( 520 region=region, url=url, endpoint_type=endpoint_type) 521 entry_endpoints.append(entry_endpoint) 522 523 entry = OpenStackServiceCatalogEntry(service_type=service_type, 524 service_name=service_name, 525 endpoints=entry_endpoints) 526 entries.append(entry) 527 528 return entries 529 530 531class OpenStackServiceCatalogEntry(object): 532 def __init__(self, service_type, endpoints=None, service_name=None): 533 """ 534 :param service_type: Service type. 535 :type service_type: ``str`` 536 537 :param endpoints: Endpoints belonging to this entry. 538 :type endpoints: ``list`` 539 540 :param service_name: Optional service name. 541 :type service_name: ``str`` 542 """ 543 self.service_type = service_type 544 self.endpoints = endpoints or [] 545 self.service_name = service_name 546 547 # For consistency, sort the endpoints 548 self.endpoints = sorted(self.endpoints, key=lambda x: x.url or '') 549 550 def __eq__(self, other): 551 return (self.service_type == other.service_type and 552 self.endpoints == other.endpoints and 553 other.service_name == self.service_name) 554 555 def __ne__(self, other): 556 return not self.__eq__(other=other) 557 558 def __repr__(self): 559 return (('<OpenStackServiceCatalogEntry service_type=%s, ' 560 'service_name=%s, endpoints=%s' % 561 (self.service_type, self.service_name, repr(self.endpoints)))) 562 563 564class OpenStackServiceCatalogEntryEndpoint(object): 565 VALID_ENDPOINT_TYPES = [ 566 OpenStackIdentityEndpointType.INTERNAL, 567 OpenStackIdentityEndpointType.EXTERNAL, 568 OpenStackIdentityEndpointType.ADMIN, 569 ] 570 571 def __init__(self, region, url, endpoint_type='external'): 572 """ 573 :param region: Endpoint region. 574 :type region: ``str`` 575 576 :param url: Endpoint URL. 577 :type url: ``str`` 578 579 :param endpoint_type: Endpoint type (external / internal / admin). 580 :type endpoint_type: ``str`` 581 """ 582 if endpoint_type not in self.VALID_ENDPOINT_TYPES: 583 raise ValueError('Invalid type: %s' % (endpoint_type)) 584 585 # TODO: Normalize / lowercase all the region names 586 self.region = region 587 self.url = url 588 self.endpoint_type = endpoint_type 589 590 def __eq__(self, other): 591 return (self.region == other.region and self.url == other.url and 592 self.endpoint_type == other.endpoint_type) 593 594 def __ne__(self, other): 595 return not self.__eq__(other=other) 596 597 def __repr__(self): 598 return (('<OpenStackServiceCatalogEntryEndpoint region=%s, url=%s, ' 599 'type=%s' % (self.region, self.url, self.endpoint_type))) 600 601 602class OpenStackAuthResponse(Response): 603 def success(self): 604 return self.status in [httplib.OK, httplib.CREATED, 605 httplib.ACCEPTED, httplib.NO_CONTENT, 606 httplib.MULTIPLE_CHOICES, 607 httplib.UNAUTHORIZED, 608 httplib.INTERNAL_SERVER_ERROR] 609 610 def parse_body(self): 611 if not self.body: 612 return None 613 614 if 'content-type' in self.headers: 615 key = 'content-type' 616 elif 'Content-Type' in self.headers: 617 key = 'Content-Type' 618 else: 619 raise LibcloudError('Missing content-type header', 620 driver=OpenStackIdentityConnection) 621 622 content_type = self.headers[key] 623 if content_type.find(';') != -1: 624 content_type = content_type.split(';')[0] 625 626 if content_type == 'application/json': 627 try: 628 data = json.loads(self.body) 629 except Exception: 630 driver = OpenStackIdentityConnection 631 raise MalformedResponseError('Failed to parse JSON', 632 body=self.body, 633 driver=driver) 634 elif content_type == 'text/plain': 635 data = self.body 636 else: 637 data = self.body 638 639 return data 640 641 642class OpenStackIdentityConnection(ConnectionUserAndKey): 643 """ 644 Base identity connection class which contains common / shared logic. 645 646 Note: This class shouldn't be instantiated directly. 647 """ 648 responseCls = OpenStackAuthResponse 649 timeout = None 650 auth_version = None # type: str 651 652 def __init__(self, auth_url, user_id, key, tenant_name=None, 653 tenant_domain_id='default', domain_name='Default', 654 token_scope=OpenStackIdentityTokenScope.PROJECT, 655 timeout=None, proxy_url=None, parent_conn=None, 656 auth_cache=None): 657 super(OpenStackIdentityConnection, self).__init__(user_id=user_id, 658 key=key, 659 url=auth_url, 660 timeout=timeout, 661 proxy_url=proxy_url) 662 663 self.parent_conn = parent_conn 664 665 # enable tests to use the same mock connection classes. 666 if parent_conn: 667 self.conn_class = parent_conn.conn_class 668 self.driver = parent_conn.driver 669 else: 670 self.driver = None 671 672 self.auth_url = auth_url 673 self.tenant_name = tenant_name 674 self.domain_name = domain_name 675 self.tenant_domain_id = tenant_domain_id 676 self.token_scope = token_scope 677 self.timeout = timeout 678 self.auth_cache = auth_cache 679 680 self.urls = {} 681 self.auth_token = None 682 self.auth_token_expires = None 683 self.auth_user_info = None 684 self.auth_user_roles = None 685 686 def authenticated_request(self, action, params=None, data=None, 687 headers=None, method='GET', raw=False): 688 """ 689 Perform an authenticated request against the identity API. 690 """ 691 if not self.auth_token: 692 raise ValueError( 693 'Need to be authenticated to perform this request') 694 695 headers = headers or {} 696 headers[AUTH_TOKEN_HEADER] = self.auth_token 697 698 response = self.request(action=action, params=params, data=data, 699 headers=headers, method=method, raw=raw) 700 # Evict cached auth token if we receive Unauthorized while using it 701 if response.status == httplib.UNAUTHORIZED: 702 self.clear_cached_auth_context() 703 return response 704 705 def morph_action_hook(self, action): 706 (_, _, _, request_path) = self._tuple_from_url(self.auth_url) 707 708 if request_path == '': 709 # No path is provided in the auth_url, use action passed to this 710 # method. 711 return action 712 713 return request_path 714 715 def add_default_headers(self, headers): 716 headers['Accept'] = 'application/json' 717 headers['Content-Type'] = 'application/json; charset=UTF-8' 718 return headers 719 720 def is_token_valid(self): 721 """ 722 Return True if the current auth token is already cached and hasn't 723 expired yet. 724 725 :return: ``True`` if the token is still valid, ``False`` otherwise. 726 :rtype: ``bool`` 727 """ 728 if not self.auth_token: 729 return False 730 731 if not self.auth_token_expires: 732 return False 733 734 expires = self.auth_token_expires - \ 735 datetime.timedelta(seconds=AUTH_TOKEN_EXPIRES_GRACE_SECONDS) 736 737 time_tuple_expires = expires.utctimetuple() 738 time_tuple_now = datetime.datetime.utcnow().utctimetuple() 739 740 if time_tuple_now < time_tuple_expires: 741 return True 742 743 return False 744 745 def authenticate(self, force=False): 746 """ 747 Authenticate against the identity API. 748 749 :param force: Forcefully update the token even if it's already cached 750 and still valid. 751 :type force: ``bool`` 752 """ 753 raise NotImplementedError('authenticate not implemented') 754 755 def clear_cached_auth_context(self): 756 """ 757 Clear the cached authentication context. 758 759 The context is cleared from fields on this connection and from the 760 external cache, if one is configured. 761 """ 762 self.auth_token = None 763 self.auth_token_expires = None 764 self.auth_user_info = None 765 self.auth_user_roles = None 766 self.urls = {} 767 768 if self.auth_cache is not None: 769 self.auth_cache.clear(self._cache_key) 770 771 def list_supported_versions(self): 772 """ 773 Retrieve a list of all the identity versions which are supported by 774 this installation. 775 776 :rtype: ``list`` of :class:`.OpenStackIdentityVersion` 777 """ 778 response = self.request('/', method='GET') 779 result = self._to_versions(data=response.object['versions']['values']) 780 result = sorted(result, key=lambda x: x.version) 781 return result 782 783 def _to_versions(self, data): 784 result = [] 785 for item in data: 786 version = self._to_version(data=item) 787 result.append(version) 788 789 return result 790 791 def _to_version(self, data): 792 try: 793 updated = parse_date(data['updated']) 794 except Exception: 795 updated = None 796 797 try: 798 url = data['links'][0]['href'] 799 except IndexError: 800 url = None 801 802 version = OpenStackIdentityVersion(version=data['id'], 803 status=data['status'], 804 updated=updated, 805 url=url) 806 return version 807 808 def _is_authentication_needed(self, force=False): 809 """ 810 Determine if the authentication is needed or if the existing token (if 811 any exists) is still valid. 812 """ 813 if force: 814 return True 815 816 if self.auth_version not in AUTH_VERSIONS_WITH_EXPIRES: 817 return True 818 819 if self.is_token_valid(): 820 return False 821 822 # See if there's a new token in the cache 823 self._load_auth_context_from_cache() 824 825 # If there was a token in the cache, it is now stored in our local 826 # auth_token and related fields. Ensure it is still valid. 827 if self.is_token_valid(): 828 return False 829 830 return True 831 832 def _to_projects(self, data): 833 result = [] 834 for item in data: 835 project = self._to_project(data=item) 836 result.append(project) 837 838 return result 839 840 def _to_project(self, data): 841 project = OpenStackIdentityProject(id=data['id'], 842 name=data['name'], 843 description=data['description'], 844 enabled=data['enabled'], 845 domain_id=data.get('domain_id', 846 None)) 847 return project 848 849 @property 850 def _cache_key(self): 851 """ 852 The key where this connection's authentication context will be cached. 853 854 :rtype: :class:`OpenStackAuthenticationCacheKey` 855 """ 856 return OpenStackAuthenticationCacheKey( 857 self.auth_url, self.user_id, self.token_scope, self.tenant_name, 858 self.domain_name, self.tenant_domain_id) 859 860 def _cache_auth_context(self, context): 861 """ 862 Store an authentication context in memory and the cache. 863 864 :param context: Authentication context to cache. 865 :type key: :class:`.OpenStackAuthenticationContext` 866 """ 867 self.urls = context.urls 868 self.auth_token = context.token 869 self.auth_token_expires = context.expiration 870 self.auth_user_info = context.user 871 self.auth_user_roles = context.roles 872 873 if self.auth_cache is not None: 874 self.auth_cache.put(self._cache_key, context) 875 876 def _load_auth_context_from_cache(self): 877 """ 878 Fetch an authentication context for this connection from the cache. 879 880 :rtype: :class:`OpenStackAuthenticationContext` 881 """ 882 if self.auth_cache is None: 883 return None 884 885 context = self.auth_cache.get(self._cache_key) 886 if context is None: 887 return None 888 889 self.urls = context.urls 890 self.auth_token = context.token 891 self.auth_token_expires = context.expiration 892 self.auth_user_info = context.user 893 self.auth_user_roles = context.roles 894 return context 895 896 897class OpenStackIdentity_1_0_Connection(OpenStackIdentityConnection): 898 """ 899 Connection class for Keystone API v1.0. 900 """ 901 902 responseCls = OpenStackAuthResponse 903 name = 'OpenStack Identity API v1.0' 904 auth_version = '1.0' 905 906 def authenticate(self, force=False): 907 if not self._is_authentication_needed(force=force): 908 return self 909 910 headers = { 911 'X-Auth-User': self.user_id, 912 'X-Auth-Key': self.key, 913 } 914 915 resp = self.request('/v1.0', headers=headers, method='GET') 916 917 if resp.status == httplib.UNAUTHORIZED: 918 # HTTP UNAUTHORIZED (401): auth failed 919 raise InvalidCredsError() 920 elif resp.status not in [httplib.NO_CONTENT, httplib.OK]: 921 body = 'code: %s body:%s headers:%s' % (resp.status, 922 resp.body, 923 resp.headers) 924 raise MalformedResponseError('Malformed response', body=body, 925 driver=self.driver) 926 else: 927 headers = resp.headers 928 # emulate the auth 1.1 URL list 929 self.urls = {} 930 self.urls['cloudServers'] = \ 931 [{'publicURL': headers.get('x-server-management-url', None)}] 932 self.urls['cloudFilesCDN'] = \ 933 [{'publicURL': headers.get('x-cdn-management-url', None)}] 934 self.urls['cloudFiles'] = \ 935 [{'publicURL': headers.get('x-storage-url', None)}] 936 self.auth_token = headers.get('x-auth-token', None) 937 self.auth_user_info = None 938 939 if not self.auth_token: 940 raise MalformedResponseError('Missing X-Auth-Token in' 941 ' response headers') 942 943 return self 944 945 946class OpenStackIdentity_1_1_Connection(OpenStackIdentityConnection): 947 """ 948 Connection class for Keystone API v1.1. 949 """ 950 951 responseCls = OpenStackAuthResponse 952 name = 'OpenStack Identity API v1.1' 953 auth_version = '1.1' 954 955 def authenticate(self, force=False): 956 if not self._is_authentication_needed(force=force): 957 return self 958 959 reqbody = json.dumps({'credentials': {'username': self.user_id, 960 'key': self.key}}) 961 resp = self.request('/v1.1/auth', data=reqbody, headers={}, 962 method='POST') 963 964 if resp.status == httplib.UNAUTHORIZED: 965 # HTTP UNAUTHORIZED (401): auth failed 966 raise InvalidCredsError() 967 elif resp.status != httplib.OK: 968 body = 'code: %s body:%s' % (resp.status, resp.body) 969 raise MalformedResponseError('Malformed response', body=body, 970 driver=self.driver) 971 else: 972 try: 973 body = json.loads(resp.body) 974 except Exception as e: 975 raise MalformedResponseError('Failed to parse JSON', e) 976 977 try: 978 expires = body['auth']['token']['expires'] 979 self._cache_auth_context( 980 OpenStackAuthenticationContext( 981 body['auth']['token']['id'], 982 expiration=parse_date(expires), 983 urls=body['auth']['serviceCatalog'])) 984 except KeyError as e: 985 raise MalformedResponseError('Auth JSON response is \ 986 missing required elements', e) 987 988 return self 989 990 991class OpenStackIdentity_2_0_Connection(OpenStackIdentityConnection): 992 """ 993 Connection class for Keystone API v2.0. 994 """ 995 996 responseCls = OpenStackAuthResponse 997 name = 'OpenStack Identity API v1.0' 998 auth_version = '2.0' 999 1000 def authenticate(self, auth_type='api_key', force=False): 1001 if not self._is_authentication_needed(force=force): 1002 return self 1003 1004 if auth_type == 'api_key': 1005 return self._authenticate_2_0_with_api_key() 1006 elif auth_type == 'password': 1007 return self._authenticate_2_0_with_password() 1008 else: 1009 raise ValueError('Invalid value for auth_type argument') 1010 1011 def _authenticate_2_0_with_api_key(self): 1012 # API Key based authentication uses the RAX-KSKEY extension. 1013 # http://s.apache.org/oAi 1014 data = {'auth': 1015 {'RAX-KSKEY:apiKeyCredentials': 1016 {'username': self.user_id, 'apiKey': self.key}}} 1017 if self.tenant_name: 1018 data['auth']['tenantName'] = self.tenant_name 1019 reqbody = json.dumps(data) 1020 return self._authenticate_2_0_with_body(reqbody) 1021 1022 def _authenticate_2_0_with_password(self): 1023 # Password based authentication is the only 'core' authentication 1024 # method in Keystone at this time. 1025 # 'keystone' - http://s.apache.org/e8h 1026 data = {'auth': 1027 {'passwordCredentials': 1028 {'username': self.user_id, 'password': self.key}}} 1029 if self.tenant_name: 1030 data['auth']['tenantName'] = self.tenant_name 1031 reqbody = json.dumps(data) 1032 return self._authenticate_2_0_with_body(reqbody) 1033 1034 def _authenticate_2_0_with_body(self, reqbody): 1035 resp = self.request('/v2.0/tokens', data=reqbody, 1036 headers={'Content-Type': 'application/json'}, 1037 method='POST') 1038 1039 if resp.status == httplib.UNAUTHORIZED: 1040 raise InvalidCredsError() 1041 elif resp.status not in [httplib.OK, 1042 httplib.NON_AUTHORITATIVE_INFORMATION]: 1043 body = 'code: %s body: %s' % (resp.status, resp.body) 1044 raise MalformedResponseError('Malformed response', body=body, 1045 driver=self.driver) 1046 else: 1047 body = resp.object 1048 1049 try: 1050 access = body['access'] 1051 expires = access['token']['expires'] 1052 self._cache_auth_context( 1053 OpenStackAuthenticationContext( 1054 access['token']['id'], 1055 expiration=parse_date(expires), 1056 urls=access['serviceCatalog'], 1057 user=access.get('user', {}))) 1058 except KeyError as e: 1059 raise MalformedResponseError('Auth JSON response is \ 1060 missing required elements', e) 1061 1062 return self 1063 1064 def list_projects(self): 1065 response = self.authenticated_request('/v2.0/tenants', method='GET') 1066 result = self._to_projects(data=response.object['tenants']) 1067 return result 1068 1069 def list_tenants(self): 1070 return self.list_projects() 1071 1072 1073class OpenStackIdentity_3_0_Connection(OpenStackIdentityConnection): 1074 """ 1075 Connection class for Keystone API v3.x. 1076 """ 1077 1078 responseCls = OpenStackAuthResponse 1079 name = 'OpenStack Identity API v3.x' 1080 auth_version = '3.0' 1081 1082 VALID_TOKEN_SCOPES = [ 1083 OpenStackIdentityTokenScope.PROJECT, 1084 OpenStackIdentityTokenScope.DOMAIN, 1085 OpenStackIdentityTokenScope.UNSCOPED 1086 ] 1087 1088 def __init__(self, auth_url, user_id, key, tenant_name=None, 1089 domain_name='Default', tenant_domain_id='default', 1090 token_scope=OpenStackIdentityTokenScope.PROJECT, 1091 timeout=None, proxy_url=None, parent_conn=None, 1092 auth_cache=None): 1093 """ 1094 :param tenant_name: Name of the project this user belongs to. Note: 1095 When token_scope is set to project, this argument 1096 control to which project to scope the token to. 1097 :type tenant_name: ``str`` 1098 1099 :param domain_name: Domain the user belongs to. Note: When token_scope 1100 is set to token, this argument controls to which 1101 domain to scope the token to. 1102 :type domain_name: ``str`` 1103 1104 :param token_scope: Whether to scope a token to a "project", a 1105 "domain" or "unscoped" 1106 :type token_scope: ``str`` 1107 1108 :param auth_cache: Where to cache authentication tokens. 1109 :type auth_cache: :class:`OpenStackAuthenticationCache` 1110 """ 1111 super(OpenStackIdentity_3_0_Connection, 1112 self).__init__(auth_url=auth_url, 1113 user_id=user_id, 1114 key=key, 1115 tenant_name=tenant_name, 1116 domain_name=domain_name, 1117 tenant_domain_id=tenant_domain_id, 1118 token_scope=token_scope, 1119 timeout=timeout, 1120 proxy_url=proxy_url, 1121 parent_conn=parent_conn, 1122 auth_cache=auth_cache) 1123 1124 if self.token_scope not in self.VALID_TOKEN_SCOPES: 1125 raise ValueError('Invalid value for "token_scope" argument: %s' % 1126 (self.token_scope)) 1127 1128 if (self.token_scope == OpenStackIdentityTokenScope.PROJECT and 1129 (not self.tenant_name or not self.domain_name)): 1130 raise ValueError('Must provide tenant_name and domain_name ' 1131 'argument') 1132 elif (self.token_scope == OpenStackIdentityTokenScope.DOMAIN and 1133 not self.domain_name): 1134 raise ValueError('Must provide domain_name argument') 1135 1136 def authenticate(self, force=False): 1137 """ 1138 Perform authentication. 1139 """ 1140 if not self._is_authentication_needed(force=force): 1141 return self 1142 1143 data = self._get_auth_data() 1144 data = json.dumps(data) 1145 response = self.request('/v3/auth/tokens', data=data, 1146 headers={'Content-Type': 'application/json'}, 1147 method='POST') 1148 self._parse_token_response(response, cache_it=True) 1149 return self 1150 1151 def list_domains(self): 1152 """ 1153 List the available domains. 1154 1155 :rtype: ``list`` of :class:`OpenStackIdentityDomain` 1156 """ 1157 response = self.authenticated_request('/v3/domains', method='GET') 1158 result = self._to_domains(data=response.object['domains']) 1159 return result 1160 1161 def list_projects(self): 1162 """ 1163 List the available projects. 1164 1165 Note: To perform this action, user you are currently authenticated with 1166 needs to be an admin. 1167 1168 :rtype: ``list`` of :class:`OpenStackIdentityProject` 1169 """ 1170 response = self.authenticated_request('/v3/projects', method='GET') 1171 result = self._to_projects(data=response.object['projects']) 1172 return result 1173 1174 def list_users(self): 1175 """ 1176 List the available users. 1177 1178 :rtype: ``list`` of :class:`.OpenStackIdentityUser` 1179 """ 1180 response = self.authenticated_request('/v3/users', method='GET') 1181 result = self._to_users(data=response.object['users']) 1182 return result 1183 1184 def list_roles(self): 1185 """ 1186 List the available roles. 1187 1188 :rtype: ``list`` of :class:`.OpenStackIdentityRole` 1189 """ 1190 response = self.authenticated_request('/v3/roles', method='GET') 1191 result = self._to_roles(data=response.object['roles']) 1192 return result 1193 1194 def get_domain(self, domain_id): 1195 """ 1196 Retrieve information about a single domain. 1197 1198 :param domain_id: ID of domain to retrieve information for. 1199 :type domain_id: ``str`` 1200 1201 :rtype: :class:`.OpenStackIdentityDomain` 1202 """ 1203 response = self.authenticated_request('/v3/domains/%s' % (domain_id), 1204 method='GET') 1205 result = self._to_domain(data=response.object['domain']) 1206 return result 1207 1208 def get_user(self, user_id): 1209 """ 1210 Get a user account by ID. 1211 1212 :param user_id: User's id. 1213 :type name: ``str`` 1214 1215 :return: Located user. 1216 :rtype: :class:`.OpenStackIdentityUser` 1217 """ 1218 response = self.authenticated_request('/v3/users/%s' % user_id) 1219 user = self._to_user(data=response.object['user']) 1220 return user 1221 1222 def list_user_projects(self, user): 1223 """ 1224 Retrieve all the projects user belongs to. 1225 1226 :rtype: ``list`` of :class:`.OpenStackIdentityProject` 1227 """ 1228 path = '/v3/users/%s/projects' % (user.id) 1229 response = self.authenticated_request(path, method='GET') 1230 result = self._to_projects(data=response.object['projects']) 1231 return result 1232 1233 def list_user_domain_roles(self, domain, user): 1234 """ 1235 Retrieve all the roles for a particular user on a domain. 1236 1237 :rtype: ``list`` of :class:`.OpenStackIdentityRole` 1238 """ 1239 # TODO: Also add "get users roles" and "get assginements" which are 1240 # available in 3.1 and 3.3 1241 path = '/v3/domains/%s/users/%s/roles' % (domain.id, user.id) 1242 response = self.authenticated_request(path, method='GET') 1243 result = self._to_roles(data=response.object['roles']) 1244 return result 1245 1246 def grant_domain_role_to_user(self, domain, role, user): 1247 """ 1248 Grant domain role to a user. 1249 1250 Note: This function appears to be idempotent. 1251 1252 :param domain: Domain to grant the role to. 1253 :type domain: :class:`.OpenStackIdentityDomain` 1254 1255 :param role: Role to grant. 1256 :type role: :class:`.OpenStackIdentityRole` 1257 1258 :param user: User to grant the role to. 1259 :type user: :class:`.OpenStackIdentityUser` 1260 1261 :return: ``True`` on success. 1262 :rtype: ``bool`` 1263 """ 1264 path = ('/v3/domains/%s/users/%s/roles/%s' % 1265 (domain.id, user.id, role.id)) 1266 response = self.authenticated_request(path, method='PUT') 1267 return response.status == httplib.NO_CONTENT 1268 1269 def revoke_domain_role_from_user(self, domain, user, role): 1270 """ 1271 Revoke domain role from a user. 1272 1273 :param domain: Domain to revoke the role from. 1274 :type domain: :class:`.OpenStackIdentityDomain` 1275 1276 :param role: Role to revoke. 1277 :type role: :class:`.OpenStackIdentityRole` 1278 1279 :param user: User to revoke the role from. 1280 :type user: :class:`.OpenStackIdentityUser` 1281 1282 :return: ``True`` on success. 1283 :rtype: ``bool`` 1284 """ 1285 path = ('/v3/domains/%s/users/%s/roles/%s' % 1286 (domain.id, user.id, role.id)) 1287 response = self.authenticated_request(path, method='DELETE') 1288 return response.status == httplib.NO_CONTENT 1289 1290 def grant_project_role_to_user(self, project, role, user): 1291 """ 1292 Grant project role to a user. 1293 1294 Note: This function appears to be idempotent. 1295 1296 :param project: Project to grant the role to. 1297 :type project: :class:`.OpenStackIdentityDomain` 1298 1299 :param role: Role to grant. 1300 :type role: :class:`.OpenStackIdentityRole` 1301 1302 :param user: User to grant the role to. 1303 :type user: :class:`.OpenStackIdentityUser` 1304 1305 :return: ``True`` on success. 1306 :rtype: ``bool`` 1307 """ 1308 path = ('/v3/projects/%s/users/%s/roles/%s' % 1309 (project.id, user.id, role.id)) 1310 response = self.authenticated_request(path, method='PUT') 1311 return response.status == httplib.NO_CONTENT 1312 1313 def revoke_project_role_from_user(self, project, role, user): 1314 """ 1315 Revoke project role from a user. 1316 1317 :param project: Project to revoke the role from. 1318 :type project: :class:`.OpenStackIdentityDomain` 1319 1320 :param role: Role to revoke. 1321 :type role: :class:`.OpenStackIdentityRole` 1322 1323 :param user: User to revoke the role from. 1324 :type user: :class:`.OpenStackIdentityUser` 1325 1326 :return: ``True`` on success. 1327 :rtype: ``bool`` 1328 """ 1329 path = ('/v3/projects/%s/users/%s/roles/%s' % 1330 (project.id, user.id, role.id)) 1331 response = self.authenticated_request(path, method='DELETE') 1332 return response.status == httplib.NO_CONTENT 1333 1334 def create_user(self, email, password, name, description=None, 1335 domain_id=None, default_project_id=None, enabled=True): 1336 """ 1337 Create a new user account. 1338 1339 :param email: User's mail address. 1340 :type email: ``str`` 1341 1342 :param password: User's password. 1343 :type password: ``str`` 1344 1345 :param name: User's name. 1346 :type name: ``str`` 1347 1348 :param description: Optional description. 1349 :type description: ``str`` 1350 1351 :param domain_id: ID of the domain to add the user to (optional). 1352 :type domain_id: ``str`` 1353 1354 :param default_project_id: ID of the default user project (optional). 1355 :type default_project_id: ``str`` 1356 1357 :param enabled: True to enable user after creation. 1358 :type enabled: ``bool`` 1359 1360 :return: Created user. 1361 :rtype: :class:`.OpenStackIdentityUser` 1362 """ 1363 data = { 1364 'email': email, 1365 'password': password, 1366 'name': name, 1367 'enabled': enabled 1368 } 1369 1370 if description: 1371 data['description'] = description 1372 1373 if domain_id: 1374 data['domain_id'] = domain_id 1375 1376 if default_project_id: 1377 data['default_project_id'] = default_project_id 1378 1379 data = json.dumps({'user': data}) 1380 response = self.authenticated_request('/v3/users', data=data, 1381 method='POST') 1382 1383 user = self._to_user(data=response.object['user']) 1384 return user 1385 1386 def enable_user(self, user): 1387 """ 1388 Enable user account. 1389 1390 Note: This operation appears to be idempotent. 1391 1392 :param user: User to enable. 1393 :type user: :class:`.OpenStackIdentityUser` 1394 1395 :return: User account which has been enabled. 1396 :rtype: :class:`.OpenStackIdentityUser` 1397 """ 1398 data = { 1399 'enabled': True 1400 } 1401 data = json.dumps({'user': data}) 1402 response = self.authenticated_request('/v3/users/%s' % (user.id), 1403 data=data, 1404 method='PATCH') 1405 1406 user = self._to_user(data=response.object['user']) 1407 return user 1408 1409 def disable_user(self, user): 1410 """ 1411 Disable user account. 1412 1413 Note: This operation appears to be idempotent. 1414 1415 :param user: User to disable. 1416 :type user: :class:`.OpenStackIdentityUser` 1417 1418 :return: User account which has been disabled. 1419 :rtype: :class:`.OpenStackIdentityUser` 1420 """ 1421 data = { 1422 'enabled': False 1423 } 1424 data = json.dumps({'user': data}) 1425 response = self.authenticated_request('/v3/users/%s' % (user.id), 1426 data=data, 1427 method='PATCH') 1428 1429 user = self._to_user(data=response.object['user']) 1430 return user 1431 1432 def _get_auth_data(self): 1433 data = { 1434 'auth': { 1435 'identity': { 1436 'methods': ['password'], 1437 'password': { 1438 'user': { 1439 'domain': { 1440 'name': self.domain_name 1441 }, 1442 'name': self.user_id, 1443 'password': self.key 1444 } 1445 } 1446 } 1447 } 1448 } 1449 1450 if self.token_scope == OpenStackIdentityTokenScope.PROJECT: 1451 # Scope token to project (tenant) 1452 data['auth']['scope'] = { 1453 'project': { 1454 'domain': { 1455 'id': self.tenant_domain_id 1456 }, 1457 'name': self.tenant_name 1458 } 1459 } 1460 elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN: 1461 # Scope token to domain 1462 data['auth']['scope'] = { 1463 'domain': { 1464 'name': self.domain_name 1465 } 1466 } 1467 elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED: 1468 pass 1469 else: 1470 raise ValueError('Token needs to be scoped either to project or ' 1471 'a domain') 1472 1473 return data 1474 1475 def _load_auth_context_from_cache(self): 1476 context = super()._load_auth_context_from_cache() 1477 if context is None: 1478 return None 1479 1480 # Since v3 only caches the token and expiration, fetch the 1481 # service catalog and other bits of the authentication context 1482 # from Keystone. 1483 try: 1484 self._fetch_auth_token() 1485 except InvalidCredsError: 1486 # Unauthorized; cached auth context was cleared as part of 1487 # _fetch_auth_token 1488 return None 1489 1490 # Local auth context variables set in _fetch_auth_token 1491 return context 1492 1493 def _parse_token_response(self, response, cache_it=False, 1494 raise_ambiguous_version_error=True): 1495 """ 1496 Parse a response from /v3/auth/tokens. 1497 1498 :param cache_it: Should we cache the authentication context? 1499 :type cache_it: ``bool`` 1500 1501 :param raise_ambiguous_version_error: Should an ambiguous version 1502 error be raised on a 300 response? 1503 :type raise_ambiguous_version_error: ``bool`` 1504 """ 1505 if response.status == httplib.UNAUTHORIZED: 1506 raise InvalidCredsError() 1507 elif response.status in [httplib.OK, httplib.CREATED]: 1508 headers = response.headers 1509 1510 try: 1511 body = json.loads(response.body) 1512 except Exception as e: 1513 raise MalformedResponseError('Failed to parse JSON', e) 1514 1515 try: 1516 roles = self._to_roles(body['token']['roles']) 1517 except Exception: 1518 roles = [] 1519 1520 try: 1521 expires = parse_date(body['token']['expires_at']) 1522 token = headers['x-subject-token'] 1523 1524 # Cache the fewest fields required for token reuse to minimize 1525 # cache size. Other fields, especially the service catalog, can 1526 # be quite large. Fetch these from Keystone when the token is 1527 # first loaded from cache. 1528 if cache_it: 1529 self._cache_auth_context( 1530 OpenStackAuthenticationContext( 1531 token, expiration=expires)) 1532 1533 self.auth_token = token 1534 self.auth_token_expires = expires 1535 # Note: catalog is not returned for unscoped tokens 1536 self.urls = body['token'].get('catalog', None) 1537 self.auth_user_info = body['token'].get('user', None) 1538 self.auth_user_roles = roles 1539 except KeyError as e: 1540 raise MalformedResponseError('Auth JSON response is \ 1541 missing required elements', e) 1542 elif raise_ambiguous_version_error and response.status == 300: 1543 # ambiguous version request 1544 raise LibcloudError( 1545 'Auth request returned ambiguous version error, try' 1546 'using the version specific URL to connect,' 1547 ' e.g. identity/v3/auth/tokens') 1548 else: 1549 body = 'code: %s body:%s' % (response.status, response.body) 1550 raise MalformedResponseError('Malformed response', body=body, 1551 driver=self.driver) 1552 1553 def _fetch_auth_token(self): 1554 """ 1555 Fetch our authentication token and service catalog. 1556 """ 1557 headers = {'X-Subject-Token': self.auth_token} 1558 response = self.authenticated_request('/v3/auth/tokens', 1559 headers=headers) 1560 self._parse_token_response(response) 1561 return self 1562 1563 def _to_domains(self, data): 1564 result = [] 1565 for item in data: 1566 domain = self._to_domain(data=item) 1567 result.append(domain) 1568 1569 return result 1570 1571 def _to_domain(self, data): 1572 domain = OpenStackIdentityDomain(id=data['id'], 1573 name=data['name'], 1574 enabled=data['enabled']) 1575 return domain 1576 1577 def _to_users(self, data): 1578 result = [] 1579 for item in data: 1580 user = self._to_user(data=item) 1581 result.append(user) 1582 1583 return result 1584 1585 def _to_user(self, data): 1586 user = OpenStackIdentityUser(id=data['id'], 1587 domain_id=data['domain_id'], 1588 name=data['name'], 1589 email=data.get('email'), 1590 description=data.get('description', 1591 None), 1592 enabled=data.get('enabled')) 1593 return user 1594 1595 def _to_roles(self, data): 1596 result = [] 1597 for item in data: 1598 user = self._to_role(data=item) 1599 result.append(user) 1600 1601 return result 1602 1603 def _to_role(self, data): 1604 role = OpenStackIdentityRole(id=data['id'], 1605 name=data['name'], 1606 description=data.get('description', 1607 None), 1608 enabled=data.get('enabled', True)) 1609 return role 1610 1611 1612class OpenStackIdentity_3_0_Connection_AppCred( 1613 OpenStackIdentity_3_0_Connection): 1614 """ 1615 Connection class for Keystone API v3.x using Application Credentials. 1616 1617 'user_id' is the application credential id and 'key' is the application 1618 credential secret. 1619 """ 1620 name = 'OpenStack Identity API v3.x with Application Credentials' 1621 1622 def __init__(self, auth_url, user_id, key, tenant_name=None, 1623 domain_name=None, tenant_domain_id=None, token_scope=None, 1624 timeout=None, proxy_url=None, parent_conn=None): 1625 """ 1626 Tenant, domain and scope options are ignored as they are contained 1627 within the app credential itself and can't be changed. 1628 """ 1629 super(OpenStackIdentity_3_0_Connection_AppCred, 1630 self).__init__(auth_url=auth_url, 1631 user_id=user_id, 1632 key=key, 1633 tenant_name=tenant_name, 1634 domain_name=domain_name, 1635 token_scope=OpenStackIdentityTokenScope.UNSCOPED, 1636 timeout=timeout, 1637 proxy_url=proxy_url, 1638 parent_conn=parent_conn) 1639 1640 def _get_auth_data(self): 1641 data = { 1642 'auth': { 1643 'identity': { 1644 'methods': ['application_credential'], 1645 'application_credential': { 1646 'id': self.user_id, 1647 'secret': self.key 1648 } 1649 } 1650 } 1651 } 1652 return data 1653 1654 1655class OpenStackIdentity_3_0_Connection_OIDC_access_token( 1656 OpenStackIdentity_3_0_Connection): 1657 """ 1658 Connection class for Keystone API v3.x. using OpenID Connect tokens 1659 1660 The OIDC token must be set in the self.key attribute. 1661 1662 The identity provider name required to get the full path 1663 must be set in the self.user_id attribute. 1664 1665 The protocol name required to get the full path 1666 must be set in the self.tenant_name attribute. 1667 1668 The self.domain_name attribute can be used either to select the 1669 domain name in case of domain scoped token or to select the project 1670 name in case of project scoped token 1671 """ 1672 1673 responseCls = OpenStackAuthResponse 1674 name = 'OpenStack Identity API v3.x with OIDC support' 1675 auth_version = '3.0' 1676 1677 def authenticate(self, force=False): 1678 """ 1679 Perform authentication. 1680 """ 1681 if not self._is_authentication_needed(force=force): 1682 return self 1683 1684 subject_token = self._get_unscoped_token_from_oidc_token() 1685 1686 data = { 1687 'auth': { 1688 'identity': { 1689 'methods': ['token'], 1690 'token': { 1691 'id': subject_token 1692 } 1693 } 1694 } 1695 } 1696 1697 if self.token_scope == OpenStackIdentityTokenScope.PROJECT: 1698 # Scope token to project (tenant) 1699 project_id = self._get_project_id(token=subject_token) 1700 data['auth']['scope'] = { 1701 'project': { 1702 'id': project_id 1703 } 1704 } 1705 elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN: 1706 # Scope token to domain 1707 data['auth']['scope'] = { 1708 'domain': { 1709 'name': self.domain_name 1710 } 1711 } 1712 elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED: 1713 pass 1714 else: 1715 raise ValueError('Token needs to be scoped either to project or ' 1716 'a domain') 1717 1718 data = json.dumps(data) 1719 response = self.request('/v3/auth/tokens', data=data, 1720 headers={'Content-Type': 'application/json'}, 1721 method='POST') 1722 self._parse_token_response(response, cache_it=True, 1723 raise_ambiguous_version_error=False) 1724 return self 1725 1726 def _get_unscoped_token_from_oidc_token(self): 1727 """ 1728 Get unscoped token from OIDC access token 1729 """ 1730 path = ('/v3/OS-FEDERATION/identity_providers/%s/protocols/%s/auth' % 1731 (self.user_id, self.tenant_name)) 1732 response = self.request(path, 1733 headers={'Content-Type': 'application/json', 1734 'Authorization': 'Bearer %s' % 1735 self.key}, 1736 method='GET') 1737 1738 if response.status == httplib.UNAUTHORIZED: 1739 # Invalid credentials 1740 raise InvalidCredsError() 1741 elif response.status in [httplib.OK, httplib.CREATED]: 1742 if 'x-subject-token' in response.headers: 1743 return response.headers['x-subject-token'] 1744 else: 1745 raise MalformedResponseError('No x-subject-token returned', 1746 driver=self.driver) 1747 else: 1748 raise MalformedResponseError('Malformed response', 1749 driver=self.driver, 1750 body=response.body) 1751 1752 def _get_project_id(self, token): 1753 """ 1754 Get the first project ID accessible with the specified access token 1755 """ 1756 # Try new path first (from ver 1.1) 1757 path = '/v3/auth/projects' 1758 response = self.request(path, 1759 headers={'Content-Type': 'application/json', 1760 AUTH_TOKEN_HEADER: token}, 1761 method='GET') 1762 1763 if response.status not in [httplib.UNAUTHORIZED, httplib.OK, 1764 httplib.CREATED]: 1765 # In case of error try old one 1766 path = '/v3/OS-FEDERATION/projects' 1767 response = self.request(path, 1768 headers={'Content-Type': 1769 'application/json', 1770 AUTH_TOKEN_HEADER: token}, 1771 method='GET') 1772 1773 if response.status == httplib.UNAUTHORIZED: 1774 # Invalid credentials 1775 raise InvalidCredsError() 1776 elif response.status in [httplib.OK, httplib.CREATED]: 1777 try: 1778 body = json.loads(response.body) 1779 # We use domain_name in both cases of the scoped tokens 1780 # as we have used tenant as the protocol 1781 if self.domain_name and self.domain_name != 'Default': 1782 for project in body['projects']: 1783 if self.domain_name in [project['name'], 1784 project['id']]: 1785 return project['id'] 1786 raise ValueError('Project %s not found' % 1787 (self.domain_name)) 1788 else: 1789 return body['projects'][0]['id'] 1790 except ValueError as e: 1791 raise e 1792 except Exception as e: 1793 raise MalformedResponseError('Failed to parse JSON', e) 1794 else: 1795 raise MalformedResponseError('Malformed response', 1796 driver=self.driver, 1797 body=response.body) 1798 1799 1800class OpenStackIdentity_2_0_Connection_VOMS(OpenStackIdentityConnection, 1801 CertificateConnection): 1802 """ 1803 Connection class for Keystone API v2.0. with VOMS proxy support 1804 In this case the key parameter will be the path of the VOMS proxy file. 1805 """ 1806 1807 responseCls = OpenStackAuthResponse 1808 name = 'OpenStack Identity API v2.0 VOMS support' 1809 auth_version = '2.0' 1810 1811 def __init__(self, auth_url, user_id, key, tenant_name=None, 1812 domain_name='Default', 1813 token_scope=OpenStackIdentityTokenScope.PROJECT, 1814 timeout=None, proxy_url=None, parent_conn=None, 1815 auth_cache=None): 1816 CertificateConnection.__init__(self, cert_file=key, 1817 url=auth_url, 1818 proxy_url=proxy_url, 1819 timeout=timeout) 1820 1821 self.parent_conn = parent_conn 1822 1823 # enable tests to use the same mock connection classes. 1824 if parent_conn: 1825 self.conn_class = parent_conn.conn_class 1826 self.driver = parent_conn.driver 1827 else: 1828 self.driver = None 1829 1830 self.auth_url = auth_url 1831 self.tenant_name = tenant_name 1832 self.domain_name = domain_name 1833 self.token_scope = token_scope 1834 self.timeout = timeout 1835 self.proxy_url = proxy_url 1836 self.auth_cache = auth_cache 1837 1838 self.urls = {} 1839 self.auth_token = None 1840 self.auth_token_expires = None 1841 self.auth_user_info = None 1842 1843 def authenticate(self, force=False): 1844 if not self._is_authentication_needed(force=force): 1845 return self 1846 1847 tenant = self.tenant_name 1848 if not tenant: 1849 # if the tenant name is not specified look for it 1850 token = self._get_unscoped_token() 1851 tenant = self._get_tenant_name(token) 1852 1853 data = {'auth': {'voms': True, 'tenantName': tenant}} 1854 1855 reqbody = json.dumps(data) 1856 return self._authenticate_2_0_with_body(reqbody) 1857 1858 def _get_unscoped_token(self): 1859 """ 1860 Get unscoped token from VOMS proxy 1861 """ 1862 data = {'auth': {'voms': True}} 1863 reqbody = json.dumps(data) 1864 1865 response = self.request('/v2.0/tokens', data=reqbody, 1866 headers={'Content-Type': 'application/json'}, 1867 method='POST') 1868 1869 if response.status == httplib.UNAUTHORIZED: 1870 # Invalid credentials 1871 raise InvalidCredsError() 1872 elif response.status in [httplib.OK, httplib.CREATED]: 1873 try: 1874 body = json.loads(response.body) 1875 return body['access']['token']['id'] 1876 except Exception as e: 1877 raise MalformedResponseError('Failed to parse JSON', e) 1878 else: 1879 raise MalformedResponseError('Malformed response', 1880 driver=self.driver, 1881 body=response.body) 1882 1883 def _get_tenant_name(self, token): 1884 """ 1885 Get the first available tenant name (usually there are only one) 1886 """ 1887 headers = {'Accept': 'application/json', 1888 'Content-Type': 'application/json', 1889 AUTH_TOKEN_HEADER: token} 1890 response = self.request('/v2.0/tenants', headers=headers, method='GET') 1891 1892 if response.status == httplib.UNAUTHORIZED: 1893 # Invalid credentials 1894 raise InvalidCredsError() 1895 elif response.status in [httplib.OK, httplib.CREATED]: 1896 try: 1897 body = json.loads(response.body) 1898 return body["tenants"][0]["name"] 1899 except Exception as e: 1900 raise MalformedResponseError('Failed to parse JSON', e) 1901 else: 1902 raise MalformedResponseError('Malformed response', 1903 driver=self.driver, 1904 body=response.body) 1905 1906 def _authenticate_2_0_with_body(self, reqbody): 1907 resp = self.request('/v2.0/tokens', data=reqbody, 1908 headers={'Content-Type': 'application/json'}, 1909 method='POST') 1910 1911 if resp.status == httplib.UNAUTHORIZED: 1912 raise InvalidCredsError() 1913 elif resp.status not in [httplib.OK, 1914 httplib.NON_AUTHORITATIVE_INFORMATION]: 1915 body = 'code: %s body: %s' % (resp.status, resp.body) 1916 raise MalformedResponseError('Malformed response', body=body, 1917 driver=self.driver) 1918 else: 1919 body = resp.object 1920 1921 try: 1922 access = body['access'] 1923 expires = access['token']['expires'] 1924 self._cache_auth_context( 1925 OpenStackAuthenticationContext( 1926 access['token']['id'], 1927 expiration=parse_date(expires), 1928 urls=access['serviceCatalog'], 1929 user=access.get('user', {}))) 1930 except KeyError as e: 1931 raise MalformedResponseError('Auth JSON response is \ 1932 missing required elements', e) 1933 return self 1934 1935 1936def get_class_for_auth_version(auth_version): 1937 """ 1938 Retrieve class for the provided auth version. 1939 """ 1940 if auth_version == '1.0': 1941 cls = OpenStackIdentity_1_0_Connection 1942 elif auth_version == '1.1': 1943 cls = OpenStackIdentity_1_1_Connection 1944 elif auth_version == '2.0' or auth_version == '2.0_apikey': 1945 cls = OpenStackIdentity_2_0_Connection 1946 elif auth_version == '2.0_password': 1947 cls = OpenStackIdentity_2_0_Connection 1948 elif auth_version == '2.0_voms': 1949 cls = OpenStackIdentity_2_0_Connection_VOMS 1950 elif auth_version == '3.x_password': 1951 cls = OpenStackIdentity_3_0_Connection 1952 elif auth_version == '3.x_appcred': 1953 cls = OpenStackIdentity_3_0_Connection_AppCred 1954 elif auth_version == '3.x_oidc_access_token': 1955 cls = OpenStackIdentity_3_0_Connection_OIDC_access_token 1956 else: 1957 raise LibcloudError('Unsupported Auth Version requested: %s' % 1958 (auth_version)) 1959 1960 return cls 1961