主题
界面
主题
《HTTP 请求响应与 Servlet》 讲了"请求怎么进到 Servlet"。本节回答**"在它进到 Servlet 之前,还会经过谁"**:
HTTP 请求
↓
Filter 1 ──┐
↓ │ ← 本节:这里可以做很多事
Filter 2 ──┘ (鉴权、编码、日志、CORS、限流……)
↓
Servlet / DispatcherServlet
↓
Controller 方法以及一个所有 Web 应用都绕不开的问题:HTTP 是无状态的,"登录状态"到底存在哪?
完成本节后,你应该能够:
HttpOnly / SameSite / Secure)各自防什么。先看一类"每个接口都要做"的事:
| 需求 | 如果写在 Controller 里 |
|---|---|
| 统一鉴权 | 每个方法开头都要检查 token ✗ 漏一个就是漏洞 |
| 统一编码 | 每个方法都 setCharacterEncoding ✗ |
| 统一记录请求日志 | 复制粘贴 N 遍 ✗ |
| 统一设置 CORS 头 | 同上 ✗ |
| 统一限流 | 同上 ✗ |
这些事的共同点:"所有请求都要做,且与具体业务无关" ✓
这正是 Filter 的设计目的:
把"横切关注点"从业务代码里抽出来,放在一个"必经之路"上。
一次实现,全部生效——而且不会漏掉新加的接口。 ✓
《日志框架与日志规范》 的 TraceIdFilter 就是最好的例子:它给每个请求打上 traceId,业务代码一行都不用改 ✓
Tomcat 接收请求
↓
Filter 1.doFilter() ── 前段
↓
Filter 2.doFilter() ── 前段
↓
DispatcherServlet / 目标 Servlet
↓
Controller 方法
↓
Filter 2.doFilter() ── 后段
↓
Filter 1.doFilter() ── 后段
↓
响应写回三件事:
| 点 | 说明 |
|---|---|
顺序由 order 决定(数字小的在外层) | 与 Spring AOP 的 @Order 是同一个概念 |
| 每个 Filter 都有"前段"与"后段" | 前段在 chain.doFilter 之前,后段在之后 |
| 后段像栈一样倒序执行 | 所以叫"洋葱模型" ✓ |
本项目注册了两个 Filter(LoggingFilter order=1、AuthFilter order=2),实测日志:
[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 节):
[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 里"(本项目就是这么写的):
try {
chain.doFilter(request, response);
} finally {
// 不管目标资源成功还是抛异常,这里都会执行
System.out.println("[LoggingFilter] ── 后段:结束,耗时 " + cost + " μs");
}如果不放 finally:目标资源抛异常时,后段代码不执行 → 日志"有进入没结束",反而更难排查 ✗
这是 Filter 最核心的能力:
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);实测结果:
===== 未带 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 认证与授权》 会讲)✓ 反例(不要这样):枚举"需要保护的路径"——迟早会漏 ✗
这是本节最常被问到的一个问题:
| 维度 | Filter | Interceptor |
|---|---|---|
| 属于哪个规范 | 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 / @WebFilter | WebMvcConfigurer.addInterceptors |
一张图看懂两者的位置:
请求
↓
【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 = "在柜台"(已经知道你要办哪件事)。 ✓
Listener 监听的是"容器生命周期与作用域变化":
| Listener 接口 | 监听什么 | 典型用途 |
|---|---|---|
ServletContextListener | 应用启动 / 关闭 | 初始化全局资源(加载配置、启动后台任务、预热缓存) ✓ |
HttpSessionListener | 会话创建 / 销毁 | 统计在线人数 ✓ |
ServletRequestListener | 请求开始 / 结束 | 很少用(Filter 更合适) |
HttpSessionAttributeListener | 会话属性的增删改 | 审计、调试 |
ServletContextListener 的一个实际用途:
@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 是无状态的——服务器怎么知道"这次请求和上次是同一个浏览器"?
答案:靠一个"标识"在对端之间来回传——而这个标识怎么存、存在哪,就产生了三种方案。
服务器 → 响应头:Set-Cookie: name=value; Path=/; HttpOnly
浏览器 → 后续请求自动带上:Cookie: name=value关键特性:
| 特性 | 说明 |
|---|---|
| 浏览器自动存取与携带 | 开发者不用手写代码 ✓(这也是它方便的地方) |
| 有域与路径限制 | Domain / Path 决定"发给谁" |
| 有大小限制 | 单个约 4KB |
| 同源策略约束 | 跨域时受 SameSite 与 CORS 约束 |
机制:
① 用户登录 → 服务器创建 Session(内存里一个 Map),生成 sessionId
② 响应头:Set-Cookie: JSESSIONID=01B06119DB67... ← 只给"钥匙",不给"内容"
③ 后续请求:浏览器自动带上 Cookie: JSESSIONID=...
④ 服务器按 sessionId 找回那个 Map,取出登录信息 ✓本项目实测:
登录:{"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 响应头实测:
Set-Cookie: JSESSIONID=57FD1A27453A7212A6C48CE5973F3E81; Path=/; HttpOnly; SameSite=Lax三个安全属性(每一个都在防不同的攻击):
| 属性 | 作用 | 防什么 |
|---|---|---|
HttpOnly | JS 读不到这个 Cookie | XSS 偷会话 ✓(document.cookie 拿不到) |
SameSite=Lax | 跨站请求时不带这个 Cookie | CSRF ✓(从别的网站发起的请求带不上会话) |
Secure | 只在 HTTPS 下发送 | 中间人窃听 ✓(生产环境必开) |
本项目的配置(application.yml):
server:
servlet:
session:
cookie:
http-only: true
same-site: lax⚠️ SameSite 的取舍:
| 值 | 行为 | 影响 |
|---|---|---|
Strict | 最严(跨站一律不带) | ⚠️ 从别的网站点链接过来会"未登录" |
Lax(现代浏览器默认) | 导航类请求(GET 跳转)可以带,POST/iframe 不带 | 安全与体验的平衡 ✓ |
None | 全带(必须同时开 Secure) | 跨站场景需要(如 iframe 嵌入)⚠️ |
机制:
① 登录成功 → 服务器签发一个"签过名的令牌",里面直接包含用户信息
② 客户端自己保存(localStorage / 内存)并在每次请求的 Authorization 头里带上
③ 服务器**验签**即可知道"你是谁"——不需要查存储 ✓| 维度 | Cookie | Session | Token(JWT) |
|---|---|---|---|
| 数据存在哪 | 浏览器 | 服务端 | 客户端 |
| 服务端有状态吗 | — | 有 ✗ | 无 ✓ |
| 集群部署 | — | 要共享(Redis / 粘性会话)✗ | 天然支持 ✓ |
| 服务端重启 | 无影响 | 会话丢失(内存存储时)✗ | 无影响 ✓ |
| 能否主动失效 | — | ✅ 能(invalidate)✓ | ❌ 难(要维护黑名单)✗ |
| 撤销/改权限立刻生效 | — | ✅ 立刻 | ⚠️ 要等过期(或查黑名单) |
| 每个请求的开销 | 小 | 要查存储(单机内存快、Redis 要一次网络往返) | 验签是纯计算,很快 ✓ |
| 存储压力 | 在客户端(4KB 限制) | 在服务端(用户多了内存吃紧)✗ | 在客户端(令牌变大会撑大请求头)⚠️ |
| 典型场景 | 会话 id 的载体 | 传统 Web、同域应用 | 前后端分离、移动端、跨服务 |
怎么选(务实版):
| 场景 | 推荐 |
|---|---|
| 单体应用 + 前后端同域 | Session(简单、能主动失效)✓ |
| 前后端分离(跨域) | Token(或用 Session + CORS 凭证模式) |
| 微服务 / 多端(App、小程序) | Token ✓ |
| 需要"立刻踢人下线" | Session(或 Token + 黑名单/短过期 + 刷新令牌)✓ |
⚠️ 一个常见误解:
"JWT 更安全"是错的 ✗
它们只是"状态放在哪"的不同取舍。JWT 的载荷是Base64 编码而非加密——任何人都能解开看内容,只是改不了(有签名)✓
所以:JWT 里不能放敏感信息 ✗
问题一:集群部署时会话丢失
用户登录 → 请求打到机器 A → 会话存在 A 的内存里
下一次请求 → 负载均衡打到机器 B → B 不认识这个 sessionId ✗ 要求重新登录三种解法:
| 方案 | 做法 | 评价 |
|---|---|---|
| 粘性会话(Sticky Session) | 让同一个用户一直打到同一台机器 | ⚠️ 机器挂了会话就没了;扩容时分布不均 |
| 会话共享(Redis) ★ | 把 Session 存到 Redis,所有机器都读它 | 推荐 ✓(Spring Session 开箱支持) |
| 无状态 Token | 干脆不用 Session | 但要接受"难主动失效" ✓ |
Spring Boot 里用 Redis 共享会话(一行依赖):
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>问题二:服务重启会话就没了
默认的 Session 存在 JVM 内存里 ✗ → 重启即全丢(用户全部被踢下线)
解法同上(存 Redis)✓ 或者接受它(很多系统的会话本来就短)✓
⚠️ 还有一个容易忽略的:Session 里的数据不能太大 ✗
session.setAttribute("user", user); // ✓ 存个对象
session.setAttribute("allOrders", millionOrders); // ✗ 存海量数据 → 内存爆炸规则:Session 里只放"标识与小对象"(userId、权限列表),大对象放缓存,Session 里存 key ✓
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");
}
}
}见第 7 节。
registration.setUrlPatterns(List.of("/api/secure/*", "/api/admin/*")); // ⚠️问题:一旦新加了 /api/v2/orders,它就完全不受保护 ✗✗
这类漏洞的特点:
| 特点 | 说明 |
|---|---|
| 不报错、不告警 | 接口"能正常访问",只是没鉴权 |
| 测试时发现不了 | 因为"功能是对的" |
| 只有攻击者或安全扫描会发现 | 代价可能很高 |
正确的做法(默认拒绝):
// ✓ 全拦,然后白名单放行
registration.setUrlPatterns(List.of("/*"));
// Filter 内部:
if (isPublicPath(request.getRequestURI())) { // /login、/health、静态资源……
chain.doFilter(request, response);
return;
}
// 其余一律要求登录设计原则:
"默认拒绝,显式放行"(Deny by Default)—— 白名单漏了会"多拦",能立刻发现;黑名单漏了会"少拦",很难发现 ✓ ✗
这与 《参数校验与全局异常处理》 的"兜底异常处理"、《Spring Boot 配置管理与多环境》 的"默认安全配置"是同一条原则 ✓
文件清单(labs/web-basics/):
filter/LoggingFilter.java order=1,统计耗时
filter/AuthFilter.java order=2,校验 Authorization
api/SessionController.java 登录 / 查看 / 退出 / Cookie 观察
WebBasicsApplication.java 注册 Servlet 与 FilterFilter 的注册与顺序:
@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;
}运行命令:
cd labs
./mvnw -pl web-basics package -DskipTests
java "-Dstdout.encoding=UTF-8" -jar web-basics/target/web-basics.jar实测命令与输出:
# 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 洋葱模型):
[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 ✓ |
LoggingFilter 与 AuthFilter 的 chain.doFilter 前后各打断点:观察断点命中顺序是"1 前 → 2 前 → 2 后 → 1 后"(洋葱模型);AuthFilter 的 return 处打断点(401 的请求):观察后续断点都不再命中;AuthFilter 抛一个异常:观察 LoggingFilter 的后段仍然执行(因为它写在 finally 里);SessionController.login 里观察 session.isNew():第一次是 true,带上 Cookie 再访问一次是 false ✓;server.servlet.session.cookie.http-only 改成 false:观察 Set-Cookie 里的 HttpOnly 消失(理解它是一道防线)✗。见 6.3 节。这是安全漏洞,不是风格问题 ✗
finally 里 long start = System.nanoTime();
chain.doFilter(request, response); // ✗ 这里抛异常,下面不执行
System.out.println("耗时 " + (System.nanoTime() - start)); // 永远看不到症状:出错的请求"有进入没结束",反而更难排查 ✗
public void doFilter(...) {
doSomething();
// ✗ 忘了 chain.doFilter(request, response)
}症状:请求"卡住"(客户端一直等,直到超时)✗
排查:"请求无响应"时,看看有没有 Filter 忘了放行 ✓
session.setAttribute("user", user); // ✓ 只属于这一个会话误区:Session 是"每个用户一份",不是"全站共享" ✗
要放全局数据:用 ServletContext(getServletContext().setAttribute)或 Spring 的单例 Bean ✓
见 5.7 节。用户一多,服务端内存就吃紧 ✗
Token 的隐性成本:
| 成本 | 说明 |
|---|---|
| 不能主动失效 | 要额外维护黑名单(那就又"有状态"了)✗ |
| 令牌体积大 | 每个请求都要带(放大了请求头) |
| 刷新机制要自己做 | access token + refresh token 的配对与轮换 |
| 不能存敏感信息 | 载荷是可解码的(只是不可篡改)✗ |
选择建议:别为了"看起来现代"而用 Token ✗ ——单体应用用 Session 更简单、更可控 ✓
credentials 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)✓
目标:把"洋葱顺序、拦截、会话"三件事亲手验证一遍。
步骤:
LoggingFilter 的 order 改成 3(比 AuthFilter 大)——观察日志顺序的变化(AuthFilter 跑到外层了)✓AuthFilter 加一行 System.out.println 在 return 之后——观察它不会被执行 ✓chain.doFilter 注释掉,用 curl 加 --max-time 5 发请求——观察超时 ✗server.servlet.session.timeout 改成 10s,登录后等 12 秒再访问 /api/me——观察"没有会话" ✓isNew 的变化:连续两次登录(带 Cookie),观察第二次的 isNew 变成 false(复用了会话)✓验收方式:能说出第 2 步顺序变化的原因、第 4 步为什么会超时。
扩展任务:把会话数据换到 Redis(或用"文件"模拟共享):
SessionStore(put(sessionId, data) / get(sessionId))SessionStore 取数据基础题
代码阅读题
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);
}
}编程题
ContentCachingRequestWrapper,供后续使用(不破坏 @RequestBody);finally 里;综合任务
https://admin.example.com,后端 https://api.example.com)、要支持"管理员踢人下线";SameSite 的取舍);提示 1(方向):第 1 题的关键是"后段写在哪"。
提示 2(关键点):第 4 题的 requestCount++ 与 《HTTP 请求响应与 Servlet》 的 totalRequests++ 是同一类问题。
提示 3(接近答案):第 7 题的"踢人下线"是 Session 的强项、Token 的痛点——这是选择的决定性因素之一。
finally 里,它一定执行 ✓try {
chain.doFilter(request, response);
} finally {
// 目标资源抛异常也会到这里 ✓
}但如果没写 finally(见 9.2 节),就不会执行 ✗
所以这道题的准确回答是:"取决于你怎么写"——规范的做法是写在 finally 里,从而保证执行 ✓
为什么要保证执行:后段通常做"收尾"——记录耗时、清理 ThreadLocal(如 MDC)、归还资源 ✓ 漏掉会:
| 漏掉什么 | 后果 |
|---|---|
| 耗时统计 | 出错的请求没有日志,排查时"看不见"✗ |
MDC.remove | traceId 串号(《日志框架与日志规范》 的 5.5 节)✗ |
| 资源归还 | 连接/文件句柄泄漏 ✗ |
ServletRequest / ServletResponse(原始的请求与响应);Spring MVC;HandlerMethod(知道要调哪个方法)。这张表是"怎么选"的依据:
| 需求 | 需要"知道方法"吗 | 选哪个 |
|---|---|---|
| 编码、CORS、覆盖静态资源 | ❌ | Filter ✓ |
| MDC traceId 注入 | ❌ | Filter ✓ |
| 读方法上的注解(权限、限流) | ✅ | Interceptor ✓ |
| 记录业务方法的耗时 | ✅ | Interceptor ✓ |
对比两种策略:
| 策略 | 漏配时的后果 | 能否发现 |
|---|---|---|
| 枚举需要保护的路径(黑名单) | 那个接口完全裸奔 ✗✗ | ❌ 极难发现(功能正常) |
| 默认全拦 + 白名单放行 | 那个接口被多拦了(返回 401) | ✅ 立刻发现(测试/自己就撞上了) |
"多拦"和"少拦"的不对称性:
多拦是"可用性问题",会被立刻抱怨、立刻修复;少拦是"安全问题",可能几个月都没人知道,直到出事。 ✗✗
所以安全设计里默认选"拒绝" ✓
这条原则有很多同名说法:Deny by Default、Fail Closed、白名单优于黑名单、最小权限原则……它们的核心都是同一件事 ✓
第 4 题:requestCount++ 不是线程安全的。
原因:Filter 同样是单例(《HTTP 请求响应与 Servlet》 的 5.4 节),doFilter 会被多个工作线程并发调用 ✗
requestCount++ 的三步(读-改-写)在并发下会丢更新 ✗
后果:计数比实际少——"每 100 个请求打一条"可能变成"每 130 个才打一条" ⚠️
第 5 题:正确写法。
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 时看一眼",还是要"长期监控"?"
- 临时看一眼 → 日志 ✓
- 长期监控 → 监控系统(别自己维护计数器)✓
这与 《日志框架与日志规范》 的"日志、指标、链路追踪三者分工"是同一个话题 ✓
/** 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));
}
}
}
}/** 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;
}
}/** 用 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 就被拦下了)
所以审计必须在最外层做 ✓
选择:Session + Redis 共享 ✓
理由:
| 需求 | Session 是否满足 |
|---|---|
| 管理员踢人下线 | ✅ session.invalidate() 立刻生效 ✓✓(这是决定性因素——Token 做不到) |
| 跨域 | ✅ 可支持(SameSite=None; Secure + Access-Control-Allow-Credentials) |
| 集群部署 | ✅ 存 Redis 即可 |
| 移动端 | ⚠️ 不方便(但"后台管理系统"本来就不需要移动端)✓ |
| 单体应用 | ✅ 不需要"无状态"带来的扩展性 |
关键权衡:
"踢人下线"是后台管理系统的硬需求(员工离职、账号被盗),Session 在这件事上有天然优势 ✓
Token 要做同样的事,必须引入黑名单——那就又变成"有状态"了,还不如直接用 Session ✓
完整流程:
【登录】
① 前端 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 traceId | Filter | 要最早注入(《日志框架与日志规范》)✓ |
| 操作日志("谁改了哪个订单") | Interceptor | 需要方法信息与参数 ✓ |
Cookie 配置:
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 头 | 只接受自己域名的请求 ✓ |
| 敏感操作二次确认 | (如删除、改密码要重新输密码)✓ |
"默认拒绝 + 白名单"的路径策略:
/** 不需要登录就能访问的路径(白名单要短、要显式、要有人 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-Cookie | wx.request 默认不保存 Cookie ⚠️ |
改造方案(两套凭证并存):
后台管理系统:继续用 Session(Cookie 载体)
小程序端: 登录接口返回一个 token,小程序自己存 storage,放在 Authorization 头里
↓
Filter 里统一处理两种情况:
① 有 Cookie(JSESSIONID)→ 走 Session 逻辑
② 有 Authorization: Bearer xxx → 走 Token 逻辑(也可以把 token 映射到一条 Redis 记录)
↓
两种情况最终都产出同一个"当前用户"对象关键设计:
"凭证的载体"(Cookie / Header)与"凭证的含义"(这是谁、有什么权限)要解耦 ✓
Filter 只负责"从各种载体里解析出当前用户",后续的鉴权逻辑(Interceptor、业务代码)只认"当前用户" ✓
这样加一种载体不用改后面的代码 ✓ ——这与 《Spring MVC 请求映射与参数绑定》 讲的"参数解析器"是同一个设计思路:把"怎么取到"与"怎么用"分开 ✓
finally"的 Filter;HttpOnly / SameSite / Secure 各防什么;ContentCachingRequestWrapper 的用途与"必须传下去"的细节。《JSON 与 XML》:请求与响应的"数据格式"这一层。
暂无