Active Directory は DirectoryEntry を使って検索する

.NET で、ドメインサーバーにある情報を検索するには、3 つのクラスを駆使します。

  • DirectoryEntry クラス: エントリそのもの
  • DirectorySearcher クラス: LDAP クエリで検索
  • SearchResult クラス: DirectorySearcher で検索した結果

あとは、DirectoryEntry オブジェクトの Properties コレクションを使えば、なんとかなるのですが…結構、これが慣れるまでが大変なので、メモがてら公開しておきます。

# 事情があって、コードは VB で。

■ドメイン構成と問題

ドメイン構成は、下記のようになっています。

訳あって、ドメインサーバーが2つあります。通常、ログインするほうは、plan.local ドメインなのですが、グループの設定やらなにやらがあるのは、moonmile.local ドメインのほうなのです。まぁ、通常業務のセキュリティ(文書閲覧とか)は plan.local ドメインで行っていて、アプリケーション絡みのややこしいセキュリティ関係は moonmile.local に閉じ込めた、と考えてください。

ここで、tomoaki@plan.local のユーザーがログインしているときに、GRP001 などのグループに属しているか?をチェックする、ことになります。

普通ならば、plan.local のほうにグループを作ればいいのですが…そこは業務的な制限です。

■ユーザーとグループの設定

実験的に、windows server 2008 R2 を使って、設定しています。

tomoaki@plan.local ユーザーを、どのようにして moonmile.local のほうに潜り込ませるかというと、tomoaki@plan.local の SID を使ったユーザーを moonmile.local に作成します。

これを moonmile.local ドメイン内で検索して、グループに入っているかどうかをチェックしようという仕組みです。
ForeignSecurityPrincipals のほうに入れているのは、SID を公開しているか、一応、ってことですね。本来ならば、moonmile.local と plan.local の SID を同じものにすれば話は簡単なのですが、作り方が分からない(苦笑)ので、別々の SID になります。


SID 自体をユーザー名にしていまいます。windows server 2008 R2 だと、SID の長さのままだと後ろのほうが切れてしまうので、実際に検索するのは表示名(displayName)になります。

■実験開始

少しずつ作っている/作ったので、ボタンが4つあります。

  • AD 検索(全検索): ひとまず、全検索してみる。
  • ForeignSecurityPrincipals: CN を指定して、絞ってみる。
  • ad-sv 問合せ: tomoaki ユーザーがログインするほうで、SID を取得します(実際は、ドメインのユーザー で、WindowsIdentity.GetCurrent().User のように SID が取得できます。
  • win2008-sv 問合せ: グループ名まで検索します。

■AD 全検索

単純に、AD の情報を取得します。

Private Sub Button1_Click(sender As System.Object, e As System.EventArgs) Handles Button1.Click

	Dim root As New DirectoryEntry("LDAP://win2008-sv/DC=moonmile,DC=local", "masuda", "password")
	Dim se As New DirectorySearcher(root)

	ListBox1.Items.Clear()
	For Each res As SearchResult In se.FindAll
		Dim de As DirectoryEntry = res.GetDirectoryEntry
		Debug.Print(de.Path)
		ListBox1.Items.Add(de.Path)
	Next
End Sub

LDAP クエリを指定して、DirectoryEntry オブジェクトを作成します。ここでは、ドメインに入っていない状態なので、AD を検索可能なユーザー名とパスワードを指定していますが、既にドメインに入っている場合は、

Dim root As New DirectoryEntry("LDAP://win2008-sv/DC=moonmile,DC=local")

のように指定しても OK です。また ドメインサーバーのフォワードがきちんと設定されていれば、

Dim root As New DirectoryEntry("LDAP://DC=moonmile,DC=local")

のように、サーバー名が無くても動作します。

全検索して、プログラム内で for/if しても良いのですが、ドメインサーバーに負担を掛けそうなので、もうちょっと工夫が必要です。

■CN などで検索を絞る

外部に公開している場合「CN=ForeignSecurityPrincipals」を付ければ少しは負担が軽くなります。

Private Sub Button2_Click(sender As System.Object, e As System.EventArgs) Handles Button2.Click
	Dim root As New DirectoryEntry("LDAP://win2008-sv/CN=ForeignSecurityPrincipals,DC=moonmile,DC=local", "masuda", "password")
	Dim obj = root.NativeObject
	Dim se As New DirectorySearcher(root)
	Dim de2 As DirectoryEntry = Nothing
	ListBox1.Items.Clear()
	For Each res As SearchResult In se.FindAll
		Dim de As DirectoryEntry = res.GetDirectoryEntry
		Debug.Print(de.Path)
		ListBox1.Items.Add(de.Path + " " + de.Properties("displayName").Value)
		If de.Path.IndexOf("増田 トニー") >= 0 Then
			de2 = de
		End If
	Next
	For Each nm In de2.Properties.PropertyNames
		Debug.Print(nm)
	Next
End Sub

表示名を調べるときは、de.Properties(“displayName”).Value のように、Properties プロパティを使えば OK です。ただし、目的のユーザーが指定できる場合は、DirectorySearcher で new するときにフィルターを指定したほうが良さそうです。

ちなみに、ここでデバッグ出力されるプロパティは、以下のものです。

objectClass
cn
sn
givenName
distinguishedName
instanceType
whenCreated
whenChanged
displayName
uSNCreated
memberOf
uSNChanged20111221
name
objectGUID
userAccountControl
badPwdCount
codePage
countryCode
badPasswordTime
lastLogoff
lastLogon
pwdLastSet
primaryGroupID
objectSid
accountExpires
logonCount
sAMAccountName
sAMAccountType
userPrincipalName
objectCategory
dSCorePropagationData
msDS-SupportedEncryptionTypes
nTSecurityDescriptor

ここでは、表示名(displayName)とSID(objectSid)を使います。あと、ユーザーが属しているグループを memberOf を使うと取得できます。

■フィルターを使ってみる

DirectorySearcher クラスで指定するフィルター(LDAPクエリ)を使って、カテゴリ(objectCategory)と名前で検索データを絞れます。

Private Sub Button3_Click(sender As System.Object, e As System.EventArgs) Handles Button3.Click
	Dim root As New DirectoryEntry("LDAP://ad-sv/DC=plan,DC=local", "tomoaki", "password")
	Dim obj = root.NativeObject
	Dim filter As String = "(&(objectCategory=User)(name=tomoaki))"
	Dim se As New DirectorySearcher(root, filter)
	Dim de As DirectoryEntry = se.FindOne.GetDirectoryEntry

	ListBox1.Items.Clear()
	For Each nm In de.Properties.PropertyNames
		Debug.Print(nm)
		Dim s As String = String.Format("{0}={1}", nm, de.Properties(nm).Value)
		ListBox1.Items.Add(s)
	Next

	Dim sid As String = SidToStringSid(
	 CType(de.Properties("objectSid").Value, Byte()))
	Debug.Print(sid)

End Sub

Declare Auto Function ConvertSidToStringSid Lib "advapi32.dll" (ByVal pSID() As Byte, _
	ByRef ptrSid As IntPtr) As Boolean
Private Function SidToStringSid(ByRef bytes As Byte()) As String

	Dim psid As IntPtr = Nothing
	Dim sid As String = ""
	ConvertSidToStringSid(bytes, psid)
	sid = System.Runtime.InteropServices.Marshal.PtrToStringAuto(psid)
	Return sid

End Function

あと、おまけですが、objectSid で取得するデータは byte 型の配列なのでちょっと扱いづらいのです。「S-…」のような文字列で使っていきたいので、変換関数を作ります。

ちなみに、CType(de.Properties(“objectSid”).Value, Byte()) のところが非常に遅いのですよね…CType を使って Byte 配列にするところが遅いらしい。DirectCast を使ってみたのですが、スピードはさほど変わらないので、妙なことになっているのかもしれません。このあたりは、後で調べる…ハズ。

■属しているグループの検索

ドメインにログオンしているユーザーの SID は WindowsIdentity.GetCurrent.User で取得できるので、最初の「ad-sv で “tomoaki” を検索」部分は不要になります。

Private Sub Button4_Click(sender As System.Object, e As System.EventArgs) Handles Button4.Click

	' ad-sv で "tomoaki" を検索
	Dim root As New DirectoryEntry("LDAP://ad-sv/DC=plan,DC=local", "tomoaki", "password")
	Dim filter As String = "(&(objectCategory=User)(name=tomoaki))"
	Dim se As New DirectorySearcher(root, filter)
	Dim de As DirectoryEntry = se.FindOne.GetDirectoryEntry
	Dim bytes As Byte() = CType(de.Properties("objectSID").Value, Byte())
	Dim sid As String = SidToStringSid(bytes)

	' win2008-sv で sid で検索
	root = New DirectoryEntry("LDAP://win2008-sv/CN=ForeignSecurityPrincipals,DC=moonmile,DC=local", "masuda", "password")
	filter = String.Format("(&(objectCategory=User)(displayName={0}))", sid)
	se = New DirectorySearcher(root, filter)
	de = se.FindOne.GetDirectoryEntry
	Dim sid2 As String = SidToStringSid(CType(de.Properties("objectSID").Value, Byte()))

	ListBox1.Items.Add("SID1:" + sid)
	ListBox1.Items.Add("SID2:" + sid2)

	' 属しているグループを取得
	root = New DirectoryEntry("LDAP://win2008-sv/DC=moonmile,DC=local", "masuda", "password")
	Dim groups As List(Of DirectoryEntry) = GetGroups(root, de)
	For Each ent As DirectoryEntry In groups
		ListBox1.Items.Add(ent.Properties("name").Value)
	Next
End Sub

Private Function GetGroups(root As DirectoryEntry, de As DirectoryEntry) As List(Of DirectoryEntry)
	Dim lst As New List(Of DirectoryEntry)

	If de.Properties("memberOf").Value IsNot Nothing Then
		If de.Properties("memberOf").Value.GetType Is GetType(String) Then
			Dim grp As String = de.Properties("memberOf").Value
			Dim se As New DirectorySearcher(root, String.Format("(&(objectCategory=Group)(distinguishedName={0}))", grp))
			Dim ent As DirectoryEntry = se.FindOne.GetDirectoryEntry
			lst.Add(ent)
			lst.AddRange(GetGroups(root, ent))
		Else
			Dim groups As Object() = CType(de.Properties("memberOf").Value, Object())
			For Each grp As String In groups
				Dim se As New DirectorySearcher(root, String.Format("(&(objectCategory=Group)(distinguishedName={0}))", grp))
				Dim ent As DirectoryEntry = se.FindOne.GetDirectoryEntry
				lst.Add(ent)
				lst.AddRange(GetGroups(root, ent))
			Next
		End If
	End If

	Return lst
End Function

LDAP クエリを使って、表示名(displayName)の SID の一致を検索するわけです。その時の DirectoryEntry オブジェクトが、それぞれのグループに属しているので、memberOf を使って調べていきます。取得した DirectoryEntry の SID を表示させていますが、実はこれも不要です。

属しているグループは、再帰的に検索させています。これは、GRP001 が GRP001PA に属している場合、ユーザーが属しているグループとしては「GRP001,GRP001PA」のように、両方とも取得させたいためです。memberOf プロパティで取得するデータは、ややこしいことに、String 単体と object 配列の 2 種類が存在します。属しているグループが1つの場合は String 単体で、2 つ以上属している場合は、String 配列が返されるという….変な仕様のため、GetType でクラスを比較させています。

ここまで来ると、属しているグループの一覧が取得できるので、グループのエントリから name プロパティなどを使えば、どのグループに属しているかどうかは簡単に調べられます。

■パフォーマンスの問題

これを試しに実行すると結構待たされます。多分、LDAP クエリの作り方がまずいような気がするのですが、object 配列から byte 配列へのキャスト(ctype)も結構重いのです。

最後の例だと、

	se = New DirectorySearcher(root, filter) ★1
	de = se.FindOne.GetDirectoryEntry
	Dim sid2 As String = SidToStringSid(CType(de.Properties("objectSID").Value, Byte())) ★2

のように、★1 の検索と、★2 の byte 配列への変換で遅くなります。
ここでは、SID を表示させているだけなので、ここは削ってしまうと早くなります。

LDAP クエリの検索部分は、キャッシュを使うようにすれば早くなるんですかね…問合せなので多少は掛かってもよいのでしょうが、もうちょっとレスポンスが良いほうがいいなぁと。

— 補足 2011/12/22

byte 配列のところ、以下のように分解すると、

Dim obj As Object = de.Properties("objectSid").Value ★ここで遅くなっている
Dim bytes As Byte() = CType(obj, Byte())
Dim sid As String = SidToStringSid(bytes)

どうやら、Properties にアクセスして値を拾ってくるところが重たいようです。byte 配列は関係ないですね。
キャッシュを有効にするとかで、スピードがあがる?

カテゴリー: 開発, VB パーマリンク

Active Directory は DirectoryEntry を使って検索する への3件のフィードバック

  1. マゴ のコメント:

    突然ですみません。DirectoryEntryで、部署コードがとれるプロパティはないでしょうか?

    • masuda のコメント:

      マゴさん、こんにちは。

      AD の環境が手元にないので確認ができないのですが、
      「部署」自体は、GROUP で作ってあると思うので、fillter のところで、

      Dim filter As String = “(&(objectClass=organizationalUnit)”
      あるいは
      Dim filter As String = “(&(objectCategory=Group)”

      のようにすると「OU」が取れるかもしれません。

      このあたりのフィルターの値は、下記のような LDAP, Active Directory の設定を参考にすると良いです。

      知られざるActive Directory技術の「舞台裏」:第3回 LDAPを使ってActive Directoryを制御しよう[その1:ldpとcsvde]|gihyo.jp … 技術評論社
      http://gihyo.jp/admin/serial/01/ad2010/0003?page=2

      ↓にグループの検索などがあります。

      ユーザやグループなどの情報の検索と取得
      http://blogs.wankuma.com/mitchin/archive/2011/07/14/201050.aspx

  2. マゴ のコメント:

    早々の回答ありがとうございます。
    ご提示して頂いたサイトを参考にさせて頂きます。
    ご丁寧にありがとうございました。

コメントは停止中です。