From 87282b635483f1ead5807c1dadd30f4e4827c5de Mon Sep 17 00:00:00 2001 From: twangboy Date: Thu, 5 Nov 2015 14:32:55 -0700 Subject: [PATCH] Backport win_useradd Backported win_useradd.py from 2015.8 to fix issues with unicode, etc. --- salt/modules/win_useradd.py | 687 +++++++++++++++++++++++++++++++----- 1 file changed, 599 insertions(+), 88 deletions(-) diff --git a/salt/modules/win_useradd.py b/salt/modules/win_useradd.py index 41588202bd..3c885dc762 100644 --- a/salt/modules/win_useradd.py +++ b/salt/modules/win_useradd.py @@ -1,10 +1,22 @@ # -*- coding: utf-8 -*- ''' -Manage Windows users with the net user command +Module for managing Windows Users -NOTE: This currently only works with local user accounts, not domain accounts +:depends: + - pywintypes + - win32api + - win32net + - win32netcon + - win32profile + - win32security + - win32ts + +.. note:: + This currently only works with local user accounts, not domain accounts ''' from __future__ import absolute_import +from datetime import datetime +import time try: from shlex import quote as _cmd_quote # pylint: disable=E0611 @@ -20,14 +32,16 @@ import logging log = logging.getLogger(__name__) try: + import pywintypes import wmi import pythoncom - import pywintypes import win32api import win32con import win32net import win32netcon + import win32profile import win32security + import win32ts HAS_WIN32NET_MODS = True except ImportError: HAS_WIN32NET_MODS = False @@ -40,33 +54,108 @@ def __virtual__(): ''' Set the user module if the kernel is Windows ''' - if HAS_WIN32NET_MODS is True and salt.utils.is_windows(): + if HAS_WIN32NET_MODS and salt.utils.is_windows(): return __virtualname__ return False +def _get_date_time_format(dt_string): + ''' + Copied from win_system.py (_get_date_time_format) + + Function that detects the date/time format for the string passed. + + :param str dt_string: + A date/time string + + :return: The format of the passed dt_string + :rtype: str + ''' + valid_formats = [ + '%Y-%m-%d %I:%M:%S %p', + '%m-%d-%y %I:%M:%S %p', + '%m-%d-%Y %I:%M:%S %p', + '%m/%d/%y %I:%M:%S %p', + '%m/%d/%Y %I:%M:%S %p', + '%Y/%m/%d %I:%M:%S %p', + '%Y-%m-%d %I:%M:%S', + '%m-%d-%y %I:%M:%S', + '%m-%d-%Y %I:%M:%S', + '%m/%d/%y %I:%M:%S', + '%m/%d/%Y %I:%M:%S', + '%Y/%m/%d %I:%M:%S', + '%Y-%m-%d %I:%M %p', + '%m-%d-%y %I:%M %p', + '%m-%d-%Y %I:%M %p', + '%m/%d/%y %I:%M %p', + '%m/%d/%Y %I:%M %p', + '%Y/%m/%d %I:%M %p', + '%Y-%m-%d %I:%M', + '%m-%d-%y %I:%M', + '%m-%d-%Y %I:%M', + '%m/%d/%y %I:%M', + '%m/%d/%Y %I:%M', + '%Y/%m/%d %I:%M', + '%Y-%m-%d', + '%m-%d-%y', + '%m-%d-%Y', + '%m/%d/%y', + '%m/%d/%Y', + '%Y/%m/%d', + ] + for dt_format in valid_formats: + try: + datetime.strptime(dt_string, dt_format) + return dt_format + except ValueError: + continue + return False + + def add(name, password=None, - # Disable pylint checking on the next options. They exist to match the - # user modules of other distributions. - # pylint: disable=W0613 - uid=None, - gid=None, - groups=None, - home=False, - shell=None, - unique=False, - system=False, fullname=False, - roomnumber=False, - workphone=False, - homephone=False, - loginclass=False, - createhome=False - # pylint: enable=W0613 - ): + description=None, + groups=None, + home=None, + homedrive=None, + profile=None, + logonscript=None): ''' - Add a user to the minion + Add a user to the minion. + + :param str name: + User name + + :param str password: + User's password in plain text. + + :param str fullname: + The user's full name. + + :param str description: + A brief description of the user account. + + :param list groups: + A list of groups to add the user to. + + :param str home: + The path to the user's home directory. + + :param str homedrive: + The drive letter to assign to the home directory. Must be the Drive Letter + followed by a colon. ie: U: + + :param str profile: + An explicit path to a profile. Can be a UNC or a folder on the system. If + left blank, windows uses it's default profile directory. + + :param str logonscript: + Path to a login script to run when the user logs on. + + :return: + True if successful. False is unsuccessful. + :rtype: bool CLI Example: @@ -74,28 +163,212 @@ def add(name, salt '*' user.add name password ''' - if password: - ret = __salt__['cmd.run_all']('net user {0} {1} /add /y'.format(name, password)) + user_info = {} + if name: + user_info['name'] = name else: - ret = __salt__['cmd.run_all']('net user {0} /add'.format(name)) - if groups: - chgroups(name, groups) + return False + user_info['password'] = password + user_info['priv'] = win32netcon.USER_PRIV_USER + user_info['home_dir'] = home + user_info['comment'] = description + user_info['flags'] = win32netcon.UF_SCRIPT + user_info['script_path'] = logonscript + + try: + win32net.NetUserAdd(None, 1, user_info) + except win32net.error as exc: + (number, context, message) = exc + log.error('Failed to create user {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + + update(name=name, + homedrive=homedrive, + profile=profile, + fullname=fullname) + + ret = chgroups(name, groups) if groups else True + + return ret + + +def update(name, + password=None, + fullname=None, + description=None, + home=None, + homedrive=None, + logonscript=None, + profile=None, + expiration_date=None, + expired=None, + account_disabled=None, + unlock_account=None, + password_never_expires=None, + disallow_change_password=None): + r''' + Updates settings for the windows user. Name is the only required parameter. + Settings will only be changed if the parameter is passed a value. + + .. versionadded:: 2015.8.0 + + :param str name: + The user name to update. + + :param str password: + New user password in plain text. + + :param str fullname: + The user's full name. + + :param str description: + A brief description of the user account. + + :param str home: + The path to the user's home directory. + + :param str homedrive: + The drive letter to assign to the home directory. Must be the Drive Letter + followed by a colon. ie: U: + + :param str logonscript: + The path to the logon script. + + :param str profile: + The path to the user's profile directory. + + :param date expiration_date: The date and time when the account expires. Can + be a valid date/time string. To set to never expire pass the string 'Never'. + + :param bool expired: Pass `True` to expire the account. The user will be + prompted to change their password at the next logon. Pass `False` to mark + the account as 'not expired'. You can't use this to negate the expiration if + the expiration was caused by the account expiring. You'll have to change + the `expiration_date` as well. + + :param bool account_disabled: True disables the account. False enables the + account. + + :param bool unlock_account: True unlocks a locked user account. False is + ignored. + + :param bool password_never_expires: True sets the password to never expire. + False allows the password to expire. + + :param bool disallow_change_password: True blocks the user from changing + the password. False allows the user to change the password. + + :return: + True if successful. False is unsuccessful. + :rtype: bool + + CLI Example: + + .. code-block:: bash + + salt '*' user.update bob password=secret profile=C:\Users\Bob + home=\\server\homeshare\bob homedrive=U: + ''' + + # Make sure the user exists + # Return an object containing current settings for the user + try: + user_info = win32net.NetUserGetInfo(None, name, 4) + except win32net.error as exc: + (number, context, message) = exc + log.error('Failed to update user {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + + # Check parameters to update + # Update the user object with new settings + if password: + user_info['password'] = password + if home: + user_info['home_dir'] = home + if homedrive: + user_info['home_dir_drive'] = homedrive + if description: + user_info['comment'] = description + if logonscript: + user_info['script_path'] = logonscript if fullname: - chfullname(name, fullname) - return ret['retcode'] == 0 + user_info['full_name'] = fullname + if profile: + user_info['profile'] = profile + if expiration_date: + if expiration_date == 'Never': + user_info['acct_expires'] = win32netcon.TIMEQ_FOREVER + else: + date_format = _get_date_time_format(expiration_date) + if date_format: + dt_obj = datetime.strptime(expiration_date, date_format) + else: + return 'Invalid start_date' + user_info['acct_expires'] = time.mktime(dt_obj.timetuple()) + if expired is not None: + if expired: + user_info['password_expired'] = 1 + else: + user_info['password_expired'] = 0 + if account_disabled is not None: + if account_disabled: + user_info['flags'] |= win32netcon.UF_ACCOUNTDISABLE + else: + user_info['flags'] ^= win32netcon.UF_ACCOUNTDISABLE + if unlock_account is not None: + if unlock_account: + user_info['flags'] ^= win32netcon.UF_LOCKOUT + if password_never_expires is not None: + if password_never_expires: + user_info['flags'] |= win32netcon.UF_DONT_EXPIRE_PASSWD + else: + user_info['flags'] ^= win32netcon.UF_DONT_EXPIRE_PASSWD + if disallow_change_password is not None: + if disallow_change_password: + user_info['flags'] |= win32netcon.UF_PASSWD_CANT_CHANGE + else: + user_info['flags'] ^= win32netcon.UF_PASSWD_CANT_CHANGE + + # Apply new settings + try: + win32net.NetUserSetInfo(None, name, 4, user_info) + except win32net.error as exc: + (number, context, message) = exc + log.error('Failed to update user {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + + return True def delete(name, - # Disable pylint checking on the next options. They exist to match - # the user modules of other distributions. - # pylint: disable=W0613 purge=False, - force=False - # pylint: enable=W0613 - ): + force=False): ''' Remove a user from the minion - NOTE: purge and force have not been implemented on Windows yet + + :param str name: + The name of the user to delete + + :param bool purge: + Boolean value indicating that the user profile should also be removed when + the user account is deleted. If set to True the profile will be removed. + + :param bool force: + Boolean value indicating that the user account should be deleted even if the + user is logged in. True will log the user out and delete user. + + :return: + True if successful + :rtype: bool CLI Example: @@ -103,35 +376,150 @@ def delete(name, salt '*' user.delete name ''' - ret = __salt__['cmd.run_all']('net user {0} /delete'.format(name)) - return ret['retcode'] == 0 + # Check if the user exists + try: + user_info = win32net.NetUserGetInfo(None, name, 4) + except win32net.error as exc: + (number, context, message) = exc + log.error('User not found: {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + + # Check if the user is logged in + # Return a list of logged in users + try: + sess_list = win32ts.WTSEnumerateSessions() + except win32ts.error as exc: + (number, context, message) = exc + log.error('No logged in users found') + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + + # Is the user one that is logged in + logged_in = False + session_id = None + for sess in sess_list: + if win32ts.WTSQuerySessionInformation(None, sess['SessionId'], win32ts.WTSUserName) == name: + session_id = sess['SessionId'] + logged_in = True + + # If logged in and set to force, log the user out and continue + # If logged in and not set to force, return false + if logged_in: + if force: + try: + win32ts.WTSLogoffSession(win32ts.WTS_CURRENT_SERVER_HANDLE, session_id, True) + except win32ts.error as exc: + (number, context, message) = exc + log.error('User not found: {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + else: + log.error('User {0} is currently logged in.'.format(name)) + return False + + # Remove the User Profile directory + if purge: + try: + sid = getUserSid(name) + win32profile.DeleteProfile(sid) + except pywintypes.error as exc: + (number, context, message) = exc + if number == 2: # Profile Folder Not Found + pass + else: + log.error('Failed to remove profile for {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + + # And finally remove the user account + try: + win32net.NetUserDel(None, name) + except win32net.error as exc: + (number, context, message) = exc + log.error('Failed to delete user {0}'.format(name)) + log.error('nbr: {0}'.format(number)) + log.error('ctx: {0}'.format(context)) + log.error('msg: {0}'.format(message)) + return False + + return True -def setpassword(name, password): +def getUserSid(username): ''' - Set a user's password + Get the Security ID for the user + + :param str username: + user name for which to look up the SID + + :return: + Returns the user SID + :rtype: str CLI Example: .. code-block:: bash - salt '*' user.setpassword name password + salt '*' user.getUserSid jsnuffy ''' - ret = __salt__['cmd.run_all']( - 'net user {0} {1}'.format(name, password), output_loglevel='quiet' - ) - return ret['retcode'] == 0 + domain = win32api.GetComputerName() + if username.find(u'\\') != -1: + domain = username.split(u'\\')[0] + username = username.split(u'\\')[-1] + domain = domain.upper() + return win32security.ConvertSidToStringSid(win32security.LookupAccountName(None, domain + u'\\' + username)[0]) + + +def setpassword(name, password): + ''' + Set the user's password + + :param str name: + user name for which to set the password + + :param str password: + the new password + + :return: + True if successful. False is unsuccessful. + :rtype: bool + + CLI Example: + + .. code-block:: bash + + salt '*' user.setpassword jsnuffy sup3rs3cr3t + ''' + return update(name=name, password=password) def addgroup(name, group): ''' Add user to a group + :param str name: + user name to add to the group + + :param str group: + name of the group to which to add the user + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash - salt '*' user.addgroup username groupname + salt '*' user.addgroup jsnuffy 'Power Users' ''' name = _cmd_quote(name) group = _cmd_quote(group).lstrip('\'').rstrip('\'') @@ -152,11 +540,21 @@ def removegroup(name, group): ''' Remove user from a group + :param str name: + user name to remove from the group + + :param str group: + name of the group from which to remove the user + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash - salt '*' user.removegroup username groupname + salt '*' user.removegroup jsnuffy 'Power Users' ''' name = _cmd_quote(name) group = _cmd_quote(group).lstrip('\'').rstrip('\'') @@ -180,6 +578,19 @@ def chhome(name, home, persist=False): Change the home directory of the user, pass True for persist to move files to the new home directory if the old home directory exist. + :param str name: + name of the user whose home directory you wish to change + + :param str home: + new location of the home directory + + :param bool persist: + True to move the contents of the existing home directory to the new location + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash @@ -194,8 +605,7 @@ def chhome(name, home, persist=False): if home == pre_info['home']: return True - if __salt__['cmd.retcode']('net user {0} /homedir:{1}'.format( - name, home)) != 0: + if not update(name=name, home=home): return False if persist and home is not None and pre_info['home'] is not None: @@ -214,59 +624,46 @@ def chprofile(name, profile): ''' Change the profile directory of the user + :param str name: + name of the user whose profile you wish to change + + :param str profile: + new location of the profile + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash salt '*' user.chprofile foo \\\\fileserver\\profiles\\foo ''' - pre_info = info(name) - - if not pre_info: - return False - - if profile == pre_info['profile']: - return True - if __salt__['cmd.retcode']('net user {0} /profilepath:{1}'.format( - name, profile)) != 0: - return False - - post_info = info(name) - if post_info['profile'] != pre_info['profile']: - return post_info['profile'] == profile - - return False + return update(name=name, profile=profile) def chfullname(name, fullname): ''' Change the full name of the user + :param str name: + user name for which to change the full name + + :param str fullname: + the new value for the full name + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash salt '*' user.chfullname user 'First Last' ''' - pre_info = info(name) - - if not pre_info: - return False - - name = _cmd_quote(name) - fullname_qts = _cmd_quote(fullname).replace("'", "\"") - - if fullname == pre_info['fullname']: - return True - if __salt__['cmd.retcode']('net user {0} /fullname:{1}'.format( - name, fullname_qts), python_shell=True) != 0: - return False - - post_info = info(name) - if post_info['fullname'] != pre_info['fullname']: - return post_info['fullname'] == fullname - - return False + return update(name=name, fullname=fullname) def chgroups(name, groups, append=True): @@ -274,11 +671,26 @@ def chgroups(name, groups, append=True): Change the groups this user belongs to, add append=False to make the user a member of only the specified groups + :param str name: + user name for which to change groups + + :param groups: + a single group or a list of groups to assign to the user + :type groups: list, str + + :param bool append: + True adds the passed groups to the user's current groups + False sets the user's groups to the passed groups only + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash - salt '*' user.chgroups foo wheel,root True + salt '*' user.chgroups jsnuffy Administrators,Users True ''' if isinstance(groups, string_types): groups = groups.split(',') @@ -312,11 +724,39 @@ def info(name): ''' Return user information + :param str name: + Username for which to display information + + :returns: + A dictionary containing user information + - fullname + - username + - SID + - passwd (will always return None) + - comment (same as description, left here for backwards compatibility) + - description + - active + - logonscript + - profile + - home + - homedrive + - groups + - password_changed + - successful_logon_attempts + - failed_logon_attempts + - last_logon + - account_disabled + - account_locked + - password_never_expires + - disallow_change_password + - gid + :rtype: dict + CLI Example: .. code-block:: bash - salt '*' user.info root + salt '*' user.info jsnuffy ''' ret = {} items = {} @@ -337,18 +777,54 @@ def info(name): ret['uid'] = win32security.ConvertSidToStringSid(items['user_sid']) ret['passwd'] = items['password'] ret['comment'] = items['comment'] + ret['description'] = items['comment'] ret['active'] = (not bool(items['flags'] & win32netcon.UF_ACCOUNTDISABLE)) ret['logonscript'] = items['script_path'] ret['profile'] = items['profile'] + ret['failed_logon_attempts'] = items['bad_pw_count'] + ret['successful_logon_attempts'] = items['num_logons'] + secs = time.mktime(datetime.now().timetuple()) - items['password_age'] + ret['password_changed'] = datetime.fromtimestamp(secs). \ + strftime('%Y-%m-%d %H:%M:%S') + if items['last_logon'] == 0: + ret['last_logon'] = 'Never' + else: + ret['last_logon'] = datetime.fromtimestamp(items['last_logon']).\ + strftime('%Y-%m-%d %H:%M:%S') + ret['expiration_date'] = datetime.fromtimestamp(items['acct_expires']).\ + strftime('%Y-%m-%d %H:%M:%S') + ret['expired'] = items['password_expired'] == 1 if not ret['profile']: ret['profile'] = _get_userprofile_from_registry(name, ret['uid']) ret['home'] = items['home_dir'] + ret['homedrive'] = items['home_dir_drive'] if not ret['home']: ret['home'] = ret['profile'] ret['groups'] = groups + if items['flags'] & win32netcon.UF_DONT_EXPIRE_PASSWD == 0: + ret['password_never_expires'] = False + else: + ret['password_never_expires'] = True + if items['flags'] & win32netcon.UF_ACCOUNTDISABLE == 0: + ret['account_disabled'] = False + else: + ret['account_disabled'] = True + if items['flags'] & win32netcon.UF_LOCKOUT == 0: + ret['account_locked'] = False + else: + ret['account_locked'] = True + if items['flags'] & win32netcon.UF_PASSWD_CANT_CHANGE == 0: + ret['disallow_change_password'] = False + else: + ret['disallow_change_password'] = True + ret['gid'] = '' - return ret + return ret + + else: + + return False def _get_userprofile_from_registry(user, sid): @@ -369,6 +845,13 @@ def list_groups(name): ''' Return a list of groups the named user belongs to + :param str name: + user name for which to list groups + + :return: + list of groups to which the user belongs + :rtype: list + CLI Example: .. code-block:: bash @@ -390,6 +873,14 @@ def getent(refresh=False): ''' Return the list of all info for all users + :param bool refresh: + Refresh the cached user information. Default is False. Useful when used from + within a state function. + + :return: + A dictionary containing information about all users on the system + :rtype: dict + CLI Example: .. code-block:: bash @@ -421,6 +912,16 @@ def getent(refresh=False): def list_users(): ''' Return a list of users on Windows + + :return: + list of users on the system + :rtype: list + + CLI Example: + + .. code-block:: bash + + salt '*' user.list_users ''' res = 0 users = [] @@ -447,11 +948,21 @@ def rename(name, new_name): ''' Change the username for a named user + :param str name: + user name to change + + :param str new_name: + the new name for the current user + + :return: + True if successful. False is unsuccessful. + :rtype: bool + CLI Example: .. code-block:: bash - salt '*' user.rename name new_name + salt '*' user.rename jsnuffy jshmoe ''' # Load information for the current name current_info = info(name)