Automatically Disable Inactive Users in Active Directory

While Microsoft provides the ability to set an expiration date on an Active Directory user account, there’s no built-in facility in Group Policy or Active Directory to automatically disable a user who hasn’t logged in in a defined period of time. This is surprising since many companies have such a policy and some information security standards such as PCI require it.

There are software products on the market that provide this functionality, but for my homelab, my goal is do this on the cheap. Well, not the cheap so much as the free. After reading up on the subject, I found that this is not quite as straightforward as it may seem. For instance, Active Directory doesn’t actually provide very good tools out of the box for determining when a user last logged on.

The Elusive Time Stamp

Active Directory actually provides three different timestamps for determining when a user last logged on, and none of them are awesome. Here are the three available AD attributes:

lastLogon – This provides a time stamp of the user’s last logon, with the caveat that it is not a replicated attribute. Each domain controller retains its own version of this attribute with the last timestamp that the user logged onto that particular domain controller. This means that any script that uses this attribute will need to pull the attribute from every domain controller in the domain and then use the most recent of those timestamps to determine that actual last logon.

lastLogonTimeStamp – This is a replicated version of the lastLogon timestamp. However, it is not replicated immediately. To reduce domain replication traffic, the replication frequency depends on a domain attribute called msDS-LogonTimeSyncInterval. The value of lastLogonTimeStamp is replicated based on a random time interval of up to five days before the msDS-LogonTimeSyncInterval. By default the msDS-LogonTimeSyncInterval attribute is unset, which makes it default to 14 days. Therefore, by default, lastLogonTimeStamp is replicated somewhere between 9 and 14 days after the previous replicated value. Needless to say, this is not useful for our purposes. In addition, this attribute is stored in a 64-bit signed numeric value that must be converted to a proper date/time to be useful in Powershell.

lastLogonDate – There are a lot of blogs that will state that this is not a replicated timestamp. Technically that’s true; it’s a copy of lastLogonTimeStamp that the domain controller has converted to a standard date/time for you. Otherwise, it’s the same as lastLogonTimeStamp and has the same 9-to-14-day replication delay.

Another caveat for all three of these attributes is that the timestamp is updated for many logon operations, including interactive and network logons and those passed from another service such as RADIUS or another Kerberos realm. Also, there’s a Kerberos operation called “Service-for-User-to-Self” (“S4u2Self”) which allows services to request Kerberos tickets for a user to perform group and access checks for that user without supplying the user’s credentials. For more information, see this blog post.

Caveats aside, this is what we have to work with. For the purposes of my relatively small domain, I’m comfortable with increasing the replication frequency of lastLogonTimeStamp. Be aware before using this in production that it will increase replication traffic, especially during periods when many users are logging in simultaneously; domain controllers will be replicating this attribute daily instead of every 9 to 14 days.

As none of these caveats applied in my homelab, I launched Active Directory Users and Computers. Under “View”, I selected “Advanced Features” to expose the attributes I needed to view or change. I then right-clicked my domain and selected “Properties.” The msDS-LogonTimeSyncInterval was “not set” as expected, so I changed it to “1” to ensure that the timestamp was replicated daily for all users.

I then created the below Powershell script in a directory.

# disableUsers.ps1  
# Set msDS-LogonTimeSyncInterval (days) to a sane number.  By
# default lastLogonDate only replicates between DCs every 9-14 
# days unless this attribute is set to a shorter interval.

# Also, make sure to create the EventLog source before running, or
# comment out the Write-EventLog lines if no event logging is
# needed.  Only needed once on each machine running this script.
# New-EventLog -LogName Application -Source "DisableUsers.ps1"

# Remove "-WhatIf"s before putting into production.

Import-Module ActiveDirectory

$inactiveDays = 90
$neverLoggedInDays = 90
$disableDaysInactive=(Get-Date).AddDays(-($inactiveDays))
$disableDaysNeverLoggedIn=(Get-Date).AddDays(-($neverLoggedInDays))

# Identify and disable users who have not logged in in x days

$disableUsers1 = Get-ADUser -SearchBase "OU=Users,OU=Demo Accounts,DC=lab,DC=clev,DC=work" -Filter {Enabled -eq $TRUE} -Properties lastLogonDate, whenCreated, distinguishedName | Where-Object {($_.lastLogonDate -lt $disableDaysInactive) -and ($_.lastLogonDate -ne $NULL)}

 $disableUsers1 | ForEach-Object {
   Disable-ADAccount $_ -WhatIf
   Write-EventLog -Source "DisableUsers.ps1" -EventId 9090 -LogName Application -Message "Attempted to disable user $_ because the last login was more than $inactiveDays ago."
   }

# Identify and disable users who were created x days ago and never logged in.

$disableUsers2 = Get-ADUser -SearchBase "OU=Users,OU=Demo Accounts,DC=lab,DC=clev,DC=work" -Filter {Enabled -eq $TRUE} -Properties lastLogonDate, whenCreated, distinguishedName | Where-Object {($_.whenCreated -lt $disableDaysNeverLoggedIn) -and (-not ($_.lastLogonDate -ne $NULL))}

$disableUsers2 | ForEach-Object {
   Disable-ADAccount $_ -WhatIf
   Write-EventLog -Source "DisableUsers.ps1" -EventId 9091 -LogName Application -Message "Attempted to disable user $_ because user has never logged in and $neverLoggedInDays days have passed."
   }

You may notice two blocks of similar code. The lastLogonDate is null for newly created accounts that have never logged in. Rather than have them all handled in a single block, I created two separate handlers for accounts that have logged in and those that haven’t. This might be useful for some organizations that want to disable inactive accounts after 90 days but disable accounts that have never logged in after only 14 or 30 days.

Note also that I have included event logging in this script. This is completely optional, but if you are bringing Windows logs into a SIEM like Splunk, it’s useful to have custom scripts logging into the Windows logs so that the security team can track and act on these events. To log Windows events from a Powershell script, you need to register your script. This is a simple one-time command on each machine running the script. Here’s the command I used to register my script:

New-EventLog -LogName Application -Source "DisableUsers.ps1"

This gives my script the ability to write events into the Application log, and the source will show as “DisableUsers.ps1”. The LogName can be used to log events to a different standard Windows log, or to even create a completely separate log. I also created two event IDs, 9090 and 9091, to log the two event types from my script. I did a quick Google search to make sure that these weren’t already used by Windows, but duplicate IDs are fine from different sources.

This script by default has a “-WhatIf” switch on each Disable-ADAccount command. This is so the script can be tested against the domain to make sure it’s behaving in an expected manner. During the run, the script will display the accounts it would have disabled, and the event log will generate an event for each disablement regardless of the WhatIf switch. Once thoroughly tested, the “-WhatIf”s can be removed to make the script active. The SearchBase should also be changed to an appropriate OU in your domain. Note that this script does not discriminate; any users in its path are subject to being disabled, including service accounts and the Administrator account if they have not logged in during the activity period.

Finally, it’s time to put the script into play. It’s as easy as creating a daily repeating task that launches powershell.exe with the arguments “-ExecutionPolicy Bypass c:\path\to\DisableUsers.ps1” If this is run on a domain controller, it can be run as the NT AUTHORITY\System user so that no credentials need to be stored or updated. I ran this in my homelab test domain and viewed the event log. Sure enough, several of my test accounts were disabled. Note that I used the weasel words “attempted to” in the log; this is because I don’t actually do any checking to verify that the account was disabled successfully.

I did see one blog post where the author added a command to update the description field of an account so that administrators could see at a glance that it was auto-disabled. I didn’t do that here, but if you wanted to add that, here’s a sample command you can add to the script:

Set-ADUser -Description ("Account Auto-Disabled by DisableUser.ps1 on $(get-date)")

Hopefully this will help others to work around a glaring oversight by Microsoft. Please drop me a comment if you have any suggestions for improvement; I’m not a Powershell coder by trade and I’m always looking for tips.

Building and Populating Active Directory for my Homelab

There are a multitude of Active Directory how-to blog posts and videos out there. This is my experience putting together a quick and dirty Active Directory server, populating it with OUs and users, and getting users randomly logged in each day. I’m going to skip the standard steps for installing AD as it’s well documented in other blogs, and it’s the populating of AD that’s more interesting to me.

Why put in all this work you ask? I’m doing some experimentation in AD, and rather than blow up a production environment, I want a working and populated AD at home. I also need users to “log in” randomly so I can use the login activity to write some scripts. So, here’s how I did it.

Step 1 was to actually install Active Directory. Again, there are numerous blogs about how to do this. Since I was running Server 2016 in evaluation mode, I wasn’t going to do a lot of customizing since it was just going to expire eventually anyway. I had already configured 2016 for DNS and DHCP for my lab network, so installing AD was as easy as adding the role and management utilities from the Server Manager. Utilizing the “Next” button repeatedly, I created a new forest and domain for the lab.

The next step was to populate the directory. For this, I went with the “CreateDemoUsers” script from the Microsoft TechNet Gallery. I downloaded the PowerShell script and accompanying CSV and executed the script. In minutes, I had a complete company including multiple OUs filled with over a thousand user accounts and groups. Each user had a complete profile, including title, location and manager.

With my sample Active Directory populated, my next goal was to record random logins for these users. I needed this initially for some scripts I was writing, but my eventual goal was to generate regular activity to bring into Splunk. For my simulated login activity, I wrote a script to pick a user at random and execute a dummy command as that user so that a login was recorded on their user record. I began by modifying the “Default Domain Controller policy” in Group Policy Management. I added “Domain Users” to “Allow log on locally.” (This is found in Computer Configuration -> Windows Settings -> Security Settings -> Local Policies -> User Rights Assignment.) This step isn’t necessary if the script is running from a domain member, but since I didn’t have any computers added to the domain, this would allow me to run the script directly on the domain controller.

Here is the script that I created. I put it in c:\temp so it could be run by any user account.

# Login.ps1

$aduser = Get-ADUser -Filter * | Select-Object -Property UserPrincipalName | Sort-Object{Get-Random} | Select -First 1
$username = $aduser.UserPrincipalName
$password = ConvertTo-SecureString 'Password1' -AsPlainText -Force

$credential = New-Object System.Management.Automation.PSCredential $username, $password
Start-Process hostname.exe -Credential $credential

I wanted my user base as a whole to log in on average every 90 days, which meant that I wanted about 11 users per day to log in. Easy enough, I created a scheduled task to run at 12:00am every day and repeat every 2 hours. That was close enough for my purposes.

Since I was running this on a lone DC in a lab, I ran it as NT AUTHORITY\SYSTEM so I didn’t have to mess with passwords. The task runs powershell.exe with the arguments -ExecutionPolicy Bypass c:\temp\login.ps1.

After saving the task, I right-clicked and clicked “Run.” Because I chose hostname.exe as the execution target, the program opened and immediately closed. While this briefly flashed on my screen when testing, nothing appears on the screen when running this as a scheduled task. A quick look through the event log confirmed that user “LEClinton” logged in, and a look at Lisa Clinton’s AD record shows that she did indeed log in.

While my eventual goal is to script all of the installation and configuration, this was enough to get the other work I needed to do done, as well as provide a semi-active AD environment for further testing and development.