Spring Boot 集成 Sa-Token 实现高可用权限认证:登录鉴权、动态路由权限与接口防重放实战

最近在搞一个公司内部的运营管理系统,权限这块儿又得从头弄。以前用过 Spring Security,功能是强,但配置起来真能把人绕晕,光自定义过滤器链就够喝一壶的。项目周期就两个月,团队里还都是写业务为主的,搞那么复杂没必要。JWT 倒是简单,可后面要踢人下线、控制 Token 失效粒度的时候,就傻眼了------根本做不到。后来朋友推荐了 Sa-Token,看了下文档,确实轻量,上手快,而且集成了 Redis 之后集群会话共享也没问题。这下对上号了。

这篇文章就把我这次集成 Sa-Token 的完整过程和一些坑记录下来,从登录鉴权到动态权限,最后加上接口防重放,都给你捋一遍。代码基于 Spring Boot 2.7.x + Sa-Token 1.37.0 + Redis 6.x,基本是当下比较常见的组合。

选型那点事儿

说实话,权限认证框架的选择,没有银弹。中小型项目的痛点往往是:不想被 Spring Security 的配置绑架,又怕自研 Session 方案有坑。Sa-Token 的定位恰好就是"简单、好用",但功能一点都不简陋。它内置了踢人下线、会话管理、账号封禁这些我们平时做系统高频用的能力,而且和 Redis 是深度整合的,部署集群的时候特别省心。

当然,如果你已经在用 Spring Security,并且团队熟,那也没必要换。但如果你是从零开始,或者被 Spring Security 的复杂配置折磨过,Sa-Token 值得一试。

集成步骤:三步走,不废话

第一步:加依赖

在 pom.xml 里只需要引入一个核心包:

xml 复制代码
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot-starter</artifactId>
    <version>1.37.0</version>
</dependency>

如果后面要连 Redis,再加两个:

xml 复制代码
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>1.37.0</version>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

注意版本号一定和核心包保持一致,尤其是 sa-token-redis-jackson,不然可能遇到奇怪的序列化问题。选 Jackson 而不是 JDK 序列化,主要是为了在 Redis 里能看到可读的数据,后期排查问题方便。

第二步:写配置

application.yml 里除了 Redis 连接信息,最重要就是 sa-token 的配置:

yaml 复制代码
server:
  port: 8080

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    password:
    database: 0

sa-token:
  # Token 名称(同时也是 header 里的键名)
  token-name: satoken
  # Token 有效期(秒),默认 30 天,-1 表示永不过期
  timeout: 2592000
  # 最低活跃频率(秒),如果超过这个时间没有访问,Token 失效;-1 表示不限制
  active-timeout: -1
  # 允许多端一起登录(true),还是新登录挤掉旧登录(false)
  is-concurrent: true
  # 同一账号多端登录时,是否共用一个 Token(true 共用,false 每次登录生成新 Token)
  is-share: false
  # 允许从 header 读取 Token
  is-read-header: true
  # 允许从 cookie 读取 Token(如果前端不方便存 header,可以用 cookie)
  is-read-cookie: true
  # 是否允许从 body 读取 Token,一般用不到
  is-read-body: false
  # Token 风格,uuid 就是标准 UUID
  token-style: uuid
  # 打印操作日志,排错的时候开着,正式环境可以关掉
  is-log: true

这里有几个点容易踩坑:

  • is-concurrent 和 is-share 要配合好。如果 is-concurrent=false,is-share 就是 true,否则会逻辑冲突。
  • active-timeout 如果设置了正数,那么用户如果在那个时间内没有操作,Token 就失效了,需要重新登录。这个对运营类系统还好,对用户频繁操作的系统要慎重。

第三步:配置过滤器和拦截器

Sa-Token 提供了两种方式:SaServletFilter 是全局过滤器,适合做跨域处理和全局前置逻辑;SaInterceptor 是 Spring MVC 拦截器,适合做路由拦截和注解鉴权。通常两个会一起用。

先来看全局过滤器:

java 复制代码
@Configuration
public class SaTokenConfigure {

    @Bean
    public SaServletFilter saServletFilter() {
        return new SaServletFilter()
                .addInclude("/**")
                .addExclude("/user/login", "/user/register", "/error")
                // 认证函数:每次请求都会执行,可以在这里放一些全局逻辑
                .setAuth(obj -> {
                    // System.out.println("请求进来了:" + SaHolder.getRequest().getRequestPath());
                })
                // 异常处理:认证过程中抛出的异常都在这里处理
                .setError(e -> {
                    if (e instanceof NotLoginException) {
                        return SaResult.error("未登录,请先登录").setCode(401);
                    }
                    if (e instanceof NotPermissionException) {
                        return SaResult.error("无权限访问").setCode(403);
                    }
                    return SaResult.error("认证失败:" + e.getMessage()).setCode(500);
                })
                // 前置函数:在认证函数之前执行,这里做 CORS 最合适
                .setBeforeAuth(r -> {
                    SaHolder.getResponse()
                            .setHeader("Access-Control-Allow-Origin", "*")
                            .setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
                            .setHeader("Access-Control-Allow-Headers", "*")
                            .setHeader("Access-Control-Max-Age", "3600");
                    // 预检请求直接返回,不继续走后续认证
                    if ("OPTIONS".equals(SaHolder.getRequest().getMethod())) {
                        SaRouter.back();
                    }
                });
    }
}

注意 setBeforeAuth 里做跨域,比用独立的 CORS 过滤器要方便,而且可以结合 SaRouter.back() 把 OPTIONS 请求直接放掉。

然后注册拦截器,目的是为了让 @SaCheckPermission 注解生效:

java 复制代码
@Configuration
public class SaTokenInterceptorConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册 SaInterceptor,并设置要执行的鉴权逻辑
        registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
                .addPathPatterns("/**")
                .excludePathPatterns("/user/login", "/user/register");
    }
}

这里我把登录接口排除掉了,因为不用鉴权。StpUtil.checkLogin() 是全局的登录校验,可以保证所有接口都需要登录。到这一步,基本框架就搭好了。

登录接口和 StpUtil 常用 API

登录无非就是查询用户、校验密码、调 StpUtil.login(),然后把 Token 返回给前端。

数据库表结构这里就不贴了,就普通的 sys_user、sys_role、sys_permission 三张表,关系表也略过。

直接看 Controller:

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    @PostMapping("/login")
    public SaResult login(@RequestBody LoginDTO loginDTO) {
        SysUser user = userService.getByUsername(loginDTO.getUsername());
        if (user == null) {
            return SaResult.error("用户名不存在");
        }
        // 注意 BCrypt 需要引入 spring-security-crypto 或 jBCrypt 依赖,这里只是演示
        if (!BCrypt.checkpw(loginDTO.getPassword(), user.getPassword())) {
            return SaResult.error("密码错误");
        }
        if (user.getStatus() == 0) {
            return SaResult.error("账号已被禁用");
        }
        // 执行登录,可以指定设备类型,比如 "web"、"app"
        StpUtil.login(user.getId(), "web");
        // 返回 Token 信息给前端
        return SaResult.data(StpUtil.getTokenInfo());
    }

    @PostMapping("/logout")
    public SaResult logout() {
        StpUtil.logout();
        return SaResult.ok("登出成功");
    }

    @GetMapping("/info")
    public SaResult info() {
        long userId = StpUtil.getLoginIdAsLong();
        SysUser user = userService.getById(userId);
        // 把用户信息放到 Session 里,后续可以直接用
        StpUtil.getSession().set("userInfo", user);
        return SaResult.data(user);
    }
}

StpUtil.getTokenInfo() 返回的 JSON 结构类似这样:

json 复制代码
{
    "tokenName": "satoken",
    "tokenValue": "1f2c3d4e-5a6b-7c8d-9e0f-1234567890ab",
    "isLogin": true,
    "loginId": "10001",
    "loginType": "login",
    "tokenTimeout": 2592000,
    "sessionTimeout": 2592000,
    "tokenSessionTimeout": -1,
    "tokenActiveTimeout": -1,
    "loginDevice": "web"
}

前端拿到 tokenValue 后,放到请求头 satoken 里即可。不需要存 Cookie,当然 Sa-Token 也能自动读 Cookie,看你习惯。

关于 StpUtil 的其他常用操作,我直接列一下,不用表格,都是平时常用的:

  • StpUtil.logout() 当前会话注销
  • StpUtil.logout(10001) 把账号 id 为 10001 的人踢下线
  • StpUtil.logoutByTokenValue(token) 按 token 值注销
  • StpUtil.isLogin() 判断当前是否登录
  • StpUtil.getLoginId() 获取当前登录的 id
  • StpUtil.getTokenValue() 获取当前 token 值
  • StpUtil.checkLogin() 校验登录,没登录就抛异常
  • StpUtil.getSession() 获取当前会话的 Session
  • StpUtil.kickout(10001) 强制下线(和 logout 的区别是 kickout 会让对方会话失效,但不会删除账号的登录记录?具体看文档,我一般用 logout)

动态权限:实现 StpInterface

Sa-Token 的权限校验核心是 StpInterface 接口,我们需要实现它,告诉框架当前用户有哪些角色和权限码。权限码可以来自数据库、缓存,甚至配置文件。

java 复制代码
@Component
public class StpInterfaceImpl implements StpInterface {

    @Autowired
    private SysUserService userService;

    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 假设 loginId 就是用户 ID
        return userService.getPermissionsByUserId(Long.parseLong(loginId.toString()));
    }

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        return userService.getRolesByUserId(Long.parseLong(loginId.toString()));
    }
}

数据库查询代码大概这样,用 MyBatis-Plus:

java 复制代码
public List<String> getPermissionsByUserId(Long userId) {
    // 先查角色
    List<SysRole> roles = sysRoleMapper.selectRolesByUserId(userId);
    if (roles.isEmpty()) return new ArrayList<>();
    // 再查角色包含的权限码
    List<Long> roleIds = roles.stream().map(SysRole::getId).collect(Collectors.toList());
    return sysPermissionMapper.selectPermsByRoleIds(roleIds);
}

然后在 Controller 里用注解:

java 复制代码
@GetMapping("/user/add")
@SaCheckPermission("user:add")
public SaResult addUser() {
    return SaResult.ok("新增用户成功");
}

@DeleteMapping("/admin/delete")
@SaCheckRole("admin")
public SaResult deleteAdmin() {
    return SaResult.ok("删除管理员成功");
}

如果权限码是动态的,比如根据请求 URL 去数据库匹配,那可以在过滤器里自己写逻辑。大概思路是:从 sys_menu 表里配好 URL 和权限码的对应关系,然后在 SaServletFilter 的 setAuth 里获取当前路径,查库得到需要的权限码,再调用 StpUtil.checkPermission()。当然,这种查询最好加个缓存,不然每个请求都查一次数据库,数据库会哭的。

接口防重放:Nonce + 时间戳 + 签名

这块是我加上的,因为有些写操作接口不安全,被人抓包后反复提交就麻烦了。做法不算复杂,就是客户端每次请求带三个东西:时间戳、随机数、签名。

签名规则:把请求参数按字典序拼成字符串,再拼上时间戳、nonce、约定的 secretKey,做 MD5。

服务端校验:

  1. 时间戳不能差太多(比如 5 分钟内),防止历史请求重放;
  2. nonce 必须唯一,用 Redis 的 setIfAbsent 来判重,过期时间设成 5 分钟;
  3. 重新计算签名,比对是否一致,防止参数被篡改。

我这里用一个 Spring MVC 拦截器实现,只拦截需要防重放的接口路径。校验代码大致如下:

java 复制代码
@Component
public class SignVerifyInterceptor implements HandlerInterceptor {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    private static final long MAX_TIME_WINDOW = 5 * 60 * 1000;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if ("OPTIONS".equals(request.getMethod())) return true;

        String timestamp = request.getHeader("timestamp");
        String nonce = request.getHeader("nonce");
        String sign = request.getHeader("sign");

        if (StrUtil.hasBlank(timestamp, nonce, sign)) {
            throw new ApiException("签名参数缺失");
        }

        long ts = Long.parseLong(timestamp);
        if (Math.abs(System.currentTimeMillis() - ts) > MAX_TIME_WINDOW) {
            throw new ApiException("请求已过期");
        }

        String nonceKey = "sign:nonce:" + nonce;
        Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(5));
        if (Boolean.FALSE.equals(ifAbsent)) {
            throw new ApiException("重复请求");
        }

        Map<String, String[]> params = request.getParameterMap();
        TreeMap<String, String> sortedParams = new TreeMap<>();
        for (Map.Entry<String, String[]> entry : params.entrySet()) {
            sortedParams.put(entry.getKey(), String.join(",", entry.getValue()));
        }
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
            sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
        }
        sb.append("timestamp=").append(timestamp).append("&nonce=").append(nonce);
        sb.append("&secretKey=").append(secretKey);

        String calcSign = DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
        if (!calcSign.equalsIgnoreCase(sign)) {
            throw new ApiException("签名验证失败");
        }

        return true;
    }
}

注意我这里用了 hutool 的 StrUtil 和 Spring 的 DigestUtils,如果你不想引入 hutool,换成 StringUtils 判断空值也行。

注册拦截器:

java 复制代码
registry.addInterceptor(signVerifyInterceptor)
        .addPathPatterns("/api/**")
        .excludePathPatterns("/user/login");

客户端生成签名的代码,可以参考这个:

java 复制代码
public static String buildSign(Map<String, String> params, String timestamp, String nonce, String secretKey) {
    TreeMap<String, String> sorted = new TreeMap<>(params);
    StringBuilder sb = new StringBuilder();
    sorted.forEach((k, v) -> sb.append(k).append("=").append(v).append("&"));
    sb.append("timestamp=").append(timestamp).append("&nonce=").append(nonce);
    sb.append("&secretKey=").append(secretKey);
    return DigestUtils.md5DigestAsHex(sb.toString().getBytes(StandardCharsets.UTF_8));
}

secretKey 千万不要放前端页面上,尤其别写在 JS 里,不然等于没加密。最好由后端下发给可信客户端,走 HTTPS 传输。

集群部署:Redis 统一 Token

默认下 Sa-Token 的 Token 是存在应用内存里的,一旦重启就丢了,集群下多个实例之间也不共享。解决方法是把 Token 存储切到 Redis,只需要引入 sa-token-redis-jackson 这个包,然后在 application.yml 里配好 Redis 连接信息,不用改任何 Java 代码,Sa-Token 自动就切换了。

我当时测试的时候,起了两个不同端口的实例,登录其中一个,去访问另一个的受保护接口,直接就能通。调用 StpUtil.logout() 之后,两个实例该 Token 都失效了,效果没问题。

这里要注意一点:如果使用了 sa-token-redis-jackson,不要再自定义 RedisTemplate 去覆盖默认配置,可能引起序列化冲突。另外,Redis 的 timeout 别设太短,不然高并发下容易连接超时。

对比一下 Spring Security + JWT

之前我也用过 Spring Security + JWT 的组合,简单说下感受。

Spring Security 功能确实强大,支持 OAuth2、SAML 这些标准协议,如果你要对接第三方登录,或者需要很复杂的认证流程,那它必然首选。但问题也明显:学习曲线陡,过滤器链从上到下一大堆,光搞清楚 Provider、AuthenticationManager 这些概念就能花一两天。而且即使配好,JWT 是无状态的,服务端没法主动让 Token 失效,只能被动等它过期。要实现踢人下线,你还得额外做黑名单,一套下来成本不低。

Sa-Token 这边就"省心"多了,API 是静态方法直接怼,没有太多弯弯绕绕。踢人、会话查询、封号这些业务常见的功能都是现成的,直接调就完事。而且因为 Token 是存在 Redis 里的,天然就是一种"有状态"的认证,服务端想让它失效就失效,非常符合业务直觉。

有人可能会说 JWT 适合无状态服务,水平扩展方便。但其实只要用了 Redis,Sa-Token 也能水平扩展,而且不依赖客户端去刷新 Token。所以如果你的系统不是那种纯粹无状态、所有鉴权都要自己算的架构,Sa-Token 反而更合适。

反正我的看法是:业务系统用 Sa-Token,省时省力;如果有复杂协议要求,用 Spring Security。别硬撑着找一个不适合的。

生产环境遇到的坑

记录几个印象深刻的坑,希望能帮你少走点弯路。

Token 序列化异常

第一次用 sa-token-redis-jackson 的时候,登录成功后第二次请求就报 SerializationException,查了下是序列化方式不匹配。后来发现是项目中自己定义了 RedisTemplate 的序列化器,覆盖了 Sa-Token 默认配置。解决办法很简单:别动 RedisTemplate,或者把自定义的删掉。

拦截器和过滤器顺序搞混

过滤器和拦截器的执行顺序是 Filter 在前,Interceptor 在后。千万别在 SaServletFilter 的 setAuth 里做 StpUtil.checkLogin(),不然你拦截器里的校验就重复了,而且可能因为异常没捕获好导致错误返回。跨域处理就放 setBeforeAuth,登录校验就交给我们上面注册的 SaInterceptor。

@SaCheckPermission 不生效

如果加了注解但发现没被拦截,大概率是没有注册 SaInterceptor。注意 @SaCheckPermission 必须依赖 SaInterceptor,只有全局过滤器是不够的。别问我怎么知道的,调了半天。

权限数据查询太慢

权限列表是高频访问数据,每次请求都查数据库扛不住。我当时就是加了个 Redis 缓存,key 就叫 user:perms:用户ID,过期时间 5 分钟。然后在登录的时候把权限塞到 Session 里,获取时优先从 Session 取,这样连 Redis 都不需要查。如果权限变更了,就调 StpUtil.getSessionByLoginId(userId).delete("permList") 清掉缓存,强制刷新。

nonce 在集群下失效

防重放里的 nonce 如果只存在单机内存,集群环境下肯定出问题。这个不用想,直接放 Redis 里,用 setIfAbsent 原子操作。

时间戳校验的时钟偏移

客户端和服务器的时钟多多少少会有偏差,时间窗口设太短会把正常请求误杀。我一般设 5 分钟,同时建议生产环境开启 NTP 时间同步。如果实在没办法同步,也可以不校验时间戳,但 nonce 和 sign 必须验。

前端跨域带 Token

如果前端用的是 localStorage 存 Token,那请求时必须放在 header 里,后端 CORS 的 Access-Control-Allow-Headers 要包含 satoken。如果用了 Cookie,Access-Control-Allow-Origin 不能设成 *,必须写具体的域名,否则浏览器不会让请求带 Cookie。

最后啰嗦两句

Sa-Token 用下来最大的感受就是"省事",集成快,文档也全,常见的需求基本开箱即用。如果你正愁权限怎么搞,可以试试。不过技术选型还是要结合自己项目情况,别跟风。就这些了,有问题评论区聊。


🎁 福利时间

如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。

知识库地址:https://farerboy.com/


相关推荐
Bazingga2 小时前
一次推理的完整旅程:ReAct推理深度解析
后端
姜鱼问生2 小时前
Nginx gzip 压缩:一个配置让页面加载快 3 倍
后端·算法·架构
颜酱2 小时前
一栋商业大厦,讲清 InnoDB 的表空间、段、区和页
后端·mysql
码事漫谈3 小时前
1956 亿行数据该怎么存:金仓时序数据库的三个实测
后端
Wx-bishekaifayuan3 小时前
springboot桂味美食分享系统40540-计算机课程设计、毕业设计
spring boot·后端·python·django·课程设计·express·旅游
彭亚川Allen3 小时前
从零手搓 RAG 之检索全流程,给大模型接上自己的知识库
前端·后端·ai编程
IT_陈寒3 小时前
别让Java字符串拼接偷偷吃掉你的内存
前端·人工智能·后端
_Evan_Yao3 小时前
前后端联调怎么排查问题?从 Network 到后端日志,一步步定位错误
开发语言·后端·团队开发·需求分析
程序员梅雨4 小时前
ZooKeeper面经总结
java·大数据·linux·分布式·后端·zookeeper·中间件