1# Copyright (c) 2018 Cisco and/or its affiliates.
2#
3# This file is part of Ansible
4#
5# Ansible is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# Ansible is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
17#
18
19from __future__ import (absolute_import, division, print_function)
20
21__metaclass__ = type
22
23DOCUMENTATION = """
24---
25author: Ansible Networking Team
26httpapi : ftd
27short_description: HttpApi Plugin for Cisco ASA Firepower device
28description:
29  - This HttpApi plugin provides methods to connect to Cisco ASA firepower
30    devices over a HTTP(S)-based api.
31version_added: "2.7"
32options:
33  token_path:
34    type: str
35    description:
36      - Specifies the api token path of the FTD device
37    vars:
38      - name: ansible_httpapi_ftd_token_path
39  spec_path:
40    type: str
41    description:
42      - Specifies the api spec path of the FTD device
43    default: '/apispec/ngfw.json'
44    vars:
45      - name: ansible_httpapi_ftd_spec_path
46"""
47
48import json
49import os
50import re
51
52from ansible import __version__ as ansible_version
53
54from ansible.module_utils.basic import to_text
55from ansible.errors import AnsibleConnectionFailure
56from ansible.module_utils.network.ftd.fdm_swagger_client import FdmSwaggerParser, SpecProp, FdmSwaggerValidator
57from ansible.module_utils.network.ftd.common import HTTPMethod, ResponseParams
58from ansible.module_utils.six.moves.urllib.error import HTTPError
59from ansible.module_utils.six.moves.urllib.parse import urlencode
60from ansible.plugins.httpapi import HttpApiBase
61from urllib3 import encode_multipart_formdata
62from urllib3.fields import RequestField
63from ansible.module_utils.connection import ConnectionError
64
65BASE_HEADERS = {
66    'Content-Type': 'application/json',
67    'Accept': 'application/json',
68    'User-Agent': 'FTD Ansible/%s' % ansible_version
69}
70
71TOKEN_EXPIRATION_STATUS_CODE = 408
72UNAUTHORIZED_STATUS_CODE = 401
73API_TOKEN_PATH_OPTION_NAME = 'token_path'
74TOKEN_PATH_TEMPLATE = '/api/fdm/{0}/fdm/token'
75GET_API_VERSIONS_PATH = '/api/versions'
76DEFAULT_API_VERSIONS = ['v2', 'v1']
77
78INVALID_API_TOKEN_PATH_MSG = ('The API token path is incorrect. Please, check correctness of '
79                              'the `ansible_httpapi_ftd_token_path` variable in the inventory file.')
80MISSING_API_TOKEN_PATH_MSG = ('Ansible could not determine the API token path automatically. Please, '
81                              'specify the `ansible_httpapi_ftd_token_path` variable in the inventory file.')
82
83
84class HttpApi(HttpApiBase):
85    def __init__(self, connection):
86        super(HttpApi, self).__init__(connection)
87        self.connection = connection
88        self.access_token = None
89        self.refresh_token = None
90        self._api_spec = None
91        self._api_validator = None
92        self._ignore_http_errors = False
93
94    def login(self, username, password):
95        def request_token_payload(username, password):
96            return {
97                'grant_type': 'password',
98                'username': username,
99                'password': password
100            }
101
102        def refresh_token_payload(refresh_token):
103            return {
104                'grant_type': 'refresh_token',
105                'refresh_token': refresh_token
106            }
107
108        if self.refresh_token:
109            payload = refresh_token_payload(self.refresh_token)
110        elif username and password:
111            payload = request_token_payload(username, password)
112        else:
113            raise AnsibleConnectionFailure('Username and password are required for login in absence of refresh token')
114
115        response = self._lookup_login_url(payload)
116
117        try:
118            self.refresh_token = response['refresh_token']
119            self.access_token = response['access_token']
120            self.connection._auth = {'Authorization': 'Bearer %s' % self.access_token}
121        except KeyError:
122            raise ConnectionError(
123                'Server returned response without token info during connection authentication: %s' % response)
124
125    def _lookup_login_url(self, payload):
126        """ Try to find correct login URL and get api token using this URL.
127
128        :param payload: Token request payload
129        :type payload: dict
130        :return: token generation response
131        """
132        preconfigured_token_path = self._get_api_token_path()
133        if preconfigured_token_path:
134            token_paths = [preconfigured_token_path]
135        else:
136            token_paths = self._get_known_token_paths()
137
138        for url in token_paths:
139            try:
140                response = self._send_login_request(payload, url)
141
142            except ConnectionError as e:
143                self.connection.queue_message('vvvv', 'REST:request to %s failed because of connection error: %s ' % (
144                    url, e))
145                # In the case of ConnectionError caused by HTTPError we should check response code.
146                # Response code 400 returned in case of invalid credentials so we should stop attempts to log in and
147                # inform the user.
148                if hasattr(e, 'http_code') and e.http_code == 400:
149                    raise
150            else:
151                if not preconfigured_token_path:
152                    self._set_api_token_path(url)
153                return response
154
155        raise ConnectionError(INVALID_API_TOKEN_PATH_MSG if preconfigured_token_path else MISSING_API_TOKEN_PATH_MSG)
156
157    def _send_login_request(self, payload, url):
158        self._display(HTTPMethod.POST, 'login', url)
159        response, response_data = self._send_auth_request(
160            url, json.dumps(payload), method=HTTPMethod.POST, headers=BASE_HEADERS
161        )
162        self._display(HTTPMethod.POST, 'login:status_code', response.getcode())
163
164        response = self._response_to_json(self._get_response_value(response_data))
165        return response
166
167    def logout(self):
168        auth_payload = {
169            'grant_type': 'revoke_token',
170            'access_token': self.access_token,
171            'token_to_revoke': self.refresh_token
172        }
173
174        url = self._get_api_token_path()
175
176        self._display(HTTPMethod.POST, 'logout', url)
177        response, dummy = self._send_auth_request(url, json.dumps(auth_payload), method=HTTPMethod.POST,
178                                                  headers=BASE_HEADERS)
179        self._display(HTTPMethod.POST, 'logout:status_code', response.getcode())
180
181        self.refresh_token = None
182        self.access_token = None
183
184    def _send_auth_request(self, path, data, **kwargs):
185        error_msg_prefix = 'Server returned an error during authentication request'
186        return self._send_service_request(path, error_msg_prefix, data=data, **kwargs)
187
188    def _send_service_request(self, path, error_msg_prefix, data=None, **kwargs):
189        try:
190            self._ignore_http_errors = True
191            return self.connection.send(path, data, **kwargs)
192        except HTTPError as e:
193            # HttpApi connection does not read the error response from HTTPError, so we do it here and wrap it up in
194            # ConnectionError, so the actual error message is displayed to the user.
195            error_msg = self._response_to_json(to_text(e.read()))
196            raise ConnectionError('%s: %s' % (error_msg_prefix, error_msg), http_code=e.code)
197        finally:
198            self._ignore_http_errors = False
199
200    def update_auth(self, response, response_data):
201        # With tokens, authentication should not be checked and updated on each request
202        return None
203
204    def send_request(self, url_path, http_method, body_params=None, path_params=None, query_params=None):
205        url = construct_url_path(url_path, path_params, query_params)
206        data = json.dumps(body_params) if body_params else None
207        try:
208            self._display(http_method, 'url', url)
209            if data:
210                self._display(http_method, 'data', data)
211
212            response, response_data = self.connection.send(url, data, method=http_method, headers=BASE_HEADERS)
213
214            value = self._get_response_value(response_data)
215            self._display(http_method, 'response', value)
216
217            return {
218                ResponseParams.SUCCESS: True,
219                ResponseParams.STATUS_CODE: response.getcode(),
220                ResponseParams.RESPONSE: self._response_to_json(value)
221            }
222        # Being invoked via JSON-RPC, this method does not serialize and pass HTTPError correctly to the method caller.
223        # Thus, in order to handle non-200 responses, we need to wrap them into a simple structure and pass explicitly.
224        except HTTPError as e:
225            error_msg = to_text(e.read())
226            self._display(http_method, 'error', error_msg)
227            return {
228                ResponseParams.SUCCESS: False,
229                ResponseParams.STATUS_CODE: e.code,
230                ResponseParams.RESPONSE: self._response_to_json(error_msg)
231            }
232
233    def upload_file(self, from_path, to_url):
234        url = construct_url_path(to_url)
235        self._display(HTTPMethod.POST, 'upload', url)
236        with open(from_path, 'rb') as src_file:
237            rf = RequestField('fileToUpload', src_file.read(), os.path.basename(src_file.name))
238            rf.make_multipart()
239            body, content_type = encode_multipart_formdata([rf])
240
241            headers = dict(BASE_HEADERS)
242            headers['Content-Type'] = content_type
243            headers['Content-Length'] = len(body)
244
245            dummy, response_data = self.connection.send(url, data=body, method=HTTPMethod.POST, headers=headers)
246            value = self._get_response_value(response_data)
247            self._display(HTTPMethod.POST, 'upload:response', value)
248            return self._response_to_json(value)
249
250    def download_file(self, from_url, to_path, path_params=None):
251        url = construct_url_path(from_url, path_params=path_params)
252        self._display(HTTPMethod.GET, 'download', url)
253        response, response_data = self.connection.send(url, data=None, method=HTTPMethod.GET, headers=BASE_HEADERS)
254
255        if os.path.isdir(to_path):
256            filename = extract_filename_from_headers(response.info())
257            to_path = os.path.join(to_path, filename)
258
259        with open(to_path, "wb") as output_file:
260            output_file.write(response_data.getvalue())
261        self._display(HTTPMethod.GET, 'downloaded', to_path)
262
263    def handle_httperror(self, exc):
264        is_auth_related_code = exc.code == TOKEN_EXPIRATION_STATUS_CODE or exc.code == UNAUTHORIZED_STATUS_CODE
265        if not self._ignore_http_errors and is_auth_related_code:
266            self.connection._auth = None
267            self.login(self.connection.get_option('remote_user'), self.connection.get_option('password'))
268            return True
269        # False means that the exception will be passed further to the caller
270        return False
271
272    def _display(self, http_method, title, msg=''):
273        self.connection.queue_message('vvvv', 'REST:%s:%s:%s\n%s' % (http_method, self.connection._url, title, msg))
274
275    @staticmethod
276    def _get_response_value(response_data):
277        return to_text(response_data.getvalue())
278
279    def _get_api_spec_path(self):
280        return self.get_option('spec_path')
281
282    def _get_known_token_paths(self):
283        """Generate list of token generation urls based on list of versions supported by device(if exposed via API) or
284        default list of API versions.
285
286        :returns: list of token generation urls
287        :rtype: generator
288        """
289        try:
290            api_versions = self._get_supported_api_versions()
291        except ConnectionError:
292            # API versions API is not supported we need to check all known version
293            api_versions = DEFAULT_API_VERSIONS
294
295        return [TOKEN_PATH_TEMPLATE.format(version) for version in api_versions]
296
297    def _get_supported_api_versions(self):
298        """
299        Fetch list of API versions supported by device.
300
301        :return: list of API versions suitable for device
302        :rtype: list
303        """
304        # Try to fetch supported API version
305        http_method = HTTPMethod.GET
306        response, response_data = self._send_service_request(
307            path=GET_API_VERSIONS_PATH,
308            error_msg_prefix="Can't fetch list of supported api versions",
309            method=http_method,
310            headers=BASE_HEADERS
311        )
312
313        value = self._get_response_value(response_data)
314        self._display(http_method, 'response', value)
315        api_versions_info = self._response_to_json(value)
316        return api_versions_info["supportedVersions"]
317
318    def _get_api_token_path(self):
319        return self.get_option(API_TOKEN_PATH_OPTION_NAME)
320
321    def _set_api_token_path(self, url):
322        return self.set_option(API_TOKEN_PATH_OPTION_NAME, url)
323
324    @staticmethod
325    def _response_to_json(response_text):
326        try:
327            return json.loads(response_text) if response_text else {}
328        # JSONDecodeError only available on Python 3.5+
329        except getattr(json.decoder, 'JSONDecodeError', ValueError):
330            raise ConnectionError('Invalid JSON response: %s' % response_text)
331
332    def get_operation_spec(self, operation_name):
333        return self.api_spec[SpecProp.OPERATIONS].get(operation_name, None)
334
335    def get_operation_specs_by_model_name(self, model_name):
336        if model_name:
337            return self.api_spec[SpecProp.MODEL_OPERATIONS].get(model_name, None)
338        else:
339            return None
340
341    def get_model_spec(self, model_name):
342        return self.api_spec[SpecProp.MODELS].get(model_name, None)
343
344    def validate_data(self, operation_name, data):
345        return self.api_validator.validate_data(operation_name, data)
346
347    def validate_query_params(self, operation_name, params):
348        return self.api_validator.validate_query_params(operation_name, params)
349
350    def validate_path_params(self, operation_name, params):
351        return self.api_validator.validate_path_params(operation_name, params)
352
353    @property
354    def api_spec(self):
355        if self._api_spec is None:
356            spec_path_url = self._get_api_spec_path()
357            response = self.send_request(url_path=spec_path_url, http_method=HTTPMethod.GET)
358            if response[ResponseParams.SUCCESS]:
359                self._api_spec = FdmSwaggerParser().parse_spec(response[ResponseParams.RESPONSE])
360            else:
361                raise ConnectionError('Failed to download API specification. Status code: %s. Response: %s' % (
362                    response[ResponseParams.STATUS_CODE], response[ResponseParams.RESPONSE]))
363        return self._api_spec
364
365    @property
366    def api_validator(self):
367        if self._api_validator is None:
368            self._api_validator = FdmSwaggerValidator(self.api_spec)
369        return self._api_validator
370
371
372def construct_url_path(path, path_params=None, query_params=None):
373    url = path
374    if path_params:
375        url = url.format(**path_params)
376    if query_params:
377        url += "?" + urlencode(query_params)
378    return url
379
380
381def extract_filename_from_headers(response_info):
382    content_header_regex = r'attachment; ?filename="?([^"]+)'
383    match = re.match(content_header_regex, response_info.get('Content-Disposition'))
384    if match:
385        return match.group(1)
386    else:
387        raise ValueError("No appropriate Content-Disposition header is specified.")
388