1#!/usr/local/bin/python3.8
2# -*- coding: utf-8 -*-
3
4# Copyright (c) 2018, KubeVirt Team <@kubevirt>
5# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
6
7from __future__ import absolute_import, division, print_function
8__metaclass__ = type
9
10DOCUMENTATION = r'''
11
12module: openshift_auth
13
14short_description: Authenticate to OpenShift clusters which require an explicit login step
15
16version_added: "0.2.0"
17
18author:
19  - KubeVirt Team (@kubevirt)
20  - Fabian von Feilitzsch (@fabianvf)
21
22description:
23  - This module handles authenticating to OpenShift clusters requiring I(explicit) authentication procedures,
24    meaning ones where a client logs in (obtains an authentication token), performs API operations using said
25    token and then logs out (revokes the token).
26  - On the other hand a popular configuration for username+password authentication is one utilizing HTTP Basic
27    Auth, which does not involve any additional login/logout steps (instead login credentials can be attached
28    to each and every API call performed) and as such is handled directly by the C(k8s) module (and other
29    resource–specific modules) by utilizing the C(host), C(username) and C(password) parameters. Please
30    consult your preferred module's documentation for more details.
31
32options:
33  state:
34    description:
35    - If set to I(present) connect to the API server using the URL specified in C(host) and attempt to log in.
36    - If set to I(absent) attempt to log out by revoking the authentication token specified in C(api_key).
37    default: present
38    choices:
39    - present
40    - absent
41    type: str
42  host:
43    description:
44    - Provide a URL for accessing the API server.
45    required: true
46    type: str
47  username:
48    description:
49    - Provide a username for authenticating with the API server.
50    type: str
51  password:
52    description:
53    - Provide a password for authenticating with the API server.
54    type: str
55  ca_cert:
56    description:
57    - "Path to a CA certificate file used to verify connection to the API server. The full certificate chain
58      must be provided to avoid certificate validation errors."
59    aliases: [ ssl_ca_cert ]
60    type: path
61  validate_certs:
62    description:
63    - "Whether or not to verify the API server's SSL certificates."
64    type: bool
65    default: true
66    aliases: [ verify_ssl ]
67  api_key:
68    description:
69    - When C(state) is set to I(absent), this specifies the token to revoke.
70    type: str
71
72requirements:
73  - python >= 2.7
74  - urllib3
75  - requests
76  - requests-oauthlib
77'''
78
79EXAMPLES = r'''
80- hosts: localhost
81  module_defaults:
82    group/k8s:
83      host: https://k8s.example.com/
84      ca_cert: ca.pem
85  tasks:
86  - block:
87    # It's good practice to store login credentials in a secure vault and not
88    # directly in playbooks.
89    - include_vars: openshift_passwords.yml
90
91    - name: Log in (obtain access token)
92      community.okd.openshift_auth:
93        username: admin
94        password: "{{ openshift_admin_password }}"
95      register: openshift_auth_results
96
97    # Previous task provides the token/api_key, while all other parameters
98    # are taken from module_defaults
99    - name: Get a list of all pods from any namespace
100      kubernetes.core.k8s_info:
101        api_key: "{{ openshift_auth_results.openshift_auth.api_key }}"
102        kind: Pod
103      register: pod_list
104
105    always:
106    - name: If login succeeded, try to log out (revoke access token)
107      when: openshift_auth_results.openshift_auth.api_key is defined
108      community.okd.openshift_auth:
109        state: absent
110        api_key: "{{ openshift_auth_results.openshift_auth.api_key }}"
111'''
112
113# Returned value names need to match k8s modules parameter names, to make it
114# easy to pass returned values of openshift_auth to other k8s modules.
115# Discussion: https://github.com/ansible/ansible/pull/50807#discussion_r248827899
116RETURN = r'''
117openshift_auth:
118  description: OpenShift authentication facts.
119  returned: success
120  type: complex
121  contains:
122    api_key:
123      description: Authentication token.
124      returned: success
125      type: str
126    host:
127      description: URL for accessing the API server.
128      returned: success
129      type: str
130    ca_cert:
131      description: Path to a CA certificate file used to verify connection to the API server.
132      returned: success
133      type: str
134    validate_certs:
135      description: "Whether or not to verify the API server's SSL certificates."
136      returned: success
137      type: bool
138    username:
139      description: Username for authenticating with the API server.
140      returned: success
141      type: str
142k8s_auth:
143  description: Same as returned openshift_auth. Kept only for backwards compatibility
144  returned: success
145  type: complex
146  contains:
147    api_key:
148      description: Authentication token.
149      returned: success
150      type: str
151    host:
152      description: URL for accessing the API server.
153      returned: success
154      type: str
155    ca_cert:
156      description: Path to a CA certificate file used to verify connection to the API server.
157      returned: success
158      type: str
159    validate_certs:
160      description: "Whether or not to verify the API server's SSL certificates."
161      returned: success
162      type: bool
163    username:
164      description: Username for authenticating with the API server.
165      returned: success
166      type: str
167'''
168
169
170import traceback
171
172from ansible.module_utils.basic import AnsibleModule
173from ansible.module_utils.six.moves.urllib_parse import urlparse, parse_qs, urlencode
174
175# 3rd party imports
176try:
177    import requests
178    HAS_REQUESTS = True
179except ImportError:
180    HAS_REQUESTS = False
181
182try:
183    from requests_oauthlib import OAuth2Session
184    HAS_REQUESTS_OAUTH = True
185except ImportError:
186    HAS_REQUESTS_OAUTH = False
187
188try:
189    from urllib3.util import make_headers
190    HAS_URLLIB3 = True
191except ImportError:
192    HAS_URLLIB3 = False
193
194
195K8S_AUTH_ARG_SPEC = {
196    'state': {
197        'default': 'present',
198        'choices': ['present', 'absent'],
199    },
200    'host': {'required': True},
201    'username': {},
202    'password': {'no_log': True},
203    'ca_cert': {'type': 'path', 'aliases': ['ssl_ca_cert']},
204    'validate_certs': {
205        'type': 'bool',
206        'default': True,
207        'aliases': ['verify_ssl']
208    },
209    'api_key': {'no_log': True},
210}
211
212
213class OpenShiftAuthModule(AnsibleModule):
214    def __init__(self):
215        AnsibleModule.__init__(
216            self,
217            argument_spec=K8S_AUTH_ARG_SPEC,
218            required_if=[
219                ('state', 'present', ['username', 'password']),
220                ('state', 'absent', ['api_key']),
221            ]
222        )
223
224        if not HAS_REQUESTS:
225            self.fail("This module requires the python 'requests' package. Try `pip install requests`.")
226
227        if not HAS_REQUESTS_OAUTH:
228            self.fail("This module requires the python 'requests-oauthlib' package. Try `pip install requests-oauthlib`.")
229
230        if not HAS_URLLIB3:
231            self.fail("This module requires the python 'urllib3' package. Try `pip install urllib3`.")
232
233    def execute_module(self):
234        state = self.params.get('state')
235        verify_ssl = self.params.get('validate_certs')
236        ssl_ca_cert = self.params.get('ca_cert')
237
238        self.auth_username = self.params.get('username')
239        self.auth_password = self.params.get('password')
240        self.auth_api_key = self.params.get('api_key')
241        self.con_host = self.params.get('host')
242
243        # python-requests takes either a bool or a path to a ca file as the 'verify' param
244        if verify_ssl and ssl_ca_cert:
245            self.con_verify_ca = ssl_ca_cert  # path
246        else:
247            self.con_verify_ca = verify_ssl   # bool
248
249        # Get needed info to access authorization APIs
250        self.openshift_discover()
251
252        if state == 'present':
253            new_api_key = self.openshift_login()
254            result = dict(
255                host=self.con_host,
256                validate_certs=verify_ssl,
257                ca_cert=ssl_ca_cert,
258                api_key=new_api_key,
259                username=self.auth_username,
260            )
261        else:
262            self.openshift_logout()
263            result = dict()
264
265        # return k8s_auth as well for backwards compatibility
266        self.exit_json(changed=False, openshift_auth=result, k8s_auth=result)
267
268    def openshift_discover(self):
269        url = '{0}/.well-known/oauth-authorization-server'.format(self.con_host)
270        ret = requests.get(url, verify=self.con_verify_ca)
271
272        if ret.status_code != 200:
273            self.fail_request("Couldn't find OpenShift's OAuth API", method='GET', url=url,
274                              reason=ret.reason, status_code=ret.status_code)
275
276        try:
277            oauth_info = ret.json()
278
279            self.openshift_auth_endpoint = oauth_info['authorization_endpoint']
280            self.openshift_token_endpoint = oauth_info['token_endpoint']
281        except Exception:
282            self.fail_json(msg="Something went wrong discovering OpenShift OAuth details.",
283                           exception=traceback.format_exc())
284
285    def openshift_login(self):
286        os_oauth = OAuth2Session(client_id='openshift-challenging-client')
287        authorization_url, state = os_oauth.authorization_url(self.openshift_auth_endpoint,
288                                                              state="1", code_challenge_method='S256')
289        auth_headers = make_headers(basic_auth='{0}:{1}'.format(self.auth_username, self.auth_password))
290
291        # Request authorization code using basic auth credentials
292        ret = os_oauth.get(
293            authorization_url,
294            headers={'X-Csrf-Token': state, 'authorization': auth_headers.get('authorization')},
295            verify=self.con_verify_ca,
296            allow_redirects=False
297        )
298
299        if ret.status_code != 302:
300            self.fail_request("Authorization failed.", method='GET', url=authorization_url,
301                              reason=ret.reason, status_code=ret.status_code)
302
303        # In here we have `code` and `state`, I think `code` is the important one
304        qwargs = {}
305        for k, v in parse_qs(urlparse(ret.headers['Location']).query).items():
306            qwargs[k] = v[0]
307        qwargs['grant_type'] = 'authorization_code'
308
309        # Using authorization code given to us in the Location header of the previous request, request a token
310        ret = os_oauth.post(
311            self.openshift_token_endpoint,
312            headers={
313                'Accept': 'application/json',
314                'Content-Type': 'application/x-www-form-urlencoded',
315                # This is just base64 encoded 'openshift-challenging-client:'
316                'Authorization': 'Basic b3BlbnNoaWZ0LWNoYWxsZW5naW5nLWNsaWVudDo='
317            },
318            data=urlencode(qwargs),
319            verify=self.con_verify_ca
320        )
321
322        if ret.status_code != 200:
323            self.fail_request("Failed to obtain an authorization token.", method='POST',
324                              url=self.openshift_token_endpoint,
325                              reason=ret.reason, status_code=ret.status_code)
326
327        return ret.json()['access_token']
328
329    def openshift_logout(self):
330        url = '{0}/apis/oauth.openshift.io/v1/oauthaccesstokens/{1}'.format(self.con_host, self.auth_api_key)
331        headers = {
332            'Accept': 'application/json',
333            'Content-Type': 'application/json',
334            'Authorization': 'Bearer {0}'.format(self.auth_api_key)
335        }
336        json = {
337            "apiVersion": "oauth.openshift.io/v1",
338            "kind": "DeleteOptions"
339        }
340
341        requests.delete(url, headers=headers, json=json, verify=self.con_verify_ca)
342        # Ignore errors, the token will time out eventually anyway
343
344    def fail(self, msg=None):
345        self.fail_json(msg=msg)
346
347    def fail_request(self, msg, **kwargs):
348        req_info = {}
349        for k, v in kwargs.items():
350            req_info['req_' + k] = v
351        self.fail_json(msg=msg, **req_info)
352
353
354def main():
355    module = OpenShiftAuthModule()
356    try:
357        module.execute_module()
358    except Exception as e:
359        module.fail_json(msg=str(e), exception=traceback.format_exc())
360
361
362if __name__ == '__main__':
363    main()
364