
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 BootACL 用 EMQX 内置的基于客户端属性的规则这样在保证安全的同时把性能开销控制住——毕竟每条消息都回调 HTTP 做 ACL 判断QPS 上万之后延迟会很感人。3. 一机一密认证的完整实现3.1 设备凭证的设计与产线烧录设备凭证至少包含三部分设备唯一 IDclientId、设备密钥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 MapString, String authenticate(RequestBody MapString, 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 规则里用%cclientId 占位符来匹配主题效果一样。提示生产环境务必用 TLS8883 端口否则 password 摘要虽然不能反推 secret但 clientId 和连接行为会暴露在链路上给攻击者提供信息。4. ACL 权限规则的设计与落地4.1 主题命名规范是 ACL 的地基ACL 规则写得好不好一半取决于主题设计。我推荐的主题结构是{productKey}/{deviceId}/up 设备上报 {productKey}/{deviceId}/down 平台下发 {productKey}/{deviceId}/cmd 命令响应这种结构的好处是设备 ID 天然出现在主题里ACL 规则可以直接用%c或%u占位符匹配。比如规则写成{productKey}/%c/upEMQX 会自动把%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、actionpublish/subscribe。Spring Boot 侧查数据库判断是否允许。这种方案灵活但性能开销大建议只对订阅操作走 HTTP发布操作走内置 ACL——因为发布频率远高于订阅。PostMapping(/mqtt/acl) public MapString, String authorize(RequestBody MapString, 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_anonymoustrue 没改认证接口有超时和限流数据库慢查询拖垮整个认证链ACL 兜底规则有 deny all漏配导致未匹配操作默认放行密钥存储非明文数据库里 secret 字段明文存储TLS生产启用图省事用 1883 明文端口日志脱敏密码不落日志认证失败日志里打印了完整 password这张表我每次上线前都会过一遍尤其是匿名接入和ACL 兜底这两项出问题的概率最高。6. 踩过的坑和排查技巧6.1 设备频繁掉线但认证日志显示成功这个问题的典型原因是clientId 冲突。两台设备用了同一个 clientIdEMQX 默认行为是踢掉旧连接导致设备反复重连。排查方法是看 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% 的常见风险。剩下的证书、轮换、审计等业务量上来、有明确需求了再逐步加避免过度设计拖慢上线节奏。