我的物联网设备接入平台(Spring Boot 3.5 + EMQX)有个"管理端":注册设备、重置密钥、禁用设备,都是 POST 接口。安全那篇里我已经把设备侧做了一机一密 + ACL,但回头自查 HTTP 接口本身时发现一个尴尬的事实——这些写接口是裸奔的。
内网服务也不能裸奔:任何人拿到地址,就能往我的平台注册设备、伪造一个数据源,后面整条数据链路都成了给"假设备"打工。这篇文章讲我怎么用一个拦截器 + 一个请求头把写操作守住,全部代码来自项目真实源码,三条实测结果也是当场跑出来的。
一、先定规矩:读开放、写守门
第一步不是写代码,是想清楚拦截策略。我的看板是公开的,读接口(查设备列表、查数据)没必要挡;真正危险的是写操作。所以规矩定为:
- GET / HEAD / OPTIONS 一律放行——看板只读,公开无妨;
- POST 等写操作必须带
X-Admin-Token请求头,令牌不对一律 401; /api/mqtt/**豁免——那是给 EMQX 回调用的内部接口,它有自己的认证体系(一机一密),凭据体系不同,不应该混用管理令牌。
令牌的配置也不硬编码在代码里:application.yml 里写 iot.admin-token: ${IOT_ADMIN_TOKEN:change-me-admin},真实值走环境变量,仓库里不存明文——这是"配置与代码分离"的底线,面试也算一个加分细节。
二、拦截器本体:40 行守住所有写接口
// 来源:src/main/java/com/iothub/common/AdminTokenInterceptor.java
@Slf4j
@Component
public class AdminTokenInterceptor implements HandlerInterceptor {
public static final String HEADER = "X-Admin-Token";
@Value("${iot.admin-token:change-me-admin}")
private String adminToken;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// 读操作放行:看板只读,公开无妨
String method = request.getMethod();
if ("GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method)
|| "OPTIONS".equalsIgnoreCase(method)) {
return true;
}
// EMQX 回调的内部接口豁免:它有自己的认证体系,不适用管理令牌
if (request.getRequestURI().startsWith("/api/mqtt/")) {
return true;
}
String token = request.getHeader(HEADER);
if (constantTimeEquals(adminToken, token)) {
return true;
}
log.warn("写操作缺少/错误的管理令牌: {} {} from {}", method, request.getRequestURI(),
request.getRemoteAddr());
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"缺少或错误的管理令牌 X-Admin-Token\"}");
return false;
}
}
核心逻辑就三层:先按请求方法放行读操作,再按路径豁免内部接口,最后比对令牌——对不上就手写一个统一响应体格式的 401 返回(复用了全局那套 ApiResponse 的结构,前端拿到错误也是同一种形状)。
源码里还有一个容易聊出深度的细节——令牌比较不是用 equals,而是常数时间比较:
// 来源:同上(节选)
/** 用常数时间比较防时序攻击——细节可以聊,态度更重要 */
private boolean constantTimeEquals(String expected, String actual) {
if (actual == null) {
return false;
}
byte[] a = expected.getBytes(StandardCharsets.UTF_8);
byte[] b = actual.getBytes(StandardCharsets.UTF_8);
int diff = a.length ^ b.length;
for (int i = 0; i < Math.min(a.length, b.length); i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
普通 equals 在第一个不同字符处就返回,攻击者理论上能靠"响应时间的微小差异"一位一位猜令牌(时序攻击)。逐字符异或累计、最后统一判断,让比较耗时和"第几位开始不一样"无关。实际风险很低,但面试里能主动讲出这个细节,比背十道八股更加分。
三、挂载:三行配置
// 来源:src/main/java/com/iothub/common/WebConfig.java
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AdminTokenInterceptor adminTokenInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminTokenInterceptor).addPathPatterns("/api/**");
}
}
拦截器挂到 /api/** 上,/api/mqtt/** 的豁免放在拦截器内部做而不是用 excludePathPatterns——把"谁免检"的规则集中在拦截器自己身上,注释里写清原因,后人不会误删。
四、实测:三条路径各回各家
服务起在 8080(H2 内存库 profile,不依赖外部环境),当场实测三条路径:
① 不带令牌 POST 注册接口 → 401,被拦在门外:
② 带上正确令牌、但设备名为空 → 400:
注意这个 400 是好消息:令牌校验通过、拦截器放行,请求才进到业务层的参数校验,报出"deviceName 不能为空"。如果拦截器没生效,这条请求会被 401 挡住;所以"401 → 400"的变化本身就是拦截器生效的证据。这也是自测拦截器的一个小技巧:用一个"合法但参数为空"的请求去探路,看到 400 才说明门真的开了。
③ GET 读接口不带令牌 → 200 正常返回:
三条路径与设计一一对应,而且我给这三条写了自动化测试(真实测试类,CI 里每次都会跑):
// 来源:src/test/java/com/iothub/common/AdminTokenInterceptorTest.java(节选)
@SpringBootTest
@AutoConfigureMockMvc
class AdminTokenInterceptorTest {
@Autowired
private MockMvc mockMvc;
@Test
void writeWithoutTokenIs401() throws Exception {
mockMvc.perform(post("/api/devices")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
void writeWithCorrectTokenPasses() throws Exception {
// 令牌正确 → 进入业务校验(空参数返回 400 而不是 401,说明拦截器放行了)
mockMvc.perform(post("/api/devices")
.header("X-Admin-Token", ADMIN_TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of(
"deviceName", "", "productKey", "th-sensor"))))
.andExpect(status().isBadRequest());
}
@Test
void mqttCallbackIsExempt() throws Exception {
// EMQX 回调是 POST,但必须豁免管理令牌(它有自己的认证体系)
mockMvc.perform(post("/api/mqtt/auth") /* ... */)
.andExpect(status().isOk());
}
}
五、面试高频:过滤器、拦截器、AOP 到底差在哪
做出一个拦截器只是"会用",面试官真正想听的是"知不知道为什么用它"。这三种横切手段我按执行时机捋一遍:
过滤器(Filter):Servlet 规范的一环,在 Servlet 容器层面工作,进 Controller 之前、也进 Spring MVC 之前。它看不到"这个请求要交给哪个 Handler",只能拿到原始的 request/response。适合全站级的粗粒度处理:字符编码、跨域响应头、请求日志。本项目没写自定义 Filter,因为需求是"挡住部分接口",粒度比全站细。
拦截器(HandlerInterceptor):Spring MVC 层面,在 DispatcherServlet 之后、Controller 之前执行,能拿到 handler 对象——知道前面是哪个 Controller 的哪个方法,所以能做"只拦 /api/、豁免 /api/mqtt/"这种基于路由的精细控制,也能从 Spring 容器注入 @Value 配置。鉴权、权限、接口级限流都是它的主场。本项目选它就是因为要按路由豁免 + 按方法区分读写。
AOP 切面:工作在 Bean 方法调用层面,不感知 HTTP 请求,拿不到 HttpServletRequest。它切的是 Service 方法的执行过程,适合业务内横切:方法耗时统计、审计日志、分布式锁。如果需求是"任何地方调用 resetSecret 都要记录审计",那是 AOP 的事,不是拦截器的事。
一句话总结:Filter 管"进不进这个应用",Interceptor 管"进不进这个接口",AOP 管"这个方法执行时额外干什么"。三者不是竞争关系,是三层关卡,粒度从粗到细。
再补两个面试常追问的点:
- 拦截器里
preHandle返回 false 后,afterCompletion只会对已经执行过preHandle且返回 true 的拦截器触发——所以多个拦截器时释放资源要小心。 - 为什么不用 Spring Security?它当然更完备(用户体系、OAuth2 都有),但当前需求只是一个管理令牌,引入整个安全框架是过度设计。升级路线是清晰的:等要做多用户体系时,再把这套"拦截器 + 固定令牌"替换成 Security 的过滤器链,Controller 和 Service 层代码一行都不用改——因为鉴权本来就被隔离在最外层了。
六、小结
这一篇把平台的最后一道门装上了:读接口公开、写操作持令牌、内部回调豁免,40 行拦截器 + 3 行配置 + 3 条自动化测试。至此这个项目的安全链路完整了——设备侧一机一密 + ACL(第 5 篇),平台侧管理令牌拦截器(本篇),业务层还有禁用设备的三重兜底。
下一篇预计回到数据侧:遥测数据量上去之后,MySQL 单表怎么撑住、什么时候该换 TDengine 或分表。欢迎关注,下期见。
作者:软件工程在读(专升本),正在从零搭一套物联网设备接入平台(Spring Boot + EMQX + MySQL),踩过的坑都写成文章。本篇是系列第 11 篇。