相信大家或多或少都接触过这个场景,那就是我们注册某个一个网站长账号后(账号A),网站会给我们的注册邮箱发送一封邮件,其中包含了激活链接,一般情况下,这个链接会在几个小时内过期,过期后就无法激活了。同样,你并不能使用这个激活链接来激活你又新注册的一个账号(此处称为账号B)。
JWT允许我们做到与上面场景同样的效果。在开始如何使用JWT实现类似的无状态验证前,先介绍下JWT的组成。
1.JWT的组成
一个JWT实际上就是一个字符串,它由三部分组成,分别是:头部
、载荷
与签名
。
头部(Header)
JWT的头部主要用于描述关于该JWT的最基本的信息,例如其类型以及签名所用算法等。这也可以表示成一个JSON对象。
{
"type": "JWT",
"alg": "HS256"
}
在这里,头部说明了这是一个JWT,并且所用的签名算法为HS256。
对JSON对象进行Base64编码后,得到的字符串作为JWT的Header。示例字符串如下
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
载荷(Payload)
载荷就是存放有效信息的地方,它包含声明(要求)。声明有三种类型:
registered claims:标准中注册的声明。这里有一组预定义的声明,它们不是强制的,但是推荐
public claims:公共的声明
private claims:私有的声明
标准中注册的声明 (建议但不强制使用) :
{
"iss": "Jerry",
"iat": 1563846005,
"exp": 1563856805,
"aud": "www.example.com",
"sub": "Jerry@example.com"
}
-
iss
:该JWT的签发者 -
sub
: 该JWT所面向的用户 -
aud
: 接收JWT的一方 -
exp
:JWT的过期时间 -
iat
:JWT的签发时间
对上面的JSON对象进行Base64编码可以得到一串字符串。这个字符串我们将它称作JWT的Payload。示例:
eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9
公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息.但不建议添加敏感信息,因为该部分在客户端可解密.
私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
对Payload进行Base64加密就得到了JWT第二部分的内容。
将头部与载荷两个编码后的字符串用句号.
连接在一起(头部在前),就形成了JWT的一部分,如下
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9
签名(Signature)
最后,将上面拼接完的字符串用HS256算法进行加密。在加密的时候,我们还需要提供一个密钥(secret)。加上我们的签名后,使用HS256算法进行加密结果示例如下:
rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
加密后得到的字符串就是我们的签名,我们将这部分与前面的字符串拼接,就得到了完整的JWT:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJKb2huIFd1IEpXVCIsImlhdCI6MTQ0MTU5MzUwMiwiZXhwIjoxNDQxNTk0NzIyLCJhdWQiOiJ3d3cuZXhhbXBsZS5jb20iLCJzdWIiOiJqcm9ja2V0QGV4YW1wbGUuY29tIiwiZnJvbV91c2VyIjoiQiIsInRhcmdldF91c2VyIjoiQSJ9.rSWamyAYwuHCo7IFAgd1oRpSP7nzL7BF5t7ItqpKViM
参考图:
签名目的
最后一步签名的过程,实际上是对头部以及载荷内容进行签名。一般而言,加密算法对于不同的输入产生的输出总是不一样的。对于两个不同的输入,产生同样的输出的概率极其地小(有可能比我成世界首富的概率还小)。所以,我们就把“不一样的输入产生不一样的输出”当做必然事件来看待吧。
所以,如果有人对头部以及载荷的内容解码之后进行修改,再进行编码的话,那么新的头部和载荷的签名和之前的签名就将是不一样的。而且,如果不知道服务器加密的时候用的密钥的话,得出来的签名也一定会是不一样的。
服务器应用在接受到JWT后,会首先对头部和载荷的内容用同一算法再次签名。那么服务器应用是怎么知道我们用的是哪一种算法呢?别忘了,我们在JWT的头部中已经用alg
字段指明了我们的加密算法了。
如果服务器应用对头部和载荷再次以同样方法签名之后发现,自己计算出来的签名和接受到的签名不一样,那么就说明这个Token的内容被别人动过的,我们应该拒绝这个Token,返回一个HTTP 401 Unauthorized响应。
2. 回顾文章开头的场景
在了解了JWT的生成过程后,你猜到了开头场景是如何实现的了嘛?
没错,其中:
- 第一个有效时间是服务器在签发JWT时设置的
exp
过期时间; - 第二个的不能用于激活其他账号原因是我们可以在载荷中设置该JWT所面向的用户。
3. JWT使用场景
一次性验证:如文中开头所说的场景,这种场景就和JWT的特性非常的贴近,JWT中的
Payload
固定的参数:iss
签发者和exp
过期时间正是为其准备的;RESTful api的无状态认证。
4. Token对比Session验证机制
4.1 基于session的验证机制
http协议本身是一种无状态的协议,而这就意味着如果用户向我们的应用提供了用户名和密码来进行用户认证,那么下一次请求时,用户还要再一次进行用户认证才行,因为根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让我们的应用能识别是哪个用户发出的请求,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器,告诉其保存为cookie,以便下次请求时发送给我们的应用,这样我们的应用就能识别请求来自哪个用户了,这就是传统的基于session认证。
但是这种基于session的认证使应用本身很难得到扩展,随着不同客户端用户的增加,独立的服务器已无法承载更多的用户,而这时候基于session认证应用的问题就会暴露出来:
- Session: 每个用户经过我们的应用认证之后,我们的应用都要在服务端做一次记录,以方便用户下次请求的鉴别,通常而言session都是保存在内存中,而随着认证用户的增多,服务端的开销会明显增大;
- 扩展性: 用户认证之后,服务端做认证记录,如果认证的记录被保存在内存中的话,这意味着用户下次请求还必须要请求在这台服务器上,这样才能拿到授权的资源,这样在分布式的应用上,相应的限制了负载均衡器的能力。这也意味着限制了应用的扩展能力;
- CSRF: 因为是基于cookie来进行用户识别的, cookie如果被截获,用户就会很容易受到跨站请求伪造的攻击;
针对扩展性,如果我们要实现分布式部署,需要我们做好集群间session的共享,这样我们才能很好的扩展我们的应用。
4.2 基于Token的验证机制
Token机制相对于传统的session认证(即Cookie机制),有以下的好处:
- 支持跨域访问:Cookie是不允许垮域访问的,这一点对Token机制是不存在的,前提是传输的用户认证信息通过HTTP头传输;
- 无状态:Token不需要在服务端额外存储,而session需要在服务端存储以便认证;
- 去耦:不需要绑定到一个特定的身份验证方案;
- 更实用于移动应用:当你的客户端是一个原生平台(iOS, Android,Windows 8等)时,Cookie是不被支持的(你需要通过Cookie容器进行处理),这时采用Token认证机制就会简单得多;
- CSRF:因为不再依赖于Cookie,所以不再需要考虑对CSRF(跨站请求伪造)的防范;
- 性能:一次网络往返时间(通过数据库查询session信息)总比做一次HMACSHA256计算 的Token验证和解析要费时得多。
我们也知道,很难有一种技术是万能的,无懈可击的,基于Token的验证机制同样有它的缺点:
- 注销:传统的 session+cookie 方案用户点击注销,服务端清空 session 即可,因为状态保存在服务端。但 JWT的方案就比较难办了,因为 JWT是无状态的,服务端通过计算来校验有效性。没有存储起来,所以即使客户端删除了 JWT,但是该 jwt 还是在有效期内,只不过处于一个游离状态。分析下痛点:注销变得复杂的原因在于 JWT的无状态;
- 续签问题:JWT本身的Payload之中的
exp
过期参数,来代表JWT的时效性,而JWT想延期这个exp
就有点身不由己了,因为payload是参与签名的,一旦过期时间被修改,整个JWT串就变了,所以续签问题也是JWT的一个缺点。
针对注销,我们可以清空或者修改服务端的用户对应的secret(这种情况下,我们的每个用户secret都是不一样的,即和用户关联,而不是使用统一的secret),这样用户在注销后,JWT本身不变,但是由于secret不存在或者改变,则无法完成校验。(仅做参考,我们可以按照我们实际需求设计)
针对于续签问题,我们可以考虑这一种方案:
服务端JWT过期时间依旧按常规设置,签发时同时存入redis,在redis中的过期时间为服务端签发的两倍(依个人而设置,大于服务端存储的就好)。验证的时候正常进行:
- 当发现JWT过期的时候,去redis中查找该JWT是否存在或是否已过期,若无过期,则签发新的JWT,并更新redis中该JWT的过期时间;
- 当发现JWT过期,同时redis中的也过期了,那么跳转到登录页面,让用户重新登录。
续签可以很好的解决这样的问题:那就是如果用户非常活跃,用户在使用超过我们的过期时间还在使用系统,那么他又得重新登录。用户体验性会很差,所以需要我们增加续签的功能来提高用户体验。
5. Springboot中的JWT实现
5.1 引入依赖
在pom.xml
中加入我们的依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.10.7</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.10.7</version>
<scope>runtime</scope>
</dependency>
<!-- Uncomment this next dependency if you want to use RSASSA-PSS (PS256, PS384, PS512) algorithms: -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.60</version>
<scope>runtime</scope>
</dependency>
编写我们的TokenUtil.class
以下为示例代码:
- 其中
secret
需要修改成我们自己的; - 生成Token的
createJWT
函数需要的入参我们可以按需修改; - 同时设置JWT claims时也可以根据我们需求做相应的修改;
-
parseJWT
函数是我们解析Token并认证的过程。
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
import java.security.Key;
import io.jsonwebtoken.*;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.slf4j.*;
/**
* @Author: JerryLiang
* @Date: 2019/7/15 10:53
**/
public class TokenUtil {
private final static Logger logger = LoggerFactory.getLogger(TokenUtil.class);
private final static String myApiKeySecret = "这里写入你的Secret";
/**
* 创建JSON WEB TOKEN
* @param id
* @param userName
* @param userPower
* @param ttlMillis
* @return
*/
public static String createJWT(String id, String userName, String userPower, long ttlMillis){
//设置签名算法
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
//设置密钥
byte[] apiKeySecretBytes = DatatypeConverter.parseBase64Binary(myApiKeySecret);
Key signingKey = new SecretKeySpec(apiKeySecretBytes, signatureAlgorithm.getJcaName());
//设置JWT claims
JwtBuilder builder = Jwts.builder().setHeaderParam("typ", "JWT")
.setId(id)
.setIssuedAt(now)
.setAudience("iot")
.setIssuer("Jerry") //设置发行者,自定义
.claim("userName", userName)
.claim("userPower", userPower)
.signWith(signatureAlgorithm, signingKey);
//设置超时时间
if (ttlMillis >= 0) {
long expMillis = nowMillis + ttlMillis;
Date exp = new Date(expMillis);
builder.setExpiration(exp);
}
//生成JWT
return builder.compact();
}
/**
* 解析JWT,并验证用户权限
* @param jwt
*/
public static Boolean parseJWT(String jwt) throws ParseException {
if (jwt == null) {
logger.error("----------Token不能为空------------");
return false;
}
try {
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(myApiKeySecret))
.parseClaimsJws(jwt).getBody();
//将超时时间格式化为时间戳time
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
String timeFormat = sdf.format(claims.getExpiration());
Date date = sdf.parse(timeFormat);
long time = date.getTime();
long currentTime = System.currentTimeMillis();
return ("Jerry").equals(claims.getIssuer()) &&
("iot").equals(claims.getAudience()) &&
(time > currentTime) &&
claims.get("userName") != null;
}catch (Exception e){
e.printStackTrace();
logger.error("----------Token格式有误------------");
return false;
}
}
/**
* 获取jwt中的userName
* @param jwt
* @return
*/
public static String getUserName(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(DatatypeConverter.parseBase64Binary(myApiKeySecret))
.parseClaimsJws(jwt).getBody();
return claims.get("userName").toString();
}
}
我们在需要验证的接口中调用TokenUtil中的方法就可以了。示例如下
if(TokenUtil.parseJWT(jwt)){
//此处为验证通过执行的代码
}else{
//此处为验证不通过执行的代码
}
至此,基于JWT的验证已经完成了,但是上面的代码中我并没有加入解决续签问题的解决方案的示例代码,可以参考我的另一篇关于springboot整合redis的文章,我们在createJWT
函数中,生成完后将JWT存入redis即可,同时,在使用parseJWT
解析认证后,若失败,则查询redis是否存在该JWT,若存在则重新调用createJWT签发新的JWT,再存入redis中即可。
注意:大家或许可能会想,这里借助redis存储JWT,是不是性质已经和session差不多了呀?其实不然,此处存储的目的是解决续签问题,而并没有实际的验证比对,所以并不是一样的哈!
参考
http://blog.didispace.com/learn-how-to-use-jwt-xjf/