JWT
JWT(JSON Web Token)实现无状态用户认证/鉴权通常分为以下几个步骤
- 引入依赖(使用官方推荐的
jjwt库) - 编写JWT工具类(生成Token、解析Token、验证Token)
- 在登录接口中生成Token返回客户端
- 自定义
package com.example.demo.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
public class JwtUtil {
private static final String SECRET = "your-256-bit-secret-key-here-make-it-long-enough!!";
// token过期时间
private static final long EXPIRATION = 1000 * 60 * 60 * 24; // 24小时
private static SecretKey getKey() {
return Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
}
/** 生成 JWT */
public static String generate(Map<String, Object> claims) {
return Jwts.builder() // 开始构建JWT
.claims(claims)
// 设置过期时间 currentTimeMillis -> 2026-08-14 17:30
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
// 数字签名 Header.Payload.Signature
.signWith(getKey())
// 生成jwt
.compact();
}
/** 解析 JWT,失败返回 null */
public static Claims validate(String token) {
try {
return Jwts.parser()
// 用密钥验证签名
.verifyWith(getKey())
// 构建parser
.build()
// 解析jwt
.parseSignedClaims(token)
// 得到Claims
.getPayload();
} catch (Exception e) {
return null;
}
}
}在登录业务里,
@PostMapping("/login")
public String login(LoginDTO dto) {
// 1. 查询用户
User user = userService.login(dto);
// 2. 准备 JWT 数据
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("username", user.getUsername());
// 3. 生成 Token
return JwtUtil.generate(claims);
}前端就会得到,eyJhbGciOiJIUzI1NiJ9...
以后请求都带上
GET /user/info
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...后端解析Token,得到Claims,从而获取用户信息
String token = ...;
Claims claims = JwtUtil.validate(token);
if (claims == null) {
// 未登录 / Token 无效
}
// 然后就能获取信息
Long userId = claims.get("userId", Long.class);JWT 结构
Header.Payload.SignatureHeader
头部,描述token类型和签名算法
{
"alg": "HS256",
"typ": "JWT"
}Payload
载荷,存放claims(声明),也就是实际信息
{
"sub": "123",
"name": "Alice",
"role": "admin",
"iat": 1700000000,
"exp": 1700003600
}常见标准claim
iss:签发者sub:主体,通常是用户 IDaud:接收方exp:过期时间nbf:生效时间iat:签发时间jti:JWT 唯一 ID
Signature
签名,用来防篡改。比如HS256
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)服务端收到JWT后重新计算签名,如果对不上,说明token被改过
注意
其中Payload通常只是base64编码,不是加密,所以
不要往jwt里面放密码,银行卡号,身份证号等敏感信息
优点
- 无状态:服务端不用保存session,适合分布式和微服务
- 自包含:用户信息在token,减少查库
- 跨域/跨服务方便:标准格式,前后端、移动端、第三方都能用
缺点
- 无法立即撤销
- Payload可读
- 体积比session id大:每次请求都带,可能浪费带宽
- 密钥泄露可以伪造任意token
- 过期和刷新逻辑复杂
与session对比,详情见login
登录流程
- 先看拦截器,
WebConfig.java:
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**") // 所有 /api 开头的接口都要先登录
.excludePathPatterns("/api/auth/**"); // 注册和登录本身当然不能要求先登录登录和注册接口必须排除在外
参数绑定和校验。在
AuthController.java的login方法,@RequestBody让 Jackson 把 JSON 变成 LoginRequest.java 对象,@Valid触发@NotBlank校验。查数据库。
javaUser user = userMapper.selectOne( Wrappers.<User>lambdaQuery().eq(User::getUsername, request.username()));对比密码。
javaif (user == null || !passwordEncoder.matches(request.password(), user.getPassword())) { throw new BusinessException(401, "用户名或密码错误"); }签发token。
javaString token = jwtUtil.generate(user.getId(), user.getUsername());JwtUtil.java 里把用户 id 塞进
sub,加了签发时间和过期时间,然后用密钥签名。产出的字符串是三段:javaeyJhbGciOiJIUzM4NCJ9 . eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJhbGljZSIsImlhdCI6MTc5MDA5MDIyMSwiZXhwIjoxNzkwMDk3NDIxfQ . DYrwr04nHmkLBYx2DqHX6b6K9psaqBskWDIemhc-YSXSAeb52s0snMuN8t1HNGWo header payload(Base64 编码,谁都能解开看) signature把中间那段拿去 Base64 解码,你会直接看到
{"sub":"1","username":"alice","iat":...,"exp":...}—— 你可以自己复制到 jwt.io 试。它不是加密,是编码。所以 token 里绝对不能放密码、手机号这类东西。它的安全性全来自第三段签名:客户端改了 payload,签名就对不上。返回token。
请求
拦截器不放行。
javaString header = request.getHeader(HttpHeaders.AUTHORIZATION); // "Bearer eyJhbGci..." if (header == null || !header.startsWith(BEARER_PREFIX)) { writeUnauthorized(response, "缺少登录凭证"); return false; } String token = header.substring(BEARER_PREFIX.length()); // 切掉 "Bearer " 前缀没带请求头 → 直接写回 401 并
return false。注意return false的含义是"就此打住",Controller 根本不会被调用,方法栈直接结束。验证token。
javaClaims claims = jwtUtil.parse(token);Jwts.parser().verifyWith(key)做了三件事:用同一个密钥把 header.payload 重新算一遍 HMAC,和 token 第三段比对,顺便检查exp过期没有。任何一项不过关就抛JwtException,被 catch 住后同样走 401。这就是为什么我测试时改签名、删签名、改 payload 全部返回 401——服务端的密钥在前端拿不到,算不出正确的签名。
把身份挂到当前线程
javaUserContext.setUserId(Long.valueOf(claims.getSubject())); // sub = "1" → 1L return true; // 放行UserContext.java 是个 ThreadLocal。为什么要它?因为接下来 Controller 的方法签名是固定的
me(),没有 userId 参数。总不能让拦截器把 id "传"给 Controller——ThreadLocal 相当于给当前线程挂了个随身便签,同一次请求里的任何代码都能读到。Controller取身份
java@GetMapping("/me") public Result<UserVO> me() { User user = userService.getById(UserContext.getUserId()); return Result.ok(UserVO.from(user)); }这个方法的签名里没有任何"用户 id"参数,这是整段流程里最重要的设计。 用户身份只能由服务端从 token 解出来,客户端无法通过传参伪装成别人。如果你把接口写成
GET /api/user/me?userId=1,那谁也拦不住别人把 1 改成 2 去看别人的资料——这是新手项目里最高频的越权漏洞。返回时用 UserVO.java 而不是直接返回
User实体,因为实体上带着password字段(哪怕是哈希)。永远不要把实体直接抛给前端。请求结束,必须清理。
java@Override public void afterCompletion(...) { UserContext.clear(); }这一步千万不能省。 Tomcat 用线程池,一个线程会依次处理成千上万个请求。如果不清,下一个请求调用
UserContext.getUserId()时会读出上一个用户的 id——这是最隐蔽也最严重的一类 bug,因为它在开发环境(请求少、线程不复用)几乎测不出来,一上线就出事。