JWT
1.是什么
JSON Web Token(JWT)是一种可以在多方之间安全共享数据的开放标准,JWT数据经过编码和数字签名生成,可以确保其真实性,也因此JWT通常用于身份认证
2.结构
组成
JWT其实就是一个很长的字符串,字符之间通过"."分隔符分为三个子串,各字串之间没有换行符。每一个子串表示了一个功能块,总共有三个部分:JWT头(header)、有效载荷(payload)、签名(signature),如下图所示:
示例
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJhZG1pbiI6dHJ1ZX0.OLvs36KmqB9cmsUrMpUutfhV52\_iSz4bQMYJjkI\_TLQ
Header
Header部分主要存储关于签名算法的信息,通常包含两个部分:token类型和采用的加密算法,使用Base64Url编码
{"alg":"HS256","type":"JWT"}
Payload
存储业务数据的部分(也是一个JSON对象),一般在不特殊修改的情况下(可自定义字段),最后也是使用Base64 URL算法转换为String,主要包含几个部分:
- iss:发行人
- exp:到期时间
- sub:主题
- aud:用户
- iat:发布时间
- jti:JWT ID用于标识该JWT
- nbf:在此之前不可用
Signature
签名实际上是一个加密的过程,是对上面两部分数据通过指定的算法生成哈希,以确保数据不会被篡改
首先需要指定一个密码(secret),该密码仅仅保存在服务器中,并且不能向用户公开。然后使用WT头中指定的签名算法(默认情况下为HMAC SHA256),根据以下公式生成签名哈希:
HMACSHA256(base64UrlEncode(header) + “.” + base64UrlEncode(payload), secret)
在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用"."分隔,就构成整个JWT对象
3.签名算法
- JWT签名算法,一般有两种选择:HS256和RS256
- HS256(带有SHA-256的HMAC)是一种对称加密算法,双方之间仅共享一个密钥。由于使用相同的密钥生成签名和验证签名,因此必须注意确保密钥不被泄露
- RS256(采用SHA-256的RSA签名)是一种非对称加密算法,它使用公共/私钥对;JWT的提供方采用私钥生成签名,JWT的使用方获取公钥以验证签名
4.工作流程
5.注意
JWT不是用来做数据加密的,JWT不保证数据不泄露所以敏感的数据不要向JWT中存储,也可以理解为JWT的内容可以被解析但是不可以被篡改
6.特点
- 无状态:JWT包含认证信息,token只需要保存在客户端,服务端不需要保存会话信息,所以JWT是无状态的。这一点使得服务器压力大大降低,增加了系统的可用性和可扩展性。但是无状态同时也是JWT最大的缺点,服务器无法主动让token失效
- 安全性:客户端每次请求都会携带token,可以有效避免CSRF攻击,同时token会自动过期,可以减少token被盗用的情况,服务器会通过jwt签名验证token,可以避免token被篡改
- 可见性:JWT的payload部分可以直接通过Base64解码,所以可存储一些其他业务逻辑所必要的非敏感信息
7.问题
续期
传统的会话续期
传统httpsession和spring-session都是采用httpsession默认机制。内部会有线程不停的轮询会话列表。把那些内存中的会话列表中的过期时间和当前时间进行比较,如果超过> 30分钟 自动把session删除。如果在30以内的请求,会自动续期(时间会从0开始计数)
为什么要这样做?
你思考。如果没有续期,会怎么样?就好比你登录腾讯游戏,你登录有效时间是30分钟。那么也就意味着你每隔30分钟要退出重新登录一次。所以我们应该是在你登录以后,未来的每一次请求中,只要用户一直在发起请求,就把时间永远覆盖。这样就永远保持热活。那么不用在登录。除非你静默的实际超过了30分钟,那确实需要重新登录
注意:续期必须要支持注销JWT,否则无限续期跟永久JWT没区别
必须续期吗
不一定要必须续期,例如阿里云就没有做续期,虽然会存在会话突然失效的情况,但是会保存当前表单内容,减少对用户的影响,没有了续期就符合了JWT的思想:去中心化,无状态化
续期方案总览
- 双令牌机制
- Redis令牌不变变续期
双令牌机制第一种
- 登陆成功后,生成两个Token,accessToken(5天)与refreshToken(10天),并且把refreshToken存储到Redis中,设置过期时间
- 接口返回3个字段分别是:accessToken,refreshToken,accessToken过期时间戳
- 前端向后端请求的时候,判断accessToken是否快过期了,如果快过期了,则客户端使用refreshToken请求刷新令牌接口获取新的accessToken,并更新前端本地的accessToken,后端主要是生成新的双令牌并且返回。
- 如果存储在Redis中的refreshToken过期,则提示重新登陆
- refreshToken可以是随机字符串,不用也是JWT字符串
- 缺点:前端每次请求需要判断accessToken距离过期时间
- 优点:后端压力小,代码逻辑改动不大
双令牌机制第二种
- 登陆成功后,生成两个Token,accessToken(30分钟)与refreshToken(1小时)
- 前端请求携带两个Token
- 服务端行为如下:
- 如果accessToken在30分钟内,那么处理请求并响应
- 如果accessToken过期并且refreshToken未过期,那么调用刷新方法重新生成两个Token然后处理请求并返回(两个Token附加在响应头返回),前端替换原有的两个令牌
- 如果两个Token都过期,拒绝请求并返回,前端清除两个令牌
- 客户端同一个用户并发请求造成的令牌交叉覆盖问题可以忽略一般
Redis令牌不变变续期
- 登录成功后,生成accessToken,并把accessToken写入Redis(key是随机数,value是accessToken),过期时间比如是2小时
- 客户端发起请求,服务端做以下行为:
- accessToken有效性校验
- 获得Redis的key,从Redis中获取accessToken
- 如果key不存在,则拒绝请求
- 距离结束时间超过1小时的,正常处理请求并返回
- 距离结束时间不足1消失的,正常处理返回响应,利用expire命令增加一小时
- 2小时内没有收到任何请求,key已被自动删除,拒绝请求
- 要注销JWT,直接删除掉key即可
- 缺点:引入了Redis,破坏了JWT的无状态
- 优点:前端无感知,key生成规则复杂能尽量避免被盗用
盗用
解决案例:在使用阿里云或淘宝的时候,登录了阿里云换了个地域或网络就需要重新登录。这是因为对应的token,不只是简单的保存了用户个人信息还保存了地理网络位置信息等,一起组成token,一旦有变化就会要求重新登录或短信验证等方式
解决方案:生成令牌的时候,加密的payload加入当前用户的ip,拦截器解密后获取payload的ip和当前访问ip是否是同一个,如果不是则提示重新登陆
优点:服务端无需存储相关内容,性能高,假如用户广州登录,泄露了token给杭州的黑客,依旧用不了
缺点:如果用户使用过程中ip频繁变动,则操作会经常提示重新登录,体验不友好
8.Java整合
介绍
jjiwt是一个提供JWT创建和验证的Java库。永远免费和开源(Apache License,版本2.0),JJWT很容易使用和理解
依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
整合
// 通过jjwt生成和解析令牌,不使用签名算法
@Test
public void test1(){
// Header部分
Map header = new HashMap();
header.put("alg","none");
header.put("type","JWT");
// Body部分
Map body = new HashMap();
body.put("userId","100");
body.put("account","admin");
body.put("role","admin");
// 生成jwt,id是个标识不给也可以唯一即可
String jwt = Jwts.builder()
.setHeader(header)
.setClaims(body)
.setId("101")
.compact();
// 解析jwt
Jwt result = Jwts.parser().parse(jwt);
Header header1 = result.getHeader();
Object obj = result.getBody();
}
// 通过jjwt生成和解析令牌,使用对称加密的签名算法
@Test
public void test2(){
// Header部分
Map header = new HashMap();
header.put("alg",SignatureAlgorithm.HS256.getValue());
header.put("type","JWT");
// Body部分
Map body = new HashMap();
body.put("userId","100");
body.put("account","admin");
body.put("role","admin");
// 生成jwt,id是个标识不给也可以唯一即可
String jwt = Jwts.builder()
.setHeader(header)
.setClaims(body)
.setId("101")
.signWith(SignatureAlgorithm.HS256,"密钥内容")
.compact();
// 解析jwt
Jwt result = Jwts.parser().setSigningKey("密钥内容").parse(jwt);
Header header1 = result.getHeader();
Object obj = result.getBody();
}
// 通过jjwt生成和解析令牌,使用非对称加密的签名算法
@Test
public void test3(){
// Header部分
Map header = new HashMap();
header.put("alg",SignatureAlgorithm.HS256.getValue());
header.put("type","JWT");
// Body部分
Map body = new HashMap();
body.put("userId","100");
body.put("account","admin");
body.put("role","admin");
// 生成jwt,id是个标识不给也可以唯一即可
String jwt = Jwts.builder()
.setHeader(header)
.setClaims(body)
.setId("101")
.signWith(SignatureAlgorithm.HS256,"密钥内容")
.compact();
// 解析jwt
Jwt result = Jwts.parser().setSigningKey("密钥内容").parse(jwt);
Header header1 = result.getHeader();
Object obj = result.getBody();
}
// 通过jjwt生成和解析令牌,使用对称加密的签名算法
@Test
public void test4(){
// Header部分
Map header = new HashMap();
header.put("alg",SignatureAlgorithm.RS256.getValue());
header.put("type","JWT");
// Body部分
Map body = new HashMap();
body.put("userId","100");
body.put("account","admin");
body.put("role","admin");
//私钥,生成jwt令牌时需要使用私钥
InputStream resourceAsStream =
this.getClass().getClassLoader().getResourceAsStream("pri.key");
DataInputStream dis = new DataInputStream(resourceAsStream);
byte[] keyBytes = new byte[resourceAsStream.available()];
dis.readFully(keyBytes);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
PrivateKey privateKey = kf.generatePrivate(spec);
// 生成jwt,id是个标识不给也可以唯一即可
String jwt = Jwts.builder()
.setHeader(header)
.setClaims(body)
.setId("101")
.signWith(SignatureAlgorithm.RS256,privateKey)
.compact();
//使用jjwt提供的API解析jwt令牌
resourceAsStream =
this.getClass().getClassLoader().getResourceAsStream("pub.key");
dis = new DataInputStream(resourceAsStream);
keyBytes = new byte[resourceAsStream.available()];
dis.readFully(keyBytes);
X509EncodedKeySpec spec2 = new X509EncodedKeySpec(keyBytes);
kf = KeyFactory.getInstance("RSA");
PublicKey publicKey = kf.generatePublic(spec2);
// 解析jwt
Jwt result = Jwts.parser().setSigningKey(publicKey).parse(jwt);
Header header1 = result.getHeader();
Object obj = result.getBody();
}
pubic static Map<String,String> generator(String pubPath,String priPath) throws Exception{
// 自定义随机密码
String password="asdasfa";
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
SecureRandom secureRandom = new SecureRandom(password.getBytes());
KeyPairGenerator.initialize(1024,secureRandom);
KeyPair keyPair = keyPairGenerator.genKeyPair();
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();
FileUtil.writeBytes(publicKeyBytes,pubPath);
FileUtil.writeBytes(privateKeyBytes,priPath);
}