上一篇:《坎坷路:ASP.NET 5 Identity 身份驗證(上集)》
ASP.NET Core 1.0 什么鬼?它是 ASP.NET vNext,也是 ASP.NET 5,以后也可能叫 ASP.NET XXX(微軟改名很不靠譜,說改就改😡)。
之所以為中集,是因為上集遺留的問題并沒有完全解決,但已經有很大的進步,我覺得對于我來說,現階段是可以接受的,簡單總結下上集遺留的三個問題或計劃:
- 嘗試解決 ASP.NET Core 1.0 中解密 Forms Authentication 生成的 Cookie。
- 嘗試解決 ASP.NET Core 1.0 中身份驗證 key 存儲問題(不使用文件共享方式)。
- ASP.NET Core 1.0 身份驗證問題換種方式嘗試解決:原有登錄站點調用 ASP.NET Core 1.0 登錄站點并完成回調,實現登錄后的 Cookie 添加。
其實當時列出這三個計劃的時候,我是一點頭緒都沒有,因為之前也花了點時間簡單了解了下,但不知道從何入手,因為自己啥都不懂,對于實際項目遇到的問題,網上資料根本找不到,所以只能查看源碼和給微軟提 Issue,又因為自己英文實在渣,所以交流起來很費勁,過程很艱辛啊。
先列一下涉及的微軟項目:
- https://github.com/aspnet/Identity (基本沒用到)
- https://github.com/aspnet/Security
- https://github.com/aspnet/DataProtection
分別說一下三個計劃。
1. 解決 ASP.NET Core 1.0 中解密 Forms Authentication 生成的 Cookie
相關 Issue:Share ASP.NET MVC 5 Forms authentication?
為啥要解決這個問題,上集已經詳細說明了,其實,解決這個問題看起來很簡單,就是只需要在 ASP.NET Core 1.0 中成功調用 FormsAuthentication.Decrypt,就可以了,但因為 ASP.NET Core 1.0 完全是另一種實現,雖然我們可以安裝 Microsoft.AspNet.DataProtection.SystemWeb 程序包,可以直接調用 FormsAuthentication.Decrypt,但運行會拋出下面異常:
Stack = at System.Web.Configuration.MachineKeySection.EncryptOrDecryptData(Boolean fEncrypt, Byte[] buf, Byte[] modifier, Int32 start, Int32 length, Boolean useValidationSymAlgo, Boolean useLegacyMode, IVType ivType, Boolean signData) at System.Web.Security.FormsAuthentication.Decrypt(String encryptedTicket) at WebApplication.Mvc.MySecureDataFormat.Unprotect(String protectedText, String purpose)
即使你在 wwwroot 目錄下的 web.config 添加 machineKey 節點內容,但還是會報錯的,原因就是 ASP.NET Core 1.0 和之前的 ASP.NET 版本運行完全不同,并且數據的加密和解密方式更加復雜,所以我們想要在 ASP.NET Core 1.0 中兼容之前 ASP.NET 版本的身份驗證,就必須在 ASP.NET Core 1.0 中重寫 FormsAuthentication.Decrypt。
- FormsAuthentication.Decrypt 源碼:https://github.com/Microsoft/referencesource/blob/master/System.Web/Security/FormsAuthentication.cs#L128
好,下面我們開工,不就是重寫一個方法嗎?應該會很簡單,我的做法是從頭到尾,就是先把 FormsAuthentication.Decrypt 中的代碼復制在 ASP.NET Core 1.0 中,然后看報什么錯,一步一步的去解決,過程我就不詳細說了,怎么說呢?就像你在毛衣上扯一根線,但最后會扯出千萬條線,比如下面:
上面就是 FormsAuthentication.Decrypt 這一個方法所牽扯出的東西,最后終于把相關代碼扯出來了,并且也把錯誤解決了,但運行還是出現了問題,沒有解密成功(應該是解決錯誤的時候,修改代碼出現了問題),關于這個工作我已經做了兩次,這次比較認真些,代碼基本上沒有刪除多少,但還是出現了問題,對我打擊很大,怎么說呢?我打算已經放棄了,不是說完成不了(我覺得還是可以成功的),只是需要花更多的時間在上面,我覺得不值得,還是另尋出路吧。
后來,我無意間 Google 搜索,發現有人也遇到了和我一樣的問題:
- MVC6 Decrypting a forms authentication cookie from another website
- FormsAuthenticationTicketHelper.cs
- partial.Global.asax.cs
那個回答者說的解決方案,也是重寫 FormsAuthentication.Decrypt,只不過代碼更加簡單,后來我也試了下,但還是會拋出異常:
具體發生在 CheckHash 檢查的時候,沒有成功,具體原因我也沒找出來,如果有園友也遇到同樣問題,歡迎告知。
這個問題解決結果:失敗!
2. 解決 ASP.NET Core 1.0 中身份驗證 key 存儲問題(不使用文件共享方式)
相關 Issue:
在之前的一個 Issue 中,有個回復:
這部分內容很關鍵,關于身份驗證 key 的問題,我覺得如果大家實際應用 ASP.NET Core 1.0 項目的時候(不是做 Demo),應該都會遇到,我的問題是相同站點發布到多臺服務器(使用負載均衡),單臺服務器運行沒問題,但使用負載均衡的時候,就會出現身份驗證不成功(具體表現是一臺服務器身份驗證,在另外一臺服務器上不通過),即使不使用負載均衡,我覺得也會出現這個問題,比如一個主站點(www.cnblogs.com)和一個子站點(home.cnblogs.com),這是兩個 Web 應用程序,我們需要發布在兩個 IIS 站點下,這時候你會發現在一臺主站點上的身份驗證,在子站點上不通過,我們之前使用 ASP.NET 版本的時候,會在 web.config 中添加相同的 machineKey 節點,但現在即使你把 ASP.NET Core 1.0 生成的 key.xml 文件拷貝成一樣,也會出現問題,具體原因就是上面那個 Issue 回復(只是很淺的解釋,并沒有深入說明原因,后面會提到)。
為了避免這個問題,在上集中我使用了 UNC 文件共享方式,具體就不說了,雖然解決了問題,但以后如果大量 ASP.NET Core 1.0 站點也這樣使用,就會造成安全隱患,所以,我打算換一種存儲讀取 key 的方式,比如使用 SQL Server,也就是我上面提的第二個 Issue,不過多久,就有人回復了,但具體的回復內容,我沒看太懂(他是使用的 Azure),我先貼一下相關資料:
- https://docs.asp.net/en/latest/security/data-protection/extensibility/key-management.html#ixmlrepository
- https://docs.asp.net/en/latest/security/data-protection/implementation/key-encryption-at-rest.html#certificate-based-encryption-with-windows-dpapi-ng
- Add Azure Storage / Azure Key Vault extensibility to DataProtection
后來,查看了下 DataProtection 的相關源碼,發現了下面的東西:
三種儲存方式:
- EphemeralXmlRepository:內存儲存,短暫的。
- FileSystemXmlRepository:文件存儲,會生成 key-xxxxx.xml。
- RegistryXmlRepository:證書存儲。
都繼承自 IXmlRepository:
using System;
using System.Collections.Generic;
using System.Xml.Linq;
namespace Microsoft.AspNet.DataProtection.Repositories
{
/// <summary>
/// The basic interface for storing and retrieving XML elements.
/// </summary>
public interface IXmlRepository
{
/// <summary>
/// Gets all top-level XML elements in the repository.
/// </summary>
/// <remarks>
/// All top-level elements in the repository.
/// </remarks>
IReadOnlyCollection<XElement> GetAllElements();
/// <summary>
/// Adds a top-level XML element to the repository.
/// </summary>
/// <param name="element">The element to add.</param>
/// <param name="friendlyName">An optional name to be associated with the XML element.
/// For instance, if this repository stores XML files on disk, the friendly name may
/// be used as part of the file name. Repository implementations are not required to
/// observe this parameter even if it has been provided by the caller.</param>
/// <remarks>
/// The 'friendlyName' parameter must be unique if specified. For instance, it could
/// be the id of the key being stored.
/// </remarks>
void StoreElement(XElement element, string friendlyName);
}
}
好,問題簡單了,我們只要基于 IXmlRepository 實現一個 CustomXmlRepository 就可以了:
public class CustomXmlRepository : IXmlRepository
{
private readonly string keyContent =@""; //key-xxxxx.xml 文件內容
public virtual IReadOnlyCollection<XElement> GetAllElements()
{
return GetAllElementsCore().ToList().AsReadOnly();
}
private IEnumerable<XElement> GetAllElementsCore()
{
yield return XElement.Parse(keyContent);
}
public virtual void StoreElement(XElement element, string friendlyName)
{
if (element == null)
{
throw new ArgumentNullException(nameof(element));
}
StoreElementCore(element, friendlyName);
}
private void StoreElementCore(XElement element, string filename)
{
}
}
Startup.cs 代碼:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IXmlRepository, CustomXmlRepository>();
services.AddDataProtection();
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
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 = ".CNBlogsAdCookie";
cookieOptions.CookiePath = "/";
});
}
但發布后還是出現相同問題,后來發現是沒有設置一個相同的 ApplicationName,將上面的 ConfigureServices 代碼修改如下:
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IXmlRepository, CustomXmlRepository>();
services.AddDataProtection(configure =>
{
configure.SetApplicationName("CNBlogs.Ad.Web");
});
}
重新發布,運行沒有出現問題,原因如下:
如果我們不設置 ApplicationName,ASP.NET Core 1.0 會在運行的時候,根據系統環境自動生成一個 ApplicationName,但這個 ApplicationName 并沒有存儲在 key-xxxxx.xml 文件中,這也就是為什么我們把它拷貝成相同文件,身份驗證也是不通過的,因為 ApplicationName 不一樣,我覺得 ApplicationName 會在運行的時候,存儲在站點內存中,為什么要這么做?為了安全考慮,比如你雖然知道了 key-xxxxx.xml 內容,但沒有 ApplicationName,身份驗證同樣通過不了。
后來我又做了下面的測試:
public void ConfigureServices(IServiceCollection services)
{
services.AddDataProtection(configure =>
{
configure.SetApplicationName("CNBlogs.Ad.Web");
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
var dataProtection = new Microsoft.AspNet.DataProtection.DataProtectionProvider(new DirectoryInfo(@"C:\keys"));// no use UNC share
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 = ".CNBlogsAdCookie";
cookieOptions.CookiePath = "/";
cookieOptions.DataProtectionProvider = dataProtection;
});
}
這個測試我原以為會成功,但運行結果是失敗的,具體原因是我們創建了一個 DataProtectionProvider,來用于身份驗證的數據加密解密,即使你在 ConfigureServices 中設置了 ApplicationName,但還是會自動創建 ApplicationName,所以需要把上面代碼修改如下:
public void ConfigureServices(IServiceCollection services)
{
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
// no use UNC share
var dataProtection = new Microsoft.AspNet.DataProtection.DataProtectionProvider(new DirectoryInfo(@"C:\keys"),
configure =>
{
configure.SetApplicationName("CNBlogs.Ad.Web");
});
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 = ".CNBlogsAdCookie";
cookieOptions.CookiePath = "/";
cookieOptions.DataProtectionProvider = dataProtection;
});
}
上面是運行是成功的,我覺得是最終合適代碼,前提是需要把各個站點或服務器,生成的 key-xxxxx.xml 拷貝成一樣(文件名也是),那為什么不使用 SQL Server?不是不能使用,我們只需要實現一個 IXmlRepository 就可以了,不這樣做是為了性能考慮,因為 SQL Server 需要網絡開銷,并且這個訪問還是比較頻繁的,所以這樣做有點不值得。
這個問題解決結果:成功!
3. ASP.NET Core 1.0 身份驗證問題換種方式嘗試解決
我之前的計劃:原有登錄站點調用 ASP.NET Core 1.0 登錄站點并完成回調,實現登錄后的 Cookie 添加。
我現在覺得還有一種方式,會比它更好,因為上面這種方式需要更改登錄站點的代碼,如果不想更改,我們可以這樣做:
用戶登錄后(通過 Forms Authentication),訪問 ASP.NET Core 1.0 站點,這時候我們把 Cookie 截獲,然后請求到另外一個解密站點(Forms Authentication),根據 Cookie 解密生成用戶信息(FormsAuthentication.Decrypt),然后把用戶信息返回給 ASP.NET Core 1.0,然后再進行身份驗證(ASP.NET Core 1.0 方式),加密生成 Cookie 添加到相應頭中,這時候再進行請求,因為已經通過身份驗證(ASP.NET Core 1.0 方式),所以就不需要再請求到解密站點進行解密 Cookie 了。
這個解決方式和上一個差不多,如果不更改登錄站點代碼,我覺得這個方式還是蠻好的,具體的實現其實很簡單,就是需要多考慮一下各種情況。
下面我貼一下實現的簡單代碼,先是解密站點的代碼:
[RoutePrefix("cookies")]
public class CookiesController : ApiController
{
[Route("")]
public HttpResponseMessage Get(string cookie)
{
var formsAuthenticationTicket = FormsAuthentication.Decrypt(cookie);//重點在這
if (formsAuthenticationTicket == null) return Request.CreateResponse(HttpStatusCode.NotFound);
var response = Request.CreateResponse(HttpStatusCode.OK);
response.Content = new StringContent(formsAuthenticationTicket?.Name, Encoding.UTF8, "text/plain");
return response;
}
}
web.config 需要添加 machineKey 節點:
<system.web>
<machineKey decryption="AES" decryptionKey="decryptionKey" validation="SHA1" validationKey="validationKey" compatibilityMode="Framework45" />
<authentication mode="Forms">
<forms name=".CNBlogsCookie" loginUrl="http://passport.cnblogs.com/login.aspx" domain=".cnblogs.com" protection="All" timeout="43200" path="/" cookieless="UseCookies" />
</authentication>
</system.web>
下面是 ASP.NET Core 1.0 站點代碼:
public class AuthorizeAttribute : ActionFilterAttribute
{
private IUserService _userService;
public AuthorizeAttribute()
{
_userService = new UserService();
}
public override void OnActionExecuting(ActionExecutingContext context)
{
var cookie = "";
var cookies = context.HttpContext.Request.Cookies;
if (cookies.Count > 0)
{
cookie = cookies.FirstOrDefault(x => x.Key == ".CNBlogsCookie").Value;
}
if (!string.IsNullOrEmpty(cookie) && !context.HttpContext.User.Identity.IsAuthenticated)
{
var userName = _userService.GetUserNameByCookie(cookie).Result;//根據 Cookie 獲取解密后的 UserName
if (!string.IsNullOrEmpty(userName))
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, userName) },
CookieAuthenticationDefaults.AuthenticationScheme));
context.HttpContext.Authentication.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
user,
new AuthenticationProperties() { IsPersistent = true });//進行新的用戶身份登錄
if (_userService.IsUserInRole(userName).Result)
{
base.OnActionExecuting(context);
return;
}
}
}
else if (context.HttpContext.User.Identity.IsAuthenticated)
{
if (_userService.IsUserInRole(context.HttpContext.User.Identity.Name).Result)
{
base.OnActionExecuting(context);
return;
}
}
else if (string.IsNullOrEmpty(cookie))
{
context.Result = new RedirectResult("http://passport.cnblogs.com/user/signin?ReturnUrl=");
return;
}
context.Result = new RedirectResult("http://www.cnblogs.com/");
return;
}
public override void OnActionExecuted(ActionExecutedContext context)
{
base.OnActionExecuted(context);
}
public override void OnResultExecuting(ResultExecutingContext context)
{
base.OnResultExecuting(context);
}
public override void OnResultExecuted(ResultExecutedContext context)
{
base.OnResultExecuted(context);
}
}
上面代碼主要是身份驗證的一些邏輯,沒有太大技術含量,我使用的是 ActionFilterAttribute,其作用就是在請求 Action 的時候截獲,所以 Action 的代碼很簡單:
[Authorize]
public IActionResult Index()
{
return View();
}
這樣就可以了,別忘了 Startup.cs 中添加 UseCookieAuthentication 相關代碼,也可以不用 Cookie Authentication,使用 ASP.NET Identity 也可以。
這種方式我已經實際測試過了,是成功的。
其實,如果微軟能在開發 ASP.NET Core 1.0 的時候,出一個兼容 Forms Authentication 的版本,那該多好,這樣我們在由 ASP.NET 老版本升級到 ASP.NET Core 1.0 版本的時候,就會省掉很多的問題,哎,這一集并沒有完美的解決問題,希望在下集的時候,身份驗證的問題可以得到完美解決。
文章列表