OAuth2 中默认使用 Bearer Tokens (一般用 UUID 值)作为 token 的数据格式,但也支持升级使用 JSON Web Token(JWT) 来作为 token 的数据格式。实际来说,OAuth 规范中并无限制 Token 采取何种格式。今天我们就采用 JWT 来作为 Token,它的一个好处是自描述 Token,包含了用户信息而并不需要通过额外的接口获取用户信息。
所谓 JWT Token,本身是明文的,前端得到之后进行 Base64 解码,即可获取用户信息(JSON)。——此时你认为可以直接使用吗?——那岂可值得信任?放心,我们还有一个 signature 参数用于校验这段 Token 是否合法,还是伪造的。即使假设这是个假的 Token,调用业务逻辑时候传入到后端,我们根据签名就能知道这个 Token 真实性。
故所以,我们必须在服务端校验过后才能用于前端的显示。因为密钥是在后端的——验证 JWT 的完整性和真实性应该在服务器端进行,使用密钥进行签名验证。
网上关于 JWT 的文章很多,但无非都是库的使用方式介绍,再深一点就研究 JWT 原理。其实如果只是生成 JWT,Java 代码是很简单的,不需要依赖什么库。今天我们就发挥一下动手能力,自己写个 JWT Token 的生成器。实际网上写 Java JWT 的轮子不是很多,我看到的有 cn.hutool.jwt.JWT
和老外一个例子。
认识 JWT
JWT 令牌由这三部分组成:
- header 头部,明文是 JSON 格式,它确定了是何种加密算法。目前采用 HmacSHA256算法,于是 header 就是
{"alg":"HS256","typ":"JWT"}
。我们用一个常量定义之:
private static final String JWT_HEADER = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
- Payload(负载):也是一个 JSON 对象,用来存放实际需要传递的数据,JWT 规定了七个官方字段:
- iss (issuer):签发人
- exp (expiration time):过期时间
- sub (subject):主题,这理解有点别扭,相当于用户 ID
- aud (audience):受众,这理解有点别扭,实际上就是角色的意思,可为多个
- nbf (Not Before):生效时间
- iat (Issued At):签发时间
- jti (JWT ID):编号
- Signature(签名):对前两部分的签名,防止数据篡改
重点是 Payload。其中最关键的三个字段是:exp、sub、aud。当然可以添加任何的信息,一般添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可解密。
我们定义个一个 Java Bean,说明这个 Payload 如何:
import lombok.Data;
import java.util.List;
/**
* JWT 基础载荷
*/
@Data
public class Payload {
/**
* 主题
*/
private String sub;
/**
* 受众
*/
private List<String> aud;
/**
* 过期时间
*/
private Long exp;
/**
* 签发人
*/
private String iss;
/**
* 签发时间
*/
private Long iat;
// /**
// * 编号,似乎不需要
// */
// private String jti;
}
进而描绘出 JWT Token 的结构,如是 JWebToken
:
import com.ajaxjs.util.map.JsonHelper;
import lombok.Data;
/**
* JWT Token
*/
@Data
public class JWebToken {
/**
* 头部
*/
public static final String JWT_HEADER = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
/**
* 头部的 Base64 编码
*/
public static final String encodedHeader = Utils.encode(JWT_HEADER);
/**
* 载荷
*/
private Payload payload;
/**
* 签名部分
*/
private String signature;
public JWebToken(Payload payload) {
this.payload = payload;
}
/**
* 头部 + Payload
*
* @return 头部 + Payload
*/
public String headerPayload() {
String p = Utils.encode(JsonHelper.toJson(payload));
return encodedHeader + "." + p;
}
/**
* 返回 Token 的字符串形式
*
* @return Token
*/
@Override
public String toString() {
return headerPayload() + "." + signature;
}
}
创建 Token
结构清楚了,我们就试着创建 Token。
首先对 Header 和 Payload 分别 base64 编码,然后通过 HMACSHA256算法得到签名(Signature )部分,这样还可以防止数据被篡改。
String signature = HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret);
String jwtToken = base64(header) + "." + base64(payload) + "." + signature;
然后把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用"点"(.)分隔,就可以返回给用户。
JWebTokenMgr
具体执行过程参见下面测试代码:
JWebTokenMgr mgr = new JWebTokenMgr();
@Test
public void testMakeToken() {
JWebToken token = mgr.tokenFactory("user01", Collections.singletonList("admin"), Utils.setExpire(24));
System.out.println(token.toString());
}
这里出现了 JWebTokenMgr
,这是封装好的 JWT 管理器,一般情况下要对其初始化,传入最关键的密钥 secretKey
信息,还有其他颁发者等的信息。
/**
* JWT 管理器
*/
public class JWebTokenMgr {
private String secretKey = "Df87sD#$%#A";
private String issuer = "foo@bar.net";
public JWebTokenMgr(String secretKey, String issuer) {
this.secretKey = secretKey;
this.issuer = issuer;
}
public JWebTokenMgr() {
}
……
当然不传也行,就是默认的密钥(无安全性可言)。
通过工厂方法创建 Token
mgr.tokenFactory()
分别传入了 sub、aud、exp 这三个 Payload 最基本的参数。最后执行 token.toString() 返回 Token 字符串。
JWebToken token = mgr.tokenFactory("user01", Collections.singletonList("admin"), Utils.setExpire(24));
System.out.println(token.toString());
当然你也可以传入 Payload 实例或其子类。
/**
* 创建 JWT Token
*
* @param payload Payload 实例或其子类
* @return JWT Token
*/
public JWebToken tokenFactory(Payload payload);
Base64 编码问题
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx
)。Base64 有三个字符+
、/
和=
,在 URL 里面有特殊含义,所以要被替换掉:=
被省略、+
替换成-
,/
替换成_
。于是这个 Base64 算法是 Base64URL,跟 Base64 算法基本类似,但有一些小的不同。在 jdk8 之后提供了这样 Base64.getUrlEncoder().withoutPadding()
的 Base64URL 方式。
Token 校验
还是一位行家说得好:
先说签名验证。当接收方接收到一个JWT的时候,首先要对这个JWT的完整性进行验证,这个就是签名认证。它验证的方法其实很简单,只要把
header 做 base64 url解码,就能知道 JWT 用的什么算法做的签名,然后用这个算法,再次用同样的逻辑对 header 和
payload 做一次签名,并比较这个签名是否与 JWT 本身包含的第三个部分的串是否完全相同,只要不同,就可以认为这个 JWT
是一个被篡改过的串,自然就属于验证失败了。接收方生成签名的时候必须使用跟 JWT 发送方相同的密钥,意味着要做好密钥的安全传递或共享。
话不多说,直接给代码:
/**
* 解析 Token
*
* @param tokenStr JWT Token
*/
public JWebToken parse(String tokenStr) {
String[] parts = tokenStr.split("\\.");
if (parts.length != 3)
throw new IllegalArgumentException("无效 Token 格式");
if (!JWebToken.encodedHeader.equals(parts[0]))
throw new IllegalArgumentException("非法的 JWT Header: " + parts[0]);
String json = Utils.decode(parts[1]);
Payload payload = JsonHelper.parseMapAsBean(json, Payload.class);
if (payload == null)
throw new RuntimeException("Payload is Empty: ");
if (payload.getExp() == null)
throw new RuntimeException("Payload 不包含过期字段 exp:" + payload);
JWebToken token = new JWebToken(payload);
token.setSignature(parts[2]);
return token;
}
/**
* 校验是否合法的 Token
*
* @param token 待检验的 Token
* @return 是否合法
*/
public boolean isValid(JWebToken token) {
String _token = signature(token);
System.out.println(">>>" + token.getSignature());
System.out.println(":::" + _token);
return token.getPayload().getExp() > Utils.now() //token not expired
&& token.getSignature().equals(_token); //signature matched
}
小结
JWT 我也是刚接触,如果有不对的地方敬请提出!
源码:https://gitee.com/sp42_admin/ajaxjs/tree/master/aj-backend/aj-user/src/main/java/com/ajaxjs/jwt。
参考
- JWT实现token-based会话管理
- 教你玩转JWT认证—从一个优惠券聊起
- OAuth2.0、OpenID Connect和JWT