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