Spring AI RAG - 08 JWT 认证与用户体系设计

文章目录

引言

任何面向用户的系统,第一道工程考题永远是:怎么知道"你是你"?这个问题在 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;
        }
    }
}

实现细节:

  1. 支持 Bearer 前缀 :兼容标准的 Authorization: Bearer xxx 格式
  2. 静态资源放行!(handler instanceof HandlerMethod) 判断,让 Swagger 等资源不被拦截
  3. 异常分级处理:过期和其他异常都返回 401,但日志可以区分原因
  4. 写入 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,没有使用用户填写的密码。生产代码中应该改成:

java 复制代码
userResult.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 写入 ThreadLocal
  • BaseContext 让业务代码无侵入地获取当前用户身份
  • 配置驱动的密钥和 TTL,便于环境隔离
  • 放行规则覆盖登录、注册和 Swagger

下一篇将进入一个有趣的话题------AI 绘图,看看如何用 Spring AI 一行代码集成文生图能力。

相关推荐
摇滚侠1 小时前
Spring 面试题 真正的 offer 偏方 Java 基础 Java 高级
java·后端·spring
用户398346161201 小时前
Go-Spring 实战第 2 课 —— 配置绑定:Properties 映射到 Go 类型
spring·go
用户398346161201 小时前
Go-Spring 实战第 3 课 —— 复杂类型的配置绑定:Duration、Time、Slice、Map
spring·go
Jul1en_3 小时前
【Spring Cloud】Spring Cloud Config详解
后端·spring·spring cloud
霸道流氓气质12 小时前
基于 Milvus Lite 的 Spring AI RAG 向量库实践方案与示例
人工智能·spring·milvus
Ting-yu13 小时前
SpringCloud快速入门(7)---- 数据隔离
spring boot·spring·spring cloud
独自归家的兔14 小时前
OCPP 1.6 协议详解:GetLocalListVersion 获取本地列表版本指令
java·后端·物联网·spring·ocpp1.6
largecode17 小时前
如何让电话显示店名?来电显示店铺名称,提升有效接通率
java·开发语言·spring·百度·学习方法·业界资讯·twitter
xuhaoyu_cpp_java17 小时前
SpringMVC学习(五)
java·开发语言·经验分享·笔记·学习·spring