7

理解ASP.NET Core - 基于JwtBearer的身份认证(Authentication)

 2 years ago
source link: https://www.cnblogs.com/xiaoxiaotank/p/15882735.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

注:本文隶属于《理解ASP.NET Core》系列文章,请查看置顶博客或点击此处查看全文目录

在开始之前,如果你还不了解基于Cookie的身份认证,那么建议你先阅读《基于Cookie的身份认证》后再阅读本文。

另外,为了方便大家理解并能够上手操作,我已经准备好了一个示例程序,请访问XXTk.Auth.Samples.JwtBearer.HttpApi获取源码。文章中的代码,基本上在示例程序中均有实现,强烈建议组合食用!

Jwt概述

Jwt是什么

Jwt是一个开放行业标准(RFC7519),英文为Json Web Token,译为“Json网络令牌”,它可以以紧凑、URL安全的方式在各方之间传递声明(claims)。

在Jwt中,声明会被编码为Json对象,用作Jws(Json Web Signature)结构的负载(payload),或作为Jwe(Json Web Encryption)结构的明文,这就使得声明可以使用MAC(Message Authentication Code)进行数字签名或完整性保护和加密。

获取更多信息请访问 https://jwt.io/
对jwt、jws、jwe有疑惑的请参考《一篇文章带你分清楚JWT,JWS与JWE》

Jwt解决了什么问题

传统的cookie只能实现跨域,而不能实现跨站(如my.abc.com和you.xyz.com),而Jwt原生支持跨域、跨站,因为它要求每次请求时,都要在请求头中携带token。

在当前应用基本都是集群部署的情况下,如果使用传统cookie + session的认证方式,为了实现session跨服务器共享,还必须引入分布式缓存中间件。而Jwt不需要分布式缓存中间件,因为它可以不存储在服务器端。

Native App友好

对于原生平台(如iOS、Android、WP)的App,没有浏览器的支持,Cookie丧失了它的优势,而使用Jwt就很简单。

Jwt的结构

先看一个Jwt示例:

plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJpYXQiOjE2NDI3NDg5OTIsIm5iZiI6MTY0Mjc0ODk5MiwiZXhwIjoxNjQyNzQ4OTkyLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJuYW1lIjoieGlhb3hpYW90YW5rIn0.nqJpZl48gnP4fv7NdsSD9JOn0VWq045Zcbmb91HMhwY

看起来就是很长一段毫无意义的乱码,不过细心点,你会发现它被符号点(.)分隔为了3个部分,看起来就像这样:

xxxxx.yyyyy.zzzzz

从左到右这3个部分称为:头部(Header)、载荷(Payload)和签名(Signature)。

头部(Header)

Header主要用于说明token类型和签名算法。

json
{ 
    "alg": "HS256",
    "typ": "JWT",
}
  • alg:签名算法,这里是 HMAC SHA256
  • typ:token类型,这里是JWT

对Header去除所有换行和空格后,得到:{"alg":"HS256","typ":"JWT"},接着对其进行Base64Url编码,即可获取到Token的第1部分

plaintext
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

载荷(Payload)

Payload是核心,主要用于存储声明信息,如token签发者、用户Id、用户角色等。

json
{
	"iss": "http://localhost:5000",
	"iat": 1642748992,
	"nbf": 1642748992,
	"exp": 1642748992,
	"aud": "http://localhost:5000",
	"name": "xiaoxiaotank"
}

其中,前五个是预定义的:

  • iss:Issuer,即token的签发者。
  • iat:Issued At,即token的签发时间
  • exp:Expiration Time,即token的过期时间
  • aud:Audience,即受众,指该token是服务于哪个群体的(群体范围),或该token所授予的有权限的资源是哪一块(资源的uri)
  • nbf:Not Before,即在指定的时间点之前该token不可用

实际上,Jwt中的声明可以分为以下三种类型:

  • Registered Claim:预定义声明,虽然并非强制使用,但是推荐使用,包括 iss(Issuer)、sub(Subject)、aud(Audience)、exp(Expiration Time)、nbf(Not Before)、iat(Issued At)和jti(JWT ID)。可以看到,这些声明名字都很短小,这是因为Jwt的核心目标是使表示紧凑。
  • Public Claim: 公共声明,Jwt的使用者可以随便定义,但是要避免和预定义声明冲突。
  • Private Claim: 私有声明,不同于公共声明的是,私有声明名称可能会发生冲突,应该谨慎使用。

对Payload(记得去除所有换行和空格)进行Base64Url编码,即可获取到Token的第2部分

plaintext
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJpYXQiOjE2NDI3NDg5OTIsIm5iZiI6MTY0Mjc0ODk5MiwiZXhwIjoxNjQyNzQ4OTkyLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJuYW1lIjoieGlhb3hpYW90YW5rIn0

不要在Payload中存储任何敏感信息,因为Base64Url不是加密,只是编码,所以这部分对于客户端来说是明文。

签名(Signature)

Signature主要用于防止token被篡改。当服务端获取到token时,会按照如下算法计算签名,若计算出的与token中的签名一致,才认为token没有被篡改。

签名算法:

  • 先将Header和Payload通过点(.)连接起来,即Base64Url编码的Header.Base64Url编码的Payload,记为 text
  • 然后使用Header中指明的签名算法对text进行加密,得到一个二进制数组,记为 signBytes
  • 最后对 signBytes 进行Base64Url编码,得到signature,即token的第三部分
plaintext
nqJpZl48gnP4fv7NdsSD9JOn0VWq045Zcbmb91HMhwY

Jwt带来了什么问题

所谓的“不安全”,是指Jwt的Payload是明文(Base64Url编码),因此其不能存储敏感数据。

不过,我们可以针对生成的token,再进行一次加密,这样相对会更加安全一些。不过无论如何,还是不如将数据保存在服务端安全。

通过前面的示例,你也看到了,虽然我们只在token中存储了少量必要信息,但是生成的token字符串长度仍然很长。而用户每次发送请求时,都会携带这个token,在一定程度上来看,开销是较大的,不过我们一般可以忽略这点性能开销。

无状态 & 一次性

jwt最大的特点是无状态和一次性,这也就导致如果我们想要修改里面的内容,必须重新签发一个新的token。因此,也就引出了另外的两个问题:

  • 无法手动过期
    如果我们想要使已签发的jwt失效,除非达到它的过期时间,否则我们是无法手动让其失效的。

  • 无法续签
    假设我们签发了一个有效时长30分钟的token,用户在这30分钟内持续进行操作,当达到token的有效期时,我们希望能够延长该token的有效期,而不是让用户重新登录。显然,要实现这个效果,必须要重新签发一个新的token,而不是在原token上操作。

Bearer概述

HTTP提供了一套标准的身份认证方案:当身份认证不通过时,服务端可以向客户端发送质询(challenge),客户端根据质询提供身份验证凭证进行应答。

质询与应答的具体工作流程如下:当身份认证不通过时,服务端向客户端返回HTTP状态码401(Unauthorized,未授权),并在WWW-Authenticate头中添加如何提供认证凭据的信息,其中至少包含有一种质询方式。然后客户端根据质询,在请求头中添加Authorization,它的值就是进行身份认证的凭证。

在HTTP标准认证方案中,大家可能比较熟悉的是BasicDigestBasic将用户名密码使用Base64编码后作为认证凭证,而DigestBasic的基础上针对安全性进行了升级,使得用户密码更加安全。在前文介绍的Cookie认证属于Form认证,并不属于HTTP标准认证方案。

而今天提到的Bearer,也属于HTTP协议标准认证方案之一,详见:RFC 6570

plaintext
     +--------+                               +---------------+
     |        |--(A)- Authorization Request ->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)-- Authorization Grant ---|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)-- Authorization Grant -->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)----- Access Token -------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)----- Access Token ------>|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)--- Protected Resource ---|               |
     +--------+                               +---------------+

                     Abstract Protocol Flow

Bearer认证中的凭据称为Bearer Token,或称为access token,标准请求格式为(添加到HTTP请求头中):

pgsql
Authorization: Bearer [Access Token]

另外,如果你对BasicDigest感兴趣,推荐阅读以下几篇文章:

身份认证(Authentication)

前文已经讲述过的身份认证中间件就不赘述了,咱们直接进入JwtBearer。

首先,通过Nuget安装以下三个包:

mathematica
Install-Package IdentityModel
Install-Package System.IdentityModel.Tokens.Jwt
Install-Package Microsoft.AspNetCore.Authentication.JwtBearer

接着,通过AddJwtBearer扩展方法添加JwtBearer认证方案:

csharp
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                // 在这里对该方案进行详细配置
            });
    }
}

CookieAuthenticationDefaults类似,JwtBearer也提供了JwtBearerDefaults,不过它比较简单,就只有一个AuthenticationScheme

csharp
public static class JwtBearerDefaults
{
    public const string AuthenticationScheme = "Bearer";
}

同样地,我们可以通过options针对Jwt的验证参数、验证处理器、事件回调等进行详细配置。它的类型为JwtBearerOptions,继承自AuthenticationSchemeOptions。下面会针对一些常用参数进行详细讲解(本文只介绍最简单的jwt签发和验证,不涉及认证授权认证中心)。

在开始之前,先自定义一个选项类JwtOptions,将常用参数配置化:

csharp
public class JwtOptions
{
    public const string Name = "Jwt";
    public readonly static Encoding DefaultEncoding = Encoding.UTF8;
    public readonly static double DefaultExpiresMinutes = 30d;

    public string Audience { get; set; }

    public string Issuer { get; set; }
    
    public double ExpiresMinutes { get; set; } = DefaultExpiresMinutes;

    public Encoding Encoding { get; set; } = DefaultEncoding;

    public string SymmetricSecurityKeyString { get; set; }

    public SymmetricSecurityKey SymmetricSecurityKey => new(Encoding.GetBytes(SymmetricSecurityKeyString));
}

现在,我们无需关注各个参数的具体值是多少,直接看下方的方案配置:

csharp
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtOptions>(Configuration.GetSection(JwtOptions.Name));
        
        var jwtOptions = Configuration.GetSection(JwtOptions.Name).Get<JwtOptions>();
    
        services.AddSingleton(sp => new SigningCredentials(jwtOptions.SymmetricSecurityKey, SecurityAlgorithms.HmacSha256Signature));
    
        services.AddScoped<AppJwtBearerEvents>();
    
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256, SecurityAlgorithms.RsaSha256 },
                    ValidTypes = new[] { JwtConstants.HeaderType },

                    ValidIssuer = jwtOptions.Issuer,
                    ValidateIssuer = true,

                    ValidAudience = jwtOptions.Audience,
                    ValidateAudience = true,

                    IssuerSigningKey = jwtOptions.SymmetricSecurityKey,
                    ValidateIssuerSigningKey = true,
                    
                    ValidateLifetime = true,
                    
                    RequireSignedTokens = true,
                    RequireExpirationTime = true,
                
                    NameClaimType = JwtClaimTypes.Name,
                    RoleClaimType = JwtClaimTypes.Role,

                    ClockSkew = TimeSpan.Zero,
                };

                options.SaveToken = true;

                options.SecurityTokenValidators.Clear();
                options.SecurityTokenValidators.Add(new JwtSecurityTokenHandler());

                options.EventsType = typeof(AppJwtBearerEvents);
            });
    }
}

其中,TokenValidationParameters是和token验证有关的参数配置,进行token验证时需要用到,下面看详细说明:

  • TokenValidationParameters.ValidAlgorithms:有效的签名算法列表,即验证Jwt的Header部分的alg。默认为null,即所有算法均可。
  • TokenValidationParameters.ValidTypes:有效的token类型列表,即验证Jwt的Header部分的typ。默认为null,即算有算法均可。
  • TokenValidationParameters.ValidIssuer:有效的签发者,即验证Jwt的Payload部分的iss。默认为null
  • TokenValidationParameters.ValidIssuers:有效的签发者列表,可以指定多个签发者。
  • TokenValidationParameters.ValidateIssuer:是否验证签发者。默认为true。注意,如果设置了TokenValidationParameters.IssuerValidator,则该参数无论是何值,都会执行。
  • TokenValidationParameters.ValidAudience:有效的受众,即验证Jwt的Payload部分的aud。默认为null
  • TokenValidationParameters.ValidAudiences:有效的受众列表,可以指定多个受众。
  • TokenValidationParameters.ValidateAudience:是否验证受众。默认为true。注意,如果设置了TokenValidationParameters.AudienceValidator,则该参数无论是何值,都会执行。
  • TokenValidationParameters.IssuerSigningKey:用于验证Jwt签名的密钥。对于对称加密来说,加签和验签都是使用的同一个密钥;对于非对称加密来说,使用私钥加签,然后使用公钥验签。
  • TokenValidationParameters.ValidateIssuerSigningKey:是否使用验证密钥验证签名。默认为false。注意,如果设置了TokenValidationParameters.IssuerSigningKeyValidator,则该参数无论是何值,都会执行。
  • TokenValidationParameters.ValidateLifetime:是否验证token是否在有效期内,即验证Jwt的Payload部分的nbfexp
  • TokenValidationParameters.RequireSignedTokens: 是否要求token必须进行签名。默认为true,即token必须签名才可能有效。
  • TokenValidationParameters.RequireExpirationTime:是否要求token必须包含过期时间。默认为true,即Jwt的Payload部分必须包含exp且具有有效值。
  • TokenValidationParameters.NameClaimType:设置 HttpContext.User.Identity.NameClaimType,便于 HttpContext.User.Identity.Name 取到正确的值
  • TokenValidationParameters.RoleClaimType:设置 HttpContext.User.Identity.RoleClaimType,便于 HttpContext.User.Identity.IsInRole(xxx) 取到正确的值
  • TokenValidationParameters.ClockSkew:设置时钟漂移,可以在验证token有效期时,允许一定的时间误差(如时间刚达到token中exp,但是允许未来5分钟内该token仍然有效)。默认为300s,即5min。本例jwt的签发和验证均是同一台服务器,所以这里就不需要设置时钟漂移了。
  • SaveToken:当token验证通过后,是否保存到 Microsoft.AspNetCore.Authentication.AuthenticationProperties,默认true。该操作发生在执行完 JwtBearerEvents.TokenValidated之后。
  • SecurityTokenValidators:token验证器列表,可以指定验证token的处理器。默认含有1个JwtSecurityTokenHandler
  • EventsType:这里我重写了JwtBearerEvents

下面来看事件回调:

csharp
public class AppJwtBearerEvents : JwtBearerEvents
{
    public override Task MessageReceived(MessageReceivedContext context)
    {
        // 从 Http Request Header 中获取 Authorization
        string authorization = context.Request.Headers[HeaderNames.Authorization];
        if (string.IsNullOrEmpty(authorization))
        {
            context.NoResult();
            return Task.CompletedTask;
        }

        // 必须为 Bearer 认证方案
        if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
        {
            // 赋值token
            context.Token = authorization["Bearer ".Length..].Trim();
        }

        if (string.IsNullOrEmpty(context.Token))
        {
            context.NoResult();
            return Task.CompletedTask;
        }
        
        return Task.CompletedTask;
    }

    public override Task TokenValidated(TokenValidatedContext context)
    {
        return Task.CompletedTask;
    }

    public override Task AuthenticationFailed(AuthenticationFailedContext context)
    {
        Console.WriteLine($"Exception: {context.Exception}");

        return Task.CompletedTask;
    }

    public override Task Challenge(JwtBearerChallengeContext context)
    {
        Console.WriteLine($"Authenticate Failure: {context.AuthenticateFailure}");
        Console.WriteLine($"Error: {context.Error}");
        Console.WriteLine($"Error Description: {context.ErrorDescription}");
        Console.WriteLine($"Error Uri: {context.ErrorUri}");

        return Task.CompletedTask;
    }

    public override Task Forbidden(ForbiddenContext context)
    {
        return Task.CompletedTask;
    }
}
  • MessageReceived:当收到请求时回调,注意,此时还未获取到token。我们可以在该方法内自定义token的获取方式,然后将获取到的token赋值到context.Token(不包含Scheme)。只要我们取到的token既非Null也非Empty,那后续验证就会使用该token
  • TokenValidated:token验证通过后回调。
  • AuthenticationFailed:由于认证过程中抛出异常,导致身份认证失败后回调。
  • Challenge:质询时回调。
  • Forbidden:当出现403(Forbidden,禁止)时回调。

其中,在MessageReceived中,针对默认获取token的逻辑进行了模拟。

用户登录和注销

现在,我们来实现用户登录功能,当登录成功时,向客户端签发一个token。

csharp
[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    private readonly JwtBearerOptions _jwtBearerOptions;
    private readonly JwtOptions _jwtOptions;
    private readonly SigningCredentials _signingCredentials;

    public AccountController(
        IOptionsSnapshot<JwtBearerOptions> jwtBearerOptions,
        IOptionsSnapshot<JwtOptions> jwtOptions,
        SigningCredentials signingCredentials)
    {
        _jwtBearerOptions = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);
        _jwtOptions = jwtOptions.Value;
        _signingCredentials = signingCredentials;
    }

    [AllowAnonymous]
    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginDto dto)
    {
        if (dto.UserName != dto.Password)
        {
            return Unauthorized();
        }

        var user = new UserDto()
        {
            Id = Guid.NewGuid().ToString("N"),
            UserName = dto.UserName
        };

        var token = CreateJwtToken(user);

        return Ok(new { token });
    }

    [NonAction]
    private string CreateJwtToken(UserDto user)
    {
        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new List<Claim>
            {
                new Claim(JwtClaimTypes.Id, user.Id),
                new Claim(JwtClaimTypes.Name, user.UserName)
            }),
            Issuer = _jwtOptions.Issuer,
            Audience = _jwtOptions.Audience,
            Expires = DateTime.UtcNow.AddMinutes(_jwtOptions.ExpiresMinutes),
            SigningCredentials = _signingCredentials
        };

        var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
            ?? new JwtSecurityTokenHandler();
        var securityToken = handler.CreateJwtSecurityToken(tokenDescriptor);
        var token = handler.WriteToken(securityToken);

        return token;
    }
}

我们目光直接来到CreateJwtToken方法,可以看到熟悉的Subject、Issuer、Audience、Expires等。其中,Subject可以装载多个自定义声明,在生成token时,会将装载的所有声明展开平铺。而另一个需要注意的就是Expires,必须使用基于UTC的时间,默认有效期为1个小时。

下面我们一起生成一个token:

然后我们给WeatherForecastController增加授权(详细配置过程略),并带上token进行请求:

当使用JwtBearer认证方案时,由于Jwt的“一次性”和“无状态”特征,用户注销一般是不会在服务端实现的,而是通过客户端来实现,比如客户端从localstorage中删除该token(当然,这只是一种“曲线救国”的实现方式)。

另外,如果你可以接受的话,可以在用户注销时,服务端将Jwt加入缓存黑名单,并将缓存过期时间设置为Jwt的过期时间。

改用非对称加密进行Jwt签名和验签

在前面的示例中,我们使用的对称加密算法HmacSha256计算的签名。试想一下,公司内的多个业务项目都会使用该token,因此,为了让每个项目都可以进行身份认证,就需要将密钥分发给所有项目,这就产生了较大的风险。因此,使用非对称加密来计算签名,是一个更加合理地选择:我们使用私钥进行签名,然后只需要将公钥暴露出去用于验签,即可验证token是有效的(没有被篡改)。下面,我们就以RsaSha256为例改进我们的程序。

首先,我们先生成Rsa的密钥对,参考以下示例代码(可在源码AccountController中找到):

csharp
public void GenerateRsaKeyParies(IWebHostEnvironment env)
{
    RSAParameters privateKey, publicKey;

    // >= 2048 否则长度太短不安全
    using (var rsa = new RSACryptoServiceProvider(2048))
    {
        try
        {
            privateKey = rsa.ExportParameters(true);
            publicKey = rsa.ExportParameters(false);
        }
        finally
        {
            rsa.PersistKeyInCsp = false;
        }
    }

    var dir = Path.Combine(env.ContentRootPath, "Rsa");
    if (!Directory.Exists(dir))
    {
        Directory.CreateDirectory(dir);
    }

    System.IO.File.WriteAllText(Path.Combine(dir, "key.private.json"), JsonConvert.SerializeObject(privateKey));
    System.IO.File.WriteAllText(Path.Combine(dir, "key.public.json"), JsonConvert.SerializeObject(publicKey));
}

具体细节不必多说,然后就来改进我们的JwtOptions

csharp
public class JwtOptions
{
    public const string Name = "Jwt";
    public readonly static double DefaultExpiresMinutes = 30d;

    public string Audience { get; set; }

    public string Issuer { get; set; }
    
    public double ExpiresMinutes { get; set; } = DefaultExpiresMinutes;
}

由于RSA签名算法的私钥和公钥都保存在另外一个文件中,而且一般这个也不会轻易更改,所以就不把它们加入到选项中了。

接着,修改我们的签名算法和验签算法:

csharp
public class Startup
{
    public Startup(IConfiguration configuration, IWebHostEnvironment env)
    {
        Configuration = configuration;
        Env = env;
    }

    public IConfiguration Configuration { get; }

    public IWebHostEnvironment Env { get; set; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.Configure<JwtOptions>(Configuration.GetSection(JwtOptions.Name));

        var jwtOptions = Configuration.GetSection(JwtOptions.Name).Get<JwtOptions>();
        
        var rsaSecurityPrivateKeyString = File.ReadAllText(Path.Combine(Env.ContentRootPath, "Rsa", "key.private.json"));
        var rsaSecurityPublicKeyString = File.ReadAllText(Path.Combine(Env.ContentRootPath, "Rsa", "key.public.json"));
        RsaSecurityKey rsaSecurityPrivateKey = new(JsonConvert.DeserializeObject<RSAParameters>(rsaSecurityPrivateKeyString));
        RsaSecurityKey rsaSecurityPublicKey = new(JsonConvert.DeserializeObject<RSAParameters>(rsaSecurityPublicKeyString));
        
        // 使用私钥加签
        services.AddSingleton(sp => new SigningCredentials(rsaSecurityPrivateKey, SecurityAlgorithms.RsaSha256Signature));

        services.AddScoped<AppJwtBearerEvents>();
        
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    
                    // 使用公钥验签
                    IssuerSigningKey = rsaSecurityPublicKey,
                }
            }
    }
}

至此,就OK了,其他全部都不需要改,以下是一个签发的Jwt示例,缺点是签名部分会比对称加密的长很多(毕竟安全嘛,我们可以忍受O(∩_∩)O哈哈~):

apache
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Ijk4NTUxMDE3YjBjYTRjOTU5NzNmMTM3Mjk2MWZlZWM2IiwibmFtZSI6InN0cmluZyIsIm5iZiI6MTY0MzIwOTIwNiwiZXhwIjoxNjQzMjA5ODA2LCJpYXQiOjE2NDMyMDkyMDYsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTAwMCJ9.GUCYTBytxv5yqGQFB6B6rlARF3F37CJh27e-qBCKApJShSr8vq-RkPu_o0dtCONKx0y1mb2Aq5hddFQYRFaMICQMeUeCJfaVoi96chsvwahnvx1_Snz4vvaiHSmTGCXm-WAkMJdpFny0zsicegLOrJJyHFecHGENGfWee28xYSi9R70bFJjVLxR965UJzOisi5pIXjemdlipaRhdITAWz-B4iKH_2-sv6j_drkJv2CNsEjOdHxHITN6oVUpP3i4i4PmXhRM7x4O0lKeKGQE9ezZIBtXa16nUCJo0VWDD2QAwWr1akzu99wtOSoJf2MoRETwK7vOOKIbTrNQOQ1WYUQ

对jwt进行加密

我们知道,Jwt中的Header和Payload都是明文,特别是Payload中我们务必不要放置敏感信息。如果你觉得Jwt明文不妥,那你可以选择针对它加一层加密,也就是Jwt标准的另一种实现Jwe。

下面是部分代码实现:

csharp
private string CreateJwtToken(UserDto user)
{
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        // ...
        
        EncryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Total Bytes Length At Least 256!")), JwtConstants.DirectKeyUseAlg, SecurityAlgorithms.Aes128CbcHmacSha256)
    };

    // ...
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ...
        
        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
            {
                options.TokenValidationParameters = new TokenValidationParameters
                {
                    // ...
                    
                    // 如果设置了 ValidAlgorithms,则加上 Aes128CbcHmacSha256
                    ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256, SecurityAlgorithms.RsaSha256, SecurityAlgorithms.Aes128CbcHmacSha256 },
                    
                    // token解密密钥
                    TokenDecryptionKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("Total Bytes Length At Least 256!"))
                }
            }
    }
}

下方是一个Jwe示例:

plaintext
eyJhbGciOiJkaXIiLCJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwidHlwIjoiSldUIn0..KsIPh-Wx8TOpgNBZ5xINSA.zgqErSkpnTaWJ1TsPoIKrgpP_2uR-Orjbn54Wo4FeGmIPczk2X8N8qx4zWe9CGztrFLxeoWvYLlfRwclfglmKE9372delByVwK_C-u7cFN2TaZ183JTWYTyJVPANTC1WtuEzSe3NEKjfRoC9QN7SN4z9cJ-CtIPb1t17XB0gG0fc7T9UARZ1eIUIfnCXROAyX96qB6ABJ5Xy8wrrYkA2m5OqqLyAd8FbZfcK_rii_lbXNZsbcfgNPBQGEO6lOdBg4I3nQv9A6cqGj9qTnsIH89Dx7mBnkx0W7C9UHtZQsNTG71VSzG8g_KVifC-oO62wrOYeh48y5l4czeIWlAl4GCZpnUQmq4Y_2cw2brgG4WV7FRYPch4RMeTB6y9qrm6Rj8TvZbf_hZ51yvDYvPPVUjMiM1xo5_KLXVZa3w5aEGB4jGynVXwuGDV8XwS8sTjEkziFfA85TWPq_N-ENm4R9K_HUzwfgpGYzM-Nrf54GV8BXpnpapTc-jWij3MOpsjeyzqXdG5t-JB9_Xt7-BadjMakiU1WihiigiYMGQBmkG30r8e6bGcoL58Ytb6PQZ3NfHGCakV5LRGWFOjRUSP7X_xC0xWhrH2R6LhD1QESoE8GsTU-YS9JUREECcD2b9gXx0JxYp2mGdCkKRspajhEj4b04PV-hpr0bNSf59GkSMu_KhHuF5AcWfLSqwzACMvsvW6QvIQTzm6gXy8Ui2N80JCGkp_LzW23RFwCPSlQQ7c7S3A-Ltd_AaDQJ9C5B-To_PHESy9bUKhU-MV2tbfSST-vBeJkSn4kz4feEWcG59A.KULA_w3_XEIIKhAHKuFpsw

它的头部就是:

json
{
  "alg": "dir",
  "enc": "A128CBC-HS256",
  "typ": "JWT"
}

借助服务端增强Jwt认证方案

虽然无状态的Jwt使用非常方便快捷,但是适用场景非常有限。为了能够实现更多功能,就需要借助服务端,从而导致Jwt的无状态性被破坏。

在进入该主题之前,请先确认一下,前面所提到的Jwt的用法已经完全符合你的要求,如果是,那么恭喜你,Jwt绝对是最适合的方案。如果不是,且你认为需要服务端,那么你应该考虑一下,你是否真的需要服务端。因为这样会使得认证行为趋向于cookie + session,从而使得认证方案的复杂性大幅增加。

Jwt静默刷新实现自动续租

试想一下以下场景:用户登录后获得了一个有效期为30分钟的token,然后填写一个表单时,花费了40分钟,点击提交后,系统要求他重新登录并重新填写表单,你猜他会不会很开心?因此,就像我们之前基于Cookie进行身份认证时一样,在基于Jwt的认证方案中,我们也需要一种类似滑动过期的机制来实现自动续租。

那该如何设计这个自动续租方案呢?你可能会想到以下的方案:

  • 方案一:每次通过认证的请求都会重新签发Jwt来重置过期时间。该方案虽然能够解决问题,但是太过暴力,也有严重的性能问题。
  • 方案二:jwt即将过期时才重新签发Jwt。乍一看,这方案看起来可行,但是实际上Jwt能否刷新完全是看运气。假设签发了一个有效期为30分钟的Jwt,我们打算在它有效期仅剩5分钟时重新签发。如果用户在最后5分钟内请求了,那会刷新Jwt,但是如果没有请求,那就需要用户重新登录,体验大打折扣。
  • 方案三:签发的Jwt中忽略过期时间,而将Jwt(或JwtId)记录在服务端的分布式缓存,并设置过期时间。然后,在初次进行Jwt校验时,不使用默认的校验器校验过期时间,校验通过后,再与缓存中的过期时间进行比对,如果有效则重置过期时间。该方案确实可行,不过这要求Jwt在有效期内才能进行刷新。

目前使用最广泛的一种方式是引入一个称为refresh token的参数。大概流程是在签发access token时,同时生成一个refresh token,并且refresh token的有效期要比access token长很多。然后,客户端将两个token都保存下来。当客户端请求服务端使用,若发现服务端返回“access token过期”的错误,那么就加上之前保存下来的refresh token请求服务端刷新token,服务端会签发一套全新的access tokenrefresh token给客户端。

其中,为了保证refresh token的安全性和有效性,除了发送给客户端外,还需要在服务端存储一份,并设置过期时间。这实际上在一定程度上破坏了Jwt的“无状态”性(个人认为可以接受)。

具体代码请参考XXTk.Auth.Samples.JwtBearerWithRefresh.HttpApi

首先,就先定义要返回给客户端的数据类型:

csharp
public class AuthTokenDto
{
    // jwt token
    public string AccessToken { get; set; }

    // 用于刷新token的刷新令牌
    public string RefreshToken { get; set; }
}

接下来定义token的服务接口IAuthTokenService和服务实现AuthTokenService

csharp
public interface IAuthTokenService
{
    Task<AuthTokenDto> CreateAuthTokenAsync(UserDto user);

    Task<AuthTokenDto> RefreshAuthTokenAsync(AuthTokenDto token);
}

public class AuthTokenService : IAuthTokenService
{
    private const string RefreshTokenIdClaimType = "refresh_token_id";

    private readonly JwtBearerOptions _jwtBearerOptions;
    private readonly JwtOptions _jwtOptions;
    private readonly SigningCredentials _signingCredentials;
    private readonly IDistributedCache _distributedCache;
    private readonly ILogger<AuthTokenService> _logger;

    public AuthTokenService(
       IOptionsSnapshot<JwtBearerOptions> jwtBearerOptions,
       IOptionsSnapshot<JwtOptions> jwtOptions,
       SigningCredentials signingCredentials,
       IDistributedCache distributedCache,
       ILogger<AuthTokenService> logger)
    {
        _jwtBearerOptions = jwtBearerOptions.Get(JwtBearerDefaults.AuthenticationScheme);
        _jwtOptions = jwtOptions.Value;
        _signingCredentials = signingCredentials;
        _distributedCache = distributedCache;
        _logger = logger;
    }
}

接下来,我们来实现CreateAuthTokenAsync方法:

csharp
public async Task<AuthTokenDto> CreateAuthTokenAsync(UserDto user)
{
    var result = new AuthTokenDto();
    
    // 先创建refresh token
    var (refreshTokenId, refreshToken) = await CreateRefreshTokenAsync(user.Id);
    result.RefreshToken = refreshToken;
    // 再签发Jwt
    result.AccessToken = CreateJwtToken(user, refreshTokenId);

    return result;
}

private async Task<(string refreshTokenId, string refreshToken)> CreateRefreshTokenAsync(string userId)
{
    // refresh token id作为缓存Key
    var tokenId = Guid.NewGuid().ToString("N");

    // 生成refresh token
    var rnBytes = new byte[32];
    using var rng = RandomNumberGenerator.Create();
    rng.GetBytes(rnBytes);
    var token = Convert.ToBase64String(rnBytes);

    // 设置refresh token的过期时间
    var options = new DistributedCacheEntryOptions();
    options.SetAbsoluteExpiration(TimeSpan.FromDays(_jwtOptions.RefreshTokenExpiresDays));
    
    // 缓存 refresh token
    await _distributedCache.SetStringAsync(GetRefreshTokenKey(userId, tokenId), token, options);

    return (tokenId, token);
}

private string CreateJwtToken(UserDto user, string refreshTokenId)
{
    if (user is null) throw new ArgumentNullException(nameof(user));
    if (string.IsNullOrEmpty(refreshTokenId)) throw new ArgumentNullException(nameof(refreshTokenId));

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new List<Claim>
        {
            new Claim(JwtClaimTypes.Id, user.Id),
            new Claim(JwtClaimTypes.Name, user.UserName),
            // 将 refresh token id 记录下来
            new Claim(RefreshTokenIdClaimType, refreshTokenId)
        }),
        Issuer = _jwtBearerOptions.TokenValidationParameters.ValidIssuer,
        Audience = _jwtBearerOptions.TokenValidationParameters.ValidAudience,
        Expires = DateTime.UtcNow.AddMinutes(_jwtOptions.AccessTokenExpiresMinutes),
        SigningCredentials = _signingCredentials,
    };

    var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
        ?? new JwtSecurityTokenHandler();
    var securityToken = handler.CreateJwtSecurityToken(tokenDescriptor);
    var token = handler.WriteToken(securityToken);

    return token;
}

private string GetRefreshTokenKey(string userId, string refreshTokenId)
{
    if (string.IsNullOrEmpty(userId)) throw new ArgumentNullException(nameof(userId));
    if (string.IsNullOrEmpty(refreshTokenId)) throw new ArgumentNullException(nameof(refreshTokenId));

    return $"{userId}:{refreshTokenId}";
}

下面看一下效果:

接着,实现RefreshAuthTokenAsync方法:

csharp
public async Task<AuthTokenDto> RefreshAuthTokenAsync(AuthTokenDto token)
{
    var validationParameters = _jwtBearerOptions.TokenValidationParameters.Clone();
    // 不校验生命周期
    validationParameters.ValidateLifetime = false;

    var handler = _jwtBearerOptions.SecurityTokenValidators.OfType<JwtSecurityTokenHandler>().FirstOrDefault()
        ?? new JwtSecurityTokenHandler();
    ClaimsPrincipal principal = null;
    try
    {
        // 先验证一下,jwt是否真的有效
        principal = handler.ValidateToken(token.AccessToken, validationParameters, out _);
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex.ToString());
        throw new BadHttpRequestException("Invalid access token");
    }

    var identity = principal.Identities.First();
    var userId = identity.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Id).Value;
    var refreshTokenId = identity.Claims.FirstOrDefault(c => c.Type == RefreshTokenIdClaimType).Value;
    var refreshTokenKey = GetRefreshTokenKey(userId, refreshTokenId);
    var refreshToken = await _distributedCache.GetStringAsync(refreshTokenKey);
    // 验证refresh token是否有效
    if (refreshToken != token.RefreshToken)
    {
        throw new BadHttpRequestException("Invalid refresh token");
    }

    // refresh token用过了记得清除掉
    await _distributedCache.RemoveAsync(refreshTokenKey);

    // 这里应该是从数据库中根据 userId 获取用户信息
    var user = new UserDto()
    {
        Id = userId,
        UserName = principal.Identity.Name
    };

    return await CreateAuthTokenAsync(user);
}

下面看一下效果:

注意:引入刷新令牌后,要记得在用户注销将当前Jwt的刷新令牌清除,或修改密码后将该用户的刷新令牌清空。

最后,解释几个问题:

  • 为什么Jwt中保存了refresh token id?直接保存refresh token不行吗?

    保存refresh token id是为了实现一个用户对应多个refresh token,这适用于同一用户在多客户端登录的情况。

    不能直接保存refresh token,由于Jwt是明文,所以这容易导致refresh token泄漏,从而导致他人可以在用户不知情的情况下申请access token。

  • 为什么要设计为一个用户对应多个refresh token?

    这适用于同一用户在多客户端登录的情况,防止其中一个客户端刷新了token,导致其他客户端无法刷新。

处理不同系统要求Jwt认证信息中存储不同的字段信息

假设有以下场景:商城采购系统和收货系统属于同一电商平台,使用的均是同一套基于JwtBearer的认证方案,现在,收货系统需要在认证信息中新增角色信息和每日最大收货次数信息,便于快速获取。

方案可能多种多样,比如就在Jwt签发时,将角色信息和每日最大收货次数存储到Jwt中,虽然这能够解决问题,但显然会使得Jwt存储很多冗余数据,在系统越来越多的情况下,就显得无法接受。

以下是我所想到的一种较为合理的方案:首先,角色信息较为通用,大部分系统都会用到,所以建议将角色信息加入到Jwt中存储,而对于每日最大收货次数,更倾向于收货系统使用,所以这条信息由收货系统在服务端进行维护,例如以用户Id为Key,记入分布式缓存中。

Jwt+服务端 vs Cookie + Session

很多人会说,我使用Jwt就是因为它的无状态性,既然它也要结合服务端,那我为啥不干脆就使用Cookie + Session

确实,如果你的系统前端是H5,客户端均是浏览器,且后续也基本不可能发生改变,那你可以把扇Jwt俩大耳刮子,并把它踢出家门,因为Cookie + Session绝对是你的首选。

但是,如果你的系统包含了H5、小程序、Native App等,由于其中某些客户端不支持Cookie,所以Cookie就丧失了它的优势,此时使用Cookie还是Jwt貌似差别都不大,但是Jwt可以实现自动续租。实际上,我比较推荐的做法是Jwt + Cookie,即将Jwt保存在Cookie中,这样,在H5应用中,仍然利用Cookie机制传递认证信息,而在其他不支持Cookie的客户端中,则直接使用Jwt(通过Authorization Header),这样可以保证认证行为的统一。

防止Jwt泄露

文章最后,我们就来看一下如何防止Jwt泄漏吧。

假设Jwt泄露了,那么他人就可以使用你的身份访问服务器进行敏感操作,不过这相对来说,还好,因为Jwt过期了也就失效了。但是,如果refresh token也泄露了,那就会产生更加严重的后果,他人就可以通过refresh token无限制的获取到最新的token。

看完上面这段话,是不是不敢用Jwt了?别怕,任何认证方案都会有导致这种情况出现的可能,例如,通过用户名和密码登录时,不还是在请求过程中有用户名和密码被窃取的可能。

既然没有绝对的安全保护措施,那我们只有尽量让它安全,以下是两点建议:

  • 使用Https协议
  • 设置较短的Jwt有效期

__EOF__


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK