For various reasons, you might need to determine when a user last logged on to the domain. On occasion, my company's management has asked me to find this information, which isn’t available from the Microsoft Management Console (MMC) Active Directory Users and Computers snap-in. Recognizing this limitation, Microsoft created Acctinfo.dll to extend the Active Directory Users and Computers snap-in to display more Active Directory (AD) attributes. If you register Acctinfo.dll and have a Windows Server 2003 domain running in Windows 2003 forest functional mode, you can use the snap-in to view the lastLogonTimestamp attribute. However, the lastLogonTimestamp attribute isn't designed to provide 100 percent accuracy; it can be inaccurate by up to a week. Dissatisfied with this limitation, I wrote a script to get exact information. Before I describe the script and how it works, however, let me review how AD stores last logon information.
When you log on to an AD domain, the domain controller (DC) that authenticates your logon stores the date and time of your logon in the lastLogon attribute—but the lastLogon attribute isn't replicated between DCs. For Windows 2003 domains running in Windows 2003 forest functional mode, Microsoft introduced the lastLogonTimestamp attribute, which records an account’s most recent logon. However, to avoid high replication traffic, the attribute isn’t replicated if the last logon occurred less than a week previously.
In some cases (e.g., a security audit), you need exact last logon information. Rather than rely on the lastLogonTimestamp attribute, I wrote a JScript script, LastLogon.js, that reads the lastLogon attribute from each DC and reports the most recent logon. Because the script doesn't use the lastLogonTimestamp attribute, its results are more accurate. In addition, it doesn't require Windows 2003 forest functional mode, so you can use it in a Windows 2000 AD domain. And it tells you which server authenticated the logon.
LastLogon.js requires the CScript host. If CScript isn’t your default host, begin your command with the cscript keyword. To configure CScript as your default host (recommended), use the following command:
cscript //h:cscript //nologo //s
The command-line syntax for LastLogon.js is
\[cscript\] lastlogon.js username \[...\] | @filename \[/DN:domainDN\] \[/D\[:char\]\]
The username argument specifies the account name for which you want to display the last logon. You can specify multiple usernames, separated by spaces, or create a text file that contains a list of usernames. In the latter case, specify the @filename argument (the @ character is required), and LastLogon.js will read the list of users from the text file.
Each username corresponds to a user account's sAMAccountName AD attribute, which the Windows GUI sometimes labels the "Pre-Windows 2000 logon name." I chose this attribute because it uniquely identifies user accounts in a forest, it's shorter to type than a distinguished name (DN), and AD indexes it for fast lookups.
LastLogon.js assumes all users exist in the current computer’s domain. To specify a different domain, use the /DN parameter with the domain's DN as its argument (e.g., /DN:DC=wascorp,DC=net).
Usually, LastLogon.js displays its output in the format that Figure 1 shows, with blank lines separating each user. This format works for a small number of users but is difficult to import into other programs. To output in delimited format instead, specify the /D parameter. If you don't specify an argument for /D (i.e., if you use /D by itself), LastLogon.js delimits the fields by using tabs. Figure 2 shows how LastLogon.js outputs data with a comma separator (i.e., /D:,). You can redirect this data to a comma-separated value (CSV) file for later processing. For example, the command
cscript lastlogon.js @users.txt /d:, > lastlogons.csv
reads the users from the file users.txt, creates comma-delimited output, and saves the output in the file lastlogons.csv.
Getting a list of users. One simple way to get a list of users is to run the Dsquery User command. Make sure you use the -o samid option to tell Dsquery to output a list of sAMAccountName attributes. For example, the command
dsquery user -o samid -limit 0 | sort > users.txt
searches for all user accounts in the current domain, sorts the list, and saves it to the file users.txt. The -o samid parameter tells Dsquery to list each account's sAMAccountName attribute, and -limit 0 returns all accounts. (Without -limit 0, only the first 100 accounts are listed.)
Limiting your search to a single OU. You can also limit your search to a specified organizational unit (OU); to do so, type the OU's DN after the user parameter, as follows:
dsquery user OU=Sales,DC=wascorp,DC=net -o samid -limit 0 | sort > users.txt
Note that Dsquery uses double quotes (") in its output. I designed LastLogon.js to ignore them, so you don't have to edit your file to remove the quotes before using it with LastLogon.js.
LastLogon.js begins by declaring a set of global variables, then executes the WScript.Quit method with the main function as an argument (i.e., the main function's return value will be the script's exit code). Before I discuss the main function, I’ll describe the script's utility functions to clarify the context in which the main function uses them.
The filetoarray and colltoarray functions. LastLogon.js lets you provide usernames by typing the names on the command line or by specifying a file of usernames. Either way, it makes sense to store the usernames in an array. The filetoarray function uses a TextStream object to read each line of a text file and return it as an array; the colltoarray returns a collection as an array.
As the filetoarray function processes the text file, it ignores leading and trailing white spaces on each line and blank lines (to avoid returning an array that contains empty elements). It also ignores double quote (") characters on each line.
The getdomainDN function. Even though LastLogon.js has the /DN parameter that specifies the DN of the domain where the accounts reside, I avoid typing the domain name every time by having the script determine the current domain. As Listing 1 shows, the getdomainDN function accepts a single parameter, the DN of an object in the directory; it uses the ADSI Pathname object to "trim" this DN to the domain name only. If the /DN parameter doesn't exist on the command line, the main function passes the current computer's DN as the parameter to the getdomainDN function, and the getdomainDN function returns the DN of the computer's domain.
The PathDemo.js script in Listing 2 shows how the getdomainDN function works. First, PathDemo.js gets the DN for the current computer by retrieving the ComputerName property of the ADSystemInfo object (the main function in LastLogon.js does the same thing). It then creates a Pathname object and assigns the computer's DN to it. Next, it gets the parent portion of the computer's DN. If the resulting portion of the DN doesn't start with DC (i.e., the domain's name), it uses the RemoveLeafElement method to remove the next leaf element in the name and gets the resulting path. Figure 3 shows example output for PathDemo.js run with the CScript host.
The getDCs function. The getDCs function returns an array containing the names of a domain's DCs by getting a reference to the domain and retrieving the domain's masteredBy attribute. The masteredBy attribute is a VBArray (aka SafeArray) containing the DNs of the nTDSDSA objects for the DCs in the domain. The getDCs function converts the VBArray to a JScript array by using the toArray method and creates an empty array to hold the names of the DCs.
To get each DC’s name, the getDCs function uses a For loop to iterate the array of nTDSDA names. It uses the GetObject function twice: The "inner" GetObject call gets a reference to the nTDSDSA object's Parent attribute (the DC’s DN), and the "outer" GetObject call gets a reference to the DC object. The function then adds the DC’s common name (CN) attribute to the array and returns the array.
The getDN function. The getDN function uses ActiveX Data Objects (ADO) to search a named domain for a specified account. The domainDN parameter is the DN of the domain, and the account parameter is the sAMAccountName to find. (The main function creates the ADODB.Command object before calling the getDN function.) The getDN function configures the CommandText property of the ADODB.Command object to search for the account, then creates a Recordset object by calling the ADODB.Command object's Execute method. If the Recordset isn’t empty, the function returns the user's DN; otherwise, it returns an empty string.
The getlastlogon function. The getlastlogon function requires three parameters: A user account's DN, an array of DCs to search, and an object variable to store its results. The function iterates the array of DCs and connects to the specified user account on each DC. For example, if the first DC’s name is APOLLO and the user account's DN is CN=Joseph Bogus,OU=Sales,DC=wascorp,DC=net, the getlastlogon function uses the following LDAP syntax to bind to the account:
LDAP://APOLLO/CN=Joseph Bogus, OU=Sales,DC=wascorp,DC=net
The getlastlogon function binds to each DC separately because the LastLogin attribute isn’t replicated between DCs. Next, the function uses the LastLogin attribute to create a new Date object for the most recent logon on this specific DC. If this logon is more recent, the function updates the server and latestlogon variables.
Notice that the getlastlogon function uses LastLogin rather than lastLogon. It does so because the lastLogon attribute is stored as a 64-bit value representing 100-nanosecond intervals since January 1, 1601 Coordinated Universal Time (UTC). You can perform this conversion from script, but it’s simpler to use the LastLogin attribute. The LastLogin attribute returns a VBScript-compatible date value (aka VT_DATE value). JScript accepts a VT_DATE parameter for the Date object; it initializes the Date object with the date and time from the VT_DATE value.
After the getlastlogon function has iterated the array of DCs, it updates the properties of the result object with the most recent logon date and server name. If no logons were found, the latest logon date's value will be zero and the server name will be an empty string.
The output function. The output function uses the result object (created in the main function) that contains the data from the most recent call to the getlastlogon function. If the delim parameter is undefined, it uses WScript.Echo to output its results in the format that Figure 1 shows. If the delim parameter is defined, it creates delimited output (Figure 2 shows sample comma-delimited output).
The output function uses the getTime method of the result object's latestlogon property to decide what to display. If the getTime method returns zero, no last logon was found; the standard output format (see Figure 1) displays N/A for the date and server, and the delimited output format uses blank fields. Otherwise, the output function uses the formatdate function to display the date in yyyy/mm/dd hh:mm:ss format.
The main function. The main function, which Listing 3 shows, declares its own set of variables; it then proceeds to check whether the command line contains at least one unnamed argument (i.e., that doesn't start with a forward slash). If there are no unnamed arguments or if the /? argument is present, the main function executes the usage function, which displays a short usage message and ends the script.
Next, the main function uses the scripthost function to determine the script host executing the script. (See my article "Function Determines the Script Host on the Fly," InstantDoc ID 48246, for information about the scripthost function.) If the script isn't being executed by cscript.exe, the main function echoes an error message and exits from the script with the return statement.
The main function then gets the first unnamed command-line argument and checks its first character. If the first character is @, the function treats the remainder of the argument as a filename and uses the filetoarray function to read each line of the file into the users array. If the first unnamed argument doesn’t start with @, the main function uses the colltoarray function to read the WshArguments.Unnamed collection into the users array.
The main function then checks whether the /D parameter exists. If so, the function reads its parameter into the delim variable. If the delim variable is undefined or an empty string, the default delimiter is a tab.
Next, the main function checks for the /DN parameter. If the /DN parameter's argument is undefined or an empty string, the main function uses the getdomainDN function to get the DN of the current computer's domain.
The main function then uses the getDCs method to get an array of DCs for the specified domain. The function checks whether the delim variable is defined. If so, the function outputs a header line for the delimited output.
At this point, the main function has parsed the command line and is ready to search for last logons. To do so, it needs to search AD for a sAMAccountName to determine the corresponding DN. It uses ADO to perform the searches. The main function stores the ADODB.Connection and ADODB.Command objects in global variables to improve performance (the getDN function won’t have to create them every time it executes).
Next, the main function creates an empty object and puts it in the result variable. When populated, the result object will contain the user's DN (if found), the user's last logon date and time (a Date object), and the DC that authenticated the user. The main function then uses a For loop to iterate the users array.
Inside the For loop, the main function assigns initial values to the result object's properties and uses the getDN function to retrieve the user's DN. The getDN function searches AD for the specified sAMAccountName and returns its DN. If the query returns no results (e.g., the sAMAccountName doesn't exist), the result object's userDN property contains an empty string. If the result object's userDN property isn't an empty string, the main function calls the getlastlogon function to find the user's most recent logon. If the result object's userDN property is an empty string, the main function puts the string ? username into the userDN property to indicate that it couldn't find the user. Finally, the main function calls the output function to report its results to the screen.
The main function uses a series of try...catch blocks to gracefully handle errors. However, I also wanted LastLogon.js to present useful error messages —not just error codes. In each catch block, the main function uses the geterror function to return an error code’s associated message.
The geterror function works by using the hex function to get the hex value of the error number (as a string) and determine whether this string is greater than four characters long (i.e., 80000035). If it is, the geterror function copies only the four right-most characters from the string into an error number using the parseInt function. The function then creates a temporary file name and runs this command:
%ComSpec% /c %SystemRoot%\system32\net.exe helpmsg errorcode > tempfile
where errorcode is the numeric value of the error number and tempfile is the temporary file. The function then reads the temporary file as a string, closes the temporary file, and deletes it. If there's no description for that error code, the geterror function returns an empty string.
With LastLogon.js, you can easily get up-to-the-minute last logon information for any users in your domain.