Sa-Token 是一款轻量级、高内聚的 Java 权限认证框架,底层依托 双层 Redis 会话映射、AOP 声明式切面拦截 与 多端自适应凭证读取机制,将复杂的登录认证、权限管控、单点登录与踢人下线浓缩为极简的开箱即用 API。

本文结合真实项目重构经验,深度对比传统 Cookie + Session、纯 JWT 与 Sa-Token 的架构选型优劣,并完整演示基于 Spring Boot 与 AOP 依赖实现零侵入权限架构的工程实践。

 

1. 组件定位与核心价值#


1.1 传统鉴权方案的工程痛点#

在企业级后端系统的演进过程中,身份认证(Authentication)与权限校验(Authorization)往往是架构选型的第一道分水岭。传统的 Cookie + Session 模式与前后端分离时代盛行的纯 JWT(JSON Web Token)模式,在实际生产落地时各自暴露出明显的工程短板:

(1) 传统 Cookie + Session 模式的局限:

  • 多端适配性差:原生依赖浏览器 Cookie 容器自动管理 JSESSIONID,面对移动端 App、微信小程序或跨域名系统时,凭证传递与跨域配置(CORS)极其繁琐。
  • 集群水平扩展重:单机内存 Session 无法直接支撑负载均衡集群,必须额外引入 Spring Session 与 Redis 完成分布式会话改造。
  • 业务侵入性强:鉴权逻辑往往需要显式获取 HttpServletRequest 与 HttpSession 对象,导致业务层与 Servlet 容器紧耦合。

(2) 纯 JWT 无状态模式的业务翻车点:

  • 无法主动吊销令牌:纯 JWT 依赖服务端 CPU 验签而不存储状态,一旦令牌签发,在过期时间到达前服务端无法主动使其失效。面对封禁违规账号、修改密码后强制下线、运营后台踢人等高频业务诉求时束手无策。
  • 同端互斥与顶号困难:由于服务端不感知当前在线客户端数量,无法原生实现“新手机登录自动将旧手机顶下线”的防盗号逻辑。
  • 续期与载荷膨胀矛盾:为兼顾安全性与防掉线体验,往往需要前后端协同设计 AccessToken 与 RefreshToken 双令牌无感刷新链路;若在 Payload 中塞入过多权限数据则会导致每次 HTTP 请求头体积膨胀。

1.2 为什么选择 Sa-Token + AOP 架构#

在实际项目实践中,采用导入 Sa-Token 的 AOP 依赖配合 Redis 的架构模式,兼顾了 JWT 的无感传输体验与 Session 的强中心化管控能力:

对比维度传统 Cookie + Session纯 JWT 方案Sa-Token + AOP 方案
多端与跨域兼容差(强依赖浏览器同源策略)优(通过 HTTP Header 传输)极佳(自动从 Header、Cookie、Query 智能读取 Token)
主动踢人下线支持(销毁服务端 Session)极难(需额外引入黑名单打破无状态)开箱即用(一行 StpUtil.kickout 即可即时封禁)
同端互斥与顶号需手写复杂会话映射表不支持(服务端无在线设备感知)内置支持(配置 is-concurrent 即可一键切换)
细粒度权限校验需手写拦截器解析 HandlerMethod需结合自定义注解与切面自行封装导入 AOP 依赖后直接通过注解声明式拦截
代码侵入性高(强耦合 HttpServletRequest)中(需维护 JwtUtil 与上下文 ThreadLocal)极低(静态工具类 + 切面注解零侵入解耦)

 

2. 环境准备与前置条件#


2.1 基础运行环境要求#

在集成 Sa-Token 与 AOP 鉴权模块前,需确保本地或服务器具备以下基础研发环境:

  • JDK 版本:JDK 17 及以上(Spring Boot 3.x 基准环境;若使用 Spring Boot 2.x 则选用 JDK 8/11 对应 Starter)。
  • 应用框架:Spring Boot 3.2.x。
  • 分布式缓存:Redis 6.0 及以上(用于持久化 Sa-Token 的 Token-Id 与 Account-Session 双层映射状态,防止应用重启导致全员掉线)。

2.2 Redis 服务端启动与验证#

(1) Windows 环境管理与启动 Redis 服务(以管理员权限打开 PowerShell):

1# 启动 Redis Windows 服务
2Start-Service Redis
3
4# 查看 Redis 服务运行状态
5Get-Service Redis

(2) Linux 环境或基于 Docker 启动 Redis 节点并验证连通性:

1# 基于 Docker 快速启动 Redis 节点
2docker run -d --name redis-satoken -p 6379:6379 redis:7.2-alpine
3
4# 验证 Redis 服务连通性(控制台返回 PONG 表示就绪)
5redis-cli -h 127.0.0.1 -p 6379 ping

 

3. 依赖声明#


3.1 Maven 核心依赖配置#

在项目中采用 Sa-Token 的 AOP 切面方案完成权限拦截,除了引入基础的 Spring Boot Starter 与 Redis 序列化插件外,核心在于导入 spring-boot-starter-aop 依赖,从而激活 Sa-Token 内置的全局注解切面。

配置文件默认路径:pom.xml

 1<dependencies>
 2    <!-- 1. Spring Boot Web 基础依赖 -->
 3    <dependency>
 4        <groupId>org.springframework.boot</groupId>
 5        <artifactId>spring-boot-starter-web</artifactId>
 6    </dependency>
 7
 8    <!-- 2. Sa-Token 权限认证核心 Starter(适配 Spring Boot 3.x) -->
 9    <dependency>
10        <groupId>cn.dev33</groupId>
11        <artifactId>sa-token-spring-boot3-starter</artifactId>
12        <version>1.39.0</version>
13    </dependency>
14
15    <!-- 3. Spring AOP 切面支持(驱动 @SaCheckLogin / @SaCheckRole / @SaCheckPermission 注解鉴权) -->
16    <dependency>
17        <groupId>org.springframework.boot</groupId>
18        <artifactId>spring-boot-starter-aop</artifactId>
19    </dependency>
20
21    <!-- 4. Sa-Token 整合 Redis 持久化插件(使用 Jackson 序列化替代默认 JDK 序列化) -->
22    <dependency>
23        <groupId>cn.dev33</groupId>
24        <artifactId>sa-token-redis-jackson</artifactId>
25        <version>1.39.0</version>
26    </dependency>
27
28    <!-- 5. Apache Commons Pool2(Spring Data Redis 连接池必选依赖) -->
29    <dependency>
30        <groupId>org.apache.commons</groupId>
31        <artifactId>commons-pool2</artifactId>
32    </dependency>
33
34    <!-- 6. Lombok 简化实体与响应样板代码 -->
35    <dependency>
36        <groupId>org.projectlombok</groupId>
37        <artifactId>lombok</artifactId>
38        <optional>true</optional>
39    </dependency>
40</dependencies>

3.2 AOP 鉴权与拦截器鉴权的选型对比#

Sa-Token 提供了路由拦截器(SaInterceptor)与 AOP 切面两种注解鉴权驱动方式,在工程实践中直接导入 AOP 依赖具有以下显著优势:

  • 零配置类启动:无需手写实现 WebMvcConfigurer 接口的配置类去注册 addInterceptors 与 excludePathPatterns 排他路径,导入 AOP 依赖后所有鉴权注解自动生效。
  • 全链路跨层保护:Spring MVC 拦截器仅能守护 Controller 层的 HTTP 请求入口;而基于 Spring AOP 的切面不仅能贴在 Controller 接口上,还能直接下沉到 Service 核心业务方法、定时任务甚至内部 RPC 接口上进行细粒度权限防护。

 

4. 客户端配置声明#


4.1 核心参数与 Redis 连接配置#

通过配置文件即可一站式声明 Token 名称、绝对有效期、临时活跃续签策略以及同端互斥登录行为,彻底省去手写 JWT 刷新逻辑的繁琐工作。

配置文件默认路径:src/main/resources/application.yml

 1server:
 2  port: 8080 # 应用服务监听端口
 3
 4spring:
 5  data:
 6    redis:
 7      host: 127.0.0.1 # Redis 服务器地址
 8      port: 6379 # Redis 服务器端口
 9      database: 0 # 会话存储使用的 Redis 库索引
10      timeout: 5000ms # 连接超时时间
11      lettuce:
12        pool:
13          max-active: 16 # 连接池最大活跃连接数
14          max-idle: 8 # 连接池最大空闲连接数
15          min-idle: 2 # 连接池最小空闲连接数
16
17# Sa-Token 核心参数配置
18sa-token:
19  token-name: Authorization # 前端传递 Token 的请求头或 Cookie 键名
20  timeout: 2592000 # Token 绝对有效期,单位秒(默认 30 天:2592000)
21  active-timeout: 1800 # Token 临时活跃有效期(30 分钟无操作则冻结,有操作自动续签,-1 代表不限制)
22  is-concurrent: false # 是否允许同一账号多地同时登录(true: 允许共用, false: 新登录顶替旧登录)
23  is-share: false # 多人登录同一账号时是否共用同一个 Token(true: 共用一码, false: 每次新建)
24  token-style: uuid # Token 生成风格(可选 uuid, simple-uuid, random-32, tik 等)
25  is-log: true # 是否在控制台打印操作日志,便于开发期追踪鉴权链路
26  is-read-header: true # 是否尝试从 HTTP Header 中读取 Token(适配 App、小程序与前后端分离)
27  is-read-cookie: true # 是否尝试从 Cookie 中读取 Token(兼顾传统同域 Web 页面)

4.2 关键配置项底层机制解析#

  • active-timeout 双重续签机制:完美解决了纯 JWT 续期难的痛点。当 timeout 设为 30 天、active-timeout 设为 1800 秒时,只要用户在 30 分钟内有任意接口调用,Sa-Token 会自动刷新其活跃时间戳;一旦闲置超过 30 分钟则立即冻结会话,兼顾长期免登与闲置防盗。
  • is-concurrent 顶号互斥控制:仅需将该开关设为 false,框架底层便会自动在 Redis 中维护 Account-Session 的设备 Token 队列,新设备登录时自动将旧设备 Token 标记为 BE_REPLACED(被顶下线)状态。

 

5. 核心业务代码实现#


5.1 统一响应结果实体定义#

定义标准化的前后端交互响应模型 Result,采用 Lombok 注解简化样板代码并实现 Serializable 序列化接口:

 1package com.example.auth.common;
 2
 3import lombok.AllArgsConstructor;
 4import lombok.Data;
 5import lombok.NoArgsConstructor;
 6
 7import java.io.Serializable;
 8
 9@Data
10@NoArgsConstructor
11@AllArgsConstructor
12public class Result<T> implements Serializable {
13
14    private static final long serialVersionUID = 1L;
15
16    private Integer code;
17    private String message;
18    private T data;
19
20    public static <T> Result<T> success(T data) {
21        return new Result<>(200, "操作成功", data);
22    }
23
24    public static <T> Result<T> fail(Integer code, String message) {
25        return new Result<>(code, message, null);
26    }
27}

5.2 自定义权限数据源实现(StpInterface)#

创建权限数据源组件 StpInterfaceImpl 并实现 StpInterface 接口。当请求触发 @SaCheckRole 或 @SaCheckPermission 切面注解时,Sa-Token 会自动回调该组件加载当前登录账号的角色与权限码列表,并结合 Account-Session 实现缓存加速:

 1package com.example.auth.security;
 2
 3import cn.dev33.satoken.session.SaSession;
 4import cn.dev33.satoken.stp.StpInterface;
 5import cn.dev33.satoken.stp.StpUtil;
 6import org.springframework.stereotype.Component;
 7
 8import java.util.ArrayList;
 9import java.util.List;
10
11@Component
12public class StpInterfaceImpl implements StpInterface {
13
14    /**
15     * 返回指定账号所拥有的权限码集合(带 SaSession 缓存优化)
16     */
17    @Override
18    public List<String> getPermissionList(Object loginId, String loginType) {
19        SaSession session = StpUtil.getSessionByLoginId(loginId);
20        return session.get("PERMISSION_LIST", () -> {
21            // 模拟从 RBAC 数据库查询权限码列表,仅在首次鉴权时执行并自动写入 Redis 会话缓存
22            List<String> permissions = new ArrayList<>();
23            Long userId = Long.valueOf(loginId.toString());
24            if (userId == 10001L) {
25                permissions.add("user:list");
26                permissions.add("user:add");
27                permissions.add("user:kickout");
28                permissions.add("order:export");
29            } else {
30                permissions.add("user:list");
31            }
32            return permissions;
33        });
34    }
35
36    /**
37     * 返回指定账号所拥有的角色标识集合(带 SaSession 缓存优化)
38     */
39    @Override
40    public List<String> getRoleList(Object loginId, String loginType) {
41        SaSession session = StpUtil.getSessionByLoginId(loginId);
42        return session.get("ROLE_LIST", () -> {
43            List<String> roles = new ArrayList<>();
44            Long userId = Long.valueOf(loginId.toString());
45            if (userId == 10001L) {
46                roles.add("super-admin");
47            } else {
48                roles.add("common-user");
49            }
50            return roles;
51        });
52    }
53}

5.3 基于 AOP 注解的业务控制器实现#

编写业务控制器 UserAuthController,无需手写任何 Token 签名解析或 ThreadLocal 绑定代码,直接通过 StpUtil 工具类与 AOP 切面注解完成登录签发、权限校验与强制踢人下线:

 1package com.example.auth.controller;
 2
 3import cn.dev33.satoken.annotation.SaCheckLogin;
 4import cn.dev33.satoken.annotation.SaCheckPermission;
 5import cn.dev33.satoken.annotation.SaCheckRole;
 6import cn.dev33.satoken.annotation.SaMode;
 7import cn.dev33.satoken.stp.SaTokenInfo;
 8import cn.dev33.satoken.stp.StpUtil;
 9import com.example.auth.common.Result;
10import org.springframework.web.bind.annotation.*;
11
12import java.util.HashMap;
13import java.util.Map;
14
15@RestController
16@RequestMapping("/api/auth")
17public class UserAuthController {
18
19    /**
20     * 1. 用户登录接口:一行代码完成凭证签发与 Redis 会话持久化
21     */
22    @PostMapping("/login")
23    public Result<Map<String, String>> doLogin(@RequestParam Long userId, @RequestParam String password) {
24        if (!"123456".equals(password)) {
25            return Result.fail(401, "账号或密码错误");
26        }
27        // 核心登录调用:自动生成 Token 并写入 Redis 与响应头
28        StpUtil.login(userId);
29        SaTokenInfo tokenInfo = StpUtil.getTokenInfo();
30
31        Map<String, String> tokenMap = new HashMap<>();
32        tokenMap.put("tokenName", tokenInfo.getTokenName());
33        tokenMap.put("tokenValue", tokenInfo.getTokenValue());
34        return Result.success(tokenMap);
35    }
36
37    /**
38     * 2. 基础登录校验:通过 @SaCheckLogin 切面拦截未登录访问
39     */
40    @SaCheckLogin
41    @GetMapping("/profile")
42    public Result<Map<String, Object>> getUserProfile() {
43        // 直接从上下文获取当前登录用户 ID,彻底告别手动维护 ThreadLocal
44        Long currentUserId = StpUtil.getLoginIdAsLong();
45        Map<String, Object> profile = new HashMap<>();
46        profile.put("userId", currentUserId);
47        profile.put("roles", StpUtil.getRoleList());
48        profile.put("permissions", StpUtil.getPermissionList());
49        return Result.success(profile);
50    }
51
52    /**
53     * 3. 细粒度组合权限校验:要求同时具备 user:add 与 order:export 权限码
54     */
55    @SaCheckPermission(value = {"user:add", "order:export"}, mode = SaMode.AND)
56    @PostMapping("/export-order")
57    public Result<String> exportOrderData() {
58        return Result.success("订单报表导出任务已启动");
59    }
60
61    /**
62     * 4. 运营后台强制踢人下线(解决纯 JWT 无法服务端主动吊销的核心痛点)
63     */
64    @SaCheckRole("super-admin")
65    @SaCheckPermission("user:kickout")
66    @PostMapping("/kickout/{targetUserId}")
67    public Result<String> kickoutUser(@PathVariable Long targetUserId) {
68        // 将目标用户直接踢下线,其客户端持有的 Token 立即转为被踢状态
69        StpUtil.kickout(targetUserId);
70        return Result.success("账号 " + targetUserId + " 已被强制踢下线");
71    }
72}

5.4 全局鉴权异常统一捕获#

创建全局异常处理器 GlobalExceptionHandler。当 AOP 切面拦截到未登录、无角色或无权限请求时,Sa-Token 会抛出包含具体场景枚举的异常,通过统一捕获可向前端精准返回掉线原因(如区分 Token 过期、异地顶号下线与管理员封禁):

 1package com.example.auth.exception;
 2
 3import cn.dev33.satoken.exception.NotLoginException;
 4import cn.dev33.satoken.exception.NotPermissionException;
 5import cn.dev33.satoken.exception.NotRoleException;
 6import com.example.auth.common.Result;
 7import org.springframework.web.bind.annotation.ExceptionHandler;
 8import org.springframework.web.bind.annotation.RestControllerAdvice;
 9
10@RestControllerAdvice
11public class GlobalExceptionHandler {
12
13    /**
14     * 捕获未登录异常(精准识别 5 种细分掉线原因)
15     */
16    @ExceptionHandler(NotLoginException.class)
17    public Result<Void> handleNotLoginException(NotLoginException e) {
18        String message;
19        if (NotLoginException.NOT_TOKEN.equals(e.getType())) {
20            message = "未能读取到有效 Token,请先登录";
21        } else if (NotLoginException.INVALID_TOKEN.equals(e.getType())) {
22            message = "Token 无效或签名伪造";
23        } else if (NotLoginException.TOKEN_TIMEOUT.equals(e.getType())) {
24            message = "登录已过期,请重新登录";
25        } else if (NotLoginException.BE_REPLACED.equals(e.getType())) {
26            message = "您的账号已在其他设备登录,当前会话已下线";
27        } else if (NotLoginException.KICK_OUT.equals(e.getType())) {
28            message = "您的账号已被管理员强制踢下线";
29        } else {
30            message = "当前会话未登录";
31        }
32        return Result.fail(401, message);
33    }
34
35    /**
36     * 捕获角色缺失异常
37     */
38    @ExceptionHandler(NotRoleException.class)
39    public Result<Void> handleNotRoleException(NotRoleException e) {
40        return Result.fail(403, "权限不足:缺少角色标识 [" + e.getRole() + "]");
41    }
42
43    /**
44     * 捕获细粒度权限码缺失异常
45     */
46    @ExceptionHandler(NotPermissionException.class)
47    public Result<Void> handleNotPermissionException(NotPermissionException e) {
48        return Result.fail(403, "权限不足:缺少操作权限 [" + e.getPermission() + "]");
49    }
50}

 

6. 接口与功能验证#


6.1 登录与 AOP 权限拦截验证#

通过 curl 命令依次模拟普通用户登录→越权调用导出接口→管理员强制踢人下线→验证原 Token 即时失效的完整链路:

(1) 普通用户(userId=20002)调用登录接口获取 Token:

1curl -X POST "http://localhost:8080/api/auth/login?userId=20002&password=123456"

(2) 携带普通用户 Token 访问受 @SaCheckPermission 保护的导出接口(触发 AOP 越权拦截):

1curl -X POST "http://localhost:8080/api/auth/export-order" \
2     -H "Authorization: d8a7f6e5-4321-4b1a-9c8d-123456789abc"

(3) 超级管理员(userId=10001)登录并将普通用户 20002 强制踢下线:

1curl -X POST "http://localhost:8080/api/auth/kickout/20002" \
2     -H "Authorization: a1b2c3d4-9999-4e5f-8a7b-987654321def"

(4) 普通用户再次携带原未过期 Token 访问个人信息接口(验证服务端即时吊销能力):

1curl -X GET "http://localhost:8080/api/auth/profile" \
2     -H "Authorization: d8a7f6e5-4321-4b1a-9c8d-123456789abc"

6.2 控制台与响应预期输出#

上述验证请求对应的服务端 JSON 响应报文与控制台日志输出如下:

(1) 步骤 1 登录成功签发 Token 的 JSON 响应:

1{
2  "code": 200,
3  "message": "操作成功",
4  "data": {
5    "tokenName": "Authorization",
6    "tokenValue": "d8a7f6e5-4321-4b1a-9c8d-123456789abc"
7  }
8}

(2) 步骤 2 触发 AOP 切面 NotPermissionException 越权拦截的 JSON 响应:

1{
2  "code": 403,
3  "message": "权限不足:缺少操作权限 [user:add]",
4  "data": null
5}

(3) 步骤 3 管理员执行 StpUtil.kickout 强制踢人成功的 JSON 响应:

1{
2  "code": 200,
3  "message": "操作成功",
4  "data": "账号 20002 已被强制踢下线"
5}

(4) 步骤 4 原 Token 即刻失效并精准识别 KICK_OUT 状态的 JSON 响应:

1{
2  "code": 401,
3  "message": "您的账号已被管理员强制踢下线",
4  "data": null
5}

(5) 应用服务端控制台输出的 Sa-Token 实时审计日志:

1SA-TOKEN [INFO] ---> 账号 20002 登录成功 (loginType=login), 会话凭证 token=d8a7f6e5-4321-4b1a-9c8d-123456789abc
2SA-TOKEN [INFO] ---> 账号 10001 登录成功 (loginType=login), 会话凭证 token=a1b2c3d4-9999-4e5f-8a7b-987654321def
3SA-TOKEN [INFO] ---> 账号 20002 被强制踢下线 (loginType=login), 关联凭证 token=d8a7f6e5-4321-4b1a-9c8d-123456789abc 已标记为 KICK_OUT(-5)

 

7. 总结#


7.1 核心链路回顾#

回顾整个鉴权架构的选型与落地,采用 Sa-Token 结合 Spring AOP 切面的设计模式之所以在实战中显著优于传统 Cookie + Session 与纯 JWT,核心在于其实现了两大维度的架构解耦:

(1) 凭证传输与会话状态解耦:既保留了无状态 Header 令牌对移动端 App、小程序及前后端分离跨域架构的天然亲和力,又通过底层 Redis 双层映射解决了纯 JWT 无法主动注销、无法顶号互斥与续期繁琐的工程痛点。

(2) 业务逻辑与安全切面解耦:引入 AOP 依赖后,鉴权校验从手写 Filter/Interceptor 与 ThreadLocal 模板代码中彻底剥离,简化为语义清晰的 @SaCheckLogin、@SaCheckRole 与 @SaCheckPermission 注解声明。

7.2 生产落地建议#

  • 善用 SaSession 缓存降低数据库压力:在 StpInterfaceImpl 实现类中,务必通过 StpUtil.getSessionByLoginId 将角色与权限列表缓存在 Redis 会话中,并在后台修改角色权限时调用 session.delete(“PERMISSION_LIST”) 实现按需刷新,避免每次触发 AOP 切面时穿透查询 MySQL。
  • 按需启用 JWT 混血插件:若微服务网关层需要在不访问 Redis 的前提下解析基础用户身份,可叠加引入 sa-token-jwt 插件并配置为 simple 混血模式,兼顾网关本地解析效率与服务端会话强管控能力。