ACI: Add signature-based authentication (#34451)

ACI: Add signature-based authentication
pull/34564/head
Dag Wieers 7 years ago committed by GitHub
parent 71ff77e51f
commit 49739dda47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -30,12 +30,21 @@
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE # 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. # USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import base64
import json import json
import os
from copy import deepcopy from copy import deepcopy
from ansible.module_utils.urls import fetch_url from ansible.module_utils.urls import fetch_url
from ansible.module_utils._text import to_bytes 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 # Optional, only used for XML payload
try: try:
import lxml.etree import lxml.etree
@ -54,7 +63,9 @@ except ImportError:
aci_argument_spec = dict( aci_argument_spec = dict(
hostname=dict(type='str', required=True, aliases=['host']), hostname=dict(type='str', required=True, aliases=['host']),
username=dict(type='str', default='admin', aliases=['user']), 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 protocol=dict(type='str', removed_in_version='2.6'), # Deprecated in v2.6
timeout=dict(type='int', default=30), timeout=dict(type='int', default=30),
use_proxy=dict(type='bool', default=True), use_proxy=dict(type='bool', default=True),
@ -106,6 +117,7 @@ def aci_response_error(result):
''' Set error information when found ''' ''' Set error information when found '''
result['error_code'] = 0 result['error_code'] = 0
result['error_text'] = 'Success' result['error_text'] = 'Success'
# Handle possible APIC error information # Handle possible APIC error information
if result['totalCount'] != '0': if result['totalCount'] != '0':
try: try:
@ -157,9 +169,20 @@ class ACIModule(object):
self.module = module self.module = module
self.params = module.params self.params = module.params
self.result = dict(changed=False) self.result = dict(changed=False)
self.headers = None self.headers = dict()
# 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() 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): def define_protocol(self):
''' Set protocol based on use_ssl parameter ''' ''' Set protocol based on use_ssl parameter '''
@ -189,9 +212,6 @@ class ACIModule(object):
def login(self): def login(self):
''' Log in to APIC ''' ''' Log in to APIC '''
# Ensure protocol is set (only do this once)
self.define_protocol()
# Perform login request # Perform login request
url = '%(protocol)s://%(hostname)s/api/aaaLogin.json' % self.params url = '%(protocol)s://%(hostname)s/api/aaaLogin.json' % self.params
payload = {'aaaUser': {'attributes': {'name': self.params['username'], 'pwd': self.params['password']}}} 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) self.module.fail_json(msg='Authentication failed for %(url)s. %(msg)s' % auth)
# Retain cookie for later use # 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): def request(self, path, payload=None):
''' Perform a REST request ''' ''' Perform a REST request '''
# Ensure method is set (only do this once) # Ensure method is set (only do this once)
self.define_method() 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 # Perform request
self.result['url'] = '%(protocol)s://%(hostname)s/' % self.params + path.lstrip('/')
resp, info = fetch_url(self.module, self.result['url'], resp, info = fetch_url(self.module, self.result['url'],
data=payload, data=payload,
headers=self.headers, headers=self.headers,
@ -248,8 +305,17 @@ class ACIModule(object):
def query(self, path): def query(self, path):
''' Perform a query with no payload ''' ''' 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, data=None,
headers=self.headers, headers=self.headers,
method='GET', method='GET',
@ -314,6 +380,7 @@ class ACIModule(object):
else: else:
path, filter_string = self._construct_url_1(root_class, child_includes) 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['url'] = '{}://{}/{}'.format(self.module.params['protocol'], self.module.params['hostname'], path)
self.result['filter_string'] = filter_string self.result['filter_string'] = filter_string
@ -508,6 +575,10 @@ class ACIModule(object):
return return
elif not self.module.check_mode: 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'], resp, info = fetch_url(self.module, self.result['url'],
headers=self.headers, headers=self.headers,
method='DELETE', method='DELETE',
@ -639,6 +710,10 @@ class ACIModule(object):
""" """
uri = self.result['url'] + self.result['filter_string'] 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, resp, info = fetch_url(self.module, uri,
headers=self.headers, headers=self.headers,
method='GET', method='GET',
@ -732,6 +807,10 @@ class ACIModule(object):
if not self.result['config']: if not self.result['config']:
return return
elif not self.module.check_mode: 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'], resp, info = fetch_url(self.module, self.result['url'],
data=json.dumps(self.result['config']), data=json.dumps(self.result['config']),
headers=self.headers, headers=self.headers,

@ -60,11 +60,11 @@ notes:
''' '''
EXAMPLES = r''' EXAMPLES = r'''
- name: Add a tenant - name: Add a tenant using certifcate authentication
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/admin.key
method: post method: post
path: /api/mo/uni.xml path: /api/mo/uni.xml
src: /home/cisco/ansible/aci/configs/aci_config.xml src: /home/cisco/ansible/aci/configs/aci_config.xml
@ -74,7 +74,7 @@ EXAMPLES = r'''
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/admin.key
validate_certs: no validate_certs: no
path: /api/mo/uni/tn-[Sales].json path: /api/mo/uni/tn-[Sales].json
method: post method: post
@ -89,7 +89,7 @@ EXAMPLES = r'''
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/admin.key
validate_certs: no validate_certs: no
path: /api/mo/uni/tn-[Sales].json path: /api/mo/uni/tn-[Sales].json
method: post method: post
@ -108,7 +108,7 @@ EXAMPLES = r'''
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/{{ aci_username}}.key
validate_certs: no validate_certs: no
path: /api/mo/uni/tn-[Sales].xml path: /api/mo/uni/tn-[Sales].xml
method: post method: post
@ -116,7 +116,7 @@ EXAMPLES = r'''
<fvTenant name="Sales" descr="Sales departement"/> <fvTenant name="Sales" descr="Sales departement"/>
delegate_to: localhost delegate_to: localhost
- name: Get tenants - name: Get tenants using password authentication
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
@ -129,7 +129,7 @@ EXAMPLES = r'''
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/admin.key
method: post method: post
path: /api/mo/uni.xml path: /api/mo/uni.xml
src: /home/cisco/ansible/aci/configs/contract_config.xml src: /home/cisco/ansible/aci/configs/contract_config.xml
@ -139,7 +139,7 @@ EXAMPLES = r'''
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/admin.key
validate_certs: no validate_certs: no
method: post method: post
path: /api/mo/uni/controller/nodeidentpol.xml path: /api/mo/uni/controller/nodeidentpol.xml
@ -155,7 +155,7 @@ EXAMPLES = r'''
aci_rest: aci_rest:
hostname: '{{ inventory_hostname }}' hostname: '{{ inventory_hostname }}'
username: '{{ aci_username }}' username: '{{ aci_username }}'
password: '{{ aci_password }}' private_key: pki/admin.key
validate_certs: no validate_certs: no
path: /api/node/class/topSystem.json?query-target-filter=eq(topSystem.role,"controller") path: /api/node/class/topSystem.json?query-target-filter=eq(topSystem.role,"controller")
register: apics register: apics
@ -313,9 +313,6 @@ def main():
content = module.params['content'] content = module.params['content']
src = module.params['src'] src = module.params['src']
method = module.params['method']
timeout = module.params['timeout']
# Report missing file # Report missing file
file_exists = False file_exists = False
if src: if src:
@ -370,22 +367,35 @@ def main():
module.fail_json(msg='Failed to parse provided XML content: %s' % to_text(e), payload=payload) 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) # Perform actual request using auth cookie (Same as aci_request, but also supports XML)
url = '%(protocol)s://%(hostname)s/' % aci.params + path.lstrip('/') aci.result['url'] = '%(protocol)s://%(hostname)s/' % aci.params + path.lstrip('/')
if method != 'get': if aci.params['method'] != 'get':
url = update_qsl(url, {'rsp-subtree': 'modified'}) path += '?rsp-subtree=modified'
aci.result['url'] = url 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['response'] = info['msg']
aci.result['status'] = info['status'] aci.result['status'] = info['status']
# Report failure # Report failure
if info['status'] != 200: if info['status'] != 200:
try: try:
# APIC error
aci_response(aci.result, info['body'], rest_type) 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: 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) aci_response(aci.result, resp.read(), rest_type)

@ -38,6 +38,17 @@ options:
description: description:
- The password to use for authentication. - The password to use for authentication.
required: yes 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: timeout:
description: description:
- The socket level timeout in seconds. - The socket level timeout in seconds.

Loading…
Cancel
Save