一、引入
回想一下传统的Web应用是怎么做登录认证的:
用户输入用户名密码登录成功,服务器在session里记下"用户已登录",然后把sessionId通过cookie返回给浏览器。浏览器下次请求时带上这个cookie,服务器就能识别出用户身份。
这套方案在单机时代没问题,但到了分布式时代就尴尬了:
-
用户登录了A服务器,下次请求被负载均衡到B服务器,B服务器不认识这个session
-
解决方法是session共享,要么存数据库,要么用Redis,但多了一次网络开销
-
如果做移动端App,cookie用起来更别扭
那有没有一种办法,让服务器不保存登录状态,用户自己把身份信息带过来,服务器验证一下就行?这就是JWT(JSON Web Token)的核心理念。
二、JWT是什么?
JWT全称是JSON Web Token,它本质上是一个字符串,由三部分组成,用点(.)连接:
这个字符串就是用户的"身份证"。用户登录成功后,服务器生成这个令牌返回给客户端。客户端以后每次请求都带着它,服务器只要验证令牌合法,就知道用户是谁。
整个过程中,服务器不需要保存任何session数据,所以特别适合分布式系统和微服务架构。
三、JWT的三段结构
把JWT拆开看,每一段都有自己的作用:
3.1 Header(头部)
Header通常由两部分组成:
-
typ:令牌类型,固定是"JWT"
-
alg:签名算法,比如HS256、RS256
json
{
"alg": "HS256",
"typ": "JWT"
}
这个JSON对象经过Base64Url编码,就成了JWT的第一段。
打个比方,Header就像是身份证上的"中华人民共和国"这几个字,告诉别人这是个什么证件。
3.2 Payload(载荷)
Payload里放的是实际需要传递的数据,比如用户ID、用户名、过期时间等。
json
{
"sub": "1234567890",
"name": "张三",
"exp": 1516239022,
"iss": "auth-server"
}
这里有几个预定义的字段(但都不是强制的):
-
sub:主题(通常是用户ID) -
exp:过期时间 -
iat:签发时间 -
iss:签发者 -
aud:接收方
你还可以加任意自定义字段,比如role、avatar等。
这个JSON同样经过Base64Url编码,成了JWT的第二段。
注意 :Payload只是Base64编码,不是加密!里面的内容任何人只要解码就能看到。所以千万不要放密码、手机号等敏感信息。
3.3 Signature(签名)
第三段是签名,它的作用是防止数据被篡改。
签名的生成过程:
-
把编码后的Header和Payload用点连接:
header.payload -
用Header里声明的算法(比如HS256),加上一个只有服务器知道的密钥,对上面的字符串进行加密
-
得到的结果就是签名
java
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
服务器收到JWT后,会重新计算一遍签名。如果计算出来的签名和JWT自带的签名一致,说明数据没有被篡改;如果不一致,说明这个令牌被改过了,直接拒绝。
四、JWT的工作流程
用一个具体的例子走一遍流程:
4.1 用户登录
java
@PostMapping("/login")
public Result login(@RequestBody LoginRequest request) {
// 1. 验证用户名密码
User user = userService.login(request.getUsername(), request.getPassword());
// 2. 生成JWT
String jwt = JwtUtil.createToken(user.getId(), user.getUsername());
// 3. 返回给客户端
return Result.success(jwt);
}
4.2 服务器生成JWT
服务器生成JWT的步骤:
java
public class JwtUtil {
// 密钥(应该配置在配置文件里,不要写死在代码里)
private static final String SECRET = "mySecretKey";
// 过期时间(1小时)
private static final long EXPIRE_TIME = 60 * 60 * 1000;
public static String createToken(Long userId, String username) {
// 1. 设置过期时间
Date expireDate = new Date(System.currentTimeMillis() + EXPIRE_TIME);
// 2. 创建JWT Builder
JwtBuilder builder = Jwts.builder()
// 设置Header(算法)
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "JWT")
// 设置Payload
.setSubject(String.valueOf(userId))
.claim("username", username)
.setIssuedAt(new Date())
.setExpiration(expireDate)
// 签名
.signWith(SignatureAlgorithm.HS256, SECRET);
// 3. 生成token
return builder.compact();
}
}
生成的JWT大概是这个样子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOiLlvKDkuIkiLCJleHAiOjE2MTUyMzkwMjIsImlhdCI6MTYxNTIzNTQyMn0.Lw1qQ_RqFbPJ4H5X7Y8Z9a0b1c2d3e4f5g6h7i8j9k
4.3 客户端存储JWT
客户端收到JWT后,需要保存起来。不同端有不同的存法:
-
Web端:存在localStorage或sessionStorage
-
App端:存在SharedPreferences或Keychain
-
小程序:存在storage
以后每次请求,客户端都要在HTTP头里带上JWT:
javascript
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
4.4 服务器验证JWT
服务器收到请求后,从Header里取出JWT,验证合法性:
java
@Component
public class JwtInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 从Header获取token
String token = request.getHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new RuntimeException("未登录");
}
token = token.substring(7); // 去掉"Bearer "
// 2. 验证token
try {
Claims claims = JwtUtil.parseToken(token);
// 把用户信息存入请求,后续方法可以直接取用
request.setAttribute("userId", claims.getSubject());
request.setAttribute("username", claims.get("username"));
return true;
} catch (Exception e) {
throw new RuntimeException("token无效或已过期");
}
}
}
验证的具体实现:
java
public static Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET) // 用相同的密钥验证签名
.parseClaimsJws(token)
.getBody();
}
验证过程做了三件事:
-
签名验证:用密钥重新计算签名,对比是否一致
-
过期验证:检查exp字段是否晚于当前时间
-
完整性验证:解码出来的数据是否完整
如果验证通过,说明这个JWT确实是我们签发的,而且没有被篡改过。
4.5 后续处理
Controller里可以直接取用JWT里的用户信息:
java
@GetMapping("/user/info")
public Result getUserInfo(HttpServletRequest request) {
// 从拦截器设置进去的属性里拿
Long userId = (Long) request.getAttribute("userId");
String username = (String) request.getAttribute("username");
// 或者直接注入
return Result.success(userService.findById(userId));
}
五、JWT的核心优势
对比传统session方案,JWT的优势很明显:
| 维度 | Session方案 | JWT方案 |
|---|---|---|
| 存储位置 | 服务器内存/Redis | 客户端自己保存 |
| 扩展性 | 需要session共享,麻烦 | 天生支持分布式 |
| 跨域 | 需要配置CORS | 直接支持 |
| 移动端 | cookie用起来别扭 | Header统一 |
| 性能 | 每次都要查session | 只需计算签名 |
六、JWT的注意事项
6.1 安全性
JWT的Payload是明文的(只是Base64编码)。打开浏览器的开发者工具就能看到:
javascript
// 在控制台输入
atob("eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOiLlvKDkuIkiLCJleHAiOjE2MTUyMzkwMjJ9")
所以绝对不能放密码、身份证号、手机号等敏感信息。如果有需要,可以再对Payload加密,但这会增加复杂度。
6.2 长度
JWT比sessionId长得多。一个普通的JWT可能有两三百个字符,如果放在URL里可能太长(浏览器URL长度有限制),所以一般放Header里。
6.3 登出
这是JWT最大的痛点:因为服务器不保存状态,所以无法主动让一个token失效。你点退出登录,服务器只能把客户端的token删掉,但如果别人拿着之前的token来访问,服务器依然认为是有效的。
解决方案:
-
设置较短的过期时间,配合refresh token机制
-
维护一个黑名单(但又回到共享状态的问题了)
6.4 续签
token过期了怎么办?不能让用户每过一小时就重新登录一次。
常见做法是refresh token机制:同时返回两个token
-
access token:有效期短(比如1小时),用于正常访问
-
refresh token:有效期长(比如7天),专门用来换取新的access token
客户端发现access token过期了,就拿refresh token去换新的,用户体验不受影响。
七、总结
JWT用一句话总结就是:把用户信息加密成一段字符串,交给客户端保存,服务器通过验证签名来确认身份。
它的核心三要素:
-
Header:声明类型和算法
-
Payload:存放实际数据(不能放敏感信息)
-
Signature:防篡改的签名
整个流程:
-
用户登录成功,服务器生成JWT返回
-
客户端保存JWT,每次请求都带上
-
服务器验证JWT,通过就放行,不通过就拒绝
相比传统session方案,JWT更适合分布式系统、移动端App、前后端分离的项目。虽然它也有一些局限(如无法主动登出、长度较大),但只要合理设计过期时间,配合refresh token机制,足以应对大多数场景。
以上是我对JWT的学习理解,如果有不准确的地方,欢迎指正。