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