.NET and Active Directory

Introduction

When we had the requirement to read the active directory from .Net, for a project, we were left with many questions and grey areas on mind; sadly there was not much info available on googling. After a thorough study by creating many proof of concepts we got a bit comfortable with the same, I thought why not put it all onto the web for the use of any people who want to access the Active directory through .Net. 

Here I will discuss the multiple options available to connect to active directory, and also the patterns of AD queries, and other small findings that I came across while doing all this.

LDAP and GC

Now why do we need to know these two jargons? - Simply because these are the two ways you can connect to the active directory. For the purpose of correct documentation the abbreviations for the two are given below.

  • LDAP - Lightweight directory access protocol.
  • GC - Global Catalog.

The conventional definition for LDAP is as follows:

  • A set of protocols for accessing information directories

The LDAP is a full replica of a single domain and that "GC:" is a partial replica of all domains in the forest.

The global catalog has a database table like structure which helps in faster searches. The Global Catalog contains directory data for all domains in a forest. The Global Catalog contains a partial replica of every domain directory. It contains an entry for every object in the enterprise forest, but does not contain all the properties of each object. Instead, it contains only the properties specified for inclusion in the Global Catalog. The global catalog is created by replicating from all the domains in a forest of the active directory on a periodical basis.

An LDAP and GC path would look something like these.

  • "LDAP://<host name>/<object name>" 
  • "GC://<host name>/<object name>"

In the examples above, "LDAP:" specifies the LDAP provider. "GC:" uses the LDAP provider to bind to the Global Catalog service to execute fast queries.

If you were well accustomed with how the active directory is set up, the above lines would give you a clear insight on what a GC and LDAP is. For an overview on the Active directory structure (what is a forest? what is a domain? etc) please refer http://www.windowsitlibrary.com/Content/155/07/toc.html.

Having known what are the two different ways the AD (hereon called AD instead of Active directory), allows itself to be contacted, lets look at how our other party ".NET" can interact with Active Directory and before that how to query AD.

Forming Queries for Active directory

The LDAP search strings used to query Active directory is a little different from the normal SQL queries we would write on databases.

These queries are based on one or more of the key attributes as follows

ObjectCategory

This could be 'user' or 'printer' or any defined category in the AD. If you would be searching only users then this value needs to be set to user e.g (objectCategory=user). By specifying this search narrows down, and you can expect to see results sooner.

AD attributes

There is a big list of fields that can be used in Activedirectory, apart from the extensive set it provides, ad administrators can add their own fields. The query can consist of any of the named fields of AD.e.g. (samAccountname=john.abraham).

To combine the criteria, the normal bitwise operators (&, |, !) can be used,

For example, if I want to query for all the users whose distinguishedName begin with 'john' my query would look like this (&(objectCategory=user)(cn=john*))

If I wanted to find how many users whose names begin either with jack or jill this is how I would frame my query.
(&(objectCategory=user)(|(cn=jack*)(cn=jill*))).

Users whose mailed is empty.
(&(objectCategory=user)(!(mail=*)))

For more such examples you could refer http://www.petri.co.il/ldap_search_samples_for_windows_2003_and_exchange.htm.
http://www.microsoft.com/technet/scriptcenter/guide/sas_ads_emwf.mspx?mfr=true

For specifying this search you need to be aware of the available properties/fields in your active directory.

So much for queries, but then if you want to involve dates in your query, there is bit more of a job to be done, i.e. AD doesn't accept our normal date format for the queries, so the date needs to be converted into a AD readable format. Pls find below a function to do the same. 

/// <summary>
/// Method to convert date to AD format
/// </summary>
/// <param name="date">date to convert</param>
/// <returns>string containing AD formatted date</returns>
private static string ToADDateString(DateTime date)
{
    string year = date.Year.ToString();
    int month = date.Month;
    int day = date.Day;
    int hr = date.Hour;
    string sb = string.Empty;
    sb += year;
    if (month < 10)
    {
        sb += "0";
    }
    sb += month.ToString(); bv                              
    if (day < 10)
    {
        sb += "0";
    }
    sb += day.ToString();
    if (hr < 10)
    {
        sb += "0";
    }
    sb += hr.ToString();
    sb += "0000.0Z";
    return sb.ToString();
} 

Another challenge was to find a unique ID from AD which can be used as a primary key in cases when we need to take a snapshot of the AD to the database. You might wonder that the email id or aliasname of a person should be unique, but in certain organizations when  request for an extra mailbox or any service account is raised, the additional account is created in the same name as the existing one, thus making those attributes unusable as unique Ids.

In AD the unique id used is the objectGUID which cannot be directly inserted into the database as it's a 128 bit octet string. To store this into a Database it needs be converted to a readable format for example a binary string. Find below a code snippet that would do just that.

byte[] arraybyte = (byte[])de.Properties["objectGUID"].Value;
StringBuilder OctetToHexStr = new StringBuilder();
for (int k = 0; k < arraybyte.Length; k++)
{
    OctetToHexStr.Append(@"\" + Convert.ToString(Convert.ToByte(arraybyte[k]),16));
}

ADO and System.DirectoryServices

From .NET there are two that you can connect to AD, one is through our good old ADO, which we have used from age-old days, and the other is through the .Net provided namespace System.DirectoryServices.

By using ActiveX data objects (or ADO as its more popularly known) you can connect to AD, as you would with any other database. The connection provider you would use to do so is "ADsDSOObject".

The other basic objects that you would require to establish a connection to AD and query it would be the connection object, recordset object and lastly the command object to maintain active connections, specify query parameters such as page size, search scope and so on.

Since our focus here is using System.DirectoryServices, we will leave the ADO part aside. But just as an intro, find below the code to establish connection through ADO.

objConnection = CreateObject("ADODB.Connection");
objConnection.Provider = "ADsDSOObject";
objConnection.Properties("User ID").Value = "myUser";
objConnection.Properties("Password").Value = "myPassword";
objConnection.Properties("Encrypt Password") = true;
objConnection.Open("Active Directory Provider");

Now that we are done with creating a connection object, we can proceed on to create a command object to query AD. The code snippet to create a command object for the above given connection object would be as follows.

ADODB.Command objCommand = new ADODB.Command();
objCommand.ActiveConnection = objConnection;
strBase = "<LDAP://OU=User Directory,DC=asia,DC=myDomain,DC=com>";

Having set the connection, we are now left with the task of reading the AD. Now we need to set a query to read the AD, just as we would specify a Sql for a database, a query needs to be passed to the active directory for it to pick the objects that match the query.

Suppose I want the list of users created after a certain date say strDate, then my AD query would look something like this.

string strFilter ="(&(objectCategory=user)(objectClass=user)(whenCreated>=" + strFromDate +"))";

The following is sample code to do the same.

ADODB.Recordset rsAD = new ADODB.RecordsetClass();  
try
{    rsAD.Open(strFilter,adConn,ADODB.CursorTypeEnum.adOpenForwardOnly,ADODB.LockTypeEnum.adLockReadOnly,0);
}
catch (Exception exp)
{
    Response.Write(exp.Message);
    Response.End();
}  
DataTable userDataTable = new DataTable();
userDataTable.Columns.Add ("AccountName");
userDataTable.Columns.Add ("CommonName");
userDataTable.Columns.Add ("CreatedDate"); 
while(!rsAD.EOF)
{  
    DataRow newRow = userDataTable.NewRow(); 
    newRow[0] = rsAD.Fields[0].Value;
    newRow[1] = rsAD.Fields[1].Value;
    newRow[2] = rsAD.Fields[2].Value;
    userDataTable.Rows.Add(newRow);
    rsAD.MoveNext();
}

Having filled up our Datatable with the records from AD, we can choose to display it in any format (DataList, DataReader or Datagrid).

System.DirectoryServices

In this namespace the most used classes are the DirectoryEntry and DirectorySearcher.

As the name suggests DirectoryEntry would represent each entry of the AD, be it a user or a printer or any such resource.

The DirectorySearcher class helps in querying the AD. Please follow the inline comments for details on code.

using System;
using System.Collections;
using System.DirectoryServices;
using System.Data;
using System.Security.Permissions;
using System.IO;
using System.Text;
[assembly: SecurityPermission(SecurityAction.RequestMinimum, Unrestricted = true)]
namespace Web.Apps.ADInterface
{
    /// <summary>
    /// Class to interface with AD and search for new, modified and deleted users.
    /// </summary>
    public class ADSearch
    {
        #region Private Variables
        private static string _gcPath = "GC://mydomain.com";
        private static string _serviceAccountName = @"Europe\abcsdfs-S";
        private static string _servicePassword = "2$%^&*()";
        private DirectoryEntry entry = new DirectoryEntry();
        #endregion
        #region Constructor
        private ADSearch()
        {
            entry.Path = _gcPath;
            entry.Username = _serviceAccountName;
            entry.Password = _servicePassword;
        }
        #endregion
        #region Methods
        /// <summary>
        /// Method to Search for new,Modified and Deleted users
        /// </summary>
        /// <param name="createdDate"></param>
        public static void SearchADUsers(DateTime createdDate, string path)
        {
            string strFilter = string.Empty;
            string strFromDate = ToADDateString(Convert.ToDateTime(createdDate));
            //Search criteria for fetching users whose account name, mail and distinguished name 
            are not empty and whose entries are changed since the specified date (either created
            or modified after the specified date) 
            strFilter += "(&(objectCategory=user)(samAccountName=*)(mail=*)(distinguishedName=*)
            (|(whenChanged>=" + strFromDate + ")(whenCreated>=" + strFromDate + ")))";
            ADSearchUsers(strFilter, path);
        }
        /// <summary>
        /// Method to Search for new,Modified and Deleted users
        /// </summary>
        public static void TakeADSnapshot()
        {
            string filter = string.Empty;
            filter += "(&(objectCategory=user)(samAccountName=*)(mail=*)(distinguishedName=*))";
            ADSnapshot(filter, @"C:\insert.CSV");
        }
        /// <summary>
        /// Method to Search for new,Modified and Deleted users
        /// </summary>
        /// <param name="path">CSV file Path</param>
        public static void TakeADSnapshot(string path)
        {
            string filter = string.Empty;
            filter += "(&(objectCategory=user)(samAccountName=*)(mail=*)(distinguishedName=*))";
            ADSnapshot(filter, path);
        }
        //The function below takes a snapshot of AD users who satisfy the specified criteria and 
        constructs a  CSV file out of it, This is done  it's the easiest way to  move it into a database.
        /// <summary>
        /// Method to get take an AD snapshot
        /// </summary>
        /// <param name="filterString">AD Search string</param>
        /// <param name="path">Path of CSV file</param>
        private static void ADSearchUsers(string filterString, string path)
        {
            DirectoryEntry entry = new DirectoryEntry();
            entry.Path = _gcPath;
            entry.Username = _serviceAccountName;
            entry.Password = _servicePassword;
            DirectorySearcher mySearcher = new DirectorySearcher(entry);
            mySearcher.Filter = filterString.ToString();
            TextWriter tw = new StreamWriter(path, true);
            mySearcher.PageSize = 10;
            mySearcher.CacheResults = false;
            StringBuilder sqlinsert = null;
            //Add all properties that need to be fetched    
            mySearcher.PropertiesToLoad.Add("displayName");
            mySearcher.PropertiesToLoad.Add("givenname"); ;
            mySearcher.PropertiesToLoad.Add("sn");
            mySearcher.PropertiesToLoad.Add("ou");
            mySearcher.PropertiesToLoad.Add("employeeType");
            mySearcher.PropertiesToLoad.Add("mail");
            mySearcher.PropertiesToLoad.Add("telephoneNumber");
            mySearcher.PropertiesToLoad.Add("samAccountName");
            mySearcher.PropertiesToLoad.Add("whenCreated");
            mySearcher.PropertiesToLoad.Add("whenChanged");
            mySearcher.PropertiesToLoad.Add("objectGUID");
            mySearcher.PropertiesToLoad.Add("c");
            //The search scope specifies how deep the search needs to be, it can be either 
            "base"- which means only in the current //level, and "OneLevel" which means the
            base and one level below and then "subtree"-which means the entire tree needs //to be searched.
            mySearcher.SearchScope = SearchScope.Subtree;
            SearchResultCollection resultUsers = mySearcher.FindAll();
            int fpos, spos;
            string dn, newdn, newerdn;
            foreach (SearchResult srUser in resultUsers)
            {
                try
                {
                    DirectoryEntry de = srUser.GetDirectoryEntry();
                    byte[] arraybyte = (byte[])de.Properties["objectGUID"].Value;
                    StringBuilder OctetToHexStr = new StringBuilder();
                    for (int k = 0; k < arraybyte.Length; k++)
                    {
                        OctetToHexStr.Append(@"\" + Convert.ToString(Convert.ToByte(arraybyte[k]), 16));
                    }
                    dn = de.Properties["distinguishedName"][0].ToString();
                    sqlinsert = new StringBuilder();
                    //To get the domain name from Distinguished name
                    fpos = dn.IndexOf("DC=", 0);
                    newdn = dn.Substring(fpos, dn.Length - fpos);
                    spos = newdn.IndexOf(",DC=", 3);
                    newdn = newdn.Substring(0, spos);
                    newerdn = newdn.Substring("DC=".Length, newdn.Length - 3);
                    sqlinsert.Append(OctetToHexStr.ToString());
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["givenname"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["sn"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["ou"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["employeeType"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["mail"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["samAccountName"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["c"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["l"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(Convert.ToDateTime(de.Properties["whenChanged"][0].ToString().TrimEnd()).ToString
                    ("dd-MMM-yyyy"));
                    sqlinsert.Append(";");
                    sqlinsert.Append(Convert.ToDateTime(de.Properties["whenCreated"][0].ToString().TrimEnd()).ToString
                    ("dd-MMM-yyyy"));
                    sqlinsert.Append(";");
                    &nbsp; sqlinsert.Append(DateTime.Now.ToString("dd-MMM-yyyy"));
                    sqlinsert.Append(";");
                    sqlinsert.Append(newerdn);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["legacyExchangeDN"].Value);
                    sqlinsert.Append(";");
                    sqlinsert.Append(de.Properties["distinguishedName"].Value);
                    //sqlinsert = OctetToHexStr+ ";" + de.Properties["givenname"].Value + ";" +
                    de.Properties["sn"].Value + ";" + de.Properties["ou"].Value+";" + de.Properties["employeeType"].Value +
                    ";" + +";"+de.Properties["mail"].Value+";"+de.Properties["samAccountName"].Value+";"+ de.Properties
                    ["c"].Value+";"+de.Properties["l"].Value+";"+Convert.ToDateTime(de.Properties["whenChanged"]
                    [0].ToString().TrimEnd()).ToString("dd-MMM-yyyy")+";"+Convert.ToDateTime(de.Properties["whenCreated"]
                    [0].ToString().TrimEnd()).ToString("dd-MMM-yyyy")+";"+DateTime.Now.ToString("dd-MMM-yyyy") +";"
                    +newerdn+";"+de.Properties["legacyExchangeDN"].Value+ ";"+de.Properties["distinguishedName"].Value;
                    de.Close();
                    tw.WriteLine(sqlinsert);
                    sqlinsert.Remove(0, sqlinsert.Length); 
                }
                catch
                {
                    throw;
                }
            }
            tw.Close();
        } 
        #endregion
    }
}

Conclusion

In conclusion, I would like to highlight upon some points, it would be useful to keep them in mind while working on Active Directory.

  • Active directory searches would be pretty slow compared to database searches, so it's imperative to narrow down the search criteria as much as possible.
  • To search for deleted users in Active directory could be quite a challenge as the deleted items are physically moved to the obsolete users directory and after a certain "tombstone" period will be permanently deleted. But organisations follow some pattern of identifying deleted user's by certain means like suffixing the samAccountname with-Deleted or prefixing the username with a "_" and so on. Before you do a search on deleted users, it would be worthwhile to consult your AD administrator to know the convention followed.
  • There are much more properties attached to the directorysearcher and directoryentry classes, it would be worthwhile to go through them in msdn.
  • This paper intends to present an insight of connecting to AD using .net, and the code used here are only snippets and not fully working solutions.
  • The class DirectorySearcher would give only a readonly snapshot of AD, to do modifications on the AD you would have to follow different pattern, which is out of scope in this document.
  • Of the methods mentioned using System.DirectoryServices is better than using ADO in .net, nevertheless ADO can be used from VB or ASP.

Happy programming!!!