之所以為上集,是因為我並沒有解決這個問題,寫這篇博文的目的是紀錄一下我所遇到的問題,以免自己忘記,其實已經忘了差不多了,寫的過程也是自己回顧的過程,並且之前收集有關 ASP.NET 5 身份驗證的書簽已經太多了,所以必須記錄下來。
之所以為上集,是因為我並沒有解決這個問題,寫這篇博文的目的是紀錄一下我所遇到的問題,以免自己忘記,其實已經忘了差不多了,寫的過程也是自己回顧的過程,並且之前收集有關 ASP.NET 5 身份驗證的書簽已經太多了,所以必須記錄下來。
在前年(2014-12-10),我寫了這篇博文《愛與恨的抉擇:ASP.NET 5+EntityFramework 7》,背景是我當時打算用 ASP.NET 5 重寫一個 Web 項目,因為那時候 ASP.NET 5 剛發佈不久(之前叫 vNext),所以當時抱了很大的激情投入在上面,但最後的結果是給自己澆了一盆冷水,放棄的原因文章中已經總結了,關於為啥放棄 ASP.NET 5,就是因為身份驗證的問題,現在時間過去一年多了,現在回過頭來看,其實還是蠻有意思的,比如下麵我說一個。
其實最後我想要的功能是不綁定 DbContext,在 ASP.NET 5 項目中,只進行判斷操作,身份驗證在另外服務中進行,然後在本項目中可以實現類似 FormsAuthentication.SetAuthCookie 操作就可以了,但最後做了幾個 Demo 都不能實現,規定的一天時間,已經用完了,所以。。。
上面我前年想要實現的想法,其實我現在也在做這個工作,但中間已經過去一年多時間了,最後還是沒有實現。
登錄系統是一個獨立的站點,這是一個老的項目,身份驗證使用的是 Forms Authentication,因為涉及到其它站點,所以不能把登錄系統的身份驗證改寫為 Claims-based 或者 OAuth,這就意味著你需要讓其它站點的身份驗證方式,來相容 Forms Authentication,登錄系統獨立的好處是,其它站點不需要管理用戶的登錄和註銷功能,只需要判斷用戶有沒有通過身份驗證即可,就像我當時說的一樣,我只需要進行判斷操作,但最後做了很多 Demo 研究,還是實現不了,現在回過頭來看,當時如果實現了才真是見鬼了,因為 ASP.NET 5 根本就不支持 Forms Authentication(後面詳細說),所以,懂得放棄也是好事,畢竟時間是寶貴的。
後來,那個 Web 項目放棄使用 ASP.NET 5 + EF 7,然後用 ASP.NET MVC 5 + EF 6 重寫完成了,但心裡面還是很不甘心,其實在當時我並不是很懂 ASP.NET Identity 身份驗證,所以也導致浪費了很多時間,後來花了點時間重新學習了 ASP.NET Identity,也就是記錄的這篇博文《跌倒了,再爬起來:ASP.NET 5 Identity》,這篇博文的主要內容是查看 ASP.NET 5 Identity 的源碼,然後拋棄 ApplicationDbContext、UserManager、SignInManager 等等,直接實現用戶的登錄操作,並且成功實現驗證,看到博文最後,你會發現 ASP.NET Identity 和之前的 Forms Authentication 還是有很多不同的,但都是基於 Cookie 加密的方式,下麵看三段代碼:
Forms Authentication 方式登錄:
System.Web.Security.FormsAuthentication.SetAuthCookie("xishuai", false);
ASP.NET Identity 方式登錄(截止 2015-01-11):
var userId = await UserManager.GetUserIdAsync(user, cancellationToken);
Context.Response.SignIn(StoreTwoFactorInfo(userId, loginProvider));
ASP.NET Identity 方式登錄(最新,來自 SignInManager.cs):
var userId = await UserManager.GetUserIdAsync(user);
await Context.Authentication.SignInAsync(Options.Cookies.TwoFactorUserIdCookieAuthenticationScheme, StoreTwoFactorInfo(userId, loginProvider));
首先,ASP.NET Identity 和 Forms Authentication 都是通過把用戶信息加密後,放入響應頭的 Cookie 中,只不過兩種 Cookie 加密的方式不同(ASP.NET Identity 會更加複雜),所以如果登錄方式使用的 Forms Authentication,那在 ASP.NET 5 中就沒有辦法判斷用戶驗證,因為加密和解密要一一對應,如果不對應,那獲取到的 Cookie 就沒有辦法解密成功,所以也就沒有辦法通過身份驗證(IsAuthenticated 為 false),另外,關於 ASP.NET Identity,它不像一個技術點,有點類似於框架的概念,只不過把身份驗證的內容包裝了一下,比如產生了 ApplicationDbContext、UserManager、SignInManager 等等,作用就是讓你使用更加方便,查看源碼就知道,其實核心內容就是上面那些。
關於 SignInManager.cs 中的代碼,我們發現有很大的變化,比如 SignInAsync 中的代碼,Context.Authentication.SignInAsync
的實現,我們可以從 Security 項目中找到,具體在 Microsoft.AspNet.Authentication/AuthenticationHandler.cs,感覺和之前的相比變的複雜了。
回到最初的問題:在 ASP.NET 5 中,如何實現身份驗證(相容 Forms Authentication)?
上面的問題雖然看起來很簡單,但是有個首要前提:ASP.NET 5 不支持 Forms Authentication,那麼這個問題就變得複雜了,但我們可以拆分下:
- 瞭解現階段 ASP.NET 5 身份驗證的實現方式。
- 在 ASP.NET 5 中,解密 Cookie(通過 Forms Authentication 加密)。
我們先研究第一問題,首先,我們不使用 ASP.NET 5 Identity,而是直接登錄進行身份驗證,為什麼要這麼做?因為登錄系統不能重寫,所以我們使用 ASP.NET 5 Identity 也沒有什麼意義,況且多了一大堆不必要的東西(UserManager、SignInManager 等),會讓問題變的複雜,在之前的博文最後,有一個簡單示例,如下:
//app.UseIdentity();
app.UseCookieAuthentication((cookieOptions) =>
{
cookieOptions.AuthenticationType = IdentityOptions.ApplicationCookieAuthenticationType;
cookieOptions.AuthenticationMode = AuthenticationMode.Active;
cookieOptions.CookieHttpOnly = true;
cookieOptions.CookieName = ".CookieName";
cookieOptions.LoginPath = new PathString("/Account/Login");
cookieOptions.CookieDomain = ".mysite.com";
}, "AccountAuthorize");
[AllowAnonymous]
public IActionResult Login(string returnUrl = null)
{
var userId = "xishuai";
var identity = new ClaimsIdentity(IdentityOptions.ApplicationCookieAuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, userId));
Response.SignIn(identity);
return Redirect(returnUrl);
}
上面是一年前的代碼,一年後變成了這樣:
//app.UseIdentity();
app.UseCookieAuthentication((cookieOptions) =>
{
cookieOptions.AutomaticAuthenticate = true;
cookieOptions.AutomaticChallenge = true;
cookieOptions.CookieHttpOnly = true;
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(43200);
cookieOptions.LoginPath = new PathString("/account/login");
cookieOptions.CookieName = ".CNBlogsCookie";
cookieOptions.CookiePath = "/";
});
public async Task<IActionResult> Login(string returnUrl = null)
{
var userId = "xishuai";
var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
identity.AddClaim(new Claim(ClaimTypes.Name, userId));
await HttpContext.Authentication.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), new AuthenticationProperties() { IsPersistent = true });
return Redirect(returnUrl);
}
上面看似沒問題的代碼,但實際使用中遇到了很多的問題,比如生成 Cookie 的 Expires 為 Session,也就是我們設置的 ExpireTimeSpan 沒有作用,解決方式:SignInAsync 需要傳遞一個 new AuthenticationProperties() { IsPersistent = true }
參數,另外還有其它問題,我現在已經記不得了,不過記錄了一個 Issue:HttpContext.Authentication.SignInAsync not working,再貼一下 project.json 中程式包版本,後來測試很多次,可能是版本不一致引起的:
"dependencies": {
"Microsoft.AspNet.Authentication.Cookies": "1.0.0-rc2-16160",
"Microsoft.AspNet.DataProtection.Extensions": "1.0.0-rc2-15874",
"Microsoft.AspNet.Diagnostics": "1.0.0-rc2-16303",
"Microsoft.AspNet.IISPlatformHandler": "1.0.0-rc2-15994",
"Microsoft.AspNet.Mvc": "6.0.0-rc2-16614",
"Microsoft.AspNet.Mvc.TagHelpers": "6.0.0-rc2-16614",
"Microsoft.AspNet.Server.Kestrel": "1.0.0-rc2-16156",
"Microsoft.AspNet.StaticFiles": "1.0.0-rc2-16036",
"Microsoft.AspNet.Tooling.Razor": "1.0.0-rc2-15994",
"Microsoft.Extensions.Configuration.FileProviderExtensions": "1.0.0-rc2-15905",
"Microsoft.Extensions.Configuration.Json": "1.0.0-rc2-15905",
"Microsoft.Extensions.Logging": "1.0.0-rc2-15907",
"Microsoft.Extensions.Logging.Console": "1.0.0-rc2-15907",
"Microsoft.Extensions.Logging.Debug": "1.0.0-rc2-15907",
"Microsoft.VisualStudio.Web.BrowserLink.Loader": "14.0.0-rc2-16142"
}
後來折騰了很久,測試可以使用了,但發佈到伺服器的時候,又出現了問題,因為站點使用的是負載均衡,需要把程式發佈到兩台伺服器上,當兩台伺服器同時在跑的時候,比如登錄請求到一臺伺服器,驗證剛好請求到另一臺伺服器,這時候身份驗證就沒有效果,然後跳轉到登錄頁面,這個問題折騰我很久,自己怎麼配置都不行,後來沒有辦法,向微軟提了一個 Issue:Multiple web servers CookieAuthentication does not work,問題提出後,很快有人回覆了,問題原因是需要提供一個 key,這個有點像 Forms Authentication 方式中 Web.config 的 MachineKey,我們需要將身份驗證的配置,修改如下:
var dataProtection = new Microsoft.AspNet.DataProtection.DataProtectionProvider(new DirectoryInfo(@"c:\shared-auth-ticket-keys\"));
app.UseCookieAuthentication((cookieOptions) =>
{
cookieOptions.AutomaticAuthenticate = true;
cookieOptions.AutomaticChallenge = true;
cookieOptions.CookieHttpOnly = true;
cookieOptions.ExpireTimeSpan = TimeSpan.FromMinutes(43200);
cookieOptions.LoginPath = new PathString("/account/login");
cookieOptions.CookieName = ".CNBlogsCookie";
cookieOptions.CookiePath = "/";
cookieOptions.DataProtectionProvider = dataProtection;
});
後來重新發佈,測試還是出現問題,和之前的問題一樣,跳轉到登錄頁面,然後我嘗試把一臺伺服器生成在 c:\shared-auth-ticket-keys
目錄下的 key 文件,拷貝到另外一臺伺服器中,但還是沒用,過了很多天,有人回覆了:
You need to point the key directory to a shared directory which both applications can access. Putting it in c:\shared-auth-ticket-keys\ isn't enough in multiple server scenarios, as it's still going to create a key ring local to each machine.
You need to create an UNC share somewhere that both applications can access, and use that, for example \keystore\keystore
Or you implement a key store yourself suitable to your architecture, for example, using SQL Server.
大致意思是,雖然是同一個目錄,但會在不同伺服器生成不同的 key 文件,所以身份驗證就不通過,解決方式是使用 key 共用文件,這樣讓不同伺服器都能訪問同一個 key 文件,另外一種方式是將 key 存儲在一個地方,比如 SQL Server 中,但我不是很瞭解 key 的讀取和存儲方式,所以,我最後嘗試用第一種方式解決,只需要我們將目錄更改為共用目錄:
var dataProtection = new Microsoft.AspNet.DataProtection.DataProtectionProvider(new DirectoryInfo(@"\\10.10.10.10\shared-auth-ticket-keys\"));
後來再重新發佈,還是出現了問題,比如共用文件放在一臺伺服器上,這台伺服器訪問沒用什麼問題,但另一臺伺服器卻不能訪問,文件資源管理器可以訪問此共用文件,這個問題也折騰我很久,但不和 ASP.NET 5 相關,主要問題是不瞭解 ASP.NET 如何訪問共用文件,後來找資料解決了,記錄了一篇博文:ASP.NET 訪問共用文件夾。
目前的情況:第一個問題已經實現,但是比較簡陋,開始考慮並實現第二個問題。
一開始的時候,我提了一個 Issue:Share ASP.NET MVC 5 Forms authentication?
這個 Issue 我覺得很有價值,它讓我瞭解了很多東西,比如 ASP.NET 5 不支持 Forms Authentication,ASP.NET 5 和 Forms Authentication 的 Cookie 加密方式不同,ASP.NET 5 會更加複雜,因為登錄系統不能被重寫,並且 ASP.NET 5 不支持 Forms Authentication,那麼擺在我面前的只有一條路,在 ASP.NET 5 中,解密 Cookie(通過 Forms Authentication 加密),針對這個問題,我的一些想法:
其實看起來這個問題好像不是很複雜,通過 Key 加密生成 Cookie(Forms Authentication),然後通過下麵方式獲取 Cookie(ASP.NET 5):
var cookies = Request.Cookies.First(x => x.Key == ".CNBlogsCookie").Value;
然後通過某些手段解密生成 IdentityUser 對象,對,沒錯,就這麼簡單。
我們先不住 ASP.NET 5 中實現下,很簡單:
var cookies = "";
FormsAuthenticationTicket authTicket = FormsAuthentication.Decrypt(cookies);
string[] roles = authTicket.UserData.Split(new char[] { ';' });
var user = new GenericPrincipal(User.Identity, roles);
這段代碼是執行成功的,但我們需要在 Web.config 中,配置如下代碼:
這段���碼必須要和登錄站點中的配置一樣,原因是加密和解密的方式要一一對應,接下來的工作,我們需要在 ASP.NET 5 中實現上面的代碼,但你會發現找不到 FormsAuthentication.Decrypt
了,這麼辦呢?只能查看源碼,然後把相關代碼貼出來編譯一下,如果成功了(我嘗試了很多次,因為涉及的代碼太多,實現起來非常困難),這是第一步,第二步我們將編譯通過的代碼,放在 ASP.NET 5 中再編譯一次,這個工作我還沒做,不過看起來並不是那麼簡單,因為運行時和基礎類庫都發生變化了。
如果重寫這部分代碼,我貼一下需要的一些資源(後面再嘗試下):
- System.Web/Security/FormsAuthentication.cs(referencesource)
- System.Web/Security/FormsAuthentication.cs(GitHub)
- System.Web/Security/FormsAuthentication.cs(mono)
- https://github.com/aspnet/Identity
- https://github.com/aspnet/Security
- https://github.com/aspnet/dataprotection
後來,上面那個 Issue 有人回覆如下:
看到這,有點想哭的趕腳,但不管怎樣,還是要嘗試下,希望下集是一個成功的博文記錄,未完待續。。。
最後,貼一下這段時間累積的有關資料:
- Sharing cookies between applications.
- Understanding OWIN Forms authentication in MVC 5
- asp.net - Cookie-based Forms Authentication Across MVC 5 and MVC 6 (vNext) Applications
- MVC5 - ASP.NET Identity登錄原理 - Claims-based認證和OWIN
- FormsAuthenticationTicket基於forms的驗證
- asp.net Forms表單驗證 使用經驗及驗證流程分析
- Difference between Claims vs OAuth
- Adding ASP.NET Identity to an Empty or Existing Web Forms Project
- authentication - CookieAuthenticationOptions, ExpireTimeSpan does not work
- Understanding OWIN Forms authentication in MVC 5
- Reading Katana Cookie Authentication Middleware’s Cookie from FormsAuthenticationModule
- MVC Forms Authentication and Storing Data in the Cookie
- Use MachineKey in ASP.NET vNext
- Setting the Machine Key as usual? ... or any other gotchas for web farm scenarios?
- Key Encryption At Rest