The Surly Admin

Father, husband, IT Pro, cancer survivor

Getting Last Logon Information With PowerShell

Recently I had to write a report that got the last logon date for all of our users and I really ran into the LastLogonDate problem.  What problem is that, you might ask?  Well, it’s been documented a lot but the root of the problem is when a user logs into a domain account, their login time is recorded into the lastLogon field in Active Directory on the domain controller they authenticated against.  This field is stored as FileTime, which is the date and time as a 64-bit value in little-endian order representing the number of 100-nanosecond intervals elapsed since January 1, 1601 (UTC).  And just to make it a little bit more fun Active Directory does not replicate it.  So if someone logs in in India, and you query your Active Directory Domain Controller here in Massachusetts you will NOT get the updated information.  So now what?

lastLogon vs LastLogonDate

To solve this, Microsoft introduced the LastLogonDate (this is its PowerShell name, in Active Directory it’s the LastLogonTimeStamp) field in 2003.  Unfortunately for us, this field is NOT directly updated when the client logs in–that’s still going to our friend lastLogon–instead there’s an internal process on the domain controller that takes lastLogon, converts it to a DateTime object and puts it into LastLogonDate.  From there, Active Directory replicates the field and eventually all of your domain controllers will have the logon date.  Problem fixed, right?  No.  The problem is LastLogonDate replicates at an extraordinarily slow pace, this is because in Enterprises that make athena health look tiny you could have 10’s of thousands of people logging in (especially in the mornings) and AD replication would be gigantic.  So it’s on a very low priority replication schedule and even that is randomized.  Ultimately, what this means is this field could be behind by as many as 11 days!  Smaller organizations don’t see this and the field replicates in a pretty timely manner.  But at athena it does not.

This makes LastLogonDate pretty useless.  Even if I hit every domain controller and checked that date it could still be wrong if the process that updates it from the lastLogon field hasn’t run.  This is from observation, I might add.  When you read the documentation it makes no mention of this process, instead saying it updates the fields directly.  Now, it’s entirely possible I’m mis-interpreting what’s happening, but when I was testing I would log into a PC, check the server I logged in against using $env:LOGONSERVER and look at the LastLogonDate on that server and it did not reflect my current logon.  Now, in a lot of cases this potential 11 day delay may not matter, and if you’re in a smaller environment you won’t even see it, but for my report I want it to be perfect.  The solution is I had to hit every domain controller and look at the lastLogon field keeping only the most recent date.

Normally I’d set up a simple loop to go through every user, then hit every domain controller and look at the date keeping the oldest.  But imagine an organization with 4,700 users and 14 domain controllers.  That translates to a whopping 65,800 separate queries to Active Directory!  As the administrator in charge of AD I wouldn’t care for that on my servers, and I’m sure the Networking group wouldn’t be too keen on it either–we actually have a network group, and a desktop group, and “access control” which is really just Windows Admin’s, so glad I don’t have to be all those things anymore!  Anyway, how to solve this problem?

My solution was to query each domain controller once, and pull all of the user accounts in one swoop.


$DCs = Get-ADDomainController Filter * | Select ExpandProperty Name
$AllUsers = ForEach ($DC in $DCs)
{ Get-ADUser Filter * SearchBase "OU=People,CN=athena,CN=health" Server $DC
}

That means I can do just 14 calls, which while they are larger are still much more efficient than making 60,000 individual calls.   Problem now is I have an array called AllUsers with 65,800 users in it, with 14 duplicates for every user.  That means we have to correlate all this data into a single data set, and we want to make sure lastLogon is the newest date out there.  This is where a hashtable can really shine.  Loop through every record, check the hashtable to see if the user exists, if they don’t add them to the table.  If they do, check the lastLogon record of the current record versus the record in the hashtable and replace if it’s newer.


$Users = @{}
ForEach ($User in $AllUsers)
{ If ($Users.ContainsKey($User.SamAccountName))
{ If ($Users[$User.SamAccountName].lastLogon -lt $User.lastLogon)
{ $Users[$User.SamAccountName].lastLogon = $User.lastLogon
}
}
Else
{ $Users.Add($User.SamAccountName,($User | Select SamAccountName,Name,lastLogon))
}
}

Pretty simple idea here.  First I initialize the hashtable and then begin the loop.  I use the “ContainsKey” method on the hashtable to see if the current user in the loop has already been added.  If they have we check the lastLogon date to see if the current user is greater than the one we already have saved.  If it is, save it, if not ignore it and move on.  You may have also noticed that at no point have I converted lastLogon to a date/time object, I’m in fact still working with the FileTime.  This works since the date is a count, in 100-nanosecond intervals from 1601, so the bigger the number the higher the date.  We don’t actually need to know the date at this point.

If the key doesn’t exist then we use the Add method to add the user to hashtable.  But notice I don’t just add the $User object, instead I use Select.  This is pretty important, actually.  $User is still a specialized object that ties directly to the user object itself.  Modifying it is really hit or miss.  By using Select I change the object to a custom PowerShell object freeing me to make changes without worry of modifying the actual user object, or having to use specialized methods to make the changes.

When the loop is completed you will have a hashtable with a single set of users in it, all with the lastest lastLogon value.  Now we just need to report on it and walk away.  Since we’ve used objects and proper PowerShell techniques our options are wide open!  We could simply pipe to Format-Table and show on the screen, we could use Out-GridView or Export-CSV.  We could use the HTML techniques I’ve talked about before.  Since this report is going to a manager of our Access Control group I’m just going to put it in a CSV so she can look at it in Excel and do whatever she wants with it:


$Report = $Users.Values | Select Name,SamAccountName,@{Name="Last Logon Date";Expression={ If ($_.lastLogon) { [datetime]::FromFileTime($_.lastLogon) } Else { "None" }}}
$Report | Export-CSV C:\Scripts\LastLogonReport.csv NoTypeInformation

This could have been one line, but I broke it up to make it a little easier to read.  I just pipe $Users.Values (this dumps all the data in the hashtables, not the keys) to a Select and use a calculated field to convert lastLogon to a date/time object.  Since it’s possible to have a 0 in this field (meaning the user has never logged in) I have to check for that.  If I find a zero I just have the report say “None”, otherwise the converted date/time.

This Script, I do not think it is what you say it is….

By now, you may have noticed I never do anything simple.  It’s a curse.  I had a few advanced modifications to make.  First we need to speed up the initial “Get-ADUser” portion, so to do that I simply submitted each domain controller into a separate PowerShell job so it could be multi-threaded.  In testing I discovered the bottle neck was waiting for the domain controllers to return their data, not the PC running the jobs so I have a very high thread count here of 15.

I also added the ability to make HTML, CSV and object style reporting, plus some advanced parameters and so on.  Check out the final product here:

Last Logon Report

Scope Creep

This is about the point where the script changed–a bit of a surprise for my manager too!  Turns out they didn’t actually want a report, they wanted a script that would look at every user and disable anyone who hasn’t logged in in 30 days!  This is complicated by the fact that we have a option for employee’s who have been with the company for 8 years to go on a sabbatical which can last a certain amount of time (I don’t actually know the time since that’s a long way away for me).  So having an active employee gone for 30 days is actually quite possible.  Additionally we often allocate users long before their actual start date, potentially over 30 days in advance so that would have to be taken into account as well.  And to be honest, there’s no native way to solve this problem in Active Directory!   My next post will talk about how I solved this.

Advertisement

June 30, 2014 - Posted by | PowerShell | , ,

35 Comments »

  1. […] Last Logon Time, Description, Organization and Manager name.  Last Logon Time is interesting, here’s why, but we’re just going to bypass this and go with the baked in field, it should be accurate […]

    Pingback by Exporting User Information « The Surly Admin | July 14, 2014 | Reply

  2. I am not real familiar with PS but I did see how to change the age parameter but when I run it I get the results in the PS screen, it is not outputting to HTML. How do I set that parameter? Thanks for your help.

    Comment by Larry Dempsey | October 14, 2014 | Reply

    • Hi Larry, you would just use -Age 13 when calling the script (13 being the # of days you want the report to go back). To get the HTML report are you using the -HTML parameter?

      Comment by Martin9700 | October 15, 2014 | Reply

      • This is what I have:

        CmdletBinding(DefaultParameterSetName=”obj”)]
        Param (
        [Parameter(ParameterSetName=”html”)]
        [string]$SearchBase,

        [Parameter(ParameterSetName=”html”)]
        [int]$Age = 30,

        [Parameter(ParameterSetName=”html”)]
        [switch]$HTML,

        [Parameter(ParameterSetName=”html”)]
        [string]$Path,

        [Parameter(ParameterSetName=”html”)]
        [string]$MaxThreads = 15
        )

        It does seem to show only logons older than 30 days but it does not create the HTML file

        Comment by Larry Dempsey | October 15, 2014

      • Are you specifying -HTML when you run the script? No need to edit the script btw, it’s designed to just use the parameter.

        Comment by Martin9700 | November 2, 2014

  3. Hi Martin. Great script. Thanks for sharing. For me, I do want your script to disable those users that have not logged in for x number of days. Could you help me out with the best way of achieving this. Many thanks.

    Mark.

    Comment by Mark | December 9, 2014 | Reply

    • Well, in the end the hashtable $Users has all of the old users in it. To enumerate the values you can just use $Users.Values, so setup a ForEach loop on that, then use Set-ADUser $User -Enabled $false

      Comment by Martin9700 | December 13, 2014 | Reply

  4. I am using adsysnet ad manager, its easy to use

    Comment by Rama | March 30, 2015 | Reply

  5. Hi, thank you for this script, it helped me a lot. Could you help me how to add the “Description” field to the report please ? I tried changing line 211 to: Get-ADUser @Params | Select Surname,GivenName,Name,description,distinguishedName,LastLogon,SamAccountName,@{Name=”DC”;Expression={$DC}},Enabled,PwdLastSet

    and 247 to include ‘description’ as well:
    Select @{Name=”LastName”;Expression={$_.Surname}},@{Name=”FirstName”;Expression={$_.GivenName}},@{Name=”DisplayName”;Expression={$_.Name}},@{Name=”Description”;Expression={$_.Description}},SamAccountName,Enabled,@{Name=”SetChangePassword”;Expression={($_.PwdLastSet -eq 0)}},@{Name=”LastLogonDate”;Expression={If ($LastLogon -eq [datetime]”12/31/1600 7:00:00 pm”) { $null } Else { $LastLogon }}}))

    But in my report, the column “description” is added but still empty 😦

    Comment by Jeroen | May 6, 2015 | Reply

  6. $LLogin = (net statistics server | find /i “Statistics since”) -split ‘\s+’
    Then you can call the date via $LLogin[2]

    This reads the local logon, not the domain logon. Sometime that is all you want..

    Comment by MacTwistie | May 28, 2015 | Reply

  7. This script will not work if your organization has disabled Active Directory Web Services, correct?

    Comment by Jim | September 17, 2015 | Reply

  8. am I right to assume that if a users LastlogonDate reads “01/01/1601 00:00:00” then they have never logged in? AS im not getting any “None” but a good few of that date and time….Cheers

    Comment by Chris | October 28, 2015 | Reply

  9. Is there anyway to change the output from
    LastName : Admin
    FirstName : Z
    DisplayName : Z Admin
    SamAccountName : Z.Admin
    Enabled : True
    SetChangePassword : False
    LastLogonDate : 12/10/2015 9:17:41 AM

    to
    Z; Admin; Z Admin; Z.Admin; TRUE; False,12/10/2015 9:17:41 AM

    This format would be much easier for me to work with. If possible, thanks in advance.

    Comment by Randy Grififths | December 10, 2015 | Reply

    • Randy, the output is actually object form, so you can pipe it into your own scripts, pipe it into Export-CSV, SORT, heck, you could assign it to a variable and go from there. You can also use the -CSV switch to output a CSV file, or -HTML to output an HTML file. Check out the help for all the options.

      Comment by Martin9700 | December 10, 2015 | Reply

  10. Randy – Your script is awesome! Is there any way possible to get each DC authentication date returned, instead of just the most recent?

    Comment by sgtmoodyman | December 15, 2015 | Reply

    • You know, I had a little to do with it too 😉

      Comment by Martin9700 | December 15, 2015 | Reply

  11. Very Good explanation, it helped me a lot, Thanks

    Comment by Pankaj | January 6, 2016 | Reply

  12. Hi! Quick question that’s cmpletely ooff topic. Do you know how to make your site mobile friendly?
    My website looks weird when browsing from my iphone.
    I’m trying to find a theme or plugin that might be able to fix this problem.
    If you have any recommendations, please share.

    Thanks!

    Comment by Lionel | March 20, 2016 | Reply

    • Sorry, no. I just use WordPress.com and the templates provided.

      Comment by Martin9700 | March 21, 2016 | Reply

  13. Pretty! This was an extremely wonderful article. Thank youu for providing this info.

    Comment by Glenda | March 25, 2016 | Reply

  14. All the dates come back as “none” for me. I check manually in the field and the correct dates are there…why am I getting “none” as a return value?

    Comment by Aaron Leininger | April 4, 2016 | Reply

  15. My last comment/request for help hasn’t seemed to make it on here yet…needless to say, I solved my problem. I had to add -properties lastLogon to the get-ADUser command at the end or else I would get a “none” for the date every time. I hope this helps someone…

    Comment by Aaron Leininger | April 4, 2016 | Reply

  16. […] Getting Last Logon Information With PowerShell « … – Getting Last Logon Information With PowerShell. Recently I had to write a report that got the last logon date for all of our users and I really ran into the … […]

    Pingback by Powershell Command For User Last Logon | Liyongbak | May 28, 2016 | Reply

  17. Thanks for the nice script, what’ve made the difference and made it require Powershell version 3. Which part will be lack of if we running on version 2.0.

    Comment by Ho | June 24, 2016 | Reply

    • Ho, try removing the #requires -version 3.0 line right after the help. In a 10 second glance through of the code I didn’t see anything 3.0 centric so there’s a good chance it’ll work. If not you’ll have to get one of your boxes upgraded to at LEAST 3.0, if not 5.

      Comment by Martin9700 | June 24, 2016 | Reply

  18. I have a couple of questions: First, I have 9 different User OUs I need to run this script against. They look similar to these:

    OU=Users,OU=GeographicLocation,DC=Our,DC=Domain,DC=com

    OU=Admins,OU=GeographicalLocation,DC=Our,DC=Domain,DC=com

    I’ve tried several different methods of calling them (Individually and using Wildcards) but every time I receive an error message than none of my four domain controllers can be contacted. Would you be able to shed some light on this?

    Second question: After running the initial script I noticed the output provides both Enabled accounts and Disabled accounts. Is there any way to specify only query the enabled accounts ?

    Comment by David Phillips | July 17, 2018 | Reply

    • David you’ll want to you use the get-aduser -enabled $true

      Comment by Chris Armitage | July 17, 2018 | Reply

      • Thank you so much for your quick reply! I really appreciate that and I appreciate your script as well. Regarding Enabled accounts – Damn me! I was on the fence for trying that but got distracted by the challenge of querying the multiple OUs .

        Comment by David Phillips | July 17, 2018

    • for the OUs I would tend to put them as variables so for examaple

      $DCs = Get-ADDomainController -Filter * | Select -ExpandProperty Name
      $AllUsers = ForEach ($DC in $DCs)
      {
      $usersOU = OU=Users,OU=GeographicLocation,DC=Our,DC=Domain,DC=com
      $AdminOU = OU=Admins,OU=GeographicalLocation,DC=Our,DC=Domain,DC=com

      Get-ADUser -enabled $true -Filter * -SearchBase “$UsersOU” -Server $DC
      Get-ADUser -enabled $true -Filter * -SearchBase “$AdminOU” -Server $DC
      }

      I think something like that would work…but I’m still new to all this haha

      but ive used this script and it works well as not my creation

      Comment by Chris Armitage | July 17, 2018 | Reply

    • Hi Chris! thanks for your suggestion. I’m familiar with what you provided and I’ve even used something similar in other scripts that I was hoping would do what this script does. I’m just not sure if adding it to the script will work (Mostly due to my very elementary knowledge of PowreShell). I’m still struggling to get the script to work with the addition of the -enabled $true suggestion from Martin. No matter how I insert that, It breaks the script (But I’m sure I’ll get it sorted).

      Comment by David Phillips | July 17, 2018 | Reply

  19. How would you add the domain controller to the output report?

    Comment by drpepper2110 | October 9, 2018 | Reply

  20. Silly question here but where do you get to pick what type of output you want and where does the output go? I see a $filepath variable but where is it defined ?

    Comment by Gino | October 26, 2018 | Reply

  21. Hello Martin, thanks for taking the time to put this together and all of the other work you do for the community. During my search, I saw this pop-up by you and stopped looking anywhere else.

    I run the script and it completes successfully. I modified the Params, removing Filter = “*” and added -Filter {enabled -eq $true} to the Get-ADUser line.

    In the returned data, there are a lot of accounts that have todays date (the day I ran the script) listed as the LastLogonDate, for example 2/3/2021 11:05. Any idea as to why this would occur?

    Also, there are a bunch that show 12/31/1600 4:00:00 PM. What does this indicate?

    Comment by Brian Whitaker | February 3, 2021 | Reply


Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: