ASP.NET CORE SWAGGER 教程三 JWT权限验证
原文作者:老张的哲学如何给接口实现权限验证?其实关于这一块,我思考了下,因为毕竟我的项目中是使用的vue + api 搭建一个前台展示,大部分页面都没有涉及到权限验证,本来要忽略这一章节,可是犹豫再三,还是给大家简单分析了下,个人还是希望陪大家一直搭建一个较为强大的,只要是涉及到后端那一定就需要 登录=》验证了,本文主要是参考网友https://www.cnblogs.com/RayWan...
原文作者:老张的哲学
如何给接口实现权限验证?
其实关于这一块,我思考了下,因为毕竟我的项目中是使用的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 的,代码记录如下:
using Blog.Core.API.Utils;
using Microsoft.IdentityModel.Tokens;
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
namespace Blog.Core.API.AuthHelper.OverWrite
{
/// <summary>
/// 颁发Jwt字符串
/// </summary>
public class JwtHelper
{
public static string IssueJwt(TokenModelJwt tokenModel)
{
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 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),
};
// 可以将一个用户的多个角色全部赋予;
// 作者: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 SerilaizeJwt(string jwtStr)
{
var jwtHandler = new JwtSecurityTokenHandler();
JwtSecurityToken jwtToken = new JwtSecurityToken(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; }
}
}
Appsettings.cs
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace Blog.Core.API.Utils
{
public class Appsettings
{
static IConfiguration Configuration { get; set; }
static Appsettings()
{
string Path = "appsettings.json";
Configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.Add(new JsonConfigurationSource
{
Path = Path,
Optional = false,
ReloadOnChange = true
})
.Build();// 这样的话,可以直接读目录里的json文件,而不是 bin 文件夹下的,所以不用修改复制属性
}
/// <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 e)
{
return "";
}
}
}
}
这个接口如何调用呢,很简单,就是我们的登录api:
/// <summary>
/// 登陆
/// </summary>
/// <param name="name">账号</param>
/// <param name="pass">密码</param>
/// <returns></returns>
[HttpPost]
public async Task<IActionResult> GetJwtStr(string name, string pass)
{
string jwtStr = string.Empty;
bool suc = false;
// 获取用户的角色名,请暂时忽略其内部是如何获取的,可以直接用 var userRole="Admin"; 来代替更好理解。
var userRole = "admin";
if (userRole != null)
{
// 将用户id和角色名,作为单独的自定义变量封装进 token 字符串中。
TokenModelJwt tokenModel = new TokenModelJwt { Uid = 1, Role = userRole };
// 登录,获取到一定规则的 Token 令牌
jwtStr = JwtHelper.IssueJwt(tokenModel);
suc = true;
}
else
{
jwtStr = "login fail!";
}
return Ok(new
{
success = suc,
token = jwtStr,
});
}
现在我们获取到Token了,那如何进行授权认证呢,别着急,重头戏马上到来!
一、JWT授权认证流程——自定义中间件
在之前的搭建中,swagger已经基本成型,其实其功能之多,不是我这三篇所能写完的,想要添加权限,先从服务开始
0、Swagger中开启JWT服务
我们要测试 JWT 授权认证,就必定要输入 Token令牌,那怎么输入呢,平时的话,我们可以使用 Postman 来控制输入,就是在请求的时候,在 Header 中,添加Authorization属性,
但是我们现在使用了 Swagger 作为接口文档,那怎么输入呢,别着急, Swagger 已经帮我们实现了这个录入 Token令牌的功能:
在ConfigureServices -> AddSwaggerGen 服务中,增加以下===...之间的代码,注意是swagger服务内部:
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
#region Swagger
services.AddSwaggerGen(c => {
c.SwaggerDoc("v1",new Info {
Version = "v0.1.0",
Title = "Blog.Core API",
Description = "框架说明文档",
TermsOfService = "None",
Contact = new Contact
{
Name = "Blog.Core",
Email = "412102100@qq.com",
Url = "https://www.jianshu.com/u/4198eba0fbdd?_wv=1031"
}
});
// 获取运行时根目录
var basePath = Microsoft.DotNet.PlatformAbstractions.ApplicationEnvironment.ApplicationBasePath;
// 这个就是刚刚配置的xml文件名
var xmlPath = Path.Combine(basePath,"Blog.Core.API.xml");
// 默认的第二个参数是false,这个是controller的注释,记得修改
c.IncludeXmlComments(xmlPath, true);
// 这个就是Model层的xml文件名
var xmlModelPath = Path.Combine(basePath,"Blog.Core.Model.xml");
c.IncludeXmlComments(xmlModelPath);
=====================================
#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入口了:
image.png
大家点开,看到输入框,在输入Token的时候,需要在Token令牌的前边加上Bearer (为什么要加这个,下文会说明,请一定要注意看,一定要明白为啥要带,因为它涉及到了什么是授权,什么是认证,还要自定义认证中间件还是官方认证中间件的区别,请注意看下文),比如是这样的:
image.png
但是请注意!如果你使用的是中间件 app.UseMiddleware<JwtTokenAuth>() ,要是使用 Bearer xxxx传值的时候,记得在中间件的方法中,把Token的 “Bearer 空格” 字符给截取掉,这样的:
1:API接口授权策略
这里可以直接在api接口上,直接设置该接口所对应的角色权限信息:
image.png
这个时候我们就需要对每一个接口设置对应的 Roles 信息,但是如果我们的接口需要对应多个角色的时候,我们就可以直接写多个:
image.png
这里有一个情况,如果角色多的话,不仅不利于我们阅读,还可能在配置的时候少一两个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请求的接口
JwtTokenAuth.cs
using Blog.Core.API.AuthHelper.OverWrite;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Blog.Core.API.AuthHelper
{
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);
}
}
}
MiddlewareHelpers.cs
using Microsoft.AspNetCore.Builder;
namespace Blog.Core.API.AuthHelper
{
// 这里定义一个中间件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接口设置了权限:
image.png
在我们没有输入 Token 的时候,点击测试接口会报错:
a.gif
InvalidOperationException: No authenticationScheme was specified, and there was no DefaultChallengeScheme found. //没有指定身份验证方案, 也没有发现默认挑战方案。
这个错误很明显,就是说我们没有配置默认的认证方案,也没有自定义身份验证方案,
但是这个时候我们再进行试验:
刚刚上边的情况是我们没有输入 Token ,但是如果我们输入token呢?看看是不是又会报错?
a.gif
我们发现了什么?!!没有报错,这是因为什么?欸,聪明的你应该想到了,请往下看,什么是 声明主体 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、首先我们自定义授权认证,为啥可以不用进行下边截图中官方认证那一块的配置:
image.png
因为这一块官方的服务,就等同于我们的自定义身份验证方案——中间件。
2、你应该明白,为什么不输入token的时候报错,而输入了就不报错了?
因为没有输入的时候,直接 return了,并没有在 httpContext 上下文中,进行配置声明主体 httpContext.User = principal 。
所以说,我们无论是自定义中间件的自定义身份验证方案,还是官方的认证方案,只要我们的登录了,也就是说,只要我们实现了某种规则:在 Http 的 Header 里,增加属性Authorization ,并赋值 :Bearer xxxxxxxxxxxxxx;
这样,就会触发我们的内部服务,将当前 token 所携带的信息,进行自动解码,然后填充到声明主体里(自定义中间件需要手动配置,官方的自动就实现该操作),
所以这个时候我们就可以轻松的拿到想到的东西,比如这里这些:
6、无策略依然授权错误
上边咱们说到了,如果我们自定义中间件的话,在中间件中,我们在 Claims 添加了角色的相关权限:
image.png
而且很自然的在 接口中,也是分为两种情况:要么没有加权限,要么就是基于角色的加权:
image.png
但是如果这个时候,我们直接对接口增加 无任何策略 的加权:
image.png
就是没有任何的策略,我们登录,然后添加 token,一看,还是报错了!具体的来看动图:
a.gif
本来 [Authorize] 这种 无策略 的授权,按理说只需要我们登录了就可以了,不需要其他任何限制就可以访问,但是现在依然报错401 ,证明我们的中间件并不能对这种方案起到效果,你可能会问,那带有 Roles=“Admin” 的为啥可以呢?反而这种无策略的不行呢,我个人感觉可能还是中间件咱们设计的解决方案就是基于角色授权的那种,(我也再研究研究,看看能不能完善下这个自定义中间件,使它能适应这个 无具体策略 的加权方案,但是可能写到最后,就是无限接近官方的授权中间件了哈哈)。
这个时候我们发现,自定义中间件还是挺麻烦的,但是你通过自己使用自定义授权中间件,不仅仅可以了解到中间件的使用,还可以了解 netcore 到底是如何授权的机制,但是我还是建议大家使用官方的认证方案,毕竟他们考虑的很全面的。
那么如果我们想要用官方的认证方案呢,要怎么写呢?请往下看:
二、JWT授权认证流程——官方认证
上边咱们说完了自定义中间件的形式,发现了也方便的地方,也有不方便之处,虽然灵活的使用了自定义身份验证,但是毕竟很受限,而且也无法对过期时间进行判断,以后的文章你会看到《36 ║解决JWT自定义中间件授权过期问题》,这里先不说,重点说说,如何通过官方认证来实现。
1:API接口授权策略
和上边自定义的过程一模一样,略。
2、官方默认认证配置
在刚刚上边,咱们说到了一个错误,不知道还有没有印象:
No authenticationScheme was specified, and there was no DefaultChallengeScheme found.
就是这个,自定义认证中间件呢,就是前者,那官方的,就是后者 DefaultChallengeScheme;
很简单,只需要在 configureService 中,添加【统一认证】即可:
#region Authorize 授权认证
...
// 2.1 认证
#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);
#endregion
services.AddAuthentication(x =>
{
//看这个单词熟悉么?没错,就是上边错误里的那个。
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(c => {
c.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = signingKey,
ValidateIssuer = audienceConfig["Issuer"].ObjToBool(),//发行人
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero,
RequireExpirationTime = true,
};
});
#endregion
具体的每个配置的含义呢,我的代码里都有,大家自己可以看看,都很简单。
划重点:我们就是用的这个官方默认的方案,来替换了我们自定义中间件的身份验证方案,从而达到目的,说白了,就是官方封装了一套方案,这样我们就不用写中间件了。
3、配置官方认证中间件
这个很简单,还是在 configure 中添加:// 如果你想使用官方认证,必须在上边ConfigureService 中,配置JWT的认证服务 (.AddAuthentication 和 .AddJwtBearer 二者缺一不可) app.UseAuthentication();
这样就完成了,结果也不用看了,大家自行测试即可,无论添加或者不添加 token ,都不会报错。
a.gif
4、补充:什么是 Claim
如果对 claim[] 定义不是很理解,可以看看dudu大神的解释《理解ASP.NET Core验证模型(Claim, ClaimsIdentity, ClaimsPrincipal)不得不读的英文博文》:
5、其他注意点
1、然后再Startup的Configure中,将TokenAuth注册中间件
注意1:HTTP管道是有先后顺序的,一定要写在 app.Mvc() 之前,否则不起作用。
image.png
注意2:
这里我们是自定义了认证中间件,来对JWT的字符串进行自定义授权认证,所以上边都很正常,甚至我们的Token可以不用带 Bearer 特定字符串,如果你以后遇到了使用官方认证中间件 UseAuthentication(),那么就必须在 configureService 中对认证进行配置(而且Token传递的时候,也必须带上"Bearer " 这样的特定字符串,这也就是解释了上文,为啥要带Bearer),这里先打个预防针,因为我的最新 Github 上已经使用了官方的认证中间件,所以除了上边配置的那些服务外,还需要配置 Service.AddAuthentication 和 Service.AddJwtBearer 两个服务。
// 如果你想使用官方认证,必须在上边ConfigureService 中,配置JWT的认证服务
// .AddAuthentication 和 .AddJwtBearer 二者缺一不可
app.UseAuthentication();
更多推荐
所有评论(0)