Fork me on GitHub

坎坷路:ASP.NET Core 1.0 Identity 身份验证(中集)

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

ASP.NET Core 1.0 什么鬼?它是 ASP.NET vNext,也是 ASP.NET 5,以后也可能叫 ASP.NET XXX(微软改名很不靠谱,说改就改😡)。

之所以为中集,是因为上集遗留的问题并没有完全解决,但已经有很大的进步,我觉得对于我来说,现阶段是可以接受的,简单总结下上集遗留的三个问题或计划:

  1. 尝试解决 ASP.NET Core 1.0 中解密 Forms Authentication 生成的 Cookie。
  2. 尝试解决 ASP.NET Core 1.0 中身份验证 key 存储问题(不使用文件共享方式)。
  3. ASP.NET Core 1.0 身份验证问题换种方式尝试解决:原有登录站点调用 ASP.NET Core 1.0 登录站点并完成回调,实现登录后的 Cookie 添加。

其实当时列出这三个计划的时候,我是一点头绪都没有,因为之前也花了点时间简单了解了下,但不知道从何入手,因为自己啥都不懂,对于实际项目遇到的问题,网上资料根本找不到,所以只能查看源码和给微软提 Issue,又因为自己英文实在渣,所以交流起来很费劲,过程很艰辛啊。

先列一下涉及的微软项目:

分别说一下三个计划。

相关 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 中的代码复制在 ASP.NET Core 1.0 中,然后看报什么错,一步一步的去解决,过程我就不详细说了,怎么说呢?就像你在毛衣上扯一根线,但最后会扯出千万条线,比如下面:

上面就是 FormsAuthentication.Decrypt 这一个方法所牵扯出的东西,最后终于把相关代码扯出来了,并且也把错误解决了,但运行还是出现了问题,没有解密成功(应该是解决错误的时候,修改代码出现了问题),关于这个工作我已经做了两次,这次比较认真些,代码基本上没有删除多少,但还是出现了问题,对我打击很大,怎么说呢?我打算已经放弃了,不是说完成不了(我觉得还是可以成功的),只是需要花更多的时间在上面,我觉得不值得,还是另寻出路吧。

后来,我无意间 Google 搜索,发现有人也遇到了和我一样的问题:

那个回答者说的解决方案,也是重写 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),我先贴一下相关资料:

后来,查看了下 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 版本的时候,就会省掉很多的问题,哎,这一集并没有完美的解决问题,希望在下集的时候,身份验证的问题可以得到完美解决。

posted @ 2016-01-22 11:57  田园里的蟋蟀  阅读(10435)  评论(10编辑  收藏  举报