單點登錄(Single Sign-On,SSO)是這些天的熱點話題。我的很多客戶都有多個Web應用,運行在不同子域的不同.NET
Framework版本中,甚至是不同的域中。他們都希望用戶能夠只登錄一次,就能在各個不同的Web站點中保持登錄狀態。今天我們來一起看看如何在各種不同的場景中實現SSO。我們首先從最簡單的情況開始,然後逐步構建它:
1.
虛擬子目錄中的父、子應用之間的SSO
2.
使用不同授權憑證(用戶名映射)的SSO
3.
同一域下的兩個子域中的Web應用之間的SSO
4.
不同.NET版本下的應用之間的SSO
5.
不同域之衆的兩個應用之間的SSO
6.
混合模式驗證(Forms和Windows)中的SSO
1.虛擬子目錄中的父、子應用之間的SSO
假設有兩個.NET應用——Foo和Bar,並且Bar位於Foo的一個虛擬子目錄中(http://foo.com/bar)。兩個應用都實現了Forms驗證。實現Forms驗證需要重寫Application_AuthenticateRequest,在這裏進行驗證,並在驗證成功後調用FormsAuthentication.RedirectFromLoginPage,將登錄的用戶名(或系統中用於標識用戶的其他信息)作爲參數傳遞進去。在ASP.NET中,登錄用戶狀態通過保存在客戶端Cookie中進行持久化。當調用RedirectFromLoginPage時,就會創建一個Cookie,其中包含了加密的、帶有登錄用戶名的FormsAuthenticationTicket。Web.Config中有一節用於定義如何創建該Cookie:
<forms name=".FooAuth" protection="All" timeout="60" loginUrl="login.aspx" />
</authentication>
<authentication mode="Forms">
<forms name=".BarAuth" protection="All" timeout="60" loginUrl="login.aspx" />
</authentication>
這裏最重要的兩個屬性是name和protection。如果在Foo和Bar中,這兩個屬性是匹配的,那麼它們就能在同樣的保護級別上使用相同的Cookie,也就實現了SSO:
<forms name=".SSOAuth" protection="All" timeout="60" loginUrl="login.aspx" />
</authentication>
當將protection屬性設置爲“All”以後,會同時對Cookie進行加密盒驗證(通過散列值)。默認的驗證和加密密鑰存儲在Machine.Config中,並且可以在應用程序的Web.Config中重寫。其默認值爲:
IsolateApps意味着將爲每個應用程序都生成一個不同的密鑰。我們不能這樣做。爲了在所有應用程序中都能加密/解謎Cookie,需要移除IsolateApps屬性,併爲使用SSO的所有應用程序指定相同的具體密鑰:
如果你正在針對不同的用戶存儲進行驗證,這就是所有需要做的——對Web.Config的一點修改。
2.使用不同授權憑證(用戶名映射)的SSO
但是,如果Foo應用使用其自己的數據庫,而Bar應用程序使用Membership
API或其他形式的驗證呢?在這種情況下,爲Foo創建的Cookie並不適用於Bar,因爲Bar並不理解其中包含的用戶名。
爲了使其工作,需要創建第二個驗證Cookie,專門用於Bar應用。還需要一種方式來將Foo用戶映射到Bar用戶。假設Foo應用中登錄了一個“John
Doe”用戶,並且經過檢測發現這個用戶在Bar應用中的標識是“johnd”。在Foo
的驗證方法中需要添加下面的代碼:
但仍然要確保Web.Config中的<machineKey>元素中爲兩個應用提供了匹配的驗證和加密密鑰。
3. 同一域下的兩個子域中的Web應用之間的SSO
現在假設Foo和Bar配置爲在不同的域http://foo.com和http://bar.foo.com中運行。前面的代碼都不能使用了,因爲Cookies將被存放到不同的文件中,並且應用程序彼此看不到(對方的Cookie)。爲了使其能夠工作,我們需要創建域級別的Cookies,並使其對所有子域可見。這樣我們就不能使用RedirectFromLoginPage方法了,因爲它不適合創建域級別的Cookie。我們可以手動完成這一工作:
HttpCookie cookie = new HttpCookie(".BarAuth");
cookie.Value = FormsAuthentication.Encrypt(fat);
cookie.Expires = fat.Expiration;
cookie.Domain = ".foo.com"; // Highlight
HttpContext.Current.Response.Cookies.Add(cookie);
FormsAuthenticationTicket fat = new FormsAuthenticationTicket(1, "John Doe", DateTime.Now, DateTime.Now.AddYears(1), true, "");
HttpCookie cookie = new HttpCookie(".FooAuth");
cookie.Value = FormsAuthentication.Encrypt(fat);
cookie.Expires = fat.Expiration;
cookie.Domain = ".foo.com"; // Highlight
HttpContext.Current.Response.Cookies.Add(cookie);
注意高亮顯示的行(Anders Liu:爲了避免格式問題,我使用的是註釋“//
Highlight”)。通過明確地將Cookie的域設定爲“.foo.com”,可以確保在http://foo.com和http://bar.foo.com以及其他子域中都能看到該Cookie。你也可以將Bar的驗證Cookie域設置爲“bar.foo.com”。這樣更加安全,因爲其他子域看不到它。注意RFC
2109在Cookie域值中要求兩個periods,因此我們在前面添加了一個period——“.foo.com”。
另外,確保在每個應用的Web.Config中使用相同的<machineKey>元素。只有一種特殊情況,接下來的小節將探討這一情況。
4. 不同.NET版本下的應用之間的SSO
有一種可能是Foo和Bar應用運行在不同版本的.NET中。這是前面的例子就不能工作了。這是因爲ASP.NET
2.0使用了不同的加密方法對驗證票據進行加密。ASP.NET 1.1使用的是3DES,而ASP.NET
2.0使用的是AES。幸運的是,ASP.NET 2.0爲了向後兼容,提供了一個新的屬性:
設置decryption="3DES"可以讓ASP.NET 2.0使用老的加密方法,這樣Cookies就又匹配了。不要向ASP.NET 1.1的Web.Config中添加這個屬性,否則會導致錯誤。
5.不同域之衆的兩個應用之間的SSO
至此爲止我們成功地創建了共享的驗證Cookie,但如果Foo和Bar位於不同的域——http://foo.com和http://bar.com——中呢?它們不可能共享Cookie,也不能彼此創建第二Cookie。這種情況下,每個站點需要創建自己的Cookies,並調用其他站點來驗證用戶是否已經在別處登錄了。完成這一工作的一種方法就是通過一些列的重定向。
爲了實現這一目的,我們分別在兩個Web站點中都創建一個特殊的頁面(我們稱之爲sso.aspx)。這個頁面的目的就是檢查其域中是否存在Cookie,並返回登錄的用戶名,這樣其他應用可以在對應的域中創建類似的Cookie。下面是來自Bar.com的sso.aspx:
<script language="C#" runat="server">
void Page_Load()
{
// this is our caller, we will need to redirect back to it eventually
UriBuilder uri = new UriBuilder(Request.UrlReferrer);
HttpCookie c = HttpContext.Current.Request.Cookies[".BarAuth"];
if (c != null && c.HasKeys) // the cookie exists!
{
try
{
string cookie = HttpContext.Current.Server.UrlDecode(c.Value);
FormsAuthenticationTicket fat = FormsAuthentication.Decrypt(cookie);
uri.Query = uri.Query + "&ssoauth=" + fat.Name; // add logged-in user name to the query
}
catch
{
}
}
Response.Redirect(uri.ToString()); // redirect back to the caller
}
</script>
這個頁面總是會重定向回調用方。如果Bar.com中存在驗證Cookie,會解密用戶名並通過查詢字符串中的ssoauth參數返回。
在另外一端(Foo.com),我們需要像http請求處理流水線中插入一些代碼。可以在Application_BeginRequest事件中或者在一個自定義的HttpHandler或HttpModule中。其用意在於在所有的頁面請求的儘可能早的地方檢驗驗證Cookie是否存在:
1) 如果Foo.com中存在驗證Cookie,繼續處理請求。此時用戶已登錄Foo.com
2)
如果驗證Cookie不存在,重定向到Bar.com/sso.aspx
3)
如果當前請求從Bar.com/sso.aspx重定向回來,分析ssoauth參數並在必要時創建驗證Cookie。
這看起來相當簡單,但要注意無限循環:
HttpCookie c = HttpContext.Current.Request.Cookies[".FooAuth"];
if (c != null && c.HasKeys) // the cookie exists!
{
try
{
string cookie = HttpContext.Current.Server.UrlDecode(c.Value);
FormsAuthenticationTicket fat = FormsAuthentication.Decrypt(cookie);
return; // cookie decrypts successfully, continue processing the page
}
catch
{
}
}
// the authentication cookie doesn't exist - ask Bar.com if the user is logged in there
UriBuilder uri = new UriBuilder(Request.UrlReferrer);
if (uri.Host != "bar.com" || uri.Path != "/sso.aspx") // prevent infinite loop
{
Response.Redirect(http://bar.com/sso.aspx);
}
else
{
// we are here because the request we are processing is actually a response from bar.com
if (Request.QueryString["ssoauth"] == null)
{
// Bar.com also didn't have the authentication cookie
return; // continue normally, this user is not logged-in
} else
{
// user is logged in to Bar.com and we got his name!
string userName = (string)Request.QueryString["ssoauth"];
// let's create a cookie with the same name
FormsAuthenticationTicket fat = new FormsAuthenticationTicket(1, userName, DateTime.Now, DateTime.Now.AddYears(1), true, "");
HttpCookie cookie = new HttpCookie(".FooAuth");
cookie.Value = FormsAuthentication.Encrypt(fat);
cookie.Expires = fat.Expiration;
HttpContext.Current.Response.Cookies.Add(cookie);
}
}
兩個站點都同樣需要這段代碼,但要在每個站點中使用正確的Cookie名字(.FooAuth vs. .BarAuth)。由於實際上並沒有共享Cookie,所以應用程序可以具有不同的<machineKey>元素。無需同步加密和驗證密鑰。
很多人可能比較擔心在查詢字符串中傳遞用戶名所帶來的安全隱患。很多方法可以對其進行保護。首先,要檢查引用方,不接受來自任何源的ssoauth參數,但除了bar.com/sso.asp(或foo.com/sso.aspx)。其次,可以很容易地使用共享密鑰對用戶名進行加密。如果Foo和Bar使用了不同的驗證機制,也可以用類似的方式傳遞用戶的附加信息(例如email地址)。
6. 混合模式驗證(Forms和Windows)中的SSO
到現在爲止,我們一直在處理Forms驗證的情況。但如果我們希望對於Internet用戶首先採用Forms驗證,如果驗證失敗,再檢查是否是NT域中的Intranet用戶並進行驗證。理論上,我們可以通過下面的參數來檢查是否與請求關聯了一個Windows已登錄用戶:
然而,除非站點禁用了匿名訪問,否則該值一直爲空。我們可以在IIS控制面板中禁用匿名訪問,並啓用集成Windows驗證。這樣LOGON_USER值中將包含已登錄的Intranet用戶的NT域名。但是所有的Internet用戶將面臨Windows用戶名和密碼的挑戰。這不爽。我們希望Internet用戶可以通過Forms驗證進行登錄,而當失敗的時候再檢測其Windows域憑證。
解決這一問題的一個方法是,爲Intranet用戶提供一個特殊的入口頁,在這裏啓用集成Windows驗證,驗證域用戶,然後創建一個Forms
Cookie並導航到主站點。我們甚至可以通過Server.Transfer來隱藏Intranet用戶訪問了不同的頁面這一事實。
還有一種簡單的解決方案。因爲IIS處理驗證過程,如果一個Web站點啓用了匿名訪問,IIS會將請求正確地傳遞給ASP.NET運行時。它不會嘗試執行任何類型的驗證。然而,如果請求的結果是一個驗證錯誤(401),IIS會嘗試特定於該站點的另外一種驗證方法。你可以同時啓用匿名訪問和集成Windows驗證,然後再Forms驗證失敗後執行下面的代碼:
System.Web.HttpContext.Current.Response.StatusCode = 401;
System.Web.HttpContext.Current.Response.End();
}
else
{
// Request.ServerVariables["LOGON_USER"] has a valid domain user now!
}
這段代碼執行時,會首先檢測域用戶並得到一個空的字符串。然後它會終止當前請求並向IIS返回驗證錯誤(401)。這將導致IIS使用另外一種驗證機制,在這種情況下是集成Windows驗證。如果用戶已經登錄到域,請求會被重複一次,此時會填充NT域用戶信息。如果用戶沒有登錄到域,他將有三次機會輸入Windows用戶名/密碼。如果用戶無法在三次嘗試之內完成登錄,他會得到403錯誤(拒絕訪問)。
小結
我們討論了在兩個ASP.NET應用之間進行的各種場景的單點登錄。當然也可以實現不同平臺間的異構系統上的SSO。其思路是同樣的,但實現起來可能需要一些創造性的想法。