Active Directory and lastlogontimestamp

The Problem
I was tasked with retrieving all of the enabled Machines accounts from Active Directory and the date/time they last logged in. After googling some articles on the topic, I found some reasonable solutions, but ran into a couple issues on the topic so I thought I’d document it here for others.

To start off, here is the one article you want to read to introduce the topic and give some background:
Dandelions, VCR Clocks, and Last Logon Times: These are a Few of Our Least Favorite Things

Most of the references online show how to retrieve the lastlogontimestamp using the DirectoryEntry object, as illustrated in the next snippet of code I pulled from one post on a forum showing how to properly retrieve the lastlogontimestamp value:
DirectoryEntry user =
new DirectoryEntry("LDAP://" + strDN);
if (user.Properties.Contains("lastlogontimestamp"))
{
// lastlogontimestamp is a IADsLargeInteger
IADsLargeInteger li = (IADsLargeInteger)
user.Properties[
"lastlogontimestamp"][0];
long lastlogonts =
(
long)li.HighPart << 32 | (uint)li.LowPart;
user.Close();
return DateTime.FromFileTime(lastlogonts);
}

What the post failed to do was explain what any of this meant.
The lastlogontimestamp is stored in Active Directory as object that implements the IADsLargeInteger (an ActiveDS object). Ideally, AD would return a long (Int64) instead of the IADsLargeInteger object, eliminating the need to reference COM (ActiveDS is where this interface is defined), as well as allowing use to work with a .NET primitive.

Instead we have to use the IADsLargeInteger interface to extract the HighPart, stored as an Int32, shift it left 32 and cast it to a long (Int64) and then OR that with the LowPart, another Int32. If you’re wondering what the bit shifting and OR ‘’ code is doing – it essentially converts the IADsLargeInteger into a long (Int64).

Now let’s say you are using the DirectorySearcher object instead of the DirectoryEntry object to find the object in Active Directory you need. If you try to use similar code as provided in the sample above, you will get an InvalidCastException when casting the lastlogontimestamp to the IADsLargeInteger. For some reasone, when using the DirectorySearcher object, the lastlogontimestamp object is returned as a long (Int64) instead of an IADsLargeInteger. I found this behavior to be a bit schizophrenic.

Here’s an example of what I’m talking about:
SearchResultCollection resultCollection = null;
DirectoryEntry entry
=
new DirectoryEntry("LDAP://DC=BLAH,DC=COM/");
DirectorySearcher searcher
=
new DirectorySearcher(entry);
searcher.SearchScope
= SearchScope.Subtree;

// Filter out only Enabled Computer Accounts
searcher.Filter =
"(&(objectCategory=computer)(name=*)(!" +
"userAccountControl:1.2.840.113556.1.4.803:=2))";

resultCollection
= searcher.FindAll();

foreach (SearchResult result in resultCollection)
{
// Huh, this time it’s a long (Int64)!
long lastlogontimestamp =
(
long)result.Properties["lastlogontimestamp"][0];
DateTime dtLastLoginTimeStamp
=
DateTime.FromFileTime(lastlogontimestamp);
...
}

A Poorly Named Method
The next thing you may be wondering is why I show an examples that use the DateTime.FromFileTime() method. What does a file time have to do with Active Directory and the lastlogontimestamp?

Well, I’m guessing whoever wrote the .NET DateTime object must have thought something like this – “a time expressed in a 100 nanosecond units starting from January 1, 1601 is how file times are stored. I’ll write a method on DateTime called FromFileTime(long nanotime)” or maybe “AH crap, I’ve got this file timestamp as a long and I need an easy way to convert it to a DateTime object without repeating the same code over and over again. I’ll just add a method to DateTime that takes a long and returns the corresponding DateTime.”

This method name, while accurate, is a limited expression of what is expressed as a 100 nanosecond unit starting from 1/1/1601- namely ANY time that is expressed as an Int64. Active Directory uses this same 100 nanosecond unit to store its timestamps and I’m better there are other things that do as well.

What IS nice about the FromFileTime() method is that it returns a DateTime using the current Time Zone, so we don’t have to convert it.

There are actually a couple ways to convert the Int64 100 nano second representation to the DateTime object in .NET and they are as follows:

1. One of the TimeSpan object constructor overloads takes a period expressed in 100 nanosecond units which is exactly what AD is returning, if we create a DateTime object that starts at 1/1/1601, and then add a TimeSpan created with our Int64 value, we should get the corresponding UTC time expressed in a DateTime object. Finally we need to convert the UTC time to local time. Here is the code to illustrate this:


long lastlogontimestamp =
(
long)result.Properties["lastlogontimestamp"][0];
// Create a date object starting at 1/1/1601
DateTime dt = new DateTime(1601, 1, 1, 0, 0, 0);
// Convert it to local time
DateTime dtLastLoginTimeStamp =
dt.Add(
new TimeSpan(lastlogontimestamp)
).ToLocalTime();

2. More simply, we can just use the DateTime.FromFileTime() and this will accomplish what the code above does, though with less code.
Here’s some sample code:

long lastlogontimestamp =
(
long)result.Properties["lastlogontimestamp"][0];
DateTime dtLastLoginTimeStamp
=
DateTime.FromFileTime(lastlogontimestamp);
With all of that said, the solution I came up with to report on the lastlogontimestamp of the machine accounts in AD is pretty straight forward:
SearchResultCollection resultCollection = null;
DirectoryEntry entry
=
new DirectoryEntry("LDAP://DC=BLAH,DC=COM/");
DirectorySearcher searcher
= new DirectorySearcher(entry);
searcher.SearchScope
= SearchScope.Subtree;

// Filter out only Enabled Computer Accounts
searcher.Filter = "(&(objectCategory=computer)(name=*)" +
"(!userAccountControl:1.2.840.113556.1.4.803:=2))";

searcher.PropertiesToLoad.Add(
"name");
searcher.PropertiesToLoad.Add(
"description");
searcher.PropertiesToLoad.Add(
"operatingSystem");
searcher.PropertiesToLoad.Add(
"operatingSystemVersion");
searcher.PropertiesToLoad.Add(
"lastlogontimestamp");

try
{
resultCollection
= searcher.FindAll();

foreach (SearchResult result in resultCollection)
{
//...
long lastlogontimestamp =
(
long)result.Properties["lastlogontimestamp"][0];
DateTime dtLastLoginTimeStamp
=
DateTime.FromFileTime(lastlogontimestamp);
string answer =
dtLastLoginTimeStamp.ToShortDateString()
+
" " +
dtLastLoginTimeStamp.ToShortTimeString();
//...
}
}
finally
{
resultCollection.Dispose();
searcher.Dispose();
entry.Close();
}
One Last Problem:
It turns out, the value of lastlogontimestamp is replicated from the DC's to the GC every 14 days. This ends up presenting a problem if you want real-time data. If you really want the latest and greatest value logon time, the solution is as follows: Connect to EACH domain controller and check the lastlogon value instead. I wrote code to do this and found that on a LARGE domain with over 4,000 computers the time it takes to run this report takes a loooooong time to run. If anyone has any suggestions on how to practically do this efficiently, please let me know.

Further reading:
Decimal Time - Computers – a reference describing how Computers store times in Decimal.

16 comments:

  1. Monty,

    Nice information. I was able to rapidly diagnose an active directoy problem using your:

    dictionaryEntry.Properties.Containts["lastLogontimestamp"][0];
    as opposed to
    object lastTime = dictionaryEntry.Properties["lastLogonTimestamp"].Value;

    The value object was not propery being intialized.

    excellent work :)

    ReplyDelete
  2. How exactly would you query each DC to get the lastlogin? We only have two DCs and 100 computers so this shouldn't be too taxing.

    ReplyDelete
  3. Here's one way:

    1. Query AD for all users you're interested.

    2. Loop over the Search Results and for each user, query each DC for their most current data using the Distinguished Name field.

    3. Prefix the Distinguished Name with the DC Server dns name like so:

    E.g.
    _directoryEntry1 = new DirectoryEntry("LDAP://DC1.BLAH.COM/CN=Monty\, J,OU=Users,OU=IT,DC=BLAH,DC=COM");
    _directoryEntry2 = new DirectoryEntry("LDAP://DC2.BLAH.COM/CN=Monty\, J,OU=Users,OU=IT,DC=BLAH,DC=COM");

    4. Then extract the lastlogon time stamp for each directory entry:

    e.g.
    _entryDc1.Properties["lastlogontimestamp"]
    _entryDc2.Properties["lastlogontimestamp"]

    5. Compare the results to see which value is more current and use the most recent timestamp.

    ReplyDelete
  4. Thanks a lot! I can't believe I couldn't get such an easy thing before.

    Great blog, keep it up.

    ReplyDelete
  5. I found a (somewhat, still has to query every DC, but is compact function) better way of doing this.

    DirectoryContext context = new DirectoryContext(DirectoryContextType.Domain, LDAP_PATH_TO_DC);

    DateTime latestLogon = DateTime.MinValue;
    DomainControllerCollection dcc = DomainController.FindAll(context);

    foreach (DomainController dc in dcc)
    {
    DirectorySearcher ds = dc.GetDirectorySearcher();

    ds.Filter = "(sAMAccountName=" + USERNAME + ")";
    ds.PropertiesToLoad.Add("lastlogon");
    ds.SizeLimit = 1;

    SearchResult sr = ds.FindOne();
    ds.Dispose();

    if (sr != null)
    {
    if (sr.Properties["lastLogon"].Count > 0)
    {
    DateTime lastLogon = DateTime.FromFileTime((long)sr.Properties["lastLogon"][0]);
    if (lastLogon > latestLogon)
    latestLogon = lastLogon;
    }
    }
    }
    }
    }

    Not incredible. But a pretty neat way of doing it.

    ReplyDelete
  6. Question! We only have one domain controller and it is only pulling the initial login timestamp. Why would we have to wait 14 days if there is no replication occurring?

    ReplyDelete
  7. > Question! We only have one
    > domain controller and it
    > is only pulling the initial
    > login timestamp. Why would we
    > have to wait 14 days if there
    > is no replication occurring?

    Ahh, interesting. I hadn't considered just one DC, though I suspect it's fairly common. I suspect (I don't know for sure) that the value should be pretty up-to-date then...if you're able to verify this, let me know!

    ReplyDelete
  8. If I read the articles correctly, LastLoginTimestamp replicates to other domain controllers roughly very 14 days. The GCs don't seems to come into play.

    Also, if your domain isn't too large, you could decrease the replication time within your domain. I doubt you'd want ti replicated in too real-time, though.

    ReplyDelete
  9. I found a way to speed up the collecting of the lastlogged on time, indeed you need to collect all domain controllers, but as you fire this as sepperate threads, the collection is running in paralel what increases the speed dramaticly.

    The make sure only one thread at a a single time can update the lastlogon dictionary (shared memmory) use lock.

    lock (oLastLogons)
    {
    // Save the most recent logon for each user in
    // a Dictionary object
    if (lastLogons.ContainsKey(distinguishedName))
    {
    if (lastLogons[distinguishedName] <
    lastLogonThisServer)
    { lastLogons[distinguishedName] =
    lastLogonThisServer;
    }
    }
    else
    {
    lastLogons.Add(distinguishedName, lastLogonThisServer);
    }
    } //end lock (after this lastlogons is free for the other threads)

    I could not upload the whole code. Hope this hint will solve the issue.

    ReplyDelete
  10. Ah, great point w/ threading - locking on the collection is def critical. thanks for the tip!

    ReplyDelete
  11. Outstanding article! Solved my issue right out the box! Thank you.

    ReplyDelete
  12. This is a great article! I have a simple question. I'd like to modify this a bit to get the last logon time stamp for a single user. I'd like to pass into the class the DistinguishedName of a single user, and get back the Last Logon time stamp in date time format.

    I know very little about c#, and I'm sure this is not that difficult. What I'd like to do is have a .DLL that I can then use in other projects.

    Any assistance would be most appreciate!
    Thanks,
    Rob Moore
    rob.moore@travelport.com

    ReplyDelete