1#!/usr/local/bin/python3.8 2# -*- coding: utf-8 -*- 3# Copyright: (c) 2017, Ansible Project 4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) 5 6from __future__ import absolute_import, division, print_function 7__metaclass__ = type 8 9DOCUMENTATION = r''' 10--- 11module: ipa_user 12author: Thomas Krahn (@Nosmoht) 13short_description: Manage FreeIPA users 14description: 15- Add, modify and delete user within IPA server. 16options: 17 displayname: 18 description: Display name. 19 type: str 20 update_password: 21 description: 22 - Set password for a user. 23 type: str 24 default: 'always' 25 choices: [ always, on_create ] 26 givenname: 27 description: First name. 28 type: str 29 krbpasswordexpiration: 30 description: 31 - Date at which the user password will expire. 32 - In the format YYYYMMddHHmmss. 33 - e.g. 20180121182022 will expire on 21 January 2018 at 18:20:22. 34 type: str 35 loginshell: 36 description: Login shell. 37 type: str 38 mail: 39 description: 40 - List of mail addresses assigned to the user. 41 - If an empty list is passed all assigned email addresses will be deleted. 42 - If None is passed email addresses will not be checked or changed. 43 type: list 44 elements: str 45 password: 46 description: 47 - Password for a user. 48 - Will not be set for an existing user unless I(update_password=always), which is the default. 49 type: str 50 sn: 51 description: Surname. 52 type: str 53 sshpubkey: 54 description: 55 - List of public SSH key. 56 - If an empty list is passed all assigned public keys will be deleted. 57 - If None is passed SSH public keys will not be checked or changed. 58 type: list 59 elements: str 60 state: 61 description: State to ensure. 62 default: "present" 63 choices: ["absent", "disabled", "enabled", "present"] 64 type: str 65 telephonenumber: 66 description: 67 - List of telephone numbers assigned to the user. 68 - If an empty list is passed all assigned telephone numbers will be deleted. 69 - If None is passed telephone numbers will not be checked or changed. 70 type: list 71 elements: str 72 title: 73 description: Title. 74 type: str 75 uid: 76 description: uid of the user. 77 required: true 78 aliases: ["name"] 79 type: str 80 uidnumber: 81 description: 82 - Account Settings UID/Posix User ID number. 83 type: str 84 gidnumber: 85 description: 86 - Posix Group ID. 87 type: str 88 homedirectory: 89 description: 90 - Default home directory of the user. 91 type: str 92 version_added: '0.2.0' 93 userauthtype: 94 description: 95 - The authentication type to use for the user. 96 choices: ["password", "radius", "otp", "pkinit", "hardened"] 97 type: list 98 elements: str 99 version_added: '1.2.0' 100extends_documentation_fragment: 101- community.general.ipa.documentation 102 103requirements: 104- base64 105- hashlib 106''' 107 108EXAMPLES = r''' 109- name: Ensure pinky is present and always reset password 110 community.general.ipa_user: 111 name: pinky 112 state: present 113 krbpasswordexpiration: 20200119235959 114 givenname: Pinky 115 sn: Acme 116 mail: 117 - pinky@acme.com 118 telephonenumber: 119 - '+555123456' 120 sshpubkey: 121 - ssh-rsa .... 122 - ssh-dsa .... 123 uidnumber: '1001' 124 gidnumber: '100' 125 homedirectory: /home/pinky 126 ipa_host: ipa.example.com 127 ipa_user: admin 128 ipa_pass: topsecret 129 130- name: Ensure brain is absent 131 community.general.ipa_user: 132 name: brain 133 state: absent 134 ipa_host: ipa.example.com 135 ipa_user: admin 136 ipa_pass: topsecret 137 138- name: Ensure pinky is present but don't reset password if already exists 139 community.general.ipa_user: 140 name: pinky 141 state: present 142 givenname: Pinky 143 sn: Acme 144 password: zounds 145 ipa_host: ipa.example.com 146 ipa_user: admin 147 ipa_pass: topsecret 148 update_password: on_create 149 150- name: Ensure pinky is present and using one time password and RADIUS authentication 151 community.general.ipa_user: 152 name: pinky 153 state: present 154 userauthtype: 155 - otp 156 - radius 157 ipa_host: ipa.example.com 158 ipa_user: admin 159 ipa_pass: topsecret 160''' 161 162RETURN = r''' 163user: 164 description: User as returned by IPA API 165 returned: always 166 type: dict 167''' 168 169import base64 170import hashlib 171import traceback 172 173from ansible.module_utils.basic import AnsibleModule 174from ansible_collections.community.general.plugins.module_utils.ipa import IPAClient, ipa_argument_spec 175from ansible.module_utils.common.text.converters import to_native 176 177 178class UserIPAClient(IPAClient): 179 def __init__(self, module, host, port, protocol): 180 super(UserIPAClient, self).__init__(module, host, port, protocol) 181 182 def user_find(self, name): 183 return self._post_json(method='user_find', name=None, item={'all': True, 'uid': name}) 184 185 def user_add(self, name, item): 186 return self._post_json(method='user_add', name=name, item=item) 187 188 def user_mod(self, name, item): 189 return self._post_json(method='user_mod', name=name, item=item) 190 191 def user_del(self, name): 192 return self._post_json(method='user_del', name=name) 193 194 def user_disable(self, name): 195 return self._post_json(method='user_disable', name=name) 196 197 def user_enable(self, name): 198 return self._post_json(method='user_enable', name=name) 199 200 201def get_user_dict(displayname=None, givenname=None, krbpasswordexpiration=None, loginshell=None, 202 mail=None, nsaccountlock=False, sn=None, sshpubkey=None, telephonenumber=None, 203 title=None, userpassword=None, gidnumber=None, uidnumber=None, homedirectory=None, 204 userauthtype=None): 205 user = {} 206 if displayname is not None: 207 user['displayname'] = displayname 208 if krbpasswordexpiration is not None: 209 user['krbpasswordexpiration'] = krbpasswordexpiration + "Z" 210 if givenname is not None: 211 user['givenname'] = givenname 212 if loginshell is not None: 213 user['loginshell'] = loginshell 214 if mail is not None: 215 user['mail'] = mail 216 user['nsaccountlock'] = nsaccountlock 217 if sn is not None: 218 user['sn'] = sn 219 if sshpubkey is not None: 220 user['ipasshpubkey'] = sshpubkey 221 if telephonenumber is not None: 222 user['telephonenumber'] = telephonenumber 223 if title is not None: 224 user['title'] = title 225 if userpassword is not None: 226 user['userpassword'] = userpassword 227 if gidnumber is not None: 228 user['gidnumber'] = gidnumber 229 if uidnumber is not None: 230 user['uidnumber'] = uidnumber 231 if homedirectory is not None: 232 user['homedirectory'] = homedirectory 233 if userauthtype is not None: 234 user['ipauserauthtype'] = userauthtype 235 236 return user 237 238 239def get_user_diff(client, ipa_user, module_user): 240 """ 241 Return the keys of each dict whereas values are different. Unfortunately the IPA 242 API returns everything as a list even if only a single value is possible. 243 Therefore some more complexity is needed. 244 The method will check if the value type of module_user.attr is not a list and 245 create a list with that element if the same attribute in ipa_user is list. In this way I hope that the method 246 must not be changed if the returned API dict is changed. 247 :param ipa_user: 248 :param module_user: 249 :return: 250 """ 251 # sshpubkeyfp is the list of ssh key fingerprints. IPA doesn't return the keys itself but instead the fingerprints. 252 # These are used for comparison. 253 sshpubkey = None 254 if 'ipasshpubkey' in module_user: 255 hash_algo = 'md5' 256 if 'sshpubkeyfp' in ipa_user and ipa_user['sshpubkeyfp'][0][:7].upper() == 'SHA256:': 257 hash_algo = 'sha256' 258 module_user['sshpubkeyfp'] = [get_ssh_key_fingerprint(pubkey, hash_algo) for pubkey in module_user['ipasshpubkey']] 259 # Remove the ipasshpubkey element as it is not returned from IPA but save it's value to be used later on 260 sshpubkey = module_user['ipasshpubkey'] 261 del module_user['ipasshpubkey'] 262 263 result = client.get_diff(ipa_data=ipa_user, module_data=module_user) 264 265 # If there are public keys, remove the fingerprints and add them back to the dict 266 if sshpubkey is not None: 267 del module_user['sshpubkeyfp'] 268 module_user['ipasshpubkey'] = sshpubkey 269 return result 270 271 272def get_ssh_key_fingerprint(ssh_key, hash_algo='sha256'): 273 """ 274 Return the public key fingerprint of a given public SSH key 275 in format "[fp] [comment] (ssh-rsa)" where fp is of the format: 276 FB:0C:AC:0A:07:94:5B:CE:75:6E:63:32:13:AD:AD:D7 277 for md5 or 278 SHA256:[base64] 279 for sha256 280 Comments are assumed to be all characters past the second 281 whitespace character in the sshpubkey string. 282 :param ssh_key: 283 :param hash_algo: 284 :return: 285 """ 286 parts = ssh_key.strip().split(None, 2) 287 if len(parts) == 0: 288 return None 289 key_type = parts[0] 290 key = base64.b64decode(parts[1].encode('ascii')) 291 292 if hash_algo == 'md5': 293 fp_plain = hashlib.md5(key).hexdigest() 294 key_fp = ':'.join(a + b for a, b in zip(fp_plain[::2], fp_plain[1::2])).upper() 295 elif hash_algo == 'sha256': 296 fp_plain = base64.b64encode(hashlib.sha256(key).digest()).decode('ascii').rstrip('=') 297 key_fp = 'SHA256:{fp}'.format(fp=fp_plain) 298 if len(parts) < 3: 299 return "%s (%s)" % (key_fp, key_type) 300 else: 301 comment = parts[2] 302 return "%s %s (%s)" % (key_fp, comment, key_type) 303 304 305def ensure(module, client): 306 state = module.params['state'] 307 name = module.params['uid'] 308 nsaccountlock = state == 'disabled' 309 310 module_user = get_user_dict(displayname=module.params.get('displayname'), 311 krbpasswordexpiration=module.params.get('krbpasswordexpiration'), 312 givenname=module.params.get('givenname'), 313 loginshell=module.params['loginshell'], 314 mail=module.params['mail'], sn=module.params['sn'], 315 sshpubkey=module.params['sshpubkey'], nsaccountlock=nsaccountlock, 316 telephonenumber=module.params['telephonenumber'], title=module.params['title'], 317 userpassword=module.params['password'], 318 gidnumber=module.params.get('gidnumber'), uidnumber=module.params.get('uidnumber'), 319 homedirectory=module.params.get('homedirectory'), 320 userauthtype=module.params.get('userauthtype')) 321 322 update_password = module.params.get('update_password') 323 ipa_user = client.user_find(name=name) 324 325 changed = False 326 if state in ['present', 'enabled', 'disabled']: 327 if not ipa_user: 328 changed = True 329 if not module.check_mode: 330 ipa_user = client.user_add(name=name, item=module_user) 331 else: 332 if update_password == 'on_create': 333 module_user.pop('userpassword', None) 334 diff = get_user_diff(client, ipa_user, module_user) 335 if len(diff) > 0: 336 changed = True 337 if not module.check_mode: 338 ipa_user = client.user_mod(name=name, item=module_user) 339 else: 340 if ipa_user: 341 changed = True 342 if not module.check_mode: 343 client.user_del(name) 344 345 return changed, ipa_user 346 347 348def main(): 349 argument_spec = ipa_argument_spec() 350 argument_spec.update(displayname=dict(type='str'), 351 givenname=dict(type='str'), 352 update_password=dict(type='str', default="always", 353 choices=['always', 'on_create'], 354 no_log=False), 355 krbpasswordexpiration=dict(type='str', no_log=False), 356 loginshell=dict(type='str'), 357 mail=dict(type='list', elements='str'), 358 sn=dict(type='str'), 359 uid=dict(type='str', required=True, aliases=['name']), 360 gidnumber=dict(type='str'), 361 uidnumber=dict(type='str'), 362 password=dict(type='str', no_log=True), 363 sshpubkey=dict(type='list', elements='str'), 364 state=dict(type='str', default='present', 365 choices=['present', 'absent', 'enabled', 'disabled']), 366 telephonenumber=dict(type='list', elements='str'), 367 title=dict(type='str'), 368 homedirectory=dict(type='str'), 369 userauthtype=dict(type='list', elements='str', 370 choices=['password', 'radius', 'otp', 'pkinit', 'hardened'])) 371 372 module = AnsibleModule(argument_spec=argument_spec, 373 supports_check_mode=True) 374 375 client = UserIPAClient(module=module, 376 host=module.params['ipa_host'], 377 port=module.params['ipa_port'], 378 protocol=module.params['ipa_prot']) 379 380 # If sshpubkey is defined as None than module.params['sshpubkey'] is [None]. IPA itself returns None (not a list). 381 # Therefore a small check here to replace list(None) by None. Otherwise get_user_diff() would return sshpubkey 382 # as different which should be avoided. 383 if module.params['sshpubkey'] is not None: 384 if len(module.params['sshpubkey']) == 1 and module.params['sshpubkey'][0] == "": 385 module.params['sshpubkey'] = None 386 387 try: 388 client.login(username=module.params['ipa_user'], 389 password=module.params['ipa_pass']) 390 changed, user = ensure(module, client) 391 module.exit_json(changed=changed, user=user) 392 except Exception as e: 393 module.fail_json(msg=to_native(e), exception=traceback.format_exc()) 394 395 396if __name__ == '__main__': 397 main() 398