文章目录
- 引言
- 设计说明
-
- [为什么选 JWT?](#为什么选 JWT?)
- 整体认证流程
- [拦截器与 ThreadLocal 的配合](#拦截器与 ThreadLocal 的配合)
- 原理方案
-
- [JWT 的三段式结构](#JWT 的三段式结构)
- 配置驱动的密钥与过期时间
- 源码解析
-
- [JwtUtil ------ 工具类](#JwtUtil —— 工具类)
- [登录接口签发 Token](#登录接口签发 Token)
- [UserService ------ 登录验证](#UserService —— 登录验证)
- [JwtTokenUserInterceptor ------ 核心拦截器](#JwtTokenUserInterceptor —— 核心拦截器)
- [BaseContext ------ ThreadLocal 用户上下文](#BaseContext —— ThreadLocal 用户上下文)
- 拦截器注册与放行规则
- [业务代码使用 BaseContext](#业务代码使用 BaseContext)
- 注册接口
- 修改密码接口
- 验证结果
-
- 登录测试
- [携带 Token 访问](#携带 Token 访问)
- [无 Token 访问](#无 Token 访问)
- [Token 过期](#Token 过期)
- [Token 篡改](#Token 篡改)
- 优化建议
- 小结

引言
任何面向用户的系统,第一道工程考题永远是:怎么知道"你是你"?这个问题在 RAG 知识库里尤其关键------对话记忆需要按用户隔离、敏感操作需要审计、不同角色访问的知识库范围也不同。
本篇将从 JWT 的原理讲起,完整拆解项目中的认证体系:从登录签发、拦截器校验、ThreadLocal 上下文,到放行规则与异常处理。
设计说明
为什么选 JWT?
传统的 Session-Cookie 方案在分布式系统中需要共享 Session 存储(Redis、DB),实现复杂。JWT(JSON Web Token)是一种无状态的认证方案,token 本身携带用户信息和签名,服务端不用存储任何会话数据。
JWT 的核心优势:
- 无状态:服务端不用维护 session,天然适合微服务和水平扩展
- 跨域友好:Token 在 Header 中传递,不依赖 Cookie
- 自包含:Token 内可以携带用户 ID、角色、过期时间等信息
- 签名校验:服务端用密钥验证签名,防伪造
整体认证流程
Java
[1] 用户登录
POST /user/login {userName, password}
↓
UserService.login() 验证用户名 + MD5 密码
↓
JwtUtil.createJWT() 生成 Token
↓
返回 { id, userName, name, token }
[2] 后续请求
GET /api/v1/xxx
Header: authorization: Bearer xxx.yyy.zzz
↓
JwtTokenUserInterceptor.preHandle()
↓
JwtUtil.parseJWT() 验证签名 + 解析 claims
↓
BaseContext.setCurrentId(userId) 写入 ThreadLocal
↓
放行 Controller
↓
业务代码用 BaseContext.getCurrentId() 获取当前用户
拦截器与 ThreadLocal 的配合
JWT 拦截器解析出用户 ID 后,用 ThreadLocal 存储。这样后续业务代码(Service、Mapper)无需层层传递,直接调用 BaseContext.getCurrentId() 就能拿到当前操作人。
ThreadLocal 的好处是:
- 单次请求内全局可用
- 线程安全(每个请求一个线程,互不干扰)
- 业务代码完全感知不到 HTTP 层
原理方案
JWT 的三段式结构
Java
xxxxx.yyyyy.zzzzz
↓ ↓ ↓
Header.Payload.Signature
- Header :算法和类型,如
{"alg":"HS256","typ":"JWT"} - Payload :业务声明,如
{"userId":1,"exp":1735689600} - Signature:用密钥对前两段签名,防篡改
配置驱动的密钥与过期时间
项目用 JwtProperties 集中管理 JWT 配置:
java
@Component
@ConfigurationProperties(prefix = "artisan.jwt")
@Data
public class JwtProperties {
// 管理端
private String adminSecretKey;
private long adminTtl;
private String adminTokenName;
// 用户端
private String userSecretKey;
private long userTtl;
private String userTokenName;
}
对应 yml 配置:
yaml
artisan:
jwt:
admin-secret-key: artisan_jwt_secret_key_for_admin_access_256_bits_required
admin-ttl: 7200000 # 2小时
admin-token-name: token
user-secret-key: artisan_jwt_secret_key_for_user_access_256_bits_required
user-ttl: 7200000
user-token-name: authorization
设计上区分了管理端和用户端的密钥,方便后续做角色隔离。
源码解析
JwtUtil ------ 工具类
java
public class JwtUtil {
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);
JwtBuilder builder = Jwts.builder()
.setClaims(claims)
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
.setExpiration(exp);
return builder.compact();
}
public static Claims parseJWT(String secretKey, String token) {
return Jwts.parser()
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
.parseClaimsJws(token)
.getBody();
}
}
两个核心方法:
- createJWT:用 HS256 算法签名,把 claims(业务数据)+ 过期时间打包成 Token
- parseJWT :解析 Token,签名不匹配会抛
SignatureException,过期会抛ExpiredJwtException
登录接口签发 Token
java
@PostMapping("/login")
@Operation(summary = "login", description = "登录")
public BaseResponse login(
@RequestParam(value = "userName", defaultValue = "admin") String userName,
@RequestParam(value = "password", defaultValue = "123456") String password)
throws AccountLockedException, AccountNotFoundException {
log.info("登录:{}", userName + ":" + password);
User user = userService.login(userName, password);
// 登录成功后,生成 JWT 令牌
Map<String, Object> claims = new HashMap<>();
claims.put(JwtClaimsConstant.USER_ID, user.getId());
String token = JwtUtil.createJWT(
jwtProperties.getUserSecretKey(),
jwtProperties.getUserTtl(),
claims);
UserLoginVO userLoginVO = UserLoginVO.builder()
.id(user.getId())
.userName(user.getUserName())
.name(user.getName())
.token(token)
.build();
return ResultUtils.success(userLoginVO);
}
关键点:
claims中只放 userId,不放敏感信息(密码、手机号等)- 密钥和 TTL 从配置读取,不硬编码
- 返回结构
UserLoginVO包含基础用户信息 + token
UserService ------ 登录验证
java
@Override
public User login(String userName, String password) {
User user = userMapper.getByUsername(userName);
if (user == null) {
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}
// MD5 加密后比对
password = DigestUtils.md5DigestAsHex(password.getBytes());
if (!password.equals(user.getPassword())) {
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}
if (user.getStatus() == StatusConstant.DISABLE) {
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}
return user;
}
安全要点:
- 密码使用 MD5 哈希存储,前端传明文,后端加密后比对
- 三种异常分别处理:用户不存在、密码错误、账号被禁用
- 密码错误的异常信息不泄露用户是否存在(防枚举)
⚠️ 生产环境建议升级到 BCrypt 或 Argon2,MD5 已不再被认为是安全的密码哈希算法
JwtTokenUserInterceptor ------ 核心拦截器
java
@Component
@Slf4j
public class JwtTokenUserInterceptor implements HandlerInterceptor {
@Autowired
private JwtProperties jwtProperties;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 不是 Controller 方法直接放行(静态资源等)
if (!(handler instanceof HandlerMethod)) {
return true;
}
// 1. 从请求头获取令牌
String token = request.getHeader(jwtProperties.getUserTokenName());
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7); // 去除 "Bearer " 前缀
}
// 2. 校验令牌
try {
log.info("jwt校验:{}", token);
Claims claims = JwtUtil.parseJWT(jwtProperties.getUserSecretKey(), token);
Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
log.info("当前用户的id:{}", userId);
BaseContext.setCurrentId(userId);
return true;
}
catch (ExpiredJwtException ex) {
response.setStatus(401);
return false;
}
catch (Exception ex) {
response.setStatus(401);
return false;
}
}
}
实现细节:
- 支持 Bearer 前缀 :兼容标准的
Authorization: Bearer xxx格式 - 静态资源放行 :
!(handler instanceof HandlerMethod)判断,让 Swagger 等资源不被拦截 - 异常分级处理:过期和其他异常都返回 401,但日志可以区分原因
- 写入 ThreadLocal:解析成功后立即把 userId 存入 BaseContext
BaseContext ------ ThreadLocal 用户上下文
java
public class BaseContext {
public static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void setCurrentId(Long id) {
threadLocal.set(id);
}
public static Long getCurrentId() {
return threadLocal.get();
}
public static void removeCurrentId() {
threadLocal.remove();
}
}
⚠️ 注意 ThreadLocal 的内存泄漏风险:
容器线程池会复用线程,如果不在请求结束后调用 removeCurrentId(),userId 会在下次请求中残留。建议增加 afterCompletion 钩子:
java
@Override
public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
Object handler, Exception ex) {
BaseContext.removeCurrentId();
}
拦截器注册与放行规则
java
@Configuration
public class ApplicationConfig implements WebMvcConfigurer {
@Autowired
private JwtTokenUserInterceptor jwtTokenUserInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtTokenUserInterceptor)
.addPathPatterns("/**") // 拦截所有
.excludePathPatterns(ApplicationConstant.API_VERSION + "/user/login")
.excludePathPatterns(ApplicationConstant.API_VERSION + "/user/register")
.excludePathPatterns("/doc.html", "/webjars/**",
"/swagger-resources/**", "/v3/api-docs/**");
}
}
放行规则:
- 登录、注册接口:未登录用户必须能访问
- Swagger 相关:开发文档需要免认证访问
- 其他所有接口:必须携带有效 Token
业务代码使用 BaseContext
在 RAG 对话接口中:
java
@PostMapping(value = "/rag")
@Loggable
public Flux<String> generatePost(@RequestParam String message) {
Long userId = BaseContext.getCurrentId(); // 直接拿当前用户 ID
return chatClient.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
.stream()
.content();
}
userId 作为 CONVERSATION_ID 实现了对话记忆的用户隔离。
注册接口
java
@PostMapping("/register")
@Operation(summary = "register", description = "注册")
public BaseResponse register(@RequestBody User user) {
log.info("注册:{}", user.toString());
if (userService.getByUsername(user.getUserName())) {
return ResultUtils.error("用户名已存在");
} else {
userService.register(user);
}
return ResultUtils.success("注册成功");
}
java
@Override
public void register(User user) {
User userResult = new User();
BeanUtils.copyProperties(user, userResult);
userResult.setStatus(StatusConstant.ENABLE);
userResult.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes()));
userResult.setCreateTime(LocalDate.now());
userResult.setUpdateTime(LocalDate.now());
userResult.setCreateUser(BaseContext.getCurrentId());
userResult.setUpdateUser(BaseContext.getCurrentId());
userMapper.insert(userResult);
}
⚠️ 这里有个明显的安全问题:注册时密码被强制设为默认值
123456,没有使用用户填写的密码。生产代码中应该改成:
javauserResult.setPassword(DigestUtils.md5DigestAsHex(user.getPassword().getBytes()));
修改密码接口
java
@PostMapping("/updatePassword")
@Operation(summary = "updatePassword", description = "修改密码")
public BaseResponse updatePassword(@RequestBody PasswordDTO passwordDTO) {
if (!passwordDTO.getNewPassword().equals(passwordDTO.getConfirmPassword())) {
return ResultUtils.error("新密码与确认密码不一致");
}
User user = userService.getById(passwordDTO.getId());
String s = DigestUtils.md5DigestAsHex(passwordDTO.getOldPassword().getBytes());
if (!user.getPassword().equals(s)) {
return ResultUtils.error("旧密码错误");
}
user.setPassword(DigestUtils.md5DigestAsHex(passwordDTO.getNewPassword().getBytes()));
userService.updateById(user);
return ResultUtils.success("修改密码成功");
}
需要校验:新密码 = 确认密码 + 旧密码正确。
验证结果
登录测试
请求:
Java
POST /api/v1/user/login?userName=admin&password=admin
响应:
json
{
"code": 0,
"data": {
"id": 666497,
"userName": "admin",
"name": "管理员",
"token": "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjY2NjQ5N..."
}
}
携带 Token 访问
请求:
Java
GET /api/v1/chat/stream?message=你好
Headers:
authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjY2NjQ5N...
响应: 正常流式返回对话内容。
无 Token 访问
请求:
Java
GET /api/v1/chat/stream?message=你好
响应: HTTP 401 Unauthorized
Token 过期
等待 2 小时后,或手动构造一个过期的 Token:
响应: HTTP 401 Unauthorized
Token 篡改
修改 Token 中间一段(Payload):
响应: HTTP 401 Unauthorized(签名校验失败)
优化建议
升级密码哈希算法
MD5 已经被认为不安全。生产环境推荐 BCrypt:
java
// 引入依赖
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
// 加密
PasswordEncoder encoder = new BCryptPasswordEncoder();
String hash = encoder.encode(rawPassword);
// 验证
boolean match = encoder.matches(rawPassword, hash);
Token 黑名单
JWT 一旦签发就无法主动作废。如果业务需要"立即注销",可以引入 Redis 黑名单:
java
// 注销时记录 token 到黑名单,TTL 设为 token 剩余有效期
redisTemplate.opsForValue().set("blacklist:" + token, "1", remainingTtl, TimeUnit.MILLISECONDS);
// 拦截器中先查黑名单
if (redisTemplate.hasKey("blacklist:" + token)) {
response.setStatus(401);
return false;
}
Refresh Token 机制
短期 Access Token(15 分钟) + 长期 Refresh Token(7 天)的组合:
- Access Token 过期后,前端用 Refresh Token 换新的 Access Token
- 不用每次都重新登录
- 即使 Access Token 泄露,影响时间也很短
角色与权限
当前实现只有"已登录/未登录"两种状态。引入角色后:
java
claims.put("roles", user.getRoles()); // ["ADMIN", "USER"]
拦截器或自定义注解上做权限校验:
java
@RequireRole("ADMIN")
@PostMapping("/admin/clear")
public void clear() { ... }
防御性编程
- 限制登录失败次数(防暴力破解)
- 登录成功记录 IP 和设备
- 异常登录告警(异地、异时)
- 敏感操作二次验证(短信、邮箱)
ThreadLocal 清理
如前所述,必须在请求结束时清理 ThreadLocal:
java
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
BaseContext.removeCurrentId();
}
否则线程复用会导致用户身份混乱,是非常隐蔽且严重的 bug。
小结
本篇梳理了完整的 JWT 认证体系:
JwtUtil提供签发与解析能力,使用 HS256 算法- 登录接口验证密码后生成 Token,包含 userId
JwtTokenUserInterceptor在每个请求前校验 Token,并将 userId 写入 ThreadLocalBaseContext让业务代码无侵入地获取当前用户身份- 配置驱动的密钥和 TTL,便于环境隔离
- 放行规则覆盖登录、注册和 Swagger
下一篇将进入一个有趣的话题------AI 绘图,看看如何用 Spring AI 一行代码集成文生图能力。
