原文:https://xie.infoq.cn/article/6b40282148451df8073209a8d
JWT 全称是 JSON Web Token,是目前非常流行的跨域认证解决方案,在单点登录场景中经常使用到。
JWT 就是一种Cookie-Session改造版的具体实现,让你省去自己造轮子的时间,JWT 还有个好处,那就是你可以不用在服务端存储认证信息(比如 token),完全由客户端提供,服务端只要根据 JWT 自身提供的解密算法就可以验证用户合法性,而且这个过程是安全的。
如果你是刚接触 JWT,最有疑问的一点可能就是: JWT 为什么可以完全依靠客户端(比如浏览器端)就能实现认证功能,认证信息全都存在客户端,怎么保证安全性?
JWT 最后的形式就是个字符串,它由头部、载荷与签名这三部分组成,中间以「.」分隔。像下面这样:
图 1 JWT格式
头部以JSON格式表示,用于指明令牌类型和加密算法。形式如下,表示使用JWT格式,加密算法采用HS256,这是最常用的算法,除此之外还有很多其他的。
{
"alg": "HS256",
"typ": "JWT"
}对应图1中红色的head部分,需要Base64部分。
用来存储服务器需要的数据,比如用户信息(例如姓名、性别、年龄等),要注意的是重要的机密信息最好不要放到这里,比如密码等。
{
"name": "古时的风筝",
"introduce": "英俊潇洒"
}另外,JWT还规定了7个字段供开发者选用。
- iss(issuer): 签发人
- exp(expiration time): 过期时间
- sub(subject): 主题
- aud(audience): 受众
- nbf(Not Before): 生效时间
- iat(issued at): 签发时间
- jti(JWT ID): 编号
这部分信息也要用Base64编码。
签名有一个计算公式:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
Secret
)适用HMACSHA256算法计算得出,这个方法有两个参数,前一个参数是(base64编码的头部 + base64编码的载荷)用点号相连,后一个参数是自定义的字符串密钥,密钥不要暴露在客户端,而应该服务器知道。
了解了JWT的结构和算法后,那怎么使用呢?假设这儿有个网站。
- 在用户登录网站的时候,需要输入用户名、密码或者短信验证码的方式登录,登录请求到达服务端的时候,服务端对账号、密码进行验证,然后计算出JWT字符串,返回给客户端
- 客户端拿到这个JWT字符串后,存储到Cookie或者浏览器的LocalStorage中
- 再次发送请求,比如请求用户设置页面的时候,在HTTP请求头中加入JWT字符串,或者直接放到请求主体中
- 服务端拿到这串JWT字符串后,使用base64的头部和base64的载荷部分,通过HMACSHA256算法计算签名部分,比较计算结果和传来的签名部分是否一致。如果一致,说明此次请求没有问题,如果不一致,说明请求过期或者是非法请求
图 2 服务端分发JWT
图 3 服务端使用JWT
保证安全性的关键就是HMACSHA256或者与它同类型的加密算法,因为加密过程是不可逆的,所以不能根据传到前端的JWT串解密到密钥信息。
另外,不同的头部和载荷加密之后得到的签名都是不同的。所以,如果有人改了载荷部分的信息,那最后加密出来的结果肯定就和改之前的不一样了。所以,最后验证的结果就是不合法的请求。
假设载荷部分存储了权限级别关键的字段,强盗拿到了JWT串后想要修改为更高级别的权限,上面说了,这种情况是不会得逞的,因为加密出来的签名会不一样,服务器可以很容易的判别出来。
如果抢到拿到后不做更改,直接使用呢?那就没有办法了。为了更大程度上防止被强盗盗取,应该使用HTTPS协议,这样可以有效地防止一些中间劫持攻击行为。
当然,如果终端设备被抢走了,而且从终端设备窃取到了JWT,那就没办法了。
所以使用JWT的时候仍然要注意合理设置过期时间。
JWT有个问题,导致很多开发团队放弃使用它,那就是一旦颁发一个JWT令牌,服务端就没办法废弃它,除非等到它过期。很多应用默认只允许最新登录的一个客户端可以正常使用,不允许多端登录,JWT就没办法做到了。因为颁发了新令牌,但是老的令牌还没有过期仍然可以使用。这种情况下,需要服务端增加相应的逻辑。
JWT官方列出了各种语言的库,其中Java的有如下几个:
图 4 JWT官方推荐的Java类库
以 java-jwt为例:
-
引入对应的Maven包
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.10.3</version> </dependency>
-
在登录时,调用create方法得到一个令牌,并返回给客户端
public static String create(){ try { Algorithm algorithm = Algorithm.HMAC256("secret"); String token = JWT.create() .withIssuer("auth0") .withSubject("subject") .withClaim("name","古时的风筝") .withClaim("introduce","英俊潇洒") .sign(algorithm); System.out.println(token); return token; } catch (JWTCreationException exception){ //Invalid Signing configuration / Couldn't convert Claims. throw exception; } }
-
登录成功后,再次发起请求的时候将token放到header或者请求体中,服务端对tokne进行验证
public static Boolean verify(String token){ try { Algorithm algorithm = Algorithm.HMAC256("secret"); JWTVerifier verifier = JWT.require(algorithm) .withIssuer("auth0") .build(); //Reusable verifier instance DecodedJWT jwt = verifier.verify(token); String payload = jwt.getPayload(); String name = jwt.getClaim("name").asString(); String introduce = jwt.getClaim("introduce").asString(); System.out.println(payload); System.out.println(name); System.out.println(introduce); return true; } catch (JWTVerificationException exception){ //Invalid signature/claims return false; } }
-
用create方法生成token,并用verify方法验证
public static void main(String[] args){ String token = create(); Boolean result = verify(token); System.out.println(result); }
得到如下结果:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdWJqZWN0IiwiaW50cm9kdWNlIjoi6Iux5L-K5r2H5rSSIiwiaXNzIjoiYXV0aDAiLCJuYW1lIjoi5Y-k5pe255qE6aOO562dIn0.ooQ1K_XyljjHf34Nv5iJvg1MQgVe6jlphxv4eeFt8pA eyJzdWIiOiJzdWJqZWN0IiwiaW50cm9kdWNlIjoi6Iux5L-K5r2H5rSSIiwiaXNzIjoiYXV0aDAiLCJuYW1lIjoi5Y-k5pe255qE6aOO562dIn0 古时的风筝 英俊潇洒 true
使用create方法创建JWT串可以通过验证
而如果我将 JWT 串中的载荷部分,两个点号中间的部分修改一下,然后再调用 verify 方法验证,会出现 JWTVerificationException异常,不能通过验证。



