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

第一章. 全局侦听器——订阅框架的关键事件

阶段式学习路径

前三篇笔记解决的都是"如何控制访问"的问题——登录、鉴权、下线。但还有一类需求没有覆盖:当这些事件发生时,我们能不能知道?

用户在哪个 IP 登录了?用哪种设备?账号被踢下线的原因是管理员操作还是被新设备顶替?二级认证是什么时候开启的?这些问题在业务层面非常重要——审计日志、异地登录告警、行为分析都依赖它们。

Sa-Token 的侦听器机制就是为此而生。它允许你订阅框架的关键性事件,在事件触发时执行自定义逻辑,而不需要侵入框架本身的任何代码。


1.1. 工作原理与内置侦听器

Sa-Token 内部在每个关键动作执行时,都会向 事件发布中心 SaTokenEventCenter 广播一个事件。所有已注册的侦听器会按注册顺序依次收到通知并执行各自的回调方法。

框架默认内置了一个侦听器实现 SaTokenListenerForLog,功能是将所有事件以 log 的形式打印到控制台。它默认关闭,通过一行配置开启:

1
2
sa-token:
is-log: true # 开启框架事件的控制台日志输出

开启后,用户每次登录、注销、被踢下线,控制台都会打印对应的日志行,方便开发阶段快速感知框架行为。生产环境建议关闭,改用自定义侦听器写入结构化日志。


1.2. 全部事件方法一览

SaTokenListener 接口一共定义了 12 个回调方法,覆盖了从登录到 Session 销毁的完整生命周期。以下逐一说明每个方法的触发时机和参数含义:

登录与注销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 每次登录时触发
*
* @param loginType 登录类型,默认 "login",多账号体系时用于区分账号类型
* @param loginId 登录的账号 id
* @param tokenValue 本次登录生成的 Token 值
* @param loginParameter 本次登录的完整参数对象,可从中取出设备类型、有效期等细节
* loginParameter.getDeviceType() → 登录设备类型(PC / APP 等)
* loginParameter.getTimeout() → 本次 Token 有效期(秒)
*/
void doLogin(String loginType, Object loginId, String tokenValue, SaLoginParameter loginParameter);

/**
* 每次注销时触发(用户主动调用 StpUtil.logout() 时)
*
* @param loginType 登录类型
* @param loginId 被注销的账号 id
* @param tokenValue 被注销的 Token 值
*/
void doLogout(String loginType, Object loginId, String tokenValue);

下线事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 每次被踢下线时触发(管理员调用 StpUtil.kickout() 时)
*
* @param loginType 登录类型
* @param loginId 被踢下线的账号 id
* @param tokenValue 被踢下线的 Token 值
*/
void doKickout(String loginType, Object loginId, String tokenValue);

/**
* 每次被顶下线时触发(同端新登录自动顶替旧会话时)
*
* @param loginType 登录类型
* @param loginId 被顶下线的账号 id
* @param tokenValue 被顶下线的 Token 值
*/
void doReplaced(String loginType, Object loginId, String tokenValue);

封禁事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 每次账号被封禁时触发
*
* @param loginType 登录类型
* @param loginId 被封禁的账号 id
* @param service 业务标识(分类封禁时有值,整体封禁时为默认值)
* @param level 封禁等级(阶梯封禁时有效,普通封禁时为 1)
* @param disableTime 封禁时长,单位:秒,-1 代表永久封禁
*/
void doDisable(String loginType, Object loginId, String service, int level, long disableTime);

/**
* 每次账号被解封时触发
*
* @param loginType 登录类型
* @param loginId 被解封的账号 id
* @param service 被解封的业务标识
*/
void doUntieDisable(String loginType, Object loginId, String service);

二级认证事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 每次开启二级认证时触发
*
* @param loginType 登录类型
* @param tokenValue 开启二级认证的 Token 值
* @param service 业务标识(不指定业务标识时为默认值)
* @param safeTime 本次二级认证的有效期,单位:秒
*/
void doOpenSafe(String loginType, String tokenValue, String service, long safeTime);

/**
* 每次关闭二级认证时触发(主动调用 StpUtil.closeSafe() 或有效期到期时)
*
* @param loginType 登录类型
* @param tokenValue 关闭二级认证的 Token 值
* @param service 业务标识
*/
void doCloseSafe(String loginType, String tokenValue, String service);

Session 事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 每次创建 Session 时触发
* Session 包括 Account-Session 和 Token-Session 两种类型
*
* @param id Session 的唯一标识(框架内部生成的字符串 key)
*/
void doCreateSession(String id);

/**
* 每次注销 Session 时触发
*
* @param id Session 的唯一标识
*/
void doLogoutSession(String id);

Token 续期事件

1
2
3
4
5
6
7
8
9
/**
* 每次 Token 续期时触发
*
* @param loginType 登录类型
* @param loginId 续期 Token 对应的账号 id
* @param tokenValue 被续期的 Token 值
* @param timeout 续期后的有效期,单位:秒,-1 代表永久有效
*/
void doRenewTimeout(String loginType, Object loginId, String tokenValue, long timeout);

1.3. 三种实现方式

方式一:实现 SaTokenListener 接口(全量实现)

适合需要处理多个事件的场景。缺点是必须实现接口中的全部方法,即使某些方法你根本不关心,也得写空实现。

方式二:继承 SaTokenListenerForSimple(推荐)

SaTokenListenerForSimple 是框架提供的适配器类,对所有事件提供了空实现。继承它之后,只需重写你关心的方法,其余方法保持默认空实现即可。这是最推荐的方式:

1
2
3
4
5
6
7
8
9
10
@Component
public class MySaTokenListener extends SaTokenListenerForSimple {

@Override
public void doLogin(String loginType, Object loginId, String tokenValue,
SaLoginParameter loginParameter) {
// 只关心登录事件,其余方法无需重写
System.out.println("用户登录:" + loginId);
}
}

方式三:匿名内部类(轻量场景)

适合只需要临时注册、或在测试代码中快速验证的场景:

1
2
3
4
5
6
7
SaTokenEventCenter.registerListener(new SaTokenListenerForSimple() {
@Override
public void doLogin(String loginType, Object loginId, String tokenValue,
SaLoginParameter loginParameter) {
System.out.println("匿名侦听器:用户 " + loginId + " 登录了");
}
});

1.4. 注册方式与 SaTokenEventCenter 管理 API

自动注册(推荐)

只要实现类上加了 @Component 注解,SpringBoot 启动时会自动将其扫描并注册到事件中心,无需任何额外配置:

1
2
3
4
@Component   // 有这个注解,框架自动发现并注册
public class MySaTokenListener extends SaTokenListenerForSimple {
// ...
}

手动注册

在非 IoC 环境下,或者需要在运行时动态注册侦听器时,使用 SaTokenEventCenter 手动操作:

1
2
3
4
5
// 注册一个侦听器
SaTokenEventCenter.registerListener(new MySaTokenListener());

// 注册一组侦听器
SaTokenEventCenter.registerListenerList(listenerList);

SaTokenEventCenter 还提供了完整的侦听器管理能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 获取已注册的所有侦听器
SaTokenEventCenter.getListenerList();

// 重置侦听器集合(替换全部)
SaTokenEventCenter.setListenerList(listenerList);

// 移除指定的侦听器实例
SaTokenEventCenter.removeListener(listener);

// 移除指定类型的所有侦听器
SaTokenEventCenter.removeListener(MySaTokenListener.class);

// 清空所有已注册的侦听器
SaTokenEventCenter.clearListener();

// 判断是否已注册了指定侦听器实例
SaTokenEventCenter.hasListener(listener);

// 判断是否已注册了指定类型的侦听器
SaTokenEventCenter.hasListener(MySaTokenListener.class);

多个侦听器可以同时存在,彼此独立,互不影响,按照注册顺序依次接收到事件通知。


1.5. 实战:登录审计日志记录器

System.out.println 能说明机制,却没有实际业务价值。下面用侦听器实现一个完整的登录审计日志记录器,覆盖登录、注销、踢人、顶替四种事件,记录每次操作的时间、账号、Token,以及下线原因。

📄 src/main/java/com/example/authsatoken/listener/AuditLogListener.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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
package com.example.authsatoken.listener;

import cn.dev33.satoken.listener.SaTokenListenerForSimple;
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
* 登录审计日志侦听器
* 记录用户登录、注销、被踢下线、被顶下线等关键事件
* <p>
* 实际项目中,可将日志写入数据库或专用日志服务(如 ELK),此处以 SLF4J 日志输出演示
*/
@Component
public class AuditLogListener extends SaTokenListenerForSimple {

private static final Logger log = LoggerFactory.getLogger(AuditLogListener.class);
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

/**
* 登录事件
* 可以从 loginParameter 中取出设备类型、Token 有效期等登录细节
*/
@Override
public void doLogin(String loginType, Object loginId, String tokenValue,
SaLoginParameter loginParameter) {
try {
String deviceType = loginParameter.getDeviceType();
long timeout = loginParameter.getTimeout();
log.info("[审计日志] 用户登录 | 时间={} | 账号={} | 设备={} | Token有效期={}秒 | Token={}",
now(), loginId, deviceType, timeout, mask(tokenValue));
} catch (Exception e) {
log.error("[审计日志] 记录登录事件失败", e);
}
}

/**
* 注销事件(用户主动退出)
*/
@Override
public void doLogout(String loginType, Object loginId, String tokenValue) {
try {
log.info("[审计日志] 用户注销 | 时间={} | 账号={} | Token={}",
now(), loginId, mask(tokenValue));
} catch (Exception e) {
log.error("[审计日志] 记录注销事件失败", e);
}
}

/**
* 踢下线事件(管理员操作)
* 区别于用户主动注销,这里可以额外触发消息推送通知用户
*/
@Override
public void doKickout(String loginType, Object loginId, String tokenValue) {
try {
log.warn("[审计日志] 用户被踢下线 | 时间={} | 账号={} | 原因=管理员操作 | Token={}",
now(), loginId, mask(tokenValue));
// 实际项目中,可在此处推送消息通知用户,例如:
// messageService.push(loginId, "您的账号已被管理员强制下线,如有疑问请联系客服");
} catch (Exception e) {
log.error("[审计日志] 记录踢下线事件失败", e);
}
}

/**
* 顶下线事件(新设备登录自动顶替)
* 典型的异地登录场景,可触发安全告警
*/
@Override
public void doReplaced(String loginType, Object loginId, String tokenValue) {
try {
log.warn("[审计日志] 用户被顶下线 | 时间={} | 账号={} | 原因=新设备登录 | Token={}",
now(), loginId, mask(tokenValue));
// 实际项目中,可在此处触发异地登录安全告警:
// securityAlertService.sendLoginAlert(loginId, "您的账号在新设备上登录,旧设备已下线");
} catch (Exception e) {
log.error("[审计日志] 记录顶下线事件失败", e);
}
}

/**
* 封禁事件
*/
@Override
public void doDisable(String loginType, Object loginId, String service,
int level, long disableTime) {
try {
String duration = disableTime == -1 ? "永久" : disableTime + "秒";
log.warn("[审计日志] 用户被封禁 | 时间={} | 账号={} | 服务={} | 等级={} | 时长={}",
now(), loginId, service, level, duration);
} catch (Exception e) {
log.error("[审计日志] 记录封禁事件失败", e);
}
}

/**
* 当前时间格式化
*/
private String now() {
return LocalDateTime.now().format(FORMATTER);
}

/**
* Token 脱敏:只显示前 8 位,其余用 * 替换
* 日志中不应记录完整 Token,防止日志泄露导致会话被劫持
*/
private String mask(String token) {
if (token == null || token.length() <= 8) {
return "****";
}
return token.substring(0, 8) + "****";
}
}

这个实现有几个值得注意的设计决策。

Token 脱敏是必须的。完整的 Token 相当于用户的身份凭证,如果日志被攻击者获取,就可以直接冒充用户发起请求。mask() 方法只保留前 8 位,足以在日志中定位问题,又不会造成安全风险。

doKickoutdoReplaced 分开处理,而不是合并到一个方法里。这两种事件对用户的含义截然不同:被踢下线说明有管理员操作,被顶下线说明可能有异地登录。前者可以推送客服通知,后者更适合触发安全告警——分开处理才能做出有意义的差异化响应。


1.6. 重要坑点:try-catch 是必须的

侦听器的回调方法在 Sa-Token 的主流程中被同步调用——登录动作完成后,框架立即调用所有侦听器的 doLogin 方法,等所有侦听器都执行完才返回结果给调用方。

这意味着:如果你的侦听器代码抛出了未捕获的异常,整个登录流程就会被强制中断,用户会看到一个 500 错误,而不是成功登录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ❌ 危险写法:如果数据库操作失败,登录接口直接报 500
@Override
public void doLogin(String loginType, Object loginId, String tokenValue,
SaLoginParameter loginParameter) {
loginLogRepository.save(new LoginLog(loginId, tokenValue)); // 如果数据库挂了怎么办?
}

// ✅ 安全写法:侦听器内的不安全代码必须用 try-catch 保护
@Override
public void doLogin(String loginType, Object loginId, String tokenValue,
SaLoginParameter loginParameter) {
try {
loginLogRepository.save(new LoginLog(loginId, tokenValue));
} catch (Exception e) {
// 记录失败不应影响用户登录,降级处理:打印错误日志后继续
log.error("[审计日志] 写入登录日志失败,loginId={}", loginId, e);
}
}

侦听器中任何可能失败的操作——数据库写入、HTTP 请求、消息推送——都必须用 try-catch 包裹。侦听器的职责是"观察和记录",不应该反过来影响主流程的成败。


1.7. 本章总结

本章回顾

本章完整讲解了 Sa-Token 的全局侦听器机制。从工作原理出发,理解了事件发布中心 SaTokenEventCenter 的广播模型,以及内置 SaTokenListenerForLog 的快速开启方式。在事件方法层面,逐一解释了全部 12 个回调方法的触发时机和每个参数的具体含义,补全了官方文档只有签名没有说明的空白。在实现方式上,比较了直接实现接口、继承 SaTokenListenerForSimple(推荐)、匿名内部类三种方式的适用场景。通过登录审计日志记录器的实战示例,将侦听器能力落地到真实业务中,演示了 Token 脱敏、差异化下线响应、异地登录告警等实践要点。最后着重说明了 try-catch 的必要性——侦听器异常会中断 Sa-Token 主流程,这是使用中最容易忽略的坑。

核心汇总表

事件方法触发时机关键参数
doLogin用户登录时loginParameter(含设备类型、有效期等登录细节)
doLogout用户主动注销时loginId / tokenValue
doKickout被管理员踢下线时loginId / tokenValue
doReplaced被新设备登录顶下线时loginId / tokenValue
doDisable账号被封禁时service(业务标识)/ level(封禁等级)/ disableTime(时长)
doUntieDisable账号被解封时service
doOpenSafe开启二级认证时service(业务标识)/ safeTime(有效期)
doCloseSafe关闭二级认证时service
doCreateSessionSession 创建时id(Session 唯一标识)
doLogoutSessionSession 注销时id
doRenewTimeoutToken 续期时tokenValue / timeout(续期后有效期)
实现方式适用场景优缺点
实现 SaTokenListener 接口需要处理所有事件必须实现全部方法,代码量大
继承 SaTokenListenerForSimple只关心部分事件(推荐)只重写需要的方法,简洁
匿名内部类轻量场景 / 测试代码简单快速,但不适合生产
注册方式适用场景
@Component 自动注册SpringBoot 项目(推荐)
SaTokenEventCenter.registerListener() 手动注册非 IoC 环境 / 运行时动态注册
安全规则说明
try-catch 包裹不安全代码侦听器异常会中断主流程,数据库写入、HTTP 请求等必须保护
Token 脱敏后再记录日志完整 Token 相当于身份凭证,不应出现在日志文件中
doKickoutdoReplaced 分开处理两种下线原因对应不同的业务响应(客服通知 vs 安全告警)

第二章. 全局过滤器——更底层的请求拦截

阶段式学习路径

第三篇笔记中,我们用 SaInterceptor 拦截器实现了路由鉴权。拦截器已经能满足绝大多数场景,但它并非唯一的选择。Sa-Token 同时提供了全局过滤器,作为拦截器的替代或补充方案。

两者不是为了互相取代而存在的——它们工作在请求处理链的不同层次,各自有擅长的场景。本章先把选型问题讲清楚,再完整介绍过滤器的注册和配置,以及几个容易踩坑的细节。


2.1. 过滤器 vs 拦截器:完整选型指南

理解两者的区别,首先要理解它们在 Spring 请求处理链中的位置:

1
2
3
4
5
6
7
8
9
HTTP 请求

Filter(过滤器)← SaServletFilter 在这里

DispatcherServlet

Interceptor(拦截器)← SaInterceptor 在这里

Controller 方法

过滤器更靠前,在 Spring 的 DispatcherServlet 之前就介入了请求;拦截器在 DispatcherServlet 之后才介入,此时 Spring 已经完成了请求的路由解析。

这个位置差异决定了两者的能力边界:

维度拦截器(SaInterceptor)过滤器(SaServletFilter)
执行时机DispatcherServlet 之后DispatcherServlet 之前
异常处理进入 @ExceptionHandler 全局处理不进入,必须在 setError 中手动处理
静态资源拦截不拦截(Spring 静态资源直接响应)可以拦截
获取 HandlerMethod可以(知道请求将进入哪个方法)不可以
注解鉴权支持✅ 内置,@SaCheckLogin 等注解生效❌ 不支持
WebFlux 支持❌ 无SaReactorFilter
防渗透扫描能力一般更强(执行更早,攻击流量更早被拦截)

选型建议:

绝大多数 SpringBoot + SpringMVC 项目,优先使用拦截器。原因很简单:异常会自动进入全局处理器,不需要额外编写错误响应逻辑;支持注解鉴权;对日常的登录校验和权限控制完全够用。

以下情况考虑使用过滤器:

  • 项目使用 Spring WebFlux(没有拦截器机制,过滤器是唯一选择)
  • 需要拦截静态资源的访问(如图片、PDF 需要权限才能下载)
  • 需要在请求处理链最早期介入,例如统一设置安全响应头

Sa-Token 同时提供两种机制,不是让谁取代谁,而是让你根据实际业务合理选择。两者也可以同时注册,互不冲突。


2.2. 注册 SaServletFilter

过滤器默认处于关闭状态,需要手动注册为 Spring Bean。与拦截器注册在 WebMvcConfigurer 中不同,过滤器以 @Bean 方式注册:

📄 src/main/java/com/example/authsatoken/config/SaTokenFilterConfig.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
package com.example.authsatoken.config;

import cn.dev33.satoken.filter.SaServletFilter;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* Sa-Token 全局过滤器配置
* 演示过滤器的四个核心配置方法,以及常见坑点的正确处理方式
*
* 注意:本项目已在 SaTokenConfig 中注册了拦截器,
* 此配置仅用于演示过滤器写法,实际项目中二选一即可,避免重复拦截
*/
@Configuration
public class SaTokenFilterConfig {

@Bean
public SaServletFilter getSaServletFilter() {
return new SaServletFilter()

// ① 指定拦截路由与放行路由
// addInclude:指定过滤器拦截哪些路径
// addExclude:直接放行哪些路径(在过滤器层面排除,不会进入 setAuth)
// /favicon.ico 是浏览器自动请求的图标,不排除会产生大量无意义的校验日志
.addInclude("/**")
.addExclude("/favicon.ico")

// ② setBeforeAuth:前置函数,在认证函数之前执行
// ⚠️ 坑点:BeforeAuth 不受 addInclude / addExclude 的限制
// 所有进入过滤器的请求(包括被 addExclude 排除的路径)都会执行 BeforeAuth
// 因此不要在 BeforeAuth 里做鉴权逻辑,它的正确用途是设置响应头等无副作用的预处理
.setBeforeAuth(req -> {
// 设置安全响应头(详见 2.3 节)
// 这里的代码对所有请求生效,包括静态资源和被排除的路径
})

// ③ setAuth:认证函数,每次请求执行(受 addInclude / addExclude 约束)
// 写法与拦截器中的 SaRouter 完全一致
.setAuth(obj -> {
SaRouter.match("/**")
.notMatch("/auth/login")
.check(r -> StpUtil.checkLogin());
})

// ④ setError:异常处理函数,认证函数抛出异常时执行
// ⚠️ 坑点一:过滤器中的异常不进入 @ExceptionHandler,必须在这里处理
// ⚠️ 坑点二:setError 的返回值直接作为字符串输出到前端
// 如果不设置响应头,Content-Type 默认是 text/plain,
// 前端收到的是纯文本而不是 JSON,无法正常解析
.setError(e -> {
// 必须手动设置 Content-Type,否则前端无法将返回值解析为 JSON
// SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
return SaResult.error(e.getMessage()).toString();
});
}
}

四个配置方法构成了过滤器的完整生命周期,理解它们的执行顺序和约束范围是正确使用过滤器的关键。


2.3. setBeforeAuth 的正确用途:设置安全响应头

setBeforeAuth 不受 addInclude / addExclude 的限制,对所有进入过滤器的请求都会执行。这个特性看起来像个坑,但其实暗示了它的正确用途:做对所有请求都需要生效的无副作用预处理,最典型的就是设置安全响应头。

安全响应头是一组 HTTP 响应头,告诉浏览器如何更安全地处理响应内容,防御 XSS、点击劫持等常见攻击。将它放在 setBeforeAuth 中,可以保证每一个响应(包括静态资源、错误页面)都携带这些头信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.setBeforeAuth(req -> {
SaHolder.getResponse()
// 禁止页面在 iframe 中显示,防止点击劫持攻击
// DENY=完全禁止 | SAMEORIGIN=只允许同域 | ALLOW-FROM uri=指定域名
.setHeader("X-Frame-Options", "SAMEORIGIN")

// 启用浏览器内置的 XSS 过滤器
// 1; mode=block 表示检测到 XSS 攻击时停止渲染整个页面
.setHeader("X-XSS-Protection", "1; mode=block")

// 禁止浏览器对响应内容进行类型嗅探
// 防止浏览器将非 JS 文件当作 JS 执行
.setHeader("X-Content-Type-Options", "nosniff")

// 服务器标识,隐藏真实服务器信息(安全加固)
.setServer("sa-server");
})

2.4. setError 的正确写法

setError 是过滤器与拦截器相比最容易踩坑的地方。拦截器中,SaInterceptor 抛出的异常会自动进入 @ExceptionHandler 全局处理,我们在番外篇二第四章写的 GlobalExceptionHandler 会帮我们把异常转换成规范的 JSON 响应。

但过滤器中的异常不走这套机制setAuth 里抛出的任何异常都会被 setError 捕获,setError 的返回值(一个字符串)直接写入 HTTP 响应体,响应就结束了。

这带来两个问题:

问题一:Content-Type 默认是 text/plain

如果不手动设置响应头,前端收到的是纯文本字符串,而不是 application/json,导致前端的 JSON 解析失败。

问题二:SaResult.toString() 不是合法 JSON

SaResult 对象的 toString() 方法返回的是 Java 对象的字符串表示(如 SaResult{code=500, ...}),不是 JSON 格式。需要用 JSON 序列化工具将其转换。

正确写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
.setError(e -> {
// 第一步:手动设置响应头,告诉前端这是 JSON 格式
SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");

// 第二步:将错误信息序列化为 JSON 字符串
// 方案 A:使用 Jackson(项目已引入 Spring Boot 时 Jackson 默认可用)
try {
SaResult result = SaResult.error(e.getMessage()).setCode(401);
return new ObjectMapper().writeValueAsString(result);
} catch (Exception ex) {
return "{\"code\":500,\"msg\":\"系统错误\",\"data\":null}";
}

// 方案 B:使用 Hutool(需要引入 hutool-json 依赖)
// return JSONUtil.toJsonStr(SaResult.error(e.getMessage()).setCode(401));

// 方案 C:直接硬编码 JSON 字符串(简单但不灵活)
// return "{\"code\":401,\"msg\":\"" + e.getMessage() + "\",\"data\":null}";
})

三种方案的选择建议:项目已使用 Spring Boot 时优先选方案 A(Jackson 已经在依赖中),项目已引入 Hutool 则选方案 B,其余情况用方案 C 兜底。


2.5. 自定义过滤器执行顺序

SaServletFilter 默认注册顺序为 -100(在 Spring Boot 中 Order 值越小执行越早)。如果项目中存在多个过滤器,或者需要 Sa-Token 过滤器在某个自定义过滤器之前 / 之后执行,可以通过 FilterRegistrationBean 包装来指定顺序:

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
@Bean
public FilterRegistrationBean<SaServletFilter> getSaServletFilter() {
FilterRegistrationBean<SaServletFilter> frBean = new FilterRegistrationBean<>();

frBean.setFilter(
new SaServletFilter()
.addInclude("/**")
.addExclude("/favicon.ico")
.setAuth(obj -> {
SaRouter.match("/**")
.notMatch("/auth/login")
.check(r -> StpUtil.checkLogin());
})
.setError(e -> {
SaHolder.getResponse().setHeader("Content-Type", "application/json;charset=UTF-8");
return SaResult.error(e.getMessage()).setCode(401).toString();
})
);

// 默认值是 -100,数值越小越先执行
// 设为 -101 表示在默认 Sa-Token 过滤器之前执行,设为 -99 表示之后执行
frBean.setOrder(-101);

return frBean;
}

2.6. WebFlux 中注册过滤器

Spring WebFlux 不提供拦截器机制,因此如果你的项目基于 WebFlux(响应式编程模型),过滤器是实现路由鉴权的唯一选择。

Sa-Token 为 WebFlux 提供了 SaReactorFilter,写法与 SaServletFilter 几乎完全一致,只需替换类名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Spring WebFlux 项目中注册 Sa-Token 过滤器
* 除类名从 SaServletFilter 换为 SaReactorFilter 外,其余写法完全相同
*/
@Bean
public SaReactorFilter getSaReactorFilter() {
return new SaReactorFilter()
.addInclude("/**")
.addExclude("/favicon.ico")
.setBeforeAuth(req -> {
// WebFlux 中同样可以设置安全响应头
})
.setAuth(obj -> {
SaRouter.match("/**")
.notMatch("/auth/login")
.check(r -> StpUtil.checkLogin());
})
.setError(e -> SaResult.error(e.getMessage()));
}

WebFlux 项目中引入的是 sa-token-reactor-spring-boot3-starter 依赖,而不是 sa-token-spring-boot3-starter,两个包提供的 API 基本相同,核心区别在于底层响应模型(阻塞 vs 响应式)。


2.7. 本章总结

本章回顾

本章系统讲解了 Sa-Token 全局过滤器的完整用法,并给出了明确的选型建议。选型层面,通过执行时机、异常处理、WebFlux 支持等六个维度的对比,得出"SpringMVC 项目优先用拦截器,WebFlux 项目或需要拦截静态资源时才用过滤器"的结论。在配置层面,详细讲解了 addInclude / addExclude / setBeforeAuth / setAuth / setError 五个配置方法的用途和约束范围,并重点说明了两个高频坑点:setBeforeAuth 不受 exclude 限制因此不适合放鉴权逻辑,setError 必须手动设置 Content-Type 响应头并使用 JSON 序列化工具才能返回正确格式。最后介绍了通过 FilterRegistrationBean 自定义执行顺序,以及 WebFlux 环境下只需将类名换为 SaReactorFilter 的迁移写法。

核心汇总表

选型维度拦截器(推荐)过滤器
适用框架Spring MVCSpring MVC / WebFlux
异常处理自动进入 @ExceptionHandler必须在 setError 中手动处理
注解鉴权支持不支持
静态资源拦截不拦截可拦截
推荐场景绝大多数 Spring Boot 项目WebFlux / 静态资源鉴权 / 最早期安全策略
配置方法作用关键约束
addInclude(path)指定过滤器拦截的路径支持 ** 通配符
addExclude(path)直接放行的路径不进入 setAuth,但仍会进入 setBeforeAuth
setBeforeAuth(lambda)前置函数,认证前执行不受 include/exclude 限制,适合设置安全响应头
setAuth(lambda)认证函数,受 include/exclude 约束写法与拦截器中 SaRouter 完全一致
setError(lambda)异常处理函数,认证函数抛出异常时执行返回值直接输出到前端,必须手动设置 Content-Type
坑点原因解决方案
setError 前端收到纯文本未设置 Content-Type 响应头setError 内调用 SaHolder.getResponse().setHeader(...)
setError 返回非法 JSONSaResult.toString() 不是 JSON使用 Jackson / Hutool 序列化,或手动拼接 JSON 字符串
setBeforeAuth 对被排除的路径也生效不受 include/exclude 限制不在 setBeforeAuth 中做鉴权,只做无副作用的预处理
安全响应头作用
X-Frame-Options: SAMEORIGIN防止点击劫持,只允许同域 iframe 嵌入
X-XSS-Protection: 1; mode=block启用浏览器 XSS 过滤,检测到攻击时停止渲染
X-Content-Type-Options: nosniff禁止浏览器进行内容类型嗅探