在 Active Directory 上也有 LINQ 可以用了:LINQ to Active Directory

Codeplex 軟體套件(Package)資訊
套件名稱 LINQ to Active Directory
作者 Bart De Smet (C# MVP)
目前版本 1.0.1 Stable (RTW)
URL http://linqtoad.codeplex.com
使用難易度
使用此套件時可用的輔助工具 Active Directory Explorer (可在 TechNet 網站的 Sysinternals 專區中找到這個工具)
基礎知識
  • 使用 ADSI 或是 System.DirectoryServices 命名空間中的物件存取 Active Directory。
  • 熟悉或知曉如何查詢 Active Directory Schema 以及設定 LDAP 查詢字串。
  • 基礎物件導向。

Active Directory:是便利還是為難?

Active Directory,只要公司或組織中使用的是 Windows 2000 以上的 Windows Server 系列,並且具有網域(Domains)的話,相信對它應該是不陌生才是,這個在 Windows 2000 開始,繼承 Windows NT 4.0 網域架構的目錄服務(Directory Services),挾帶著 LDAP 查詢以及 DNS 標準支援的各種網路與資料存取的功能,在使用 Windows Server 的企業中已被廣泛使用,而且微軟所發展的伺服器產品,大多數都可以與 Active Directory 溝通與交換資料,甚至是依賴 Active Directory 的架構(例如 Exchange Server 以及 System Center 系列中控軟體等),因為 Active Directory 太適合用來發展企業級的網路應用服務,所以也有很多應用程式與軟體,或多或少都會和 Active Directory 溝通。

目錄服務最重要的特性之一,就是「廣納百川」,在 Active Directory 中除了可支援 Windows 本身的網域能力以外,它還提供了給外界存取以及擴充 Active Directory 本體的管道以及支援,Exchange Server 就是利用擴充它的資料結構的能力,將它所需要的資料移植到 Active Directory 中,以使用 Active Directory 現有的各種網路服務,核心則透過一組 APIs 以及 COM 物件,與 Active Directory 連接並且查詢(或更新)其資料,以支援核心所需要的服務與功能。ADSI(Active Directory Service Interfaces)即為與 Active Directory 溝通的 API 集,它是由 COM 技術所開發,由 ActiveDs.tlb(Active Directory Service Type Library)顯著其介面,供支援 COM 技術的程式語言呼叫,也就是說它可以支援 Visual Basic、Scripting、C/C++ 等等語言所開發的應用程式,當然 .NET 也可以利用 COM 橋接(Interoperability)功能來引用 ActiveDs 元件,以低階的方式來存取 ADSI 的各種介面。

下列 VBScript 程式即為使用 ADSI 查詢哪些使用者帳戶的密碼到期指令(使用 Windows Scriptig Hosts 執行):

[VBScript]

On Error Resume Next

Const ADS_UF_DONT_EXPIRE_PASSWD = &h10000
Const E_ADS_PROPERTY_NOT_FOUND  = &h8000500D
Const ONE_HUNDRED_NANOSECOND    = .000000100
Const SECONDS_IN_DAY            = 86400

Set objUser = GetObject("LDAP://OU=MyOU,DC=acme,DC=com")

intUserAccountControl = objUser.Get("userAccountControl")
If intUserAccountControl And ADS_UF_DONT_EXPIRE_PASSWD Then     ' LINE 11
    WScript.Echo "The password does not expire."
    WScript.Quit
Else
    dtmValue = objUser.PasswordLastChanged
    If Err.Number = E_ADS_PROPERTY_NOT_FOUND Then               ' LINE 16
        WScript.Echo "The password has never been set."
        WScript.Quit
    Else
        intTimeInterval = Int(Now - dtmValue)
        WScript.Echo "The password was last set on " & _
          DateValue(dtmValue) & " at " & TimeValue(dtmValue)  & vbCrLf & _
          "The difference between when the password was last" & vbCrLf & _
          "set and today is " & intTimeInterval & " days"
    End If

    Set objDomain = GetObject("LDAP://DC=acme,DC=com")
    Set objMaxPwdAge = objDomain.Get("maxPwdAge")

    If objMaxPwdAge.LowPart = 0 Then
        WScript.Echo "The Maximum Password Age is set to 0 in the " & _
                     "domain. Therefore, the password does not expire."
        WScript.Quit
    Else
        dblMaxPwdNano = _
            Abs(objMaxPwdAge.HighPart * 2^32 + objMaxPwdAge.LowPart)
        dblMaxPwdSecs = dblMaxPwdNano * ONE_HUNDRED_NANOSECOND  ' LINE 37
        dblMaxPwdDays = Int(dblMaxPwdSecs / SECONDS_IN_DAY)     ' LINE 38
        WScript.Echo "Maximum password age is " & dblMaxPwdDays & " days"

        If intTimeInterval >= dblMaxPwdDays Then
            WScript.Echo "The password has expired."
        Else
            WScript.Echo "The password will expire on " & _
              DateValue(dtmValue + dblMaxPwdDays) & " (" & _
              Int((dtmValue + dblMaxPwdDays) - Now) & " days from today)."
        End If
    End If
End If

當然,身為一個系統的基礎類別庫,.NET 本身當然也要有一組可以不使用 ActiveDs 元件就可以存取 Active Directory 的類別,這個類別存在於 System.DirectoryServices(實作於 System.DirectoryServices.DLL)命名空間中,名為 DirectoryEntry 及 DirectorySearcher,前者處理與 Active Directory 物件溝通的功能,後者則處理搜尋的功能,到了 .NET Framework 2.0,這個命名空間進一步的擴充成四個:

  • System.DirectoryServices:原生的 ADSI 介面,封裝 DirectoryEntry 與 DirectorySearcher 與工具類別。
  • System.DirectoryServices.AccountManagement:封裝對 IADsUser、IADsComputer、IADsGroup 等介面,簡化處理物件的工具。
  • System.DirectoryServices.ActiveDirectory:封裝對 Active Directory 結構物件的工具。
  • System.DirectoryServices.Protocol:封裝對 LDAP 協定與目錄服務通訊的工具與基礎類別。

下列程式碼為使用 C# 以及 System.DirectoryServices 命名空間停用使用者帳戶的程式碼:

[C#]

DirectoryEntry usr = 
    new DirectoryEntry("LDAP://CN=Old User,CN=users,DC=fabrikam,DC=com");
int val = (int) usr.Properties["userAccountControl"].Value;
usr.Properties["userAccountControl"].Value = val | 
    (int)ActiveDs.ADS_USER_FLAG.ADS_UF_ACCOUNTDISABLE;
usr.CommitChanges();

然而,就如同標題所說的,雖然 Active Directory 是一個相當適合在企業環境中開發服務的一種大型基礎架構,但是也因為它大且複雜,在學習上的曲線會較陡。除了要學習如何去操控 System.DirectoryServices 命名空間以外,還要學會 LDAP 語法(對 Active Directory 的查詢多半要使用這個語法)、ADSI 各式物件以及 Active Directory 基礎架構的組成(樹系、網域、全域目錄、組織單元、帳戶與群組等等),才大概開始可以寫出具生產力的簡單小程式,並且要在 Active Directory 上開發應用程式,負責管理 Active Directory 通常會需要介入以支援在 Active Directory 結構資訊以及網路通訊上的問題(有時程式錯誤未必是因為程式有錯,而是 Active Directory 本身的問題),種種因素都會讓人認為 Active Directory 實在是有一點雞肋的感覺,不過只要身在具有 Windows 網域的組織,熟悉 Active Directory 就是必要的。

 

NOTE

Active Directory 常見的幾個問題,多半是出現在與全域目錄伺服器(Global Catalog Server,簡稱 GC Server)或是網域控制站(Domain Controller)連線時發生的(例如「0x8007302b:無法操作伺服器」或是「0x8007054b:指定的網域控制站不存在或無法連線」等),這會和帳戶權限以及網路設定(如 DNS)有關係,因此開發人員通常都要和 IT 人員合作進行。

 

查詢與操控 Active Directory 的物件的難題

前面有提到,若要寫程式去控制 Active Directory,就必須要學會怎麼使用 LDAP 查詢字串,它是 Active Directory 中定位物件與範圍的基本組成,例如查詢一個網域的根目錄,LDAP 可以這樣下:

[LDAP]

LDAP://dc=acme, dc=com, dc=tw

若要查詢在 MyOU 下的使用者,則 LDAP 要這樣下:

[LDAP]

LDAP://ou=MyOU, dc=acme, dc=com, dc=tw

若要以特定的主機查詢(通常是 GC Server),則 LDAP 要這樣下:

[LDAP]

LDAP://gc.acme.com.tw/ou=MyOU, dc=acme, dc=com, dc=tw
或是
GC://gc.acme.com.tw/ ou=MyOU, dc=acme, dc=com, dc=tw

除了 LDAP 字串以外,當使用 DirectorySearcher 做搜尋時,也要建立一個查詢過濾字串,這個字串不像是一般 SQL 指令的那種 AND/OR,而是像這樣的格式:

(&((!(accountExpires = 0))(objectCategory=user)))
其意義等同下列 C# 指令:
(accountExpires != 0) && (objectCategory=user)

如果組合錯誤,會找不到資料或是擲出格式錯誤的例外,因此多半要實驗很多次。

 

NOTE

現在有了 Active Directory Explorer 的加持,組成查詢字串已不再是難事了,在 Active Directory Explorer 的搜尋功能中,可以設定條件,由它自動產生搜尋的 pattern。

 

若要操控 Active Directory 物件,則對 Active Directory Schema 也不能陌生,Active Directory Schema 是存在於 Active Directory 資料庫中的資料結構,由二十七種資料型別組成,目前在 Windows Server 2003 以上的資料庫,都有數百種的屬性(attribute),這些屬性是儲存資料的基本單元,因此要操作 Active Directory 的資料,這些屬性也必須要熟悉才行,例如下列程式可以修改使用者資料:

[C#]

// 修改 Active Directory 使用者與電腦管理工具中,使用者內容的「一般」頁籤資訊。
usr.Properties["givenName"].Value = "New User";
usr.Properties["initials"].Value = "Ms";
usr.Properties["sn"].Value = "Name";
usr.Properties["displayName"].Value = "New User Name";
usr.Properties["description"].Value = "Vice President-Operation";
usr.Properties["physicalDeliveryOfficeName"].Value = "40/5802";
usr.Properties["telephoneNumber"].Value = "(425)222-9999";
usr.Properties["mail"].Value = "newuser@fabrikam.com";
usr.Properties["wWWHomePage"].Value = "http://www.fabrikam.com/newuser";
usr.Properties["otherTelephone"].AddRange(new 
string[]{"(425)111-2222","(206)222-5263"});
usr.Properties["url"].AddRange(new 
string[]{"http://newuser.fabrikam.com","http://www.fabrikam.com/officers"});
usr.CommitChanges();

 

NOTE

Active Directory Schema 的屬性清單,可以由此查詢:
https://msdn.microsoft.com/en-us/library/ms675089(VS.85).aspx

Active Directory Schema 的資料型別,可以由此查詢:
https://msdn.microsoft.com/en-us/library/ms684419(VS.85).aspx

 

不同的資料型別要有不同的處理方式,例如 Interval 型別,在 ADSI 中就不能直接控制,而要改用 IADsLargeInteger 物件,透過它的 HighPart 和 LowPart 來運算出真正的數值,才可以繼續操作;又如 Enumeration 型別,它要以與 C# 的列舉型別一樣的操作方式來處理;String(Octet) 則要以 byte[] 來處理;String(SID) 則要用 SecurityIdentifier 物件(在System.Security.Principal 命名空間)來處理等等。

如果可以有一個方法,將物件的搜尋以及控制包裝起來,那不但可以簡化撰寫 ADSI 程式的負擔,也可以讓初級的程式設計人員快速上手,不用去管太多細節的部份,那該有多好呢?通常有在用 .NET 3.5 的人,腦子會閃過一個只有 .NET 3.5 以上才有的名詞:LINQ。LINQ 在 .NET Framework 3.5 上不但可以查詢,也可以更新資料庫,像 LINQ to SQL、LINQ to Entity、LINQ to Oracle 這些東西都具有這樣的能力,現在 Active Directory 也有了這樣的東西,它叫做 LINQ to Active Directory。

在 Active Directory 使用 LINQ

LINQ to Active Directory 是由 Bart De Smet 所開發,可以將 LINQ 的能力用在 Active Directory 上的一組函式庫,除了能對應 .NET 以及 COM 上的 Active Directory 元件以外,它能將 LINQ 的查詢字串轉換成符合 RFC 2254 規格的 LDAP 查詢過濾字串,再傳送到 DirectorySearcher 物件中執行搜尋。它也可以直接利用 DirectoryEntry 來更新 Active Directory 中的資料。

LINQ to Active Directory 是以 Entity 的概念來組織 Active Directory 的物件,也就是說,Entity 要先由開發人員定義,才能夠在 LINQ 中使用。例如,開發人員可以定義這樣的指令:

[C#]

[DirectorySchema("user", typeof(IADsUser))]
public class User : DirectoryEntity
{
    private string office = null;

    [DirectoryAttribute("objectGUID")]
    public Guid Id { get; set; }

    public string Name { get; set; }

    public string Description { get; set; }

    public int LogonCount { get; set; }

    [DirectoryAttribute("PasswordLastChanged", DirectoryAttributeType.ActiveDs)]
    public DateTime PasswordLastSet { get; set; }

    [DirectoryAttribute("distinguishedName")]
    public string Dn { get; set; }

    [DirectoryAttribute("memberOf")]
    public string[] Groups { get; set; }

    [DirectoryAttribute("physicalDeliveryOfficeName")]
    public string Office
    {
        get { return office; }
        set
        {
            if (office != value)
            {
                office = value;
                OnPropertyChanged("Office");
            }
        }
    }
}

然後就可以在主程式中這樣用:

[C#]

DirectorySource<User> users = new DirectorySource<User>(new DirectoryEntry("LDAP://acme.com.tw", "user", "password"), SearchScope.Subtree);

// LINQ
// Lookup account’s name contains “s” character.
var query = from user in users
             where user.Name.Contains("s")
             select user;

foreach (var item in query)
    Console.WriteLine("Name: {0}, Office: {1}", item.Name, item.Office);

users = null;
query = null;
Console.ReadLine();

它也可以支援匿名型別(Anonymous Type)物件,例如:

[C#]

// LINQ
var query = from user in users
             where user.Name.Contains("s")
             select new { Name = user.Name, Office = user.Office }; 

foreach (var item in query)
    Console.WriteLine("Name: {0}, Office: {1}", item.Name, item.Office);

使用方式

  1. 由 Codeplex 上下載並解壓縮 LINQtoAD.zip 檔案,可以看到兩個資料夾,一個是 BdsSoft.DirectoryServices.Linq(函式庫專案),另一個是 Demo 專案。
  2. 因為下載的檔案中並未包含二進位檔案,因此請使用 Visual Studio 打開 LinqToAD.sln,並且建置它,如此即可以在 BdsSoft.DirectoryServices.Linq\bin\Debug 資料夾中找到 BdsSoft.DirectoryServices.dll。
  3. 開啟(或新增)要使用它的專案,將 BdsSoft.DirectoryServices.dll 加入參考。
  4. 在命名空間宣告區加入 using BdsSoft.DirectoryServices.Ling; 即可在程式中使用。

 

NOTE

可一併加入 System.DirectoryServices.dll 以及 COM 的 Active DS Type Library 兩個元件的參考,以備在專案需要時使用。

 

實例應用:密碼到期檢查程式

這是一個可以在 Active Directory 中搜尋使用者,並且列出哪些使用者密碼即將到期,或是已到期的清單工具,此工具適合在中大型企業或具規模的 Active Directory 環境,並且有設定密碼到期原則時,在密碼到期前通知使用者變更密碼的工作,如果要用原有的 DirectoryEntry 和 DirectorySearcher 來寫,程式可能會稍微複雜一點,其中主要的原因是資料型別的處理。

若要處理這個工作,則需要用到 Active Directory Schema 的兩個屬性:

  • 在 Domain 層級的 maxPwdAge 屬性,這是一個 Interval 型別,記錄密碼自最後更新日起算的有效期間多長,預設值是 0x8000000000000000。
  • 在 User 層級的 pwdLastSet 屬性,這是一個 Interval 型別,記錄使用者最後一次變更密碼的日期與時間,但由於 pwdLastSet 若是 0 或是 0x7fffffffffffffff 的話,代表密碼永不過期。

 

NOTE

在某些 Active Directory 環境中,pwdLastSet 會查到 0x7ffffffeffffffff,不過由於它還是大於 DateTime.MaxValue.Tick,所以可以直接用判斷它是否大於 DateTime.MaxValue.Tick 來決定密碼是否永不過期。

 

若是使用 DirectoryEntry 和 DirectorySearcher 來撰寫,則程式會是如此:

[C#]

DirectoryEntry entry = new DirectoryEntry("LDAP://acme.com.tw", "myuser", "mypassword");

// 查詢密碼期限。
IADsLargeInteger maxPwdAgeData = entry.Properties["maxPwdAge"].Value as IADsLargeInteger;

// 將密碼期限轉換成 TimeSpan 物件。
TimeSpan maxPwdAge = TimeSpan.FromTicks(Math.Abs(maxPwdAgeData.HighPart * 0x100000000) + maxPwdAgeData.LowPart);

// 設定搜尋帳戶。
DirectorySearcher searcher = new DirectorySearcher(
    entry, "(&(!(pwdLastSet=0))(objectCategory=user))", new string[] { "pwdLastSet", "sAMAccountName", "displayName" }, SearchScope.Subtree);

SearchResultCollection results = searcher.FindAll();

if (results.Count == 0)
    Console.WriteLine("No any user's password is limited as date");
else
{
    foreach (SearchResult result in results)
    {
        DirectoryEntry userEntry = result.GetDirectoryEntry();

        if (userEntry.Properties["pwdLastSet"].Value != null)
        {
            // 將 pwdLastSet 屬性轉換成 DateTime,指示最後修改密碼的日期。
            // 然後將 maxPwdAge 的日數加上去,變成密碼到期日。
            DateTime pwdExpireDate = DateTime.FromFileTime(
                (((IADsLargeInteger)userEntry.Properties["pwdLastSet"].Value).HighPart * 0x100000000) +
                ((IADsLargeInteger)userEntry.Properties["pwdLastSet"].Value).LowPart).AddDays(maxPwdAge.Days);

            // 比對密碼到期日和今日,如果差異天數 <0 表示已過期。
            if (((TimeSpan)(pwdExpireDate - DateTime.Now)).Days < 0)
            {
                // password is expired.
                Console.WriteLine("User: {0}, Account: {1}, password is expired.",
                    (userEntry.Properties["displayName"].Value == null) ? "Unknown" : userEntry.Properties["displayName"].Value.ToString(),
                    (userEntry.Properties["sAMAccountName"].Value == null) ? "Unknown" : userEntry.Properties["sAMAccountName"].Value.ToString());
            }
            else
            {
                // 如果密碼未過期但接近到期(可以設定接近的天數,本例為 10 天),列為即將到期。
                if (((TimeSpan)(pwdExpireDate - DateTime.Now)).Days < 10 && ((TimeSpan)(pwdExpireDate - DateTime.Now)).Days > 0)
                {
                    // password is pending expired.
                    Console.WriteLine("User: {0}, Account: {1}, password is pending expired at {2} days",
                        (userEntry.Properties["displayName"].Value == null) ? "Unknown" : userEntry.Properties["displayName"].Value.ToString(),
                        (userEntry.Properties["sAMAccountName"].Value == null) ? "Unknown" : userEntry.Properties["sAMAccountName"].Value.ToString(),
                        ((TimeSpan)(pwdExpireDate - DateTime.Now)).Days);
                }
            }
        }
    }
}

results.Dispose();
searcher.Dispose();
entry.Close();
entry.Dispose();

Console.ReadLine();

若同樣的功能,改用 LINQ to Active Directory 來寫,則會變成這樣:

[C#]

DirectoryEntry entry = new DirectoryEntry("LDAP://acme.com.tw", "myuser", "mypassword");

IADsLargeInteger maxPwdAgeData = entry.Properties["maxPwdAge"].Value as IADsLargeInteger;
TimeSpan maxPwdAge = TimeSpan.FromTicks(Math.Abs(maxPwdAgeData.HighPart * 0x100000000) + maxPwdAgeData.LowPart);

DirectorySource<User> users = new DirectorySource<User>(entry, SearchScope.Subtree);

// LINQ query.
var query = from user in users
             select user;

foreach (var item in query)
{
    item.DomainMaxPasswordAge = maxPwdAge;

    if (item.IsPasswordExpired)
        Console.WriteLine("User: {0}, Account: {1}, password is expired.", 
item.DisplayName, item.LogonName);
    if (item.IsPasswordPendingExpired)
        Console.WriteLine("User: {0}, Account: {1}, password is pending expired at {2} days", 
            item.DisplayName, item.LogonName, item.PasswordPendingExpiredCount);
}

Console.ReadLine();

可以看出來 LINQ to Active Directory 可以幫開發人員省下多少的工夫,尤其是在轉換資料結構上,前面由於 maxPwdAge 以及 pwdLastSet 是 Interval 型別,必須要自己去轉換成 IADsLargeInteger 物件,再繼續處理的工作,而 LINQ to Active Directory 將它隱藏起來了,由內部的指令自己來處理轉換的邏輯。

LINQ to Active Directory 的限制

LINQ to Active Directory 雖然好用,但它因為只有實作基本的 LINQ 查詢解譯功能,而且 DirectorySearcher 的 LDAP filter 又無法使用類似於物件導向的能力,因此 LINQ to Active Directory 只能使用一些很基本的條件(例如字串模糊搜尋或比對),而無法在條件中額外加上函數呼叫的能力,所以在前面的例子中,無法使用像這樣的查詢指令:

// LINQ query.
var query = from user in users
             where user.IsPasswordExpired == true || 
user.IsPasswordPendingExpired == true
             select user;

使用 LINQ to Active Directory 更新 Active Directory 資料

在前面有提過,LINQ to Active Directory 除了可以查詢以外,也可以寫回資料到 Active Directory 中,條件是開發人員所宣告的 Entity 類別中,必須要繼承自 DirectoryEntry 類別,例如前面的範例所使用的 Entity:

[C#]

// Entity Definition for user.
[DirectorySchema("user", typeof(IADsUser))]
public class User : DirectoryEntity
{
    [DirectoryAttribute("objectGUID")]
    public Guid Id { get; set; }

    [DirectoryAttribute("displayName")]
    public string DisplayName { get; set; }

    [DirectoryAttribute("sAMAccountName")]
    public string LogonName { get; set; }

    [DirectoryAttribute("PasswordLastChanged", DirectoryAttributeType.ActiveDs)]
    public DateTime PasswordLastSet { get; set; }

    public TimeSpan DomainMaxPasswordAge
    {
        get;
        set;
    }

    public bool IsPasswordExpired
    {
        get
        {
            return (DateTime.Now > PasswordLastSet.AddDays(DomainMaxPasswordAge.Days));
        }
    }

    public bool IsPasswordPendingExpired
    {
        get
        {
            return (((TimeSpan)((PasswordLastSet.AddDays(DomainMaxPasswordAge.Days)) - DateTime.Now)).Days > 0 && 
                ((TimeSpan)((PasswordLastSet.AddDays(DomainMaxPasswordAge.Days)) - DateTime.Now)).Days <= 10);
        }
    }

    public int PasswordPendingExpiredCount
    {
        get
        {
            return ((TimeSpan)((PasswordLastSet.AddDays(DomainMaxPasswordAge.Days)) - DateTime.Now)).Days;
        }
    }
}

這樣就可以利用下列的程式碼去變更它的資料:

[C#]

// LINQ query.
var query = from user in users
             select user;

foreach (var item in query)
{
   // 更新資料。
   item.Office = “New Office”;
}

// 更新資料寫回 Active Directory
users.Update();

NOTE

使用 LINQ 方式更新資料時請多加留意,查詢到的真的是要更新的記錄,否則更新錯誤的話,容易徒增 IT 管理人員的困擾。

 

結語

LINQ to Active Directory 是一個很輕巧的小工具類別,可以將 LINQ 的能力使用在 Active Directory 中,除了可以簡化存取 Active Directory 所需的工作以外,也能讓查詢 Active Directory 物件的工作變得更直覺,讀者可以多玩玩它,相信會有更多更好的心得。

 

NOTE

原作者 Bart De Smet 對於 LINQ to Active Directory 這個元件定義為一個展示實作 LINQ Provider 的小專案,因此目前只發展到 1.0.1 版本,也提出要開發人員在使用此元件時要多小心的警語**(不要未經測試就用在生產環境)**,但作者歡迎大家回報此元件的使用問題,也許未來作者會更新這個元件也說不定。

 


[下載範例檔案]