登录注册番外篇(二) - Sa-Token:权限认证完全指南
登录注册番外篇(二) - Sa-Token:权限认证完全指南
Prorise第一章. 告诉框架"谁能做什么"——StpInterface
阶段式学习路径
番外篇二完整讲解了登录会话的生命周期——Token 怎么生成、怎么续签、怎么下线。但到目前为止,我们的项目只解决了"你是谁"这个问题,任何登录用户都能访问任何接口。
要实现"张三能发文章,李四只能看文章,王五连登录都进不来",还差一个关键环节:告诉框架每个用户有哪些权限。这就是本章的主角 StpInterface。
1.1. 为什么是接口而不是配置
在动手写代码之前,先理解一个设计决策:Sa-Token 为什么不直接提供"查数据库获取权限"的功能,而是要求开发者实现一个接口?
不同项目的权限数据来源千差万别。有的项目权限数据存在 MySQL 的三张关联表里,有的存在 MongoDB 的文档里,有的通过远程 RPC 调用其他服务获取,有的小型项目直接硬编码在代码里。如果 Sa-Token 内置了"查 MySQL 获取权限"的逻辑,那么使用 MongoDB 或 RPC 的项目就完全无法使用这个框架。
通过 StpInterface 接口,Sa-Token 把"权限数据从哪来"的决策权完全交给了开发者。框架只定义两个方法的签名:
1 | // 给定用户 ID,返回该用户拥有的权限码列表 |
你在实现里查数据库、查缓存、查配置文件都行,框架只认你返回的 List<String>。
两个方法都有 loginType 参数,这是为多业务线场景设计的——一个电商系统可能同时存在普通用户(loginType = "login")和商家(loginType = "merchant"),两套账号体系对应完全不同的权限表。通过 loginType 区分,可以在同一个实现类里根据不同业务线返回不同的权限数据。本篇只用默认的 "login" 类型,多业务线场景留到番外篇四展开。
1.2. Sa-Token 的权限模型:字符串即权限
在实现 StpInterface 之前,还有一个基础概念需要建立:Sa-Token 的权限模型。
它非常简单——权限用字符串表示,角色也用字符串表示。框架不强制你使用 RBAC 模型,不要求你建特定的数据库表,不限制你的权限编码格式。它只需要你回答两个问题:这个用户有哪些权限码?这个用户有哪些角色标识?
权限码的格式完全由你决定。业界最常见的格式是 资源:操作,比如:
1 | user:add 用户新增 |
角色标识通常用简单的单词:
1 | admin 管理员 |
Sa-Token 在鉴权时会调用你实现的 StpInterface,拿到权限列表和角色列表后,判断列表中是否包含目标值,决定是否放行。整个校验逻辑由框架完成,你只负责提供数据。
1.3. 实现 StpInterface
现在把权限数据源接入进来。为了聚焦 Sa-Token 本身的鉴权机制,我们先用硬编码 Map 模拟权限数据,后续替换为数据库查询时只需要修改这一个类,其余代码完全不用动。
在 com.example.authsatoken 包下新建 auth 子包,然后创建实现类:
📄 src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java(新建)
1 | package com.example.authsatoken.auth; |
1.4. 与登录接口的数据对齐
StpInterfaceImpl 里 USER_ROLE_MAP 的 key 是用户 ID,而用户 ID 是登录时由我们的业务逻辑决定的。需要确认两边的数据完全对齐。
回顾番外篇二第一章升级后的登录接口,USER_DB 的映射是:
1 | admin → userId 10001 |
对应 USER_ROLE_MAP:
1 | "10001" → admin 角色 |
两边完全对齐。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 | package com.example.authsatoken.config; |
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 | import cn.dev33.satoken.exception.NotPermissionException; |
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 | // 登录校验:只有登录之后才能进入该方法 |
它和手动调用 StpUtil.checkLogin() 的效果完全等价,只是换成了声明式写法。适合所有登录用户都能访问的接口,比如"获取个人信息"。
2.3.2. @SaCheckRole:角色校验
校验当前用户是否拥有指定角色。框架调用 StpInterfaceImpl.getRoleList() 拿到角色列表,再判断是否包含注解指定的值:
1 | // 角色校验(单角色):必须拥有 admin 角色 |
mode 有两种取值:SaMode.AND(默认)要求全部满足;SaMode.OR 满足其一即可。
2.3.3. @SaCheckPermission:权限码校验
校验当前用户是否拥有指定权限码。框架调用 StpInterfaceImpl.getPermissionList(),其余逻辑与角色校验完全对称:
1 | // 权限码校验(单权限):必须拥有 user:add 权限 |
orRole:角色权限双重 OR 校验
假设有这样的业务场景:接口在"拥有 user:add 权限"或"拥有 admin 角色"时均可访问。@SaCheckPermission 提供了 orRole 参数来处理这种跨类型 OR 条件,比嵌套 @SaCheckOr 更简洁:
1 | // 拥有 user:add 权限,或者拥有 admin 角色,满足其一即可 |
orRole 有三种写法,分别对应不同的角色逻辑:
1 | // 写法一:需要拥有 admin 角色 |
写法二和写法三的区别在于数组元素的数量:多个元素是 OR 关系,单个元素内部用逗号分隔是 AND 关系。
2.3.4. @SaCheckSafe:二级认证校验
校验当前会话是否已完成二级认证,番外篇二第七章中已详细讲解:
1 | // 必须通过默认二级认证才能访问 |
2.3.5. @SaCheckDisable:账号封禁服务校验
校验当前账号是否被封禁指定服务,番外篇二第六章中已详细讲解:
1 | // 校验当前账号是否被封禁(无服务标识,校验整体封禁) |
2.3.6. @SaCheckHttpBasic / @SaCheckHttpDigest:HTTP 认证校验
用于需要 HTTP Basic 或 HTTP Digest 认证的接口,常见于内部 API 或监控端点:
1 | // 只有通过 Http Basic 认证后才能进入该方法 |
这两个注解与 Token 体系无关,是独立的 HTTP 协议层认证,通常用于保护不需要完整登录流程的运维接口。
2.3.7. @SaCheckSign:API 签名校验
用于跨系统调用的接口签名验证,属于 Sa-Token 扩展能力,本系列不展开讲解,了解存在即可:
1 | // 校验 API 签名参数,用于跨系统的接口调用验证 |
2.3.8. @SaIgnore:忽略所有校验
@SaIgnore 是优先级最高的注解——当它出现时,所有其他鉴权注解和路由拦截器规则都会被忽略,请求直接进入方法:
1 | // 整个类需要登录才能访问 |
@SaIgnore 的几个重要特性:
- 修饰方法时:只有这个方法可以游客访问,类上的其他注解对此方法无效。
- 修饰类时:这个类下所有接口都可以游客访问。
- 具有最高优先级:与其他鉴权注解同时出现时,其他注解全部被忽略。
- 同样可以忽略掉路由拦截器的规则(第三章会演示)。
@SaIgnore 的忽略效果只针对 SaInterceptor 拦截器和 AOP 注解鉴权生效,对自定义拦截器与 Spring Security 等第三方过滤器无效。
2.4. 多注解叠加 = AND 关系
当一个方法上写了多个鉴权注解时,它们之间天然是 AND 关系——必须全部满足,才能进入方法,只要有一个不满足就抛出异常:
1 | // 必须同时满足:已登录 + 拥有 admin 角色 + 拥有 user:add 权限 |
这也解释了为什么 Sa-Token 没有提供 @SaCheckAnd 注解——多注解叠加本身就是 AND 语义,不需要额外的注解来声明。
2.5. @SaCheckOr:批量 OR 校验
当需要"多个条件满足其一即可"的 OR 逻辑,且条件跨越不同注解类型时,使用 @SaCheckOr:
1 | // 满足以下任意一个条件即可进入方法: |
每一项属性都可以写成数组形式,数组内部的多个元素之间是 OR 关系:
1 | // 只要有 login 类型账号登录,或者有 user 类型账号登录,任一满足即可通过 |
append 字段:用于追加扩展包中的注解,将它们纳入 OR 逻辑:
1 | // 通过登录校验,或者提供了正确的 ApiKey,满足其一即可 |
append 字段接收的是注解类型的 Class 数组,被追加的注解同时也需要写在方法上,@SaCheckOr 会在运行时读取这些注解的具体参数进行校验。
2.6. 完整的 PermissionController
将前面所有演示整合到一个控制器中:
📄 src/main/java/com/example/authsatoken/controller/PermissionController.java(完整版)
1 | package com.example.authsatoken.controller; |
2.7. 测试验证:双账号对比矩阵
重启项目,用两个账号分别登录,测试各接口的实际表现。
准备工作:分别获取两个账号的 Token
1 | POST http://localhost:8081/auth/login?username=admin&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 | GET http://localhost:8081/permission/adminOnly |
预期响应:
1 | { "code": 403, "msg": "权限不足,缺少角色:admin", "data": null } |
以下是完整的双账号测试矩阵:
| 接口 | admin | user | 未登录 |
|---|---|---|---|
/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/adminOrUser 对 user 账号是放行的——因为注解配置了 mode = SaMode.OR,user 账号虽然没有 admin 角色,但有 user 角色,满足 OR 条件中的一个,所以通过。
2.8. 本章总结
本章回顾
本章完成了注解鉴权体系的完整建设。首先注册了 SaInterceptor 拦截器——这是注解鉴权生效的前置条件,不注册则所有鉴权注解形同虚设。随后在 GlobalExceptionHandler 中追加了 NotPermissionException 和 NotRoleException 两个处理方法,配合已有的 NotLoginException 处理,构成了完整的认证授权异常响应体系,并明确了 401(未认证)与 403(已认证但权限不足)的语义边界。在注解覆盖层面,系统梳理了 Sa-Token 全部 8 种鉴权注解的用法,并重点讲解了 @SaCheckPermission 的 orRole 三种写法、多注解叠加等于 AND 关系的原理、以及 @SaCheckOr 的完整用法(单值、数组形式、append 字段)。最后通过双账号对比测试矩阵验证了权限隔离在实际请求中的完整表现。
核心汇总表
| 注解 | 校验内容 | AND/OR 支持 | 典型场景 |
|---|---|---|---|
@SaCheckLogin | 是否已登录 | 无 | 所有登录用户可访问的接口 |
@SaCheckRole | 是否拥有指定角色 | 支持,默认 AND | 角色专属功能 |
@SaCheckPermission | 是否拥有指定权限码 | 支持,默认 AND | 接口级细粒度权限控制 |
@SaCheckSafe | 是否已完成二级认证 | 无 | 高危操作的额外验证门槛 |
@SaCheckDisable | 指定服务是否被封禁 | 多值时任一封禁即拦截 | 分类封禁场景 |
@SaCheckHttpBasic | HTTP Basic 认证 | 无 | 内部 API / 运维接口 |
@SaCheckHttpDigest | HTTP Digest 认证 | 无 | 内部 API / 运维接口 |
@SaCheckSign | API 签名校验 | 无 | 跨系统接口调用 |
@SaIgnore | 忽略所有校验 | 最高优先级 | 公开接口豁免 |
orRole 写法 | 示例 | 语义 |
|---|---|---|
| 单角色 | orRole = "admin" | 权限码 OR admin 角色 |
| 数组多元素(OR) | orRole = {"admin", "editor"} | 权限码 OR(admin 或 editor) |
| 数组单元素内逗号(AND) | orRole = {"admin, editor"} | 权限码 OR(admin 且 editor) |
| 异常类型 | 状态码 | 触发场景 |
|---|---|---|
NotLoginException | 401 | 未登录或 Token 失效 |
NotPermissionException | 403 | 缺少所需权限码 |
NotRoleException | 403 | 缺少所需角色 |
| 多注解组合规则 | 语义 | 实现方式 |
|---|---|---|
| 多注解叠加 | AND(全部满足) | 在方法上写多个注解 |
| OR 关系 | 满足其一即可 | @SaCheckOr 或 mode = SaMode.OR |
第三章. 路由拦截鉴权——集中式的批量管控
阶段式学习路径
第二章的注解鉴权是方法级的——每个接口单独声明权限要求,粒度精确,但有一个天然的局限:它是分散的。如果一个模块下有几十个接口都需要登录,就得在几十个方法上逐一加 @SaCheckLogin,不仅繁琐,而且一旦漏加某个方法,那个接口就完全暴露了。
路由拦截鉴权解决的是这个问题。在拦截器的配置中集中表达"哪些路径需要什么权限",一处配置批量生效,再也不用担心漏加注解。本章从 SaInterceptor 的有参写法切入,系统覆盖 SaRouter 的全部匹配特征和流程控制手段。
3.1. 从无参到有参:SaInterceptor 的两种工作模式
回顾第二章注册的 SaTokenConfig:
1 | registry.addInterceptor(new SaInterceptor()) |
new SaInterceptor() 不传参数时,拦截器处于纯注解模式——扫描每个请求对应的 Controller 方法,有鉴权注解就执行校验,没有就直接放行。未加注解的接口对未登录用户完全开放。
new SaInterceptor(handle -> { ... }) 传入一个 Lambda 时,拦截器进入路由规则模式——在 Lambda 中用 SaRouter 工具类按路径批量声明访问策略。两种模式并不互斥:有参版本会同时执行路由规则和注解鉴权,路由规则先执行,注解鉴权后执行。
现在把 SaTokenConfig 升级为有参版本:
📄 src/main/java/com/example/authsatoken/config/SaTokenConfig.java(修改,替换 addInterceptors 方法)
1 | package com.example.authsatoken.config; |
两条规则解决了两个最典型的全局诉求:规则一让"所有接口必须登录"这个要求集中到一处,不再散落在几十个注解里;规则二给 /admin/** 整个模块追加了角色防护,无论后续管理模块新增多少接口,这条规则自动覆盖。
3.2. SaRouter 匹配特征全览
SaRouter 的 API 设计成链式调用风格,match 指定拦截范围,notMatch 排除白名单,check 指定校验逻辑。除了 path 路由匹配,它还支持多种其他特征:
path 路由匹配
最常用的匹配方式,支持 Ant 风格通配符(* 匹配单层,** 匹配多层),支持 RESTful 风格路由,支持同时传入多个 path:
1 | // 匹配单个路径 |
按 HTTP 方法匹配
在 RESTful 接口设计中,同一路径的不同 HTTP 方法往往需要不同的权限。SaHttpMethod 枚举提供了所有常用方法:
1 | // 只拦截 POST 请求 |
按 boolean 条件和 lambda 表达式匹配
匹配条件不局限于路径,可以是任意 boolean 值或返回 boolean 的 lambda:
1 | // 根据一个 boolean 条件进行匹配 |
多条件无限连缀
多个 match / notMatch 可以无限连缀,所有条件之间是 AND 关系——只有全部条件都满足,才会执行最后的 check 校验函数:
1 | // 必须是 GET 请求,且路径以 /user/ 开头 |
3.3. 流程控制三件套:stop、back、free
除了匹配和校验,SaRouter 还提供了三个用于控制匹配流程的方法,解决"某条规则命中后不再继续匹配"的需求。
stop():停止匹配,进入 Controller
SaRouter.stop() 可以提前退出整个 Lambda 函数,跳过后续所有未执行的 match 规则:
1 | registry.addInterceptor(new SaInterceptor(handle -> { |
如上代码,执行到第二条规则的 stop() 时,后续的规则三、四都会被跳过,请求正常进入 Controller。
back():停止匹配,直接返回前端
SaRouter.back() 同样会停止匹配,但不进入 Controller,而是直接将参数作为返回值输出到前端:
1 | // 执行 back 后,停止匹配,不进入 Controller,直接返回字符串给前端 |
stop() 与 back() 的区别:
stop() | back() | |
|---|---|---|
| 停止匹配 | ✅ | ✅ |
| 进入 Controller | ✅ | ❌ |
| 直接返回前端 | ❌ | ✅ |
free():独立作用域
free() 打开一个独立的作用域,使内部的 stop() 不再跳出整个 Lambda,而是仅仅跳出当前 free 作用域,外部的 match 规则继续正常执行:
1 | // 进入 free 独立作用域 |
free() 适合"某个模块内部有复杂的互斥规则,但不希望影响模块外部的其他规则"的场景。
3.4. @SaIgnore 忽略路由拦截
第二章介绍 @SaIgnore 时提到它同样可以忽略路由拦截器的规则。这意味着即使拦截器配置了"所有路径必须登录",只要方法或类上加了 @SaIgnore,该接口就会跳过拦截器的校验,直接放行。
先配置拦截规则:
1 | registry.addInterceptor(new SaInterceptor(handle -> { |
然后在需要豁免的接口上加 @SaIgnore:
1 | // 虽然 /user/** 规则要求 user 权限,但此接口因 @SaIgnore 直接放行,游客可访问 |
请求到达被 @SaIgnore 修饰的方法时,拦截器会跳过所有 SaRouter 规则和注解鉴权,直接进入方法体。
@SaIgnore 的忽略效果只针对 SaInterceptor 拦截器和 AOP 注解鉴权生效,对自定义拦截器与过滤器不生效。
3.5. 高级配置:isAnnotation 与 setBeforeAuth
isAnnotation(false):关闭注解校验能力
SaInterceptor 注册到项目后,默认同时开启路由规则和注解鉴权两种能力。如果你只想做路由拦截,不希望框架扫描方法上的鉴权注解,可以通过 isAnnotation(false) 关闭注解校验:
1 | registry.addInterceptor( |
关闭后,Controller 方法上的 @SaCheckLogin@SaCheckRole 等注解将全部失效,框架只执行 Lambda 中配置的路由规则。
setBeforeAuth():认证前置函数
setBeforeAuth() 用于注册一个在注解鉴权之前执行的前置函数,适合需要在正式鉴权之前做一些预处理的场景(如记录请求日志、设置请求上下文等):
1 | registry.addInterceptor(new SaInterceptor(handle -> { |
实际执行顺序是:beforeAuth → 注解鉴权 → auth(Lambda 中的路由规则)。
如果在 beforeAuth 中调用了 SaRouter.stop(),将跳过后续的注解鉴权和 auth 认证环节,直接进入 Controller:
1 | .setBeforeAuth(handle -> { |
3.6. 路由拦截 vs 注解鉴权:执行顺序与职责分工
现在项目中同时存在两种鉴权方式,理解它们的执行顺序对排查问题非常重要。
一个请求进入后,完整的执行顺序是:
1 | beforeAuth 前置函数 → 注解鉴权 → auth 路由规则 → Controller 方法 → 编程式鉴权(第四章) |
前两者都在请求到达方法之前执行,只要任意一关没通过,请求就会被拒绝,不会继续往后走。
两种方式各有清晰的职责边界:
| 维度 | 路由拦截鉴权 | 注解鉴权 |
|---|---|---|
| 配置位置 | 集中在 SaTokenConfig | 分散在每个 Controller 方法上 |
| 粒度 | 路径模块级(批量) | 方法级(单个接口) |
| 典型场景 | “所有接口必须登录”、“整个管理模块需要 admin 角色” | “这个接口需要 user:delete 权限” |
| 遗漏风险 | 低,默认拦截所有匹配路径 | 较高,漏加注解就没有校验 |
| 改动影响范围 | 一处规则影响一批接口 | 一个注解只影响一个方法 |
最佳实践是两者配合:路由拦截负责粗粒度的全局策略作为底线兜底,注解鉴权负责细粒度的接口级声明。以我们当前项目为例:
1 | 路由拦截(SaTokenConfig): |
这样即使某个开发者在新增接口时忘记加权限注解,路由拦截的登录校验依然兜底——至少保证未登录用户无法访问任何接口。
3.7. 验证路由拦截效果
重启项目后,通过四个场景验证路由规则是否生效。
场景一:未登录访问需要登录的接口(验证规则一)
不携带任何 Token,直接访问:
1 | GET http://localhost:8081/auth/info |
预期响应:
1 | { |
在第二章中,/auth/info 上加了 StpUtil.checkLogin() 但未加路由规则,行为已由拦截器规则一统一接管——任何未加 notMatch 白名单的接口,未登录一律返回 401。
场景二:未登录访问白名单接口(验证 notMatch)
1 | POST http://localhost:8081/auth/login?username=admin&password=123456 |
预期响应:
1 | { |
/auth/login 被 notMatch 排除在规则一之外,未登录也能正常访问,不会被拦截。
场景三:user 账号访问 /admin 接口(验证规则二)
以 user 身份登录获取 Token-U,然后:
1 | DELETE http://localhost:8081/admin/users/10001/sessions |
预期响应:
1 | { |
user 账号已登录(通过规则一),但没有 admin 角色(未通过规则二),被拦截在 Controller 方法之前。
场景四:admin 账号访问 /admin 接口(验证两条规则均通过)
以 admin 身份登录获取 Token-A,然后:
1 | DELETE http://localhost:8081/admin/users/10002/sessions |
预期响应:
1 | { |
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 | // 角色判断 |
check 系列没有返回值,校验失败时直接抛出 NotRoleException 或 NotPermissionException,由全局异常处理器统一拦截,适合"不满足条件就直接中断"的场景:
1 | // 角色校验 |
选择哪组 API 的判断逻辑很简单:
- 需要根据权限结果走不同分支(if-else)→ 用
has系列,拿 boolean 做判断 - 权限不满足时直接返回错误,不需要任何后续逻辑 → 用
check系列,让异常处理器兜底
4.2. 获取当前用户的权限与角色数据
除了判断和校验,有时候需要直接拿到当前用户的完整权限列表,比如在"个人中心"展示用户拥有哪些角色,或者在前端做菜单权限控制时返回权限码列表:
1 | // 获取当前登录用户的权限码列表(调用 StpInterfaceImpl.getPermissionList) |
这四个方法的返回值就是 StpInterfaceImpl 中两个方法的返回值。Sa-Token 在这里充当代理——你调用 StpUtil.getPermissionList(),框架内部调用 StpInterfaceImpl.getPermissionList(loginId, loginType),然后把结果透传给你。
我们先在 PermissionController 中追加一个接口来暴露这些数据,后续测试时也可以用它来确认权限数据是否正确加载:
📄 src/main/java/com/example/authsatoken/controller/PermissionController.java(修改,追加方法)
1 | import java.util.Map; |
4.3. 补充 editor 账号
本章的实战场景需要三种角色——admin、editor、user——来覆盖不同权限分支的测试路径。当前 StpInterfaceImpl 和 LoginController 都没有 editor 账号,先补进去。
📄 src/main/java/com/example/authsatoken/auth/StpInterfaceImpl.java(修改,更新两个 Map)
1 | private static final Map<String, List<String>> ROLE_PERMISSION_MAP = Map.of( |
📄 src/main/java/com/example/authsatoken/controller/LoginController.java(修改,更新 USER_DB)
1 | private static final Map<String, long[]> USER_DB = Map.of( |
同时,SaTokenConfig 中规则一的白名单记得更新——/auth/login 已经在里面了,不需要额外改动。
4.4. 实战:文章管理系统的动态权限判断
用一个完整的业务场景来体验编程式鉴权的价值。我们要实现一个文章管理系统,权限规则如下:
- 所有登录用户都可以创建文章(默认草稿状态)
- 只有作者本人可以提交自己的文章审核,且只能提交草稿状态的文章
- 只有
editor或admin角色可以审核文章 editor只能发布已审核通过的文章,admin可以强制发布任何状态的文章- 作者可以撤回自己处于草稿或待审核状态的文章,
admin可以撤回任何文章 - 已发布的文章所有登录用户可以查看,未发布的文章只有作者本人、
editor、admin可以查看
这六条规则涉及角色判断、数据归属判断、状态判断三种维度的组合,任何一条都无法用单一注解表达。
首先在 com.example.authsatoken 包下新建 model 子包,创建文章状态枚举和文章模型:
📄 src/main/java/com/example/authsatoken/model/ArticleStatus.java(新建)
1 | package com.example.authsatoken.model; |
📄 src/main/java/com/example/authsatoken/model/Article.java(新建)
1 | package com.example.authsatoken.model; |
然后创建文章管理控制器。我们分三个部分拆解,每个方法都标注清楚权限判断的维度和用 has 系列而非注解的原因:
📄 src/main/java/com/example/authsatoken/controller/ArticleController.java(新建)
第一部分:创建与提交
1 | package com.example.authsatoken.controller; |
第二部分:审核与发布
1 | /** |
第三部分:撤回与查看
1 | /** |
4.5. 全流程测试
重启项目后,按以下顺序验证各场景下的权限判断是否符合预期。
准备工作:三个账号分别登录,拿到对应 Token
1 | POST http://localhost:8081/auth/login?username=admin&password=123456 |
分别记录 Token-A(admin)、Token-E(editor)、Token-U(user)。
场景一:user 创建文章,记录文章 ID
1 | POST http://localhost:8081/articles?title=我的第一篇文章 |
预期响应:
1 | { "code": 200, "msg": "文章创建成功", "data": 1 } |
场景二:user 提交审核
1 | POST http://localhost:8081/articles/1/submit |
预期响应:
1 | { "code": 200, "msg": "文章已提交审核,等待编辑审核", "data": null } |
场景三:user 尝试审核文章(应被拒绝)
1 | POST http://localhost:8081/articles/1/review?approved=true |
预期响应:
1 | { "code": 403, "msg": "只有编辑或管理员可以审核文章", "data": null } |
场景四:editor 审核文章
1 | POST http://localhost:8081/articles/1/review?approved=true |
预期响应:
1 | { "code": 200, "msg": "文章审核通过", "data": null } |
场景五:user 尝试发布文章(应被拒绝)
1 | POST http://localhost:8081/articles/1/publish |
预期响应:
1 | { "code": 403, "msg": "无权发布文章,请联系编辑或管理员", "data": null } |
场景六:editor 发布已审核的文章
1 | POST http://localhost:8081/articles/1/publish |
预期响应:
1 | { "code": 200, "msg": "文章发布成功", "data": null } |
场景七:admin 强制发布草稿状态的文章
先让 user 再创建一篇文章(不提交审核,保持草稿状态),记录 ID 为 2,然后:
1 | POST http://localhost:8081/articles/2/publish |
预期响应:
1 | { "code": 200, "msg": "管理员强制发布成功", "data": null } |
admin 越过了"必须先审核"的流程限制,直接发布——这是 hasRole("admin") 分支优先执行的效果。
场景八:验证已发布文章的访问权限
文章 1 已发布,用 user 的 Token 访问:
1 | GET http://localhost:8081/articles/1 |
预期:正常返回文章数据(已发布,所有登录用户可查看)。
场景九:验证未发布文章的访问权限
再创建一篇文章(保持草稿,不提交),ID 为 3,作者是 user(userId=10002)。用 Token-E(editor)访问:
1 | GET http://localhost:8081/articles/3 |
预期:正常返回(editor 可查看任何文章)。
再用另一个 user 账号登录(此处用同一个 user 账号模拟"他人"即可,因为文章 3 的 authorId 是 10002,当前请求者也是 10002,所以会走作者本人分支——如需严格测试,可创建第二个 user 账号来验证"他人无权查看草稿"场景)。
以下是完整的权限测试矩阵,对照验证:
| 操作 | admin | editor | user(作者) | 未登录 |
|---|---|---|---|---|
| 创建文章 | ✅ | ✅ | ✅ | ❌ 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 全局权限优先,作者只能操作自己的数据)。为了覆盖三种角色的完整测试路径,同步在 LoginController 和 StpInterfaceImpl 中补充了 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 受状态限制 |
| 运行时数据组合条件 | 任何依赖数据库查询结果的权限判断 |





