├── LICENSE.md ├── README.md ├── edu.psu.macoslaps-check.plist ├── edu.psu.macoslaps.plist └── macOSLAPS /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Joshua D. Miller - The Pennsylvania State University 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | macOS LAPS (Local Administrator Password Solution) 2 | ================================================== 3 | Python script that utilizes Open Directory to determine if the 4 | local administrator password has expired as specified by the Active Directory 5 | attribute dsAttrTypeNative:ms-Mcs-AdmPwdExpirationTime. If this is the case 6 | then a new randomly generated password will be set for the local admin account 7 | and a new expiration date will be set. The LAPS password is stored in the 8 | Active Directory attribute dsAttrTypeNative:ms-Mcs-AdmPwd. This attribute can 9 | only be read by those designated to view the attribute. The computer record 10 | can write to this attribute but it cannot read. 11 | 12 | Requirements 13 | ------------ 14 | 15 | The following parameters must be set or the application will use the defaults: 16 | 17 | **LocalAdminAccount** - Local Administrator Account. Default is 'admin' 18 | **DaysTillExpiration** - Expiration date of random password. Default is 60 Days 19 | **PasswordLength** - Length of randomly generated password. Default is 12 20 | **RemoveKeyChain** - Remove the local admin keychains after password change. (Recommended) 21 | **RemovePassChars** - Exclude any characters you'd like from the randomly generated password (In String format) 22 | 23 | These parameters are set in the location /Libary/Preferences/edu.psu.macoslaps.plist 24 | or you can use your MDM's Custom Settings to set these values. 25 | 26 | Exclusions 27 | ---------------- 28 | As pointed out by one of my fellow colleagues, the **'** key on macOS cannot be used on Windows without opening 29 | the character map to enter it. Since this is very detriment to using a LAPS password from a Windows client I have made this key excluded by default. 30 | 31 | Installation Instructions 32 | ------------------------- 33 | At this time you can clone the repo or download a zip of the repo or you can 34 | use the package created using Packages to install. This script will run 35 | 3 times a day between 8 A.M. and 5 P.M. at 9 A.M., 1 P.M. and 4 P.M. 36 | 37 | Logging 38 | ------- 39 | The script will also perform logging so that you know when the password is changed 40 | and its new expiration date or when the current unchanged password will expire. This 41 | file is stored in /Library/Logs/ as macOSLAPS.log 42 | 43 | Feedback 44 | -------- 45 | All feedback I have received is positive so far but please continue testing this in your environment and let me know the results. 46 | 47 | Local Admin Keychain 48 | -------- 49 | By default, the local admin you choose has its keychain deleted since we wouldn't know the randomized password. 50 | 51 | Credits 52 | -------------- 53 | * Rusty Myers - For helping to determine that Windows has its own time method vs 54 | Epoch time 55 | * Michael Lynn - For critiquing and assisting with generating the random password 56 | * Ben Toms - For sharing code from ADPassMon to query Active Directory with Open 57 | Directory 58 | * Tom Burgin - For showing me the benefits and how to use Open Directory with Python 59 | * Per Olofsson - For also assisting with Open Directory commands 60 | * Clayton Burlison - For keeping my lack of markdown skills in check 61 | -------------------------------------------------------------------------------- /edu.psu.macoslaps-check.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | edu.psu.macoslaps-check 7 | ProgramArguments 8 | 9 | /usr/local/laps/macOSLAPS 10 | 11 | StartCalendarInterval 12 | 13 | 14 | Minute 15 | 0 16 | Hour 17 | 9 18 | 19 | 20 | Minute 21 | 0 22 | Hour 23 | 13 24 | 25 | 26 | Minute 27 | 0 28 | Hour 29 | 16 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /edu.psu.macoslaps.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DaysTillExpiration 6 | 30 7 | LocalAdminAccount 8 | admin 9 | PasswordLength 10 | 14 11 | RemovePassChars 12 | {}[]| 13 | 14 | 15 | -------------------------------------------------------------------------------- /macOSLAPS: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | '''LAPS for macOS devices''' 3 | # pylint: disable=C0103, E0611, W0703 4 | # ############################################################ 5 | # This python script will set a randomly generated password for your 6 | # local adminsitrator account on macOS if the expiration date has passed 7 | # in your Active Directory. Mimics behavior of LAPS 8 | # (Local Administrator Password Solution) for Windows 9 | ############################################################# 10 | # Active Directory Attributes Modified: 11 | # dsAttrTypeNative:ms-Mcs-AdmPwd - Where Password is stored 12 | # dsAttrTypeNative:ms-Mcs-AdmPwdself.expirationTime - Expiration Time 13 | # ############################################################# 14 | # Joshua D. Miller - josh@psu.edu - The Pennsylvania State University 15 | # Script was Last Updated June 2, 2017 16 | # ############################################################# 17 | 18 | from Foundation import CFPreferencesCopyAppValue 19 | from datetime import datetime, timedelta 20 | from logging import (basicConfig as log_config, 21 | error as log_error, info as log_info) 22 | from OpenDirectory import (ODSession, ODNode, 23 | kODRecordTypeComputers, kODRecordTypeUsers) 24 | from os import path 25 | from random import choice 26 | from shutil import rmtree 27 | from string import ascii_letters, punctuation, digits 28 | from SystemConfiguration import (SCDynamicStoreCreate, 29 | SCDynamicStoreCopyValue) 30 | from time import mktime 31 | from unicodedata import normalize 32 | 33 | 34 | class macOSLAPS(object): 35 | '''main class of application''' 36 | # Current time 37 | now = datetime.now() 38 | # Preference Variables 39 | bundleid = 'edu.psu.macoslaps' 40 | defaultpreferences = { 41 | 'LocalAdminAccount': 'admin', 42 | 'PasswordLength': 12, 43 | 'DaysTillExpiration': 60, 44 | 'RemoveKeyChain': True, 45 | 'RemovePassChars': '\'' 46 | } 47 | # Define Active Directory Attributes 48 | adpath = '' 49 | computerpath = '' 50 | expirationtime = '' 51 | lapsattributes = dict() 52 | computer_record = None 53 | # Setup Logging 54 | log_format = '%(asctime)s|%(levelname)s:%(message)s' 55 | log_config(filename='/Library/Logs/macOSLAPS.log', 56 | level=10, format=log_format) 57 | 58 | def get_config_settings(self, preference_key): 59 | '''Function to retrieve configuration settings from 60 | /Library/Preferences or /Library/Managed Preferences''' 61 | preference_file = self.bundleid 62 | preference_value = CFPreferencesCopyAppValue(preference_key, 63 | preference_file) 64 | if preference_value is None: 65 | preference_value = self.defaultpreferences.get(preference_key) 66 | if isinstance(preference_value, unicode): 67 | preference_value = normalize( 68 | 'NFKD', preference_value).encode('ascii', 'ignore') 69 | return preference_value 70 | 71 | def connect_to_ad(self): 72 | '''Function to connect and pull information from Active Directory 73 | some code borrowed from AD PassMon - Thanks @macmuleblog''' 74 | # Active Directory Connection and Extraction of Data 75 | try: 76 | # Create Net Config 77 | net_config = SCDynamicStoreCreate(None, "net", None, None) 78 | # Get Active Directory Info 79 | ad_info = dict( 80 | SCDynamicStoreCopyValue( 81 | net_config, 'com.apple.opendirectoryd.ActiveDirectory')) 82 | # Create Active Directory Path 83 | self.adpath = '{0:}/{1:}'.format(ad_info['NodeName'], 84 | ad_info['DomainNameDns']) 85 | # Computer Path 86 | self.computerpath = 'Computers/{0:}'.format( 87 | ad_info['TrustAccount']) 88 | # Use Open Directory To Connect to Active Directory 89 | node, error = ODNode.nodeWithSession_name_error_( 90 | ODSession.defaultSession(), self.adpath, None) 91 | # Grab the Computer Record 92 | self.computer_record, error = node.\ 93 | recordWithRecordType_name_attributes_error_( 94 | kODRecordTypeComputers, ad_info 95 | ['TrustAccount'], None, None) 96 | # Convert to Readable Values 97 | values, error = self.computer_record.\ 98 | recordDetailsForAttributes_error_(None, None) 99 | # LAPS Attributes 100 | self.lapsattributes[0] = 'dsAttrTypeNative:ms-Mcs-AdmPwd' 101 | self.lapsattributes[1] = '{0:}'.format( 102 | 'dsAttrTypeNative:ms-Mcs-AdmPwdExpirationTime') 103 | # Get Expiration Time of Password 104 | try: 105 | self.expirationtime = values[self.lapsattributes[1]] 106 | except Exception: 107 | log_info('There has never been a random password generated' 108 | ' for this device. Setting a default expiration' 109 | ' date of 01/01/2001 in Active Directory to' 110 | ' force a password change...') 111 | self.expirationtime = '126227988000000000' 112 | except Exception as error: 113 | log_error(error) 114 | exit(1) 115 | 116 | @staticmethod 117 | def make_random_password(length): 118 | '''Generate a Random Password 119 | Thanks Mike Lynn - @frogor''' 120 | # Characters used for random password 121 | characters = ascii_letters + punctuation + digits 122 | remove_pass_characters = macOSLAPS().get_config_settings( 123 | 'RemovePassChars') 124 | # Remove Characters if specified 125 | if remove_pass_characters: 126 | characters = characters.translate(None, remove_pass_characters) 127 | password = [] 128 | for i in range(length): 129 | password.insert(i, choice(characters)) 130 | return ''.join(password) 131 | 132 | def windows_epoch_time_converter(self, time_type, expires): 133 | '''Convert from Epoch to Windows or from Windows 134 | to Epoch - Thanks Rusty Myers for determining Windows vs. 135 | Epoch Time @rustymyers''' 136 | if time_type == 'epoch': 137 | # Convert Windows Time to Epoch Time 138 | format_expiration_time = int( 139 | self.expirationtime[0]) / 10000000 - 11644473600 140 | format_expiration_time = datetime.fromtimestamp( 141 | format_expiration_time) 142 | return format_expiration_time 143 | elif time_type == 'windows': 144 | # Convert the time back from Time Stamp to Epoch to Windows 145 | # and add 30 days onto the time 146 | new_expiration_time = (self.now + timedelta(days=expires)) 147 | formatted_new_expiration_time = new_expiration_time 148 | new_expiration_time = new_expiration_time.timetuple() 149 | new_expiration_time = mktime(new_expiration_time) 150 | new_expiration_time = ((new_expiration_time + 11644473600) * 151 | 10000000) 152 | return (new_expiration_time, formatted_new_expiration_time) 153 | 154 | def password_check(self): 155 | '''Perform a password check and change the local 156 | admin password and write it to Active Directory if 157 | needed - Thanks to Tom Burgin and Ben Toms 158 | @tomjburgin, @macmuleblog''' 159 | local_admin = LAPS.get_config_settings('LocalAdminAccount') 160 | exp_days = LAPS.get_config_settings('DaysTillExpiration') 161 | pass_length = LAPS.get_config_settings('PasswordLength') 162 | keychain_remove = LAPS.get_config_settings('RemoveKeyChain') 163 | password = LAPS.make_random_password(pass_length) 164 | formatted_expiration_time = LAPS.windows_epoch_time_converter( 165 | 'epoch', exp_days) 166 | # Determine if the password expired and then change it 167 | if formatted_expiration_time < self.now: 168 | # Log that the password change is being started 169 | log_info('Password change required.' 170 | ' Performing password change...') 171 | try: 172 | # Set new random password in Active Directory 173 | self.computer_record.setValue_forAttribute_error_( 174 | password, self.lapsattributes[0], None) 175 | # Change the local admin password 176 | log_info('Setting random password for local' 177 | ' admin account %s...', local_admin) 178 | # Connect to Local Node 179 | local_node, error = ODNode.nodeWithSession_name_error_( 180 | ODSession.defaultSession(), '/Local/Default', None) 181 | # Pull Local Administrator Record 182 | local_admin_change, error = local_node.\ 183 | recordWithRecordType_name_attributes_error_( 184 | kODRecordTypeUsers, local_admin, None, None) 185 | # Change the password for the account 186 | local_admin_change.changePassword_toPassword_error_( 187 | None, password, None) 188 | # Convert Time to Windows Time to prepare 189 | # for new expiration time to be written to AD 190 | new_expires = dict() 191 | new_expires[0], new_expires[1] = LAPS.\ 192 | windows_epoch_time_converter('windows', exp_days) 193 | # Set the Expiration Time in AD 194 | self.computer_record.setValue_forAttribute_error_( 195 | str(int(new_expires[0])), self.lapsattributes[1], None) 196 | log_info('Password change has been completed. ' 197 | 'New expiration date is %s', 198 | new_expires[1]) 199 | if keychain_remove is True: 200 | local_admin_path = '/Users/{0:}/Library/Keychains'.\ 201 | format(local_admin) 202 | if path.exists(local_admin_path): 203 | rmtree(local_admin_path) 204 | log_info('Removed keychains for local ' 205 | 'administrator account {0:}.' 206 | .format(local_admin)) 207 | else: 208 | log_info('The keychain directory for ' 209 | '{0:} does not exist. Keychain ' 210 | 'removal not required...'.format(local_admin)) 211 | else: 212 | log_info('Keychain has NOT been modified. Keep ' 213 | 'in mind that this may cause keychain ' 214 | 'prompts and the old password may not ' 215 | 'be accessible.') 216 | except Exception as error: 217 | log_error(error) 218 | exit(1) 219 | else: 220 | # Log that a password change is not necessary at this time 221 | log_info('Password change not necessary at this time as' 222 | ' the expiration date is %s', formatted_expiration_time) 223 | exit(0) 224 | 225 | 226 | LAPS = macOSLAPS() 227 | LAPS.connect_to_ad() 228 | LAPS.password_check() 229 | --------------------------------------------------------------------------------