📌 项目源码与分支信息:

  • 项目源码地址GitHub - xf-boot-base
  • 本篇配套分支feature/admin-auth-satoken (通过命令 git checkout feature/admin-auth-satoken 切换)

前言:业务背景与痛点

在项目架构演进中,针对不同业务场景需要选择不同力度的会话控制方案。
本项目的主体功能基于 master 分支,该分支主要面向 C端(用户端)应用
针对 C端 庞大的用户体量与分布式扩展需求,master 分支采用了经典的 JWT(JSON Web Token) 进行无状态登录认证,服务端无需维持会话状态,轻量且高效。

然而,随着系统扩展出 B端(后台管理系统),我们面临的核心痛点转向了复杂的 RBAC(基于角色的访问控制) 以及强力会话管控(如账号实时封禁、强制下线、权限动态变更生效)。
若强行在 C端的 JWT 架构上堆叠这些功能,不仅会让原本无状态的 token 变得臃肿,还会丧失 JWT 本身的无状态优势。

为了解决 B端 的安全痛点,我们在 feature/admin-auth-satoken 分支中引入了国产轻量级安全框架 Sa-Token
它以极简 we API 设计和极低的侵入性,完美契合后台权限体系。
本文将结合本分支的真实代码,带你一步步落地一套高可用、生产级的权限系统。


技术选型思考

对于权限管理框架的选型,开发者常在 Spring SecurityApache ShiroSa-Token 之间进行权衡。
以下是针对这三款主流框架的客观对比:

维度 Spring Security Apache Shiro Sa-Token
上手难度 较高(过滤器链机制复杂,概念繁杂) 中等(配置略显传统) 极低(开箱即用,API 人性化)
社区活跃度 极高(Spring 官方维护,行业事实标准) 较低(近年来维护节奏放缓) 极高(国内活跃,文档建设完善)
分布式支持 优秀(可无缝贴合 Spring Cloud 生态) 一般(通常需要手动集成 Redis) 极佳(内置 Redis Jackson 等整合插件)
会话管控 相对繁琐(需要定制过滤器与监听器) 一般 极强(原生支持强制下线、踢人等)
适用场景 中大型企业级项目、高度定制的安全网关 遗留单体系统的日常维护与迭代 快速迭代、追求轻量级的中小型系统

💡 选型决策建议:
两个框架并没有绝对的优劣,只有更适合当前业务的选择。
如果项目追求高标准的安全认证、完善的生态集成(如 OAuth2),Spring Security 是行业首选(我们将在下一篇博客中进行深度集成拆解)。
如果项目以交付速度为先,且需要极高开发体验与灵活的会话管理,Sa-Token 则是极好的敏捷开发方案。

话不多说,我们首先来看本篇聚焦的 Sa-Token 的具体实践……


数据库设计 (RBAC 模型)

为了实现标准的 RBAC 权限控制,本分支设计并使用了以下五张核心表,用以支撑“用户 -> 角色 -> 权限”的经典多对多模型。

1. 核心表结构说明

  • sys_user:用户表。存储用户的基本信息(账号、密码、昵称等)。
  • sys_role:角色表。定义系统中的角色角色(如:超级管理员、普通用户)。
  • sys_permission:权限(菜单)表。定义具体的资源权限标识(如 user:info, user:add 等)。
  • sys_user_role:用户-角色关联表。多对多关联关系。
  • sys_role_permission:角色-权限关联表。多对多关联关系。

SQL 脚本说明:本分支的完整 DDL 结构及测试初始化数据已包含在项目根目录的 sql 文件夹中。


核心架构与数据流向(Mermaid 图解)

在本项目中,我们设计了 双拦截器机制,在确保安全鉴权的同时,实现了 ThreadLocal 线程上下文的注入,以便于在后续业务链路中随时、优雅地获取当前登录用户信息。
以下是核心请求数据流向图:

携带 Authorization 头

未登录/无权限

通过

读取用户信息

AOP 注解校验

通过

前端请求

Spring MVC WebMvcConfigurer

1. SaInterceptor 安检

返回 401/403 异常

2. SaTokenContextInterceptor 搬运

从 Redis Session 获取 loginUser

存入 ThreadLocal SessionContext

Controller 层 业务逻辑

SessionContext.getInstance().get()

Sa-Token 权限/角色校验

执行 Mapper 数据库操作

请求结束 afterCompletion

清理 ThreadLocal 释放内存

响应前端


环境准备与前置边界

1. 技术栈与版本说明

在落地本方案时,请确保你的基础环境与以下版本保持一致或向上兼容:

  • JDK: 17
  • Spring Boot: 3.3.3
  • Sa-Token: 1.44.0
  • MySQL: 8.1.0
  • Redis: 5.0+ (生产环境强依赖 Redis 做分布式会话持久化)

2. 技术局限性声明

⚠️ 生产边界警告
本方案适用于**中等量级(日活十万级以内)**的系统。
在高并发场景下,Sa-Token 的 AOP 注解校验会频繁调用 StpInterface 获取权限列表。
如果直接查库会给数据库造成巨大压力,必须在 StpInterfaceImpl 中引入多级缓存(如 Caffeine + Redis)。


核心实践与核心参数推演

1. Maven 依赖配置

在项目 pom.xml 中,引入 Sa-Token 的 Spring Boot 3 整合包以及 Redis Jackson 序列化组件:

<properties>
        <commons-pool2.version>2.11.1</commons-pool2.version>
        <sa-token.version>1.44.0</sa-token.version>
    </properties>

<!-- Sa-Token 核心依赖 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
    <version>${sa-token.version}</version>
</dependency>

<!-- Sa-Token 整合 Redis(使用 Jackson 序列化,便于在 Redis 中直观查看) -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
    <version>${sa-token.version}</version>
</dependency>

<!-- Redis 物理连接池依赖 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>${commons-pool2.version}</version>
</dependency>

2. 核心配置文件 (sa-token-config.yml)

完整的 sa-token-config.yml 配置如下:

# Sa-Token 核心参数配置
sa-token:
  # Token 标识名(前端请求 Header 中需要携带的 Key)
  token-name: Authorization
  # Token 前缀(遵循 Bearer Token 标准规范,注意后面有一个空格)
  token-prefix: Bearer
  # Token 有效期(单位:秒)。2592000 秒即 30 天
  timeout: 2592000
  # 临时有效期(指定时间内无操作则半途失效,-1 代表不开启)
  activity-timeout: -1
  # 是否允许同一账号多端同时登录
  is-concurrent: true
  # 在多人登录同一账号时,是否共享同一个 Token
  is-share: true
  # Token 生成风格,采用 uuid 格式
  token-style: uuid
  # 是否向控制台打印框架内部日志
  is-log: false
  # 是否从 Cookie 中尝试读取 Token
  is-read-cookie: false
  # 是否从 Header 中读取 Token(前后端分离项目必须开启)
  is-read-header: true
  # 是否从 Body 请求体中读取 Token
  is-read-body: false
📌 核心参数因果链推演:
  • token-style: uuid
    • 因果推演:相较于 jwt 的去中心化设计,uuid 风格下 Token 本身仅作为随机 Key。
    • 用户的登录状态与数据完全保留在 Redis 中。当需要实现强力踢人账号封禁实时更改权限时,只需在 Redis 中删除或修改对应 Key 即可,具有极高实时性与安全把控度。
  • token-prefix: Bearer
    • 因果推演:标准的前后端分离开发中,使用 Bearer 前缀能够无缝对接各种标准的 API 网关及第三方客户端,规范接口通信协议。
  • timeout: 2592000activity-timeout: -1
    • 因果推演:对于 B 端管理后台,将全局有效期设为 30 天,并不设置活跃超时,可减少用户频繁登录的烦恼。对于安全性极高的系统,可配置 activity-timeout: 1800(30分钟无操作自动注销)。

3. 权限及角色加载实现 (StpInterfaceImpl.java)

我们需要实现 StpInterface 接口,以便 Sa-Token 在进行权限校验时,能够动态加载当前登录用户的权限和角色。
完整的 StpInterfaceImpl.java 代码如下:

package cn.xf.basedemo.interceptor;

import cn.dev33.satoken.stp.StpInterface;
import cn.xf.basedemo.mappers.SysPermissionMapper;
import cn.xf.basedemo.mappers.SysRoleMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * @Description: 权限加载组件类,告诉 Sa-Token 如何获取用户的角色 and 权限
 * @ClassName: StpInterfaceImpl
 * @Author: xiongfeng
 */
@Component
public class StpInterfaceImpl implements StpInterface {

    @Autowired
    private SysPermissionMapper sysPermissionMapper;

    @Autowired
    private SysRoleMapper sysRoleMapper;

    @Override
    public List<String> getPermissionList(Object userId, String loginType) {
        // 根据用户 ID 查数据库,获取该用户拥有的权限标识列表(如:["user:add", "user:delete"])
        Long uId = Long.valueOf(userId.toString());
        return sysPermissionMapper.getPermissionListByUserId(uId);
    }

    @Override
    public List<String> getRoleList(Object userId, String loginType) {
        // 根据用户 ID 查数据库,获取该用户拥有的角色编码列表(如:["super-admin", "common-user"])
        Long uId = Long.valueOf(userId.toString());
        return sysRoleMapper.getRoleListByUserId(uId);
    }
}

4. 线程上下文 SessionContext.java

用于在当前请求线程中临时保存用户信息,实现业务代码解耦。
完整的 SessionContext.java 代码如下:

package cn.xf.basedemo.interceptor;

import cn.xf.basedemo.common.model.LoginUser;

/**
 * @program: xf-boot-base
 * @ClassName SessionContext
 * @description: 线程上下文,用于持有当前登录用户的信息
 * @author: xiongfeng
 **/
public class SessionContext {

    private ThreadLocal<LoginUser> threadLocal;

    private SessionContext() {
        this.threadLocal = new ThreadLocal<>();
    }

    /**
     * 使用静态内部类创建单例
     */
    private static class Context {
        private static final SessionContext INSTANCE = new SessionContext();
    }

    public static SessionContext getInstance() {
        return Context.INSTANCE;
    }

    public void set(LoginUser user) {
        this.threadLocal.set(user);
    }

    public LoginUser get() {
        return this.threadLocal.get();
    }

    public void clear() {
        this.threadLocal.remove();
    }
}

5. 自定义上下文拦截器 (SaTokenContextInterceptor.java)

该拦截器的职责是实现“搬运工”的角色:在 Sa-Token 验证登录通过后,取出缓存的用户详情并塞入 SessionContext,同时在请求结束时负责清理,规避内存泄漏。
完整的 SaTokenContextInterceptor.java 代码如下:

package cn.xf.basedemo.interceptor;

import cn.dev33.satoken.stp.StpUtil;
import cn.xf.basedemo.common.model.LoginUser;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
 * @Description: Sa-Token 上下文拦截器
 *               用于将 Sa-Token Session 中的用户信息注入到 SessionContext (ThreadLocal)
 *               以方便业务层随时获取登录用户信息 (SessionContext.getInstance().get())
 * @Author: xiongfeng
 */
@Component
public class SaTokenContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 如果已登录,尝试从 Session 中获取用户信息并注入 ThreadLocal
        if (StpUtil.isLogin()) {
            // 从 Sa-Token Session 中读取 loginUser (需确保登录时已存入)
            LoginUser loginUser = (LoginUser) StpUtil.getSession().get("loginUser");
            if (loginUser != null) {
                SessionContext.getInstance().set(loginUser);
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
            throws Exception {
        // 请求结束后必须清理 ThreadLocal,防止 Tomcat 线程池复用导致内存泄漏和数据交叉污染
        SessionContext.getInstance().clear();
    }
}

6. 拦截器注册配置 (InterceptorConfig.java)

在这里将 Sa-Token 的核心路由拦截器与我们的上下文拦截器串联到 Spring MVC 链路中。
完整的 InterceptorConfig.java 代码如下:

package cn.xf.basedemo.interceptor;

import cn.dev33.satoken.interceptor.SaInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @program: spring-boot-base-demo
 * @ClassName InterceptorConfig
 * @description: 拦截器全局配置类
 * @author: xiongfeng
 **/
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

    @Autowired
    private SaTokenContextInterceptor saTokenContextInterceptor;

    // 统一配置排除路径,放行登录接口、静态资源及 Knife4j 接口文档
    private static final String[] EXCLUDE_PATHS = {
            "/user/login",
            "/web/**",
            "/swagger-resources/**",
            "/webjars/**",
            "/v3/**",
            "/doc.html",
            "/swagger-ui.html",
            "/swagger-ui/**"
    };

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 1. 注册 Sa-Token 拦截器 (负责拦截未登录请求并支持注解鉴权)
        registry.addInterceptor(new SaInterceptor(handler -> {
            cn.dev33.satoken.stp.StpUtil.checkLogin();
        }))
                .addPathPatterns("/**")
                .excludePathPatterns(EXCLUDE_PATHS);

        // 2. 注册 Context 拦截器 (负责注入 ThreadLocal,便于随时获取用户信息)
        registry.addInterceptor(saTokenContextInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(EXCLUDE_PATHS);
    }

    /**
     * 放行 Knife4j 静态资源请求
     */
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
}

7. 控制层鉴权实战 (UserController.java)

完整的 UserController.java 控制器代码,包含了登录入口以及丰富的鉴权注解示例:

package cn.xf.basedemo.controller.business;

import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.annotation.SaMode;
import cn.xf.basedemo.common.model.LoginUser;
import cn.xf.basedemo.common.model.RetObj;
import cn.xf.basedemo.interceptor.SessionContext;
import cn.xf.basedemo.model.res.LoginInfoRes;
import cn.xf.basedemo.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

/**
 * @program: xf-boot-base
 * @ClassName UserController
 * @description: 用户控制器,包含登录与各种角色权限拦截演示
 * @author: xiongfeng
 **/
@RestController
@RequestMapping("/user")
@Tag(name = "用户控制器")
public class UserController {

    @Autowired
    private UserService userService;

    @Operation(summary = "用户登录", description = "用户登录接口,登录成功后会下发 Token")
    @PostMapping("/login")
    public RetObj login(@RequestBody @Validated LoginInfoRes res) {
        return userService.login(res);
    }

    @Operation(summary = "用户信息", description = "获取当前已登录用户的详细信息")
    @PostMapping("/info")
    @SaCheckPermission("user:info") // 必须拥有 user:info 权限才能访问
    public RetObj info() {
        // 从 ThreadLocal 上下文中直接获取用户信息,优雅且解耦
        LoginUser loginUser = SessionContext.getInstance().get();
        return RetObj.success(loginUser);
    }

    @Operation(summary = "注解示例-角色校验", description = "必须具有 'super-admin' 角色才能访问")
    @PostMapping("/check-role")
    @SaCheckRole("super-admin")
    public RetObj checkRole() {
        return RetObj.success("您拥有 super-admin 角色,验证通过");
    }

    @Operation(summary = "注解示例-权限组合(OR)", description = "只要拥有 user:add 或 user:update 其中一个权限即可")
    @PostMapping("/check-permission-or")
    @SaCheckPermission(value = { "user:add", "user:update" }, mode = SaMode.OR)
    public RetObj checkPermissionOr() {
        return RetObj.success("您拥有 user:add 或 user:update 权限,验证通过");
    }

    @Operation(summary = "注解示例-权限组合(AND)", description = "必须同时拥有 user:delete 和 user:export 权限")
    @PostMapping("/check-permission-and")
    @SaCheckPermission(value = { "user:delete", "user:export" }, mode = SaMode.AND)
    public RetObj checkPermissionAnd() {
        return RetObj.success("您同时拥有 user:delete 和 user:export 权限,验证通过");
    }

    @Operation(summary = "注解示例-忽略鉴权", description = "无需登录即可访问(常用于注册、验证码等公开接口)")
    @PostMapping("/public-api")
    @SaIgnore
    public RetObj publicApi() {
        return RetObj.success("这是一个公开接口,@SaIgnore 生效");
    }
}

运行效果与结果验证

为保证功能 100% 正常复现,请按照以下步骤进行联调测试。

步骤 1:调用登录接口,获取 Token 凭证

  • 请求类型POST
  • 请求路径/user/login
  • 请求体:包含经过 RSA 算法加密的账号密码数据 LoginInfoRes
  • 预期响应
    返回 code: 200,并在返回结果中携带 token 值,例如:"token": "4a719c8f-a9eb-4a6c-9407-cb8b23f89e4c"
    > 📷 **[请在此处插入:调用登录接口返回 Token 成功的 Postman 截图]**

步骤 2:携带 Token 请求受保护接口 /user/info

  • 请求类型POST
  • 请求路径/user/info
  • 请求头:必须添加 Authorization: Bearer 4a719c8f-a9eb-4a6c-9407-cb8b23f89e4c
  • 预期响应
    控制台及响应体正常返回该登录用户的昵称、账户等 JSON 信息。此时 SaTokenContextInterceptor 已成功将用户信息搬运到了 SessionContext 中。
    在这里插入图片描述

步骤 3:验证权限越权拦截

  • 测试手段:使用不具备 super-admin 角色的普通用户 Token,请求 /user/check-role
  • 预期响应
    接口被 SaInterceptor 拦截,系统抛出 NotRoleException 或触发全局异常处理器返回自定义错误消息,例如:“无此角色:super-admin”。
    在这里插入图片描述
    拥有权限返回
    在这里插入图片描述

生产踩坑与避坑指南

在生产环境中落地 Sa-Token 会面临更复杂的网络和高并发场景,以下整理了两个极高频的真实事故案例,请注意规避:

🚨 事故 1:Tomcat 线程池复用引发的用户上下文串流与越权

  • 问题现象
    在高并发情况下,A 用户访问系统时,接口中 SessionContext.getInstance().get() 竟然拿到了 B 用户的信息,导致严重的数据越权读取。
  • 原因分析
    Web 容器(如 Tomcat)底层采用线程池来处理 HTTP 请求。
    SaTokenContextInterceptorpreHandle 方法中,我们将用户信息存入了当前线程 of ThreadLocal(即 SessionContext)里。
    如果请求在执行过程中抛出未捕获异常、或者由于某些原因没有调用 afterCompletion 进行清理,该线程被收回线程池并重新分配给另一个请求时,新请求就会读取到上一次未清除的残留数据。
  • ✅ 解决方案
    必须在拦截器的 afterCompletion 中,强制调用清理方法,哪怕请求抛出了异常,也必须清空。
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 彻底清空 ThreadLocal 变量,防患于未然
        SessionContext.getInstance().clear();
    }
    

🚨 事故 2:Redis 序列化 ClassCastException 导致系统瘫痪

  • 问题现象
    系统热部署或升级微服务版本后,原本正常运行的系统突然全线报错:java.lang.ClassCastException: cn.xf.basedemo.common.model.LoginUser cannot be cast to...,导致所有用户被迫下线且无法登录。
  • 原因分析
    由于引入了 sa-token-redis-jackson,Sa-Token 会把 LoginUser 实体通过 Jackson 序列化后存入 Redis Session 中。
    Jackson 默认会带入类的全路径名。如果应用重启、代码重构移动了类路径、或者包路径在多个子服务中不完全一致,Jackson 反序列化时就会因为类定义不匹配而报错。
  • ✅ 解决方案
    1. 首选方案:避免将复杂的自定义实体对象直接存入 Sa-Token Session。可以将用户信息转化为 Map<String, Object> 或直接存入 JSON 格式的字符串。
    2. 备选方案:如果必须存对象,务必在 LoginUser 中显式声明一个固定的 serialVersionUID,并且确保各个微服务模块中的包路径完全一致。

总结

通过 UUID + Redis 模式的 Sa-Token 架构,我们以极低的代码成本在 Spring Boot 3 中落地了功能强大且便于实时管控的 RBAC 权限体系。
同时,利用双拦截器,我们实现了安全鉴权与线程上下文(ThreadLocal)的无缝集成,保障了用户信息的全局随取随用。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐