From 49739dda4789c80d1b1f54437bf7648afec3aba5 Mon Sep 17 00:00:00 2001 From: Dag Wieers Date: Mon, 8 Jan 2018 00:44:30 +0100 Subject: [PATCH] ACI: Add signature-based authentication (#34451) ACI: Add signature-based authentication --- lib/ansible/module_utils/network/aci/aci.py | 99 +++++++++++++++++-- lib/ansible/modules/network/aci/aci_rest.py | 50 ++++++---- .../utils/module_docs_fragments/aci.py | 11 +++ 3 files changed, 130 insertions(+), 30 deletions(-) diff --git a/lib/ansible/module_utils/network/aci/aci.py b/lib/ansible/module_utils/network/aci/aci.py index 97a8f4bd64b..7889fa7ec87 100644 --- a/lib/ansible/module_utils/network/aci/aci.py +++ b/lib/ansible/module_utils/network/aci/aci.py @@ -30,12 +30,21 @@ # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import base64 import json +import os from copy import deepcopy from ansible.module_utils.urls import fetch_url from ansible.module_utils._text import to_bytes +# Optional, only used for APIC signature-based authentication +try: + from OpenSSL.crypto import FILETYPE_PEM, load_privatekey, sign + HAS_OPENSSL = True +except ImportError: + HAS_OPENSSL = False + # Optional, only used for XML payload try: import lxml.etree @@ -54,7 +63,9 @@ except ImportError: aci_argument_spec = dict( hostname=dict(type='str', required=True, aliases=['host']), username=dict(type='str', default='admin', aliases=['user']), - password=dict(type='str', required=True, no_log=True), + password=dict(type='str', no_log=True), + private_key=dict(type='path', aliases=['cert_key']), # Beware, this is not the same as client_key ! + certificate_name=dict(type='str', aliases=['cert_name']), # Beware, this is not the same as client_cert ! protocol=dict(type='str', removed_in_version='2.6'), # Deprecated in v2.6 timeout=dict(type='int', default=30), use_proxy=dict(type='bool', default=True), @@ -106,6 +117,7 @@ def aci_response_error(result): ''' Set error information when found ''' result['error_code'] = 0 result['error_text'] = 'Success' + # Handle possible APIC error information if result['totalCount'] != '0': try: @@ -157,9 +169,20 @@ class ACIModule(object): self.module = module self.params = module.params self.result = dict(changed=False) - self.headers = None + self.headers = dict() - self.login() + # Ensure protocol is set + self.define_protocol() + + if self.params['private_key'] is None: + if self.params['password'] is None: + self.module.fail(msg="Parameter 'password' is required for HTTP authentication") + # Only log in when password-based authentication is used + self.login() + elif not HAS_OPENSSL: + self.module.fail_json(msg='Cannot use signature-based authentication because pyopenssl is not available') + elif self.params['password'] is not None: + self.module.warn('When doing ACI signatured-based authentication, a password is not required') def define_protocol(self): ''' Set protocol based on use_ssl parameter ''' @@ -189,9 +212,6 @@ class ACIModule(object): def login(self): ''' Log in to APIC ''' - # Ensure protocol is set (only do this once) - self.define_protocol() - # Perform login request url = '%(protocol)s://%(hostname)s/api/aaaLogin.json' % self.params payload = {'aaaUser': {'attributes': {'name': self.params['username'], 'pwd': self.params['password']}}} @@ -214,16 +234,53 @@ class ACIModule(object): self.module.fail_json(msg='Authentication failed for %(url)s. %(msg)s' % auth) # Retain cookie for later use - self.headers = dict(Cookie=resp.headers['Set-Cookie']) + self.headers['Cookie'] = resp.headers['Set-Cookie'] + + def cert_auth(self, path=None, payload='', method=None): + ''' Perform APIC signature-based authentication, not the expected SSL client certificate authentication. ''' + + if method is None: + method = self.params['method'].upper() + + # NOTE: ACI documentation incorrectly uses complete URL + if path is None: + path = self.result['path'] + path = '/' + path.lstrip('/') + + if payload is None: + payload = '' + + # Use the private key basename (without extension) as certificate_name + if self.params['certificate_name'] is None: + self.params['certificate_name'] = os.path.basename(os.path.splitext(self.params['private_key'])[0]) + + try: + sig_key = load_privatekey(FILETYPE_PEM, open(self.params['private_key'], 'r').read()) + except: + self.module.fail_json(msg='Cannot load private key %s' % self.params['private_key']) + + # NOTE: ACI documentation incorrectly adds a space between method and path + sig_request = method + path + payload + sig_signature = base64.b64encode(sign(sig_key, sig_request, 'sha256')) + sig_dn = 'uni/userext/user-%s/usercert-%s' % (self.params['username'], self.params['certificate_name']) + self.headers['Cookie'] = 'APIC-Certificate-Algorithm=v1.0; ' +\ + 'APIC-Certificate-DN=%s; ' % sig_dn +\ + 'APIC-Certificate-Fingerprint=fingerprint; ' +\ + 'APIC-Request-Signature=%s' % sig_signature def request(self, path, payload=None): ''' Perform a REST request ''' # Ensure method is set (only do this once) self.define_method() + self.result['path'] = path + self.result['url'] = '%(protocol)s://%(hostname)s/' % self.params + path.lstrip('/') + + # Sign and encode request as to APIC's wishes + if self.params['private_key'] is not None: + self.cert_auth(path=path, payload=payload) # Perform request - self.result['url'] = '%(protocol)s://%(hostname)s/' % self.params + path.lstrip('/') resp, info = fetch_url(self.module, self.result['url'], data=payload, headers=self.headers, @@ -248,8 +305,17 @@ class ACIModule(object): def query(self, path): ''' Perform a query with no payload ''' - url = '%(protocol)s://%(hostname)s/' % self.params + path.lstrip('/') - resp, query = fetch_url(self.module, url, + + # Ensure method is set + self.result['path'] = path + self.result['url'] = '%(protocol)s://%(hostname)s/' % self.params + path.lstrip('/') + + # Sign and encode request as to APIC's wishes + if self.params['private_key'] is not None: + self.cert_auth(path=path, method='GET') + + # Perform request + resp, query = fetch_url(self.module, self.result['url'], data=None, headers=self.headers, method='GET', @@ -314,6 +380,7 @@ class ACIModule(object): else: path, filter_string = self._construct_url_1(root_class, child_includes) + self.result['path'] = path self.result['url'] = '{}://{}/{}'.format(self.module.params['protocol'], self.module.params['hostname'], path) self.result['filter_string'] = filter_string @@ -508,6 +575,10 @@ class ACIModule(object): return elif not self.module.check_mode: + # Sign and encode request as to APIC's wishes + if self.params['private_key'] is not None: + self.cert_auth(method='DELETE') + resp, info = fetch_url(self.module, self.result['url'], headers=self.headers, method='DELETE', @@ -639,6 +710,10 @@ class ACIModule(object): """ uri = self.result['url'] + self.result['filter_string'] + # Sign and encode request as to APIC's wishes + if self.params['private_key'] is not None: + self.cert_auth(path=self.result['path'] + self.result['filter_string'], method='GET') + resp, info = fetch_url(self.module, uri, headers=self.headers, method='GET', @@ -732,6 +807,10 @@ class ACIModule(object): if not self.result['config']: return elif not self.module.check_mode: + # Sign and encode request as to APIC's wishes + if self.params['private_key'] is not None: + self.cert_auth(method='POST', payload=json.dumps(self.result['config'])) + resp, info = fetch_url(self.module, self.result['url'], data=json.dumps(self.result['config']), headers=self.headers, diff --git a/lib/ansible/modules/network/aci/aci_rest.py b/lib/ansible/modules/network/aci/aci_rest.py index 16ab182a9e6..938ece731c2 100644 --- a/lib/ansible/modules/network/aci/aci_rest.py +++ b/lib/ansible/modules/network/aci/aci_rest.py @@ -60,11 +60,11 @@ notes: ''' EXAMPLES = r''' -- name: Add a tenant +- name: Add a tenant using certifcate authentication aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/admin.key method: post path: /api/mo/uni.xml src: /home/cisco/ansible/aci/configs/aci_config.xml @@ -74,7 +74,7 @@ EXAMPLES = r''' aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/admin.key validate_certs: no path: /api/mo/uni/tn-[Sales].json method: post @@ -89,7 +89,7 @@ EXAMPLES = r''' aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/admin.key validate_certs: no path: /api/mo/uni/tn-[Sales].json method: post @@ -108,7 +108,7 @@ EXAMPLES = r''' aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/{{ aci_username}}.key validate_certs: no path: /api/mo/uni/tn-[Sales].xml method: post @@ -116,7 +116,7 @@ EXAMPLES = r''' delegate_to: localhost -- name: Get tenants +- name: Get tenants using password authentication aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' @@ -129,7 +129,7 @@ EXAMPLES = r''' aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/admin.key method: post path: /api/mo/uni.xml src: /home/cisco/ansible/aci/configs/contract_config.xml @@ -139,7 +139,7 @@ EXAMPLES = r''' aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/admin.key validate_certs: no method: post path: /api/mo/uni/controller/nodeidentpol.xml @@ -155,7 +155,7 @@ EXAMPLES = r''' aci_rest: hostname: '{{ inventory_hostname }}' username: '{{ aci_username }}' - password: '{{ aci_password }}' + private_key: pki/admin.key validate_certs: no path: /api/node/class/topSystem.json?query-target-filter=eq(topSystem.role,"controller") register: apics @@ -313,9 +313,6 @@ def main(): content = module.params['content'] src = module.params['src'] - method = module.params['method'] - timeout = module.params['timeout'] - # Report missing file file_exists = False if src: @@ -369,23 +366,36 @@ def main(): except Exception as e: module.fail_json(msg='Failed to parse provided XML content: %s' % to_text(e), payload=payload) - # Perform actual request using auth cookie (Same as aci_request,but also supports XML) - url = '%(protocol)s://%(hostname)s/' % aci.params + path.lstrip('/') - if method != 'get': - url = update_qsl(url, {'rsp-subtree': 'modified'}) - aci.result['url'] = url + # Perform actual request using auth cookie (Same as aci_request, but also supports XML) + aci.result['url'] = '%(protocol)s://%(hostname)s/' % aci.params + path.lstrip('/') + if aci.params['method'] != 'get': + path += '?rsp-subtree=modified' + aci.result['url'] = update_qsl(aci.result['url'], {'rsp-subtree': 'modified'}) + + # Sign and encode request as to APIC's wishes + if aci.params['private_key'] is not None: + aci.cert_auth(path=path, payload=payload) + + # Perform request + resp, info = fetch_url(module, aci.result['url'], + data=payload, + headers=aci.headers, + method=aci.params['method'].upper(), + timeout=aci.params['timeout'], + use_proxy=aci.params['use_proxy']) - resp, info = fetch_url(module, url, data=payload, method=method.upper(), timeout=timeout, headers=aci.headers) aci.result['response'] = info['msg'] aci.result['status'] = info['status'] # Report failure if info['status'] != 200: try: + # APIC error aci_response(aci.result, info['body'], rest_type) - module.fail_json(msg='Request failed: %(error_code)s %(error_text)s' % aci.result, payload=payload, **aci.result) + module.fail_json(msg='Request failed: %(error_code)s %(error_text)s' % aci.result, **aci.result) except KeyError: - module.fail_json(msg='Request failed for %(url)s. %(msg)s' % info, payload=payload, **aci.result) + # Connection error + module.fail_json(msg='Request failed for %(url)s. %(msg)s' % info, **aci.result) aci_response(aci.result, resp.read(), rest_type) diff --git a/lib/ansible/utils/module_docs_fragments/aci.py b/lib/ansible/utils/module_docs_fragments/aci.py index db951b67942..12c633ff96a 100644 --- a/lib/ansible/utils/module_docs_fragments/aci.py +++ b/lib/ansible/utils/module_docs_fragments/aci.py @@ -38,6 +38,17 @@ options: description: - The password to use for authentication. required: yes + private_key: + description: + - PEM formatted file that contains your private key to be used for client certificate authentication. + - The name of the key (without extension) is used as the certificate name in ACI, unless C(certificate_name) is specified. + aliases: [ cert_key ] + certificate_name: + description: + - The X.509 certificate name attached to the APIC AAA user. + - It defaults to the C(private_key) basename, without extension. + aliases: [ cert_name ] + default: C(private_key) basename timeout: description: - The socket level timeout in seconds.