本文从我本人参与的一个 ERP 项目的认证模块出发,由浅入深地讲解 Sa-Token 如何实现登录校验、权限鉴权、Session 管理,以及如何用 Redis 做 Session 共享来优化鉴权性能。读完本文,你将理解 Sa-Token 的完整流转过程,而不仅仅是"怎么用"。


一、Sa-Token 是什么?

在 Java 后端开发中,登录认证和权限校验几乎是每个项目的刚需。传统方案要么自己手写拦截器 + JWT,要么引入 Spring Security 这种"重量级"框架——前者重复造轮子,后者学习曲线陡峭。

Sa-Token 是一个轻量级的 Java 权限认证框架,它的核心设计理念是:一行代码完成认证鉴权

// 登录
StpUtil.login(userId);

// 校验登录
StpUtil.checkLogin();

// 校验权限
StpUtil.checkPermission("user:add");

就这么简单。但简单不代表简陋,Sa-Token 覆盖了登录认证、权限认证、Session 会话、单点登录、OAuth2.0 等几乎所有认证场景。

Sa-Token 的核心架构分为四层:

应用层 (Controller / Service)
       ↓
StpUtil (工具类门面层,提供静态方法)
       ↓
StpLogic (核心逻辑层,所有鉴权逻辑在这里)
       ↓
SaTokenDao (数据持久层接口)
       ↓
具体实现 (内存 / Redis / 等)
  • StpUtil 是业务代码直接调用的入口,本质上是一个静态工具类的门面
  • StpLogic 是真正的核心逻辑所在,登录、校验、Session 操作都在这里
  • SaTokenDao 是数据存取的抽象接口,默认用内存实现,引入 sa-token-redis-jackson 后就切换为 Redis 实现

理解了这个分层,后面看任何 Sa-Token 的行为,你都能知道它"在哪一层做的"。


二、实现登录认证

2.1 引入依赖

首先在 pom.xml 中引入 Sa-Token 和 Redis 相关依赖:

<!-- Sa-Token -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-redis-jackson</artifactId>
</dependency>

<!-- Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Redis 连接池 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

引入 sa-token-redis-jackson 后,Sa-Token 会自动将所有数据(Token 映射、Session 数据)存入 Redis,无需额外配置。这就是 Sa-Token 的"插件化"设计——换一个依赖,底层存储就换了,上层代码完全不用改。

2.2 Sa-Token 配置

application.yml 中添加 Sa-Token 配置:

sa-token:
  # Token 名称,同时也是 Cookie 名称和 Header key
  token-name: satoken
  # Token 有效期(秒),默认 30 天
  timeout: 2592000
  # Token 临时有效期(指定时间内无操作就过期),-1 表示不限制
  active-timeout: -1
  # 是否允许同一账号多地同时登录
  is-concurrent: true
  # 多人登录同一账号时,是否共用一个 Token(false = 每次登录新建 Token)
  is-share: false
  # Token 风格
  token-style: uuid
  # 是否开启日志
  is-log: true

同时需要配置 Redis 连接信息(application-dev.yml):

spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      lettuce:
        pool:
          max-active: 16
          max-wait: -1ms
          max-idle: 8
          min-idle: 0

2.3 登录接口实现

下面是我本人项目中的登录流程,核心思路是:查用户 → 校验密码 → Sa-Token 登录 → 存 Session → 返回 Token。

// 登录核心流程(伪代码)
public LoginResponse login(LoginRequest request) {
    // 1. 根据账号查询用户(联查部门、角色、权限)
    LoginUser loginUser = loginUserService.findByUsername(request.getUsername());

    // 2. 校验密码
    if (!passwordUtil.matches(request.getPassword(), loginUser.getPasswordHash())) {
        throw new BizException("用户名或密码错误");
    }

    // 3. Sa-Token 登录(核心:传入 userId,生成 Token 并建立映射)
    StpUtil.login(loginUser.getUserId());

    // 4. 将用户信息存入 Session(后续鉴权直接从 Redis 取,不再查库)
    UserContext.setCurrentUser(loginUser);

    // 5. 返回 Token 给前端
    LoginResponse response = new LoginResponse();
    response.setToken(StpUtil.getTokenValue());     // Token 值
    response.setTokenName(StpUtil.getTokenName());   // Header key(即 "satoken")
    response.setUser(loginUser);
    return response;
}

其中 LoginUser 是存入 Session 的用户快照,包含 userIdusernameroleCodespermissionCodes 等鉴权所需字段。LoginResponse 返回给前端的 tokentokenName,前端后续请求需以 tokenName 作为 Header key 携带 Token。

核心就是 StpUtil.login(loginUser.getUserId()) 这一行。调用后,Sa-Token 会生成一个 UUID Token,并在 Redis 中建立 Token → userId 的映射。后续请求只要带上这个 Token,Sa-Token 就能识别出是哪个用户。


三、实现路由拦截

登录接口本身当然不能被拦截,否则用户永远无法登录。其他所有接口都需要校验登录状态。Sa-Token 通过 Spring MVC 拦截器来实现这一点。

3.1 拦截器配置

下面是我本人项目中的拦截器配置:

// SaTokenConfig.java
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
                .addPathPatterns("/**")
                .excludePathPatterns(
                        // 登录接口放行
                        "/auth/login",
                        // Knife4j 文档放行
                        "/doc.html",
                        "/swagger-resources",
                        "/swagger-resources/**",
                        "/v3/api-docs",
                        "/v3/api-docs/**",
                        "/webjars/**",
                        "/favicon.ico"
                );
    }
}

这段配置的含义:

  • addPathPatterns("/**"):拦截所有路径
  • excludePathPatterns(...):排除登录接口和 API 文档路径
  • SaInterceptor(handle -> StpUtil.checkLogin()):拦截器的核心逻辑就是调用 StpUtil.checkLogin()

当请求到达时,SaInterceptor 会先执行 StpUtil.checkLogin()。如果当前请求没有携带有效的 Token,就会抛出 NotLoginException,请求被拦截。如果 Token 有效,请求放行,继续执行后续的业务逻辑。

3.2 退出登录

退出登录只需要一行代码:

StpUtil.logout();

这会清除 Redis 中当前 Token 对应的所有映射和 Session 数据,之后这个 Token 就失效了。


四、实现权限校验

4.1 实现 StpInterface 接口

Sa-Token 的权限校验(如 StpUtil.checkPermission("user:add"))需要知道当前用户拥有哪些权限和角色。这些数据从哪来?Sa-Token 不会帮你查数据库,它定义了一个接口 StpInterface你必须实现这个接口,告诉 Sa-Token 当前用户有哪些权限和角色

这是 Sa-Token 官方给出的示例代码:

// 官方示例:StpInterface 的基本实现
@Component
public class StpInterfaceImpl implements StpInterface {

    @Autowired
    private PermissionService permissionService;

    /**
     * 返回一个账号所拥有的权限码集合
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        // 根据 loginId 查询数据库,获取该用户的权限码列表
        Long userId = Long.parseLong(loginId.toString());
        return permissionService.getPermissionsByUserId(userId);
    }

    /**
     * 返回一个账号所拥有的角色标识集合
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        // 根据 loginId 查询数据库,获取该用户的角色编码列表
        Long userId = Long.parseLong(loginId.toString());
        return permissionService.getRolesByUserId(userId);
    }
}

可以看到,官方示例的实现方式是每次鉴权都查数据库。当调用 StpUtil.checkPermission("user:add") 时,Sa-Token 会调用 getPermissionList() 获取权限列表,然后在列表中查找是否包含 "user:add"

4.2 使用注解鉴权

实现了 StpInterface 之后,就可以在 Controller 中使用注解进行权限校验:

// 需要登录才能访问
@SaCheckLogin
@GetMapping("/user/info")
public Result getUserInfo() { ... }

// 需要 user:add 权限才能访问
@SaCheckPermission("user:add")
@PostMapping("/user/add")
public Result addUser() { ... }

// 需要 admin 角色
@SaCheckRole("admin")
@DeleteMapping("/user/delete/{id}")
public Result deleteUser() { ... }

也可以在代码中手动校验:

// 校验权限,没有则抛异常
StpUtil.checkPermission("user:add");

// 判断是否有权限,返回 boolean
StpUtil.hasPermission("user:add");

4.3 问题:每次鉴权都查数据库?

按照官方示例的实现方式,每次调用 StpUtil.checkPermission() 都会触发 StpInterfaceImpl.getPermissionList(),进而查询数据库。在一个权限校验密集的系统中,一个页面可能涉及多个接口,每个接口都可能校验权限,数据库压力可想而知。

而用户信息和权限是改动极少的低频变更数据,每次都查库完全是浪费。有没有更好的方案?


五、深入机制:Sa-Token 登录校验的底层原理

在讲优化方案之前,我们需要先搞清楚 Sa-Token 的登录校验机制到底是怎么运作的。理解了底层,优化思路自然就出来了。

5.1 StpUtil.login() 存了什么?

当调用 StpUtil.login(userId) 时,Sa-Token 在底层做了以下事情:

  1. 检查此账号是否已被封禁(如果之前调用了 StpUtil.disable() 封禁账号,这里会抛异常)
  2. 生成 Token 凭证:根据配置的 token-style(我们用的是 uuid)生成一个唯一的 token 字符串
  3. 在 Redis 中建立 Token → userId 的映射satoken:login:token:{tokenValue}userId
  4. 在 Redis 中建立 userId → Token 列表的映射satoken:login:session:{userId}[token1, token2, ...](因为同一账号可能多处登录)
  5. 创建 Account-Sessionsatoken:session:account:{userId}{}(空 Session)
  6. 将 Token 写入响应:默认写入 Cookie,也可以通过响应体返回给前端

用 Redis 的视角来看,登录后 Redis 中多了这些 key:

satoken:login:token:abc-123-uuid    →  10001          (Token → userId)
satoken:login:session:10001         →  [abc-123-uuid]  (userId → Token列表)
satoken:session:account:10001       →  {}              (Account-Session,暂为空)

5.2 StpUtil.checkLogin() 怎么知道登录没有?

当请求到达拦截器,调用 StpUtil.checkLogin() 时,核心逻辑如下:

// StpLogic 核心逻辑(简化版)
public void checkLogin() {
    // 1. 从请求中获取 Token 值
    String tokenValue = getTokenValue();

    // 2. 如果 Token 为空,抛出未登录异常
    if (tokenValue == null || tokenValue.isEmpty()) {
        throw new NotLoginException(...);
    }

    // 3. 根据 Token 从 Redis 获取对应的 userId
    Object loginId = getLoginIdByToken(tokenValue);

    // 4. 如果找不到对应的 userId,说明 Token 无效或已过期
    if (loginId == null) {
        throw new NotLoginException(...);
    }

    // 5. 校验通过,可能还会续签 Token 有效期
}

关键就在第 1 步 getTokenValue()。Sa-Token 从请求中读取 Token 的优先级顺序是:

Header → Cookie → Request Body (parameter)

具体来说:

  1. 先从 Header 读:读取请求头中名为 satoken(配置中 token-name: satoken)的字段值
  2. 再从 Cookie 读:读取 Cookie 中名为 satoken 的值
  3. 最后从请求参数读:读取 URL 参数或请求体中名为 satoken 的值

这三个读取开关可以分别通过配置控制:

sa-token:
  is-read-head: true    # 是否从 Header 读取 Token(默认 true)
  is-read-cookie: true  # 是否从 Cookie 读取 Token(默认 true)
  is-read-body: true    # 是否从请求参数读取 Token(默认 true)

5.3 Sa-Token 是怎么拿到当前请求的 Header 和 Cookie 的?

这是很多人困惑的地方:StpUtil.checkLogin() 是一个静态方法调用,它怎么知道当前请求是谁发的?

答案在 Spring 框架的 RequestContextHolder。整个调用链如下:

StpUtil.checkLogin()

StpLogic.getTokenValue()

SaHolder.getRequest()

SaManager.getSaTokenContext()

SaTokenContextForSpring.getRequest()

SpringMVCUtil.getRequest()

RequestContextHolder
.getRequestAttributes()

ThreadLocal 中取出
当前线程的 HttpServletRequest

request.getHeader('satoken')
读取 Token 值

RequestContextHolder 是 Spring 提供的工具类,它内部使用 ThreadLocal 将当前线程的 HttpServletRequest 绑定到线程上。Spring MVC 在处理每个请求时,都会将 Request 对象存入 ThreadLocal,这样在任何代码位置都能通过 RequestContextHolder 获取到当前请求——这就是线程隔离

所以,StpUtil.checkLogin() 能读到 Header 中的 Token,本质上是 Spring MVC 通过 ThreadLocal 把当前请求对象暴露出来了,Sa-Token 只是读取了这个请求对象中的 Header 信息

5.4 完整的登录校验流程

把上面的知识点串起来,一个请求从到达后端到完成登录校验的完整流程:

Redis StpLogic SaInterceptor 前端 Redis StpLogic SaInterceptor 前端 SaHolder.getRequest() → RequestContextHolder → ThreadLocal → HttpServletRequest → request.getHeader("satoken") → "abc-123" 请求 Header: satoken=abc-123 StpUtil.checkLogin() getTokenValue() GET satoken:login:token:abc-123 "10001" (userId) 校验 Token 有效期、账号状态 校验通过 放行,执行业务逻辑

如果 Redis 中找不到对应的 userId,或者 Token 已过期,就会抛出 NotLoginException,拦截器捕获异常后返回未登录的响应。


六、Sa-Token 的 Session 模型:不是 HttpSession!

在讲优化方案之前,还需要理解 Sa-Token 的 Session 体系。因为后面的 UserContext 优化方案,正是基于 Sa-Token 的 Account-Session 实现的。

6.1 先说清楚:这不是 HttpSession

提到 Session,很多人第一反应是 JSP/Servlet 中的 HttpSession。那是一种基于 Cookie 的会话机制:

  • 客户端首次访问时,服务器创建一个 HttpSession,生成一个 JSESSIONID
  • JSESSIONID 通过 Cookie 返回给浏览器
  • 后续请求浏览器自动带上这个 Cookie,服务器根据 JSESSIONID 找到对应的 Session

这种机制有几个致命问题:

  1. 每个访问者都创建 Session:即使只是浏览了一个页面,服务器也会创建 Session 对象,浪费资源
  2. 绑定客户端:Session 是分配给客户端的,同一账号在 PC 和 APP 登录会被识别为两个不相干的会话
  3. 强依赖 Cookie:在不支持 Cookie 的环境(如小程序、APP)下失效
  4. 无法跨域:Cookie 受同源策略限制,前后端分离部署时跨域无法携带
  5. 无法共享:多实例部署时,Session 存在各自 JVM 内存中,无法共享

Sa-Token 的 Session 完全不同。 它只在调用 StpUtil.login(id) 时才创建,是分配给账号 id 的,而不是分配给客户端的。它不依赖 Cookie,数据存在 Redis 中,天然支持分布式共享。

6.2 三种 Session 模型

Sa-Token 提供了三种 Session 模型,适用于不同场景:

Account-Session(账号会话)—— 最常用

以账号 id 为主,同一账号的所有登录共享同一个 Session。

// 获取当前账号的 Account-Session
SaSession session = StpUtil.getSession();

// 存取数据
session.set("loginUser", loginUser);
LoginUser user = (LoginUser) session.get("loginUser");

特点:只要 Token 指向的账号 id 一致,对应的 Session 对象就是同一个。也就是说,用户在 PC 和 APP 上登录同一账号,它们操作的是同一个 Session,数据天然同步。

这是最常用的 Session 模型,我本人项目就是用 Account-Session 来存储用户信息的。

Token-Session(令牌会话)

以 Token 为主,不同的 Token 对应不同的 Session。

// 获取当前 Token 的 Token-Session
SaSession session = StpUtil.getTokenSession();

特点:即使两个设备登录了同一账号,只要它们的 Token 不同,对应的 Token-Session 就不同。这为不同端的独立数据读写提供了支持。

典型场景:实现"指定设备超过 2 小时无操作就自动下线"。如果把最后操作时间存在 Account-Session 里,PC 端无操作但 APP 端在操作,PC 端也不会过期——因为它们共享同一个 Session。而存在 Token-Session 里,每个设备的操作记录是独立的,互不影响。

Custom-Session(自定义会话)

以自定义 key 为主,不依赖账号 id 或 Token。

// 获取指定 key 的 Custom-Session
SaSession session = SaSessionCustomUtil.getSessionById("goods-10001");

特点:只要 key 相同,就是同一个 Session。完全自定义,灵活度最高。

典型场景:按业务实体维度缓存数据,比如按商品 ID 缓存库存信息。

三种 Session 的关系

假设三个客户端登录同一账号,且配置了不共享 Token(is-share: false),Session 模型如下:

Account-Session (userId=10001)
  ├── Token-A (PC端)  → Token-Session-A
  ├── Token-B (APP端) → Token-Session-B
  └── Token-C (H5端)  → Token-Session-C

Custom-Session (key="goods-10001")  ← 独立于账号和Token
Session 类型 绑定维度 共享范围 常用程度
Account-Session 账号 id 同一账号所有登录共享 最常用
Token-Session Token 值 同一 Token 独享 特定场景
Custom-Session 自定义 key 同 key 共享 按需使用

七、优化:UserContext 方案,告别每次查库

7.1 回顾问题

按照前面官方示例的 StpInterfaceImpl 实现,每次鉴权都要查数据库:

请求到达 → StpUtil.checkLogin() 校验通过 → 拿到 loginId(userId)
→ StpInterfaceImpl.getPermissionList() 被调用
→ 根据 userId 查数据库获取角色和权限 → 返回权限列表
→ StpUtil.checkPermission() 判断权限

这意味着每个需要鉴权的请求都要查数据库,而用户信息和权限是改动极少的低频变更数据,每次都查库完全是浪费。

7.2 优化思路:在 Session 中留下快照

既然 Sa-Token 的 Session 数据存在 Redis 中,而 Redis 的读取速度是微秒级的,那我们完全可以在登录时把用户信息一次性查出来,存入 Account-Session,后续请求直接从 Session 中取,不再查库。

这就是我本人项目中 UserContext 的设计思路:

// UserContext.java
public class UserContext {

    private static final String SESSION_KEY = "loginUser";

    private UserContext() {
    }

    /**
     * 获取当前登录用户,未登录返回 null
     */
    public static LoginUser getCurrentUser() {
        if (!StpUtil.isLogin()) {
            return null;
        }
        return (LoginUser) StpUtil.getSession().get(SESSION_KEY);
    }

    /**
     * 将登录用户信息存入 Session
     */
    public static void setCurrentUser(LoginUser loginUser) {
        StpUtil.getSession().set(SESSION_KEY, loginUser);
    }

    /**
     * 获取当前用户ID,未登录返回 null
     */
    public static Long getUserId() {
        LoginUser user = getCurrentUser();
        return user != null ? user.getUserId() : null;
    }

    /**
     * 获取当前用户登录账号,未登录返回 null
     */
    public static String getUsername() {
        LoginUser user = getCurrentUser();
        return user != null ? user.getUsername() : null;
    }

    /**
     * 判断当前用户是否超级管理员,未登录返回 false
     */
    public static boolean isAdmin() {
        LoginUser user = getCurrentUser();
        return user != null && Boolean.TRUE.equals(user.getIsAdmin());
    }
}

登录时存入(回顾前面的 AuthServiceImpl):

// 3. Sa-Token 登录
StpUtil.login(loginUser.getUserId());

// 5. 存入 Session(关键优化点)
UserContext.setCurrentUser(loginUser);

后续请求直接取

// 任何需要当前用户信息的地方
LoginUser user = UserContext.getCurrentUser();

7.3 优化后的 StpInterfaceImpl

有了 UserContext,StpInterfaceImpl 就不需要查数据库了:

// StpInterfaceImpl.java — 优化后,从 Session 读取权限
@Component
public class StpInterfaceImpl implements StpInterface {

    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        LoginUser user = UserContext.getCurrentUser();
        if (user == null) {
            return Collections.emptyList();
        }
        return user.getPermissionCodes();
    }

    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        LoginUser user = UserContext.getCurrentUser();
        if (user == null) {
            return Collections.emptyList();
        }
        return user.getRoleCodes();
    }
}

对比一下优化前后的差异:

对比项 优化前(查库) 优化后(Session 缓存)
数据来源 每次查 MySQL 从 Redis Session 读取
响应时间 1-10ms(数据库查询) 0.01-0.1ms(Redis 读取)
数据库压力 高(每个鉴权请求一次查询) 低(仅登录时查询一次)
数据时效性 实时最新 可能有短暂延迟

7.4 权限变更怎么办?

有人可能会问:如果管理员修改了某个角色的权限,Session 中缓存的旧权限怎么办?

答案很简单:直接更新 Redis 中的 Session 数据,或者清除该用户对应的 Session 让其重新登录。

// 方案一:更新 Session 中的权限数据
StpUtil.getSessionByLoginId(userId).set("loginUser", newLoginUser);

// 方案二:强制用户重新登录(踢下线)
StpUtil.logout(userId);

由于权限变更是低频操作,而鉴权是高频操作,这种"写少读多"的场景非常适合用缓存。即使权限变更了,也只需要一次 Redis 写操作就能让所有实例同步生效——因为 Session 数据存在 Redis 中,所有服务实例读的都是同一份数据。


八、深入优化:Redis Session 共享与跨域问题

8.1 为什么需要 Redis 存储 Session?

如果 Sa-Token 使用默认的内存存储,会面临以下问题:

  1. 服务重启数据丢失:Token 映射和 Session 数据都在 JVM 内存中,重启就没了,所有用户需要重新登录
  2. 多实例无法共享:部署多个服务实例时,用户在实例 A 登录,请求被负载均衡到实例 B 就无法识别
  3. 内存占用:随着在线用户增多,JVM 内存压力越来越大

引入 sa-token-redis-jackson 后,所有数据存入 Redis:

  • 服务重启不丢失:Redis 是独立进程,重启 Java 应用不影响
  • 多实例共享:所有实例连接同一个 Redis,读取同一份数据
  • 内存可控:Redis 的内存管理比 JVM 更可控,且支持淘汰策略

8.2 Cookie 无法跨域的问题

Sa-Token 默认在登录成功后,会将 Token 写入浏览器的 Cookie 中。后续请求时,浏览器会自动携带 Cookie,Sa-Token 从 Cookie 中读取 Token 完成校验。

这个默认行为在前后端同域部署时没问题,但在前后端分离、跨域部署时会出问题:

  • 后端在 localhost:8080,前端在 localhost:5173,端口不同,属于跨域
  • Cookie 受同源策略限制,无法跨域携带
  • 即使服务端设置了 Cookie,前端后续请求也不会自动带上

8.3 解决方案:Token 通过 Header 传递

我本人项目采用前后端分离架构,因此采用了 Token 通过 Header 传递 的方案:

  1. 登录成功后,后端不依赖 Cookie,而是将 Token 通过响应体返回给前端
  2. 前端将 Token 存储在 localStorage
  3. 后续请求时,前端手动将 Token 添加到请求 Header 中
  4. 后端从 Header 中读取 Token 完成校验

前端代码(伪代码):

// 登录
const res = await axios.post('/auth/login', { username, password });
const { token, tokenName } = res.data;
localStorage.setItem(tokenName, token);

// 后续请求
axios.get('/some-api', {
    headers: {
        [tokenName]: localStorage.getItem(tokenName)  // Header: satoken=xxx
    }
});

8.4 为什么 StpUtil.checkLogin() 能从 Header 中读取 Token?

回到前面的问题。我们在配置中设置了 token-name: satoken,Sa-Token 的 Token 读取逻辑(在 StpLogic 中)大致如下:

// StpLogic.getTokenValue() 简化逻辑
public String getTokenValue() {
    // 1. 先从当前请求的 Storage(ThreadLocal 缓存)中取
    String tokenValue = getFromStorage();
    if (tokenValue != null) return tokenValue;

    // 2. 从 Header 中读取(key = 配置的 token-name,即 "satoken")
    if (config.getIsReadHead()) {
        tokenValue = SaHolder.getRequest().getHeader(config.getTokenName());
        if (tokenValue != null) return tokenValue;
    }

    // 3. 从 Cookie 中读取
    if (config.getIsReadCookie()) {
        tokenValue = SaHolder.getRequest().getCookieValue(config.getTokenName());
        if (tokenValue != null) return tokenValue;
    }

    // 4. 从请求参数中读取
    if (config.getIsReadBody()) {
        tokenValue = SaHolder.getRequest().getParam(config.getTokenName());
        if (tokenValue != null) return tokenValue;
    }

    return null;
}

所以,当前端在请求头中加上 satoken: xxx 时,Sa-Token 在第 2 步就能读到,根本不需要走到 Cookie 那一步。

整个链路

前端请求
Header: satoken=abc-123

Spring MVC
Request 存入 ThreadLocal

SaInterceptor 拦截
StpUtil.checkLogin()

StpLogic.getTokenValue()

SaHolder.getRequest()
→ RequestContextHolder
→ ThreadLocal
→ HttpServletRequest

request.getHeader
('satoken')
→ 'abc-123'

Redis: GET
satoken:login:token:abc-123
→ userId

校验通过


九、完整流转图

把所有知识点串起来,从登录到鉴权的完整流程:

MySQL Redis StpLogic AuthService Controller 前端 MySQL Redis StpLogic AuthService Controller 前端 ═══ 登录阶段 ═══ ═══ 鉴权阶段 ═══ SaInterceptor 拦截 权限校验 POST /auth/login {username, password} login(request) 查询用户信息 + 角色 + 权限 LoginUser {userId, roleCodes, permissionCodes} StpUtil.login(userId) SET satoken:login:token:{token} → userId SET satoken:session:account:{userId} → {} Token 生成完成 UserContext.setCurrentUser(loginUser) SET satoken:session:account:{userId} → {"loginUser": {...}} LoginResponse {token, tokenName, user} 返回 Token 请求 Header: satoken=xxx StpUtil.checkLogin() getTokenValue() → 从 Header 读取 GET satoken:login:token:{token} userId 校验通过 StpUtil.checkPermission("user:add") StpInterfaceImpl.getPermissionList() GET satoken:session:account:{userId} LoginUser {permissionCodes: [...]} 判断 "user:add" 是否在列表中 校验通过 返回业务数据

关键点:整个鉴权过程中,没有任何数据库查询,所有数据都从 Redis 中读取。Redis 的读取速度是微秒级的,远快于数据库的毫秒级,所以鉴权几乎是无感的。


十、总结

回顾全文,我们按照"实现 → 机制 → 优化"的顺序,由浅入深地讲解了 Sa-Token 的认证鉴权体系:

实现层面:

  1. 登录StpUtil.login(userId) 生成 Token、建立映射、创建 Session
  2. 路由拦截SaInterceptor + StpUtil.checkLogin() 拦截未登录请求
  3. 权限校验:实现 StpInterface 接口,告诉 Sa-Token 当前用户的权限和角色

机制层面:
4. Token 读取:Sa-Token 按 Header → Cookie → Body 的优先级读取 Token,底层通过 Spring 的 RequestContextHolder + ThreadLocal 获取当前请求对象
5. Session 模型:Account-Session(按账号共享,最常用)、Token-Session(按 Token 隔离)、Custom-Session(自定义 key),这不是 HttpSession,是 Sa-Token 自己的 Session 体系

优化层面:
6. UserContext 方案:登录时将用户信息存入 Account-Session,鉴权时从 Redis 读取,避免每次查库,性能提升数量级
7. Redis Session 共享:引入 sa-token-redis-jackson,Session 数据存 Redis,多实例共享、服务重启不丢失
8. 跨域方案:前后端分离时,Token 不走 Cookie,改用 Header 传递,前端手动添加 satoken 请求头

核心思想:Sa-Token 的 Session 方案本质上是用 Redis 做了一个高性能的"用户快照缓存"。登录时一次性加载用户数据到 Redis,后续鉴权全部走 Redis 读取,既保证了性能,又通过 Redis 天然实现了分布式 Session 共享。权限变更时只需更新 Redis 中的 Session 数据,所有实例立即生效。

这就是 Sa-Token 的优雅之处——用最简单的 API,背后是精心设计的 Session 体系和存储策略

Logo

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

更多推荐