登录注册番外篇(二) - Sa-Token:权限认证完全指南

第一章. 告诉框架"谁能做什么"——StpInterface

阶段式学习路径

番外篇二完整讲解了登录会话的生命周期——Token 怎么生成、怎么续签、怎么下线。但到目前为止,我们的项目只解决了"你是谁"这个问题,任何登录用户都能访问任何接口。

要实现"张三能发文章,李四只能看文章,王五连登录都进不来",还差一个关键环节:告诉框架每个用户有哪些权限。这就是本章的主角 StpInterface


1.1. 为什么是接口而不是配置

在动手写代码之前,先理解一个设计决策:Sa-Token 为什么不直接提供"查数据库获取权限"的功能,而是要求开发者实现一个接口?

不同项目的权限数据来源千差万别。有的项目权限数据存在 MySQL 的三张关联表里,有的存在 MongoDB 的文档里,有的通过远程 RPC 调用其他服务获取,有的小型项目直接硬编码在代码里。如果 Sa-Token 内置了"查 MySQL 获取权限"的逻辑,那么使用 MongoDB 或 RPC 的项目就完全无法使用这个框架。

通过 StpInterface 接口,Sa-Token 把"权限数据从哪来"的决策权完全交给了开发者。框架只定义两个方法的签名:

1
2
3
4
5
// 给定用户 ID,返回该用户拥有的权限码列表
List<String> getPermissionList(Object loginId, String loginType);

// 给定用户 ID,返回该用户拥有的角色标识列表
List<String> getRoleList(Object loginId, String loginType);

你在实现里查数据库、查缓存、查配置文件都行,框架只认你返回的 List<String>

两个方法都有 loginType 参数,这是为多业务线场景设计的——一个电商系统可能同时存在普通用户(loginType = "login")和商家(loginType = "merchant"),两套账号体系对应完全不同的权限表。通过 loginType 区分,可以在同一个实现类里根据不同业务线返回不同的权限数据。本篇只用默认的 "login" 类型,多业务线场景留到番外篇四展开。


1.2. Sa-Token 的权限模型:字符串即权限

在实现 StpInterface 之前,还有一个基础概念需要建立:Sa-Token 的权限模型。

它非常简单——权限用字符串表示,角色也用字符串表示。框架不强制你使用 RBAC 模型,不要求你建特定的数据库表,不限制你的权限编码格式。它只需要你回答两个问题:这个用户有哪些权限码?这个用户有哪些角色标识?

权限码的格式完全由你决定。业界最常见的格式是 资源:操作,比如:

1
2
3
4
5
6
7
user:add          用户新增
user:delete 用户删除
user:update 用户修改
user:view 用户查看
article:publish 文章发布
article:review 文章审核
order:cancel 订单取消

角色标识通常用简单的单词:

1
2
3
admin     管理员
editor 编辑
user 普通用户

Sa-Token 在鉴权时会调用你实现的 StpInterface,拿到权限列表和角色列表后,判断列表中是否包含目标值,决定是否放行。整个校验逻辑由框架完成,你只负责提供数据。


1.3. 实现 StpInterface

现在把权限数据源接入进来。为了聚焦 Sa-Token 本身的鉴权机制,我们先用硬编码 Map 模拟权限数据,后续替换为数据库查询时只需要修改这一个类,其余代码完全不用动。

com.example.authsatoken 包下新建 auth 子包,然后创建实现类:

📄 src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java(新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.example.authsatoken.auth;

import cn.dev33.satoken.stp.StpInterface;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* 权限数据源实现类
* Sa-Token 每次进行权限校验时,都会调用此类的方法获取当前用户的权限和角色数据
*
* 当前使用硬编码 Map 模拟,实际项目中替换为数据库查询即可,其余代码无需改动
*/
@Component
public class StpInterfaceImpl implements StpInterface {

/**
* 角色 → 权限码列表的映射
*
* admin 角色:拥有用户模块的全部操作权限
* user 角色:只有查看类权限
*/
private static final Map<String, List<String>> ROLE_PERMISSION_MAP = Map.of(
"admin", List.of("user:add", "user:delete", "user:update", "user:view"),
"user", List.of("user:view", "article:view")
);

/**
* 用户 ID(String 形式)→ 角色标识列表的映射
*
* 10001 对应 admin 账号,拥有 admin 角色
* 10002 对应 user 账号,拥有 user 角色
*
* 注意:Sa-Token 内部将 loginId 统一转为 String 存储,
* 所以这里的 Map key 必须是 String 类型,不能是 Long
*/
private static final Map<String, List<String>> USER_ROLE_MAP = Map.of(
"10001", List.of("admin"),
"10002", List.of("user")
);

/**
* 返回指定账号所拥有的权限码集合
*
* 本实现采用 RBAC 思路:先查角色,再根据角色聚合权限
* 实际项目中建议对此方法的返回值做缓存(如 Redis),避免每次请求都查数据库
*/
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
List<String> permissions = new ArrayList<>();
// 先拿到该用户的所有角色,再根据角色聚合权限码
List<String> roles = getRoleList(loginId, loginType);
for (String role : roles) {
List<String> perms = ROLE_PERMISSION_MAP.get(role);
if (perms != null) {
permissions.addAll(perms);
}
}
return permissions;
}

/**
* 返回指定账号所拥有的角色标识集合
*/
@Override
public List<String> getRoleList(Object loginId, String loginType) {
// loginId 是 Object 类型,必须转为 String 才能与 Map 的 key 匹配
List<String> roles = USER_ROLE_MAP.get(String.valueOf(loginId));
// 若用户 ID 不在映射中(如测试账号),返回空列表而非 null,避免 NPE
return roles != null ? roles : List.of();
}
}

1.4. 与登录接口的数据对齐

StpInterfaceImplUSER_ROLE_MAP 的 key 是用户 ID,而用户 ID 是登录时由我们的业务逻辑决定的。需要确认两边的数据完全对齐。

回顾番外篇二第一章升级后的登录接口,USER_DB 的映射是:

1
2
admin → userId 10001
user → userId 10002

对应 USER_ROLE_MAP

1
2
"10001" → admin 角色
"10002" → user 角色

两边完全对齐。admin 账号登录后,Sa-Token 存储的 loginId 是 "10001",当框架调用 getRoleList("10001", "login") 时,能从 USER_ROLE_MAP 中正确取到 ["admin"],进而从 ROLE_PERMISSION_MAP 中聚合出 ["user:add", "user:delete", "user:update", "user:view"]

整条数据流是:登录时的 userId → Redis 存储 → 鉴权时框架调用 StpInterface → 返回权限数据 → 框架完成校验。任何一环的 ID 不一致都会导致权限查不到,排查时从这条链路逐段检查。


1.5. 本章总结

本章回顾

本章完成了权限认证体系的数据层建设。我们首先从设计角度理解了 StpInterface 接口方案的价值:框架只约定接口规范,权限数据来源完全由开发者决定,loginType 参数进一步支持多业务线场景。随后建立了 Sa-Token 权限模型的基础认知:权限码和角色标识都是字符串,资源:操作 是最常见的权限码格式。在实现层面,我们创建了 StpInterfaceImpl,用两个硬编码 Map 分别维护"用户 ID → 角色"和"角色 → 权限码"的映射,体现了 RBAC 的核心两级查询结构。最后确认了权限数据与登录 userId 的对齐关系,确保整条数据流连贯。

核心汇总表

组件职责关键点
StpInterface定义权限数据查询规范框架只认接口,不关心实现细节
StpInterfaceImpl提供具体的权限数据必须加 @Component,全项目唯一
getPermissionList()返回用户的权限码列表loginId 实际是 String,注意类型转换
getRoleList()返回用户的角色标识列表未找到时返回空列表,避免 NPE
权限码格式示例含义
资源:操作user:add用户模块新增操作
资源:*user:*用户模块所有操作(通配符,第五章讲解)
**所有模块所有操作(超级管理员,第五章讲解)
数据对齐检查点说明
登录接口的 userId决定了 loginId 存入 Redis 的值
USER_ROLE_MAP 的 key必须是 String 类型,与 Redis 中的 loginId 完全一致
String.valueOf(loginId)从 Object 类型转换,防止类型不匹配导致查询结果为 null

第二章. 注解鉴权——声明式的权限控制

阶段式学习路径

第一章完成了权限认证的数据层——框架现在知道每个用户有哪些权限和角色了。但"知道归知道",还差最后一步:在接口上声明"访问这个接口需要什么权限",让框架在请求到达方法之前自动完成校验。

注解鉴权就是最直观的表达方式。把权限要求写在方法上,业务代码和鉴权逻辑一目了然,互不干扰。本章先完成注解生效的前置步骤,再系统覆盖 Sa-Token 提供的全部 8 种鉴权注解。


2.1. 前置步骤:注册拦截器

Sa-Token 使用全局拦截器完成注解鉴权功能,为了不为项目带来不必要的性能负担,拦截器默认处于关闭状态。因此,使用注解鉴权之前,必须手动将 SaInterceptor 注册到项目中——这是最常见的入门坑,注解加了但请求完全不受拦截,原因往往就在这里。

com.example.authsatoken 包下新建 config 子包,创建配置类:

📄 src/main/java/com/example/authsatoken/config/SaTokenConfig.java(新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.authsatoken.config;

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

/**
* Sa-Token 拦截器配置
* 注册 SaInterceptor,使 Controller 方法上的鉴权注解生效
*
* 当前配置:无参版本,只开启注解鉴权,不附加任何路由规则
* 第三章将升级为有参版本,在 Lambda 中配置路由级别的访问策略
*/
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
// new SaInterceptor() 不传参数:扫描注解并执行鉴权,没有注解的接口直接放行
registry.addInterceptor(new SaInterceptor())
.addPathPatterns("/**");
}
}

new SaInterceptor() 不传参数时,拦截器只做一件事:扫描 Controller 方法上是否有 Sa-Token 的鉴权注解,有则执行对应校验,没有则直接放行。这意味着当前阶段未加注解的接口对所有人开放,包括未登录用户——第三章引入路由规则后才会改变这个默认行为。


2.2. 补全异常处理

注解鉴权失败时,Sa-Token 会抛出两种新的异常类型,它们不是 NotLoginException,需要在 GlobalExceptionHandler 中单独捕获。

  • NotPermissionException:权限码校验失败,用户没有访问该接口所需的权限码
  • NotRoleException:角色校验失败,用户没有访问该接口所需的角色

这两种异常对应的 HTTP 状态码是 403(Forbidden)

与番外篇二第四章的 401(Unauthorized)语义不同:

  • 401 表示"你还没有证明你是谁"

  • 403 表示"我知道你是谁,但你没有权限做这件事"

前端可以根据状态码的不同决定是跳转到登录页(401)还是展示"权限不足"提示(403)。

📄 src/main/java/com/example/authsatoken/exception/GlobalExceptionHandler.java(修改,追加两个方法)

在已有的 handleNotLoginException 方法下方追加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import cn.dev33.satoken.exception.NotPermissionException;
import cn.dev33.satoken.exception.NotRoleException;

/**
* 捕获权限码校验失败异常
* 触发场景:用户已登录,但不拥有接口要求的权限码
* e.getPermission() 返回校验失败的具体权限码,方便日志定位
*/
@ExceptionHandler(NotPermissionException.class)
public SaResult handleNotPermissionException(NotPermissionException e) {
return SaResult.error("权限不足,缺少权限:" + e.getPermission()).setCode(403);
}

/**
* 捕获角色校验失败异常
* 触发场景:用户已登录,但不拥有接口要求的角色
* e.getRole() 返回校验失败的具体角色标识
*/
@ExceptionHandler(NotRoleException.class)
public SaResult handleNotRoleException(NotRoleException e) {
return SaResult.error("权限不足,缺少角色:" + e.getRole()).setCode(403);
}

2.3. Sa-Token 全部注解一览

Sa-Token 一共提供 8 种鉴权注解,覆盖了从登录校验到 API 签名的各类场景。以上注解都可以加在类上,代表为这个类下的所有方法统一进行鉴权。

我们在 com.example.authsatoken.controller 包下新建演示控制器:

📄 src/main/java/com/example/authsatoken/controller/PermissionController.java(新建)


2.3.1. @SaCheckLogin:登录校验

最基础的门槛——只校验"是否已登录",不关心角色和权限:

1
2
3
4
5
6
// 登录校验:只有登录之后才能进入该方法
@SaCheckLogin
@GetMapping("/loginRequired")
public SaResult loginRequired() {
return SaResult.ok("已通过登录校验");
}

它和手动调用 StpUtil.checkLogin() 的效果完全等价,只是换成了声明式写法。适合所有登录用户都能访问的接口,比如"获取个人信息"。


2.3.2. @SaCheckRole:角色校验

校验当前用户是否拥有指定角色。框架调用 StpInterfaceImpl.getRoleList() 拿到角色列表,再判断是否包含注解指定的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 角色校验(单角色):必须拥有 admin 角色
@SaCheckRole("admin")
@GetMapping("/adminOnly")
public SaResult adminOnly() {
return SaResult.ok("你拥有 admin 角色");
}

// 角色校验(多角色 AND 模式,默认):必须同时拥有 admin 和 editor 两个角色
@SaCheckRole({"admin", "editor"})
@GetMapping("/adminAndEditor")
public SaResult adminAndEditor() {
return SaResult.ok("你同时拥有 admin 和 editor 角色");
}

// 角色校验(多角色 OR 模式):拥有 admin 或 editor 任一角色即可
@SaCheckRole(value = {"admin", "editor"}, mode = SaMode.OR)
@GetMapping("/adminOrEditor")
public SaResult adminOrEditor() {
return SaResult.ok("你拥有 admin 或 editor 角色");
}

mode 有两种取值:SaMode.AND(默认)要求全部满足;SaMode.OR 满足其一即可。


2.3.3. @SaCheckPermission:权限码校验

校验当前用户是否拥有指定权限码。框架调用 StpInterfaceImpl.getPermissionList(),其余逻辑与角色校验完全对称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 权限码校验(单权限):必须拥有 user:add 权限
@SaCheckPermission("user:add")
@GetMapping("/canAddUser")
public SaResult canAddUser() {
return SaResult.ok("你拥有用户新增权限");
}

// 权限码校验(多权限 AND 模式,默认):必须同时拥有 user:add 和 user:delete
@SaCheckPermission({"user:add", "user:delete"})
@GetMapping("/canAddAndDeleteUser")
public SaResult canAddAndDeleteUser() {
return SaResult.ok("你同时拥有用户新增和删除权限");
}

// 权限码校验(多权限 OR 模式):拥有 user:add 或 user:delete 任一即可
@SaCheckPermission(value = {"user:add", "user:delete"}, mode = SaMode.OR)
@GetMapping("/canAddOrDeleteUser")
public SaResult canAddOrDeleteUser() {
return SaResult.ok("你拥有用户新增或删除权限");
}

orRole:角色权限双重 OR 校验

假设有这样的业务场景:接口在"拥有 user:add 权限"或"拥有 admin 角色"时均可访问。@SaCheckPermission 提供了 orRole 参数来处理这种跨类型 OR 条件,比嵌套 @SaCheckOr 更简洁:

1
2
3
4
5
6
// 拥有 user:add 权限,或者拥有 admin 角色,满足其一即可
@SaCheckPermission(value = "user:add", orRole = "admin")
@GetMapping("/addUserOrAdmin")
public SaResult addUserOrAdmin() {
return SaResult.ok("通过权限或角色校验");
}

orRole 有三种写法,分别对应不同的角色逻辑:

1
2
3
4
5
6
7
8
9
// 写法一:需要拥有 admin 角色
@SaCheckPermission(value = "user:add", orRole = "admin")

// 写法二:拥有 admin、manager、staff 三个角色中的任意一个即可(OR 关系)
@SaCheckPermission(value = "user:add", orRole = {"admin", "manager", "staff"})

// 写法三:必须同时拥有 admin、manager、staff 三个角色(AND 关系)
// 注意:写法三是把三个角色写在同一个字符串里,逗号在同一元素内部
@SaCheckPermission(value = "user:add", orRole = {"admin, manager, staff"})

写法二和写法三的区别在于数组元素的数量:多个元素是 OR 关系,单个元素内部用逗号分隔是 AND 关系。


2.3.4. @SaCheckSafe:二级认证校验

校验当前会话是否已完成二级认证,番外篇二第七章中已详细讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 必须通过默认二级认证才能访问
@SaCheckSafe
@GetMapping("/safeRequired")
public SaResult safeRequired() {
return SaResult.ok("通过二级认证");
}

// 必须通过指定业务标识的二级认证才能访问
@SaCheckSafe("delete-project")
@GetMapping("/safeDeleteProject")
public SaResult safeDeleteProject() {
return SaResult.ok("通过删除项目的二级认证");
}

2.3.5. @SaCheckDisable:账号封禁服务校验

校验当前账号是否被封禁指定服务,番外篇二第六章中已详细讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 校验当前账号是否被封禁(无服务标识,校验整体封禁)
@SaCheckDisable
@PostMapping("/send")
public SaResult send() {
return SaResult.ok("消息发送成功");
}

// 校验当前账号的 comment 服务是否被封禁
@SaCheckDisable("comment")
@PostMapping("/comment")
public SaResult comment() {
return SaResult.ok("评论发布成功");
}

// 同时校验多个服务,任意一个被封禁就无法进入方法
@SaCheckDisable({"comment", "place-order", "open-shop"})
@PostMapping("/multiCheck")
public SaResult multiCheck() {
return SaResult.ok("多服务校验通过");
}

2.3.6. @SaCheckHttpBasic / @SaCheckHttpDigest:HTTP 认证校验

用于需要 HTTP Basic 或 HTTP Digest 认证的接口,常见于内部 API 或监控端点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 只有通过 Http Basic 认证后才能进入该方法
// account 格式为 "用户名:密码"
@SaCheckHttpBasic(account = "sa:123456")
@GetMapping("/basicAuth")
public SaResult basicAuth() {
return SaResult.ok("通过 Http Basic 认证");
}

// 只有通过 Http Digest 认证后才能进入该方法
@SaCheckHttpDigest(value = "sa:123456")
@GetMapping("/digestAuth")
public SaResult digestAuth() {
return SaResult.ok("通过 Http Digest 认证");
}

这两个注解与 Token 体系无关,是独立的 HTTP 协议层认证,通常用于保护不需要完整登录流程的运维接口。


2.3.7. @SaCheckSign:API 签名校验

用于跨系统调用的接口签名验证,属于 Sa-Token 扩展能力,本系列不展开讲解,了解存在即可:

1
2
3
4
5
6
// 校验 API 签名参数,用于跨系统的接口调用验证
@SaCheckSign
@GetMapping("/apiSign")
public SaResult apiSign() {
return SaResult.ok("API 签名校验通过");
}

2.3.8. @SaIgnore:忽略所有校验

@SaIgnore 是优先级最高的注解——当它出现时,所有其他鉴权注解和路由拦截器规则都会被忽略,请求直接进入方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 整个类需要登录才能访问
@SaCheckLogin
@RestController
@RequestMapping("/user")
public class UserController {

// 但这个接口单独加了 @SaIgnore,可以游客访问
@SaIgnore
@GetMapping("/getList")
public SaResult getList() {
return SaResult.ok("游客可访问的列表");
}

// 其他方法仍然需要登录
@GetMapping("/info")
public SaResult info() {
return SaResult.ok("登录才能查看的信息");
}
}

@SaIgnore 的几个重要特性:

  • 修饰方法时:只有这个方法可以游客访问,类上的其他注解对此方法无效。
  • 修饰时:这个类下所有接口都可以游客访问。
  • 具有最高优先级:与其他鉴权注解同时出现时,其他注解全部被忽略。
  • 同样可以忽略掉路由拦截器的规则(第三章会演示)。

@SaIgnore 的忽略效果只针对 SaInterceptor 拦截器和 AOP 注解鉴权生效,对自定义拦截器与 Spring Security 等第三方过滤器无效。


2.4. 多注解叠加 = AND 关系

当一个方法上写了多个鉴权注解时,它们之间天然是 AND 关系——必须全部满足,才能进入方法,只要有一个不满足就抛出异常:

1
2
3
4
5
6
7
8
// 必须同时满足:已登录 + 拥有 admin 角色 + 拥有 user:add 权限
@SaCheckLogin
@SaCheckRole("admin")
@SaCheckPermission("user:add")
@GetMapping("/strictControl")
public SaResult strictControl() {
return SaResult.ok("三重校验通过");
}

这也解释了为什么 Sa-Token 没有提供 @SaCheckAnd 注解——多注解叠加本身就是 AND 语义,不需要额外的注解来声明。


2.5. @SaCheckOr:批量 OR 校验

当需要"多个条件满足其一即可"的 OR 逻辑,且条件跨越不同注解类型时,使用 @SaCheckOr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 满足以下任意一个条件即可进入方法:
// - 已登录(@SaCheckLogin)
// - 拥有 admin 角色(@SaCheckRole)
// - 拥有 user:add 权限(@SaCheckPermission)
// - 已完成二级认证(@SaCheckSafe)
// - 通过 Http Basic 认证(@SaCheckHttpBasic)
@SaCheckOr(
login = @SaCheckLogin,
role = @SaCheckRole("admin"),
permission = @SaCheckPermission("user:add"),
safe = @SaCheckSafe("update-password"),
httpBasic = @SaCheckHttpBasic(account = "sa:123456"),
disable = @SaCheckDisable("submit-orders")
)
@GetMapping("/orCheck")
public SaResult orCheck() {
return SaResult.ok("通过 OR 校验");
}

每一项属性都可以写成数组形式,数组内部的多个元素之间是 OR 关系:

1
2
3
4
5
6
7
8
9
// 只要有 login 类型账号登录,或者有 user 类型账号登录,任一满足即可通过
// (注意 type 属性涉及多账号模式,是番外篇四的内容,此处了解写法即可)
@SaCheckOr(
login = { @SaCheckLogin(type = "login"), @SaCheckLogin(type = "user") }
)
@GetMapping("/multiTypeLogin")
public SaResult multiTypeLogin() {
return SaResult.ok("通过多账号类型 OR 校验");
}

append 字段:用于追加扩展包中的注解,将它们纳入 OR 逻辑:

1
2
3
4
5
6
7
// 通过登录校验,或者提供了正确的 ApiKey,满足其一即可
@SaCheckOr(login = @SaCheckLogin, append = { SaCheckApiKey.class })
@SaCheckApiKey
@GetMapping("/loginOrApiKey")
public SaResult loginOrApiKey() {
return SaResult.ok("通过登录或 ApiKey 校验");
}

append 字段接收的是注解类型的 Class 数组,被追加的注解同时也需要写在方法上,@SaCheckOr 会在运行时读取这些注解的具体参数进行校验。


2.6. 完整的 PermissionController

将前面所有演示整合到一个控制器中:

📄 src/main/java/com/example/authsatoken/controller/PermissionController.java(完整版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.example.authsatoken.controller;

import cn.dev33.satoken.annotation.*;
import cn.dev33.satoken.util.SaResult;
import cn.dev33.satoken.util.SaMode;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* 注解鉴权演示控制器
*
* 测试账号与权限对照(来自 StpInterfaceImpl):
* admin / 123456 → userId=10001 → admin 角色 → user:add / user:delete / user:update / user:view
* user / 123456 → userId=10002 → user 角色 → user:view / article:view
*/
@RestController
@RequestMapping("/permission")
public class PermissionController {

@SaCheckLogin
@GetMapping("/loginRequired")
public SaResult loginRequired() {
return SaResult.ok("已通过登录校验");
}

@SaCheckRole("admin")
@GetMapping("/adminOnly")
public SaResult adminOnly() {
return SaResult.ok("你拥有 admin 角色");
}

@SaCheckRole(value = {"admin", "user"}, mode = SaMode.OR)
@GetMapping("/adminOrUser")
public SaResult adminOrUser() {
return SaResult.ok("你拥有 admin 或 user 角色");
}

@SaCheckPermission("user:add")
@GetMapping("/canAddUser")
public SaResult canAddUser() {
return SaResult.ok("你拥有用户新增权限");
}

@SaCheckPermission({"user:add", "user:delete"})
@GetMapping("/canAddAndDeleteUser")
public SaResult canAddAndDeleteUser() {
return SaResult.ok("你同时拥有用户新增和删除权限");
}

@SaCheckPermission(value = {"user:add", "user:delete"}, mode = SaMode.OR)
@GetMapping("/canAddOrDeleteUser")
public SaResult canAddOrDeleteUser() {
return SaResult.ok("你拥有用户新增或删除权限");
}

@SaCheckPermission(value = "user:delete", orRole = "admin")
@GetMapping("/deleteOrAdmin")
public SaResult deleteOrAdmin() {
return SaResult.ok("通过权限或角色 OR 校验");
}

@SaCheckOr(
role = @SaCheckRole("admin"),
permission = @SaCheckPermission("user:delete")
)
@GetMapping("/adminOrCanDelete")
public SaResult adminOrCanDelete() {
return SaResult.ok("你是管理员,或者拥有用户删除权限");
}
}

2.7. 测试验证:双账号对比矩阵

重启项目,用两个账号分别登录,测试各接口的实际表现。

准备工作:分别获取两个账号的 Token

1
2
POST http://localhost:8081/auth/login?username=admin&password=123456
POST http://localhost:8081/auth/login?username=user&password=123456

分别记录 Token-A(admin)和 Token-U(user)。

场景:未登录访问需要登录的接口(验证 401)

1
GET http://localhost:8081/permission/loginRequired

预期响应:

1
{ "code": 401, "msg": "未提供 Token,请先登录", "data": null }

场景:user 账号访问 adminOnly(验证 403)

1
2
GET http://localhost:8081/permission/adminOnly
Header: satoken: <Token-U>

预期响应:

1
{ "code": 403, "msg": "权限不足,缺少角色:admin", "data": null }

以下是完整的双账号测试矩阵:

接口adminuser未登录
/permission/loginRequired✅ 200✅ 200❌ 401 未提供 Token
/permission/adminOnly✅ 200❌ 403 缺少角色 admin❌ 401
/permission/adminOrUser✅ 200✅ 200❌ 401
/permission/canAddUser✅ 200❌ 403 缺少权限 user:add❌ 401
/permission/canAddAndDeleteUser✅ 200❌ 403 缺少权限 user:add❌ 401
/permission/canAddOrDeleteUser✅ 200❌ 403 缺少权限 user:add❌ 401
/permission/deleteOrAdmin✅ 200❌ 403 缺少权限 user:delete❌ 401
/permission/adminOrCanDelete✅ 200❌ 403 缺少角色 admin❌ 401

矩阵中有一个值得关注的细节:/permission/adminOrUseruser 账号是放行的——因为注解配置了 mode = SaMode.ORuser 账号虽然没有 admin 角色,但有 user 角色,满足 OR 条件中的一个,所以通过。


2.8. 本章总结

本章回顾

本章完成了注解鉴权体系的完整建设。首先注册了 SaInterceptor 拦截器——这是注解鉴权生效的前置条件,不注册则所有鉴权注解形同虚设。随后在 GlobalExceptionHandler 中追加了 NotPermissionExceptionNotRoleException 两个处理方法,配合已有的 NotLoginException 处理,构成了完整的认证授权异常响应体系,并明确了 401(未认证)与 403(已认证但权限不足)的语义边界。在注解覆盖层面,系统梳理了 Sa-Token 全部 8 种鉴权注解的用法,并重点讲解了 @SaCheckPermissionorRole 三种写法、多注解叠加等于 AND 关系的原理、以及 @SaCheckOr 的完整用法(单值、数组形式、append 字段)。最后通过双账号对比测试矩阵验证了权限隔离在实际请求中的完整表现。

核心汇总表

注解校验内容AND/OR 支持典型场景
@SaCheckLogin是否已登录所有登录用户可访问的接口
@SaCheckRole是否拥有指定角色支持,默认 AND角色专属功能
@SaCheckPermission是否拥有指定权限码支持,默认 AND接口级细粒度权限控制
@SaCheckSafe是否已完成二级认证高危操作的额外验证门槛
@SaCheckDisable指定服务是否被封禁多值时任一封禁即拦截分类封禁场景
@SaCheckHttpBasicHTTP Basic 认证内部 API / 运维接口
@SaCheckHttpDigestHTTP Digest 认证内部 API / 运维接口
@SaCheckSignAPI 签名校验跨系统接口调用
@SaIgnore忽略所有校验最高优先级公开接口豁免
orRole 写法示例语义
单角色orRole = "admin"权限码 OR admin 角色
数组多元素(OR)orRole = {"admin", "editor"}权限码 OR(admin 或 editor)
数组单元素内逗号(AND)orRole = {"admin, editor"}权限码 OR(admin 且 editor)
异常类型状态码触发场景
NotLoginException401未登录或 Token 失效
NotPermissionException403缺少所需权限码
NotRoleException403缺少所需角色
多注解组合规则语义实现方式
多注解叠加AND(全部满足)在方法上写多个注解
OR 关系满足其一即可@SaCheckOrmode = SaMode.OR

第三章. 路由拦截鉴权——集中式的批量管控

阶段式学习路径

第二章的注解鉴权是方法级的——每个接口单独声明权限要求,粒度精确,但有一个天然的局限:它是分散的。如果一个模块下有几十个接口都需要登录,就得在几十个方法上逐一加 @SaCheckLogin,不仅繁琐,而且一旦漏加某个方法,那个接口就完全暴露了。

路由拦截鉴权解决的是这个问题。在拦截器的配置中集中表达"哪些路径需要什么权限",一处配置批量生效,再也不用担心漏加注解。本章从 SaInterceptor 的有参写法切入,系统覆盖 SaRouter 的全部匹配特征和流程控制手段。


3.1. 从无参到有参:SaInterceptor 的两种工作模式

回顾第二章注册的 SaTokenConfig

1
2
registry.addInterceptor(new SaInterceptor())
.addPathPatterns("/**");

new SaInterceptor() 不传参数时,拦截器处于纯注解模式——扫描每个请求对应的 Controller 方法,有鉴权注解就执行校验,没有就直接放行。未加注解的接口对未登录用户完全开放。

new SaInterceptor(handle -> { ... }) 传入一个 Lambda 时,拦截器进入路由规则模式——在 Lambda 中用 SaRouter 工具类按路径批量声明访问策略。两种模式并不互斥:有参版本会同时执行路由规则和注解鉴权,路由规则先执行,注解鉴权后执行。

现在把 SaTokenConfig 升级为有参版本:

📄 src/main/java/com/example/authsatoken/config/SaTokenConfig.java(修改,替换 addInterceptors 方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.authsatoken.config;

import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
* Sa-Token 拦截器配置(升级版)
* 在第二章无参版本的基础上,增加路由级别的访问策略
*/
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> {

// ---- 规则一:全局登录校验 ----
// 拦截所有路径,但排除登录接口
// 效果:除了 /auth/login,其余所有接口都必须登录才能访问
SaRouter.match("/**")
.notMatch("/auth/login")
.check(r -> StpUtil.checkLogin());

// ---- 规则二:管理员模块角色校验 ----
// /admin/** 下的所有接口,额外要求 admin 角色
// 请求会先通过规则一的登录校验,再通过规则二的角色校验,两者是 AND 关系
SaRouter.match("/admin/**")
.check(r -> StpUtil.checkRole("admin"));

// ⚠️ 必须用 excludePathPatterns("/error") 排除 /error 路径
// 原因:Spring Boot 发生异常时,Tomcat 会将请求内部 forward 转发到 /error 端点。
// 但此次 forward 是在 Sa-Token 的 ThreadLocal 上下文已销毁之后发生的,
// 导致 SaInterceptor.preHandle() 被再次触发,SaRouter.match() 内部调用
// SaHolder.getRequest() 时找不到上下文,抛出 SaTokenContextException。
//
// ❌ 错误做法:在 Lambda 内部使用 .notMatch("/error")
// SaRouter.match("/**") 这行本身就需要获取当前请求的 URI,
// 还没执行到 notMatch 判断,就已经因上下文不存在而崩溃了。
//
// ✅ 正确做法:使用 Spring MVC 的 excludePathPatterns("/error")
// 这样 /error 请求在 Spring MVC 层面就被排除,
// SaInterceptor 的 preHandle() 根本不会被调用,彻底规避问题。
})).addPathPatterns("/**").excludePathPatterns("/error");
}
}

两条规则解决了两个最典型的全局诉求:规则一让"所有接口必须登录"这个要求集中到一处,不再散落在几十个注解里;规则二给 /admin/** 整个模块追加了角色防护,无论后续管理模块新增多少接口,这条规则自动覆盖。


3.2. SaRouter 匹配特征全览

SaRouter 的 API 设计成链式调用风格,match 指定拦截范围,notMatch 排除白名单,check 指定校验逻辑。除了 path 路由匹配,它还支持多种其他特征:

path 路由匹配

最常用的匹配方式,支持 Ant 风格通配符(* 匹配单层,** 匹配多层),支持 RESTful 风格路由,支持同时传入多个 path:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 匹配单个路径
SaRouter.match("/user/info").check(r -> StpUtil.checkLogin());

// 多层通配符,匹配整个模块
SaRouter.match("/admin/**").check(r -> StpUtil.checkRole("admin"));

// 同时传入多个 path,满足其一即命中
SaRouter.match("/user/**", "/goods/**", "/art/get/{id}")
.check(r -> StpUtil.checkLogin());

// notMatch 排除白名单,支持多个路径,支持通配符
SaRouter.match("/**")
.notMatch("/auth/login", "/auth/register", "/public/**")
.check(r -> StpUtil.checkLogin());

按 HTTP 方法匹配

在 RESTful 接口设计中,同一路径的不同 HTTP 方法往往需要不同的权限。SaHttpMethod 枚举提供了所有常用方法:

1
2
3
4
5
6
7
8
9
// 只拦截 POST 请求
SaRouter.match(SaHttpMethod.POST).check(r -> StpUtil.checkLogin());

// 同一路径,不同 HTTP 方法,不同权限要求
SaRouter.match("/article/**", SaHttpMethod.POST)
.check(r -> StpUtil.checkPermission("article:add"));

SaRouter.match("/article/**", SaHttpMethod.DELETE)
.check(r -> StpUtil.checkPermission("article:delete"));

按 boolean 条件和 lambda 表达式匹配

匹配条件不局限于路径,可以是任意 boolean 值或返回 boolean 的 lambda:

1
2
3
4
5
// 根据一个 boolean 条件进行匹配
SaRouter.match(StpUtil.isLogin()).check(r -> System.out.println("当前已登录"));

// 根据一个返回 boolean 结果的 lambda 表达式匹配
SaRouter.match(r -> StpUtil.isLogin()).check(r -> System.out.println("当前已登录(lambda 写法)"));

多条件无限连缀

多个 match / notMatch 可以无限连缀,所有条件之间是 AND 关系——只有全部条件都满足,才会执行最后的 check 校验函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 必须是 GET 请求,且路径以 /user/ 开头
SaRouter.match(SaHttpMethod.GET)
.match("/user/**")
.check(r -> StpUtil.checkLogin());

// 同时满足:GET 方式、/admin 开头、路径中含有 /send/、结尾不是 .js 和 .css
SaRouter
.match(SaHttpMethod.GET)
.match("/admin/**")
.match("/**/send/**")
.notMatch("/**/*.js")
.notMatch("/**/*.css")
.check(r -> StpUtil.checkRole("admin"));

3.3. 流程控制三件套:stop、back、free

除了匹配和校验,SaRouter 还提供了三个用于控制匹配流程的方法,解决"某条规则命中后不再继续匹配"的需求。

stop():停止匹配,进入 Controller

SaRouter.stop() 可以提前退出整个 Lambda 函数,跳过后续所有未执行的 match 规则:

1
2
3
4
5
6
registry.addInterceptor(new SaInterceptor(handle -> {
SaRouter.match("/**").check(r -> System.out.println("规则一:进入"));
SaRouter.match("/**").check(r -> System.out.println("规则二:进入")).stop(); // 执行完后停止
SaRouter.match("/**").check(r -> System.out.println("规则三:不会执行"));
SaRouter.match("/**").check(r -> System.out.println("规则四:不会执行"));
})).addPathPatterns("/**");

如上代码,执行到第二条规则的 stop() 时,后续的规则三、四都会被跳过,请求正常进入 Controller。

back():停止匹配,直接返回前端

SaRouter.back() 同样会停止匹配,但不进入 Controller,而是直接将参数作为返回值输出到前端

1
2
// 执行 back 后,停止匹配,不进入 Controller,直接返回字符串给前端
SaRouter.match("/user/back").back("该接口暂时不对外开放");

stop()back() 的区别:

stop()back()
停止匹配
进入 Controller
直接返回前端

free():独立作用域

free() 打开一个独立的作用域,使内部的 stop() 不再跳出整个 Lambda,而是仅仅跳出当前 free 作用域,外部的 match 规则继续正常执行:

1
2
3
4
5
6
7
8
9
// 进入 free 独立作用域
SaRouter.match("/**").free(r -> {
SaRouter.match("/a/**").check(/* 校验 a 模块 */);
SaRouter.match("/b/**").check(/* 校验 b 模块 */).stop(); // 只跳出 free,不影响外部
SaRouter.match("/c/**").check(/* 校验 c 模块 */); // 如果命中 /b,这条不会执行
});

// free 执行完毕后,外部的规则继续执行,不受 free 内部 stop 的影响
SaRouter.match("/**").check(r -> System.out.println("外部规则,始终执行"));

free() 适合"某个模块内部有复杂的互斥规则,但不希望影响模块外部的其他规则"的场景。


3.4. @SaIgnore 忽略路由拦截

第二章介绍 @SaIgnore 时提到它同样可以忽略路由拦截器的规则。这意味着即使拦截器配置了"所有路径必须登录",只要方法或类上加了 @SaIgnore,该接口就会跳过拦截器的校验,直接放行。

先配置拦截规则:

1
2
3
4
registry.addInterceptor(new SaInterceptor(handle -> {
SaRouter.match("/user/**").check(r -> StpUtil.checkPermission("user"));
SaRouter.match("/admin/**").check(r -> StpUtil.checkPermission("admin"));
})).addPathPatterns("/**");

然后在需要豁免的接口上加 @SaIgnore

1
2
3
4
5
6
// 虽然 /user/** 规则要求 user 权限,但此接口因 @SaIgnore 直接放行,游客可访问
@SaIgnore
@GetMapping("/user/getList")
public SaResult getList() {
return SaResult.ok("游客可访问的列表");
}

请求到达被 @SaIgnore 修饰的方法时,拦截器会跳过所有 SaRouter 规则和注解鉴权,直接进入方法体。

@SaIgnore 的忽略效果只针对 SaInterceptor 拦截器和 AOP 注解鉴权生效,对自定义拦截器与过滤器不生效。


3.5. 高级配置:isAnnotation 与 setBeforeAuth

isAnnotation(false):关闭注解校验能力

SaInterceptor 注册到项目后,默认同时开启路由规则和注解鉴权两种能力。如果你只想做路由拦截,不希望框架扫描方法上的鉴权注解,可以通过 isAnnotation(false) 关闭注解校验:

1
2
3
4
5
registry.addInterceptor(
new SaInterceptor(handle -> {
SaRouter.match("/**").check(r -> StpUtil.checkLogin());
}).isAnnotation(false) // 关闭注解鉴权,只做路由拦截校验
).addPathPatterns("/**");

关闭后,Controller 方法上的 @SaCheckLogin@SaCheckRole 等注解将全部失效,框架只执行 Lambda 中配置的路由规则。

setBeforeAuth():认证前置函数

setBeforeAuth() 用于注册一个在注解鉴权之前执行的前置函数,适合需要在正式鉴权之前做一些预处理的场景(如记录请求日志、设置请求上下文等):

1
2
3
4
5
6
7
8
9
registry.addInterceptor(new SaInterceptor(handle -> {
// 这里是 auth 函数,在注解鉴权之后执行
System.out.println("步骤 2:auth 函数");
})
.setBeforeAuth(handle -> {
// 这里是 beforeAuth 函数,在注解鉴权之前执行
System.out.println("步骤 1:beforeAuth 函数");
})
).addPathPatterns("/**");

实际执行顺序是:beforeAuth → 注解鉴权 → auth(Lambda 中的路由规则)

如果在 beforeAuth 中调用了 SaRouter.stop(),将跳过后续的注解鉴权和 auth 认证环节,直接进入 Controller:

1
2
3
4
.setBeforeAuth(handle -> {
// 满足某个条件时,跳过所有鉴权,直接放行
SaRouter.match("/health").stop();
})

3.6. 路由拦截 vs 注解鉴权:执行顺序与职责分工

现在项目中同时存在两种鉴权方式,理解它们的执行顺序对排查问题非常重要。

一个请求进入后,完整的执行顺序是:

1
beforeAuth 前置函数 → 注解鉴权 → auth 路由规则 → Controller 方法 → 编程式鉴权(第四章)

前两者都在请求到达方法之前执行,只要任意一关没通过,请求就会被拒绝,不会继续往后走。

两种方式各有清晰的职责边界:

维度路由拦截鉴权注解鉴权
配置位置集中在 SaTokenConfig分散在每个 Controller 方法上
粒度路径模块级(批量)方法级(单个接口)
典型场景“所有接口必须登录”、“整个管理模块需要 admin 角色”“这个接口需要 user:delete 权限”
遗漏风险低,默认拦截所有匹配路径较高,漏加注解就没有校验
改动影响范围一处规则影响一批接口一个注解只影响一个方法

最佳实践是两者配合:路由拦截负责粗粒度的全局策略作为底线兜底,注解鉴权负责细粒度的接口级声明。以我们当前项目为例:

1
2
3
4
5
6
7
8
路由拦截(SaTokenConfig):
- 所有接口必须登录(排除 /auth/login)
- /admin/** 需要 admin 角色

注解鉴权(PermissionController 上的注解):
- /permission/canAddUser 需要 user:add 权限
- /permission/adminOrCanDelete 需要 admin 角色或 user:delete 权限
- ...

这样即使某个开发者在新增接口时忘记加权限注解,路由拦截的登录校验依然兜底——至少保证未登录用户无法访问任何接口。


3.7. 验证路由拦截效果

重启项目后,通过四个场景验证路由规则是否生效。

场景一:未登录访问需要登录的接口(验证规则一)

不携带任何 Token,直接访问:

1
GET http://localhost:8081/auth/info

预期响应:

1
2
3
4
5
{
"code": 401,
"msg": "未提供 Token,请先登录",
"data": null
}

在第二章中,/auth/info 上加了 StpUtil.checkLogin() 但未加路由规则,行为已由拦截器规则一统一接管——任何未加 notMatch 白名单的接口,未登录一律返回 401。

场景二:未登录访问白名单接口(验证 notMatch)

1
POST http://localhost:8081/auth/login?username=admin&password=123456

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "登录成功",
"data": "1db92b69-74ce-4c5f-a838-13c0f414d047"
}

/auth/loginnotMatch 排除在规则一之外,未登录也能正常访问,不会被拦截。

场景三:user 账号访问 /admin 接口(验证规则二)

user 身份登录获取 Token-U,然后:

1
2
DELETE http://localhost:8081/admin/users/10001/sessions
Header: satoken: <Token-U>

预期响应:

1
2
3
4
5
{
"code": 403,
"msg": "权限不足,缺少角色:admin",
"data": null
}

user 账号已登录(通过规则一),但没有 admin 角色(未通过规则二),被拦截在 Controller 方法之前。

场景四:admin 账号访问 /admin 接口(验证两条规则均通过)

admin 身份登录获取 Token-A,然后:

1
2
DELETE http://localhost:8081/admin/users/10002/sessions
Header: satoken: <Token-A>

预期响应:

1
2
3
4
5
{
"code": 200,
"msg": "已将账号 10002 的所有设备踢下线",
"data": null
}

admin 账号通过规则一(已登录)和规则二(拥有 admin 角色),顺利到达 Controller 方法执行业务逻辑。


3.8. 本章总结

本章回顾

本章将 SaInterceptor 从无参版本升级为有参版本,在 Lambda 中配置了全局登录校验和管理模块角色校验两条核心路由规则,并说明了 excludePathPatterns("/error") 必须放在 Spring MVC 层而非 Lambda 内部的原因。在 SaRouter API 层面,系统覆盖了 path 路由匹配、HTTP 方法匹配、boolean 条件匹配、lambda 表达式匹配以及多条件无限连缀五种匹配特征。流程控制三件套方面,stop() 用于提前退出并进入 Controller,back() 用于提前退出并直接返回前端,free() 用于创建不影响外部规则的独立匹配作用域。高级配置方面,isAnnotation(false) 可关闭注解校验只做路由拦截,setBeforeAuth() 可注册在注解鉴权之前执行的前置函数。最后通过执行顺序说明和职责分工对比,确立了"路由拦截负责底线兜底,注解鉴权负责细粒度声明"的最佳实践。

核心汇总表

SaRouter 方法作用示例
match(String... paths)指定拦截路径,支持 ** 通配符match("/admin/**")
match(SaHttpMethod)按 HTTP 方法匹配match(SaHttpMethod.DELETE)
match(boolean)按 boolean 条件匹配match(StpUtil.isLogin())
match(lambda)按 lambda 返回值匹配match(r -> StpUtil.isLogin())
notMatch(String... paths)排除白名单路径notMatch("/auth/login", "/public/**")
check(lambda)指定校验逻辑check(r -> StpUtil.checkRole("admin"))
stop()停止匹配,进入 Controller链式调用在 check() 之后
back(value)停止匹配,直接返回前端back("暂不对外开放")
free(lambda)打开独立作用域,内部 stop 不影响外部free(r -> { ... })
高级配置作用默认值
isAnnotation(false)关闭注解校验,只做路由拦截默认开启注解校验
setBeforeAuth(lambda)注册在注解鉴权之前执行的前置函数
执行顺序阶段说明
第一beforeAuth 前置函数早于注解鉴权,适合预处理
第二注解鉴权扫描 Controller 方法上的鉴权注解
第三auth 路由规则Lambda 中配置的 SaRouter 规则
第四Controller 方法业务逻辑,含编程式鉴权(第四章)

第四章. 编程式鉴权——动态的业务内判断

阶段式学习路径

路由拦截和注解鉴权解决的都是"能不能进这扇门"的问题——请求要么通过,要么被拒绝在 Controller 方法之外。但真实业务中,权限判断往往发生在门里面。

管理员可以编辑任何文章,普通用户只能编辑自己写的;财务可以查看所有订单金额,普通用户只能看自己的。这类判断无法用注解表达,因为注解只能声明"需要什么权限",无法表达"如果有这个权限就走 A 分支,没有就走 B 分支"。编程式鉴权就是为这种场景设计的——在业务代码中直接调用 Sa-Token 的 API,根据返回值动态决定执行路径。


4.1. has 系列与 check 系列:两组 API 的选择依据

Sa-Token 提供了两组编程式鉴权 API,外形相似但行为截然不同。

has 系列返回 boolean,校验失败时不抛任何异常,适合需要条件分支的场景:

1
2
3
4
5
6
7
8
9
// 角色判断
StpUtil.hasRole("admin"); // 是否拥有 admin 角色
StpUtil.hasRoleAnd("admin", "editor"); // 是否同时拥有 admin 和 editor 角色
StpUtil.hasRoleOr("admin", "editor"); // 是否拥有 admin 或 editor 任一角色

// 权限码判断
StpUtil.hasPermission("user:add"); // 是否拥有 user:add 权限
StpUtil.hasPermissionAnd("user:add", "user:delete"); // 是否同时拥有两个权限
StpUtil.hasPermissionOr("user:add", "user:delete"); // 是否拥有任一权限

check 系列没有返回值,校验失败时直接抛出 NotRoleExceptionNotPermissionException,由全局异常处理器统一拦截,适合"不满足条件就直接中断"的场景:

1
2
3
4
5
6
7
8
9
// 角色校验
StpUtil.checkRole("admin"); // 必须拥有 admin 角色,否则抛异常
StpUtil.checkRoleAnd("admin", "editor"); // 必须同时拥有两个角色
StpUtil.checkRoleOr("admin", "editor"); // 拥有任一角色即可

// 权限码校验
StpUtil.checkPermission("user:add"); // 必须拥有 user:add 权限,否则抛异常
StpUtil.checkPermissionAnd("user:add", "user:delete"); // 必须同时拥有两个权限
StpUtil.checkPermissionOr("user:add", "user:delete"); // 拥有任一权限即可

选择哪组 API 的判断逻辑很简单:

  • 需要根据权限结果走不同分支(if-else)→ 用 has 系列,拿 boolean 做判断
  • 权限不满足时直接返回错误,不需要任何后续逻辑 → 用 check 系列,让异常处理器兜底

4.2. 获取当前用户的权限与角色数据

除了判断和校验,有时候需要直接拿到当前用户的完整权限列表,比如在"个人中心"展示用户拥有哪些角色,或者在前端做菜单权限控制时返回权限码列表:

1
2
3
4
5
6
7
8
9
10
11
// 获取当前登录用户的权限码列表(调用 StpInterfaceImpl.getPermissionList)
List<String> permissions = StpUtil.getPermissionList();

// 获取当前登录用户的角色列表(调用 StpInterfaceImpl.getRoleList)
List<String> roles = StpUtil.getRoleList();

// 管理视角:获取指定用户的权限码列表
List<String> permissions = StpUtil.getPermissionList(10001L);

// 管理视角:获取指定用户的角色列表
List<String> roles = StpUtil.getRoleList(10001L);

这四个方法的返回值就是 StpInterfaceImpl 中两个方法的返回值。Sa-Token 在这里充当代理——你调用 StpUtil.getPermissionList(),框架内部调用 StpInterfaceImpl.getPermissionList(loginId, loginType),然后把结果透传给你。

我们先在 PermissionController 中追加一个接口来暴露这些数据,后续测试时也可以用它来确认权限数据是否正确加载:

📄 src/main/java/com/example/authsatoken/controller/PermissionController.java(修改,追加方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.Map;

/**
* 查询当前登录用户的权限和角色列表
* 前端可用此接口实现动态菜单权限控制
*/
@GetMapping("/myAuth")
public SaResult myAuth() {
StpUtil.checkLogin();
return SaResult.data(Map.of(
"roles", StpUtil.getRoleList(),
"permissions", StpUtil.getPermissionList()
));
}

4.3. 补充 editor 账号

本章的实战场景需要三种角色——admin、editor、user——来覆盖不同权限分支的测试路径。当前 StpInterfaceImplLoginController 都没有 editor 账号,先补进去。

📄 src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java(修改,更新两个 Map)

1
2
3
4
5
6
7
8
9
10
11
private static final Map<String, List<String>> ROLE_PERMISSION_MAP = Map.of(
"admin", List.of("user:add", "user:delete", "user:update", "user:view"),
"editor", List.of("article:add", "article:update", "article:view", "article:publish"),
"user", List.of("user:view", "article:view")
);

private static final Map<String, List<String>> USER_ROLE_MAP = Map.of(
"10001", List.of("admin"),
"10002", List.of("user"),
"10003", List.of("editor") // 新增 editor 账号映射
);

📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改,更新 USER_DB)

1
2
3
4
5
private static final Map<String, long[]> USER_DB = Map.of(
"admin", new long[]{123456L, 10001L},
"user", new long[]{123456L, 10002L},
"editor", new long[]{123456L, 10003L} // 新增 editor 账号
);

同时,SaTokenConfig 中规则一的白名单记得更新——/auth/login 已经在里面了,不需要额外改动。


4.4. 实战:文章管理系统的动态权限判断

用一个完整的业务场景来体验编程式鉴权的价值。我们要实现一个文章管理系统,权限规则如下:

  1. 所有登录用户都可以创建文章(默认草稿状态)
  2. 只有作者本人可以提交自己的文章审核,且只能提交草稿状态的文章
  3. 只有 editoradmin 角色可以审核文章
  4. editor 只能发布已审核通过的文章,admin 可以强制发布任何状态的文章
  5. 作者可以撤回自己处于草稿或待审核状态的文章,admin 可以撤回任何文章
  6. 已发布的文章所有登录用户可以查看,未发布的文章只有作者本人、editoradmin 可以查看

这六条规则涉及角色判断、数据归属判断、状态判断三种维度的组合,任何一条都无法用单一注解表达。

首先在 com.example.authsatoken 包下新建 model 子包,创建文章状态枚举和文章模型:

📄 src/main/java/com/example/authsatoken/model/ArticleStatus.java(新建)

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.authsatoken.model;

/**
* 文章状态枚举
*/
public enum ArticleStatus {
DRAFT, // 草稿
PENDING, // 待审核
APPROVED, // 已审核通过
REJECTED, // 已审核拒绝
PUBLISHED // 已发布
}

📄 src/main/java/com/example/authsatoken/model/Article.java(新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.authsatoken.model;

/**
* 文章模型(简化版,聚焦权限逻辑)
*/
public class Article {
private Long id;
private String title;
private Long authorId; // 作者的 userId
private ArticleStatus status; // 当前文章状态

public Article(Long id, String title, Long authorId, ArticleStatus status) {
this.id = id;
this.title = title;
this.authorId = authorId;
this.status = status;
}

public Long getId() { return id; }
public String getTitle() { return title; }
public Long getAuthorId() { return authorId; }
public ArticleStatus getStatus() { return status; }
public void setStatus(ArticleStatus s) { this.status = s; }
}

然后创建文章管理控制器。我们分三个部分拆解,每个方法都标注清楚权限判断的维度和用 has 系列而非注解的原因:

📄 src/main/java/com/example/authsatoken/controller/ArticleController.java(新建)

第一部分:创建与提交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
package com.example.authsatoken.controller;

import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import com.example.authsatoken.model.Article;
import com.example.authsatoken.model.ArticleStatus;
import org.springframework.web.bind.annotation.*;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
* 文章管理控制器
* 演示编程式鉴权在多角色、多状态、数据归属三维度组合场景下的应用
*
* 所有接口均依赖路由拦截的全局登录校验,无需在每个方法上重复加 @SaCheckLogin
*
* 权限角色对照(来自 StpInterfaceImpl):
* admin → userId=10001 → user:add / user:delete / user:update / user:view
* user → userId=10002 → user:view / article:view
* editor → userId=10003 → article:add / article:update / article:view / article:publish
*/
@RestController
@RequestMapping("/articles")
public class ArticleController {

// 模拟数据库:实际项目中替换为 Repository 层
private final Map<Long, Article> articleDb = new ConcurrentHashMap<>();
private final AtomicLong idGenerator = new AtomicLong(1);

/**
* 创建文章
* 权限规则:所有登录用户都可以创建,路由拦截已保证登录校验,此处无需额外判断
*/
@PostMapping
public SaResult createArticle(@RequestParam String title) {
long currentUserId = StpUtil.getLoginIdAsLong();
Article article = new Article(
idGenerator.getAndIncrement(),
title,
currentUserId,
ArticleStatus.DRAFT
);
articleDb.put(article.getId(), article);
return SaResult.ok("文章创建成功").setData(article.getId());
}

/**
* 提交文章审核
* 权限规则:只有作者本人可以提交,且文章必须处于草稿状态
*
* 用编程式鉴权而非注解的原因:
* 需要同时判断"数据归属"(是不是自己的文章)和"文章状态"(是不是草稿)
* 这两个维度都是运行时数据,注解无法表达
*/
@PostMapping("/{id}/submit")
public SaResult submitForReview(@PathVariable Long id) {
Article article = articleDb.get(id);
if (article == null) return SaResult.error("文章不存在");

long currentUserId = StpUtil.getLoginIdAsLong();

// 数据归属校验:只有作者本人可以提交
if (!article.getAuthorId().equals(currentUserId)) {
return SaResult.error("只能提交自己的文章").setCode(403);
}
// 状态校验:只有草稿状态可以提交
if (article.getStatus() != ArticleStatus.DRAFT) {
return SaResult.error("只有草稿状态的文章才能提交审核");
}

article.setStatus(ArticleStatus.PENDING);
return SaResult.ok("文章已提交审核,等待编辑审核");
}

第二部分:审核与发布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 审核文章
* 权限规则:editor 或 admin 角色可以审核,普通用户无权操作
*
* 用 hasRoleOr 而非 @SaCheckRole 的原因:
* 如果用注解 @SaCheckRole("editor"),403 的提示是"缺少 editor 角色",
* 但实际上有 admin 角色也能通过——注解无法表达跨类型的 OR 逻辑下更友好的错误提示
*/
@PostMapping("/{id}/review")
public SaResult reviewArticle(@PathVariable Long id,
@RequestParam boolean approved) {
// 角色校验:editor 或 admin 任一即可
if (!StpUtil.hasRoleOr("editor", "admin")) {
return SaResult.error("只有编辑或管理员可以审核文章").setCode(403);
}

Article article = articleDb.get(id);
if (article == null) return SaResult.error("文章不存在");

// 状态校验:只有待审核的文章可以审核
if (article.getStatus() != ArticleStatus.PENDING) {
return SaResult.error("只有待审核状态的文章可以审核");
}

article.setStatus(approved ? ArticleStatus.APPROVED : ArticleStatus.REJECTED);
return SaResult.ok(approved ? "文章审核通过" : "文章审核未通过");
}

/**
* 发布文章
* 权限规则:
* admin → 可以强制发布任何状态的文章
* editor → 只能发布已通过审核的文章
* 其他 → 无权发布
*
* 用编程式鉴权而非注解的原因:
* 同一个接口对不同角色有不同的状态限制,
* 注解无法表达"角色 A 跳过状态校验,角色 B 不跳过"这种差异化逻辑
*/
@PostMapping("/{id}/publish")
public SaResult publishArticle(@PathVariable Long id) {
Article article = articleDb.get(id);
if (article == null) return SaResult.error("文章不存在");

// admin 可以强制发布任何状态的文章,不受状态限制
if (StpUtil.hasRole("admin")) {
article.setStatus(ArticleStatus.PUBLISHED);
return SaResult.ok("管理员强制发布成功");
}

// editor 只能发布已审核通过的文章
if (StpUtil.hasRole("editor")) {
if (article.getStatus() != ArticleStatus.APPROVED) {
return SaResult.error("编辑只能发布已审核通过的文章,当前状态:" + article.getStatus());
}
article.setStatus(ArticleStatus.PUBLISHED);
return SaResult.ok("文章发布成功");
}

// 其他角色无权发布
return SaResult.error("无权发布文章,请联系编辑或管理员").setCode(403);
}

第三部分:撤回与查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
    /**
* 撤回文章
* 权限规则:
* admin → 可以撤回任何文章到草稿状态
* 作者本人 → 只能撤回自己处于草稿或待审核状态的文章
* 其他 → 无权操作
*
* 这里体现了编程式鉴权中最常见的模式:
* 先判断特权角色(admin),再判断数据归属,最后判断状态
*/
@PostMapping("/{id}/withdraw")
public SaResult withdrawArticle(@PathVariable Long id) {
Article article = articleDb.get(id);
if (article == null) return SaResult.error("文章不存在");

long currentUserId = StpUtil.getLoginIdAsLong();

// admin 可以撤回任何文章,无需其他判断
if (StpUtil.hasRole("admin")) {
article.setStatus(ArticleStatus.DRAFT);
return SaResult.ok("管理员撤回成功");
}

// 非 admin:先校验数据归属
if (!article.getAuthorId().equals(currentUserId)) {
return SaResult.error("只能撤回自己的文章").setCode(403);
}

// 再校验状态:只能撤回草稿或待审核状态的文章
if (article.getStatus() != ArticleStatus.DRAFT
&& article.getStatus() != ArticleStatus.PENDING) {
return SaResult.error("只能撤回草稿或待审核状态的文章,当前状态:" + article.getStatus());
}

article.setStatus(ArticleStatus.DRAFT);
return SaResult.ok("文章撤回成功");
}

/**
* 查看文章详情
* 权限规则:
* 已发布 → 所有登录用户可查看
* 未发布 → 作者本人、editor、admin 可查看,其他用户无权访问
*
* 用编程式鉴权而非注解的原因:
* 权限判断依赖文章的当前状态(运行时数据),注解在编译期无法感知
*/
@GetMapping("/{id}")
public SaResult getArticle(@PathVariable Long id) {
Article article = articleDb.get(id);
if (article == null) return SaResult.error("文章不存在");

// 已发布的文章所有登录用户都可以查看
if (article.getStatus() == ArticleStatus.PUBLISHED) {
return SaResult.data(article);
}

long currentUserId = StpUtil.getLoginIdAsLong();

// 未发布文章:管理员和编辑可以查看
if (StpUtil.hasRoleOr("admin", "editor")) {
return SaResult.data(article);
}

// 未发布文章:作者本人可以查看自己的文章
if (article.getAuthorId().equals(currentUserId)) {
return SaResult.data(article);
}

return SaResult.error("无权查看此文章").setCode(403);
}
}

4.5. 全流程测试

重启项目后,按以下顺序验证各场景下的权限判断是否符合预期。

准备工作:三个账号分别登录,拿到对应 Token

1
2
3
POST http://localhost:8081/auth/login?username=admin&password=123456
POST http://localhost:8081/auth/login?username=editor&password=123456
POST http://localhost:8081/auth/login?username=user&password=123456

分别记录 Token-A(admin)、Token-E(editor)、Token-U(user)。

场景一:user 创建文章,记录文章 ID

1
2
POST http://localhost:8081/articles?title=我的第一篇文章
Header: satoken: <Token-U>

预期响应:

1
{ "code": 200, "msg": "文章创建成功", "data": 1 }

场景二:user 提交审核

1
2
POST http://localhost:8081/articles/1/submit
Header: satoken: <Token-U>

预期响应:

1
{ "code": 200, "msg": "文章已提交审核,等待编辑审核", "data": null }

场景三:user 尝试审核文章(应被拒绝)

1
2
POST http://localhost:8081/articles/1/review?approved=true
Header: satoken: <Token-U>

预期响应:

1
{ "code": 403, "msg": "只有编辑或管理员可以审核文章", "data": null }

场景四:editor 审核文章

1
2
POST http://localhost:8081/articles/1/review?approved=true
Header: satoken: <Token-E>

预期响应:

1
{ "code": 200, "msg": "文章审核通过", "data": null }

场景五:user 尝试发布文章(应被拒绝)

1
2
POST http://localhost:8081/articles/1/publish
Header: satoken: <Token-U>

预期响应:

1
{ "code": 403, "msg": "无权发布文章,请联系编辑或管理员", "data": null }

场景六:editor 发布已审核的文章

1
2
POST http://localhost:8081/articles/1/publish
Header: satoken: <Token-E>

预期响应:

1
{ "code": 200, "msg": "文章发布成功", "data": null }

场景七:admin 强制发布草稿状态的文章

先让 user 再创建一篇文章(不提交审核,保持草稿状态),记录 ID 为 2,然后:

1
2
POST http://localhost:8081/articles/2/publish
Header: satoken: <Token-A>

预期响应:

1
{ "code": 200, "msg": "管理员强制发布成功", "data": null }

admin 越过了"必须先审核"的流程限制,直接发布——这是 hasRole("admin") 分支优先执行的效果。

场景八:验证已发布文章的访问权限

文章 1 已发布,用 user 的 Token 访问:

1
2
GET http://localhost:8081/articles/1
Header: satoken: <Token-U>

预期:正常返回文章数据(已发布,所有登录用户可查看)。

场景九:验证未发布文章的访问权限

再创建一篇文章(保持草稿,不提交),ID 为 3,作者是 user(userId=10002)。用 Token-E(editor)访问:

1
2
GET http://localhost:8081/articles/3
Header: satoken: <Token-E>

预期:正常返回(editor 可查看任何文章)。

再用另一个 user 账号登录(此处用同一个 user 账号模拟"他人"即可,因为文章 3 的 authorId 是 10002,当前请求者也是 10002,所以会走作者本人分支——如需严格测试,可创建第二个 user 账号来验证"他人无权查看草稿"场景)。

以下是完整的权限测试矩阵,对照验证:

操作admineditoruser(作者)未登录
创建文章❌ 401
提交审核(自己的)❌ 401
提交审核(别人的)❌ 403 归属❌ 403 归属❌ 403 归属❌ 401
审核文章❌ 403 角色❌ 401
发布(草稿/待审)✅ 强制❌ 403 状态❌ 403 角色❌ 401
发布(已审核)❌ 403 角色❌ 401
撤回(自己的文章)❌ 403 归属✅(草稿/待审)❌ 401
撤回(任意文章)❌ 403 归属❌ 403 归属❌ 401
查看(已发布)❌ 401
查看(未发布,自己的)❌ 401
查看(未发布,他人的)❌ 403❌ 401

4.6. 本章总结

本章回顾

本章完成了编程式鉴权的完整建设。在 API 层面,系统梳理了 has 系列(返回 boolean,适合条件分支)和 check 系列(抛异常,适合直接中断)两组方法的完整签名,以及 getPermissionList()getRoleList() 四个获取型 API 的用法。在实战层面,文章管理系统的六条业务规则覆盖了编程式鉴权的三种核心应用场景:纯角色判断hasRoleOr 做多角色 OR 分支)、角色 + 状态组合判断(不同角色跳过不同的状态限制)、角色 + 数据归属组合判断(admin 全局权限优先,作者只能操作自己的数据)。为了覆盖三种角色的完整测试路径,同步在 LoginControllerStpInterfaceImpl 中补充了 editor 账号及其权限映射。九个测试场景从"创建 → 提交 → 审核 → 发布 → 查看"完整串联了文章的生命周期,最终形成覆盖四种身份的权限测试矩阵。

核心汇总表

API 分组方法示例返回值失败行为适用场景
has 系列hasRole() / hasPermission()boolean返回 false条件分支(if-else)
has 系列(多值)hasRoleOr() / hasPermissionAnd()boolean返回 false多角色/权限组合判断
check 系列checkRole() / checkPermission()void抛出异常不满足则直接中断请求
check 系列(多值)checkRoleOr() / checkPermissionAnd()void抛出异常多角色/权限组合校验
获取型getPermissionList() / getRoleList()List<String>返回完整权限/角色列表
编程式鉴权的三种核心场景判断维度推荐写法
纯角色 / 权限判断只看角色或权限码hasRole / hasRoleOr + if-else
角色 + 状态组合不同角色跳过不同状态限制先判断高权限角色(admin),再判断低权限角色条件
角色 + 数据归属组合特权角色不受归属限制,普通用户只能操作自己的先判断特权角色,再判断归属,最后判断状态
注解鉴权 vs 编程式鉴权注解无法表达的场景
数据归属判断只有作者本人可以操作
状态依赖的权限文章必须是"已审核"才能发布
角色差异化分支admin 强制发布,editor 受状态限制
运行时数据组合条件任何依赖数据库查询结果的权限判断