Created
March 3, 2020 09:18
-
-
Save JohnLBevan/8c9b8ad33291a629f6b1bf3128b4b9c4 to your computer and use it in GitHub Desktop.
A C# implementation of Get-ADUserLastLogon that works for a single server, running for all DCs in a given domain's forest / doesn't rely on the DC having web services installed. For a similar PowerShell script (which does rely on web services) see: https://sid-500.com/2019/08/12/powershell-get-last-domain-logon-with-get-aduserlastlogon/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <Query Kind="Program"> | |
| <Reference><RuntimeDirectory>\System.DirectoryServices.AccountManagement.dll</Reference> | |
| <Reference><RuntimeDirectory>\System.DirectoryServices.dll</Reference> | |
| <Namespace>System.DirectoryServices</Namespace> | |
| <Namespace>System.DirectoryServices.AccountManagement</Namespace> | |
| <Namespace>System.DirectoryServices.ActiveDirectory</Namespace> | |
| <Namespace>System.Threading.Tasks</Namespace> | |
| </Query> | |
| void Main() | |
| { | |
| // // CHANGE AS NEEDED \\ | |
| var domain = "the domain you're trying to query"; | |
| var samAccountName = "the user you're hunting for"; | |
| // \\ CHANGE AS NEEDED // | |
| var result = GetUserLastLogonTime(domain, samAccountName); | |
| if (result == DateTime.MinValue) // using MinValue as a NotFound placeholder | |
| { | |
| Console.WriteLine($"{samAccountName} has never logged on, so far as we can see"); | |
| } | |
| else | |
| { | |
| Console.WriteLine($"{samAccountName} last logged in on {result.ToString("yyyy-MM-dd HH:mm:ss")}"); | |
| } | |
| } | |
| DateTime GetUserLastLogonTime(string domain, string samAccountName) | |
| { | |
| var result = DateTime.MinValue; | |
| var domainObj = Domain.GetDomain(new DirectoryContext(DirectoryContextType.Domain, domain)); | |
| var dcs = domainObj.Forest.Domains.Cast<Domain>().SelectMany(x => x.DomainControllers.Cast<DomainController>()).Distinct(); | |
| var tasks = new List<Task<DateTime?>>(); | |
| foreach (DomainController dc in dcs) //linqpad requires type; not var | |
| { | |
| tasks.Add( GetUsersLastLogonFromDomainControllerAsync(dc, samAccountName) ); | |
| } | |
| foreach (var task in tasks) { | |
| task.Wait(); | |
| if (task.Result.HasValue && task.Result.Value > result) result = task.Result.Value; | |
| } | |
| return result; //GetMostRecentDate(result.Values.Select<Tuple<DateTime?, string>, DateTime?>(x => x.Item1)); //original code from my thinking I'd return a tuple per dc holding logondate or error info... but that's pointless complexity | |
| } | |
| DateTime GetMostRecentDate (IEnumerable<DateTime?> dates) | |
| { | |
| return dates.Select<DateTime?, DateTime>(x => x ?? DateTime.MinValue).OrderByDescending(x => x).FirstOrDefault(); | |
| } | |
| DirectoryEntry GetDCDirectoryEntry (DomainController dc) | |
| { | |
| return new DirectoryEntry(string.Format("LDAP://{0}", dc.Name)); //caller to dispose | |
| } | |
| DirectorySearcher GetDirectorySearcher(DirectoryEntry de, string filter, string[] properties) | |
| { | |
| var ds = new DirectorySearcher(de); | |
| ds.PageSize = 1000; | |
| ds.Filter = filter; | |
| ds.PropertiesToLoad.AddRange(properties); | |
| return ds; //caller to dispose | |
| } | |
| string EscapeLdapQueryValue(string term) | |
| { | |
| return Regex.Replace(term, @"[\\*\()\u0000\/]", match => { | |
| switch (match.Value[0]) { | |
| case '\\': return @"\5c"; | |
| case '*': return @"\2a"; | |
| case '(': return @"\28"; | |
| case ')': return @"\29"; | |
| case '\u0000': return @"\00"; | |
| case '/': return @"\2f"; | |
| default: throw new InvalidOperationException($"Issue escaping regex term: '{term}'. Match value '{match.Value}'."); | |
| } | |
| }); | |
| } | |
| Task<DateTime?> GetUsersLastLogonFromDomainControllerAsync (DomainController dc, string samAccountName) | |
| { | |
| return Task.Run(() => GetUsersLastLogonFromDomainControllerWrapped(dc, samAccountName)); | |
| } | |
| DateTime? GetUsersLastLogonFromDomainControllerWrapped(DomainController dc, string samAccountName) | |
| { | |
| try | |
| { | |
| return new Nullable<DateTime>(GetUsersLastLogonFromDomainController(dc, samAccountName)); | |
| } | |
| catch (Exception e) | |
| { | |
| Debug.WriteLine($"Error Processing Domain Controller {dc}"); | |
| Debug.WriteLine($"- {e.ToString()}"); | |
| return null; | |
| } | |
| } | |
| DateTime GetUsersLastLogonFromDomainController (DomainController dc, string samAccountName) | |
| { | |
| using (var de = GetDCDirectoryEntry(dc)) | |
| { | |
| var filter = $"(&(objectClass=user)(!objectClass=computer)(sAMAccountName={EscapeLdapQueryValue(samAccountName)}))"; | |
| var adProperties = new[] { "lastLogon" }; //"SAMAccountName","distinguishedName" //we may want to add in these other fields if we amend to run for multiple users / need to check the DN to confirm we're hitting the write domain / etc - overkill for now | |
| using (var ds = GetDirectorySearcher(de, filter, adProperties)) | |
| { | |
| var results = ds.FindAll(); | |
| Debug.WriteLine($"- DC {dc.Name} - found {results.Count} matches for {samAccountName}"); | |
| if (results == null || results.Count == 0 || !results[0].Properties.Contains("lastLogon")) return DateTime.MinValue; //or throw new NoMatchingPrincipalException(samAccountName)... if it's a valid user presumably they'd have an entry on all DCs even if no logonDate | |
| if (results != null && results.Count > 1) throw new MultipleMatchesException(samAccountName); | |
| var result = ConvertToDateTime((long)results[0].Properties["lastLogon"][0]); | |
| Debug.WriteLine($"- DC {dc.Name} - last logon for {samAccountName} is {result.ToString("yyyy-MM-dd HH:mm:ss")}"); | |
| return result; | |
| } | |
| } | |
| } | |
| DateTime ConvertToDateTime(long lastLogon) | |
| { | |
| return DateTime.FromFileTime(lastLogon); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment