We're running Windows 2000 Advanced Server with Service Pack 3 (SP3). Our corporate security policy mandates that we disable Active Directory (AD) user accounts after 31 days of inactivity. I want to write an Active Directory Service Interfaces (ADSI) script that reads the value of each user's lastLogon attribute and disables the user account if that value exceeds 31 days. However, I've been unsuccessful. Can you provide some guidance?
Disabling stale user accounts by using the lastLogon attribute's value to measure staleness sounds easy enough, but unfortunately, it's not. Here's why: AD doesn't replicate the lastLogon attribute, which represents the last date and time a user was authenticated by a specific domain controller (DC). As a result, the lastLogon value will be different on each DC. To accurately determine the last date and time a user logged on, you would have to retrieve the lastLogon value from every DC in the domain, determine which of those values is the most recent, and compare that value with your inactivity threshold.
Although using the lastLogon attribute in a script is technically possible, writing this script would be difficult, especially if you have a large multidomain AD forest. But instead of using the lastLogon attribute, you can use the pwdLastSet attribute. The pwdLastSet attribute contains the date and time the corresponding user changed his or her password. Because AD replicates the pwdLastSet attribute, you can use one query to retrieve and disable all AD accounts for those users who haven't changed their password since a specific date. Admittedly, using the pwdLastSet attribute solution has one small catch. You must enable the Maximum password age password policy setting in your domain. Enabling this setting is always a good idea. Based on your corporate security policy, I suspect this requirement isn't a problem.
Here's how the pwdLastSet-based solution works. Suppose you set your domain's Maximum password age to 42 days. You can safely assume any user account's pwdLastSet attribute will contain a date within 42 days of the current date plus any time away from the office for reasons such as holidays, vacations, or leaves of absence. Let's factor in 18 days to account for time away from the office. If you query for user accounts whose pwdLastSet attribute value is less than the current date minus 60 days (42 days maximum password age policy plus 18 days for time away from the office), you should wind up with users who haven't changed their passwords in 60 days. You can then disable those inactive user accounts.
The script DisableInactiveADUsers.vbs, which Listing 1 shows, puts this theory to the test. DisableInactiveADUsers.vbs demonstrates how to use the pwdLastSet attribute to determine whether a user account is inactive and, if it is, optionally disable that account. The script supports three modes of operation: batch, interactive, and display only. In batch mode, the script automatically disables inactive accounts. In interactive mode, the script asks whether you want to disable each inactive account. The display-only mode simply displays inactive accounts on the console. The script also supports an exclusion list to ensure that special-purpose accounts aren't inadvertently disabled. Let's walk through DisableInactiveADUsers.vbs to see how it works.
Like many Windows Script Host (WSH)/VBScript–based systems administration scripts, DisableInactiveADUsers.vbs starts by enabling Option Explicit and declaring all the script's variables. Next, the script defines the three constants (i.e., BATCH_MODE, INTERACTIVE_MODE, and DISPLAY_ONLY_MODE) that identify the script's three modes of operation. I discuss these constants shortly.
At callout A in Listing 1, the script uses the MAXIMUM_PASSWORD_AGE constant to set the script's inactive threshold. As the constant's name suggests, MAXIMUM_PASSWORD_AGE is the maximum password age in days. Users who haven't changed their passwords within the specified number of days are considered inactive, and their accounts are subject to being disabled. The value you assign to the MAXIMUM_PASSWORD_AGE constant must not be less than your domain's Maximum password age policy setting. Otherwise, you might disable active accounts.
At callout B, the script uses the BATCH_MODE, INTERACTIVE_MODE, or DISPLAY_ONLY_MODE constant to set the mode of operation. The script's default mode is DISPLAY_ONLY_MODE. Setting the intMode variable to INTERACTIVE_MODE or BATCH_MODE will change the script's behavior accordingly.
At callout C, the script creates a Dictionary object named objExclusions to hold the names of special-purpose user accounts that are excluded from the disable operation. If any of the user accounts stored in objExclusions are inactive, the script reports them as inactive, but you'll have to disable them manually.
To add a name to the exclusion list, you append a new objExclusions.Add statement to the existing list. Just be sure you use the user account's sAMAccountName, which is also known as the pre-Win2K user logon name. Also be sure to use all lowercase letters. For example, if I want to exclude my user account, I'd append the statement
objExclusions.Add "bobwells", ""
to the code that callout C shows.
The code at callout D calculates the date that represents the maximum password age threshold. The trick is expressing the cutoff date in the form of 100-nanosecond intervals that have passed since January 1, 1601. You must express the cutoff date in this format because, internally, pwdLastSet is defined as a FILETIME data structure. FILETIME is an 8-byte (64-bit) value representing the number of 100-nanosecond intervals (i.e., 0.0000001 or 10^-7) that have passed since 1/1/1601. So, to compare the cutoff date with the value stored in pwdLastSet, you must convert the date to the same numeric format that pwdLastSet uses.
Calculating the cutoff date in 100-nanosecond intervals is a three-step exercise. First, you determine the cutoff date by subtracting the number of days in MAXIMUM_PASSWORD_AGE from the current date, and you store the result in the dtmDate variable. Next, you use the VBScript DateDiff function to get the number of seconds that have elapsed between 1/1/1601 and the cutoff date stored in dtmDate. You multiply that number by 10,000,000 to convert the seconds to 100-nanosecond intervals. Finally, you use VBScript's FormatNumber function to format the resulting floating point number. You now have a value that you can compare against the value in pwdLastSet.
The code at callout E uses the RootDSE object to get the domain's distinguished name (DN) and stores the DN in the variable named strDomainNC. The code at callout F performs the initial steps necessary to query AD by using the ADSI OLE DB provider. For the sake of time and space, I'm not going to cover the basics of searching AD here. If you're unfamiliar with how to use ADSI to search AD, I encourage you to read the Searching topic in the "ADSI Scripting Primer" chapter of the Microsoft Windows 2000 Scripting Guide (http://www.microsoft.com/technet/scriptcenter/scrguide/sas_ads_gjur.asp).
At callout G, the script defines the Lightweight Directory Access Protocol (LDAP) query that will find the inactive user accounts. The string assigned to the strBaseDn variable represents the search starting point. In this case, the starting point is the root of the current domain. The string assigned to the strFilter variable is the LDAP query filter. Table 1 shows the English interpretation of that filter. The list of attributes that will be returned by objects that match the query are assigned to the variable named strAttributes. The strScope variable represents the scope of the search. The last line of code at callout G concatenates the four elements that make up the LDAP query string and assigns the result to the strLdapQuery variable.
The code at callout H assigns the strLdapQuery variable's value to the ADO Command object's CommandText property and uses the ADO Command object's Execute method to submit the query to AD. The query results are returned in the form of an ADO Recordset object, which the objAdoRecordSet variable references.
Now that you have a recordset that contains all users whose pwdLastSet attribute hasn't been updated in MAXIMUM_PASSWORD_AGE days, you can start disabling the inactive accounts or reporting the inactive accounts, in the case of the DISPLAY_ONLY_MODE. The While...Wend statement, which starts after callout H and continues to the end of the script, enumerates the inactive accounts in objAdoRecordSet. The script performs the following tasks on each record in the recordset in the body of the While loop:
- The code at callout I assigns the contents of the current record's fields to four working variables: strUserCN, strUserDN, strUserSamAccountName, and objPwdLastSet. The script initializes objPwdLastSet differently from the three string variables because pwdLastSet is a 64-bit large integer. ADSI handles 64-bit large integers as objects, so the script uses VBScript's Set keyword.
- The code at callout J converts the pwdLastSet attribute value from a 64-bit large integer to a Date data type.
- The script writes to the console the user's common name (CN), DN, and the date when the user's password was last set.
- At callout K, one of several possible actions takes place, depending on whether the user is in the excluded users list (i.e., in the objExclusions dictionary) and the script's mode of operation. First, the script checks the objExclusions dictionary. If the current user's sAMAccountName exists in the dictionary, the script writes an appropriate message to the console and the user is skipped. If the user isn't in the dictionary, one of three actions occurs, based on the script's operational mode (i.e., based on the value in intMode):
- If you left intMode set to DISPLAY_ONLY_MODE (i.e., a value of 3), the script takes no action.
- If you set intMode to BATCH_MODE (i.e., 1), the script automatically disables the user account and writes an appropriate message to the console.
- If you set intMode to INTERACTIVE_MODE (i.e., 2), you must confirm that you want to disable the account by responding to a prompt. Pressing an uppercase letter Y or a lowercase letter y disables the account. Any other response results in the account being skipped.
Provided that more records exist in the recordset, the script moves to the next record and repeats Steps 1 through 4. After the last record, the script ends.
To use DisableInactiveADUsers.vbs in your environment, you need to configure the MAXIMUM_PASSWORD_AGE constant, the intMode variable, and the objExclusions code in Listing 1 to your environment. You should run the script in DISPLAY_
ONLY_MODE for testing purposes. Using this mode is a good way to verify that you haven't set your MAXIMUM_PASSWORD_AGE constant improperly. You can launch the script with the command
C:\Scripts> cscript.exe DisableInactiveADUsers.vbs
(Although this command appears on two lines here, you would enter it on one line in the command-shell window.) You must use cscript.exe to run DisableInactiveADUsers.vbs because the script uses standard input and output (STDIO—i.e., STDIN and STDOUT).
As DisableInactiveADUsers.vbs demonstrates, finding and disabling inactive AD accounts isn't difficult in Win2K. However, Microsoft has made this task several orders of magnitude easier in Windows Server 2003. Microsoft introduced a new AD attribute named lastLogonTimeStamp, which AD replicates. The only catch is that all the DCs in the target domain must be running Windows 2003 at the domain functional level.