前言

关于JWT一共三篇 姊妹篇,内容分别从简单到复杂,一定要多看多想:

      一、Swagger的使用 3.3 JWT权限验证【修改】

      二、解决JWT权限验证过期问题

      三、JWT完美实现权限与接口的动态分配

 

本文章不仅在Blog.Core 框架里有代码,而且我也单写了一个关于 JWT 的小demo,在文章末,大家可以下载看看。

书接上文,在前边的两篇文章中,我们简单提到了接口文档神器Swagger,

三 || Swagger的使用 3.1》、

四 || Swagger的使用 3.2》,

 

两个文章中,也对常见的几个问题做了简单的讨论,最后还剩下一个小问题,

1、如何给接口实现权限验证?

其实关于这一块,我思考了下,因为毕竟我的项目中是使用的vue + api 搭建一个前台展示,大部分页面都没有涉及到权限验证,本来要忽略这一章节,可是犹豫再三,还是给大家简单分析了下,个人还是希望陪大家一直搭建一个较为强大的,只要是涉及到后端那一定就需要 登录=》验证了,本文主要是参考网友https://www.cnblogs.com/RayWang/p/9255093.html的思路,我自己稍加改动,大家都可以看看。

根据维基百科定义,JWT(读作 [/dʒɒt/]),即JSON Web Tokens,是一种基于JSON的、用于在网络上声明某种主张的令牌(token)。JWT通常由三部分组成: 头信息(header), 消息体(payload)和签名(signature)。它是一种用于双方之间传递安全信息的表述性声明规范。JWT作为一个开放的标准(RFC 7519),定义了一种简洁的、自包含的方法,从而使通信双方实现以JSON对象的形式安全的传递信息。

以上是JWT的官方解释,可以看出JWT并不是一种只能权限验证的工具,而是一种标准化的数据传输规范。所以,只要是在系统之间需要传输简短但却需要一定安全等级的数据时,都可以使用JWT规范来传输。规范是不因平台而受限制的,这也是JWT做为授权验证可以跨平台的原因。

如果理解还是有困难的话,我们可以拿JWT和JSON类比:

JSON是一种轻量级的数据交换格式,是一种数据层次结构规范。它并不是只用来给接口传递数据的工具,只要有层级结构的数据都可以使用JSON来存储和表示。当然,JSON也是跨平台的,不管是Win还是Linux,.NET还是Java,都可以使用它作为数据传输形式。

1)客户端向授权服务系统发起请求,申请获取“令牌”。

2)授权服务根据用户身份,生成一张专属“令牌”,并将该“令牌”以JWT规范返回给客户端

3)客户端将获取到的“令牌”放到http请求的headers中后,向主服务系统发起请求。主服务系统收到请求后会从headers中获取“令牌”,并从“令牌”中解析出该用户的身份权限,然后做出相应的处理(同意或拒绝返回资源)


 

 

零、生成 Token 令牌

关于JWT授权,其实过程是很简单的,大家其实这个时候静下心想一想就能明白,这个就是四步走:

首先我们需要一个具有一定规则的 Token 令牌,也就是 JWT 令牌(比如我们的公司门禁卡),//登录

然后呢,我们再定义哪些地方需要什么样的角色(比如领导办公室我们是没办法进去的),//授权机制

接下来,整个公司需要定一个规则,就是如何对这个 Token 进行验证,不能随便写个字条,这样容易被造假(比如我们公司门上的每一道刷卡机),//认证方案

最后,就是安全部门,开启认证中间件服务(那这个服务可以关闭的,比如我们电影里看到的黑客会把这个服务给关掉,这样整个公司安保就形同虚设了)。//开启中间件

 

那现在我们就是需要一个具有一定规则的 Token 令牌,大家可以参考:

这个实体类就是用来生成 Token 的,代码记录如下:

    public class JwtHelper
    {

        /// <summary>
        /// 颁发JWT字符串
        /// </summary>
        /// <param name="tokenModel"></param>
        /// <returns></returns>
        public static string IssueJwt(TokenModelJwt tokenModel)
        {
            // 自己封装的 appsettign.json 操作类,看下文
            string iss = Appsettings.app(new string[] { "Audience", "Issuer" });
            string aud = Appsettings.app(new string[] { "Audience", "Audience" });
            string secret = Appsettings.app(new string[] { "Audience", "Secret" });

            //var claims = new Claim[] //old
            var claims = new List<Claim>
                {
                 /*
                 * 特别重要:
                   1、这里将用户的部分信息,比如 uid 存到了Claim 中,如果你想知道如何在其他地方将这个 uid从 Token 中取出来,请看下边的SerializeJwt() 方法,或者在整个解决方案,搜索这个方法,看哪里使用了!
                   2、你也可以研究下 HttpContext.User.Claims ,具体的你可以看看 Policys/PermissionHandler.cs 类中是如何使用的。
                 */

                    

                new Claim(JwtRegisteredClaimNames.Jti, tokenModel.Uid.ToString()),
                new Claim(JwtRegisteredClaimNames.Iat, $"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}"),
                new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
                //这个就是过期时间,目前是过期1000秒,可自定义,注意JWT有自己的缓冲过期时间
                new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.AddSeconds(1000)).ToUnixTimeSeconds()}"),
                new Claim(JwtRegisteredClaimNames.Iss,iss),
                new Claim(JwtRegisteredClaimNames.Aud,aud),
                
                //new Claim(ClaimTypes.Role,tokenModel.Role),//为了解决一个用户多个角色(比如:Admin,System),用下边的方法
               };

            // 可以将一个用户的多个角色全部赋予;
            // 作者:DX 提供技术支持;
            claims.AddRange(tokenModel.Role.Split(',').Select(s => new Claim(ClaimTypes.Role, s)));



            //秘钥 (SymmetricSecurityKey 对安全性的要求,密钥的长度太短会报出异常)
            var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
            var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

            var jwt = new JwtSecurityToken(
                issuer: iss,
                claims: claims,
                signingCredentials: creds);

            var jwtHandler = new JwtSecurityTokenHandler();
            var encodedJwt = jwtHandler.WriteToken(jwt);

            return encodedJwt;
        }

        /// <summary>
        /// 解析
        /// </summary>
        /// <param name="jwtStr"></param>
        /// <returns></returns>
        public static TokenModelJwt SerializeJwt(string jwtStr)
        {
            var jwtHandler = new JwtSecurityTokenHandler();
            JwtSecurityToken jwtToken = jwtHandler.ReadJwtToken(jwtStr);
            object role;
            try
            {
                jwtToken.Payload.TryGetValue(ClaimTypes.Role, out role);
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                throw;
            }
            var tm = new TokenModelJwt
            {
                Uid = (jwtToken.Id).ObjToInt(),
                Role = role != null ? role.ObjToString() : "",
            };
            return tm;
        }
    }

    /// <summary>
    /// 令牌
    /// </summary>
    public class TokenModelJwt
    {
        /// <summary>
        /// Id
        /// </summary>
        public long Uid { get; set; }
        /// <summary>
        /// 角色
        /// </summary>
        public string Role { get; set; }
        /// <summary>
        /// 职能
        /// </summary>
        public string Work { get; set; }

    }

 

    public class Appsettings
    {
        static IConfiguration Configuration { get; set; }

        //static Appsettings()
        //{
        //    //ReloadOnChange = true 当appsettings.json被修改时重新加载
        //    Configuration = new ConfigurationBuilder()
        //    .Add(new JsonConfigurationSource { Path = "appsettings.json", ReloadOnChange = true })//请注意要把当前appsetting.json 文件->右键->属性->复制到输出目录->始终复制
        //    .Build();
        //}

        static Appsettings()
        {
            string Path = "appsettings.json";
            {
                //如果你把配置文件 是 根据环境变量来分开了,可以这样写
                //Path = $"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}.json";
            }

            //Configuration = new ConfigurationBuilder()
            //.Add(new JsonConfigurationSource { Path = Path, ReloadOnChange = true })//请注意要把当前appsetting.json 文件->右键->属性->复制到输出目录->始终复制
            //.Build();

            Configuration = new ConfigurationBuilder()
               .SetBasePath(Directory.GetCurrentDirectory())
               .Add(new JsonConfigurationSource { Path = Path, Optional = false, ReloadOnChange = true })//这样的话,可以直接读目录里的json文件,而不是 bin 文件夹下的,所以不用修改复制属性
               .Build();


        }

        /// <summary>
        /// 封装要操作的字符
        /// </summary>
        /// <param name="sections"></param>
        /// <returns></returns>
        public static string app(params string[] sections)
        {
            try
            {
                var val = string.Empty;
                for (int i = 0; i < sections.Length; i++)
                {
                    val += sections[i] + ":";
                }

                return Configuration[val.TrimEnd(':')];
            }
            catch (Exception)
            {
                return "";
            }

        }
    }
Appsettings —— appsetting.json 操作类

 

 

 

这个接口如何调用呢,很简单,就是我们的登录api:

      public async Task<object> GetJwtStr(string name, string pass)
        {
            string jwtStr = string.Empty;
            bool suc = false;

// 获取用户的角色名,请暂时忽略其内部是如何获取的,可以直接用 var userRole="Admin"; 来代替更好理解。 var userRole = await _sysUserInfoServices.GetUserRoleNameStr(name, pass);
      
if (userRole != null) {
// 将用户id和角色名,作为单独的自定义变量封装进 token 字符串中。 TokenModelJwt tokenModel
= new TokenModelJwt {Uid = 1, Role = userRole}; jwtStr = JwtHelper.IssueJwt(tokenModel);//登录,获取到一定规则的 Token 令牌 suc = true; } else { jwtStr = "login fail!!!"; } return Ok(new { success = suc, token = jwtStr }); }

 

 /// <summary>
 /// 令牌
 /// </summary>
 public class TokenModelJwt
    {
        /// <summary>
        /// Id
        /// </summary>
        public long Uid { get; set; }
        /// <summary>
        /// 角色
        /// </summary>
        public string Role { get; set; }
        /// <summary>
        /// 职能
        /// </summary>
        public string Work { get; set; }

    }

 

现在我们获取到Token了,那如何进行授权认证呢,别着急,重头戏马上到来!

 

一、JWT授权认证流程——自定义中间件

在之前的搭建中,swagger已经基本成型,其实其功能之多,不是我这三篇所能写完的,想要添加权限,先从服务开始

0、Swagger中开启JWT服务

我们要测试 JWT 授权认证,就必定要输入 Token令牌,那怎么输入呢,平时的话,我们可以使用 Postman 来控制输入,就是在请求的时候,在 Header 中,添加Authorization属性,

但是我们现在使用了 Swagger 作为接口文档,那怎么输入呢,别着急, Swagger 已经帮我们实现了这个录入 Token令牌的功能:

在ConfigureServices  -> AddSwaggerGen 服务中,增加以下红色代码,注意是swagger服务内部

/// <summary>
        /// ConfigureServices 方法
        /// </summary>
        /// <param name="services"></param>
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            #region Swagger
            services.AddSwaggerGen(c =>
            {
                c.SwaggerDoc("v1", new Info
                {
                    Version = "v0.1.0",
                    Title = "Blog.Core API",
                    Description = "框架说明文档",
                    TermsOfService = "None",
                    Contact = new Swashbuckle.AspNetCore.Swagger.Contact { Name = "Blog.Core", Email = "Blog.Core@xxx.com", Url = "https://www.jianshu.com/u/94102b59cc2a" }
                });

                //就是这里

                #region 读取xml信息
                var basePath = Microsoft.DotNet.PlatformAbstractions.ApplicationEnvironment.ApplicationBasePath;
                var xmlPath = Path.Combine(basePath, "Blog.Core.xml");//这个就是刚刚配置的xml文件名
                var xmlModelPath = Path.Combine(basePath, "Blog.Core.Model.xml");//这个就是Model层的xml文件名
                c.IncludeXmlComments(xmlPath, true);//默认的第二个参数是false,这个是controller的注释,记得修改
                c.IncludeXmlComments(xmlModelPath);
                #endregion

                #region Token绑定到ConfigureServices
                //添加header验证信息
                //c.OperationFilter<SwaggerHeader>();
                var security = new Dictionary<string, IEnumerable<string>> { { "Blog.Core", new string[] { } }, };
                c.AddSecurityRequirement(security);
                //方案名称“Blog.Core”可自定义,上下一致即可
                c.AddSecurityDefinition("Blog.Core", new ApiKeyScheme
                {
                    Description = "JWT授权(数据将在请求头中进行传输) 直接在下框中输入Bearer {token}(注意两者之间是一个空格)"",
                    Name = "Authorization",//jwt默认的参数名称
                    In = "header",//jwt默认存放Authorization信息的位置(请求头中)
                    Type = "apiKey"
                }); 
                #endregion


            });
            #endregion

        }

 

然后执行代码,就可以在 swagger/index.html 页面里看到这个Token入口了:

 

 

大家点开,看到输入框,在输入Token的时候,需要在Token令牌的前边加上Bearer (为什么要加这个,下文会说明,请一定要注意看,一定要明白为啥要带,因为它涉及到了什么是授权,什么是认证,还要自定义认证中间件还是官方认证中间件的区别,请注意看下文),比如是这样的:

但是请注意!如果你使用的是中间件 app.UseMiddleware<JwtTokenAuth>() ,要是使用 Bearer xxxx传值的时候,记得在中间件的方法中,把Token的 “Bearer 空格” 字符给截取掉,这样的:


 

1:API接口授权策略

这里可以直接在api接口上,直接设置该接口所对应的角色权限信息:

这个时候我们就需要对每一个接口设置对应的 Roles 信息,但是如果我们的接口需要对应多个角色的时候,我们就可以直接写多个:

 

这里有一个情况,如果角色多的话,不仅不利于我们阅读,还可能在配置的时候少一两个role,比如这个 api接口1 少了一个 system 的角色,再比如那个 api接口2 把 Admin 角色写成了 Adnin 这种不必要的错误,真是很难受,那怎么办呢,欸!这个时候就出现了基于策略的授权机制:

我们在 ConfigureService 中可以这么设置:

// 1【授权】、这个和上边的异曲同工,好处就是不用在controller中,写多个 roles 。
// 然后这么写 [Authorize(Policy = "Admin")]
services.AddAuthorization(options =>
{
    options.AddPolicy("Client", policy => policy.RequireRole("Client").Build());
    options.AddPolicy("Admin", policy => policy.RequireRole("Admin").Build());
    options.AddPolicy("SystemOrAdmin", policy => policy.RequireRole("Admin", "System"));
});

 

这样的话,我们只需要在 controller 或者 action 上,直接写策略名就可以了:

 [HttpGet]
 [Authorize(Policy = "SystemOrAdmin")]
 public ActionResult<IEnumerable<string>> Get()
 {
     return new string[] { "value1", "value2" };
 }

 

这样我们的第一步就完成了。继续走第二步,身份验证方案。

 关于授权认证有两种方式,可以使用官方的认证方式,也可以使用自定义中间件的方法,具体请往下看,咱们先说说如何进行自定义认证。

 

2、自定义认证之身份验证设置

上边第一步中,咱们已经对每一个接口api设置好了 授权机制 ,那这里就要开始认证,咱们先看看如何实现自定义的认证:

 

JwtTokenAuth,一个中间件,用来过滤每一个http请求,就是每当一个用户发送请求的时候,都先走这一步,然后再去访问http请求的接口

 

    public class JwtTokenAuth
    {
        // 中间件一定要有一个next,将管道可以正常的走下去
        private readonly RequestDelegate _next;
        public JwtTokenAuth(RequestDelegate next)
        {
            _next = next;
        }

        public Task Invoke(HttpContext httpContext)
        {

            //检测是否包含'Authorization'请求头
            if (!httpContext.Request.Headers.ContainsKey("Authorization"))
            {
                return _next(httpContext);
            }
            var tokenHeader = httpContext.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");

            try
            {
                if (tokenHeader.Length >= 128)
                {
                    TokenModelJwt tm = JwtHelper.SerializeJwt(tokenHeader);

                    //授权 Claim 关键
                    var claimList = new List<Claim>();
                    var claim = new Claim(ClaimTypes.Role, tm.Role);
                    claimList.Add(claim);
                    var identity = new ClaimsIdentity(claimList);
                    var principal = new ClaimsPrincipal(identity);
                    httpContext.User = principal;
                }

            }
            catch (Exception e)
            {
                Console.WriteLine($"{DateTime.Now} middleware wrong:{e.Message}");
            }
            return _next(httpContext);
        }

    }
    // 这里定义一个中间件Helper,主要作用就是给当前模块的中间件取一个别名
    public static class MiddlewareHelpers
    {
        public static IApplicationBuilder UseJwtTokenAuth(this IApplicationBuilder app)
        {
            return app.UseMiddleware<JwtTokenAuth>();
        }
    }

 

 

 

 
前两步咱们都完成了,从授权到自定义身份验证方案,就剩下最后一步,开启中间件了。
 

3:开启自定义认证中间件,实现Http信道拦截

这个很简单,只需要在 startup.cs -> Configure 中配置认证中间件
//自定义认证中间件
app.UseJwtTokenAuth(); //也可以app.UseMiddleware<JwtTokenAuth>();

 

4:开始测试

这个时候我们的自定义JWT授权认证已经结束了,我们开始测试,假设对某一个 api接口设置了权限:

 

在我们没有输入 Token 的时候,点击测试接口会报错:

 

 

InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found.
//没有指定身份验证方案, 也没有发现默认挑战方案。

这个错误很明显,就是说我们没有配置默认的认证方案,也没有自定义身份验证方案,

但是这个时候我们再进行试验:

刚刚上边的情况是我们没有输入 Token ,但是如果我们输入token呢?看看是不是又会报错?

 

 

我们发现了什么?!!没有报错,这是因为什么?欸,聪明的你应该想到了,请往下看,什么是 声明主体 ClaimsPrincipal 。

 

5、声明主体 ClaimsPrincipal 是如何保存的?

在上边,我们解决了一些问题,同时也出现了一个问题,就是为什么不输入 Token 就报错了,而输入了 Bearer xxxxxxxxxxx 这样的Token 就不报错了呢?这里要说到 声明主体的作用了。

就是我们上边写的自定义中间件,大家可以再来看看:

      // 自定义认证中间件,我们省略部分代码,来分析分析  
      public Task Invoke(HttpContext httpContext)
        {

            //检测是否包含'Authorization'请求头
            if (!httpContext.Request.Headers.ContainsKey("Authorization"))
            {
                //直接返回了 http 信道 ,就出现了我们上边的报错,没有指定身份验证方案, 也没有发现默认挑战方案
                return _next(httpContext);
            }

             //但是!请注意,这个时候我们输入了 token,我们就会在 httpcontext 上下文中,添加上我们自己自定义的身份验证方案!!!这就是没有继续报错的根本原因
             var tokenHeader = httpContext.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
             //........
             //授权
             var claimList = new List<Claim>();
             var claim = new Claim(ClaimTypes.Role, tm.Role);
             claimList.Add(claim);
             var identity = new ClaimsIdentity(claimList);
             var principal = new ClaimsPrincipal(identity);
             httpContext.User = principal;
            }

            return _next(httpContext);
        }

 

 这个时候你就应该明白了吧,

1、首先我们自定义授权认证,为啥可以不用进行下边截图中官方认证那一块的配置:

 

 

因为这一块官方的服务,就等同于我们的自定义身份验证方案——中间件

2、你应该明白,为什么不输入token的时候报错,而输入了就不报错了?

因为没有输入的时候,直接 return了,并没有在 httpContext 上下文中,进行配置声明主体 httpContext.User = principal

所以说,我们无论是自定义中间件的自定义身份验证方案,还是官方的认证方案,只要我们的登录了,也就是说,只要我们实现了某种规则:

在 Http 的 Header 里,增加属性Authorization ,并赋值 :Bearer xxxxxxxxxxxxxx;

 

这样,就会触发我们的内部服务,将当前 token 所携带的信息,进行自动解码,然后填充到声明主体里(自定义中间件需要手动配置,官方的自动就实现该操作),

所以这个时候我们就可以轻松的拿到想到的东西,比如这里这些:

 

 

6、无策略依然授权错误

上边咱们说到了,如果我们自定义中间件的话,在中间件中,我们在 Claims 添加了角色的相关权限:

而且很自然的在 接口中,也是分为两种情况:要么没有加权限,要么就是基于角色的加权:

 

 但是如果这个时候,我们直接对接口增加 无任何策略 的加权:

 

就是没有任何的策略,我们登录,然后添加 token,一看,还是报错了!具体的来看动图:

 

 


本来 [Authorize] 这种 无策略 的授权,按理说只需要我们登录了就可以了,不需要其他任何限制就可以访问,但是现在依然报错401 ,证明我们的中间件并不能对这种方案起到效果,你可能会问,那带有 Roles=“Admin” 的为啥可以呢?反而这种无策略的不行呢,我个人感觉可能还是中间件咱们设计的解决方案就是基于角色授权的那种,(我也再研究研究,看看能不能完善下这个自定义中间件,使它能适应这个 无具体策略 的加权方案,但是可能写到最后,就是无限解决官方的授权中间件了哈哈)。

这个时候我们发现,自定义中间件还是挺麻烦的,但是你通过自己使用自定义授权中间件,不仅仅可以了解到中间件的使用,还可以了解 netcore 到底是如何授权的机制,但是我还是建议大家使用官方的认证方案,毕竟他们考虑的很全面的。

 

那么如果我们想要用官方的认证方案呢,要怎么写呢?请往下看:

 

二、JWT授权认证流程——官方认证

上边咱们说完了自定义中间件的形式,发现了也方便的地方,也有不方便之处,虽然灵活的使用了自定义身份验证,但是毕竟很受限,而且也无法对过期时间进行判断,以后的文章你会看到《36 ║解决JWT自定义中间件授权过期问题》,这里先不说,重点说说,如何通过官方认证来实现。

1:API接口授权策略

和上边自定义的过程一模一样,略。

 

2、官方默认认证配置

在刚刚上边,咱们说到了一个错误,不知道还有没有印象:
No authenticationScheme was specified, and there was no DefaultChallengeScheme found. 
就是这个,自定义认证中间件呢,就是前者,那官方的,就是后者 DefaultChallengeScheme;
 
很简单,只需要在 configureService 中,添加【统一认证】即可:
 //2.1【认证】
 services.AddAuthentication(x =>
 {
     //看这个单词熟悉么?没错,就是上边错误里的那个。
     x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
     x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
 })
  .AddJwtBearer(o =>
  {
      o.TokenValidationParameters = new TokenValidationParameters
      {
          ValidateIssuerSigningKey = true,
          IssuerSigningKey = signingKey,//参数配置在下边
          ValidateIssuer = true,
          ValidIssuer = audienceConfig["Issuer"],//发行人
          ValidateAudience = true,
          ValidAudience = audienceConfig["Audience"],//订阅人
          ValidateLifetime = true,
          ClockSkew = TimeSpan.Zero,
          RequireExpirationTime = true,
      };

  });

 

上边代码中出现的部分参数定义(如果还看不懂,请看项目代码)

 #region 参数
 //读取配置文件
 var audienceConfig = Configuration.GetSection("Audience");
 var symmetricKeyAsBase64 = audienceConfig["Secret"];
 var keyByteArray = Encoding.ASCII.GetBytes(symmetricKeyAsBase64);
 var signingKey = new SymmetricSecurityKey(keyByteArray);


 var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256);

 

 

具体的每个配置的含义呢,我的代码里都有,大家自己可以看看,都很简单。

划重点:我们就是用的这个官方默认的方案,来替换了我们自定义中间件的身份验证方案,从而达到目的,说白了,就是官方封装了一套方案,这样我们就不用写中间件了。

 

3、配置官方认证中间件

这个很简单,还是在 configure 中添加:
 //如果你想使用官方认证,必须在上边ConfigureService 中,配置JWT的认证服务 (.AddAuthentication 和 .AddJwtBearer 二者缺一不可)
 app.UseAuthentication();

这样就完成了,结果也不用看了,大家自行测试即可,无论添加或者不添加 token ,都不会报错。

 

 

4、补充:什么是 Claim

如果对 claim[] 定义不是很理解,可以看看dudu大神的解释《理解ASP.NET Core验证模型(Claim, ClaimsIdentity, ClaimsPrincipal)不得不读的英文博文》:

这篇英文博文是 Andrew Lock 写的 Introduction to Authentication with ASP.NET Core 。

以下是简单的阅读笔记:

-----------------------------------

ASP.NET Core 的验证模型是 claims-based authentication 。Claim 是对被验证主体特征的一种表述,比如:登录用户名是...,email是...,用户Id是...,其中的“登录用户名”,“email”,“用户Id”就是ClaimType。

You can think of claims as being a statement about...That statement consists of a name and a value.

对应现实中的事物,比如驾照,驾照中的“身份证号码:xxx”是一个claim,“姓名:xxx”是另一个claim。

一组claims构成了一个identity,具有这些claims的identity就是 ClaimsIdentity ,驾照就是一种ClaimsIdentity,可以把ClaimsIdentity理解为“证件”,驾照是一种证件,护照也是一种证件。

ClaimsIdentity的持有者就是 ClaimsPrincipal ,一个ClaimsPrincipal可以持有多个ClaimsIdentity,就比如一个人既持有驾照,又持有护照。

------------------------------------

理解了Claim, ClaimsIdentity, ClaimsPrincipal这三个概念,就能理解生成登录Cookie为什么要用下面的代码?

var claimsIdentity = new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, loginName) }, "Basic");
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
await context.Authentication.SignInAsync(_cookieAuthOptions.AuthenticationScheme, claimsPrincipal);
要用Cookie代表一个通过验证的主体,必须包含Claim, ClaimsIdentity, ClaimsPrincipal这三个信息,以一个持有合法驾照的人做比方,ClaimsPrincipal就是持有证件的人,ClaimsIdentity就是证件,"Basic"就是证件类型(这里假设是驾照),Claim就是驾照中的信息。 

 

5、其他注意点

1、然后再Startup的Configure中,将TokenAuth注册中间件

注意1:HTTP管道是有先后顺序的,一定要写在 app.Mvc() 之前,否则不起作用。

 

注意2:这里我们是自定义了认证中间件,来对JWT的字符串进行自定义授权认证,所以上边都很正常,甚至我们的Token可以不用带 Bearer 特定字符串,如果你以后遇到了使用官方认证中间件 UseAuthentication(),那么就必须在 configureService 中对认证进行配置(而且Token传递的时候,也必须带上"Bearer " 这样的特定字符串,这也就是解释了上文,为啥要带Bearer),这里先打个预防针,因为我的最新 Github 上已经使用了官方的认证中间件,所以除了上边配置的那些服务外,还需要配置 Service.AddAuthentication 和 Service.AddJwtBearer 两个服务。

// 如果你想使用官方认证,必须在上边ConfigureService 中,配置JWT的认证服务
// .AddAuthentication 和 .AddJwtBearer 二者缺一不可
app.UseAuthentication();

 

 如果你感觉上边没看懂,继续用下边的知识点来巩固吧!

 

三、核心知识点梳理

以下是参考大神文章:@ASP.NET Core 认证与授权[4]:JwtBearer认证 ,一定要多看多想,下边的代码我没有试验正确性,大家看个意思即可,不用纠结正确与否,重点跟着这个系列往后走就行。

1、Bearer认证

HTTP提供了一套标准的身份验证框架:服务器可以用来针对客户端的请求发送质询(challenge),客户端根据质询提供身份验证凭证。质询与应答的工作流程如下:服务器端向客户端返回401(Unauthorized,未授权)状态码,并在WWW-Authenticate头中添加如何进行验证的信息,其中至少包含有一种质询方式。然后客户端可以在请求中添加Authorization头进行验证,其Value为身份验证的凭证信息。

HTTPAuth

在HTTP标准验证方案中,我们比较熟悉的是"Basic"和"Digest",前者将用户名密码使用BASE64编码后作为验证凭证,后者是Basic的升级版,更加安全,因为Basic是明文传输密码信息,而Digest是加密后传输。在前文介绍的Cookie认证属于Form认证,并不属于HTTP标准验证。

本文要介绍的Bearer验证也属于HTTP协议标准验证,它随着OAuth协议而开始流行,详细定义见: RFC 6570

A security token with the property that any party in possession of the token (a "bearer") can use the token in any way that any other party in possession of it can. Using a bearer token does not require a bearer to prove possession of cryptographic key material (proof-of-possession).

Bearer验证中的凭证称为BEARER_TOKEN,或者是access_token,它的颁发和验证完全由我们自己的应用程序来控制,而不依赖于系统和Web服务器,Bearer验证的标准请求方式如下:

Authorization: Bearer [BEARER_TOKEN] 

那么使用Bearer验证有什么好处呢?

  • CORS: cookies + CORS 并不能跨不同的域名。而Bearer验证在任何域名下都可以使用HTTP header头部来传输用户信息。

  • 对移动端友好: 当你在一个原生平台(iOS, Android, WindowsPhone等)时,使用Cookie验证并不是一个好主意,因为你得和Cookie容器打交道,而使用Bearer验证则简单的多。

  • CSRF: 因为Bearer验证不再依赖于cookies, 也就避免了跨站请求攻击。

  • 标准:在Cookie认证中,用户未登录时,返回一个302到登录页面,这在非浏览器情况下很难处理,而Bearer验证则返回的是标准的401 challenge

2、JWT(JSON WEB TOKEN)

上面介绍的Bearer认证,其核心便是BEARER_TOKEN,而最流行的Token编码方式便是:JSON WEB TOKEN。

Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准(RFC 7519)。该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

JWT是由.分割的如下三部分组成:

头部(Header)

Header 一般由两个部分组成:

  • alg
  • typ

alg是是所使用的hash算法,如:HMAC SHA256或RSA,typ是Token的类型,在这里就是:JWT。

{
  "alg": "HS256",
  "typ": "JWT"
}

然后使用Base64Url编码成第一部分:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<second part>.<third part>

载荷(Payload)

这一部分是JWT主要的信息存储部分,其中包含了许多种的声明(claims)。

Claims的实体一般包含用户和一些元数据,这些claims分成三种类型:

  • reserved claims:预定义的 一些声明,并不是强制的但是推荐,它们包括 iss (issuer), exp (expiration time), sub (subject),aud(audience) 等(这里都使用三个字母的原因是保证 JWT 的紧凑)。

  • public claims: 公有声明,这个部分可以随便定义,但是要注意和 IANA JSON Web Token 冲突。

  • private claims: 私有声明,这个部分是共享被认定信息中自定义部分。

一个简单的Pyload可以是这样子的:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

这部分同样使用Base64Url编码成第二部分:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.<third part>

签名(Signature)

Signature是用来验证发送者的JWT的同时也能确保在期间不被篡改。

在创建该部分时候你应该已经有了编码后的Header和Payload,然后使用保存在服务端的秘钥对其签名,一个完整的JWT如下:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

因此使用JWT具有如下好处:

  • 通用:因为json的通用性,所以JWT是可以进行跨语言支持的,像JAVA,JavaScript,NodeJS,PHP等很多语言都可以使用。

  • 紧凑:JWT的构成非常简单,字节占用很小,可以通过 GET、POST 等放在 HTTP 的 header 中,非常便于传输。

  • 扩展:JWT是自我包涵的,包含了必要的所有信息,不需要在服务端保存会话信息, 非常易于应用的扩展。

关于更多JWT的介绍,网上非常多,这里就不再多做介绍。下面,演示一下 ASP.NET Core 中 JwtBearer 认证的使用方式。

3、示例

模拟Token

ASP.NET Core 内置的JwtBearer验证,并不包含Token的发放,我们先模拟一个简单的实现:

[HttpPost("authenticate")]
public IActionResult Authenticate([FromBody]UserDto userDto)
{
    var user = _store.FindUser(userDto.UserName, userDto.Password);
    if (user == null) return Unauthorized();
    var tokenHandler = new JwtSecurityTokenHandler();
    var key = Encoding.ASCII.GetBytes(Consts.Secret);
    var authTime = DateTime.UtcNow;
    var expiresAt = authTime.AddDays(7);
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new Claim[]
        {
            new Claim(JwtClaimTypes.Audience,"api"),
            new Claim(JwtClaimTypes.Issuer,"http://localhost:5200"),
            new Claim(JwtClaimTypes.Id, user.Id.ToString()),
            new Claim(JwtClaimTypes.Name, user.Name),
            new Claim(JwtClaimTypes.Email, user.Email),
            new Claim(JwtClaimTypes.PhoneNumber, user.PhoneNumber)
        }),
        Expires = expiresAt,
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
    };
    var token = tokenHandler.CreateToken(tokenDescriptor);
    var tokenString = tokenHandler.WriteToken(token);
    return Ok(new
    {
        access_token = tokenString,
        token_type = "Bearer",
        profile = new
        {
            sid = user.Id,
            name = user.Name,
            auth_time = new DateTimeOffset(authTime).ToUnixTimeSeconds(),
            expires_at = new DateTimeOffset(expiresAt).ToUnixTimeSeconds()
        }
    });
}

如上,使用微软提供的Microsoft.IdentityModel.Tokens帮助类(源码地址:azure-activedirectory-identitymodel-extensions-for-dotnet),可以很容易的创建出JwtToen,就不再多说。

注册JwtBearer认证

首先添加JwtBearer包引用:

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 2.0.0

然后在Startup类中添加如下配置:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(x =>
    {
        x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(o =>
    {
        o.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = JwtClaimTypes.Name,
            RoleClaimType = JwtClaimTypes.Role, 

            ValidIssuer = "http://localhost:5200",
            ValidAudience = "api",
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(Consts.Secret))

            /***********************************TokenValidationParameters的参数默认值***********************************/
            // RequireSignedTokens = true,
            // SaveSigninToken = false,
            // ValidateActor = false,
            // 将下面两个参数设置为false,可以不验证Issuer和Audience,但是不建议这样做。
            // ValidateAudience = true,
            // ValidateIssuer = true, 
            // ValidateIssuerSigningKey = false,
            // 是否要求Token的Claims中必须包含Expires
            // RequireExpirationTime = true,
            // 允许的服务器时间偏移量
            // ClockSkew = TimeSpan.FromSeconds(300),
            // 是否验证Token有效期,使用当前时间与Token的Claims中的NotBefore和Expires对比
            // ValidateLifetime = true
        };
    });
}

public void Configure(IApplicationBuilder app)
{
    app.UseAuthentication();
}

JwtBearerOptions的配置中,通常IssuerSigningKey(签名秘钥)ValidIssuer(Token颁发机构)ValidAudience(颁发给谁) 三个参数是必须的,后两者用于与TokenClaims中的IssuerAudience进行对比,不一致则验证失败(与上面发放Token中的Claims对应)。

NameClaimTypeRoleClaimType需与Token中的ClaimType一致,在IdentityServer中也是使用的JwtClaimTypes,否则会造成User.Identity.Name为空等问题。

添加受保护资源

创建一个需要授权的控制器,直接使用Authorize即可:

[Authorize]
[Route("api/[controller]")]
public class SampleDataController : Controller
{
    [HttpGet("[action]")]
    public IEnumerable<WeatherForecast> WeatherForecasts()
    {
        return ...
    }
}

运行

最后运行,直接访问/api/SampleData/WeatherForecasts,将返回一个401:

HTTP/1.1 401 Unauthorized
Server: Kestrel
Content-Length: 0
WWW-Authenticate: Bearer

让我们调用api/oauth/authenticate,获取一个JWT:

请求:
POST http://localhost:5200/api/oauth/authenticate HTTP/1.1
content-type: application/json

{
  "username": "alice",
  "password": "alice"
}

响应:
HTTP/1.1 200 OK
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiYWxpY2UiLCJlbWFpbCI6ImFsaWNlQGdtYWlsLmNvbSIsInBob25lX251bWJlciI6IjE4ODAwMDAwMDAxIiwibmJmIjoxNTA5NDY0MzQwLCJleHAiOjE1MTAwNjkxNDAsImlhdCI6MTUwOTQ2NDM0MH0.Y1TDz8KjLRh_vjQ_3iYP4oJw-fmhoboiAGPqIZ-ooNc","token_type":"Bearer","profile":{"sid":1,"name":"alice","auth_time":1509464340,"expires_at":1510069140}}

最后使用该Token,再次调用受保护资源:

GET http://localhost:5200/api/SampleData/WeatherForecasts HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiYWxpY2UiLCJlbWFpbCI6ImFsaWNlQGdtYWlsLmNvbSIsInBob25lX251bWJlciI6IjE4ODAwMDAwMDAxIiwibmJmIjoxNTA5NDY0MzQwLCJleHAiOjE1MTAwNjkxNDAsImlhdCI6MTUwOTQ2NDM0MH0.Y1TDz8KjLRh_vjQ_3iYP4oJw-fmhoboiAGPqIZ-ooNc

授权成功,返回了预期的数据:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"dateFormatted":"2017/11/3","temperatureC":35,"summary":"Chilly","temperatureF":94}]

4、扩展

自定义Token获取方式

JwtBearer认证中,默认是通过Http的Authorization头来获取的,这也是最推荐的做法,但是在某些场景下,我们可能会使用Url或者是Cookie来传递Token,那要怎么来实现呢?

其实实现起来非常简单,如前几章介绍的一样,JwtBearer也在认证的各个阶段为我们提供了事件,来执行我们的自定义逻辑:

.AddJwtBearer(o =>
{
    o.Events = new JwtBearerEvents()
    {
        OnMessageReceived = context =>
        {
            context.Token = context.Request.Query["access_token"];
            return Task.CompletedTask;
        }
    };
    o.TokenValidationParameters = new TokenValidationParameters
    {
        ...
    };

然后在Url中添加access_token=[token],直接在浏览器中访问:

access_token_in_url

同样的,我们也可以很容易的在Cookie中读取Token,就不再演示。

除了OnMessageReceived外,还提供了如下几个事件:

  • TokenValidated:在Token验证通过后调用。

  • AuthenticationFailed: 认证失败时调用。

  • Challenge: 未授权时调用。

使用OIDC服务

在上面的示例中,我们简单模拟的Token颁发,功能非常简单,并不适合在生产环境中使用,可是微软也没有提供OIDC服务的实现,好在.NET社区中提供了几种实现,可供我们选择:

NameDescription
AspNet.Security.OpenIdConnect.Server (ASOS) Low-level/protocol-first OpenID Connect server framework for ASP.NET Core and OWIN/Katana
IdentityServer4 OpenID Connect and OAuth 2.0 framework for ASP.NET Core - officially certified by the OpenID Foundation and under governance of the .NET Foundation
OpenIddict Easy-to-use OpenID Connect server for ASP.NET Core
PwdLess Simple, stateless, passwordless authentication for ASP.NET Core

我们在这里使用IdentityServer4来搭建一个OIDC服务器,并添加如下配置:

/********************OIDC服务器代码片段********************/
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();

    // 配置IdentitryServer
    services.AddIdentityServer()
        .AddInMemoryPersistedGrants()
        .AddInMemoryApiResources(Config.GetApis())
        .AddInMemoryIdentityResources(Config.GetIdentityResources())
        .AddInMemoryClients(Config.GetClients())
        .AddTestUsers(Config.GetUsers())
        .AddDeveloperSigningCredential();
}

new Client
{
    ClientId = "jwt.implicit",
    ClientName = "Implicit Client (Web)",
    AllowedGrantTypes = GrantTypes.Implicit,
    AllowAccessTokensViaBrowser = true,
    RedirectUris = { "http://localhost:5200/callback" },
    PostLogoutRedirectUris = { "http://localhost:5200/home" },
    AllowedCorsOrigins = { "http://localhost:5200" },
    AllowedScopes = { "openid", "profile", "email", "api" },
}

而JwtBearer客户端的配置就更加简单了,因为OIDC具有配置发现的功能:

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(x =>
    {
        x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(o =>
    {
        o.Authority = "https://oidc.faasx.com/";
        o.Audience = "api";
        o.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = JwtClaimTypes.Name,
            RoleClaimType = JwtClaimTypes.Role,
        };
    });
}

如上,最重要的是Authority参数,用来表示OIDC服务的地址,然后便可以自动发现IssuerIssuerSigningKey等配置,而o.Audienceo.TokenValidationParameters = new TokenValidationParameters { ValidAudience = "api" }是等效的,后面分析源码时会介绍。

OIDC兼容OAuth2协议,我们可以使用上一章介绍的授权码模式来获取Token,也可以直接用户名密码模式来获取Token:

请求:
POST https://oidc.faasx.com/connect/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

client_id=client.rop&client_secret=secret&grant_type=password&scope=api&username=alice&password=alice

响应:
HTTP/1.1 200 OK
Content-Type: application/json

{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IjdlYzk5MjVlMmUzMTA2NmY2ZmU2ODgzMDRhZjU1ZmM0IiwidHlwIjoiSldUIn0.eyJuYmYiOjE1MDk2NzI1NjksImV4cCI6MTUwOTY3NjE2OSwiaXNzIjoiaHR0cHM6Ly9vaWRjLmZhYXN4LmNvbSIsImF1ZCI6WyJodHRwczovL29pZGMuZmFhc3guY29tL3Jlc291cmNlcyIsImFwaSJdLCJjbGllbnRfaWQiOiJjbGllbnQucm9wIiwic3ViIjoiMDAxIiwiYXV0aF90aW1lIjoxNTA5NjcyNTY5LCJpZHAiOiJsb2NhbCIsIm5hbWUiOiJBbGljZSBTbWl0aCIsImVtYWlsIjoiQWxpY2VTbWl0aEBlbWFpbC5jb20iLCJzY29wZSI6WyJhcGkiXSwiYW1yIjpbInB3ZCJdfQ.PM93LThOZA3lkgPFVwieqGQQQtgmYDCY0oSFVmudv1hpKO6UaaZsmnn4ci9QjbGl5g2433JkDks5UIZsZ0xE62Qqq8PicPBBuaNoYrCf6dxR7j-0uZcoa7-FCKGu-0TrM8OL-NuMvN6_KEpbWa3jlkwibCK9YDIwJZilVoWUOrbbIEsKTa-DdLScmzHLUzksT8GBr0PAVhge9PRFiGqg8cgMLjsA62ZeDsR35f55BucSV5Pj0SAj26anYvrBNTHKOF7ze1DGW51Dbz6DRu1X7uEIxSzWiNi4cRVJ6Totjkwk5F78R9R38o_mYEdehZBjRHFe6zLd91hXcCKqOEh5eQ","expires_in":3600,"token_type":"Bearer"}

我们使用https://jwt.io解析一下OIDC服务器颁发的Token中的Claims:

{
  "nbf": 1509672569, // 2017/11/3 1:29:29 NotBefore Token生效时间,在此之前不可用
  "exp": 1509676169, // 2017/11/3 2:29:29 Expiration Token过期时间,在此之后不可用
  "iss": "https://oidc.faasx.com", // Issuer 颁发者,通常为STS服务器地址
  "aud": [ // Audience Token的作用对象,也就是被访问的资源服务器授权标识
    "https://oidc.faasx.com/resources",
    "api"
  ],
  "client_id": "client.rop", // 客户端标识
  "sub": "001",
  "auth_time": 1509672569, // Token颁发时间
  "idp": "local",
  "name": "Alice Smith",
  "email": "AliceSmith@email.com",
  "scope": [
    "api"
  ],
  "amr": [
    "pwd"
  ]
}

 

这一篇呢,写的比较潦草,主要是讲如何使用,具体的细节知识,还是大家摸索,还是那句话,这里只是抛砖引玉的作用哟,通过阅读本文,你会了解到,什么是JWT,如何添加配置.net core 中间件,如何使用Token验证,在以后的项目里你就可以在登录的时候,调用Token,返回客户端,然后判断是否有相应的接口权限。

 

四、常见疑惑解析

1、JWT里会存在一些用户的信息,比如用户id、角色role 等等,这样会不会不安全,信息被泄露?

答:JWT 本来就是一种无状态的登录授权认证,用来替代每次请求都需要输入用户名+密码的尴尬情况,存在一些不重要的明文很正常,只要不把隐私放出去就行,就算是被动机不良的人得到,也做不了什么事情。

2、生成 JWT 的时候需要 secret ,但是 解密的时候 为啥没有用到 secret ?

答:secret的作用,主要是用来防止 token 被伪造和篡改的,想想上边的那个第一个问题,用户得到了你的令牌,获取到了你的个人信息,这个是没事儿的,他什么也干不了,但是如果用户自己随便的生成一个 token ,带上你的uid,岂不是随便就可以访问资源服务器了,所以这个时候就需要一个 secret 来生成 token,这样的话,就能保证数字签名的正确性。

  而且,在我们资源服务器里,将token解析的时候,微软封装了方法,将secret进行校验了,这就是保证了token的安全性,从而保证我们的资源api是安全的,你不信的话,可以用你网站的 token 来访问我的在线项目,就算是 uid,role等等全部正确,还是不能访问我的网站,因为你不知道我的secret,所以你生成的令牌对我的是无效的。

 

可以看看这个视频:https://www.bilibili.com/video/av52076900?share_medium=android&share_source=qq&bbid=XZ786B57591674D68847894D8F16996AAFFB6&ts=1559452290064

 

 

 

五、结语

好啦!项目准备阶段就这么结束了,以后咱们就可以直接用swagger来调试了,而不是每次都用F5运行等,接下来我们就要正式开始搭建项目了,主要采用的是泛型仓储模式 Repository+Service,也是一种常见的模式。

六、Github

本系列开源地址

https://github.com/anjoy8/Blog.Core.git

本文章小Demo

https://github.com/anjoy8/BlogArti/tree/master/Blog.Core_JWT

 

内容来源于网络如有侵权请私信删除
你还没有登录,请先登录注册
  • 还没有人评论,欢迎说说您的想法!