昨天的 .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 連線的關聯有更清楚的認識。

  1. 使用整合式 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.
  2. 基於上述特性,KeepAlive = true 共用 TCP 連線行為將導致登入身分錯亂,故 HttpWebRequest 遇整合式 Windows 驗證(即 Credentials = new NetworkCredential())時,會因 UnsafeAuthenticatedConnectionSharing 預設 false 停止共用連線(等同 KeepAlive = false)
  3. 若擔心不共用連線會影響效能,可考慮將 UnsafeAuthenticatedConnectionSharing 設為 true,但務必要配合 ConnectionGroupName 防止身分錯亂,並確保不會有人利用此一特性冒用身分。
  4. 要注意:當 HttpWebRequest 是透過 Proxy 連上網站時,共用連線的行為可能受 Proxy 影響出現非預期結果。
  5. 而由上述觀察,現在比較能理解為什麼總是得把所有 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, 謝謝指正。

Post a comment