令牌机制
为什么不能使用 Session 实现登录功能?
传统思路:
- 登录页面把用户名密码提交给服务器。
- 服务器端验证用户名密码是否正确,并返回校验结果给前端。
- 如果密码正确,则在服务器端创建
Session
。通过 Cookie 把sessionId
返回给浏览器。
- 问题:
集群环境下无法直接使用 Session。
原因分析:
右边的三台服务器为一个
集群
,集群中的每一个服务器称为集群的节点
。我们开发的项目,在企业中很少会部署在一台机器上,容易发生单点故障(单点故障:一旦这台服务器挂了,整个应用都没法访问了)。
所以通常情况下,
一个 Web 应用会部署在多个服务器上
,通过Nginx
等进行负载均衡
。此时,来自一个用户的请求
就会被分发到不同的服务器上
。
回忆 Session 机制:
假设我们使用 Session
进行会话跟踪
,我们来思考如下场景:
- 用户登录:用户登录请求,经过
负载均衡
,把请求转给了第一台服务器,第一台服务器进行账号密码验证,验证成功后,把Session
存在了第一台服务器上。 - 查询操作:用户登录成功之后,携带
Cookie
(里面包含sessionId
)继续执行查询操作,比如查询博客列表。此时请求转发到了第二台机器,第二台机器会先进行权限验证操作(通过 sessionId 验证用户是否登录)
,此时第二台机器上没有该用户的 Session
,就会出现问题,提示用户登录,这是用户不能忍受的。
Session 存储在内存中(耗费服务器资源),服务重启,Session 丢失
,接下来我们介绍第三种方案:令牌技术。
令牌技术
令牌的运行机制
令牌其实就是用户身份的标识,名称起得很高端,其实本质就是一个字符串
。
比如我们出行在外,会带着自己的身份证,需要验证身份时,就掏出身份证。
- 身份证不能伪造,可以辨别真假。
服务器具备生成令牌和验证令牌的能力。
我们使用令牌技术,继续思考上述场景:
用户登录:用户登录请求,经过
负载均衡
,把请求
转给了第一台服务器
,第一台服务器进行账号密码验证
,验证成功后,生成一个令牌,并返回给客户端。
客户端收到令牌之后,把令牌存储起来
。可以存储在Cookie
中,也可以存储在其他的存储空间(比如localStorage
)。查询操作:
用户登录成功之后,携带令牌继续执行查询操作
,比如查询博客列表。此时请求
转发到了第二台机器
,第二台机器会先进行权限验证操作
。服务器验证令牌是否有效,如果有效,就说明用户已经执行了登录操作;如果令牌是无效的,就说明用户之前未执行登录操作
。
令牌的优缺点
优点:
解决了集群环境下的认证问题
(服务重启,Session 丢失)。令牌无需在服务器端存储
,减轻服务器的存储压力,而Session 存储在内存中
,会耗费服务器资源。
缺点:
- 需要自己实现(包括令牌的生成、令牌的传递、令牌的校验)。
当前企业开发中,解决会话跟踪使用最多的方案就是令牌技术
。
JWT 令牌介绍
JWT 全称:JSON Web Token
描述:JSON Web Token(JWT)
是一个开放的行业标准(RFC 7519),用于客户端和服务器之间传递安全可靠的信息
。其本质是一个 token,是一种紧凑的 URL 安全方法
。
JWT 令牌组成
JWT 由三部分组成,每部分中间使用点(.)分隔
,比如:aaaaa.bbbbb.cccc
Header(头部)
:- 头部包括
令牌的类型(即 JWT)
及使用的哈希算法
(如HMAC SHA256
或RSA
)。
- 头部包括
Payload(负载)
:负载部分是存放有效信息的地方,里面是一些自定义内容。
比如:
{ "userId":"666", "userName":"kunkun" }
- 也可以存在 JWT 提供的内置字段,比如
exp
(过期时间戳)等。此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。
Signature(签名)
:- 此部分用于
防止 JWT 内容被篡改
,确保安全性。防止被篡改,而不是防止被解析
。 JWT 之所以安全,就是因为最后的签名。JWT 当中任何一个字符被篡改,整个令牌都会校验失败。
- 就好比我们的身份证,之所以能标识一个人的身份,是因为它不能被篡改,而不是因为内容加密(任何人可以看到身份证的信息,JWT 也是)。
- 此部分用于
对上述部分的信息,使用 Base64Url 进行编码,合并在一起就是 JWT 令牌
。
Base64 是编码方式,而不是加密方式。
引入 JWT 令牌依赖
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
引入依赖后,接下来,我们使用 Jar 包中提供的 API 来完成 JWT 令牌的生成和校验
生成 JWT 令牌
public class JwtTest {
@Test
void genToken(){
Map<String, Object> claims = new HashMap<>();
claims.put("id", 666);
claims.put("name", "kunkun");
// 这个 Map 表示存放到 token 中的信息, 用户登录 id 为 666, 用户名为 kunkun
// 设置 Jwts 令牌的载荷
String compact = Jwts.builder().setClaims(claims).compact();
// setClaims() 允许 Map 作为参数
// compact() 可以将令牌转换成可以被打印的信息
System.out.println(compact);
}
}
运行测试方法,查看运行结果:
将生成的令牌进行解码:
接下来,我们要为该令牌设置签名
import java.security.Key; // key 要导入的包
public class JwtTest {
@Test
void genToken(){
// Keys.hmacShaKeyFor() 用于生成安全密钥, 在这里是以 "123455556" 这个字符串的 getBytes() 进行对密钥的生成
Key key = Keys.hmacShaKeyFor("123455556".getBytes(StandardCharsets.UTF_8)); // 这里设置的密钥长度没有达到要求, 运行程序会在这里报错
Map<String, Object> claims = new HashMap<>();
claims.put("id", 666);
claims.put("name", "kunkun");
String compact = Jwts.builder()
.setClaims(claims)
.signWith(key)
.compact();
// signWith() 用于设置签名, 需要传一个 Key 类型的参数
System.out.println(compact);
}
}
Keys.hmacShaKeyFor(byte[] keyMaterial)
方法的作用是根据提供的字节数组
生成一个适用于HMAC-SHA 签名算法
(如 HS256、HS384、HS512)的密钥对象
(Key
)。这个方法是JWT 工具类
中用于生成密钥
的常用方法之一。
运行测试方法 genToken() ,观察运行结果:
报错中提到,可以考虑使用 secretKeyFor(SignatureAlgorithm)
方法来创建一个 Key
,接下来,我就来演示该方法如何使用:
public class JwtTest {
@Test
void genToken(){
// Key key = Keys.hmacShaKeyFor("123455556".getBytes(StandardCharsets.UTF_8));
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// 使用 secretKeyFor(SignatureAlgorithm) 方法来创建一个 Key, 该方法每次生成的 Key 都是随机的
Map<String, Object> claims = new HashMap<>();
claims.put("id", 666);
claims.put("name", "kunkun");
String compact = Jwts.builder()
.setClaims(claims)
.signWith(key)
.compact();
System.out.println(compact);
}
}
运行测试方法,观察运行结果:
输出的内容,就是 JWT 令牌。因此,我们服务端生成令牌的方法就写好了;
固定令牌签名部分
每次调用 secretKeyFor()
方法,生成的密钥是随机的:
// 第一次调用生成的令牌
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.iDb7jpsCG-EBSVNz6Ee4kPoRA5olz3ML6fZJ4ZddVMM
// 第二次调用生成的令牌
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.upxovdUjdxF4nXLFeG3rSTRH-Gkw2foz2CICN3kzWlE
观察到两次生成的令牌签名部分不一致,这表明每次调用 secretKeyFor()
方法时生成的密钥是随机的。
为了确保服务端的安全性和一致性
,我们使用 secretKeyFor()
方法生成一个固定的密钥,并将其作为生成令牌的签名部分。
密钥(Key):是用于生成和验证 JWT 签名的基础数据。
在代码中,Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
生成的是一个密钥,用于签名算法。签名信息:是 JWT 的一部分,由密钥和签名算法生成的哈希值,用于验证 JWT 的完整性和真实性。
前面是根据 secretKeyFor()
方法生成的密钥为基础,创建令牌。如果希望每次生成的 JWT 签名一致,需要使用固定的密钥
。
因此,我们需要先获取公共令牌的签名信息
。
@Test
void getKey() {
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
// 使用 HS256 算法生成一个随机的密钥
String encodedKey = Encoders.BASE64.encode(key.getEncoded());
// 将密钥的字节数组转换为 Base64 编码的字符串
// getEncoded():获取密钥的字节数组表示
// Encoders.BASE64.encode():将字节数组转换为 Base64 编码的字符串,便于存储和传输
System.out.println(encodedKey);
// 打印 Base64 编码的密钥字符串
}
程序运行结果:
sYAN5HvB8HQRzX1QTEFRhseSsgXIDJsggPhC1gNLa0Y=
Process finished with exit code 0
因此,我们就获取到了公共令牌的密钥;
使用hmacShaKeyFor()
,根据刚刚生成的密钥字符串来创建密钥对象:
@Test
void genToken(){
// Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
Key key = Keys.hmacShaKeyFor("sYAN5HvB8HQRzX1QTEFRhseSsgXIDJsggPhC1gNLa0Y=".getBytes(StandardCharsets.UTF_8));
// 使用刚刚获取到的密钥字符串, 来创建密钥对象
Map<String, Object> claims = new HashMap<>();
claims.put("id", 666);
claims.put("name", "kunkun");
String compact = Jwts.builder()
.setClaims(claims)
.signWith(key)
.compact();
// signWith(key) 表示设置令牌签名, 会根据传入的密钥和密钥算法, 转化为签名部分
System.out.println(compact);
}
运行两次方法,输出的内容,就是 JWT 令牌,我们查看一下,生成令牌对应的签名是否相同:
第一次调用:
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.kSCNNN-_b3aPZRkCaTiAlZ1Jqt5lizfxW0HtPNdcP-Y
第二次调用:
eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoia3Vua3VuIiwiaWQiOjY2Nn0.kSCNNN-_b3aPZRkCaTiAlZ1Jqt5lizfxW0HtPNdcP-Y
此时,两次调用 genToken()
方法生成的令牌,其中签名的部分就被固定好了’
通过点(.
)对三个部分进行分割,我们把生成的令牌通过官网进行解析,就可以看到我们存储的信息了。
HEADER
部分:可以看到使用的算法为HS256
。PAYLOAD
部分:是我们自定义的内容
,exp
表示过期时间
。VERIFY SIGNATURE
部分:是经过签名算法
计算出来的,所以不会解析
。
校验 JWT 令牌
服务端完成了令牌的生成,我们需要根据令牌,来校验令牌的合法性(以防客户端伪造)。
令牌解析
public class JwtTest {
@Test
void genToken(){
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
Map<String, Object> claims = new HashMap<>();
claims.put("id", 666);
claims.put("name", "kunkun");
String compact = Jwts.builder()
.setClaims(claims)
.signWith(key)
.compact();
System.out.println(compact);
// 创建解析器,设置签名密钥
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
// 解析 token 并打印解析结果
System.out.println(build.parse(compact).getBody());
// parse() 的参数是一个字符串, 表示要解析的令牌
// getBody() 表示获取解析结果, 进而可以打印除解析的结果
}
}
测试方法运行结果:
令牌解析后,我们可以看到里面存储的信息
。如果在解析的过程中没有报错,就说明解析成功了。- 令牌解析时,也会进行
时间有效性的校验
。如果令牌过期了,解析也会失败。
令牌是可以被解析的,那么令牌是否可以被修改呢?
public class JwtTest {
@Test
void genToken(){
// .....
JwtParser build = Jwts.parserBuilder().setSigningKey(key).build();
// 原来的令牌也是字符串, 现在解析(原来的令牌+多余字符串)
System.out.println(build.parse(compact + "kunkun666").getBody());
}
}
运行测试方法,程序运行结果:
因此,修改令牌中的任何一个字符,都会校验失败,所以令牌无法篡改。