1. 从一个真实事故说起:设备数据被伪造的代价
去年帮一个做智慧农业的团队排查问题,他们的土壤墒情监测系统上线三个月,突然发现某片区域的灌溉决策完全乱套——明明传感器上报湿度只有20%,实际地里已经涝了。查了两天日志才定位到问题:有人用一台普通开发板,伪造了十几个"合法"设备的身份,往平台里灌了一堆假数据。更离谱的是,这些假设备还能订阅到其他真实设备的上报主题,等于把整个片区的数据都看光了。
这个事故暴露的其实是物联网平台最基础也最容易被忽视的两道门:身份认证和权限控制。很多团队在开发阶段图省事,EMQX 直接开匿名接入,设备连上来就能发数据,等业务跑起来再想补安全,发现存量设备已经几千台,改造成本高得吓人。
这篇就围绕一机一密认证 + ACL 权限这套组合拳,用 Spring Boot 做认证后端、EMQX 做消息接入,把从设备接入到主题授权的完整链路讲透。适合正在搭物联网平台的后端开发、负责设备接入的嵌入式工程师,以及需要给现有平台补安全短板的运维同学。不管你是刚接触 MQTT 的新手,还是已经用过 EMQX 但没深挖过认证机制的老手,下面这些实操细节和踩坑记录应该都能对上你的场景。
2. 为什么是"一机一密 + ACL"这套组合
2.1 先搞清楚物联网平台到底在防谁
在聊方案之前,得先明确威胁模型。物联网平台的安全威胁大致分三类:设备身份伪造(别人冒充你的设备发数据)、越权访问(设备 A 偷看设备 B 的数据)、重放攻击(截获合法报文重复发送)。这三类里,前两类是绝大多数中小平台最先撞上的,也是本文方案主要解决的。
一机一密解决的是"你是谁"的问题——每台设备出厂时烧录唯一的设备 ID 和密钥,接入时用这对凭证换取出入证。ACL 解决的是"你能干什么"的问题——即使身份合法,也只能发布和订阅自己被授权的主题。两者缺一不可:只有认证没有 ACL,一台被逆向的设备就能监听全网;只有 ACL 没有认证,权限规则根本无从绑定到具体设备。
2.2 为什么不用共享密钥或者用户名密码
我见过不少团队图快,所有设备共用一个 MQTT 用户名密码。这种方案在设备数量少、且设备固件不会被提取的前提下勉强能用,但只要有一台设备被拆机读出凭证,整个平台就等于裸奔。而且共享密钥没法做精细化的 ACL——你没法区分"这台设备只能发自己的数据"和"那台设备可以发自己的数据",因为它们在平台眼里是同一个身份。
一机一密的核心价值在于凭证与设备物理绑定。设备 ID 通常用芯片唯一 ID、MAC 地址或者出厂序列号,密钥在产线烧录时写入安全存储区。这样即使某台设备的密钥泄露,影响范围也被限制在单台设备,平台侧只需要吊销这一台的凭证即可。
2.3 EMQX 的认证链和 ACL 链是怎么走的
EMQX 5.x 的认证和授权是两条独立的链路。设备 CONNECT 时先走认证链,按配置的顺序依次尝试认证器(比如先查 HTTP 认证,再查内置数据库),任一通过即放行。连接建立后,每次 PUBLISH 或 SUBSCRIBE 都会走 ACL 链,同样按顺序匹配规则,命中即执行允许或拒绝。
这个设计的好处是认证和授权解耦。认证可以对接你现有的用户系统(比如 Spring Boot 写的设备管理服务),ACL 可以用 EMQX 内置的规则引擎,也可以用外部 HTTP 服务动态判断。我下面给的方案是认证走 HTTP 回调到 Spring Boot,ACL 用 EMQX 内置的基于客户端属性的规则,这样在保证安全的同时把性能开销控制住——毕竟每条消息都回调 HTTP 做 ACL 判断,QPS 上万之后延迟会很感人。
3. 一机一密认证的完整实现
3.1 设备凭证的设计与产线烧录
设备凭证至少包含三部分:设备唯一 ID(clientId)、设备密钥(secret)、产品标识(productKey)。clientId 建议直接用"产品标识 + 设备序列号"拼接,比如agri_soil_0001,这样在 EMQX 侧看连接列表时一眼能认出设备归属。密钥用 32 位随机字符串,产线烧录时写入设备的安全存储区(比如 ESP32 的 NVS 加密分区、STM32 的 OTP 区域)。
这里有个容易踩的坑:clientId 不能重复。EMQX 默认允许同一 clientId 重复连接,后连接的会把先连接的踢掉。如果你的设备序列号生成逻辑有 bug 导致重复,会出现设备频繁掉线但日志里看不出明显错误。建议在 Spring Boot 的设备注册接口里对 clientId 做唯一性校验,产线烧录前先调接口注册,注册失败就不烧录。
密钥的存储方式直接决定了方案的安全上限。如果设备固件能被轻易读出,那一机一密就退化成了共享密钥。低成本方案可以用芯片自带的唯一 ID 作为密钥派生因子,比如secret = HMAC(芯片UID, 产品级主密钥),这样即使固件被读,攻击者也拿不到产品级主密钥,无法伪造其他设备。当然这要求芯片有硬件加密模块,纯软件实现的话安全性会打折扣。
3.2 Spring Boot 认证接口的写法
EMQX 的 HTTP 认证器会在设备 CONNECT 时 POST 一个 JSON 到你的接口,包含 clientid、username、password 等字段。Spring Boot 侧需要返回{"result": "allow"}或{"result": "deny"}。下面是一个最小可用的实现:
@RestController @RequestMapping("/mqtt/auth") public class MqttAuthController { @Autowired private DeviceService deviceService; @PostMapping("/connect") public Map<String, String> authenticate(@RequestBody Map<String, String> body) { String clientId = body.get("clientid"); String password = body.get("password"); if (clientId == null || password == null) { return Map.of("result", "deny"); } Device device = deviceService.findByClientId(clientId); if (device == null || !device.isEnabled()) { return Map.of("result", "deny"); } // 密钥比对,实际生产建议用 HMAC 而非明文存储 String expected = hmacSha256(device.getSecret(), clientId); if (!constantTimeEquals(expected, password)) { return Map.of("result", "deny"); } return Map.of("result", "allow"); } }几个关键点。第一,密码不要明文传输和存储。设备侧用HMAC-SHA256(secret, clientId)算出摘要作为 password,服务端用同样算法验证。这样即使传输被截获,攻击者也拿不到原始 secret。第二,比对要用恒定时间算法,防止时序攻击——虽然对物联网场景来说这个威胁等级不高,但养成习惯没坏处。第三,接口要做限流,防止有人拿大量伪造 clientId 刷你的认证接口,把数据库打挂。
EMQX 侧配置 HTTP 认证器时,URL 填http://your-backend:8080/mqtt/auth/connect,请求方法选 POST,内容类型选 JSON。认证器顺序放在第一位,后面可以跟一个内置数据库认证器作为兜底(比如给运维用的管理账号)。
3.3 设备侧连接代码示例
以 ESP32 用 PubSubClient 为例,连接逻辑大概是这样:
String clientId = "agri_soil_0001"; String secret = "设备烧录时写入的密钥"; String password = hmacSha256(secret, clientId); client.setServer("mqtt.yourdomain.com", 1883); client.connect(clientId.c_str(), clientId.c_str(), password.c_str());注意 username 这里也填了 clientId,是因为 EMQX 的 ACL 规则里要用到 username 做主题匹配。如果你不想用 username,也可以在 ACL 规则里用%c(clientId 占位符)来匹配主题,效果一样。
提示:生产环境务必用 TLS(8883 端口),否则 password 摘要虽然不能反推 secret,但 clientId 和连接行为会暴露在链路上,给攻击者提供信息。
4. ACL 权限规则的设计与落地
4.1 主题命名规范是 ACL 的地基
ACL 规则写得好不好,一半取决于主题设计。我推荐的主题结构是:
{productKey}/{deviceId}/up 设备上报 {productKey}/{deviceId}/down 平台下发 {productKey}/{deviceId}/cmd 命令响应这种结构的好处是设备 ID 天然出现在主题里,ACL 规则可以直接用%c或%u占位符匹配。比如规则写成{productKey}/%c/up,EMQX 会自动把%c替换成当前连接的 clientId,只有 clientId 匹配的设备才能发布到这个主题。
如果主题设计成sensor/data这种扁平结构,所有设备都往同一个主题发,ACL 就没法做设备级隔离了,只能整主题允许或拒绝。所以主题规范要在项目初期定死,后期改主题等于所有设备固件都要升级,成本极高。
4.2 EMQX 内置 ACL 规则配置
EMQX 5.x 的 ACL 可以在 Dashboard 的"访问控制"里配,也可以直接改配置文件。内置 ACL 的规则格式是:
{allow, {username, {re, "^agri_soil_.*"}}, publish, ["agri/%u/up"]}. {allow, {username, {re, "^agri_soil_.*"}}, subscribe, ["agri/%u/down", "agri/%u/cmd"]}. {deny, all}.这几条规则的意思是:用户名匹配agri_soil_开头的设备,允许发布到agri/{自己的clientId}/up,允许订阅agri/{自己的clientId}/down和cmd,其他一律拒绝。最后那条{deny, all}是兜底,非常重要——没有它的话,EMQX 默认行为是允许所有未匹配的操作,等于 ACL 白配了。
这里有个细节:%u是 username 占位符,%c是 clientId 占位符。如果你的设备连接时 username 和 clientId 填的不一样,要确认用哪个。我一般建议两者填一样的值,避免混淆。
4.3 动态 ACL:什么时候需要回调 HTTP
内置 ACL 适合规则固定的场景。但有些业务需要动态判断,比如"设备 A 可以订阅设备 B 的数据,因为它们在同一个农户账号下"。这种关系存在数据库里,内置 ACL 表达不了,就得用 HTTP 授权器。
HTTP 授权器的配置和认证器类似,EMQX 会在每次 PUBLISH/SUBSCRIBE 时 POST 请求到你的接口,body 里包含 clientid、topic、action(publish/subscribe)。Spring Boot 侧查数据库判断是否允许。这种方案灵活但性能开销大,建议只对订阅操作走 HTTP,发布操作走内置 ACL——因为发布频率远高于订阅。
@PostMapping("/mqtt/acl") public Map<String, String> authorize(@RequestBody Map<String, String> body) { String clientId = body.get("clientid"); String topic = body.get("topic"); String action = body.get("action"); if ("subscribe".equals(action) && topic.startsWith("share/")) { // 共享订阅场景,查设备所属农户的授权关系 boolean allowed = aclService.canSubscribeShared(clientId, topic); return Map.of("result", allowed ? "allow" : "deny"); } return Map.of("result", "deny"); }注意:HTTP 授权器返回 deny 时,EMQX 会直接断开连接还是仅拒绝本次操作,取决于版本配置。EMQX 5.x 默认是拒绝操作但保持连接,这个行为在调试时容易让人困惑——设备看起来连着,但消息发不出去。
5. 联调与压测:把方案跑通再上线
5.1 用 MQTTX 模拟设备做认证测试
方案配好后,别急着烧设备,先用 MQTTX 这类客户端工具模拟。建两个连接:一个用合法的 clientId 和正确密码,一个用错误密码。合法连接应该能成功,错误密码应该被拒绝。然后再测 ACL:用合法设备连接后,尝试发布到别的设备的主题,应该被拒绝。
我习惯用 MQTTX 的脚本功能批量模拟,比如同时起 100 个连接,clientId 从agri_soil_0001到agri_soil_0100,密码用脚本算 HMAC。这样能在几分钟内验证认证接口的并发能力和 ACL 规则的准确性。
5.2 压测时容易暴露的两个问题
第一个是认证接口的响应延迟。EMQX 的 HTTP 认证器默认超时是 5 秒,如果 Spring Boot 接口因为数据库慢查询卡住,设备连接会大面积超时。建议给认证接口加缓存——设备密钥不常变,用 Caffeine 或 Redis 缓存 clientId 到 secret 的映射,TTL 设 5 分钟,能挡掉绝大部分重复查询。
第二个是ACL 规则匹配的性能。内置 ACL 的规则条数如果超过几百条,匹配会变慢。我实测过,规则在 50 条以内时,单节点 EMQX 处理 ACL 匹配的额外延迟在 0.1ms 级别,基本无感;超过 500 条后延迟开始明显。所以规则要精简,能用通配符就别写死列表。
5.3 上线前的安全检查清单
| 检查项 | 合格标准 | 常见问题 |
|---|---|---|
| 匿名接入 | 已关闭 | 开发环境遗留的 allow_anonymous=true 没改 |
| 认证接口 | 有超时和限流 | 数据库慢查询拖垮整个认证链 |
| ACL 兜底规则 | 有 deny all | 漏配导致未匹配操作默认放行 |
| 密钥存储 | 非明文 | 数据库里 secret 字段明文存储 |
| TLS | 生产启用 | 图省事用 1883 明文端口 |
| 日志脱敏 | 密码不落日志 | 认证失败日志里打印了完整 password |
这张表我每次上线前都会过一遍,尤其是"匿名接入"和"ACL 兜底"这两项,出问题的概率最高。
6. 踩过的坑和排查技巧
6.1 设备频繁掉线但认证日志显示成功
这个问题的典型原因是clientId 冲突。两台设备用了同一个 clientId,EMQX 默认行为是踢掉旧连接,导致设备反复重连。排查方法是看 EMQX 的连接日志,搜索 "kicked" 关键字。解决方式是在设备注册接口做 clientId 唯一性校验,产线烧录前先注册。
另一个可能原因是keepalive 设置不合理。设备侧 keepalive 设了 60 秒,但网络抖动导致心跳包偶尔丢失,EMQX 在 1.5 倍 keepalive 时间后判定设备离线。建议 keepalive 设 30-60 秒,并在设备侧实现断线重连,重连时用指数退避避免风暴。
6.2 ACL 规则明明配了却不生效
最常见的原因是规则顺序。EMQX 的 ACL 是按顺序匹配的,第一条命中的规则决定结果。如果你把{deny, all}写在了允许规则前面,那所有操作都会被拒绝。规则顺序应该是:具体的允许规则在前,宽泛的拒绝规则在后。
第二个原因是占位符用错。%u匹配 username,%c匹配 clientId,%a匹配 IP 地址。如果你的设备连接时 username 为空,用%u的规则就永远匹配不上。排查时可以在 EMQX Dashboard 的"访问控制"页面看规则命中统计,哪条规则命中次数为 0 就重点查哪条。
6.3 认证接口被刷导致服务不可用
有次客户的平台突然大量设备掉线,查下来是有人用脚本拿随机 clientId 刷认证接口,把 Spring Boot 的数据库连接池打满了。后来加了两层防护:一是认证接口前置 Redis 缓存,未注册的 clientId 直接返回 deny 不查库;二是用 Sentinel 做限流,单 IP 每秒最多 100 次认证请求。加完之后再没出现过类似问题。
提示:认证接口的限流阈值要根据你的设备规模定。10 万台设备同时重连的场景下,认证 QPS 可能瞬间到几千,限流阈值设太低会误伤正常设备。建议按"设备总数 / 重连窗口秒数"估算,再留 2 倍余量。
6.4 共享订阅场景下的 ACL 特殊处理
共享订阅($share/group/topic)的 ACL 规则和普通订阅不一样,因为主题里多了$share前缀。如果你的 ACL 规则写的是agri/%c/down,共享订阅的主题$share/g1/agri/device1/down就匹配不上。解决办法是在规则里显式加上共享订阅的格式,或者用 HTTP 授权器动态判断。
这个坑我在一个多租户项目里踩过,当时设备订阅用的是共享订阅做负载均衡,结果 ACL 一直拒绝,查了半天才发现是主题前缀的问题。后来在规则里加了一条{allow, {username, {re, "^agri_.*"}}, subscribe, ["$share/+/agri/%u/down"]}才解决。
7. 方案的可扩展方向
这套一机一密加 ACL 的方案跑通后,还有几个方向可以按需扩展。设备证书双向认证适合对安全要求更高的场景,用 X.509 证书替代 HMAC 密钥,EMQX 原生支持,但产线烧录成本会上升。动态密钥轮换适合担心密钥长期暴露的场景,设备定期调接口换新密钥,旧密钥保留一个宽限期后失效。审计日志适合有合规要求的项目,把每次认证和 ACL 判断的结果落库,方便事后追溯。
我个人在实际操作中的体会是,安全方案不要一次上太满,先把认证和 ACL 这两道最基础的门守住,把匿名接入关掉,把兜底拒绝规则配上,就已经挡住了 90% 的常见风险。剩下的证书、轮换、审计,等业务量上来、有明确需求了再逐步加,避免过度设计拖慢上线节奏。