1、初始JWT
1.1、JWT原理
JWT(JSON Web Token)是目前最流行的跨域身份验证解决方案,他的优势就在于服务器不用存token便于分布式开发,给APP提供数据用于前后端分离的项目。登录产生的 token的项目完全可以独立与其他项目。当用户访问登录接口的时候会返回一个token,然后访问其他需要登录的接口都会带上这个token,后台进行验证如果token是有效的我们就认为用户是正常登录的,然后我们可以从token中取出来一些携带的信息进行操作。当然这些携带的信息都可以通过其他额外的字段进行传递,但是用token传递的话,不用其他额外加其他字段了。
JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
1.2、JWT结构
JWT是由三段信息构成的,将这三段信息文本用.
链接一起就构成了Jwt字符串。就像这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJsb2dpbklEIjoiYWRtaW4iLCJuYmYiOjE1ODc4OTE2OTMsImV4cCI6MTU4NzkyNzY5MywiaXNzIjoiV1lZIiwiYXVkIjoiRXZlcnlUZXN0T25lIn0.-snenNVHrrKq9obN8FzKe0t99ok6FUm5pHv-P_eYc30
第一部分我们称它为头部(header):声明类型,这里是jwt;声明加密的算法 通常直接使用 HMAC SHA256
{
‘typ‘: ‘JWT‘,
‘alg‘: ‘HS256‘
}
第二部分我们称其为载荷(payload, 类似于飞机上承载的物品):
iss:Token发布者
exp:过期时间 分钟
sub:主题
aud:Token接受者
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
除以上默认字段外,我们还可以自定义私有字段,如下例:
{ "sub": "1234567890", "name": "wyy", "admin": true }
第三部分是签证(signature):这个部分需要base64加密后的header和base64加密后的payload使用.
连接组成的字符串,然后通过header中声明的加密方式进行加盐secret
组合加密,然后就构成了jwt的第三部分。
2、生成Token
2.1、建立项目
在VS2019中新建一个Core Api程序 Core选3.1 然后在项目上添加一个Jwt文件夹帮助类,新建接口ITokenHelper,类:TokenHelper继承ITokenHelper,类JWTConfig,类TnToken
JWTConfig:用来保存读取jwt相关配置
/// <summary> /// 配置token生成信息 /// </summary> public class JWTConfig { /// <summary> /// Token发布者 /// </summary> public string Issuer { get; set; } /// <summary> /// oken接受者 /// </summary> public string Audience { get; set; } /// <summary> /// 秘钥 /// </summary> public string IssuerSigningKey { get; set; } /// <summary> /// 过期时间 /// </summary> public int AccessTokenExpiresMinutes { get; set; } }
TnToken:存放Token 跟过期时间的类
/// <summary> /// 存放Token 跟过期时间的类 /// </summary> public class TnToken { /// <summary> /// token /// </summary> public string TokenStr { get; set; } /// <summary> /// 过期时间 /// </summary> public DateTime Expires { get; set; } }
ITokenHelper接口:token工具类的接口,方便使用依赖注入,很简单提供两个常用的方法
/// <summary> /// token工具类的接口,方便使用依赖注入,很简单提供两个常用的方法 /// </summary> public interface ITokenHelper { /// <summary> /// 根据一个对象通过反射提供负载生成token /// </summary> /// <typeparam name="T"></typeparam> /// <param name="user"></param> /// <returns></returns> TnToken CreateToken<T>(T user) where T : class; /// <summary> /// 根据键值对提供负载生成token /// </summary> /// <param name="keyValuePairs"></param> /// <returns></returns> TnToken CreateToken(Dictionary<string, string> keyValuePairs); }
TokenHelper:实现类
/// <summary> /// Token生成类 /// </summary> public class TokenHelper : ITokenHelper { private readonly IOptions<JWTConfig> _options; public TokenHelper(IOptions<JWTConfig> options) { _options = options; } /// <summary> /// 根据一个对象通过反射提供负载生成token /// </summary> /// <typeparam name="T"></typeparam> /// <param name="user"></param> /// <returns></returns> public TnToken CreateToken<T>(T user) where T : class { //携带的负载部分,类似一个键值对 List<Claim> claims = new List<Claim>(); //这里我们用反射把model数据提供给它 foreach (var item in user.GetType().GetProperties()) { object obj = item.GetValue(user); string value = ""; if (obj != null) value = obj.ToString(); claims.Add(new Claim(item.Name, value)); } //创建token return CreateToken(claims); } /// <summary> /// 根据键值对提供负载生成token /// </summary> /// <param name="keyValuePairs"></param> /// <returns></returns> public TnToken CreateToken(Dictionary<string, string> keyValuePairs) { //携带的负载部分,类似一个键值对 List<Claim> claims = new List<Claim>(); //这里我们通过键值对把数据提供给它 foreach (var item in keyValuePairs) { claims.Add(new Claim(item.Key, item.Value)); } //创建token return CreateTokenString(claims); } /// <summary> /// 生成token /// </summary> /// <param name="claims">List的 Claim对象</param> /// <returns></returns> private TnToken CreateTokenString(List<Claim> claims) { var now = DateTime.Now; var expires = now.Add(TimeSpan.FromMinutes(_options.Value.AccessTokenExpiresMinutes)); var token = new JwtSecurityToken( issuer: _options.Value.Issuer,//Token发布者 audience: _options.Value.Audience,//Token接受者 claims: claims,//携带的负载 notBefore: now,//当前时间token生成时间 expires: expires,//过期时间 signingCredentials: new SigningCredentials(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Value.IssuerSigningKey)), SecurityAlgorithms.HmacSha256)); return new TnToken { TokenStr = new JwtSecurityTokenHandler().WriteToken(token), Expires = expires }; } }
2.2、在Startup中去配置jwt相关:
ConfigureServices中:
#region jwt配置 services.AddTransient<ITokenHelper, TokenHelper>(); //读取配置文件配置的jwt相关配置 services.Configure<JWTConfig>(Configuration.GetSection("JWTConfig")); //启用JWT services.AddAuthentication(Options => { Options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; Options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }). AddJwtBearer();#endregion
JwtBearerDefaults.AuthenticationScheme与AddJwtBearer();下载两个依赖即可。或者NuGet安装
appsettings中简单配置一下jwt相关的信息:
"JWTConfig": { "Issuer": "WYY", //Token发布者 "Audience": "EveryTestOne", //Token接受者 "IssuerSigningKey": "WYY&YL889455200Sily", //秘钥可以构建服务器认可的token;签名秘钥长度最少16 "AccessTokenExpiresMinutes": "600" //过期时间 分钟 },
Configure中去启用验证中间件:
//启用认证中间件 要写在授权UseAuthorization()的前面
app.UseAuthentication();
2.3、一个简单的登录获取token
在Controllers文件夹里面新建一个api 名字LoginTest
[EnableCors("AllowCors")] [Route("api/[controller]/[action]")] [ApiController] public class LoginTestController : ControllerBase { private readonly ITokenHelper tokenHelper = null; /// <summary> /// 构造函数 /// </summary> /// <param name="_tokenHelper"></param> public LoginTestController(ITokenHelper _tokenHelper) { tokenHelper = _tokenHelper; } /// <summary> /// 登录测试 /// </summary> /// <param name="user"></param> /// <returns></returns> [HttpPost] public ReturnModel Login([FromBody]UserDto user) { var ret = new ReturnModel(); try { if (string.IsNullOrWhiteSpace(user.LoginID) || string.IsNullOrWhiteSpace(user.Password)) { ret.Code = 201; ret.Msg = "用户名密码不能为空"; return ret; } //登录操作 我就没写了 || 假设登录成功 if (1 == 1) { Dictionary<string, string> keyValuePairs = new Dictionary<string, string> { { "loginID", user.LoginID } }; ret.Code = 200; ret.Msg = "登录成功"; ret.TnToken= tokenHelper.CreateToken(keyValuePairs); } } catch(Exception ex) { ret.Code = 500; ret.Msg = "登录失败:"+ex.Message; } return ret; } }
UserDto接收类
/// <summary> /// 登录类Dto /// </summary> public class UserDto { /// <summary> /// 用户名 /// </summary> public string LoginID { get; set; } /// <summary> /// 密码 /// </summary> public string Password { get; set; } }
ReturnModel 只是我自己封装的一个统一的接口返回格式标准
/// <summary> /// 返回类 /// </summary> public class ReturnModel { /// <summary> /// 返回码 /// </summary> public int Code { get; set; } /// <summary> /// 消息 /// </summary> public string Msg { get; set; } /// <summary> /// 数据 /// </summary> public object Data { get; set; } /// <summary> /// Token信息 /// </summary> public TnToken TnToken { get; set; } }
跨域上篇文章说了这里就不提了
2.4、前端获取token
我是用传统的MVC的一个启动页面
<input type="hidden" id="tokenValue" name="tokenValue" value="" /> <br /><br /><br /> <span>Token:</span><div id="txtval"></div><br /> <span>有效期:</span><div id="txtvalTime"></div><br /> <div> <input type="button" value="获取Token" onclick="getToken()" /><br /><br /><br /> </div> <script src="~/Scripts/jquery-3.3.1.js"></script> <script type="text/javascript"> //获取token function getToken() { var data = JSON.stringify({ LoginID: "admin", Password: "admin888" }); $.ajax({ type: "post", url: "https://localhost:44331/api/LoginTest/Login", dataType: "json", async: true, data: data, contentType: ‘application/json‘, success: function (data) { console.log(data); $("#txtval").html(data.tnToken.tokenStr); $("#txtvalTime").html(new Date(data.tnToken.expires).Format("yyyy-MM-dd hh:mm")); $("#tokenValue").val(data.tnToken.tokenStr); }, error: function (data) { console.log("错误" + data); } }); } Date.prototype.Format = function (fmt) { //author: zhengsh 2016-9-5 var o = { "M+": this.getMonth() + 1, //月份 "d+": this.getDate(), //日 "h+": this.getHours(), //小时 "m+": this.getMinutes(), //分 "s+": this.getSeconds(), //秒 "q+": Math.floor((this.getMonth() + 3) / 3), //季度 "S": this.getMilliseconds() //毫秒 }; if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length)); for (var k in o) if (new RegExp("(" + k + ")").test(fmt)) fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length))); return fmt; } </script>
把Api启动起来 MVC也启动起来试试看
在JWT管网解码
3、验证前端传递的token
现在说说怎么来验证前台传递的jwt,其实很简单,最主要的就是验证token的有效性和是否过期。在接口ITokenHelper中添加验证的两个方法 。TokenHelper中实现
ITokenHelper中添加
/// <summary> /// Token验证 /// </summary> /// <param name="encodeJwt">token</param> /// <param name="validatePayLoad">自定义各类验证; 是否包含那种申明,或者申明的值</param> /// <returns></returns> bool ValiToken(string encodeJwt, Func<Dictionary<string, string>, bool> validatePayLoad = null); /// <summary> /// 带返回状态的Token验证 /// </summary> /// <param name="encodeJwt">token</param> /// <param name="validatePayLoad">自定义各类验证; 是否包含那种申明,或者申明的值</param> /// <param name="action"></param> /// <returns></returns> TokenType ValiTokenState(string encodeJwt, Func<Dictionary<string, string>, bool> validatePayLoad, Action<Dictionary<string, string>> action);
TokenHelper中添加
/// <summary> /// 验证身份 验证签名的有效性 /// </summary> /// <param name="encodeJwt"></param> /// <param name="validatePayLoad">自定义各类验证; 是否包含那种申明,或者申明的值, </param> public bool ValiToken(string encodeJwt, Func<Dictionary<string, string>, bool> validatePayLoad = null) { var success = true; var jwtArr = encodeJwt.Split(‘.‘); if (jwtArr.Length < 3)//数据格式都不对直接pass { return false; } var header = JsonConvert.DeserializeObject<Dictionary<string, string>>(Base64UrlEncoder.Decode(jwtArr[0])); var payLoad = JsonConvert.DeserializeObject<Dictionary<string, string>>(Base64UrlEncoder.Decode(jwtArr[1])); //配置文件中取出来的签名秘钥 var hs256 = new HMACSHA256(Encoding.ASCII.GetBytes(_options.Value.IssuerSigningKey)); //验证签名是否正确(把用户传递的签名部分取出来和服务器生成的签名匹配即可) success = success && string.Equals(jwtArr[2], Base64UrlEncoder.Encode(hs256.ComputeHash(Encoding.UTF8.GetBytes(string.Concat(jwtArr[0], ".", jwtArr[1]))))); if (!success) { return success;//签名不正确直接返回 } //其次验证是否在有效期内(也应该必须) var now = ToUnixEpochDate(DateTime.UtcNow); success = success && (now >= long.Parse(payLoad["nbf"].ToString()) && now < long.Parse(payLoad["exp"].ToString())); //不需要自定义验证不传或者传递null即可 if (validatePayLoad == null) return true; //再其次 进行自定义的验证 success = success && validatePayLoad(payLoad); return success; } /// <summary> /// 时间转换 /// </summary> /// <param name="date"></param> /// <returns></returns> private long ToUnixEpochDate(DateTime date) { return (long)Math.Round((date.ToUniversalTime() - new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero)).TotalSeconds); } /// <summary> /// /// </summary> /// <param name="encodeJwt"></param> /// <param name="validatePayLoad"></param> /// <param name="action"></param> /// <returns></returns> public TokenType ValiTokenState(string encodeJwt, Func<Dictionary<string, string>, bool> validatePayLoad, Action<Dictionary<string, string>> action) { var jwtArr = encodeJwt.Split(‘.‘); if (jwtArr.Length < 3)//数据格式都不对直接pass { return TokenType.Fail; } var header = JsonConvert.DeserializeObject<Dictionary<string, string>>(Base64UrlEncoder.Decode(jwtArr[0])); var payLoad = JsonConvert.DeserializeObject<Dictionary<string, string>>(Base64UrlEncoder.Decode(jwtArr[1])); var hs256 = new HMACSHA256(Encoding.ASCII.GetBytes(_options.Value.IssuerSigningKey)); //验证签名是否正确(把用户传递的签名部分取出来和服务器生成的签名匹配即可) if (!string.Equals(jwtArr[2], Base64UrlEncoder.Encode(hs256.ComputeHash(Encoding.UTF8.GetBytes(string.Concat(jwtArr[0], ".", jwtArr[1])))))) { return TokenType.Fail; } //其次验证是否在有效期内(必须验证) var now = ToUnixEpochDate(DateTime.UtcNow); if (!(now >= long.Parse(payLoad["nbf"].ToString()) && now < long.Parse(payLoad["exp"].ToString()))) { return TokenType.Expired; } //不需要自定义验证不传或者传递null即可 if (validatePayLoad == null) { action(payLoad); return TokenType.Ok; } //再其次 进行自定义的验证 if (!validatePayLoad(payLoad)) { return TokenType.Fail; } //可能需要获取jwt摘要里边的数据,封装一下方便使用 action(payLoad); return TokenType.Ok; }
其中TokenType是返回类型成功失败
public enum TokenType { Ok, Fail, Expired }
在api LoginTest中新增两个验证的方法
/// <summary> /// 验证Token /// </summary> /// <param name="tokenStr">token</param> /// <returns></returns> [HttpGet] public ReturnModel ValiToken(string tokenStr) { var ret = new ReturnModel { TnToken = new TnToken() }; bool isvilidate = tokenHelper.ValiToken(tokenStr); if(isvilidate) { ret.Code = 200; ret.Msg = "Token验证成功"; ret.TnToken.TokenStr = tokenStr; } else { ret.Code = 500; ret.Msg = "Token验证失败"; ret.TnToken.TokenStr = tokenStr; } return ret; } /// <summary> /// 验证Token 带返回状态 /// </summary> /// <param name="tokenStr"></param> /// <returns></returns> [HttpGet] public ReturnModel ValiTokenState(string tokenStr) { var ret = new ReturnModel { TnToken = new TnToken() }; string loginID = ""; TokenType tokenType = tokenHelper.ValiTokenState(tokenStr, a => a["iss"] == "WYY" && a["aud"] == "EveryTestOne", action => { loginID = action["loginID"]; }); if (tokenType == TokenType.Fail) { ret.Code = 202; ret.Msg = "token验证失败"; return ret; } if (tokenType == TokenType.Expired) { ret.Code = 205; ret.Msg = "token已经过期"; return ret; } //..............其他逻辑 var data = new List<Dictionary<string, string>>(); var bb = new Dictionary<string, string> { { "Wyy", "123456" } }; data.Add(bb); ret.Code = 200; ret.Msg = "访问成功!"; ret.Data =data ; return ret; }
上面一个简单的验证和支持自定义验证的就写好了。下面带有状态的是让我们清楚的知道是什么状态请求登录的时候 或者请求数据的时候,是token过期还是说token没有获取到等等。
ValiTokenState第三个参数我还更了一个系统委托,是这样想的,处理可以验证token,还可以顺便取一个想要的数据,当然其实这样把相关逻辑混到一起也增加代码的耦合性,当时可以提高一点效率不用在重新解析一次数据,当然这个数据也可以通前台传递过来,所以怎么用还是看实际情况,这里只是封装一下提供这样一个方法,用的时候也可以用。
其前端请求代码
$.ajax({
type: "post",
url: "https://localhost:44331/api/LoginTest/ValiToken?tokenStr="+ $("#tokenValue").val(),
dataType: "json",
async: true,
data: { token: $("#tokenValue").val() },
contentType: ‘application/json‘,
success: function (data) {
console.log(data);
},
error: function (data) {
console.log("错误" + data);
}
});
4、Api中过滤器实现通用token验证
项目上新建一个文件夹Filter,在文件夹Filter里新建一个过滤器TokenFilter
namespace JWTToken.Filter { public class TokenFilter : Attribute, IActionFilter { private ITokenHelper tokenHelper; public TokenFilter(ITokenHelper _tokenHelper) //通过依赖注入得到数据访问层实例 { tokenHelper = _tokenHelper; } public void OnActionExecuted(ActionExecutedContext context) { } public void OnActionExecuting(ActionExecutingContext context) { ReturnModel ret = new ReturnModel(); //获取token object tokenobj = context.ActionArguments["token"]; if (tokenobj == null) { ret.Code = 201; ret.Msg = "token不能为空"; context.Result = new JsonResult(ret); return; } string token = tokenobj.ToString(); string userId = ""; //验证jwt,同时取出来jwt里边的用户ID TokenType tokenType = tokenHelper.ValiTokenState(token, a => a["iss"] == "WYY" && a["aud"] == "EveryTestOne", action => { userId = action["userId"]; }); if (tokenType == TokenType.Fail) { ret.Code = 202; ret.Msg = "token验证失败"; context.Result = new JsonResult(ret); return; } if (tokenType == TokenType.Expired) { ret.Code = 205; ret.Msg = "token已经过期"; context.Result = new JsonResult(ret); } if (!string.IsNullOrEmpty(userId)) { //给控制器传递参数(需要什么参数其实可以做成可以配置的,在过滤器里边加字段即可) //context.ActionArguments.Add("userId", Convert.ToInt32(userId)); } } } }
context.ActionArguments。这是前段请求的时候地址栏带上的参数 token=xxx;这种类型的,不是请求的参数 不然会报错;
把过滤器在startup中注入一下:
services.AddScoped<TokenFilter>();
需要验证token的地方,直接加上这个过滤器即可
前台试试 请求上图的GetList
<input type="hidden" id="tokenValue" name="tokenValue" value="" /> <br /><br /><br /> <span>Token:</span><div id="txtval"></div><br /> <span>有效期:</span><div id="txtvalTime"></div><br /> <div> <input type="button" value="获取Token" onclick="getToken()" /><br /><br /><br /> </div> <input type="button" value="获取List" onclick="getList()" /><br /> <script src="~/Scripts/jquery-3.3.1.js"></script> <script type="text/javascript"> //获取token function getToken() { var data = JSON.stringify({ LoginID: "admin", Password: "admin888" }); $.ajax({ type: "post", url: "https://localhost:44331/api/LoginTest/Login", dataType: "json", async: true, data: data, contentType: ‘application/json‘, success: function (data) { console.log(data); $("#txtval").html(data.tnToken.tokenStr); $("#txtvalTime").html(new Date(data.tnToken.expires).Format("yyyy-MM-dd hh:mm")); $("#tokenValue").val(data.tnToken.tokenStr); }, error: function (data) { console.log("错误" + data); } }); } //获取list function getList() { var data = JSON.stringify(); $.ajax({ type: "post", url: "https://localhost:44331/api/Home/GetList?token="+ $("#tokenValue").val(), dataType: "json", async: true, data: { token: $("#tokenValue").val() }, contentType: ‘application/json‘, success: function (data) { console.log(data); $("#txtval").html(JSON.stringify(data)); }, error: function (data) { console.log("错误" + data); } }); } Date.prototype.Format = function (fmt) { //author: zhengsh 2016-9-5 var o = { "M+": this.getMonth() + 1, //月份 "d+": this.getDate(), //日 "h+": this.getHours(), //小时 "m+": this.getMinutes(), //分 "s+": this.getSeconds(), //秒 "q+": Math.floor((this.getMonth() + 3) / 3), //季度 "S": this.getMilliseconds() //毫秒 }; if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length)); for (var k in o) if (new RegExp("(" + k + ")").test(fmt)) fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k]).length))); return fmt; } </script>
现获取token 赋值在隐藏框里在请求
5、在Api中使用Swagger
5.1项目中添加Swagger的相关包
5.2ConfigureServices、Configure 中添加
#region Swagger services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Version = "v1", Title = "测试接口文档", Description = "测试接口" }); // 为 Swagger 设置xml文档注释路径 var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); c.IncludeXmlComments(xmlPath); c.DocInclusionPredicate((docName, description) => true); //添加对控制器的标签(描述) c.DocumentFilter<ApplyTagDescriptions>();//显示类名 c.CustomSchemaIds(type => type.FullName);// 可以解决相同类名会报错的问题 //c.OperationFilter<AuthTokenHeaderParameter>(); }); #endregion
app.UseSwagger(c => { c.RouteTemplate = "swagger/{documentName}/swagger.json"; }); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "Web App v1"); c.RoutePrefix = "doc";//设置根节点访问 //c.DocExpansion(DocExpansion.None);//折叠 c.DefaultModelsExpandDepth(-1);//不显示Schemas });
5.3、项目属性修改
5.4、添加接口类的注释
看效果
6、总结
JWT个人的理解就是api配置文件的IssuerSigningKey作为秘钥来加密的,客户端登录后获取到token 地址栏请求传到后端 后端通过解码获取到IssuerSigningKey是否跟后台解析出来的一直来匹配。后端可以卸载锅炉器里面来接收这个token来验证从而限制能不能访问Api。前端可以自己封装一个请求把token穿进去的参数就可以避免每次输入Token,前端可以Session?
下了班写的仓促了 哈哈。欢迎补充。