1 背景
1.1 什么是JWT
JWT(JSON WEB TOKEN):JSON网络令牌,JWT是一个轻便的安全跨平台传输格式,定义了一个紧凑的自包含的方式在不同实体之间安全传输信息(JSON格式)。它是在Web环境下两个实体之间传输数据的一项标准。实际上传输的就是一个字符串。广义上讲JWT是一个标准的名称;狭义上JWT指的就是用来传递的那个token字符串。
1.2 JWT的作用
由于http协议是无状态的,所以可以认为客户端和服务端的所有交互都是新的请求,这就意味着当我们通过账号密码验证用户时,当下一个request请求时它就不会携带刚刚的资料,于是程序只能再次重新识别。JWT就是实现了以JSON的格式,在客户端和服务端安全的传输供认证使用的信息。
1.3 传统方式
1.3.1 基于session身份认证方案:
根据http协议,我们并不能知道是哪个用户发出的请求,所以为了让应用能识别,我们只能在服务器存储一份用户登录的信息,这份登录信息会在响应时传递给浏览器保存为cookie,以便下次请求时发送给应用,这样应用就能识别请求来自哪个用户了,这就是传统的基于session认证。
但是session是保存到服务器内存当中的,不能跨应用服务器共享,使得应用很难扩展,随着客户端用户量增加,独立的服务器已无法承载更多的用户,这是基于session身份认证方案的问题就会暴露出来,并且这种方案存在CSRF风险,因此随着技术的发展就有了基于Token身份认证的方案去解决这些问题。
1.3.2 基于Token身份认证方案:
基于token的鉴权机制类似于http协议也是无状态的,它不需要在服务端去保留用户的认证信息或者会话信息。这就意味着基于token认证机制的应用不需要去考虑用户在哪一台服务器登录了,这就为应用的扩展提供了便利,另外因为用户的信息是保存在分布式缓存中,这种方式就支持分布式水平扩展,支持高并发。
由于token是保存在Redis服务器中,使用这种方式无疑加大了对Redis缓存组件的依赖和增加了硬件资源的投资。
那么我们开始介绍JWT的特点。
1.4 基于JWT的token身份认证方案
服务端验证后,将部分的用户信息存放到JWT中,也就是存在token的字符串中,比如用户的email和用户的姓名等。在鉴权的流程当中,是直接从JWT中直接获取用户信息,这样减少了对Redis缓存组件的依赖,也减少了硬件资源的投入。
优点:
安全性高,防止token被伪造和篡改
自包含,减少存储开销
跨语言,支持多种语言实现
支持过期,发布者等校验
缺点:
JWT不适用存放大量信息,会造成token过长
无法作废未过期的JWT,所以需要搭配Redis使用,达到用户登出操作token即失效的要求。
2 JWT的结构
一个JWT是一个字符串,其由Header(头部)、Payload(负载)和Signature(签名)三个部分组成,中间以.号分隔,其格式为Header.Payload.Signature。
Header:
Payload:
Signature:
按照header.payload.signature这个格式串起来,串之前注意,header和payload也要做一个base64url encoded的转换。那么最终拼出来的一个例子是:
3 JWT的代码实现
3.1 导入依赖
3.2 生成token
JWT指定了七个默认claims字段供选择。
iss:发行人
sub:主题
aud:用户
exp:到期时间
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT
除以上默认字段外,我们还可以自定义私有字段,如下例:
{
"sub":"8208208820",
"name":"hubert",
"role":"admin"
}
按照JWT标准的说明:保留的claims都是可选的,在生成payload不强制用上面的那些claim,另外你可以按照自己的想法来定义payload的结构,不过这样搞根本没必要:第一是,如果把JWT用于认证, 那么JWT标准内规定的几个claim就足够用了,假如想往JWT里多存一些用户业务信息,比如用户名(name)和角色(role)等才需要考虑添加自定义claim;第二是,JWT标准里面针对它自己规定的claim都提供了有详细的验证规则描述,每个实现库都会参照这个描述来提供JWT的验证实现,所以如果是自定义的claim名称,那么你用到的实现库就不会主动去验证这些claim。
3.3 校验token
根据源码可以发现在校验过程中主要是校验JWT字符串的格式、过期时间、加密算法正确性等。
具体校验的源码:
public Jwt parse(String jwt) throws ExpiredJwtException, MalformedJwtException, SignatureException {
Assert.hasText(jwt, "JWT String argument cannot be null or empty.");
String base64UrlEncodedHeader = null;
String base64UrlEncodedPayload = null;
String base64UrlEncodedDigest = null;
int delimiterCount = 0;
StringBuilder sb = new StringBuilder(128);
char[] var7 = jwt.toCharArray();
int var8 = var7.length;
for(int var9 = 0; var9 < var8; ++var9) {
char c = var7[var9];
if (c == '.') { // 以"."作为分隔符号切割JWT字符串
CharSequence tokenSeq = Strings.clean(sb);
String token = tokenSeq != null ? tokenSeq.toString() : null;
if (delimiterCount == 0) {
base64UrlEncodedHeader = token;
} else if (delimiterCount == 1) {
base64UrlEncodedPayload = token;
}
++delimiterCount;
sb.setLength(0);
} else {
sb.append(c);
}
}
if (delimiterCount != 2) { // 判断JWT字符串是否由两部分组成
String msg = "JWT strings must contain exactly 2 period characters. Found: " + delimiterCount;
throw new MalformedJwtException(msg);
} else {
if (sb.length() > 0) {
base64UrlEncodedDigest = sb.toString();
}
// 判断Payload信息
if (base64UrlEncodedPayload == null) {
throw new MalformedJwtException("JWT string '" + jwt + "' is missing a body/payload.");
} else {
Header header = null;
CompressionCodec compressionCodec = null;
String payload;
if (base64UrlEncodedHeader != null) {
payload = TextCodec.BASE64URL.decodeToString(base64UrlEncodedHeader);
Map<String, Object> m = this.readValue(payload);
if (base64UrlEncodedDigest != null) {
header = new DefaultJwsHeader(m);
} else {
header = new DefaultHeader(m);
}
compressionCodec = this.compressionCodecResolver.resolveCompressionCodec((Header)header);
}
/** 此处省略部分代码 */
if (algorithm == null || algorithm == SignatureAlgorithm.NONE) { // 加密算法有没指定
object = "JWT string has a digest/signature, but the header does not reference a valid signature algorithm.";
throw new MalformedJwtException(object);
}
if (this.key != null && this.keyBytes != null) { // 不能同时指定两种类型的签名秘钥
throw new IllegalStateException("A key object and key bytes cannot both be specified. Choose either.");
}
/** 此处省略部分代码 */
Assert.notNull(key, "A signing key must be specified if the specified JWT is digitally signed.");
String jwtWithoutSignature = base64UrlEncodedHeader + '.' + base64UrlEncodedPayload;
JwtSignatureValidator validator;
try {
// 验证算法结果正确性
validator = this.createSignatureValidator(algorithm, (Key)key);
}
}
boolean allowSkew = this.allowedClockSkewMillis > 0L;
if (claims != null) {
Date now = this.clock.now();
long nowTime = now.getTime();
Date exp = claims.getExpiration();
String nbfVal;
SimpleDateFormat sdf;
// 验证JWT设置的过期时间
if (exp != null) {
long maxTime = nowTime - this.allowedClockSkewMillis;
Date max = allowSkew ? new Date(maxTime) : now;
if (max.after(exp)) {
sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
String expVal = sdf.format(exp);
nbfVal = sdf.format(now);
long differenceMillis = maxTime - exp.getTime();
String msg = "JWT expired at " + expVal + ". Current time: " + nbfVal + ", a difference of " + differenceMillis + " milliseconds. Allowed clock skew: " + this.allowedClockSkewMillis + " milliseconds.";
throw new ExpiredJwtException((Header)header, claims, msg);
}
}
Date nbf = claims.getNotBefore();
if (nbf != null) {
long minTime = nowTime + this.allowedClockSkewMillis;
Date min = allowSkew ? new Date(minTime) : now;
if (min.before(nbf)) {
sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
nbfVal = sdf.format(nbf);
String nowVal = sdf.format(now);
long differenceMillis = nbf.getTime() - minTime;
String msg = "JWT must not be accepted before " + nbfVal + ". Current time: " + nowVal + ", a difference of " + differenceMillis + " milliseconds. Allowed clock skew: " + this.allowedClockSkewMillis + " milliseconds.";
throw new PrematureJwtException((Header)header, claims, msg);
}
}
this.validateExpectedClaims((Header)header, claims);
}
/** 此处省略部分代码 */
}
}
}
3.4 获得claims(即Payload的信息)
4 实践使用建议
1 发送JWT要用https,因为JWT本身无法保证数据安全性
2 JWT的payload中不要包含太多用户信息,特别是权限角色的信息。
3 JWT的payload中建议设定一个expire时间,且不能设置太长,为什么要设置其实和cookie为什么设置过期时间一样,都是为了安全,JWT一旦生成发出去就不可以更改,在有效期内就可以永久使用。