坎坷路:ASP.NET 5 Identity 身份验证(上集)

坎坷路:ASP.NET 5 Identity 身份验证(上集)

之所以为上集,是因为我并没有解决这个问题,写这篇博文的目的是纪录一下我所遇到的问题,以免自己忘记,其实已经忘了差不多了,写的过程也是自己回顾的过程,并且之前收集有关 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,那么这个问题就变得复杂了,但我们可以拆分下:

  1. 了解现阶段 ASP.NET 5 身份验证的实现方式。
  2. 在 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 中再编译一次,这个工作我还没做,不过看起来并不是那么简单,因为运行时和基础类库都发生变化了。

如果重写这部分代码,我贴一下需要的一些资源(后面再尝试下):

后来,上面那个 Issue 有人回复如下:

坎坷路:ASP.NET 5 Identity 身份验证(上集)

看到这,有点想哭的赶脚,但不管怎样,还是要尝试下,希望下集是一个成功的博文记录,未完待续。。。

最后,贴一下这段时间累积的有关资料:

上一篇:docker搭建ros-indigo-arm交叉编译环境


下一篇:IdentityServer4 使用OpenID Connect添加用户身份验证