Skip to content

Filter、Listener 与会话管理 ​

S3-019建议学进阶约 60 分钟JDK 21企业开发基础 · Java Web 基础

1. 本节定位 ​

《HTTP 请求响应与 Servlet》 讲了"请求怎么进到 Servlet"。本节回答**"在它进到 Servlet 之前,还会经过谁"**:

text
HTTP 请求
   ↓
Filter 1  ──┐
   ↓         │  ← 本节:这里可以做很多事
Filter 2  ──┘     (鉴权、编码、日志、CORS、限流……)
   ↓
Servlet / DispatcherServlet
   ↓
Controller 方法

以及一个所有 Web 应用都绕不开的问题:HTTP 是无状态的,"登录状态"到底存在哪?

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 Filter 在请求处理链条上的位置,以及**"洋葱模型"的执行顺序**;
  • 用 Filter 实现一个能拦截请求的鉴权逻辑;
  • 说出 Filter 与 Interceptor 的四个核心区别,并知道某个需求该用哪个;
  • 说出 Listener 能监听哪些事件;
  • 说出 Cookie / Session / Token 三者的存储位置与取舍;
  • 说出 Session 在"集群部署"与"服务重启"时的两个现实问题;
  • 说出 Cookie 的三个安全属性(HttpOnly / SameSite / Secure)各自防什么。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一类"每个接口都要做"的事:

需求如果写在 Controller 里
统一鉴权每个方法开头都要检查 token ✗ 漏一个就是漏洞
统一编码每个方法都 setCharacterEncoding ✗
统一记录请求日志复制粘贴 N 遍 ✗
统一设置 CORS 头同上 ✗
统一限流同上 ✗

这些事的共同点:"所有请求都要做,且与具体业务无关" ✓

这正是 Filter 的设计目的:

把"横切关注点"从业务代码里抽出来,放在一个"必经之路"上。

一次实现,全部生效——而且不会漏掉新加的接口。 ✓

《日志框架与日志规范》 的 TraceIdFilter 就是最好的例子:它给每个请求打上 traceId,业务代码一行都不用改 ✓

5. 核心概念 ​

5.1 Filter 在链条上的位置 ​

text
Tomcat 接收请求
   ↓
Filter 1.doFilter()  ── 前段
   ↓
Filter 2.doFilter()  ── 前段
   ↓
DispatcherServlet / 目标 Servlet
   ↓
Controller 方法
   ↓
Filter 2.doFilter()  ── 后段
   ↓
Filter 1.doFilter()  ── 后段
   ↓
响应写回

三件事:

点说明
顺序由 order 决定(数字小的在外层)与 Spring AOP 的 @Order 是同一个概念
每个 Filter 都有"前段"与"后段"前段在 chain.doFilter 之前,后段在之后
后段像栈一样倒序执行所以叫"洋葱模型" ✓

5.2 洋葱模型:实测执行顺序 ​

本项目注册了两个 Filter(LoggingFilter order=1、AuthFilter order=2),实测日志:

text
[LoggingFilter] ── 前段:进入 GET /raw/hello(线程 http-nio-18090-exec-1)
===== 收到一个请求(第 1 次)=====
请求方法:GET
请求 URI :/raw/hello
线程名   :http-nio-18090-exec-1(Tomcat 的工作线程池)
[LoggingFilter] ── 后段:结束 /raw/hello,耗时 22862 μs

能看到前段 → 目标资源 → 后段的结构 ✓

被拦截时更明显(见 5.3 节):

text
[LoggingFilter] ── 前段:进入 GET /api/secure/whoami
[AuthFilter]   ── 前段:校验 Authorization = null
[AuthFilter]   ── 拦截:返回 401,后续 Filter 与目标资源都不会执行
[LoggingFilter] ── 后段:结束 /api/secure/whoami,耗时 2612 μs

注意 AuthFilter 没有"后段"——因为它直接 return 了,没有调用 chain.doFilter ✓

这张日志说明了三件事:

观察含义
LoggingFilter 的前段在最外order=1 在外层(数字小的先执行前段、后执行后段)
AuthFilter 在内层order=2
拦截后 LoggingFilter 的后段仍然执行因为它在外层——所以耗时统计放在最外层 Filter 最准 ✓

"后段一定要写在 finally 里"(本项目就是这么写的):

java
try {
    chain.doFilter(request, response);
} finally {
    // 不管目标资源成功还是抛异常,这里都会执行
    System.out.println("[LoggingFilter] ── 后段:结束,耗时 " + cost + " μs");
}

如果不放 finally:目标资源抛异常时,后段代码不执行 → 日志"有进入没结束",反而更难排查 ✗

5.3 Filter 能"不放行" ​

这是 Filter 最核心的能力:

java
if (authorization == null || !authorization.startsWith("Bearer ")) {
    httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    httpResponse.setContentType("application/json;charset=UTF-8");
    httpResponse.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"...\"}");
    return;                       // ★ 直接返回,不调用 chain.doFilter
}
chain.doFilter(request, response);

实测结果:

text
===== 未带 token =====
{"code":"UNAUTHORIZED","message":"缺少或格式错误的 Authorization 头,期望形如 Bearer <token>"}
[HTTP 401]

===== 带 token =====
{"message":"你通过了 AuthFilter 的校验","authorization":"Bearer demo-token-123",...}
[HTTP 200]

"不放行"意味着:后面的 Filter 与目标 Controller 都不会执行 ✓

为什么 Filter 适合做鉴权:

理由说明
在业务之前不用进到 Controller 就能拒绝
覆盖全部 URL只要"URL 模式"配到,新加的接口自动被保护 ✓
统一只写一次

⚠️ 但"用 Filter 做鉴权"有个前提:URL 模式必须配全 ✗

如果某个新接口的路径不在 urlPatterns 里,它就不受保护 ✗✗

所以实践中有两种更稳的做法:

做法说明
"默认全拦 + 白名单放行" ★Filter 配 /*,然后显式列出不需要登录的路径(/login、/health)✓
用 Spring Security它把"哪条路径什么权限"做成统一配置,并有大量安全默认值(《Spring Security 认证与授权》 会讲)✓

反例(不要这样):枚举"需要保护的路径"——迟早会漏 ✗

5.4 Filter 与 Interceptor 的区别 ​

这是本节最常被问到的一个问题:

维度FilterInterceptor
属于哪个规范Servlet 规范(jakarta.servlet)Spring MVC(org.springframework.web.servlet)
作用范围所有请求(含静态资源、其他 Servlet、非 MVC 请求)只作用于 DispatcherServlet 处理的请求
能拿到什么ServletRequest / ServletResponse(原始的)HttpServletRequest + HandlerMethod ★
知道"要调哪个方法"吗❌ 不知道✅ 知道((HandlerMethod) handler)
能改请求体吗✅ 能(ContentCachingRequestWrapper)❌ 不能(已经解析完了)
能改响应吗✅ 能(包装 ServletResponse)⚠️ 有限
执行时机更早(在 DispatcherServlet 之前)更晚(在 HandlerMapping 之后、方法调用之前)
异常处理在 MVC 的异常处理器之外在 MVC 的异常体系之内
注册方式FilterRegistrationBean / @WebFilterWebMvcConfigurer.addInterceptors

一张图看懂两者的位置:

text
请求
 ↓
【Filter】                ← 这里还不知道要调哪个方法
 ↓
DispatcherServlet
 ↓
HandlerMapping(找到方法)
 ↓
【Interceptor.preHandle】 ← 这里已经知道要调哪个方法了 ★
 ↓
Controller 方法
 ↓
【Interceptor.postHandle】
 ↓
【Interceptor.afterCompletion】  ← 不管成功失败都会执行
 ↓
【Filter 的后段】
 ↓
响应

怎么选(按需求对照):

需求用哪个为什么
字符编码设置Filter要在最早的时候设,且要覆盖所有请求 ✓
CORS 头Filter静态资源与错误响应也要带 ✓
请求/响应日志(原始报文)Filter能看到最原始的请求(含静态资源)
鉴权(看 token)Filter(或 Spring Security)要覆盖全部路径 ✓
基于注解的权限检查(如 @RequirePermission)Interceptor需要知道"要调哪个方法",才能读到方法上的注解 ★
记录"某个业务方法的耗时"Interceptor能拿到方法信息
给方法参数做预处理Interceptor同上
MDC traceId 注入(《日志框架与日志规范》)Filter要在最早的时候注入 ✓

一句话记法:

Filter = "在门口"(还不知道你要找谁);Interceptor = "在柜台"(已经知道你要办哪件事)。 ✓

5.5 Listener:监听容器的事件 ​

Listener 监听的是"容器生命周期与作用域变化":

Listener 接口监听什么典型用途
ServletContextListener应用启动 / 关闭初始化全局资源(加载配置、启动后台任务、预热缓存) ✓
HttpSessionListener会话创建 / 销毁统计在线人数 ✓
ServletRequestListener请求开始 / 结束很少用(Filter 更合适)
HttpSessionAttributeListener会话属性的增删改审计、调试

ServletContextListener 的一个实际用途:

java
@WebListener
public class AppStartupListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent event) {
        // 应用启动完成时执行(比 ApplicationRunner 更早,属于容器层)
    }

    @Override
    public void contextDestroyed(ServletContextEvent event) {
        // 应用关闭时:**关闭线程池、释放资源** —— 漏了会导致"进程退不出去" ✗
    }
}

⚠️ 一个实践建议:在 Spring Boot 项目里,大多数场景用 ApplicationRunner / @PostConstruct / DisposableBean 就够了 ✓

什么时候必须用容器级 Listener:

场景说明
需要比 Spring 容器更早执行例如容器启动即注册某个东西
需要感知"会话"级别的事件HttpSessionListener 没有 Spring 等价物 ⚠️
不用 Spring 的项目那 Listener 就是唯一选择

先明确问题:HTTP 是无状态的——服务器怎么知道"这次请求和上次是同一个浏览器"?

答案:靠一个"标识"在对端之间来回传——而这个标识怎么存、存在哪,就产生了三种方案。

text
服务器 → 响应头:Set-Cookie: name=value; Path=/; HttpOnly
浏览器 → 后续请求自动带上:Cookie: name=value

关键特性:

特性说明
浏览器自动存取与携带开发者不用手写代码 ✓(这也是它方便的地方)
有域与路径限制Domain / Path 决定"发给谁"
有大小限制单个约 4KB
同源策略约束跨域时受 SameSite 与 CORS 约束

机制:

text
① 用户登录 → 服务器创建 Session(内存里一个 Map),生成 sessionId
② 响应头:Set-Cookie: JSESSIONID=01B06119DB67...     ← 只给"钥匙",不给"内容"
③ 后续请求:浏览器自动带上 Cookie: JSESSIONID=...
④ 服务器按 sessionId 找回那个 Map,取出登录信息 ✓

本项目实测:

text
登录:{"message":"登录成功","sessionId":"01B06119DB671F798CE3F58DB628B358","isNew":true,...}
查看:{"message":"已登录","user":"alice","token":"a2a3d004","sessionId":"01B06119DB671F798CE3F58DB628B358",...}
退出:{"message":"已退出","destroyedSessionId":"01B06119DB671F798CE3F58DB628B358",...}
退出后:{"message":"没有会话(未登录或会话已过期)"}

四项观察:

观察含义
isNew: true这次是新建的会话(第一次来)
sessionId 四次一致浏览器一直带着同一个 Cookie(curl 的 -b/-c 模拟了这一点)
user: alice 能读出来登录信息存在服务端,不靠客户端传
invalidate() 后立刻查不到服务端可以"主动失效" ✓

Set-Cookie 响应头实测:

text
Set-Cookie: JSESSIONID=57FD1A27453A7212A6C48CE5973F3E81; Path=/; HttpOnly; SameSite=Lax

三个安全属性(每一个都在防不同的攻击):

属性作用防什么
HttpOnlyJS 读不到这个 CookieXSS 偷会话 ✓(document.cookie 拿不到)
SameSite=Lax跨站请求时不带这个 CookieCSRF ✓(从别的网站发起的请求带不上会话)
Secure只在 HTTPS 下发送中间人窃听 ✓(生产环境必开)

本项目的配置(application.yml):

yaml
server:
  servlet:
    session:
      cookie:
        http-only: true
        same-site: lax

⚠️ SameSite 的取舍:

值行为影响
Strict最严(跨站一律不带)⚠️ 从别的网站点链接过来会"未登录"
Lax(现代浏览器默认)导航类请求(GET 跳转)可以带,POST/iframe 不带安全与体验的平衡 ✓
None全带(必须同时开 Secure)跨站场景需要(如 iframe 嵌入)⚠️

Token(JWT):把状态放到客户端 ​

机制:

text
① 登录成功 → 服务器签发一个"签过名的令牌",里面直接包含用户信息
② 客户端自己保存(localStorage / 内存)并在每次请求的 Authorization 头里带上
③ 服务器**验签**即可知道"你是谁"——不需要查存储 ✓

三种方案的取舍 ​

维度CookieSessionToken(JWT)
数据存在哪浏览器服务端客户端
服务端有状态吗—有 ✗无 ✓
集群部署—要共享(Redis / 粘性会话)✗天然支持 ✓
服务端重启无影响会话丢失(内存存储时)✗无影响 ✓
能否主动失效—✅ 能(invalidate)✓❌ 难(要维护黑名单)✗
撤销/改权限立刻生效—✅ 立刻⚠️ 要等过期(或查黑名单)
每个请求的开销小要查存储(单机内存快、Redis 要一次网络往返)验签是纯计算,很快 ✓
存储压力在客户端(4KB 限制)在服务端(用户多了内存吃紧)✗在客户端(令牌变大会撑大请求头)⚠️
典型场景会话 id 的载体传统 Web、同域应用前后端分离、移动端、跨服务

怎么选(务实版):

场景推荐
单体应用 + 前后端同域Session(简单、能主动失效)✓
前后端分离(跨域)Token(或用 Session + CORS 凭证模式)
微服务 / 多端(App、小程序)Token ✓
需要"立刻踢人下线"Session(或 Token + 黑名单/短过期 + 刷新令牌)✓

⚠️ 一个常见误解:

"JWT 更安全"是错的 ✗

它们只是"状态放在哪"的不同取舍。JWT 的载荷是Base64 编码而非加密——任何人都能解开看内容,只是改不了(有签名)✓

所以:JWT 里不能放敏感信息 ✗

5.7 Session 的两个现实问题 ​

问题一:集群部署时会话丢失

text
用户登录 → 请求打到机器 A → 会话存在 A 的内存里
下一次请求 → 负载均衡打到机器 B → B 不认识这个 sessionId ✗ 要求重新登录

三种解法:

方案做法评价
粘性会话(Sticky Session)让同一个用户一直打到同一台机器⚠️ 机器挂了会话就没了;扩容时分布不均
会话共享(Redis) ★把 Session 存到 Redis,所有机器都读它推荐 ✓(Spring Session 开箱支持)
无状态 Token干脆不用 Session但要接受"难主动失效" ✓

Spring Boot 里用 Redis 共享会话(一行依赖):

xml
<dependency>
  <groupId>org.springframework.session</groupId>
  <artifactId>spring-session-data-redis</artifactId>
</dependency>

问题二:服务重启会话就没了

默认的 Session 存在 JVM 内存里 ✗ → 重启即全丢(用户全部被踢下线)

解法同上(存 Redis)✓ 或者接受它(很多系统的会话本来就短)✓

⚠️ 还有一个容易忽略的:Session 里的数据不能太大 ✗

java
session.setAttribute("user", user);                  // ✓ 存个对象
session.setAttribute("allOrders", millionOrders);    // ✗ 存海量数据 → 内存爆炸

规则:Session 里只放"标识与小对象"(userId、权限列表),大对象放缓存,Session 里存 key ✓

6. 递进示例 ​

6.1 最小示例:一个记录耗时的 Filter ​

java
public class TimingFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        long start = System.nanoTime();
        try {
            chain.doFilter(request, response);
        } finally {
            System.out.println(Thread.currentThread().getName() + " 耗时 "
                    + (System.nanoTime() - start) / 1_000 + " μs");
        }
    }
}

6.2 业务示例:鉴权 Filter + 会话 ​

见第 7 节。

6.3 反例:用"枚举受保护路径"的方式做鉴权 ​

java
registration.setUrlPatterns(List.of("/api/secure/*", "/api/admin/*"));      // ⚠️

问题:一旦新加了 /api/v2/orders,它就完全不受保护 ✗✗

这类漏洞的特点:

特点说明
不报错、不告警接口"能正常访问",只是没鉴权
测试时发现不了因为"功能是对的"
只有攻击者或安全扫描会发现代价可能很高

正确的做法(默认拒绝):

java
// ✓ 全拦,然后白名单放行
registration.setUrlPatterns(List.of("/*"));
// Filter 内部:
if (isPublicPath(request.getRequestURI())) {     // /login、/health、静态资源……
    chain.doFilter(request, response);
    return;
}
// 其余一律要求登录

设计原则:

"默认拒绝,显式放行"(Deny by Default)—— 白名单漏了会"多拦",能立刻发现;黑名单漏了会"少拦",很难发现 ✓ ✗

这与 《参数校验与全局异常处理》 的"兜底异常处理"、《Spring Boot 配置管理与多环境》 的"默认安全配置"是同一条原则 ✓

7. 完整可运行代码 ​

文件清单(labs/web-basics/):

text
filter/LoggingFilter.java          order=1,统计耗时
filter/AuthFilter.java             order=2,校验 Authorization
api/SessionController.java         登录 / 查看 / 退出 / Cookie 观察
WebBasicsApplication.java          注册 Servlet 与 Filter

Filter 的注册与顺序:

java
@Bean
public FilterRegistrationBean<LoggingFilter> loggingFilter() {
    FilterRegistrationBean<LoggingFilter> registration = new FilterRegistrationBean<>(new LoggingFilter());
    registration.setUrlPatterns(List.of("/raw/*", "/api/*"));
    registration.setOrder(1);                    // 数字小的在外层
    return registration;
}

@Bean
public FilterRegistrationBean<AuthFilter> authFilter() {
    FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>(new AuthFilter());
    registration.setUrlPatterns(List.of("/raw/secure/*", "/api/secure/*"));
    registration.setOrder(2);
    return registration;
}

运行命令:

powershell
cd labs
./mvnw -pl web-basics package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar web-basics/target/web-basics.jar

实测命令与输出:

powershell
# 1. 未带 token —— 被 AuthFilter 拦截
curl.exe -s -w "`n[HTTP %{http_code}]`n" "http://localhost:18090/api/secure/whoami"
# {"code":"UNAUTHORIZED","message":"缺少或格式错误的 Authorization 头,期望形如 Bearer <token>"}
# [HTTP 401]

# 2. 带 token —— 放行
curl.exe -s -w "`n[HTTP %{http_code}]`n" -H "Authorization: Bearer demo-token-123" "http://localhost:18090/api/secure/whoami"
# {"message":"你通过了 AuthFilter 的校验","authorization":"Bearer demo-token-123",...}
# [HTTP 200]

# 3. 会话:登录 → 查看 → 退出 → 再查看
curl.exe -s -c cookies.txt "http://localhost:18090/api/login?user=alice"
# {"message":"登录成功","sessionId":"01B06119DB671F798CE3F58DB628B358","isNew":true,...}
curl.exe -s -b cookies.txt "http://localhost:18090/api/me"
# {"message":"已登录","user":"alice","token":"a2a3d004","sessionId":"01B06119DB671F798CE3F58DB628B358",...}
curl.exe -s -b cookies.txt "http://localhost:18090/api/logout"
# {"message":"已退出","destroyedSessionId":"01B06119DB671F798CE3F58DB628B358",...}
curl.exe -s -b cookies.txt "http://localhost:18090/api/me"
# {"message":"没有会话(未登录或会话已过期)"}

# 4. 看 Set-Cookie 的安全属性
curl.exe -s -D - -o NUL "http://localhost:18090/api/login?user=bob" | Select-String "Set-Cookie"
# Set-Cookie: JSESSIONID=57FD1A27453A7212A6C48CE5973F3E81; Path=/; HttpOnly; SameSite=Lax

应用日志(Filter 洋葱模型):

text
[LoggingFilter] ── 前段:进入 GET /api/secure/whoami
[AuthFilter]   ── 前段:校验 Authorization = null
[AuthFilter]   ── 拦截:返回 401,后续 Filter 与目标资源都不会执行
[LoggingFilter] ── 后段:结束 /api/secure/whoami,耗时 2612 μs

[LoggingFilter] ── 前段:进入 GET /api/secure/whoami
[AuthFilter]   ── 前段:校验 Authorization = Bearer demo-token-123
[AuthFilter]   ── 放行(token 前缀正确)

四处最值得盯住的:

输出说明了什么
被拦截时 AuthFilter 没有"后段"它 return 了,没有 chain.doFilter ✓
拦截时 LoggingFilter 的"后段"仍然执行它在更外层,所以耗时统计放这里最准 ✓
sessionId 四次完全一致浏览器(curl 的 cookie jar)一直带着同一个 Cookie ✓
Set-Cookie 带 HttpOnly; SameSite=Lax这两个属性分别防 XSS 偷会话与 CSRF ✓

8. Debug 观察点 ​

  1. 在 LoggingFilter 与 AuthFilter 的 chain.doFilter 前后各打断点:观察断点命中顺序是"1 前 → 2 前 → 2 后 → 1 后"(洋葱模型);
  2. 在 AuthFilter 的 return 处打断点(401 的请求):观察后续断点都不再命中;
  3. 让 AuthFilter 抛一个异常:观察 LoggingFilter 的后段仍然执行(因为它写在 finally 里);
  4. 在 SessionController.login 里观察 session.isNew():第一次是 true,带上 Cookie 再访问一次是 false ✓;
  5. 把 server.servlet.session.cookie.http-only 改成 false:观察 Set-Cookie 里的 HttpOnly 消失(理解它是一道防线)✗。

9. 常见错误 ​

错误 1:在 Filter 里用"枚举受保护路径" ​

见 6.3 节。这是安全漏洞,不是风格问题 ✗

错误 2:后段代码不写在 finally 里 ​

java
long start = System.nanoTime();
chain.doFilter(request, response);              // ✗ 这里抛异常,下面不执行
System.out.println("耗时 " + (System.nanoTime() - start));      // 永远看不到

症状:出错的请求"有进入没结束",反而更难排查 ✗

错误 3:Filter 里忘了放行 ​

java
public void doFilter(...) {
    doSomething();
    // ✗ 忘了 chain.doFilter(request, response)
}

症状:请求"卡住"(客户端一直等,直到超时)✗

排查:"请求无响应"时,看看有没有 Filter 忘了放行 ✓

错误 4:以为 Session 是"服务端全局的" ​

java
session.setAttribute("user", user);       // ✓ 只属于这一个会话

误区:Session 是"每个用户一份",不是"全站共享" ✗

要放全局数据:用 ServletContext(getServletContext().setAttribute)或 Spring 的单例 Bean ✓

错误 5:把大对象放进 Session ​

见 5.7 节。用户一多,服务端内存就吃紧 ✗

错误 6:以为"Token 不需要服务端存储"就等于"更简单" ​

Token 的隐性成本:

成本说明
不能主动失效要额外维护黑名单(那就又"有状态"了)✗
令牌体积大每个请求都要带(放大了请求头)
刷新机制要自己做access token + refresh token 的配对与轮换
不能存敏感信息载荷是可解码的(只是不可篡改)✗

选择建议:别为了"看起来现代"而用 Token ✗ ——单体应用用 Session 更简单、更可控 ✓

java
response.setHeader("Access-Control-Allow-Origin", "https://example.com");
// ⚠️ 但这还不足以让浏览器发送 Cookie ✗

跨域带 Cookie 需要三个条件:

条件配置
浏览器端fetch(url, { credentials: 'include' })
服务端Access-Control-Allow-Credentials: true
服务端Allow-Origin 不能是 *(必须是具体域名)✗

第三条容易被忽略:"允许任意来源" + "允许携带凭证"是极度危险的组合,浏览器直接禁止 ✓(这是规范层面的保护)

还有第四个坑:SameSite 与跨域的关系——SameSite=Lax 时,跨域的 POST 请求带不上 Cookie ✗ 需要 SameSite=None; Secure(必须 HTTPS)✓

10. 动手实验 ​

目标:把"洋葱顺序、拦截、会话"三件事亲手验证一遍。

步骤:

  1. 运行第 7 节的命令,核对四处关键输出;
  2. 改 Filter 顺序:把 LoggingFilter 的 order 改成 3(比 AuthFilter 大)——观察日志顺序的变化(AuthFilter 跑到外层了)✓
  3. 观察"不放行"的完整影响:给 AuthFilter 加一行 System.out.println 在 return 之后——观察它不会被执行 ✓
  4. 制造"忘了放行":把 chain.doFilter 注释掉,用 curl 加 --max-time 5 发请求——观察超时 ✗
  5. 观察会话过期:把 server.servlet.session.timeout 改成 10s,登录后等 12 秒再访问 /api/me——观察"没有会话" ✓
  6. 观察 isNew 的变化:连续两次登录(带 Cookie),观察第二次的 isNew 变成 false(复用了会话)✓

验收方式:能说出第 2 步顺序变化的原因、第 4 步为什么会超时。

扩展任务:把会话数据换到 Redis(或用"文件"模拟共享):

  • 写一个"会话存储"抽象:SessionStore(put(sessionId, data) / get(sessionId))
  • 两种实现:内存 Map 与文件存储
  • 用 Filter 从 Cookie 里读 sessionId、从 SessionStore 取数据
  • 说明:为什么"把会话放文件"能让两个应用实例共享会话?(提示:文件是两进程都能读的外部存储——Redis 就是它的高效版本)✓

11. 练习题 ​

基础题

  1. 判断:Filter 的"后段"代码在目标资源抛异常时不会执行。(对 / 错)
  2. 填空:Filter 属于 ______ 规范,能拿到 ______;Interceptor 属于 ______,能拿到 ______(提示:其中一个有"知道要调哪个方法"的能力)。
  3. 简答:为什么"枚举受保护路径"的鉴权方式是危险的?

代码阅读题

java
public class MyFilter implements Filter {
    private int requestCount = 0;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        requestCount++;
        if (requestCount % 100 == 0) {
            System.out.println("已处理 " + requestCount + " 个请求");
        }
        chain.doFilter(request, response);
    }
}
  1. 这段代码有什么问题?
  2. 如果要实现"每 100 个请求打一条日志",正确写法是什么?

编程题

  1. 写一套"请求审计"的 Filter 组合:
    • Filter A:请求体缓存——把请求体读出来存进一个 ContentCachingRequestWrapper,供后续使用(不破坏 @RequestBody);
    • Filter B:审计日志——记录"谁在什么时间调用了哪个接口、请求体摘要、响应状态码、耗时";
    • 要求:
      • A 必须在 B 之前(说明为什么);
      • 审计日志不打印敏感字段(复用 《日志框架与日志规范》 的掩码思路);
      • 耗时统计放在 finally 里;
      • 大请求体(> 1MB)只记录长度不打内容(说明为什么);
    • 回答:为什么"审计日志"更适合用 Filter,而"记录某个业务方法的耗时"更适合用 Interceptor?

综合任务

  1. 为一个"后台管理系统"设计完整的会话与鉴权方案:
    • 场景:单体应用、前后端分离(前端 https://admin.example.com,后端 https://api.example.com)、要支持"管理员踢人下线";
    • 要求:
      • 在 Session 与 Token 之间做选择,并说明理由(考虑:跨域、踢人下线、集群部署、移动端支持);
      • 设计登录 → 携带凭证 → 鉴权 → 退出的完整流程(每一步谁做什么、传什么);
      • 设计Filter 与 Interceptor 的分工(哪些事放 Filter、哪些放 Interceptor,说明理由);
      • 说明Cookie 的安全属性怎么配(考虑跨域场景下 SameSite 的取舍);
      • 设计"默认拒绝 + 白名单"的路径策略(列出白名单应该包含哪些);
    • 额外要求:说明"如果以后要接入微信小程序(不能方便地管理 Cookie),你的方案要怎么改?"
分级提示

提示 1(方向):第 1 题的关键是"后段写在哪"。

提示 2(关键点):第 4 题的 requestCount++ 与 《HTTP 请求响应与 Servlet》 的 totalRequests++ 是同一类问题。

提示 3(接近答案):第 7 题的"踢人下线"是 Session 的强项、Token 的痛点——这是选择的决定性因素之一。

12. 参考答案 ​

基础题
  1. 错。如果后段写在 finally 里,它一定执行 ✓
java
try {
    chain.doFilter(request, response);
} finally {
    // 目标资源抛异常也会到这里 ✓
}

但如果没写 finally(见 9.2 节),就不会执行 ✗

所以这道题的准确回答是:"取决于你怎么写"——规范的做法是写在 finally 里,从而保证执行 ✓

为什么要保证执行:后段通常做"收尾"——记录耗时、清理 ThreadLocal(如 MDC)、归还资源 ✓ 漏掉会:

漏掉什么后果
耗时统计出错的请求没有日志,排查时"看不见"✗
MDC.removetraceId 串号(《日志框架与日志规范》 的 5.5 节)✗
资源归还连接/文件句柄泄漏 ✗
  1. Servlet;ServletRequest / ServletResponse(原始的请求与响应);Spring MVC;HandlerMethod(知道要调哪个方法)。

这张表是"怎么选"的依据:

需求需要"知道方法"吗选哪个
编码、CORS、覆盖静态资源❌Filter ✓
MDC traceId 注入❌Filter ✓
读方法上的注解(权限、限流)✅Interceptor ✓
记录业务方法的耗时✅Interceptor ✓
  1. 因为它"默认放行"——漏配的路径不受任何保护,而且不会报错 ✗✗

对比两种策略:

策略漏配时的后果能否发现
枚举需要保护的路径(黑名单)那个接口完全裸奔 ✗✗❌ 极难发现(功能正常)
默认全拦 + 白名单放行那个接口被多拦了(返回 401)✅ 立刻发现(测试/自己就撞上了)

"多拦"和"少拦"的不对称性:

多拦是"可用性问题",会被立刻抱怨、立刻修复;少拦是"安全问题",可能几个月都没人知道,直到出事。 ✗✗

所以安全设计里默认选"拒绝" ✓

这条原则有很多同名说法:Deny by Default、Fail Closed、白名单优于黑名单、最小权限原则……它们的核心都是同一件事 ✓

代码阅读题(第 4、5 题)

第 4 题:requestCount++ 不是线程安全的。

原因:Filter 同样是单例(《HTTP 请求响应与 Servlet》 的 5.4 节),doFilter 会被多个工作线程并发调用 ✗

requestCount++ 的三步(读-改-写)在并发下会丢更新 ✗

后果:计数比实际少——"每 100 个请求打一条"可能变成"每 130 个才打一条" ⚠️

第 5 题:正确写法。

java
public class MyFilter implements Filter {
    private final AtomicInteger requestCount = new AtomicInteger();      // ✓ 线程安全

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        // incrementAndGet 是原子的:它返回的是"自增后的值",不会两个线程拿到同一个数
        int current = requestCount.incrementAndGet();
        if (current % 100 == 0) {
            System.out.println("已处理 " + current + " 个请求");
        }
        chain.doFilter(request, response);
    }
}

⚠️ 更彻底的思路:这个计数器根本不该放在 Filter 里 ✓

问题说明
多实例部署时每个实例各算各的 ✗集群下"总数"没有意义
重启就归零 ✗—
这类"运行指标"有更专业的地方放监控系统(《日志框架与日志规范》 的 5.8 节:日志/指标/链路追踪的分工)✓

推荐:用 Micrometer + Prometheus 打点,或在日志里输出结构化字段让采集系统聚合 ✓

判断标准:

"这个数据,我是要"debug 时看一眼",还是要"长期监控"?"

  • 临时看一眼 → 日志 ✓
  • 长期监控 → 监控系统(别自己维护计数器)✓

这与 《日志框架与日志规范》 的"日志、指标、链路追踪三者分工"是同一个话题 ✓

编程题(第 6 题)
java
/** Filter A:把请求体缓存起来,让后续可以反复读 */
public class RequestBodyCachingFilter implements Filter {

    private static final int MAX_CACHE_BYTES = 1024 * 1024;      // 1MB

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        if (!(request instanceof HttpServletRequest httpRequest)) {
            chain.doFilter(request, response);
            return;
        }
        // ContentCachingRequestWrapper:读一次,缓存起来,之后 getContentAsByteArray() 可以反复取
        ContentCachingRequestWrapper wrapper =
                new ContentCachingRequestWrapper(httpRequest, MAX_CACHE_BYTES);
        try {
            chain.doFilter(wrapper, response);          // 注意:把 wrapper 传下去
        } finally {
            // 目标资源读完之后,这里就能拿到完整的请求体了
            byte[] body = wrapper.getContentAsByteArray();
            if (body.length > 0) {
                AuditContext.setRequestBody(new String(body, StandardCharsets.UTF_8));
            }
        }
    }
}
java
/** Filter B:审计日志 */
public class AuditLogFilter implements Filter {

    private static final Logger log = LoggerFactory.getLogger(AuditLogFilter.class);
    private static final int MAX_BODY_LOG_LENGTH = 1000;         // 日志里最多打 1000 字符
    private static final Set<String> SENSITIVE_KEYS =
            Set.of("password", "token", "idCard", "bankCard", "phone");

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        long start = System.nanoTime();
        int status = HttpServletResponse.SC_OK;
        try {
            chain.doFilter(request, response);
            status = ((HttpServletResponse) response).getStatus();
        } catch (Exception exception) {
            status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
            throw exception;
        } finally {
            long costMillis = (System.nanoTime() - start) / 1_000_000;
            String body = AuditContext.getRequestBody();
            log.info("审计 uri={} method={} status={} cost={}ms user={} body={}",
                    httpRequest.getRequestURI(),
                    httpRequest.getMethod(),
                    status,
                    costMillis,
                    CurrentUser.of(httpRequest),          // 从会话/Token 里解出来
                    summarize(body));
            AuditContext.clear();                              // ★ 一定要清理
        }
    }

    /** 脱敏 + 截断 */
    private String summarize(String body) {
        if (body == null || body.isBlank()) {
            return "(空)";
        }
        if (body.length() > MAX_BODY_LOG_LENGTH) {
            // 大请求体只记长度,不打内容 —— 既省日志空间,也避免把文件内容写进日志
            return "(过大,已省略,长度 " + body.length() + ")";
        }
        String masked = body;
        for (String key : SENSITIVE_KEYS) {
            // 简易掩码:把 "password":"xxx" 里的值替换掉
            masked = masked.replaceAll("(\"" + key + "\"\\s*:\\s*\")([^\"]*)(\")", "$1***$3");
        }
        return masked;
    }
}
java
/** 用 ThreadLocal 在同一个请求的两个 Filter 之间传数据(注意必须清理) */
public final class AuditContext {
    private static final ThreadLocal<String> REQUEST_BODY = new ThreadLocal<>();

    public static void setRequestBody(String body) {
        REQUEST_BODY.set(body);
    }

    public static String getRequestBody() {
        return REQUEST_BODY.get();
    }

    public static void clear() {
        REQUEST_BODY.remove();          // ★ 不清理会内存泄漏 + 串号
    }

    private AuditContext() {
    }
}

A 必须在 B 之前的原因:

因为 B 要读"缓存好的请求体",而缓存是 A 做的 ✓

顺序反了会怎样:B 执行时 A 还没包装,B 读到的 request 是原始的 ✗

但更糟的是:如果 B 直接去读原始请求体,会把流读空——后面的 @RequestBody 就解析失败了 ✗✗

这就是 5.6 节"请求体只能读一次"的实际影响 ✓

⚠️ 还有两个细节:

细节说明
A 要"把 wrapper 传下去"chain.doFilter(wrapper, response)——否则后面的代码拿到的还是原始 request ✗
ContentCachingRequestWrapper 的缓存是"读时记录"只有下游真的读了,缓存里才有内容 ⚠️(所以通常还要在 Filter 里主动触发一次读取)

为什么"审计日志用 Filter"、"业务方法耗时用 Interceptor":

需求选理由
审计日志Filter① 要覆盖所有请求(含静态资源、404、被拦截的未授权请求)✓ ② 未授权请求根本到不了 Interceptor ✗ ——但这恰恰是最需要审计的 ✓✓
业务方法耗时Interceptor需要知道方法名/参数(否则日志里只有 URL,分不清调了哪个方法)

第 ① 条是决定性的:

"谁在什么时候试图访问了什么、被拒绝了"——这是安全审计的核心内容 ✓

而这类请求在 Interceptor 那一层是看不见的 ✗(它们在 Filter 就被拦下了)

所以审计必须在最外层做 ✓

综合任务(第 7 题)

选择:Session + Redis 共享 ✓

理由:

需求Session 是否满足
管理员踢人下线✅ session.invalidate() 立刻生效 ✓✓(这是决定性因素——Token 做不到)
跨域✅ 可支持(SameSite=None; Secure + Access-Control-Allow-Credentials)
集群部署✅ 存 Redis 即可
移动端⚠️ 不方便(但"后台管理系统"本来就不需要移动端)✓
单体应用✅ 不需要"无状态"带来的扩展性

关键权衡:

"踢人下线"是后台管理系统的硬需求(员工离职、账号被盗),Session 在这件事上有天然优势 ✓

Token 要做同样的事,必须引入黑名单——那就又变成"有状态"了,还不如直接用 Session ✓

完整流程:

text
【登录】
① 前端 POST /api/auth/login  {"username":"admin","password":"..."}
   ⚠️ 凭证放在请求体里,不放 URL(避免进日志)✓
② 后端校验 → 创建 Session(存 userId、角色)→ 存进 Redis
③ 响应 Set-Cookie: JSESSIONID=xxx; HttpOnly; Secure; SameSite=None
   (跨域场景需要 SameSite=None,因此必须是 HTTPS)

【携带凭证】
④ 前端后续请求带 credentials: 'include' → 浏览器自动带 Cookie
⑤ 服务端从 Redis 按 sessionId 取会话数据

【鉴权】
⑥ Filter(全拦 + 白名单):
   - 路径在白名单(/api/auth/login、/actuator/health)→ 放行
   - 否则:没有有效会话 → 401
   - 有会话但角色不够 → 这里先不判(留给 Interceptor)
⑦ Interceptor:
   - 读方法或类上的 @RequireRole("ADMIN") → 比对会话里的角色 → 403
   (放在这里的原因:需要"知道要调哪个方法"才读得到注解)

【退出】
⑧ POST /api/auth/logout → session.invalidate() → 删 Redis → 前端跳登录页

Filter 与 Interceptor 的分工:

事放在理由
会话解析(sessionId → 用户)Filter后面所有环节都要用;且未登录时要在最外层就拒绝
登录校验(有没有登录)Filter覆盖全部路径(含静态资源与 404)✓
角色/权限校验Interceptor需要读方法上的注解 ✓
审计日志Filter未授权请求也要记录(6 题的理由)✓
MDC traceIdFilter要最早注入(《日志框架与日志规范》)✓
操作日志("谁改了哪个订单")Interceptor需要方法信息与参数 ✓

Cookie 配置:

yaml
server:
  servlet:
    session:
      timeout: 2h
      cookie:
        http-only: true          # 防 XSS 偷会话
        secure: true             # 跨域 + SameSite=None 时必须 HTTPS
        same-site: none          # 前后端跨域时的无奈选择 ⚠️
        domain: example.com      # 让 admin.example.com 与 api.example.com 共享

⚠️ SameSite=None 使 CSRF 防护变弱 ✗ ——必须补上另外两道防线:

补法说明
CSRF Token每个表单/请求带一个随机 token,服务端校验(《Spring Security 认证与授权》 会讲)✓
校验 Origin / Referer 头只接受自己域名的请求 ✓
敏感操作二次确认(如删除、改密码要重新输密码)✓

"默认拒绝 + 白名单"的路径策略:

java
/** 不需要登录就能访问的路径(白名单要短、要显式、要有人 review) */
private static final List<String> PUBLIC_PATTERNS = List.of(
        "/api/auth/login",              // 登录
        "/api/auth/captcha",            // 验证码
        "/actuator/health",             // 健康检查(给负载均衡用)
        "/error"                        // 错误页(Spring Boot 的默认错误入口)
);

// Filter 里:
if (PUBLIC_PATTERNS.stream().anyMatch(path::startsWith)) {
    chain.doFilter(request, response);         // 放行
    return;
}
// 其余一律要求已登录 —— 不写"哪些需要保护",而是"哪些不需要"

⚠️ 白名单的三个实践要点:

要点说明
白名单要短每加一条都要问"这个真的能公开吗?"
不要放"/actuator/**"只放 health——其他端点可能泄漏配置与环境信息 ✗
静态资源要单独处理/static/**、/*.js 等——但别用 /* 通配,那等于全放行 ✗

接入微信小程序时怎么改:

问题小程序环境的限制
不能方便地管理 Cookie小程序没有浏览器那套 Cookie 机制 ✗
不能依赖 Set-Cookiewx.request 默认不保存 Cookie ⚠️

改造方案(两套凭证并存):

text
后台管理系统:继续用 Session(Cookie 载体)
小程序端:      登录接口返回一个 token,小程序自己存 storage,放在 Authorization 头里
                ↓
Filter 里统一处理两种情况:
    ① 有 Cookie(JSESSIONID)→ 走 Session 逻辑
    ② 有 Authorization: Bearer xxx → 走 Token 逻辑(也可以把 token 映射到一条 Redis 记录)
                ↓
    两种情况最终都产出同一个"当前用户"对象

关键设计:

"凭证的载体"(Cookie / Header)与"凭证的含义"(这是谁、有什么权限)要解耦 ✓

Filter 只负责"从各种载体里解析出当前用户",后续的鉴权逻辑(Interceptor、业务代码)只认"当前用户" ✓

这样加一种载体不用改后面的代码 ✓ ——这与 《Spring MVC 请求映射与参数绑定》 讲的"参数解析器"是同一个设计思路:把"怎么取到"与"怎么用"分开 ✓

13. 掌握标准 ​

  • [ ] 能说出 Filter 在请求链条上的位置与"洋葱模型"的执行顺序;
  • [ ] 会写"后段放 finally"的 Filter;
  • [ ] 会用 Filter 实现拦截(返回 401 而不放行);
  • [ ] 能说出 Filter 与 Interceptor 的四个核心区别,并会按需求选择;
  • [ ] 知道 Listener 的常见类型与用途;
  • [ ] 能说出 Cookie / Session / Token 的存储位置、各自优劣与适用场景;
  • [ ] 知道 Session 在集群与重启时的问题及解法;
  • [ ] 能说出 HttpOnly / SameSite / Secure 各防什么;
  • [ ] 知道"默认拒绝 + 白名单"是安全设计的通用原则;
  • [ ] 知道 ContentCachingRequestWrapper 的用途与"必须传下去"的细节。

14. 扩展资源 ​

15. 下一步学习 ​

《JSON 与 XML》:请求与响应的"数据格式"这一层。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引