IIS 探索 - 整合式 Windows 驗證與 TCP 連線
2 |
昨天的 .NET 探索 - HttpWebRequest 如何重複使用 TCP 連線?提到:
Windows 整合式驗證(Kerberos)會與連線關聯,若程序修改 WebRequest.Credentials 同時以不同身份連上 IIS 主機,記得要指定 WebRequest.ConnectionGroupName 以免登入身分錯置。
這勾起我的好奇心,言下之意是 Windows 驗證登入後,IIS 將依據 TCP 連線判定使用者身份,而不像 Form 驗證是靠每次發送 Request 加上 Cookie HTTP Header 識別身分。沒親眼看到心裡不踏實,所以又到了做實驗的時間。
昨天我是用 Reflection 從 ServicePoint 一路摸出 Socket 判斷本地端 Port 號,做法費力又不可靠(連線數超過一條就會破功),靈機一動我想到 ASP.NET Core 是用 HttpContext.Connection 抓客戶端 IP,說不定也能查到來源端的 Port 號,一查還真有! 所以我寫了一個簡單 Razor Page - Index.cshtml.cs 可顯示 TCP 連線的客戶端 IP 及 Port 號:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Linq;
namespace AspNetCoreWeb.Pages
{
[Authorize]
public class IndexModel : PageModel
{
public IndexModel()
{
}
public ActionResult OnGet()
{
return Content(
HttpContext.User.Identity.Name.Split('\\').Last() + "@" +
HttpContext.Connection.RemoteIpAddress + ":" +
HttpContext.Connection.RemotePort);
}
}
}
用這個方法,連上網頁就能看到 TCP 連線資訊,比用 Reletion 蠻幹優雅許多:
IIS 做 Windows 驗證時,會先回傳 HTTP 401,與客戶端透過 WWW-Authenticaion、Authorization 等 Header 交換資訊完成 Challenge/Response 驗證,這段之前在 Windows 驗證歷程觀察與 Kerberos/NTLM 判別做過詳細介紹,但當時只到兩個 HTTP 401 後第三次 Request 在 Authoritzation Header 附上編碼後的 NTLM/Kerberos Response 資料驗證成功得到 HTTP 200 便結束,未深究之後是否要繼續附上 Authorization 或其他 Header 證明自己登入身分,這次實驗前先要確認這點。
我使用瀏覽器連續檢視三次網頁,分別帶上 ?random=1、?random=2、?random=3 參數,方便識別也防止 Cache。理論上 random=1 會歷經 401、401、200 的驗證過程,random=2 及 3 則直接 200。實測是如此沒錯,而檢視只有 ?random=1 的 Request 有帶 Authorization Header,2 與 3 沒有,但 IIS 知道登入帳號,故推測 IIS 可依據 TCP 連線判定來源之 Windows 驗證身分。
接著來寫實驗程式。我在主機新增三個使用者帳號 user1、user2 及 user3,透過指定 HttpWebRequest.Credentials 模擬不同帳號登入:
static string passwd = ".....";
static string urlPfx = "http://192.168.50.7/aspnetcore#";
static string TestAuthWebRequest(string userName, bool keepAlive)
{
var sb = new StringBuilder();
var url = urlPfx + userName;
sb.AppendLine($"[{url}]");
var req = (HttpWebRequest)HttpWebRequest.Create(url + userName);
req.Credentials = new NetworkCredential(userName, passwd);
req.Method = "GET";
req.KeepAlive = keepAlive;
using (var resp = req.GetResponse())
{
using (var sr = new StreamReader(resp.GetResponseStream()))
{
var result = sr.ReadToEnd();
sb.AppendLine($"Result = {result}");
}
}
return sb.ToString();
}
這次的實驗過程還蠻多波折的,比原本預期多花了一倍的時間。起初我開了 Fiddler 側錄監看過程,實測結果大致符合預期,但事後想到這等同在 .NET 程式與 IIS 間加入 Proxy,會影響結果,便關掉 Fiddler 再測一次,但結果卻顛覆我的認知,讓我卡關很久狂找原因,最後才明白是怎麼一回事。
實驗一 KeepAlive = true 用同一條連線查詢 (經過 Fiddler Proxy)
設定 KeepAlive = true,依序以 user1, user2, user3, user1, user2, user3 身分登入:
static void TestAuthKeepAlive()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Keep Alive = true");
Console.ResetColor();
Console.WriteLine(TestAuthWebRequest("user1", true));
Console.WriteLine(TestAuthWebRequest("user2", true));
Console.WriteLine(TestAuthWebRequest("user3", true));
Console.WriteLine(TestAuthWebRequest("user1", true));
Console.WriteLine(TestAuthWebRequest("user2", true));
Console.WriteLine(TestAuthWebRequest("user3", true));
}
由於從頭到尾都是同一條連線(Local Port = 61037),一但用 user1 登入成功後,就算 Credentials 換成 user2、user3,IIS 端始終認定登入帳號是 user1:
實驗二 KeepAlive = false 每次都新建連線 (經過 Fiddler Proxy)
設定 KeepAlive = false,依序以 user1, user2, user3, user1, user2, user3 身分登入:
static void TestAuthNoKeepAlive()
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Keep Alive = false");
Console.ResetColor();
Console.WriteLine(TestAuthWebRequest("user1", false));
Console.WriteLine(TestAuthWebRequest("user2", false));
Console.WriteLine(TestAuthWebRequest("user3", false));
Console.WriteLine(TestAuthWebRequest("user1", false));
Console.WriteLine(TestAuthWebRequest("user2", false));
Console.WriteLine(TestAuthWebRequest("user3", false));
}
每次 Local Port 號都不同,IIS 每次都重新驗證身分,結果正確:
實驗三 多執行緒共用連線 (經過 Fiddler Proxy)
指定 ServicePointManager.DefaultConnectionLimit = 2 只用兩條連線(這會覆寫原本本機連線數量無上限的設定),以多緒並行方式同時發出 user1, user2, user3, user1, user2, user3 六個 Request:
static List<Task> tasks = new List<Task>();
static void AddTask(Func<string> job)
{
tasks.Add(Task.Factory.StartNew(() => {
Console.WriteLine(job());
}));
}
static void TestAuthKeepAliveMultiThread()
{
ServicePointManager.DefaultConnectionLimit = 2;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Multithreading");
Console.ResetColor();
AddTask(() => TestAuthWebRequest("user1", true));
AddTask(() => TestAuthWebRequest("user2", true));
AddTask(() => TestAuthWebRequest("user3", true));
AddTask(() => TestAuthWebRequest("user1", true));
AddTask(() => TestAuthWebRequest("user2", true));
AddTask(() => TestAuthWebRequest("user3", true));
Task.WaitAll(tasks.ToArray());
}
觀察 HttpWebRequest.ServicePoint.CurrentConnections 的數字維持在 2,但過程應有連線關閉(或許是處理 401 請求造成),前後出現了四組 Local Port 號,而 IIS 判定的登入身分與 Local Port 號是一對一關係(61290=user1、61293=user1、61294=user3、61295=user2),最後兩筆出現了 Credentials 指定 user2 得到 user3、指定 user3 得到 user1 的錯搭狀況:
然後依據官方文件的教學,針對不同登入帳號指定不同 ConnectionGroupName:
static string TestAuthWebRequest(string userName, bool keepAlive)
{
var sb = new StringBuilder();
var url = urlPfx + userName;
sb.AppendLine($"[{url}]");
var req = (HttpWebRequest)HttpWebRequest.Create(url + userName);
req.ServicePoint.ConnectionLimit = 1;
req.Credentials = new NetworkCredential(userName, passwd);
req.Method = "GET";
//針對不同登入身分指定不同 ConnectionGroupName
//避免共用連線字串
req.ConnectionGroupName = userName;
req.KeepAlive = keepAlive;
實測結果跟未指定 ConnectionGroupName 前一樣,還是有身分錯亂的狀況。查了一陣子我才幡然領悟,我現在觀察到的現象有 Fiddler Proxy 摻雜其中,不全然是 .NET HttpWebRequest 的行為。而關閉 Fiddler 後,出現出乎意料的結果。
實驗四 KeepAlive = true + ConnectionGroupName (無 Fiddler)
關閉 Fiddler 後,即使 KeepAlive = true,即使是依序發送的 Request 也不共用連線! 這讓我大吃一驚~
案情再次陷入焦著,努力爬文查到關鍵屬性 HttpWebRequest.UnsafeAuthenticatedConnectionSharing,其預設值為 false,意思是當使用整合式 Windows 驗證時,HttpWebRequest 會在每次執行完畢時關閉連線(類似 KeepAlive = false)防止身分錯亂。若因此導致效低落,開發者也可考慮將其設為 true,但務必要配合 ConnectionGroupName 使用,並確保不會有人利用這個特性冒用身分。另一個做法是啟用 PreAuthticate = true,在每個 Request 加上 Authorization Header,但這需要調整 IIS 配合且僅限 NTLM (企業環境現多以 Kerberos 為主,Kerberos 與 NTLM 比較可參考這篇討論)。
搞清楚原理,修改程式如下:
static string TestAuthWebRequest(string userName, bool keepAlive)
{
var sb = new StringBuilder();
var url = urlPfx + userName;
sb.AppendLine($"[{url}]");
var req = (HttpWebRequest)HttpWebRequest.Create(url + userName);
req.ServicePoint.ConnectionLimit = 1;
req.Credentials = new NetworkCredential(userName, passwd);
req.Method = "GET";
//針對不同登入身分指定不同 ConnectionGroupName
req.ConnectionGroupName = userName;
req.UnsafeAuthenticatedConnectionSharing = true;
req.KeepAlive = keepAlive;
調整後重跑多緒並行測試,沒有出現登入身分錯亂,且實現同一登入帳號共用一條連線(user1: 49437, user2: 49438, user3: 49436),算是較理想的模式:
結論
一番實驗下來,算是對 IIS Windows 驗證與 TCP 連線的關聯有更清楚的認識。
- 使用整合式 Windows 驗證時,IIS 會 Cache 每個 TCP Session 的 Kerboros (非 NTLM) Ticket 或 Token,避免對同一 TCP Session (亦即 Local Port 號相同)反覆驗證身分影響效能。參考 authPersistNonNTLM setting of true means that the client will be authenticated only once on the same connection. IIS will cache a token or ticket on the server for a TCP session that stays established.
- 基於上述特性,KeepAlive = true 共用 TCP 連線行為將導致登入身分錯亂,故 HttpWebRequest 遇整合式 Windows 驗證(即 Credentials = new NetworkCredential())時,會因 UnsafeAuthenticatedConnectionSharing 預設 false 停止共用連線(等同 KeepAlive = false)
- 若擔心不共用連線會影響效能,可考慮將 UnsafeAuthenticatedConnectionSharing 設為 true,但務必要配合 ConnectionGroupName 防止身分錯亂,並確保不會有人利用此一特性冒用身分。
- 要注意:當 HttpWebRequest 是透過 Proxy 連上網站時,共用連線的行為可能受 Proxy 影響出現非預期結果。
- 而由上述觀察,現在比較能理解為什麼總是得把所有 IE 視窗都關掉才能換個 AD 帳號登入企業內部網站。
This article explains IIS's Kerboros ticket cache principle on TCP session and HttpWebRequest's connection resuing policy on integrated Windows authentication.
Comments
# by Anthony LEE
Relection -> Reflection
# by Jeffrey
to LEE, 謝謝指正。