5分钟带你了解JWT
目录
JWT详解
jwt介绍:
Json web token (JWT), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准((RFC 7519).该token被设计为紧凑且安全的,特别适用于分布式站点的单点登录(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
其实用一句话简单概括,jwt是一种token,并且是无状态的token,服务器无需存储颁发给验证用户的令牌,它可以通过自身的加密解密手段实现应用于分布式站点的单点登录。
jwt产生的原因:
说起这个,必须从传统web服务验证客户端身份的流程说起。
-
第一阶段:
我们知道,http协议本身是一种无状态的协议,也就意味着每当客户端向服务器发起一次请求,都要提供当前登录者的用户名和密码,这种处理方式在软件初试锋芒之时还算可以,但随着时代发展,这个方式很快就被淘汰。
-
第二阶段
软件设计者们为了让我们的应用能够识别是哪个用户发出的请求,决定在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给客户端,客户端必须将这个信息保存下来,以便下次请求时能够被服务器识别 ,避免不断认证的繁琐流程,这个就是传统的基于session的认证。
-
第三阶段
随着互联网的兴起,电商行业的兴起更是大大提升了人们的生活水平,慢慢改变了人们的购物方式,人们是愈加依赖互联网产品,这个时候就要考虑系统的扩展能离,比如并发处理。大部分时候,提高一个系统的并发性能,就是我们常常想到的横向扩展,加机器,就是这么简单且暴力,不够就加机器,1台不够上2台(分布式出现了)。这时,token便不能简单的存储在服务器内存中了,开发者们开始想办法将token持久化到数据库、中间缓存件当中,但是这种解决方案会加剧修改架构的复杂性,更重要的是万一token存储中心出现问题,整个认证体系都会挂掉。这时JWT就应运而生了。
JWT格式:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ
JWT的构成:
如上所示,JWT由.分割成了三部分,这三部分也就是JWT的组成结构。第一部分,我们称为头部(header),第二部分称为负载(payload),第三部分称为签名,写成一行就是Header.Payload.Signature。
-
Header
{
"alg": "HS256",
"typ": "JWT"
}
上面代码中,alg属性表示签名的算法(algorithm),默认是 HMAC SHA256(写成 HS256);typ属性表示这个令牌(token)的类型(type),JWT 令牌统一写为JWT。最后,将上面的 JSON 对象使用 Base64URL 算法(详见后文)转成字符串。
-
Payload
iss (issuer):签发人
exp (expiration time):过期时间 //在校验jwt是否过期时就是用这个时间
sub (subject):主题
aud (audience):受众
nbf (Not Before):生效时间
iat (Issued At):签发时间
jti (JWT ID):编号
除了官方字段,你还可以在这个部分定义私有字段,下面就是一个例子。
{
"useId": "1",
"phone": "xxxxxxx"
}
由于负载信息比较多,开发使用的时候可能定义的私有字段也比较多,考虑到加密信息的传输速度,JWT开发者使用了压缩解压缩数据策略,并提供了两种实现,一种为JDK提供的Deflate压缩和Inflate解压缩,另一种也是JDK提供的,为Gzip。
注意,此部分一般不建议存放敏感信息,因为base64是对称解密的,意味着该部分信息可以归类为明文信息。
这个 JSON 对象也要使用 Base64URL 算法转成字符串。
-
Signature
Signature 部分是对前两部分的签名,防止数据篡改。
首先,需要指定一个**(secret)。这个**只有服务器才知道,不能泄露给用户。然后,使用 Header 里面指定的签名算法(默认是 HMAC SHA256),按照下面的公式产生签名。
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。
Base64URL算法:
有时前后端在交互过程中,可能会把JWT放在URL中(例如api.example/?token=xxx)。 Base64中用的三个字符是"+","/"和"=",由于在URL中有特殊含义,因此Base64URL中对他们做了替换:"="去掉,"+"用"-"替换,"/"用"_"替换,这就是Base64URL算法,可以看一下源码:
// 编码的时候替换
@Override
public String encode(byte[] data) {
String base64Text = TextCodec.BASE64.encode(data);
byte[] bytes = base64Text.getBytes(US_ASCII);
// base64url encoding doesn't use padding chars:
bytes = removePadding(bytes);
// replace URL-unfriendly Base64 chars to url-friendly ones:
for (int i = 0; i < bytes.length; i++) {
if (bytes[i] == '+') {
bytes[i] = '-';
} else if (bytes[i] == '/') {
bytes[i] = '_';
}
}
return new String(bytes, US_ASCII);
}
// 解码的时候还原
@Override
public byte[] decode(String encoded) {
char[] chars = encoded.toCharArray(); //always ASCII - one char == 1 byte
// Base64 requires padding to be in place before decoding, so add it if necessary:
chars = ensurePadding(chars);
// Replace url-friendly chars back to normal Base64 chars:
for (int i = 0; i < chars.length; i++) {
if (chars[i] == '-') {
chars[i] = '+';
} else if (chars[i] == '_') {
chars[i] = '/';
}
}
String base64Text = new String(chars);
return TextCodec.BASE64.decode(base64Text);
}
生成jwt信息的拼接:
@Override
public String compact() {
// payload和claims只能二选一
if (payload == null && Collections.isEmpty(claims)) {
throw new IllegalStateException("Either 'payload' or 'claims' must be specified.");
}
if (payload != null && !Collections.isEmpty(claims)) {
throw new IllegalStateException("Both 'payload' and 'claims' cannot both be specified. Choose either one.");
}
// key和keyBytes只能二选一
if (key != null && keyBytes != null) {
throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either one.");
}
Header header = ensureHeader();
Key key = this.key;
if (key == null && !Objects.isEmpty(keyBytes)) {
key = new SecretKeySpec(keyBytes, algorithm.getJcaName());
}
JwsHeader jwsHeader;
// 解析Header
if (header instanceof JwsHeader) {
jwsHeader = (JwsHeader)header;
} else {
jwsHeader = new DefaultJwsHeader(header);
}
if (key != null) {
jwsHeader.setAlgorithm(algorithm.getValue());
} else {
//no signature - plaintext JWT:
jwsHeader.setAlgorithm(SignatureAlgorithm.NONE.getValue());
}
if (compressionCodec != null) {
jwsHeader.setCompressionAlgorithm(compressionCodec.getAlgorithmName());
}
String base64UrlEncodedHeader = base64UrlEncode(jwsHeader, "Unable to serialize header to json.");
String base64UrlEncodedBody;
// 解析负载信息
if (compressionCodec != null) {
byte[] bytes;
try {
bytes = this.payload != null ? payload.getBytes(Strings.UTF_8) : toJson(claims);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Unable to serialize claims object to json.");
}
base64UrlEncodedBody = TextCodec.BASE64URL.encode(compressionCodec.compress(bytes));
} else {
base64UrlEncodedBody = this.payload != null ?
TextCodec.BASE64URL.encode(this.payload) :
base64UrlEncode(claims, "Unable to serialize claims object to json.");
}
// 拼接Header和Payload
String jwt = base64UrlEncodedHeader + JwtParser.SEPARATOR_CHAR + base64UrlEncodedBody;
// 获取Signature
if (key != null) { //jwt must be signed:
JwtSigner signer = createSigner(algorithm, key);
String base64UrlSignature = signer.sign(jwt);
jwt += JwtParser.SEPARATOR_CHAR + base64UrlSignature;
} else {
// no signature (plaintext), but must terminate w/ a period, see
// https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-25#section-6.1
jwt += JwtParser.SEPARATOR_CHAR;
}
return jwt;
}
这块就不做具体的解释了,逻辑挺简单,各种判断,最终用.将Header,Payload,SIgnature拼接在一起。
token到jwt的转换:
具体逻辑可以在DefaultJwtParser的parse方法中查看,通过.将token分割为三部分并分别校验,之后再调用生成jwt相关类的解密、解压缩等方法还原信息。信息还原成功后,获取到失效时间等关键信息对JWT做校验,通过校验则放行用户的请求,否则报错打回。
JWT提供的Claims类会将Payload中的信息保存,上面提到用户可以在Payload这里自定义属性,是因为Claims类实现了Map<String, Object>,在开发使用上提供了更多的扩展性。
JWT用法:
客户端接收服务器返回的JWT,将其存储在Cookie或localStorage中。
此后,客户端将在与服务器交互中都会带JWT。如果将它存储在Cookie中,就可以自动发送,但是不会跨域,因此一般是将它放入HTTP请求的Header Authorization字段中(Authorization: Bearer)。当跨域时,也可以将JWT被放置于POST请求的数据主体中。
JWT存在的问题:
服务器无法废弃令牌
JWT的最大缺点是服务器不保存会话状态,所以在使用期间不可能取消令牌或更改令牌的权限。也就是说,一旦JWT签发,在有效期内将会一直有效。
这个问题也有相应的解决方案,但是要借助于缓存中间件,每次JWT生成一个token的同时,也生成一个refreshToken(UUID方式生成即可)存储在Redis等缓存中间件中,并将生成时间、refreshToken封装在jwt中。若用户退出登录就将Redis中对应的refreshToken清除,若用户更新jwt就将Redis中refreshToken的生成时间更新。每次客户端验证时,校验jwt中的refreshToken生成时间和Redis中存储的是否一致,如果不一致,证明此用户的jwt已被更新过,不予通过。
用上面的解决方案也可以让服务端有强制登录者下线的能力,只要将jwt有效时间设置短一些,refreshToken有效时间长一些,当客户端请求服务器时,若判断jwt过期,从Redis中查询refreshToken是否在有效时间内,有效的话,重新生成一个jwtToken给客户端。这样的话,当服务器检测到用户登录地异常或者其他异常情况发生,便可以删掉Redis中存储当前用户的refreshToken,强制其下线。
其实出于信息安全的考虑,jwt的有效时间不宜设置过长,尽量在一下重要操作里每次都进行身份验证。