summaryrefslogtreecommitdiff
path: root/lib/chef
diff options
context:
space:
mode:
authorSerdar Sutay <serdar@opscode.com>2014-07-30 16:03:42 -0700
committerSerdar Sutay <serdar@opscode.com>2014-08-12 16:18:18 -0700
commitebe9a7f262f23ff7bd9f94afa9b0c1a07cbd2a73 (patch)
tree09e965ef64c6a9db49a4d2118dfa70ac3e649290 /lib/chef
parent0c90f9868fb8a4576145645ca507f2452286ded3 (diff)
downloadchef-ebe9a7f262f23ff7bd9f94afa9b0c1a07cbd2a73.tar.gz
* Dscl user provider changes to support Mac 10.7, 10.8 & 10.9.
* Make the dscl user provider password handling idempotent. * Refactor / modernize dscl user provider unit tests. * Functional tests for dscl user provider.
Diffstat (limited to 'lib/chef')
-rw-r--r--lib/chef/exceptions.rb1
-rw-r--r--lib/chef/provider/user/dscl.rb684
-rw-r--r--lib/chef/resource/user.rb18
3 files changed, 547 insertions, 156 deletions
diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb
index 194c758f37..0d86b08558 100644
--- a/lib/chef/exceptions.rb
+++ b/lib/chef/exceptions.rb
@@ -83,6 +83,7 @@ class Chef
class RequestedUIDUnavailable < RuntimeError; end
class InvalidHomeDirectory < ArgumentError; end
class DsclCommandFailed < RuntimeError; end
+ class PlutilCommandFailed < RuntimeError; end
class UserIDNotFound < ArgumentError; end
class GroupIDNotFound < ArgumentError; end
class ConflictingMembersInGroup < ArgumentError; end
diff --git a/lib/chef/provider/user/dscl.rb b/lib/chef/provider/user/dscl.rb
index 96b5db24ba..0c4ac27377 100644
--- a/lib/chef/provider/user/dscl.rb
+++ b/lib/chef/provider/user/dscl.rb
@@ -16,40 +16,193 @@
# limitations under the License.
#
+require 'mixlib/shellout'
require 'chef/provider/user'
require 'openssl'
+require 'plist'
class Chef
class Provider
class User
+ #
+ # The most tricky bit of this provider is the way it deals with user passwords.
+ # Mac OS X has different password shadow calculations based on the version.
+ # < 10.7 => password shadow calculation format SALTED-SHA1
+ # => stored in: /var/db/shadow/hash/#{guid}
+ # => shadow binary length 68 bytes
+ # => First 4 bytes salt / Next 64 bytes shadow value
+ # = 10.7 => password shadow calculation format SALTED-SHA512
+ # => stored in: /var/db/dslocal/nodes/Default/users/#{name}.plist
+ # => shadow binary length 68 bytes
+ # => First 4 bytes salt / Next 64 bytes shadow value
+ # > 10.7 => password shadow calculation format SALTED-SHA512-PBKDF2
+ # => stored in: /var/db/dslocal/nodes/Default/users/#{name}.plist
+ # => shadow binary length 128 bytes
+ # => Salt / Iterations are stored seperately in the same file
+ #
+ # This provider only supports Mac OSX versions 10.7 and above
class Dscl < Chef::Provider::User
- NFS_HOME_DIRECTORY = %r{^NFSHomeDirectory: (.*)$}
- AUTHENTICATION_AUTHORITY = %r{^AuthenticationAuthority: (.*)$}
+ def define_resource_requirements
+ super
+
+ requirements.assert(:all_actions) do |a|
+ a.assertion { mac_osx_version_less_than_10_7? == false }
+ a.failure_message(Chef::Exceptions::User, "Chef::Provider::User::Dscl only supports Mac OS X versions 10.7 and above.")
+ end
+
+ requirements.assert(:all_actions) do |a|
+ a.assertion { ::File.exists?("/usr/bin/dscl") }
+ a.failure_message(Chef::Exceptions::User, "Cannot find binary '/usr/bin/dscl' on the system for #{@new_resource}!")
+ end
+
+ requirements.assert(:all_actions) do |a|
+ a.assertion { ::File.exists?("/usr/bin/plutil") }
+ a.failure_message(Chef::Exceptions::User, "Cannot find binary '/usr/bin/plutil' on the system for #{@new_resource}!")
+ end
- def dscl(*args)
- shell_out("dscl . -#{args.join(' ')}")
+ requirements.assert(:create, :modify, :manage) do |a|
+ # Password Requirements
+ a.assertion do
+ if @new_resource.password
+ if mac_osx_version_greater_than_10_7?
+ if salted_sha512?(@new_resource.password)
+ # SALTED-SHA512 password shadow hashes are not supported
+ false
+ elsif salted_sha512_pbkdf2?(@new_resource.password)
+ # salt and iterations should be specified when
+ # SALTED-SHA512-PBKDF2 password shadow hash is given
+ @new_resource.salt && @new_resource.iterations
+ else
+ true
+ end
+ else
+ # On 10.7 SALTED-SHA512-PBKDF2 is not supported
+ !salted_sha512_pbkdf2?(@new_resource.password)
+ end
+ else
+ true
+ end
+ end
+ a.failure_message(Chef::Exceptions::User, "Requirements for password is not achieved. Check \
+ http://docs.getchef.com/resource_user.html#attributes for more information!")
+ end
end
- def safe_dscl(*args)
- result = dscl(*args)
- return "" if ( args.first =~ /^delete/ ) && ( result.exitstatus != 0 )
- raise(Chef::Exceptions::DsclCommandFailed,"dscl error: #{result.inspect}") unless result.exitstatus == 0
- raise(Chef::Exceptions::DsclCommandFailed,"dscl error: #{result.inspect}") if result.stdout =~ /No such key: /
- return result.stdout
+ def load_current_resource
+ @current_resource = Chef::Resource::User.new(@new_resource.username)
+ @current_resource.username(@new_resource.username)
+
+ user_info = read_user_info
+ if user_info
+ @current_resource.uid(dscl_get(user_info, :uid))
+ @current_resource.gid(dscl_get(user_info, :gid))
+ @current_resource.home(dscl_get(user_info, :home))
+ @current_resource.shell(dscl_get(user_info, :shell))
+ @current_resource.comment(dscl_get(user_info, :comment))
+ @authentication_authority = dscl_get(user_info, :auth_authority)
+
+ if @new_resource.password && dscl_get(user_info, :password) == "********"
+ # A password is set. Let's get the password information from shadow file
+ shadow_hash_binary = dscl_get(user_info, :shadow_hash)
+
+ # Calling shell_out directly since we want to give an input stream
+ shadow_hash_xml = convert_binary_plist_to_xml(shadow_hash_binary.string)
+ shadow_hash = Plist::parse_xml(shadow_hash_xml)
+
+ if shadow_hash["SALTED-SHA512"]
+ # Convert the shadow value from Base64 encoding to hex before consuming them
+ @password_shadow_conversion_algorithm = "SALTED-SHA512"
+ @current_resource.password(shadow_hash["SALTED-SHA512"].string.unpack('H*').first)
+ elsif shadow_hash["SALTED-SHA512-PBKDF2"]
+ @password_shadow_conversion_algorithm = "SALTED-SHA512-PBKDF2"
+ # Convert the entropy from Base64 encoding to hex before consuming them
+ @current_resource.password(shadow_hash["SALTED-SHA512-PBKDF2"]["entropy"].string.unpack('H*').first)
+ @current_resource.iterations(shadow_hash["SALTED-SHA512-PBKDF2"]["iterations"])
+ # Convert the salt from Base64 encoding to hex before consuming them
+ @current_resource.salt(shadow_hash["SALTED-SHA512-PBKDF2"]["salt"].string.unpack('H*').first)
+ else
+ raise(Chef::Exceptions::User,"Unknown shadow_hash format: #{shadow_hash.keys.join(' ')}")
+ end
+ end
+
+ convert_group_name if @new_resource.gid
+ else
+ @user_exists = false
+ Chef::Log.debug("#{@new_resource} user does not exist")
+ end
+
+ @current_resource
+ end
+
+ #
+ # Provider Actions
+ #
+
+ def create_user
+ dscl_create_user
+ dscl_create_comment
+ dscl_set_uid
+ dscl_set_gid
+ dscl_set_home
+ dscl_set_shell
+ set_password
end
- # This is handled in providers/group.rb by Etc.getgrnam()
- # def user_exists?(user)
- # users = safe_dscl("list /Users")
- # !! ( users =~ Regexp.new("\n#{user}\n") )
- # end
+ def manage_user
+ dscl_create_user if diverged?(:username)
+ dscl_create_comment if diverged?(:comment)
+ dscl_set_uid if diverged?(:uid)
+ dscl_set_gid if diverged?(:gid)
+ dscl_set_home if diverged?(:home)
+ dscl_set_shell if diverged?(:shell)
+ set_password if diverged_password?
+ end
+
+ #
+ # Action Helpers
+ #
+
+ #
+ # Create a user using dscl
+ #
+ def dscl_create_user
+ run_dscl("create /Users/#{@new_resource.username}")
+ end
+
+ #
+ # Saves the specified Chef user `comment` into RealName attribute
+ # of Mac user.
+ #
+ def dscl_create_comment
+ run_dscl("create /Users/#{@new_resource.username} RealName '#{@new_resource.comment}'")
+ end
+
+ #
+ # Sets the user id for the user using dscl.
+ # If a `uid` is not specified, it finds the next available one starting
+ # from 200 if `system` is set, 500 otherwise.
+ #
+ def dscl_set_uid
+ @new_resource.uid(get_free_uid) if (@new_resource.uid.nil? || @new_resource.uid == '')
+
+ if uid_used?(@new_resource.uid)
+ raise(Chef::Exceptions::RequestedUIDUnavailable, "uid #{@new_resource.uid} is already in use")
+ end
+
+ run_dscl("create /Users/#{@new_resource.username} UniqueID #{@new_resource.uid}")
+ end
- # get a free UID greater than 200
+ #
+ # Find the next available uid on the system. starting with 200 if `system` is set,
+ # 500 otherwise.
+ #
def get_free_uid(search_limit=1000)
- uid = nil; next_uid_guess = 200
- users_uids = safe_dscl("list /Users uid")
- while(next_uid_guess < search_limit + 200)
+ uid = nil
+ base_uid = @new_resource.system ? 200 : 500
+ next_uid_guess = base_uid
+ users_uids = run_dscl("list /Users uid")
+ while(next_uid_guess < search_limit + base_uid)
if users_uids =~ Regexp.new("#{Regexp.escape(next_uid_guess.to_s)}\n")
next_uid_guess += 1
else
@@ -60,22 +213,41 @@ class Chef
return uid || raise("uid not found. Exhausted. Searched #{search_limit} times")
end
+ #
+ # Returns true if uid is in use by a different account, false otherwise.
+ #
def uid_used?(uid)
return false unless uid
- users_uids = safe_dscl("list /Users uid")
+ users_uids = run_dscl("list /Users uid")
!! ( users_uids =~ Regexp.new("#{Regexp.escape(uid.to_s)}\n") )
end
- def set_uid
- @new_resource.uid(get_free_uid) if (@new_resource.uid.nil? || @new_resource.uid == '')
- if uid_used?(@new_resource.uid)
- raise(Chef::Exceptions::RequestedUIDUnavailable, "uid #{@new_resource.uid} is already in use")
+ #
+ # Sets the group id for the user using dscl. Fails if a group doesn't
+ # exist on the system with given group id.
+ #
+ def dscl_set_gid
+ unless @new_resource.gid && @new_resource.gid.to_s.match(/^\d+$/)
+ begin
+ possible_gid = run_dscl("read /Groups/#{@new_resource.gid} PrimaryGroupID").split(" ").last
+ rescue Chef::Exceptions::DsclCommandFailed => e
+ raise Chef::Exceptions::GroupIDNotFound.new("Group not found for #{@new_resource.gid} when creating user #{@new_resource.username}")
+ end
+ @new_resource.gid(possible_gid) if possible_gid && possible_gid.match(/^\d+$/)
end
- safe_dscl("create /Users/#{@new_resource.username} UniqueID #{@new_resource.uid}")
+ run_dscl("create /Users/#{@new_resource.username} PrimaryGroupID '#{@new_resource.gid}'")
end
- def modify_home
- return safe_dscl("delete /Users/#{@new_resource.username} NFSHomeDirectory") if (@new_resource.home.nil? || @new_resource.home.empty?)
+ #
+ # Sets the home directory for the user. If `:manage_home` is set home
+ # directory is managed (moved / created) for the user.
+ #
+ def dscl_set_home
+ if @new_resource.home.nil? || @new_resource.home.empty?
+ run_dscl("delete /Users/#{@new_resource.username} NFSHomeDirectory")
+ return
+ end
+
if @new_resource.supports[:manage_home]
validate_home_dir_specification!
@@ -87,199 +259,399 @@ class Chef
move_home
end
end
- safe_dscl("create /Users/#{@new_resource.username} NFSHomeDirectory '#{@new_resource.home}'")
+ run_dscl("create /Users/#{@new_resource.username} NFSHomeDirectory '#{@new_resource.home}'")
end
- def osx_shadow_hash?(string)
- return !! ( string =~ /^[[:xdigit:]]{1240}$/ )
+ def validate_home_dir_specification!
+ unless @new_resource.home =~ /^\//
+ raise(Chef::Exceptions::InvalidHomeDirectory,"invalid path spec for User: '#{@new_resource.username}', home directory: '#{@new_resource.home}'")
+ end
+ end
+
+ def current_home_exists?
+ ::File.exist?("#{@current_resource.home}")
+ end
+
+ def new_home_exists?
+ ::File.exist?("#{@new_resource.home}")
end
- def osx_salted_sha1?(string)
- return !! ( string =~ /^[[:xdigit:]]{48}$/ )
+ def ditto_home
+ skel = "/System/Library/User Template/English.lproj"
+ raise(Chef::Exceptions::User,"can't find skel at: #{skel}") unless ::File.exists?(skel)
+ shell_out! "ditto '#{skel}' '#{@new_resource.home}'"
+ ::FileUtils.chown_R(@new_resource.username,@new_resource.gid.to_s,@new_resource.home)
end
- def guid
- safe_dscl("read /Users/#{@new_resource.username} GeneratedUID").gsub(/GeneratedUID: /,"").strip
+ def move_home
+ Chef::Log.debug("#{@new_resource} moving #{self} home from #{@current_resource.home} to #{@new_resource.home}")
+
+ src = @current_resource.home
+ FileUtils.mkdir_p(@new_resource.home)
+ files = ::Dir.glob("#{src}/*", ::File::FNM_DOTMATCH) - ["#{src}/.","#{src}/.."]
+ ::FileUtils.mv(files,@new_resource.home, :force => true)
+ ::FileUtils.rmdir(src)
+ ::FileUtils.chown_R(@new_resource.username,@new_resource.gid.to_s,@new_resource.home)
end
- def shadow_hash_set?
- user_data = safe_dscl("read /Users/#{@new_resource.username}")
- if user_data =~ /AuthenticationAuthority: / && user_data =~ /ShadowHash/
- true
+ #
+ # Sets the shell for the user using dscl.
+ #
+ def dscl_set_shell
+ if @new_resource.shell || ::File.exists?("#{@new_resource.shell}")
+ run_dscl("create /Users/#{@new_resource.username} UserShell '#{@new_resource.shell}'")
else
- false
+ run_dscl("create /Users/#{@new_resource.username} UserShell '/usr/bin/false'")
end
end
- def modify_password
- if @new_resource.password
- shadow_hash = nil
+ #
+ # Sets the password for the user based on given password parameters.
+ # Chef supports specifying plain-text passwords and password shadow
+ # hash data.
+ #
+ def set_password
+ # Return if there is no password to set
+ return if @new_resource.password.nil?
+
+ shadow_info = prepare_password_shadow_info
+
+ # Shadow info is saved as binary plist. Convert the info to binary plist.
+ shadow_info_binary = StringIO.new
+ command = Mixlib::ShellOut.new("plutil -convert binary1 -o - -",
+ :input => shadow_info.to_plist, :live_stream => shadow_info_binary)
+ command.run_command
+
+ # Replace the shadow info in user's plist
+ user_info = read_user_info
+ dscl_set(user_info, :shadow_hash, shadow_info_binary)
+
+ #
+ # Before saving the user's plist file we need to wait for dscl to
+ # update its caches and flush them to disk. In order to achieve this
+ # we need to wait first for our changes to get into the dscl cache
+ # and then flush the cache to disk before saving password into the
+ # plist file. 3 seconds is the minimum experimental value for dscl
+ # cache to be updated. We can get rid of this sleep when we find a
+ # trigger to update dscl cache.
+ #
+ sleep 3
+ shell_out("dscacheutil '-flushcache'")
+ save_user_info(user_info)
+ end
- Chef::Log.debug("#{new_resource} updating password")
- if osx_shadow_hash?(@new_resource.password)
- shadow_hash = @new_resource.password.upcase
+ #
+ # Prepares the password shadow info based on the platform version.
+ #
+ def prepare_password_shadow_info
+ shadow_info = { }
+ entropy = nil
+ salt = nil
+ iterations = nil
+
+ if mac_osx_version_10_7?
+ hash_value = if salted_sha512?(@new_resource.password)
+ @new_resource.password
else
- if osx_salted_sha1?(@new_resource.password)
- salted_sha1 = @new_resource.password.upcase
- else
- hex_salt = ""
- OpenSSL::Random.random_bytes(10).each_byte { |b| hex_salt << b.to_i.to_s(16) }
- hex_salt = hex_salt.slice(0...8)
- salt = [hex_salt].pack("H*")
- sha1 = ::OpenSSL::Digest::SHA1.hexdigest(salt+@new_resource.password)
- salted_sha1 = (hex_salt+sha1).upcase
- end
- shadow_hash = String.new("00000000"*155)
- shadow_hash[168] = salted_sha1
+ # Create a random 4 byte salt
+ salt = OpenSSL::Random.random_bytes(4)
+ encoded_password = OpenSSL::Digest::SHA512.hexdigest(salt + @new_resource.password)
+ hash_value = salt.unpack('H*').first + encoded_password
end
- ::File.open("/var/db/shadow/hash/#{guid}",'w',0600) do |output|
- output.puts shadow_hash
+ shadow_info["SALTED-SHA512"] = StringIO.new
+ shadow_info["SALTED-SHA512"].string = convert_to_binary(hash_value)
+ shadow_info
+ else
+ if salted_sha512_pbkdf2?(@new_resource.password)
+ entropy = convert_to_binary(@new_resource.password)
+ salt = convert_to_binary(@new_resource.salt)
+ iterations = @new_resource.iterations
+ else
+ salt = OpenSSL::Random.random_bytes(32)
+ iterations = @new_resource.iterations # Use the default if not specified by the user
+
+ entropy = OpenSSL::PKCS5::pbkdf2_hmac(
+ @new_resource.password,
+ salt,
+ iterations,
+ 128,
+ OpenSSL::Digest::SHA512.new
+ )
end
- unless shadow_hash_set?
- safe_dscl("append /Users/#{@new_resource.username} AuthenticationAuthority ';ShadowHash;'")
+ pbkdf_info = { }
+ pbkdf_info["entropy"] = StringIO.new
+ pbkdf_info["entropy"].string = entropy
+ pbkdf_info["salt"] = StringIO.new
+ pbkdf_info["salt"].string = salt
+ pbkdf_info["iterations"] = iterations
+
+ shadow_info["SALTED-SHA512-PBKDF2"] = pbkdf_info
+ end
+
+ shadow_info
+ end
+
+ #
+ # Removes the user from the system after removing user from his groups
+ # and deleting home directory if needed.
+ #
+ def remove_user
+ if @new_resource.supports[:manage_home]
+ # Remove home directory
+ FileUtils.rm_rf(@current_resource.home)
+ end
+
+ # Remove the user from its groups
+ run_dscl("list /Groups").each_line do |group|
+ if member_of_group?(group.chomp)
+ run_dscl("delete /Groups/#{group.chomp} GroupMembership '#{@new_resource.username}'")
end
end
+
+ # Remove user account
+ run_dscl("delete /Users/#{@new_resource.username}")
end
- def load_current_resource
- super
- raise Chef::Exceptions::User, "Could not find binary /usr/bin/dscl for #{@new_resource}" unless ::File.exists?("/usr/bin/dscl")
+ #
+ # Locks the user.
+ #
+ def lock_user
+ run_dscl("append /Users/#{@new_resource.username} AuthenticationAuthority ';DisabledUser;'")
end
- def create_user
- dscl_create_user
- dscl_create_comment
- set_uid
- dscl_set_gid
- modify_home
- dscl_set_shell
- modify_password
+ #
+ # Unlocks the user
+ #
+ def unlock_user
+ auth_string = @authentication_authority.gsub(/AuthenticationAuthority: /,"").gsub(/;DisabledUser;/,"").strip
+ run_dscl("create /Users/#{@new_resource.username} AuthenticationAuthority '#{auth_string}'")
end
- def manage_user
- dscl_create_user if diverged?(:username)
- dscl_create_comment if diverged?(:comment)
- set_uid if diverged?(:uid)
- dscl_set_gid if diverged?(:gid)
- modify_home if diverged?(:home)
- dscl_set_shell if diverged?(:shell)
- modify_password if diverged?(:password)
+ #
+ # Returns true if the user is locked, false otherwise.
+ #
+ def locked?
+ if @authentication_authority
+ !!(@authentication_authority =~ /DisabledUser/ )
+ else
+ false
+ end
end
- def dscl_create_user
- safe_dscl("create /Users/#{@new_resource.username}")
+ #
+ # This is the interface base User provider requires to provide idempotency.
+ #
+ def check_lock
+ return @locked = locked?
end
- def dscl_create_comment
- safe_dscl("create /Users/#{@new_resource.username} RealName '#{@new_resource.comment}'")
+ #
+ # Helper functions
+ #
+
+ #
+ # Returns true if the system state and desired state is different for
+ # given attribute.
+ #
+ def diverged?(parameter)
+ parameter_updated?(parameter) && (not @new_resource.send(parameter).nil?)
end
- def dscl_set_gid
- unless @new_resource.gid && @new_resource.gid.to_s.match(/^\d+$/)
- begin
- possible_gid = safe_dscl("read /Groups/#{@new_resource.gid} PrimaryGroupID").split(" ").last
- rescue Chef::Exceptions::DsclCommandFailed => e
- raise Chef::Exceptions::GroupIDNotFound.new("Group not found for #{@new_resource.gid} when creating user #{@new_resource.username}")
- end
- @new_resource.gid(possible_gid) if possible_gid && possible_gid.match(/^\d+$/)
- end
- safe_dscl("create /Users/#{@new_resource.username} PrimaryGroupID '#{@new_resource.gid}'")
+ def parameter_updated?(parameter)
+ not (@new_resource.send(parameter) == @current_resource.send(parameter))
end
- def dscl_set_shell
- if @new_resource.password || ::File.exists?("#{@new_resource.shell}")
- safe_dscl("create /Users/#{@new_resource.username} UserShell '#{@new_resource.shell}'")
+ #
+ # We need a special check function for password since we support both
+ # plain text and shadow hash data.
+ #
+ # Checks if password needs update based on platform version and the
+ # type of the password specified.
+ #
+ def diverged_password?
+ return false if @new_resource.password.nil?
+
+ # Dscl provider supports both plain text passwords and shadow hashes.
+ if mac_osx_version_10_7?
+ if salted_sha512?(@new_resource.password)
+ diverged?(:password)
+ else
+ !salted_sha512_password_match?
+ end
else
- safe_dscl("create /Users/#{@new_resource.username} UserShell '/usr/bin/false'")
+ # When a system is upgraded to a version 10.7+ shadow hashes of the users
+ # will be updated when the user logs in. So it's possible that we will have
+ # SALTED-SHA512 password in the current_resource. In that case we will force
+ # password to be updated.
+ return true if salted_sha512?(@current_resource.password)
+
+ if salted_sha512_pbkdf2?(@new_resource.password)
+ diverged?(:password) || diverged?(:salt) || diverged?(:iterations)
+ else
+ !salted_sha512_pbkdf2_password_match?
+ end
end
end
- def remove_user
- if @new_resource.supports[:manage_home]
- user_info = safe_dscl("read /Users/#{@new_resource.username}")
- if nfs_home_match = user_info.match(NFS_HOME_DIRECTORY)
- #nfs_home = safe_dscl("read /Users/#{@new_resource.username} NFSHomeDirectory")
- #nfs_home.gsub!(/NFSHomeDirectory: /,"").gsub!(/\n$/,"")
- nfs_home = nfs_home_match[1]
- FileUtils.rm_rf(nfs_home)
- end
- end
- # remove the user from its groups
- groups = []
- Etc.group do |group|
- groups << group.name if group.mem.include?(@new_resource.username)
+ #
+ # Returns true if user is member of the specified group, false otherwise.
+ #
+ def member_of_group?(group_name)
+ membership_info = ""
+ begin
+ membership_info = run_dscl("read /Groups/#{group_name}")
+ rescue Chef::Exceptions::DsclCommandFailed
+ # Raised if the group doesn't contain any members
end
- groups.each do |group_name|
- safe_dscl("delete /Groups/#{group_name} GroupMembership '#{@new_resource.username}'")
- end
- # remove user account
- safe_dscl("delete /Users/#{@new_resource.username}")
+ # Output is something like:
+ # GroupMembership: root admin etc
+ members = membership_info.split(" ")
+ members.shift # Get rid of GroupMembership: string
+ members.include?(@new_resource.username)
end
- def locked?
- user_info = safe_dscl("read /Users/#{@new_resource.username}")
- if auth_authority_md = AUTHENTICATION_AUTHORITY.match(user_info)
- !!(auth_authority_md[1] =~ /DisabledUser/ )
- else
- false
+ #
+ # DSCL Helper functions
+ #
+
+ # A simple map of Chef's terms to DSCL's terms.
+ DSCL_PROPERTY_MAP = {
+ :uid => "generateduid",
+ :gid => "gid",
+ :home => "home",
+ :shell => "shell",
+ :comment => "realname",
+ :password => "passwd",
+ :auth_authority => "authentication_authority",
+ :shadow_hash => "ShadowHashData"
+ }.freeze
+
+ # Directory where the user plist files are stored for versions 10.7 and above
+ USER_PLIST_DIRECTORY = "/var/db/dslocal/nodes/Default/users".freeze
+
+ #
+ # Reads the user plist and returns a hash keyed with DSCL properties specified
+ # in DSCL_PROPERTY_MAP. Return nil if the user is not found.
+ #
+ def read_user_info
+ user_info = nil
+
+ begin
+ user_plist_file = "#{USER_PLIST_DIRECTORY}/#{@new_resource.username}.plist"
+ user_plist_info = run_plutil("convert xml1 -o - #{user_plist_file}")
+ user_info = Plist::parse_xml(user_plist_info)
+ rescue Chef::Exceptions::PlutilCommandFailed
end
+
+ user_info
end
- def check_lock
- return @locked = locked?
+ #
+ # Saves the given hash keyed with DSCL properties specified
+ # in DSCL_PROPERTY_MAP to the disk.
+ #
+ def save_user_info(user_info)
+ user_plist_file = "#{USER_PLIST_DIRECTORY}/#{@new_resource.username}.plist"
+ Plist::Emit.save_plist(user_info, user_plist_file)
+ run_plutil("convert binary1 #{user_plist_file}")
end
- def lock_user
- safe_dscl("append /Users/#{@new_resource.username} AuthenticationAuthority ';DisabledUser;'")
+ #
+ # Sets a value in user information hash using Chef attributes as keys.
+ #
+ def dscl_set(user_hash, key, value)
+ raise "Unknown dscl key #{key}" unless DSCL_PROPERTY_MAP.keys.include?(key)
+ user_hash[DSCL_PROPERTY_MAP[key]] = [ value ]
+ user_hash
end
- def unlock_user
- auth_info = safe_dscl("read /Users/#{@new_resource.username} AuthenticationAuthority")
- auth_string = auth_info.gsub(/AuthenticationAuthority: /,"").gsub(/;DisabledUser;/,"").strip#.gsub!(/[; ]*$/,"")
- safe_dscl("create /Users/#{@new_resource.username} AuthenticationAuthority '#{auth_string}'")
+ #
+ # Gets a value from user information hash using Chef attributes as keys.
+ #
+ def dscl_get(user_hash, key)
+ raise "Unknown dscl key #{key}" unless DSCL_PROPERTY_MAP.keys.include?(key)
+ # DSCL values are set as arrays
+ value = user_hash[DSCL_PROPERTY_MAP[key]]
+ value.nil? ? value : value.first
end
- def validate_home_dir_specification!
- unless @new_resource.home =~ /^\//
- raise(Chef::Exceptions::InvalidHomeDirectory,"invalid path spec for User: '#{@new_resource.username}', home directory: '#{@new_resource.home}'")
- end
+ #
+ # System Helpets
+ #
+
+ def mac_osx_version
+ # This provider will only be invoked on node[:platform] == "mac_os_x"
+ # We do not check or assert that here.
+ node[:platform_version]
end
- def current_home_exists?
- ::File.exist?("#{@current_resource.home}")
+ def mac_osx_version_10_7?
+ mac_osx_version.start_with?("10.7.")
end
- def new_home_exists?
- ::File.exist?("#{@new_resource.home}")
+ def mac_osx_version_less_than_10_7?
+ versions = mac_osx_version.split(".")
+ # Make integer comparison in order not to report 10.10 less than 10.7
+ (versions[0].to_i <= 10 && versions[1].to_i < 7)
end
- def ditto_home
- skel = "/System/Library/User Template/English.lproj"
- raise(Chef::Exceptions::User,"can't find skel at: #{skel}") unless ::File.exists?(skel)
- shell_out! "ditto '#{skel}' '#{@new_resource.home}'"
- ::FileUtils.chown_R(@new_resource.username,@new_resource.gid.to_s,@new_resource.home)
+ def mac_osx_version_greater_than_10_7?
+ versions = mac_osx_version.split(".")
+ # Make integer comparison in order not to report 10.10 less than 10.7
+ (versions[0].to_i >= 10 && versions[1].to_i > 7)
end
- def move_home
- Chef::Log.debug("#{@new_resource} moving #{self} home from #{@current_resource.home} to #{@new_resource.home}")
+ def run_dscl(*args)
+ result = shell_out("dscl . -#{args.join(' ')}")
+ return "" if ( args.first =~ /^delete/ ) && ( result.exitstatus != 0 )
+ raise(Chef::Exceptions::DsclCommandFailed,"dscl error: #{result.inspect}") unless result.exitstatus == 0
+ raise(Chef::Exceptions::DsclCommandFailed,"dscl error: #{result.inspect}") if result.stdout =~ /No such key: /
+ result.stdout
+ end
- src = @current_resource.home
- FileUtils.mkdir_p(@new_resource.home)
- files = ::Dir.glob("#{src}/*", ::File::FNM_DOTMATCH) - ["#{src}/.","#{src}/.."]
- ::FileUtils.mv(files,@new_resource.home, :force => true)
- ::FileUtils.rmdir(src)
- ::FileUtils.chown_R(@new_resource.username,@new_resource.gid.to_s,@new_resource.home)
+ def run_plutil(*args)
+ result = shell_out("plutil -#{args.join(' ')}")
+ raise(Chef::Exceptions::PlutilCommandFailed,"plutil error: #{result.inspect}") unless result.exitstatus == 0
+ result.stdout
end
- def diverged?(parameter)
- parameter_updated?(parameter) && (not @new_resource.send(parameter).nil?)
+ def convert_binary_plist_to_xml(binary_plist_string)
+ Mixlib::ShellOut.new("plutil -convert xml1 -o - -", :input => binary_plist_string).run_command.stdout
end
- def parameter_updated?(parameter)
- not (@new_resource.send(parameter) == @current_resource.send(parameter))
+ def convert_to_binary(string)
+ string.unpack('a2'*(string.size/2)).collect { |i| i.hex.chr }.join
+ end
+
+ def salted_sha512?(string)
+ !!(string =~ /^[[:xdigit:]]{136}$/)
+ end
+
+ def salted_sha512_password_match?
+ # Salt is included in the first 4 bytes of shadow data
+ salt = @current_resource.password.slice(0,8)
+ shadow = OpenSSL::Digest::SHA512.hexdigest(convert_to_binary(salt) + @new_resource.password)
+ @current_resource.password == salt + shadow
end
+
+ def salted_sha512_pbkdf2?(string)
+ !!(string =~ /^[[:xdigit:]]{256}$/)
+ end
+
+ def salted_sha512_pbkdf2_password_match?
+ salt = convert_to_binary(@current_resource.salt)
+
+ OpenSSL::PKCS5::pbkdf2_hmac(
+ @new_resource.password,
+ salt,
+ @current_resource.iterations,
+ 128,
+ OpenSSL::Digest::SHA512.new
+ ).unpack('H*').first == @current_resource.password
+ end
+
end
end
end
diff --git a/lib/chef/resource/user.rb b/lib/chef/resource/user.rb
index 05c076229f..9d6e857de7 100644
--- a/lib/chef/resource/user.rb
+++ b/lib/chef/resource/user.rb
@@ -45,6 +45,8 @@ class Chef
:manage_home => false,
:non_unique => false
}
+ @iterations = 27855
+ @salt = nil
@allowed_actions.push(:create, :remove, :modify, :manage, :lock, :unlock)
end
@@ -106,6 +108,22 @@ class Chef
)
end
+ def salt(arg=nil)
+ set_or_return(
+ :salt,
+ arg,
+ :kind_of => [ String ]
+ )
+ end
+
+ def iterations(arg=nil)
+ set_or_return(
+ :iterations,
+ arg,
+ :kind_of => [ Integer ]
+ )
+ end
+
def system(arg=nil)
set_or_return(
:system,