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


    注:本文隶属于《理解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请求头中):

    highlighter- pgsql
    Authorization: Bearer [Access Token]
    

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

    身份认证(Authentication)

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

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

    highlighter- 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哈哈~):

    highlighter- 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就是因为它的无状态性,既然它也要结合服务端,那我为啥不干脆就使用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__

  • 本文作者: xiaoxiaotank
  • 本文链接: https://www.cnblogs.com/xiaoxiaotank/p/15882735.html
  • 关于博主: 使用微信扫描一下左侧的二维码关注我的订阅号,或加入我的QQ交流群:705680556
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。
  • 相关阅读:
    周四见 | 物流人的一周资讯
    【Java数据结构】详解Stack与Queue(一)
    跳槽前恶补面试题,成功上岸阿里,拿到33k的测开offer
    计算机毕业设计Java家政服务系统(源码+系统+mysql数据库+lw文档)
    Google官方控件ShapeableImageView使用
    第1篇 目标检测概述 —(1)目标检测基础知识
    Java中关键字packag和import的使用
    哈夫曼树原理及Java编码实现
    【零基础入门MyBatis系列】第十五篇——分页插件与注解式开发
    常见的六大聚类算法
  • 原文地址:https://www.cnblogs.com/xiaoxiaotank/p/15882735.html