Creating user objects in Active Directory is not difficult. As we have seen, with the right permissions, we can create a basic user account in just a few lines of code.
However, getting user objects to behave like Windows user accounts is a bit more challenging. Windows accounts have many features, such as enabled/disabled status, names and identifications used for security and email, password management, and expiration and lockout status, all of which require more-intimate knowledge of how things work under the hood. The next several sections explore this in detail.
Managing Basic User Account Properties in Active Directory
Many of the important behaviors associated with a Windows account in Active Directory, such as enabled/disabled status, are controlled by an attribute called userAccountControl. This attribute contains a 32-bit integer that represents a bitwise enumeration of various flags that control account behavior.
These flags are represented in ADSI by an enumerated constant called ADS_USER_FLAG. Because this enumeration is so important in terms of working with user objects in System.DirectoryServices (SDS), we will convert the ADSI enumeration into a .NET-style enumeration, as shown in Listing 10.2.
Listing 10.2. User Account Control Flags
[Flags] public enum AdsUserFlags { Script = 1, // 0x1 AccountDisabled = 2, // 0x2 HomeDirectoryRequired = 8, // 0x8 AccountLockedOut = 16, // 0x10 PasswordNotRequired = 32, // 0x20 PasswordCannotChange = 64, // 0x40 EncryptedTextPasswordAllowed = 128, // 0x80 TempDuplicateAccount = 256, // 0x100 NormalAccount = 512, // 0x200 InterDomainTrustAccount = 2048, // 0x800 WorkstationTrustAccount = 4096, // 0x1000 ServerTrustAccount = 8192, // 0x2000 PasswordDoesNotExpire = 65536, // 0x10000 MnsLogonAccount = 131072, // 0x20000 SmartCardRequired = 262144, // 0x40000 TrustedForDelegation = 524288, // 0x80000 AccountNotDelegated = 1048576, // 0x100000 UseDesKeyOnly= 2097152, // 0x200000 DontRequirePreauth= 4194304, // 0x400000 PasswordExpired = 8388608, // 0x800000 TrustedToAuthenticateForDelegation = 16777216, // 0x1000000 NoAuthDataRequired = 33554432 // 0x2000000 } |
As we look through the members of this enumeration, we see a variety of words we associate with Windows accounts, such as AccountDisabled and PasswordNotRequired (the last one we hope you never use!). We also see some flags that we probably do not recognize, such as MnsLogonAccount and UseDesKeyOnly. For the most part, the esoteric flags are not important in daily account management tasks, so we can ignore them. Chances are, if we need these flags we are probably quite aware of them already.
The important thing to note is that even though 21 flags are currently defined for use with the userAccountControl attribute, Active Directory does not actually use all of them! Specifically, the ones that are not meaningful to Active Directory are
Active Directory actually uses different mechanisms to control these account properties, so do not try to read them from userAccountControl! We discuss how to deal with the special cases in the upcoming sections.
Reading User Account Properties
Reading the userAccountControl attribute is simple. Listing 10.3 uses an enumeration we defined in Listing 10.2.
Listing 10.3. Reading the userAccountControl Attribute
DirectoryEntry user = new DirectoryEntry( "LDAP://CN=User1,CN=users,DC=domain,DC=com", null, null, AuthenticationTypes.Secure ); AdsUserFlags userFlags = (AdsUserFlags) user.Properties["userAccountControl"].Value; Console.WriteLine( "AdsUserFlags for {0}: {1}", user.Path, userFlags ); |
This will generally write NormalAccount to the console for a typical user and may include other flags as well, depending on our specific deployment. If the account is disabled, AccountDisabled will be displayed in addition.
For Windows Server 2003 Active Directory, we could also use the msDS-User-Account-Control-Computed attribute in place of userAccountControl. Since it is constructed, we would need to use our RefreshCache technique from Chapter 3. However, the one benefit of using this attribute is that the three flags we previously mentioned as not being used with userAccountControl would actually be used and be accurate. This is not an option with Windows 2000 Server Active Directory installationsthey will always need to use userAccountControl for reading user account properties.
Writing User Account Properties
Writing values is equally as easy as reading them. We just create an integer value representing the proper combination of flags and overwrite the existing userAccountControl value, as shown in Listing 10.4.
Listing 10.4. Writing Account Values
DirectoryEntry entry = new DirectoryEntry( "LDAP://CN=some user,CN=users,DC=mydomain,DC=com", null, null, AuthenticationTypes.Secure ); AdsUserFlags newValue = AdsUserFlags.NormalAccount | AdsUserFlags.DontExpirePassword; entry.Properties["userAccountControl"].Value = newValue; entry.CommitChanges() |
The trick here is that we must use valid combinations of flag values. In addition, other aspects of the account or the policies in effect may prevent certain values from being set.
A classic example that can trip up new developers happens when enabling an account by "unsetting" the AccountDisabled flag. In many domains, a minimum password length is required for all user accounts (and hopefully this is what you use as well). An account cannot be enabled unless it has a password. Therefore, we must set a valid password before enabling the account.
As a result, many typical provisioning processes that create accounts follow this protocol.
Keep this in mind when creating and provisioning user accounts if errors occur.
Delegation Settings
Three flags in the enumeration relate to delegation, which we discussed in Chapter 8. Specifically, TRustedForDelegation and trustedToAuthenticateForDelegation are used by service accounts that will be allowed to delegate users' credentials to other machines. The difference between them is that TRustedForDelegation is the "unconstrained" delegation setting that works with Windows 2000 Server, and it represents the flag used when only Kerberos authentication is allowed in constrained delegation. trustedToAuthenticateForDelegation is new with Windows Server 2003 and is used when delegation from any protocol is allowed. This is known as the "Protocol Transition" setting.
Finally, AccountNotDelegated is used to flag an account as "sensitive and cannot be delegated." This is typically used on highly privileged accounts such as those used by directory administrators, where we would probably not want their account to be delegated by another service due to the security risk it poses.
Managing Basic User Account Properties in ADAM
ADAM works differently than Active Directory in that it does not rely on the userAccountControl attribute to maintain important account properties. Instead, Microsoft introduced a number of attributes prefixed with ms-DS- or msDS to hold this information:
The * in the preceding list indicates that the attribute is constructed.
With the exception of the integer-valued msDS-User-Account-Control-Computed, these attributes are Boolean values in the directory. Some of these attributes are also constructed attributes and as such, they cannot be written. We wish we could say there was some method behind the slightly different ldapDisplayName prefix values, but it just appears that it was overlooked.
Reading User Account Properties
The constructed attribute called msDS-User-Account-Control-Computed takes the place of userAccountControl in ADAM. This attribute is new to Windows Server 2003 Active Directory and ADAM and we can use it rather than userAccountControl to read account properties. However, given that this is a constructed attribute, we can neither search on any values held within it nor set any values on this attribute. Listing 10.5 demonstrates how similar it is to read this attribute compared to using the userAccountControl attribute in Listing 10.3.
Listing 10.5. Reading the msDS-User-Account-Control-Computed Attribute
DirectoryEntry user = new DirectoryEntry( "LDAP://CN=User1,CN=users,DC=domain,DC=com", null, null, AuthenticationTypes.Secure ); //this is a pain to type a lot :) string msDS = "msDS-User-Account-Control-Computed"; using (user) { //this is constructed attribute user.RefreshCache( new string[]{msDS} ); AdsUserFlags userFlags = (AdsUserFlags)user.Properties[msDS].Value; Console.WriteLine( "AdsUserFlags for {0}: {1}", user.Path, userFlags ); } |
We should note that the msDS-User-Account-Control-Computed attribute will accurately hold values like AccountLockedOut, PasswordCannotChange, and PasswordExpired. This departs from the userAccountControl attribute, where these values are not represented accurately because the flags are not used. We also have the option of using the special "alias" attributes such as ms-DS-UserAccountAutoLocked here. We simply read the Boolean value they return. In many cases, this may be more straightforward.
Writing User Account Properties
Since the userAccountControl attribute is not used with ADAM, and its equivalent but constructed attribute cannot be written, we need to use the other msDS and ms-DS attributes to actually set values. Listing 10.6 shows one such example where we can enable or disable an ADAM account.
Listing 10.6. Writing Account Values
string adsPath = "LDAP://localhost:389/" + "CN=User1,OU=Users,O=dunnry,C=US"; DirectoryEntry user = new DirectoryEntry( adsPath, null, null, AuthenticationTypes.Secure ); string attrib = "msDS-UserAccountDisabled"; using (user) { //disable the account user.Properties[attrib].Value = true; user.CommitChanges(); } |
As writing each of the other nonconstructed Boolean account properties is exactly the same, we will not demonstrate further examples. The key point to take away here is that we need to look to these attributes in lieu of using the userAccountControl attribute for ADAM.
Note: Boolean Attributes Can Accept String Values as Well
Boolean syntax attributes can accept both the .NET Boolean true and false as well as the LDAP string equivalent trUE and FALSE (note the case). This is because the underlying value is held as a string in the LDAP directory and only marshaled as a Boolean for us to use in .NET. If we remember that searching for Boolean attributes requires using trUE and FALSE, all of this stuff starts to make sense. Since using the actual Boolean type in .NET tends to be easier, we only mention it in passing as a fun factoid.
Determining Domain-Wide Account Policies
When working with user accounts in Active Directory, it is common to need to refer to domain-wide account policies. For example, policies such as the minimum and maximum password age and the minimum password length, as well as lockout policy, are determined at the domain level and apply to each user object in the domain.
All of the values are stored directly in the domain root object (not in RootDSE, but in the object pointed to by the defaultNamingContext attribute in RootDSE) as a set of attributes such as maxPwdAge, minPwdLength, and lockoutThreshold. Additionally, the password complexity rules are encoded in an enumerated value in the pwdProperties attribute.
These values tend to be quite static in most domains, so we would typically want to read these values only once per program execution. To make the policy values easy to consume, we show in Listing 10.7 a wrapper class for the domain account policies that converts all of the values into convenient .NET data types, such as TimeSpan. A special .NET enumeration type for the types of the password policy is also included. We won't be able to include all of the class properties in the book, as that would take too much space, but we will have the full class available on the book's web site.
We will refer to this sample in future discussions when demonstrating how to determine an account's lockout status and for finding accounts with expiring passwords. It is also worthy to note that any LargeInteger values in these policy attributes are stored as negative values. We chose to invert them back to positive values because it is easier to think about them in this way. Developers choosing to use these attributes should keep this in mind, as it will throw off calculations later if not accounted for.
Listing 10.7. Determining Domain Policies
[Flags] public enum PasswordPolicy { DOMAIN_PASSWORD_COMPLEX=1, DOMAIN_PASSWORD_NO_ANON_CHANGE=2, DOMAIN_PASSWORD_NO_CLEAR_CHANGE=4, DOMAIN_LOCKOUT_ADMINS=8, DOMAIN_PASSWORD_STORE_CLEARTEXT=16, DOMAIN_REFUSE_PASSWORD_CHANGE=32 } public class DomainPolicy { ResultPropertyCollection attribs; public DomainPolicy(DirectoryEntry domainRoot) { string[] policyAttributes = new string[] { "maxPwdAge", "minPwdAge", "minPwdLength", "lockoutDuration", "lockOutObservationWindow", "lockoutThreshold", "pwdProperties", "pwdHistoryLength", "objectClass", "distinguishedName" }; //we take advantage of the marshaling with //DirectorySearcher for LargeInteger values... DirectorySearcher ds = new DirectorySearcher( domainRoot, "(objectClass=domainDNS)", policyAttributes, SearchScope.Base ); SearchResult result = ds.FindOne(); //do some quick validation... if (result == null) { throw new ArgumentException( "domainRoot is not a domainDNS object." ); } this.attribs = result.Properties; } //for some odd reason, the intervals are all stored //as negative numbers. We use this to "invert" them private long GetAbsValue(object longInt) { return Math.Abs((long)longInt); } public TimeSpan MaxPasswordAge { get { string val = "maxPwdAge"; if (this.attribs.Contains(val)) { long ticks = GetAbsValue( this.attribs[val][0] ); if (ticks > 0) return TimeSpan.FromTicks(ticks); } return TimeSpan.MaxValue; } } public PasswordPolicy PasswordProperties { get { string val = "pwdProperties"; //this should fail if not found return (PasswordPolicy)this.attribs[val][0]; } } //truncated for book space } |
Listing 10.7 is meant to run on an Active Directory domain. Where does this leave ADAM instances? By default, ADAM will assume any local or domain policies on the Windows 2003 server where it is running. This means that if our Windows 2003 server is a member of the domain, we can simply use code similar to that in Listing 10.7. If, however, the server is running in a workgroup configuration, the policy will be determined locally. Therefore, Listing 10.7 would not be appropriate. Instead, we would need to know our local policy or attempt to discover it using Windows Management Instrumentation (WMI) classes.
Determining Password Expiration
Earlier in this chapter, we mentioned that accounts could have passwords that expire. Most Active Directory domains and many ADAM instances force passwords to expire periodically to improve security. As such, we often need to know when a user's password will expire.
Determining password expiration on user accounts in Active Directory and ADAM might appear tricky, but really, it is a simple matter of calculation.
Password expiration is determined based on when an individual password was last changed, and on the domain-wide password expiration policy, which we detailed in the previous section. The algorithm is essentially this:
if "password change date" + "max password age" >= "now" "password is expired"
Typically, Windows monitors password expiration and will inform a user that her password is expiring soon when she logs on locally to Windows. It then provides a mechanism to change the password. As long as the user changes her password before it expires, she can continue to log in to the domain and all is good. However, if the password expires, then the user cannot log in again until an administrator resets it.
This situation is not as straightforward for ADAM users, as there is no natural "login" process that informs users of pending password expiration and prompts them for a password change. Instead, it is completely up to the developer to supply both a notification and a means by which to change a password when using ADAM.
Programmatic LDAP binds to either directory must be handled explicitly by the developer, as we will not be warned of pending password expiration. Once a password has expired, all LDAP binds will fail until the password is reset by the user or an administrator.
How Password Modification Dates Are Stored
Active Directory and ADAM use the pwdLastSet attribute to record when a password was last changed, via either an end-user password change or an administrative reset. Like most time-based Windows data in the directory, the attribute uses the 2.5.5.16 LargeInteger attribute syntax, which essentially holds a Windows FILETIME structure as an 8-byte integer. We already discussed how to read and write these attributes in Chapter 6, as well as build LDAP search filters based on them in Chapter 4, so that knowledge should be easy to apply to this problem.
There is one edge case that developers should be aware of when dealing with this attribute. Namely, the pwdLastSet attribute can be set to zero (0), which implies that the password is automatically expired and must be changed at next login.
Note: Zero Is Not a Valid FILETIME Value in .NET
The 0 value for pwdLastSet is especially important to remember because it is not a valid FILETIME value. If we pass it to a .NET function that converts between .NET DateTime structures and FILETIME, it will throw an error. Additionally, if pwdLastSet is 0, the user cannot bind to the directory via LDAP. This makes it impossible to do programmatic password changes via LDAP. Only administrative resets with different credentials are possible in this state.
Determining a Single User's Password Expiration Date
Now that we know the details on how this mechanism works, we are ready to write some code to check this. The first thing we need is a user's pwdLastSet value as a .NET Int64, or long integer. As per Chapter 6, we can do this using DirectorySearcher and its built-in marshaling of the data, or we can use one of the conversion functions we described for use with DirectoryEntry. For our purposes, we will use DirectorySearcher for converting the LargeInteger values in conjunction with Listing 10.7 to obtain domain policies.
We will step through a larger class we have chosen to name PasswordExpires, explaining as we go the thought process that surrounds what we are trying to accomplish. As such, we might have to refer to previous listings to see any member variables. This is a complete class and it requires a number of lines, but don't worry about needing to copy it verbatim. We will include it as a sample on the book's web site under its listing number.
The first part of determining password expiration is to determine our domain policy for the maximum password age (MaxPwdAge). Listing 10.8 shows how we can easily accomplish this using the DomainPolicy class we introduced in Listing 10.7 along with some tricks we learned in Chapter 9 using System.DirectoryServices.ActiveDirectory (SDS.AD) classes.
Listing 10.8. PasswordExpires, Part I
public class PasswordExpires { DomainPolicy policy; const int UF_DONT_EXPIRE_PASSWD = 0x10000; public PasswordExpires() { //get our current domain policy Domain domain = Domain.GetCurrentDomain(); DirectoryEntry root = domain.GetDirectoryEntry(); using (domain) using (root) { this.policy = new DomainPolicy(root); } } |
In Listing 10.8, we are simply using the Domain class from SDS.AD to get a DirectoryEntry object bound to the current domain's default naming context. We need the root partition of the domain in order to determine our domain policies. At this point, we simply load our DomainPolicy object with our root domainDNS object. Next, we need to calculate the actual DateTime when a user's password would expire. Listing 10.9 shows how we can accomplish this.
Listing 10.9. PasswordExpires, Part II
public DateTime GetExpiration(DirectoryEntry user) { int flags = (int)user.Properties["userAccountControl"][0]; //check to see if password is set to expire if(Convert.ToBoolean(flags & UF_DONT_EXPIRE_PASSWD)) { //the user's password will never expire return DateTime.MaxValue; } long ticks = GetInt64(user, "pwdLastSet"); //user must change password at next login if (ticks == 0) return DateTime.MinValue; //password has never been set if (ticks == -1) { throw new InvalidOperationException( "User does not have a password" ); } //get when the user last set their password; DateTime pwdLastSet = DateTime.FromFileTime( ticks ); //use our policy class to determine when //it will expire return pwdLastSet.Add( this.policy.MaxPasswordAge ); } |
The first thing we do in Listing 10.9 is check to see if the user's account is set so that the password never expires. We will use the convention that DateTime.MaxValue means it will never expire. Next, we are using a helper function called GetInt64 (see Listing 10.10). This function marshals the user's pwdLastSet attribute into an Int64 for us using a DirectorySearcher object. Notice that one of three conditions can arise out of this check. First, the pwdLastSet attribute might be null (if it is not set on the object), in which case the user account has no password. We chose to treat the situation where a user does not have a password as an error condition, but this can differ by application. Second, the attribute might be 0, which means that the user must change her password at the next logon. We chose to use the convention that DateTime.MinValue meant that the password must be changed at next log on. Lastly, the attribute might contain some value that we can interpret as a FILETIME structure and can convert using DateTime.FromFileTime. The calculation for determining when a user's password will expire is simple. We just add the DateTime value of when the user last changed her password to the TimeSpan value of the domain's MaxPwdAge policy. If the user's password has already expired, we will still get a DateTime value, but it will be in the past.
Knowing the date a user's password has expired is nice, but we might actually want to know how much time is left before the user's password expires. That is a very easy calculation, as Listing 10.10 demonstrates.
Listing 10.10. PasswordExpires, Part III
public TimeSpan GetTimeLeft(DirectoryEntry user) { DateTime willExpire = GetExpiration(user); if (willExpire == DateTime.MaxValue) return TimeSpan.MaxValue; if (willExpire == DateTime.MinValue) return TimeSpan.MinValue; if (willExpire.CompareTo(DateTime.Now) > 0) { //the password has not expired //(pwdLast + MaxPwdAge)- Now = Time Left return willExpire.Subtract(DateTime.Now); } //the password has already expired return TimeSpan.MinValue; } private Int64 GetInt64(DirectoryEntry entry, string attr) { //we will use the marshaling behavior of //the searcher DirectorySearcher ds = new DirectorySearcher( entry, String.Format("({0}=*)", attr), new string[] { attr }, SearchScope.Base ); SearchResult sr = ds.FindOne(); if (sr != null) { if (sr.Properties.Contains(attr)) { return (Int64)sr.Properties[attr][0]; } } return -1; } |
We chose to return a TimeSpan value representing the time left before a user's password would expire in Listing 10.10. If either TimeSpan.MaxValue or TimeSpan.MinValue is returned, it is meant to indicate that the user's password does not expire or has already expired, respectively. For completeness, we have also included our helper GetInt64 in Listing 10.10, though by now we know that everyone is probably aware of how to marshal LargeInteger values from Chapter 6.
We typically want to do something based on when a password will expire, so it is important to know how much time is left. However, if we just want to know whether an account has expired, we can use the previously mentioned msDS-User-Account-Control-Computed attribute for Windows 2003 Active Directory and ADAM, or the aptly named msDSUserPasswordExpired attribute for ADAM, to just give us a yes/no answer. Listing 10.11 shows one such example.
Listing 10.11. Checking Password Expiration
string adsPath = "LDAP://CN=User1,OU=Users,DC=domain,DC=com"; DirectoryEntry user = new DirectoryEntry( adsPath, null, null, AuthenticationTypes.Secure ); string attrib = "msDS-User-Account-Control-Computed"; using (user) { user.RefreshCache(new string[] { attrib }); int flags = (int)user.Properties[attrib].Value & (int)AdsUserFlags.PasswordExpired); if (Convert.ToBoolean(flags) { //password has expired Console.WriteLine("Expired"); } } |
Of course, the problem with something like Listing 10.11 is that we don't know when the password actually expired or how much time is left before it does expire. Additionally, as we previously mentioned, a solution like this will work with only Windows 2003 Active Directory and ADAM. Windows 2000 Active Directory users must use a solution such as that shown in Listing 10.8.
Searching for Accounts with Expiring Passwords
Another thing we may wish to do is find all of the accounts with passwords expiring within a certain time range, perhaps to send an email notification directing users to a web-based portal where they can change their passwords. This is important for ADAM users and any Active Directory users that do not typically log in to Windows via the workstation.
The crux of this search is based on creating a search filter with the correct values. Let's say we want to find user accounts with passwords expiring between two dates. Since password expiration is based on the date the password was last changed and the maximum password age domain policy, we subtract the maximum password age from the two dates to get the values of pwdLastSet that will match. The code might look like that shown in Listing 10.12.
Listing 10.12. Finding Expiring Passwords
public static string GetExpirationFilter( DateTime startDate, DateTime endDate, TimeSpan maxPwdAge ) { Int64 lowDate; Int64 highDate; string filterPattern = "(&(sAMAccountType=805306368)" + "(pwdLastSet>={0})(pwdLastSet<={1}))" lowDate = startDate.Subtract(maxPwdAge).ToFileTime(); highDate = endDate.Subtract(maxPwdAge).ToFileTime(); return String.Format( filterPattern, lowDate, highDate ); } |
A complete sample that enumerates users with expiring passwords between two dates is available on this book's web site.
In both examples, we see that .NET makes this especially easy. The built-in support for dates, time spans, and Windows FILETIME structures simplifies much of the work. We can also easily construct variations on this, using similar techniques, to find accounts whose passwords have already expired, or to find all accounts that will expire before or after a certain date.
Note: Performance Implications of Using pwdLastSet
The pwdLastSet attribute is not indexed, nor is it stored in the global catalog in Active Directory by default. As such, this search cannot be performed across an entire forest, and it can be slow, introducing the possibility of timeouts. Using a small page size for our DirectorySearcher, our results will be returned more quickly and we can help mitigate some of these risks. It is definitely much faster than enumerating all of the users in the domain and looking at each individual attribute value on the client side.
Determining Last Logon
The last time a user has logged onto the domain is held in an attribute on the user object. Called lastLogon, this attribute is a nonreplicated attribute, which means that each domain controller holds its own copy of the attribute, likely with different values. Checking the last time a user has logged onto the domain requires us to visit each domain controller and read the attribute. The value found for the latest lastLogon is the value we are after.
We covered in Chapter 9 how to use the Locator to enumerate all the domain controllers. We will use this technique again to iterate through each controller and retrieve the lastLogon attribute for each user. Listing 10.13 demonstrates how to accurately determine the last time a user has logged into the domain.
Listing 10.13. Finding a User's Last Logon
string username = "user1"; string domain = "mydomain.com"; public static void LastLogon(string username, string domain) { DirectoryContext context = new DirectoryContext( DirectoryContextType.Domain, domain ); DateTime latestLogon = DateTime.MinValue; string servername = null; DomainControllerCollection dcc = DomainController.FindAll(context); foreach (DomainController dc in dcc) { DirectorySearcher ds; using (dc) using (ds = dc.GetDirectorySearcher()) { ds.Filter = String.Format( "(sAMAccountName={0})", username ); ds.PropertiesToLoad.Add("lastLogon"); ds.SizeLimit = 1; SearchResult sr = ds.FindOne(); if (sr != null) { DateTime lastLogon = DateTime.MinValue; if (sr.Properties.Contains("lastLogon")) { lastLogon = DateTime.FromFileTime( (long)sr.Properties["lastLogon"][0] ); } if (DateTime.Compare(lastLogon,latestLogon) > 0) { latestLogon = lastLogon; servername = dc.Name; } } } } Console.WriteLine( "Last Logon: {0} at {1}", servername, latestLogon.ToString() ); } |
We are using the SDS.AD namespace here to enumerate all of our domain controllers, and then we are using DirectorySearcher and SearchResult to marshal the LargeInteger syntax lastLogon attribute more easily. In domains with widely distributed domain controllers, we should be aware that network latency can slow this technique dramatically.
We should further keep in mind that this technique is relatively slow because it must bind to each domain controller in turn to find the user account and retrieve the lastLogon value. However, it is accurate, as each domain controller is searched and the latest logon is found.
Finding Stale Accounts
There is one more method we can use for finding old or unused accounts if we're running Windows Server 2003 Active Directory in full Windows 2003 mode. The user class schema has been updated to add a new attribute called lastLogonTimestamp. This is a replicated value that is updated periodically. How often the attribute is updated depends on a new domain policy attribute named msDS-LogonTimeSyncInterval. By default, this value is 14 days, but it is configurable. As such, this attribute is not accurate for purposes of determining exactly the last time a user logged into the domain. We must use the lastLogon attribute when accuracy matters.
The syntax of this attribute is LargeInteger, so it is similar to other techniques we have already demonstrated. We can simply create a filter using the DateTime.ToFileTime method appropriately:
//find all users and computers that //have not been used in 30 days String filter = String.Format( "(&(objectClass=user)(lastLogonTimestamp<={0}))", DateTime.Now.Subtract(TimeSpan.FromDays(30)).ToFileTime() );
At first blush, this seems like a pretty easy thing to do. Indeed it is, if we can live with the following limitations.
Only NTLM and Kerberos logins update this attribute. Service Pack 1 must be applied to correct problems with NTLM as well. This means that any other type of operation that generates a logincertificates, custom Security Support Provider Interface (SSPI), Kerberos Service for User (S4U), and so onwill not update this attribute.
Additional caveats that depend on when the domain functional level was increased also affect the accuracy of this attribute. You can find further information about all the caveats at www.microsoft.com/technet/prodtechnol/windowsserver2003/library/TechRef/54094485-71f6-4be8-8ebf-faa45bc5db4c.mspx.
Determining Account Lockout
Determining whether an account is locked out is a strange odyssey. On the surface, it appears simple enough: We have a flag on userAccountControl called UF_LOCKOUT. Surely, that would mean that checking to see if the flag was flipped would tell us whether an account was locked out, right? Well, that really depends on which provider we use. As we mentioned earlier in this chapter, the userAccountControl attribute is inaccurate in terms of determining an account's lockout status for the LDAP provider. The situation is somewhat better if we use the WinNT provider, as this version of the userAccountControl attribute accurately reflects lockout status. Microsoft was aware of this flakey implementation and fixed it on Windows 2003 Active Directory and ADAM with the msDS-User-Account-Control-Computed attribute. This constructed attribute will accurately reflect the UF_LOCKOUT flag for the LDAP provider. Listing 10.14 shows a sample of how this would work.
Listing 10.14. Determining Account Lockout
//user is a DirectoryEntry for our user account string attrib = "msDS-User-Account-Control-Computed"; //this is a constructed attrib user.RefreshCache(new string[]{attrib}); const int UF_LOCKOUT = 0x0010; int flags = (int)user.Properties[attrib].Value; if (Convert.ToBoolean(flags & UF_LOCKOUT)) { Console.WriteLine( "{0} is locked out", user.Name ); } |
There are a couple problems with this method, of course.
Unfortunately, since this attribute cannot be used in a search filter, we really cannot use this method to find locked accounts proactively. Luckily, we can actually compute whether an account is locked out fairly accurately, and search for it. Previously in this chapter, we showed how we could determine the domain's lockout duration policy. Used in conjunction with the lockoutTime attribute, we can accurately predict whether an account is locked out, and search for it. Listing 10.15 shows one such example.
Listing 10.15. Searching for Locked-Out Accounts
class Lockout : IDisposable { DirectoryContext context; DirectoryEntry root; DomainPolicy policy; public Lockout(string domainName) { this.context = new DirectoryContext( DirectoryContextType.Domain, domainName ); //get our current domain policy Domain domain = Domain.GetDomain(this.context); this.root = domain.GetDirectoryEntry(); this.policy = new DomainPolicy(this.root); } public void FindLockedAccounts() { //default for when accounts stay locked indefinitely string qry = "(lockoutTime>=1)"; TimeSpan duration = this.policy.LockoutDuration; if (duration != TimeSpan.MaxValue) { DateTime lockoutThreshold = DateTime.Now.Subtract(duration); qry = String.Format( "(lockoutTime>={0})", lockoutThreshold.ToFileTime() ); } DirectorySearcher ds = new DirectorySearcher( this.root, qry ); using (SearchResultCollection src=ds.FindAll()) { foreach (SearchResult sr in src) { long ticks = (long)sr.Properties["lockoutTime"][0]; Console.WriteLine( "{0} locked out at {1}", sr.Properties["name"][0], DateTime.FromFileTime(ticks) ); } } } public void Dispose() { if (this.root != null) { this.root.Dispose(); } } } |
Listing 10.15 gives us a fairly simple way of finding accounts that have been locked. We are using the DomainPolicy helper class we introduced earlier in this chapter to read the lockoutDuration attribute on the root domain. If the attribute is set to TimeSpan.MaxValue, it means that an account is to stay locked until an administrator unlocks it. We are accounting for this policy possibly by setting our search filter to designate that any account with a lockoutTime with a nonzero value is locked out. This filter is appropriate only when our domain policy tells us that accounts are to be locked out indefinitely, until an administrator unlocks them.
Managing Passwords for Active Directory Users |
Part I: Fundamentals
Introduction to LDAP and Active Directory
Introduction to .NET Directory Services Programming
Binding and CRUD Operations with DirectoryEntry
Searching with the DirectorySearcher
Advanced LDAP Searches
Reading and Writing LDAP Attributes
Active Directory and ADAM Schema
Security in Directory Services Programming
Introduction to the ActiveDirectory Namespace
Part II: Practical Applications
User Management
Group Management
Authentication
Part III: Appendixes
Appendix A. Three Approaches to COM Interop with ADSI
Appendix B. LDAP Tools for Programmers
Appendix C. Troubleshooting and Help
Index