Use PowerShell to Find and Remove Inactive Active Directory Users

Use PowerShell to Find and Remove Inactive Active Directory Users

  • Comments 15
  • Likes

Summary: Guest blogger, Ken McFerron, discusses how to use Windows PowerShell to find and to disable or remove inactive Active Directory users.

Microsoft Scripting Guy, Ed Wilson, is here. One of the highlights of our trip to Canada, was—well, there were lots of highlights—but one of the highlights was coming through Pittsburgh and having dinner with Ken and his wife. When the Scripting Wife and I first met Ken in person (at the Windows PowerShell deep dive in Vegas), we were impressed with Ken's knowledge and enthusiasm (although the Scripting Wife already knew Ken from the PowerScripting Podcast chat room this was the first time I had met him). We later had a chance to see him at Atlanta TechStravaganza 2011. He is the founder of the Pittsburgh PowerShell Users group (I am speaking in person at their first meeting on December 13, 2011), and he is extremely passionate about Windows PowerShell. Here is what Ken has to say about himself.

My name is Ken McFerron. I currently work as a senior system administrator, and I focus on Active Directory. I have been in the IT field since 1999, and I started using VBScript and Batch scripting shortly after. I have always enjoyed trying to automate as much as I can with my scripts. I was introduced to Windows PowerShell around 2008, and I have been trying to learn as much as I can about it since then. I use Windows PowerShell on a daily basis now, and I dread going back to troubleshoot or update old VBScript scripts—these usually end up getting converted to Windows PowerShell. I have been working on getting a Windows PowerShell users group started in the Pittsburgh area. On December 13, we will be having our first meeting. I cannot wait to get the group started and start sharing and learning more about Windows PowerShell with others in the area.

One big problem for companies that do not utilize an identity management system (such as Forefront Identiy Manager 2010) is stale user accounts. I have seen companies that have thousands of accounts for users who have not logged into the domain in years, or at all. With Windows PowerShell and the Microsoft Active Directory (AD) module, the task of identifying and deleting these accounts is an easy one.

First we need to determine what we need to look for. Beginning with Active Directory in Windows Server 2003, there is an attribute called LastLogonTimeStamp, which is replicated between domain controllers every 9 to 14 days. The AD module also displays this attribute in an easy-to-read format called LastLogonDate. There are some instances when this attribute is not updated, so I also like to look at PasswordLastSet.

So the first step is to query AD to find all the enabled accounts that have the attributes LastLogonTimeStamp and PasswordLastSet that are over 90 days old. Any users that have not logged on will not have a value for LastLogonDate. One way to do this is to use the Get-ADUser cmdlet, and then pipe the results to Where-Object to do the filtering as follows:

get-aduser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -filter * -Properties lastlogondate, passwordlastset | Where-Object {($_.lastlogondate -le $90days -or $_.lastlogondate -notlike "*")-AND ($_.passwordlastset -le $90days) -AND ($_.Enabled -eq $True)} | Select-Object name, lastlogondate, passwordlastset

Doing it this way will work, but it is not the most efficient. By running Measure-Command on my virtual machine, you can see how long this took to complete for about 10,000 users.

A better way to filter the users would be to remove the pipe to Where-Object, and use the following filter:

$90Days = (get-date).adddays(-90)

Get-ADUser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -filter {(lastlogondate -notlike "*" -OR lastlogondate -le $90days) -AND (passwordlastset -le $90days) -AND (enabled -eq $True)} -Properties lastlogondate, passwordlastset | Select-Object name, lastlogondate, passwordlastset

If we run Measure-Command again, we can see that the time has really decreased.

Now that we have a list of all the user accounts, we need to determine what to do with them. I like to disable the accounts first before I delete them. If you find that one of these accounts is needed, it is much easier to enable the account than to restore it. Some administrators like to move all of these user accounts to a separate OU, and disable all the accounts for X number of days before they delete them. This will work most of the time. But I do not like doing it because you can run into some issues. For example, you could run into people who have the same name. You cannot have identical distinguished names in AD, so if you try to move one, you will get and error message like this:

So I like to leave the accounts in place and update an attribute with the date that they were disabled. To keep it simple, I will use the Description attribute. When we determine how long to keep these accounts disabled, we can read this attribute and then delete any accounts that have been disabled for X number of days. To update the description attribute we would use the Set-ADUser cmdlet as follows:

Get-ADUser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -filter {lastlogondate -le $90days -AND passwordlastset -le $90days} -Properties lastlogondate, passwordlastset | set-aduser -Description (get-date).toshortdatestring())

This will update the description, but not disable the account. So we need to disable the account as well. We can use the PassThru switch to update the description and disable each account.

Get-ADUser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -filter {lastlogondate -le $90days -AND passwordlastset -le $90days} -Properties lastlogondate, passwordlastset | set-aduser -Description ((get-date).toshortdatestring()) –passthru | Disable-ADAccount

Now that we have all the accounts disabled, we need to delete them. We can use the Remove-ADObject cmdlet to delete the account, and then use Get-ADUser to read the Description attribute. To compare the date that the account was disabled to the current date, we can use Where-Object, as shown here:

$14days = (get-date).adddays(-14)

Get-Aduser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -Filter {enabled -eq $False} -properties description | Where { (get-date $_.Description) -le $14Days} | remove-adobject

Be very careful with this. The command that I have provided will prompt for every user before deleting the accounts. To get a list, you can use WhatIf, or if you do not want to get prompted, you can use Confirm:$False, as shown here:

Get-Aduser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -Filter {enabled -eq $False} -properties description | Where { (get-date $_.Description) -le $14Days} | remove-adobject –whatif

Get-Aduser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -Filter {enabled -eq $False} -properties description | Where { (get-date $_.Description) -le $14Days} | remove-adobject –confirm:$False

In summary, we opened this post with a couple one liners that can disable accounts for users who have not logged on or changed their passwords in the last 90 days. We just created a couple of additional one liners to delete disabled accounts after 14 days. Now we can put everything together into a single script. I added a bit of code to handle common error conditions and to log accounts that are deleted and disabled, but the essential script is the four one liners that we examined earlier. Here is the complete script:

#import the ActiveDirectory Module

Import-Module ActiveDirectory

#Create a variable for the date stamp in the log file

$LogDate = get-date -f yyyyMMddhhmm

#Sets the OU to do the base search for all user accounts, change for your env.

$SearchBase = "OU=User_Accounts,DC=DEVLAB,DC=LOCAL"

#Create an empty array for the log file

$LogArray = @()

#Sets the number of days to delete user accounts based on value in description field

$Disabledage = (get-date).adddays(-14)

#Sets the number of days to disable user accounts based on lastlogontimestamp and pwdlastset.

$PasswordAge = (Get-Date).adddays(-90)

#RegEx pattern to verify date format in user description field.

$RegEx = '^(0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])[- /.](20)\d\d$'

#Use ForEach to loop through all users with description date older than date set. Deletes the accounts and adds to log array.

ForEach ($DeletedUser in (Get-Aduser -searchbase $SearchBase -Filter {enabled -eq $False} -properties description ) ){

  #Verifies description field is in the correct date format by matching the regular expression from above to prevent errors with other disbaled users.

  If ($DeletedUser.Description -match $Regex){

    #Compares date in the description field to the DisabledAge set.

    If((get-date $DeletedUser.Description) -le $Disabledage){

      #Deletes the user object. This will prompt for each user. To suppress the prompt add "-confirm:$False". To log only add "-whatif".

      Remove-ADObject $DeletedUser

        #Create new object for logging

        $obj = New-Object PSObject

        $obj | Add-Member -MemberType NoteProperty -Name "Name" -Value $DeletedUser.name

        $obj | Add-Member -MemberType NoteProperty -Name "samAccountName" -Value $DeletedUser.samaccountname

        $obj | Add-Member -MemberType NoteProperty -Name "DistinguishedName" -Value $DeletedUser.DistinguishedName

        $obj | Add-Member -MemberType NoteProperty -Name "Status" -Value 'Deleted'

        #Adds object to the log array

        $LogArray += $obj

    }

  }

}

#Use ForEach to loop through all users with pwdlastset and lastlogontimestamp greater than date set. Also added users with no lastlogon date set. Disables the accounts and adds to log array.

ForEach ($DisabledUser in (Get-ADUser -searchbase $SearchBase -filter {((lastlogondate -notlike "*") -OR (lastlogondate -le $Passwordage)) -AND (passwordlastset -le $Passwordage) -AND (enabled -eq $True)} )) {

  #Sets the user objects description attribute to a date stamp. Example "11/13/2011"

  set-aduser $DisabledUser -Description ((get-date).toshortdatestring())

  #Disabled user object. To log only add "-whatif"

  Disable-ADAccount $DisabledUser

    #Create new object for logging

    $obj = New-Object PSObject

    $obj | Add-Member -MemberType NoteProperty -Name "Name" -Value $DisabledUser.name

    $obj | Add-Member -MemberType NoteProperty -Name "samAccountName" -Value $DisabledUser.samaccountname

    $obj | Add-Member -MemberType NoteProperty -Name "DistinguishedName" -Value $DisabledUser.DistinguishedName

    $obj | Add-Member -MemberType NoteProperty -Name "Status" -Value 'Disabled'

    #Adds object to the log array

    $LogArray += $obj

}

#Exports log array to CSV file in the temp directory with a date and time stamp in the file name.

$logArray | Export-Csv "C:\Temp\User_Report_$logDate.csv" -NoTypeInformation

Guest Blogger Week will continue tomorrow when Josh Gavant will talk about using SharePoint Web Services with Windows PowerShell to query for search results.

Thank you, Ken, and see you in a couple of weeks for the Pittsburgh PowerShell Users Group meeting.

I invite you to follow me on Twitter and Facebook. If you have any questions, send email to me at scripter@microsoft.com, or post your questions on the Official Scripting Guys Forum. See you tomorrow. Until then, peace.

Ed Wilson, Microsoft Scripting Guy

Your comment has been posted.   Close
Thank you, your comment requires moderation so it may take a while to appear.   Close
Leave a Comment
  • Hi Ken,

    this is an interesting, reasonable and careful approach to solve the problem of inactive users!

    I like the idea to use the description attribute and store the disabling date in it even if this attribute might contain some useful information that have to be restored in case of renabling the account later on.

    ( An alternative approach might be: Read the Information attribute and prefix it with the 10 characters of the short DateTime string --- it could be removed easily later on, if the account should be enabled again.

    But even this doen't work if the resulting length would be longer as the max. length of the attribute )

    And one other little thing to mention is that the test with  $regexp to verify that a date is comtained in the description field doesn't work in germany!

    PS C:\Users\Schulte> [string]$information=(get-date).toshortdatestring()

    PS C:\Users\Schulte> $RegEx = '^(0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])[- /.](20)\d\d$'

    PS C:\Users\Schulte> $information -match $regex

    False

    PS C:\Users\Schulte> $information

    30.11.2011

    You know ... the german date is usually in the form: dd.mm.yyyy :-)

    And your test does fail an the first character because the regexp group expect to find a '0' or '1' in first position!

    Well, we can and maybe we should change that ( because the regex is not really suited to exactly define all valid dates ) a little bit.

    You can introduce a new variable of type DateTime and try to parse the first 10 character of the information attribute as that type!

    PS C:\Users\Schulte> [DateTime]$date= get-date

    PS C:\Users\Schulte> [DateTime]::TryParse($information.substring(0,10), [ref]$date)

    True

    PS C:\Users\Schulte> $date

    Mittwoch, 30. November 2011 00:00:00

    This should work well for all other cultures, too!

    Klaus.

  • You might also consider moving accounts when your script disables them to a specific OU using the Move-ADObject cmdlet.  This way the helpdesk, security or some other team that reviews stale accounts can look in one spot.  You're also welcome to graft on some bits from a script I wrote a while back that gathers up the disabled/deleted accounts in arrays, then builds them into an HTML email to be sent to the relevant parties.  I'm trying to make a summary email a staple of any automation scripts I write...

    gallery.technet.microsoft.com/.../Disabled-AD-Account-8cc92a7d

  • Klaus

    Thanks for your reply. I used the description attribute for simplicity in the blog post. I agree there could be some useful information in there. The best way would be to extend the schema with a custom attribute or use a attribute that is not in use. I liked to use the exchange extension attributes before but now with the Odata restrictions on Exchange I stay away from using those.  

    You bring up a good point about the date format. I am not use to having people in other countries reading anything I post and did not think about the format.  I like your solution better and will keep that in mind for any future date formats I use.

    Thanks again for you response to this post and all the other ones you respond to. The information you provide has been very helpful in learning more about powershell.

    Ken

  • @Nfields03

    As I mentioned in my post I do not like moving disabled users to a Single OU.  While this may work for most small companies you could run into problems with users with the same name. I posted a screenshot of a sample error message you would get using the move-adobject cmdlet with duplicate names. Thanks for posting a link to your script. I always like looking at other scripts to get ideas.

  • Hey Ed, I am new to Windows Power Shell and this script does almost exactly of what I was looking for, however, I need to change the code sothat the script will check the user accounts in multiple OUs and move the accounts that have not been logged in to in X days to a disabled users accounts OU (that already exists), and then check that OU foraccounts that have been disabled for more than X days and delete them. Do you have any suggestions?

    Thanks,

    Tom

  • We use netwrix inactive users tracker for this. It’s a freeware tool that automatically detects and reports on all user accounts that have been inactive for a specified number of days. They also offer it as part of their identity management suite.

  • Hi Ken,

           This is a great script. I'm using it in my environment. The only problem I tend to run into is that it also pulls recently created account, accounts that have not been logged into yet (accounts created within 7 days). How would I exclude ad accounts that have been created within the past 7 days? I've looked at putting a filter in for the account creation date. but Haven't been able to get it to work. I'm fairly new to powershell so all the help you could give me would be greatly appreciated.  

    Best Regards,

    John Collura

  • @John, thanks for the reply. You are right i had a mistake in the filter and it will pull newly created accounts. you can try this

    Get-ADUser -SearchBase "OU=User_Accounts,DC=DEVLAB,DC=LOCAL" -filter {(lastlogondate -notlike "*" -OR lastlogondate -le $90days) -AND (passwordlastset -le $90days) -AND (enabled -eq $True) -AND (whencreated -le $90days)}

  • Hi, good script.

    But between disabling and later on deleting,  it will be nice to remove all groups from the user before the deletion so if the data owners are reviewing group memberships than the disabled users are no longer and we have not orphans members.  What will be the code to strip out all access groups (except Domain Users) ?

    Thanks

  • Great post

    One question. Need to have the lastlogon Date in the log. Have been trying to get it to work but so far I seam to be missing something. Anyone know what I would need to add to do this.

    Thanks for the help

    Gary

  • Hey Ken.  Thanks for the article but I am having awful trouble getting any of it to work.  I tried running the first code example and it just returns with nothing.  No list is actually generated and the command completes immediately with no error.  I then tried the second code example and get the exact same thing.  No error and no list is generated.  Hell, I apparently can't even use the code example listed under Search-ADAccount for finding users who haven't logged in for 90 days as it returns every single user account in my entire active directory, including mine.  It appears I can either automatically disable every account in the domain (including mine) or I can get no users at all.  Do you know what could be happening?  I am using Server 2008r2.

  • The set-aduser -description command does not allow piped imput generating the following error

    Set-ADUser : The input object cannot be bound to any parameters for the command

    either because the command does not take pipeline input or the input and its p

    roperties do not match any of the parameters that take pipeline input.

    Your script accounts for this by useing a fore loop to set the description, but if you could remove the part of the command that does not work, that would be nice, or at least note that it does not work.

    Chris

  • Has anybody by chance update the script to reflect multiple DC's in a domain?

    dmitrysotnikov.wordpress.com/.../finding-the-latest-logon-time

    It looks like you could get arguably "fresher" data if you leveraged the lastLogon attribute and pick the latest value when querying each domain controller.  This would of course increase the run time.

  • Great script! I'd like to add a column to the log file that includes the lastLogonTimestamp but the output is coming out blank (see below). Any ideas why? The value definitely exists in AD for these disabled users. I have a hunch that it has something to do with the actual value (e.g. - 130100816639918715) vs. the displayed value (e.g. - 4/10/2013 11:34:23 AM Eastern Standard Time). The syntax is categorized as "Large Integer/Interval." $obj | Add-Member -MemberType NoteProperty -Name "lastLogonTimestamp" -Value $DisabledUser.lastLogonTimestamp

  • Have look here too,http://www.adsysnet.com/asn-active-directory-inactive-account-tracker-features.aspx Nice tool.