JWT(JSON Web Token), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。JWT一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息。
说到底,Token其实就是一串字符串,只不过字符串的规则是大家都公认的,所以形成了一个标准了。
目 录
一、JWT的应用场景
JWT本质是token,应用场景和其他token一样,如下图所示:
(1)用户通过登录等手段向Authentication Server发送一个认证请求
(2)认证通过之后,Authentication Server会返回给用户一个JWT。
(3)此后用户向Application Server发送的所有请求都要捎带上这个JWT。
(4)Application Server每次会验证用户提交的JWT的合法性,验证通过则说明用户请求时来自合法守信的客户端,即响应客户端的请求。
再具体一点的过程如下图所示,从前端和后端角度看。JWT在认证成功之后产生,在前端的每次请求中都需要使用。
二、JWT的结构
参照简书上的优质博客,JWT其实就是由三部分拼接而成的字符串,每个部分之间通过.
连接起来。这也是JWT和其他一般token不一样的地方,JWT有自己的构成规则,所有使用JWT的地方都必须遵守这个规则,这样客户端和服务端才能把这个token用起来。
一个JWT就像这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
。
第一部分我们称它为头部(header),第二部分我们称其为载荷(payload, 类似于飞机上承载的物品),第三部分是签名(signature)。
下面看看每个部分的字符串都是怎么生成的。
1.header
JWT的头部承载两部分信息:
- 声明类型,这里是JWT
- 声明加密的算法 通常直接使用 HMAC SHA256
完整的头部就像下面这样的JSON:
{
'typ': 'JWT',
'alg': 'HS256'
}
然后将头部进行base64
加密(该加密是可以对称解密的),构成了第一部分。
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
上面经过Base64编码之后产生的结果,我们也可以通过https://base64.us/在线base64编解码验证。
2.payload
载荷就是存放有效信息的地方。这个名字像是特指飞机上承载的货品,这些有效信息包含三个部分
- 标准中注册的声明
- 公共的声明
- 私有的声明
(1)标准中注册的声明 (建议但不强制使用) :
iss: JWT签发者
sub: JWT所面向的用户
aud: 接收JWT的一方
exp: JWT的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该JWT都是不可用的.
iat: JWT的签发时间
jti: JWT的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
(2)公共的声明 :
公共的声明可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息。
(3)私有的声明 :
私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
按照上面的规则,可以自定义一个payload:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
然后将其进行base64
编码,得到JWT的第二部分。
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
3.signature
JWT的第三部分是一个签证信息,这个签证信息是将JWT2个部分(JWT第一部分haeder + JWT第二部分payload + secret
)连接之后,进行加盐secret组合,经过单向散列算法SHA256
加密后,再通过Base64编码而成。
// 伪代码
String encodedString = base64UrlEncode(header) + '.' + base64UrlEncode(payload);
String hashResult = HMACSHA256(encodedString, 'secret');
String signature = Base64.encodeBase64URLSafeString(hashResult);
// TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
注意:secret
是保存在服务器端的,JWT的签发生成也是在服务器端的,secret
就是用来进行JWT的签发和JWT的验证,所以,它就是你服务端的私钥,在任何场景都不应该流露出去。一旦客户端得知这个secret
, 那就意味着客户端是可以自我签发JWT了。
将这三部分用.连接成一个完整的字符串,构成了最终的JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
第三部分与前两部分不一样:前两部分都是将JSON进行Base64编码,产生对应的字符串,字符串也可以可以通过Base64解码还原成JSON的。第三部分是通过SHA256单向HASH散列算法产生的,是不可逆的。
例如以上JWT的前两个部分,可以通过Base64解码器(https://base64.us/)直接解码出来:
三、JWT的优点
传统的HttpSession依靠浏览器的Cookie存放SessionId,所以要求客户端必须是浏览器。现在的JavaWeb系统,客户端可以是浏览器、APP、小程序,以及物联网设备。为了让这些设备都能访问到JavaWeb项目,就必须要引入JWT技术。JWT的Token是纯字符串,至于客户端怎么保存,没有具体要求,并且是跨语言的。只要客户端发起请求的时候,附带上Token即可。所以像物联网设备,我们可以用SQLite存储Token数据。
四、JWT在JAVA中的应用
package com.example.demo.config.shiro;
import cn.hutool.core.date.DateField;
import cn.hutool.core.date.DateUtil;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTCreator;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
@Slf4j
public class JwtUtil {
//用于生成消息认证码的密钥Secret(自定义即可)
private static String secret = "1234578Iloveu";
//过期时间1min
private int expire = 60000;
/**
* 为登录成功的用户生成Token
* @param userId 用户的id
* @return token字符串
*/
public String createToken(int userId){
Date date=DateUtil.offset(new Date(), DateField.DAY_OF_YEAR, expire);
//使用HMAC256加密算法,生成签名
Algorithm algorithm=Algorithm.HMAC256(secret);
JWTCreator.Builder builder= JWT.create();
String token=builder.withClaim("userId",userId).withExpiresAt(date).sign(algorithm);
return token;
}
/**
* 从Token字符串中获取UserId
* @param token 符合JWT规则的token字符串
* @return userId
*/
public int getUserId(String token){
DecodedJWT jwt=JWT.decode(token);
int userId=jwt.getClaim("userId").asInt();
return userId;
}
/**
* 验证Token是否有效
* @param token 符合JWT规则的token字符串
* 如果校验不通过,该函数会直接抛出异常
*/
public void verifierToken(String token){
Algorithm algorithm=Algorithm.HMAC256(secret);
JWTVerifier verifier=JWT.require(algorithm).build();
verifier.verify(token);
}
(一)关于HMAC
HMAC是一种使用单向散列函数来构造消息认证码的方法,其中HMAC中的H就是Hash的意思。
HMAC(Hash-based Message Authentication Code)是一种使用单项散列函数来构造消息认证码的方法。HMAC算法利用哈希运算,以一个密钥和一个消息为输入,生成一个消息摘要作为输出。其安全性是建立在Hash加密算法基础上的。它要求通信双方共享密钥、约定算法、对报文进行Hash运算,形成固定长度的认证码。通信双方通过认证码的校验来确定报文的合法性。HMAC算法可以用来作加密、数字签名、报文验证等。
HMAC中所使用的单向散列函数并不仅限于一种,任何高强度的单向散列函数都可以被用于HMAC,如果将来设计出的新的单向散列函数,也同样可以使用。
使用SHA-1、SHA-224、SHA-256、SHA-384、SHA-512所构造的HMAC,分别称为HMAC-SHA1、HMAC-SHA-224、HMAC-SHA-384、HMAC-SHA-512。
本文中使用的SHA-256(Secure Hash Algorithm 256,安全散列算法256)是散列函数(或哈希函数)的一种,能对一个任意长度(按bit计算)的数字消息(message),计算出一个32个字节
长度的字符串(又称消息摘要,message digest)。散列函数它被认为是一种单向函数——根据函数输出的结果,极难回推输入的数据。散列函数把消息数据打乱混合,压缩成散列值(摘要),使得数据量变小。
(二)JWTVerifier到底验证什么
//源码
public DecodedJWT verify(DecodedJWT jwt) throws JWTVerificationException {
//校验JWT Header中的hash算法和JWTVerifier中定义的是否一致
this.verifyAlgorithm(jwt, this.algorithm);
this.algorithm.verify(jwt);
this.verifyClaims(jwt, this.claims);
return jwt;
}
首先,验证是否符合JWT的格式,接着验证字符串中算法字段"alg":"HS256"
是否和JWTVerifier中定义的是否一致;
接着,最最重要的,如果token字符串中的算法和JWTVerifier中的算法一致,则通过这个散列算法验证secret
是否一致。如果secret不一致,会直接报异常。
最后,验证payload中的字段标准字段,包括aud、exp、iat、iss等等,如下verifyClaims()
代码所示。因为上面的示例中没有给JWTVerifier对象的claims属性赋值,所以实际也没有校验。
//JWTVerifier类源码
private void verifyClaims(DecodedJWT jwt, Map<String, Object> claims) throws TokenExpiredException, InvalidClaimException {
Iterator var3 = claims.entrySet().iterator();
while(var3.hasNext()) {
Entry<String, Object> entry = (Entry)var3.next();
String var5 = (String)entry.getKey();
byte var6 = -1;
switch(var5.hashCode()) {
case 96944:
if (var5.equals("aud")) {
var6 = 0;
}
break;
case 100893:
if (var5.equals("exp")) {
var6 = 1;
}
break;
case 104028:
if (var5.equals("iat")) {
var6 = 2;
}
break;
case 104585:
if (var5.equals("iss")) {
var6 = 4;
}
break;
case 105567:
if (var5.equals("jti")) {
var6 = 5;
}
break;
case 108850:
if (var5.equals("nbf")) {
var6 = 3;
}
break;
case 114240:
if (var5.equals("sub")) {
var6 = 6;
}
}
switch(var6) {
case 0:
this.assertValidAudienceClaim(jwt.getAudience(), (List)entry.getValue());
break;
case 1:
this.assertValidDateClaim(jwt.getExpiresAt(), (Long)entry.getValue(), true);
break;
case 2:
this.assertValidDateClaim(jwt.getIssuedAt(), (Long)entry.getValue(), false);
break;
case 3:
this.assertValidDateClaim(jwt.getNotBefore(), (Long)entry.getValue(), false);
break;
case 4:
this.assertValidIssuerClaim(jwt.getIssuer(), (List)entry.getValue());
break;
case 5:
this.assertValidStringClaim((String)entry.getKey(), jwt.getId(), (String)entry.getValue());
break;
case 6:
this.assertValidStringClaim((String)entry.getKey(), jwt.getSubject(), (String)entry.getValue());
break;
default:
this.assertValidClaim(jwt.getClaim((String)entry.getKey()), (String)entry.getKey(), entry.getValue());
}
}
}
五、JWT的自动续期机制
令牌(token)生成之后会一只保存在客户端中,如果用户一直在登录使用系统,也不会重新生成新的JWT。如果令牌到期,用户必须重新登录,这样的设计对用户是非常不友好的。因此,需要令牌可以自动续期。目前常用的自动续期的方式有两种:双令牌机制、令牌缓存机制。
双令牌机制是服务端每次同时生成失效时间一长一短的两个令牌,同时保存在客户端。客户端每次请求时将长令牌和短令牌同时提交至服务端,如果短令牌过期,长令牌有效,则为客户端重新生成一长一短两个新令牌,以实现自动续期。
令牌缓存机制是利用redis,将返回给客户端的令牌缓存起来,同时缓存的实效时间是token实效时间的两倍。如果客户端令牌实效,但是缓存中的令牌没有实效,则为用户生成新的令牌。并且,同时更新客户端和缓存上的令牌,以实现令牌的自动续期。