Jwt隐藏大坑,通过源码帮你揭秘
前言
JWT是目前最为流行的接口认证方案之一,有关JWT协议的详细内容,请参考:https://jwt.io/introduction
今天分享一下在使用JWT
在项目中遇到的一个问题,主要是一个协议的细节,非常容易被忽略,如果不是自己遇到,或者去看源码的实现,我估计至少80%的人都会栽在这里,下面来还原一下这个问题的过程,由于这个问题出现有一定的概率,不是每次都会出现,所以才容易掉坑里。
集成JWT
在Asp.Net Core中集成JWT
认证的方式在网络上随便一搜就能找到一堆,主要有两个步骤:
- 在IOC容器中注入依赖
public void ConfigureServices(IServiceCollection services)
{
// 添加这一行添加jwt验证:
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,//是否验证Issuer
ValidateAudience = true,//是否验证Audience
ValidateLifetime = true,//是否验证失效时间
ClockSkew = TimeSpan.FromSeconds(30),
ValidateIssuerSigningKey = true,//是否验证SecurityKey
ValidAudience = Const.Domain,//Audience
ValidIssuer = Const.Domain,//Issuer,这两项和前面签发jwt的设置一致
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey))//拿到SecurityKey
};
});
}
- 应用认证中间件
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
// 添加这一行 使用认证中间件
app.UseAuthentication();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
- 在Controller
[Route("api/[controller]")]
[ApiController] // 添加这一行
public class MyBaseController : ControllerBase
{
}
- 提供一个认证的接口,用于前端获取token
[AllowAnonymous]
[HttpGet]
public IActionResult Get(string userName, string pwd)
{
if (!string.IsNullOrEmpty(userName) && !string.IsNullOrEmpty(pwd))
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.AddMinutes(30)).ToUnixTimeSeconds()}"),
new Claim(ClaimTypes.Name, userName)
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Const.SecurityKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Const.Domain,
audience: Const.Domain,
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds);
return Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(token)
});
}
else
{
return BadRequest(new { message = "username or password is incorrect." });
}
}
至此,你的应用已经完成了集成JWT
认证。
本文为
Gui.H
原创文章,更过高质量博文,欢迎关注公众号dotnet之美
。
坑在哪里
直接上代码,下面这段代码是我用来能复现该大坑的示例,有空的可以按照该代码重现下面的问题。
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
var SecurityKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDI2a2EJ7m872v0afyoSDJT2o1+SitIeJSWtLJU8/Wz2m7gStexajkeD+Lka6DSTy8gt9UwfgVQo6uKjVLG5Ex7PiGOODVqAEghBuS7JzIYU5RvI543nNDAPfnJsas96mSA7L/mD7RTE2drj6hf3oZjJpMPZUQI/B1Qjb5H3K3PNwIDAQAB";
var Domain = "http://localhost:5000";
var email = "username@qq.com";
var userName = "阿哈";
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Nbf,$"{new DateTimeOffset(DateTime.Now).ToUnixTimeSeconds()}") ,
new Claim (JwtRegisteredClaimNames.Exp,$"{new DateTimeOffset(DateTime.Now.AddMinutes(30)).ToUnixTimeSeconds()}"),
new Claim("Name", userName),
new Claim("Email", email),
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SecurityKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: Domain,
audience: Domain,
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: creds);
var JWTToken = new JwtSecurityTokenHandler().WriteToken(token);
Console.WriteLine(JWTToken);
Console.ReadLine();
上面代码运行的结果是:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9.eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi_5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ.RBtP7zroK7YueGlDdZNHGy3tT8-xcGkf8ZyiTL81w2I
我们知道Token由三部分组成,使用.
分割,如果是标准的Jwt协议加密的,那这三部分均为Base64加密(此处不准确,下文解释为什么),也可以说就是明文,我们将三部分内容进行Base64解密看看。
我们在线验证一下我们的Jwt是否符合标准:
打开网站:https://jwt.io/
,选择顶部菜单的Debugger
,将我们的token填进去:
然后将代码中用的SecurityKey
填到图中标记的位置
显示签名认证通过。
头
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6IkpXVCJ9
{ "alg": "HS256", "typ": "JWT", "cty": "JWT" }
载荷
eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi_5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ
{
"nbf": "1653400694",
"exp": 1653402494,
"Name": "阿哈",
"Email": "username@qq.com",
"iss": "http://localhost:5000",
"aud": "http://localhost:5000"
}
签名
RBtP7zroK7YueGlDdZNHGy3tT8-xcGkf8ZyiTL81w2I
到目前未知一切都十分顺利。
既然Token的内容前端直接可以通过base64解密出来,那在需要展示用户名的地方,我们就可以直接解析token的载荷,然后获得Name
,下面是使用在线base64工具解密上面的token载荷内容,可以看到用户名为啊哈
。
逻辑没有任何问题,那就开始前端进行解析token中的用户名用于展示在个人中心吧。
下面是在Vue3
框架和Piana
中的演示,window.atob
是浏览器自带base64decode的方法
export const useUserStore = defineStore({
id: 'user',
state: () => {
return {
token: '',
}
},
getters: {
accessToken: (state) => {
return state.accesstoken || localStorage.getItem("accesstoken");
},
/**
* 获取token中解密后的用户信息
*/
userInfo(state) {
var token = state.token || localStorage.getItem("accesstoken");
if (!token || token == '') {
return null;
}
var json = window.atob(token.split(".")[1]);
return JSON.parse(json);
}
}
})
在需要获取用户名的地方使用
computed:{
...mapState(useUserStore, ["userInfo"]),
}
感觉一切都很优雅的写完了代码,但是实际运行会报错:
这里为了方便是直接在浏览器的调式器中执行的
报错的意思来看是说我们的字符串没用正确的加密(就是它说咱这个字符串不是合法的base64加密)。
可是我们通过一些在线base64解密工具,还有Jwt的debugger工具都能解密出来明文。而且这不是我第一次将token拿出来进行解密了,之前也都没问题。
- 是不是token有问题?
经过测试,调用接口完全不会有问题,只是前端解密时报错,排除token不合法。 - 前端的atob函数存在bug?
那我们在后端用c#的base64解密一下看看:
居然后端解密也报错了,头部解密成功,载荷部分解密异常,和前端报错一样都是说字符串不是合法的base64内容,不知道你是不是偶尔遇到过这个问题,如果没有,那你更要往下看了,不然以后遇到了,要耽误不少时间去排查了。
查看源码探索问题原因
上面遇到的问题曾经花了我不少时间去排查,关键是有工具能解密的还有工具不能解密,一时不知道到底是谁的问题了,抱着试试看的态度,看看源码生成token三部分的字符串过程。
既然token是这个函数生成的,那就直接看它的实现,直接F12即可,这个包是不是框架自带的,所以能直接通过vs看源码,比较方便的。
源码如下,
encodedPayload
根据它的命名不难看出是机密后的载荷,我们需要看的是它如何加密的
查看
jwtToken.EncodedPayload
这个属性怎么来的(F12)
图中标记了三个数字:
- 上一步我们逆向找到加密后的属性
EncodedPayload
- 上一步我们逆向找到加密后的属性
EncodedPayload
属性里面用到了另一个属性Payload
,我们需要找Payload
哪里赋值的
Payload
是在构造函数中根据传参内容进行初始化的。
- 上一步我们已经锁定进加密的逻辑在
Payload.Base64UrlEncode()
中,看JwtPayload
的类定义
可以看出,载荷的加密和我们想象的一样简单,把JwtPayload
对象转成Json
,然后进行Base64Url
加密
5. 现在只剩Base64UrlEncoder.Encode
的实现能为我们揭秘了
整体看下类定义,我们调用的Encode
按标记顺序,依次调用了三个重载方法,最终实现都标记为3的那个方法。
6. 不知道你有没有注意到这些内容
看到这里我恍然大悟了一点,再看看他这里面的decode方法
看见了吧,我们因为是单纯的Base64加解密,其实不然,在进行Convert.FromBase64String(decodedString)
解密前还需要进行一些字符串的替换,我赶紧看下上面出问题的载荷内容,发现其中有_
这个字符,我赶紧将其进行替换成+
,在次在尝试:
eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi_5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ
// 替换后
eyJuYmYiOiIxNjUzNDAwNjk0IiwiZXhwIjoxNjUzNDAyNDk0LCJOYW1lIjoi6Zi+5ZOIIiwiRW1haWwiOiJ1c2VybmFtZUBxcS5jb20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0OjUwMDAifQ
果然如此,替换后解密成功了,只有一个汉字的编码问题。
这下找到问题了,优化下前端的解密代码
userInfo(state) {
var token = state.token || localStorage.getItem("accesstoken");
if (!token || token == '') {
return null;
}
token = token.replace("_", "/").replace("-", "+") // 添加这一行
var json = window.atob(token.split(".")[1]);
return JSON.parse(json);
}
问题解决了。
注意官方对加密过程的描述
哈哈,是不是草率了,并不是Base64
加密~~
总结
我们都以为Jwt三部分是用Base64
加密,其实不完全对,因为他确切的加密方式是Base64Url
加密,没有深入理解的我们只以为就是纯粹的base64,而且在大部分情况下确实是这样,更加坚定了我们这种错误认知。而只有当Base64加密后出现字符+
或/
时,才会有所不同,希望对大家有帮助。
Jwt隐藏大坑,通过源码帮你揭秘的更多相关文章
- 通过源码了解ASP.NET MVC 几种Filter的执行过程
一.前言 之前也阅读过MVC的源码,并了解过各个模块的运行原理和执行过程,但都没有形成文章(所以也忘得特别快),总感觉分析源码是大神的工作,而且很多人觉得平时根本不需要知道这些,会用就行了.其实阅读源 ...
- Linux下通过源码编译安装程序
本文简单的记录了下,在linux下如何通过源码安装程序,以及相关的知识.(大神勿喷^_^) 一.程序的组成部分 Linux下程序大都是由以下几部分组成: 二进制文件:也就是可以运行的程序文件 库文件: ...
- 通过源码了解ASP.NET MVC 几种Filter的执行过程 在Winform中菜单动态添加“最近使用文件”
通过源码了解ASP.NET MVC 几种Filter的执行过程 一.前言 之前也阅读过MVC的源码,并了解过各个模块的运行原理和执行过程,但都没有形成文章(所以也忘得特别快),总感觉分析源码是大神 ...
- 在centos6.7通过源码安装python3.6.7报错“zipimport.ZipImportError: can't decompress data; zlib not available”
在centos6.7通过源码安装python3.6.7报错: zipimport.ZipImportError: can't decompress data; zlib not available 从 ...
- Kafka详解六:Kafka如何通过源码实现监控
问题导读: 1.kafka的消费者组的消费偏移存储,kafka支持两个版本? 2.ConsumerOffsetChecker类的作用是什么? 3.Kafka如何通过源码实现 ...
- 通过源码编译安装VIM
开发中使用的是Ubuntu 12.04 LTS,通过sudo apt-get install vim安装的版本较低,不支持YCM,所以,用源码编译并安装最新的Vim. 卸载旧版本的Vim: sudo ...
- echarts 通过源码方法 传入对应data数据获取分割步长值
通过源码方法获取这里的分割数字长度 /** * Quantity of a number. e.g. 0.1, 1, 10, 100 * * @param {number} val * @return ...
- 通过源码安装PostgresSQL
通过源码安装PostgresSQL 1.1 下载源码包环境: Centos6.8 64位 yum -y install bison flex readline-devel zlib-devel yum ...
- 如何通过源码包的方式在linux安装python36
背景: python34的安装非常简单,直接用yum就可以安装,但是安装最新版的python36通过yum方式是不行的,需要通过源码包进行安装 具体步骤如下: 1.安装openssl静态库[pip3安 ...
随机推荐
- 前端面试题整理——手写简易jquery
class jQuery { constructor(selector) { const result = document.querySelectorAll(selector) console.lo ...
- php弹窗后跳入另一个页面
之前写项目时,在跳转页面前加入一个弹窗,发现弹窗没有弹出来就直接跳转了,之前使用的header跳转发现不行,换成location.href也不行,后来再location.href前加入一个parent ...
- CCF201709-2公共钥匙盒改进版
问题描述 有一个学校的老师共用N个教室,按照规定,所有的钥匙都必须放在公共钥匙盒里,老师不能带钥匙回家.每次老师上课前,都从公共钥匙盒里找到自己上课的教室的钥匙去开门,上完课后,再将钥匙放回到钥匙盒中 ...
- 小程序中引入iconfont
注释:本人喜欢 font class, 你们可以依葫芦画瓢unicode和 symbol,,下面是 font class 演示: 1.选择好图标,然后: font class 2.步骤二: 复制 ...
- 圆桌killer
[问题描述] 圆桌上围坐着2n个人.其中n个人是好人,另外n个人是坏人.如果从第m(m<=2n)个人开始数数,数到第k个人,则立即处死该人:然后从被处死的人之后重新开始数数,再将数到的第k个人处 ...
- [ Terminal ] 在 Windows Terminal 中使用 Git Bash
https://www.cnblogs.com/yeungchie/ Git 自带的 git-bash 太简陋了,ConEmu 又太卡了,还是这个 Windows Terminal 最好用. 安装 W ...
- 美团动态线程池实践思路开源项目(DynamicTp),线程池源码解析及通知告警篇
大家好,这篇文章我们来聊下动态线程池开源项目(DynamicTp)的通知告警模块.目前项目提供以下通知告警功能,每一个通知项都可以独立配置是否开启.告警阈值.告警间隔时间.平台等,具体代码请看core ...
- Java实现单链表的反转
思路1:初始化一个新的头节点reverseHead,然后遍历旧链表,利用头插法向reverseHead进行插入 思路2: 1.反转相当于数据的更换(1和n,2和n-1,3和n-2)n为链表的长度 2. ...
- Python学习笔记.md
Python学习笔记 1.变量类型 x=5 int x="ss" string x='a' string x=True bool #查看变量类型 type(x) 2.字符串常用操作 ...
- thinkphp6事件监听event-listene
事件系统可以看成是行为系统的升级版,相比行为系统强大的地方在于事件本身可以是一个类,并且可以更好的支持事件订阅者. 事件相比较中间件的优势是事件比中间件更加精准定位(或者说粒度更细),并且更适合一些业 ...