如何简单的在 ASP.NET Core 中集成 JWT 认证?

2018/07/30

前情提要:ASP.NET Core 使用 JWT 搭建分布式无状态身份验证系统

文章超长预警(1万字以上),不想看全部实现过程的同学可以直接跳转到末尾查看成果或者一键安装相关的 nuget 包

自上一篇介绍如何在 ASP.NET Core 中集成 JWT 的博文发布接近一年后,我又想来分享一些使用 JWT 的经验了。过去的一年中,我每次遇到一些小的,垃圾的项目,就会按照去年的那片文章来进行配置,虽然代码不多,但是每次写这么一些模板代码,又感觉很枯燥、冗余,而且稍不注意就有可能配置的有问题,导致验证不成功。前几天,我继续写自己的垃圾毕设,写到集成 JWT 的时候,我终于忍受不了这种重复的配置工作了,于是便着手封装一个简单易用的 JWT 插件。

之前集成 JWT 的方法在 ConfigureServices 方法里面添加了太多细节上的东西,所以在新的实现里面,添加服务依赖的 API 一定要足够简单,其次,之前的实现里面,签发一个 Token 步骤太多且比较复杂,所以签发 Token 的步骤也要简化。最后,之前在 Cookie 中添加 JWT 支持也比较 hack,跟 ASP.NET Core 的集成也不是很好。带着这些痛点,我在网上经历了一番搜索,最终找到了这个仓库 ,本来都想直接用他的实现了,不过他的配置看起来还是有些麻烦的,所以没办法,只好自己手写一个了。

从设计配置 API 开始

其实不管是我之前写的实现还是 GitHub 上找到的那个仓库的实现,最让我不满意的地方就是配置,很多时候,我就只想快速地搭建一个项目,根本不想去研究“怎样配置”,所以我的第一步的目标就是设计一个简单的配置接口:

public abstract class EasyJwtOption
{
    public string Audience { get; set; }
    public string Issuer { get; set; }
    public bool EnableCookie { get; set; }
    /// <summary>
    /// 自定义 Cookie 选项,可空
    /// </summary>
    public Action<CookieAuthenticationOptions> CookieOptions { get; set; }
    /// <summary>
    /// 自定义 jwt 选项,可空
    /// </summary>
    public Action<JwtBearerOptions> JwtOptions { get; set; }
    public abstract SecurityKey GenerateKey();
    public abstract SigningCredentials GenerateCredentials();
}

EasyJwtOption 是用来进行描述 EasyJwt 配置的类型,它的每个属性都是我们可以进行配置的地方,同时为了避免把 ASP.NET Core 自带的对 JWT 跟 Cookie 的配置项目重写一遍,我就定义了 CookieOptionsJwtOptions 这两个属性,用来向微软的 AuthenticationBuilder 传递配置。

GenerateKey() GenerateCredentials() 这两个抽象方法则跟加密算法相关,在 JWT 中,我们可以使用两类算法进行加密:对称加密与非对称加密,在我之前写的文章中,我使用的是非对称加密的 RSA 算法,将原先的配置写成新的 EasyJwtOption 就是:

public class EasyRSAOptions : EasyJwtOption
{
    public EasyRSAOptions(string path)
    {
        if (string.IsNullOrEmpty(path))
        {
            throw new ArgumentException("Path can not be null", nameof(path));
        }

        Path = path;
    }

    public string Path { get; set; }
    public override SecurityKey GenerateKey()
    {
        if (RsaUtils.TryGetKeyParameters(Path, true, out var rsaParams) == false)
        {
            rsaParams = RsaUtils.GenerateAndSaveKey(Path);
        }

        return new RsaSecurityKey(rsaParams);
    }

    public override SigningCredentials GenerateCredentials()
    {
        return new SigningCredentials(GenerateKey(), SecurityAlgorithms.RsaSha256);
    }
}

由于 RSA 算法的私钥与密钥只能机器生成,所以我还是延续了以前的做法,把算法参数导出成 json 保存在本地,故 EasyRSAOptions 的构造函数接受一个存储位置作为必须参数。但是这种做法普适性不太好,更好的做法是把 RSA 私钥与公钥导出成标准格式的文本,这样其他的应用也可以导入,不过我比较懒,先这么凑活吧。

在 GitHub 找到的那个项目中,作者使用的是对称加密算法,把这个算法改成我的 EasyJwtOption 就是:

public class EasySymmetricOptions : EasyJwtOption
{
    public EasySymmetricOptions(string secret)
    {
        Secret = secret ?? throw new ArgumentNullException(nameof(secret));
        Secret = Secret.GetMd5();
    }

    public string Secret { get; set; }
    public override SecurityKey GenerateKey()
    {
        return new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Secret));
    }

    public override SigningCredentials GenerateCredentials()
    {
        return new SigningCredentials(GenerateKey(), SecurityAlgorithms.HmacSha256);
    }
}

在非对称加密算法中,我们需要提供一个密钥供加密、解密使用,所以 EasySymmetricOptions 的构造函数接受一个任意的字符串作为参数,又因为 SymmetricSecurityKey 对安全性的要求,密钥的长度太短会报出异常,用户的输入的密钥字符串进行了一些转换,来满足密钥长度条件。

方便的签发 Token

为了能够让网站的各个组件能够方便的随时签发 Token ,我设计了下面这个类,它的构造函数接受一个 EasyJwtOption 作为参数:

public class EasyJwt
{
    private readonly EasyJwtOption _option;

    public EasyJwt(EasyJwtOption option)
    {
        _option = option;
    }

    public string GenerateToken(string userName, IEnumerable<Claim> claims, DateTime expiratoin)
    {
        ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(userName));
        identity.AddClaims(claims);
        var handler = new JwtSecurityTokenHandler();
        var token = handler.CreateEncodedJwt(new SecurityTokenDescriptor
        {
            Issuer = _option.Issuer,
            Audience = _option.Audience,
            SigningCredentials = _option.GenerateCredentials(),
            Subject = identity,
            Expires = expiratoin
        });
        return token;
    }
}

只要我们在 Starpup.ConfigureServices 方法中把这个类添加进 IoC 容器,任何依赖 EasyJwt 的对象都可以非常简便的为用户生成 Token,调用方法大致如下:

var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, userName, ClaimValueTypes.String)
};
var token = _jwt.GenerateToken(userName, claims, DateTime.Now.AddDays(1));

claims 是 Identity 中的概念,表示用户的信息,例如:用户名、邮箱。签发 token 需要指定用户名、用户相关的信息以及 token 过期时间。我们 EasyJwt 得到了签发 token 所需要的参数后会创建一个 ClaimsIdentity 对象,这同样也是 Identity 中的概念,用来表示用户的一些身份信息的集合,我们可以把一个 Identity 对象想象成一张通行证,上面记录着用户的身份信息。一个用户可以有多张通行证,这些通行证既可以由我们自己的应用生成,也可以由第三方授权的应用生成,不过具体的细节就涉及到了 Identity 的身份认证设计,在此就不拓展来讲了。

为应用添加 JWT 认证支持

上面说了这么多还只是停留在签发 Token 的阶段,进行身份认证从这里才真正开始。微软早就已经提供了一个添加 JWT 认证支持的拓展,不过那个还不算特别的简单易用,所以我就在微软的 API 之上设计了一个新的拓展方法来结合之前的 EasyJwt 配置 JWT 认证:

public static IServiceCollection AddEasyJwt(this IServiceCollection services, EasyJwtOption option)
{
    var easyJwt = new EasyJwt(option);
    var jwtParams = easyJwt.ExportTokenParameters();
    services.AddSingleton(easyJwt);

    var authBuilder = services.AddAuthentication()
        .AddJwtBearer(jwtOptions =>
        {
            jwtOptions.Audience = option.Audience;
            jwtOptions.ClaimsIssuer = option.Issuer;
            jwtOptions.TokenValidationParameters = jwtParams;
            option.JwtOptions?.Invoke(jwtOptions);
        });

    return services;
}

这个拓展方法接受一个 EasyJwtOption 的子类实例作为参数,并通过这个参数初始化一个 EasyJwt 对象,并将其添加进 IoC 容器中。接着就是简单的调用微软的拓展方法,为应用程序添加 JwtBearer 认证。这里的 jwtParams 是由 EasyJwt 对象导出的,具体的导出代码实现可以在我的 GitHub 上看到,并不是很重要的代码,所以就不在这里贴出来了。

为 Cookie 添加 Jwt 支持是最让人头疼的了,而且还要让我们的 API 跟 ASP.NET Core 自己的机制能够较完美的结合起来,这里就需要比较多的代码了。

首先我们需要自定义一个 Cookie 中存储 Jwt Token 的格式,也就是下面这个 EasyJwtAuthTicketFormat

/// <summary>
/// user info |> jwt |> store in ticket |> serialize |> data protection |> base64 encode
/// https://amanagrawal.blog/2017/09/18/jwt-token-authentication-with-cookies-in-asp-net-core/
/// </summary>
public class EasyJwtAuthTicketFormat : ISecureDataFormat<AuthenticationTicket>
{
    private readonly TokenValidationParameters _validationParameters;
    private readonly IDataSerializer<AuthenticationTicket> _ticketSerializer;
    private readonly IDataProtector _dataProtector;

    /// <summary>
    /// Create a new instance of the <see cref="EasyJwtAuthTicketFormat"/>
    /// </summary>
    /// <param name="validationParameters">
    /// instance of <see cref="TokenValidationParameters"/> containing the parameters you
    /// configured for your application
    /// </param>
    /// <param name="ticketSerializer">
    /// an implementation of <see cref="IDataSerializer{TModel}"/>. The default implemenation can
    /// also be passed in"/&gt;
    /// </param>
    /// <param name="dataProtector">
    /// an implementation of <see cref="IDataProtector"/> used to securely encrypt and decrypt
    /// the authentication ticket.
    /// </param>
    public EasyJwtAuthTicketFormat(TokenValidationParameters validationParameters,
        IDataSerializer<AuthenticationTicket> ticketSerializer,
        IDataProtector dataProtector)
    {
        _validationParameters = validationParameters ??
                                    throw new ArgumentNullException($"{nameof(validationParameters)} cannot be null");
        _ticketSerializer = ticketSerializer ??
                                throw new ArgumentNullException($"{nameof(ticketSerializer)} cannot be null"); ;
        _dataProtector = dataProtector ??
                             throw new ArgumentNullException($"{nameof(dataProtector)} cannot be null");
    }

    /// <summary>
    /// Does the exact opposite of the Protect methods i.e. converts an encrypted string back to
    /// the original <see cref="AuthenticationTicket"/> instance containing the JWT and claims.
    /// </summary>
    /// <param name="protectedText"></param>
    /// <returns></returns>
    public AuthenticationTicket Unprotect(string protectedText)
        => Unprotect(protectedText, null);

    /// <summary>
    /// Does the exact opposite of the Protect methods i.e. converts an encrypted string back to
    /// the original <see cref="AuthenticationTicket"/> instance containing the JWT and claims.
    /// Additionally, optionally pass in a purpose string.
    /// </summary>
    /// <param name="protectedText"></param>
    /// <param name="purpose"></param>
    /// <returns></returns>
    public AuthenticationTicket Unprotect(string protectedText, string purpose)
    {
        var authTicket = _ticketSerializer.Deserialize(
            _dataProtector.Unprotect(
                Base64UrlTextEncoder.Decode(protectedText)));

        var embeddedJwt = authTicket
            .Properties?
            .GetTokenValue(JwtBearerDefaults.AuthenticationScheme);

        try
        {
            // 校验并读取 jwt 中的用户信息(Claims)
            var principal = new JwtSecurityTokenHandler()
                .ValidateToken(embeddedJwt, _validationParameters, out var token);

            if (!(token is JwtSecurityToken))
            {
                throw new SecurityTokenValidationException("JWT token was found to be invalid");
            }
            // todo: 此处还可以校验 token 是否被吊销
            // 将 jwt 中的用户信息与 Cookie 中的包含的用户信息合并起来
            authTicket.Principal.AddIdentities(principal.Identities);
            return authTicket;
        }
        catch (Exception)
        {
            return null;
        }
    }

    /// <summary>
    /// Protect the authentication ticket and convert it to an encrypted string before sending
    /// out to the users.
    /// </summary>
    /// <param name="data">an instance of <see cref="AuthenticationTicket"/></param>
    /// <returns>encrypted string representing the <see cref="AuthenticationTicket"/></returns>
    public string Protect(AuthenticationTicket data) => Protect(data, null);

    /// <summary>
    /// Protect the authentication ticket and convert it to an encrypted string before sending
    /// out to the users. Additionally, specify the purpose of encryption, default is null.
    /// </summary>
    /// <param name="data">an instance of <see cref="AuthenticationTicket"/></param>
    /// <param name="purpose">a purpose string</param>
    /// <returns>encrypted string representing the <see cref="AuthenticationTicket"/></returns>
    public string Protect(AuthenticationTicket data, string purpose)
    {
        var array = _ticketSerializer.Serialize(data);

        return Base64UrlTextEncoder.Encode(_dataProtector.Protect(array));
    }
}

这个类我借鉴了前面提到的 Github 上面的那个项目的实现,并花了一些功夫对它做了一些改动。你可以看到,这个真的是非常大的一坨代码,不过我们还是先克服困难,从构造函数来阅读吧。

EasyJwtAuthTicketFormat 的构造函数接受三个参数,第一个我们已经见过了,是 EasyJwt 导出的 TokenValidationParameters,用来对 Jwt token 进行验证、解密。另外两个参数与 ASP.NET Core 的安全机制有关,IDataSerializer<AuthenticationTicket> ticketSerializer 用来将要存入 Cookie 中的数据序列化或者从 Cookie 中反序列化我们需要读出来的数据。IDataProtector dataProtector 则是用来对 Cookie 进行加密、解密的工具。

据 Github 上那个项目的作者说,他的代码是从微软的默认实现里面魔改出来的,所以我个人认为其中有些东西对于 Jwt 来说其实不是必须的,理由我会在下面详细解释。

首先一起来看看 Unprotect 方法,他的 protectedText 参数就是存储在 Cookie 中的字符串,首先我们需要对他用 Base64 进行解码,然后接着要用之前的 dataProtector 进行解密,最后再用 ticketSerializer 反序列化出 AuthenticationTicket 对象,这个 AuthenticationTicket 中存储的就是一些跟身份认证相关的数据,在我们这里,主要就是存储着 jwt Token。当我们把 token 中的用户数据解密并提取出来之后,再跟 Cookie 中可能含有的其他的身份信息合并起来(虽然可能并不会有什么其他信息。。。),最终就把结果返回出去。

Protect 方法就很简单了,基本上就是 Unprotect 开头一部分的逆序,先把 AuthenticationTicket 序列化,然后使用 dataProtector 加密,最终 Base64 编码成字符串返回出去。

那么很有意思的事情就出现了,jwt 本身的设计就是可以直接在 HTTP 协议中直接传递的,一般来说,并不需要我们重新对其进行 Base64 编码,而且 JWT 本身的内容就是有加密校验的,也就是说信息可读但是不可被修改,那么使用 dataProtector 对其加密的过程也应该是可以省略的。不过由于我比较懒,而且对这里不太肯定,所以就没有移除这部分的代码。

你可能觉得这有啥意思,不就是直接 Cookies.Add() 就好了?然而这样做是没法让认证中间件正确的提取出 Token 的,我们需要用到 HttpContext.SignInAsync 这个方法。这个方法的一个重载是接受一个 ClaimsPrincipal 跟一个 AuthentifactionProperties 作为参数,而这两个东西就是上一节提到的 AuthenticationTicket 的重要组成。

所以,我们除了要让 EasyJwt 签发 token 之外,还要它能够生成 AuthTicket,方便我们跟自带的认证中间件结合使用,相关的实现代码如下:

// EasyJwt.cs

public (ClaimsPrincipal, AuthenticationProperties) GenerateAuthTicket(string userName, IEnumerable<Claim> claims, DateTime expiratoin)
    {
        ClaimsIdentity identity = new ClaimsIdentity(new GenericIdentity(userName));
        var principal = new ClaimsPrincipal(identity);
        var authProps = new AuthenticationProperties();
        var token = GenerateToken(userName, claims, expiratoin);
        authProps.StoreTokens(new[]
        {
            new AuthenticationToken
                {Name = JwtBearerDefaults.AuthenticationScheme ,Value = token}
        });
        return (principal, authProps);
    }

这个方法跟签发 Token 的方法长得一个样,接受一个 Claims 集合,然后用这些 claims 构建出一张通行证(ClaimsIdentity),然后把这个 identity 对象扔进一个 ClaimsPrincipal 里面。同时,我们还需要把 token 塞进一个 AuthentifactionProperties 对象里面。最后,把这两个创建出来的东西返回出去。

为了能够简化这部分的调用,我又写了一个拓展方法把 SignInAsync 重新包装了一下:

public static async Task SignInAsync(this HttpContext context, string userName, IEnumerable<Claim> claims, DateTime expiratoin)
    {
        var jwt = context.RequestServices.GetService<EasyJwt>();
        var (principal, authProps) = jwt.GenerateAuthTicket(userName, claims, expiratoin);
        // 调用自带的 SignInAsync
        await context.SignInAsync(principal, authProps);
    }

这样,在用户登录的时候就可以非常的简单的同时把 token 显式的返回并设置在 Cookie 中了:

var claims = new[]
{
    new Claim(ClaimTypes.NameIdentifier, user, ClaimValueTypes.String)
};
var token = _jwt.GenerateToken(user, claims, DateTime.Now.AddDays(1));
await HttpContext.SignInAsync(user, claims, DateTime.Now.AddDays(1));
return Json(new {Token = token});

看起来我们终于能够正确的签发 token 了,然而事情并没有结束,我们还没有把 Cookie 认证及其相关依赖添加到 IoC 容器中,让我们直接修改一下前面的操作注册服务的拓展方法好了:

public static IServiceCollection AddEasyJwt(this IServiceCollection services, EasyJwtOption option)
{
    // 略

    var authBuilder = services.AddAuthentication(authOptions =>
        {
            // 默认使用 Cookie 认证方式
            authOptions.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        })
        .AddJwtBearer(jwtOptions =>
        {
            // 略
        });
    // 启用
    if (option.EnableCookie)
    {
        // 注册 DataProtector 服务
        services.AddDataProtection(dpOptions =>
        {
            dpOptions.ApplicationDiscriminator = $"app-{option.Issuer}";
        });
        // 注册 TicketSerializer 服务
        services.AddScoped<IDataSerializer<AuthenticationTicket>, TicketSerializer>();
        
        var tmpProvider = services.BuildServiceProvider();
        var protectionProvider = tmpProvider.GetService<IDataProtectionProvider>();
        var dataProtector = protectionProvider.CreateProtector("jwt-cookie");
        authBuilder.AddCookie(options =>
        {
            // 设置 Cookie 内容格式
            options.TicketDataFormat =
                new EasyJwtAuthTicketFormat(jwtParams,
                    tmpProvider.GetService<IDataSerializer<AuthenticationTicket>>(),
                    dataProtector);
            options.ClaimsIssuer = option.Issuer;
            options.LoginPath = "/Login";
            options.AccessDeniedPath = "/Login";
            options.Cookie.HttpOnly = true;
            options.Cookie.Name = "tk";
            option.CookieOptions?.Invoke(options);
        });
    }

    return services;
}

至此,我们终于能够完整的让 Jwt 的功能运行起来了。

成果展示

那么如何在一个空白的项目中使用 EasyJwt 认证呢?

1. 注册服务

// Startup.ConfigureServices

// 使用对称加密算法
services.AddEasyJwt(new EasySymmetricOptions("test")
{
    Audience = "test",
    Issuer = "test",
    EnableCookie = true
});

// 或者你可以使用非对称加密算法
services.AddEasyJwt(new EasyRSAOptions(PlatformServices.Default.Application.ApplicationBasePath)
{
    Audience = "test",
    Issuer = "test",
    EnableCookie = true
});

2. 添加认证中间件

// Startup.Configure

app.UseAuthentication();

3. 编写自己的登录注册控制器

/// <summary>
/// 演示性登录 API,返回新的 token 并设置 Cookie
/// </summary>
/// <param name="user"></param>
[HttpPost]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
public async Task<IActionResult> Post([FromForm]string user)
{
    // 假的用户信息
    var claims = new[]
    {
        new Claim(ClaimTypes.NameIdentifier, user, ClaimValueTypes.String)
    };
    var token = _jwt.GenerateToken(user, claims, DateTime.Now.AddDays(1));
    await HttpContext.SignInAsync(user, claims, DateTime.Now.AddDays(1));
    return Json(new {Token = token});
}

4. 使用 EasyJwtAuthorize 认证过滤器保护你的 API 或者 MVC 控制器

// POST api/<controller>
[EasyJwtAuthorize]
[HttpPost]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
public string Post([FromForm]string value)
{
    var userName = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value;
    return userName;
}

终于,经过我们一系列的魔改,我们可以非常快速的来构建一个使用 Jwt 来进行身份认证的网站了。

本文的全部代码您都可以在我的这个项目中找到,或者,如果您想在您的项目中试试我写的这个小拓展,可以直接使用 dotnet cli 来安装:

dotnet add package ZeekoUtilsPack.AspNetCore --source https://www.myget.org/F/zeekoget/api/v3/index.json

可以改进的地方

  1. 加入吊销 token 的功能
  2. 移除 EasyJwtAuthTicketFormat 中冗余的代码