1. 引入:解决跨域认证问题
互联网服务离不开用户认证。一般流程是下面这样。
- 用户向服务器发送用户名和密码。
- 服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。
- 服务器向用户返回一个 session_id,写入用户的 Cookie。
- 用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。
- 服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。
这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。
举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?
一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。
另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。
2.JWT概述
2.1.概念
JSON Web Token,通过数字签名的方式,以JSON字符串为载体,在不同的服务终端之间进行更安全的信息传输
2.2.组成
JWT由三个部分组成,用.
拼接
- Header(头部)
里面包含两个信息,分别是类型(typ),另一个是算法名称(alg)
{
"typ": "JWT",
"alg": "HS256"
}
通过base64进行编码,便得到token中的第一部分
- Payload(载荷)
里面存储这个令牌的有效信息,
包括标准注册的(签发时间、过期时间等)和自定义的(下方的stuNo、stuName、admin)
{
"stuNo": "3119******",
"stuName": "xyx",
"admin": true
}
通过base64进行编码,便得到token中的第二部分
注意:第二部分并未进行加密,因此该部分是可以被直接解码得到其中内容的,因此JWT并不适合存储敏感信息(如密码),只适合存储用户id、用户名等信息
- Signature(签名)
从以下代码可以看出,
原理是将Header、Payload用base64编码后,再用.
拼接(第一部分.第二部分)
再将拼接结果通过Header中声明的算法(HS256)用密钥加密,得到第三部分
var signingKey = "abcdefg1234567"
var encodedString =
base64UrlEncode(header) + "." + base64UrlEncode(Payload);
var signature = HMACSHA256(encodingString, signingKey);
注意:第三部分是前面两部分根据加密算法(HS256)和"密钥"加密后得到的。因此如果没有"密钥",第三部分就无法被伪造。服务端的验证也正是利用了这个原理来防止用户伪造信息,确保安全性
3.JWT的工作流程
3.1.登录阶段
用户首次登录时,若成功登录,就会将一些附带信息封装成一个JWT字符串,并返回给客户端。
这个JWT字符串里的附带信息一般是:用户id、用户权限、有效期。
下次用户登录的时候,必须把这个令牌也一起带上,否则将被要求重新登录。
3.2.认证阶段
登录阶段时,用户在客户端获取到了自己的JWT字符串
此后前端的每次请求,都要将这个JWT字符串放在请求头的某一位置一并发送过来,后端接受到请求后,解析JWT,验证该JWT是否合法(是否被伪造)、是否过期等
3.3.补充内容
3.3.1.关于有效期
由于jwt是直接给用户的,只要能验证成功的jwt都可以被视作登录成功,所以,如果不给jwt设置一个过期时间的话,用户只要存着这个jwt,就相当于永远登录了,而这是不安全的,因为如果这个令牌泄露了,那么服务器是没有任何办法阻止该令牌的持有者访问的(因为拿到这个令牌就等于随便冒充你身份访问了),所以往往jwt都会有一个有效期,通常存在于载荷部分,下面是一段生成jwt的java代码:
return JWT.create().withAudience(userId) .withIssuedAt(new Date()) <---- 发行时间 .withExpiresAt(expiresDate) <---- 有效期 .withClaim("sessionId", sessionId) .withClaim("userName", userName) .withClaim("realName", realName) .sign(Algorithm.HMAC256(userId+"HelloLehr"));
在实际的开发中,令牌的有效期往往是越短越安全,因为令牌会频繁变化,即使有某个令牌被别人盗用,也会很快失效。但是有效期短也会导致用户体验不好(总是需要重新登录),所以这时候就会出现另外一种令牌—refresh token刷新令牌。刷新令牌的有效期会很长,只要刷新令牌没有过期,就可以再申请另外一个jwt而无需登录(且这个过程是在用户访问某个接口时自动完成的,用户不会感觉到令牌替换),对于刷新令牌的具体实现这里就不详细讲啦(其实因为我也没深入研究过XD…)
3.3.2.对比session
在传统的session会话机制中,服务器识别用户是通过用户首次访问服务器的时候,给用户一个sessionId,然后把用户对应的会话记录放在服务器这里,以后每次通过sessionId来找到对应的会话记录。这样虽然所有的数据都存在服务器上是安全的,但是对于分布式(一个项目部署到多个服务器中)的应用来说,就需要考虑session共享的问题了,不然同一个用户的sessionId的请求被自动分配到另外一个服务器上就等于失效了
而Jwt不但可以用于登录认证,也把相应的数据返回给了用户(就是载荷里的内容),通过签名来保证数据的真实性,该应用的各个服务器上都有统一的验证方法,只要能通过验证,就说明你的令牌是可信的,我就可以从你的令牌上获取你的信息,知道你是谁了,从而减轻了服务器的压力,而且也对分布式应用更为友好。(毕竟就不用担心服务器session的分布式存储问题了)
3.3.3.如何确保安全
如图,前面已讲到JWT共被分成三部分
- 头部header:只经过base64编码,可伪造
- 载荷payload:只经过base64编码,可伪造
- 签名signature:前面两部分进行拼接,根据密钥按规定的算法(HS256)加密
签名部分由于密钥的存在,用户在不得知密钥的情况下,是无法伪造出一个有效的签名的。正是这个第三部分的存在,确保了JWT的安全性。
4.代码实现
主要参考文章:SpringBoot整合JWT
4.1.JwtUtil.java
在util包中创建JwtUtil.java
调用Jwts.builder()生成一个JWT字符串:
- 设置头部header:
.setHeaderParam(K, V)- 设置载荷payload:
.claim(K, V) 自定义内容
.setExpiration(expireDate) 过期时间- 设置签名signature:
.signWith(SignatureAlgorithm.JS256, signingKey)
package com.eshang.jwt.learning.util;
import io.jsonwebtoken.*;
import java.util.Date;
/**
* @author xyx-Eshang
*/
public class JwtUtil {
/**
* 密钥
*/
private static String signingKey = "abcdefghijklmn";
/**
* 过期的分钟
*/
private static Integer expireMinutes = 60;
public static String createToken(Integer stuNo, Boolean whetherAdmin) {
Date expireDate = new Date(System.currentTimeMillis() + 1000 * 60 * expireMinutes);
return Jwts.builder()
//header
.setHeaderParam("typ", "JWT")
.setHeaderParam("alg", "HS256")
//payload
.claim("stuNo", stuNo)
.claim("admin", whetherAdmin)
.setExpiration(expireDate)
//signature
.signWith(SignatureAlgorithm.HS256, signingKey)
//拼接起来
.compact();
}
public static Claims parseToken(String token) {
JwtParser jwtParser = Jwts.parser();
return jwtParser
.setSigningKey(signingKey)
.parseClaimsJws(token)
.getBody();
}
}
4.2.JwtLearningApplicationTests.java
编写测试类JwtLearningApplicationTests.java
package com.eshang.jwt.learning;
import com.eshang.jwt.learning.util.JwtUtil;
import io.jsonwebtoken.Claims;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class JwtLearningApplicationTests {
@Test
void contextLoads() {
//生成JWT:学号为1,非管理员
String jwt = JwtUtil.createToken(1, false);
//打印这个JWT
System.out.println("===打印这个JWT===" + "\n" + jwt);
//解析
System.out.println("===解析这个JWT===");
Claims claims = JwtUtil.parseToken(jwt);
System.out.println("stuNo:\t" + claims.get("stuNo"));
System.out.println("admin:\t" + claims.get("admin"));
}
}