谁都能伪造别人的设备数据?物联网平台安全两道门:一机一密认证 + ACL 权限(Spring Boot + EMQX 实战)
这是"物联网设备接入平台"系列的第 6 篇。前几篇我们把设备接入、数据入库、实时看板都跑通了,但一直有个安全隐患没处理:任何一台设备都能伪造另一台设备的数据。这篇把它关死。
一、没有鉴权的平台,等于没锁门
先看我们之前的架构有什么问题。
MQTT Broker(EMQX)默认是"谁都能连、连上啥都能发"的。如果什么都不做,会出现两个级别的风险:
- 陌生人随便连:任何拿到 Broker 地址的人都能连上来,发布、订阅任意主题。
- 自己人互相伪造:这个更隐蔽也更致命——认证只解决"是不是自己人",不解决"你是哪个设备"。设备 A 完全可以往设备 B 的主题
up/{productKey}/{B的id}发数据,服务端按 B 的身份存库。看板上 B 的温度曲线从此不可信;如果下游接了告警、控制逻辑,这就是安全事故。
对应的解法是两道门:
- 认证(Authentication):你是谁?——一机一密,每台设备独立的密钥。
- 授权(Authorization / ACL):你能干什么?——每台设备只能发自己的主题。
类比一下:认证是小区门禁刷脸,授权是你家的钥匙开不了别人家的门。
下面是我在自己的项目(Spring Boot 3.5 + EMQX 5.3)里的完整实现。
二、第一道门:一机一密,密钥明文永远不落库
设备注册时,服务端生成一个随机密钥,明文只在注册响应里出现这一次,库里只存 SHA-256 哈希:
/** 设备密钥的 SHA-256 哈希。@JsonIgnore:任何接口都不允许把它序列化出去 */
@JsonIgnore
private String secretHash;
public RegisterResult register(RegisterDeviceRequest request) {
// ... 省略重名校验
String secret = generateSecret(); // UUID + SecureRandom 拼接
Device device = new Device();
device.setSecretHash(sha256(secret)); // 只存哈希,不存明文
Device saved = deviceRepository.save(device);
return new RegisterResult(saved.getId(), /* ... */, secret, /* ... */);
// secret 明文只在这一刻返回给设备端,之后连服务端自己都不知道
}
这样即使数据库被拖走,攻击者拿到的也只是哈希,没法直接连 Broker。设备丢了密钥怎么办?提供 reset-secret 接口轮换:旧密钥立即作废,新明文同样只返回一次。
面试考点:为什么不存明文?——存明文的库一旦泄露 = 全部设备裸奔;存哈希的话,认证时把设备提交的明文做同样的 SHA-256 再比对即可,服务端全程不需要知道密钥原文。
三、让 EMQX 来"敲门":HTTP 认证器
密钥存好了,但 MQTT 连接是 EMQX 处理的,它怎么知道密码对不对?答案是 EMQX 5.x 的 HTTP 认证器:每台设备连 Broker 时,EMQX 把用户名密码回调给我们写的 HTTP 接口,由我们的业务库说了算。
EMQX 控制台配置(访问控制 → 认证 → Password-Based → HTTP Server):
接口实现(真实代码,项目里的 MqttAuthController):
@PostMapping("/auth")
public ResponseEntity<Map<String, String>> auth(@RequestBody Map<String, String> body) {
String clientId = body.getOrDefault("clientid", "");
String username = body.getOrDefault("username", "");
String password = body.getOrDefault("password", "");
// 服务端内部账号:固定用户名直接放行(要订阅所有 up/+/+、st/+/+)
if (SERVER_USERNAME.equals(username)) {
return allow();
}
// 设备约定:username = {productKey}:{deviceId},password = deviceSecret 明文
String[] parts = username.split(":", 2);
if (parts.length != 2) return deny();
String productKey = parts[0];
long deviceId = Long.parseLong(parts[1]);
Device device = deviceRepository.findById(deviceId).orElse(null);
if (device == null || !productKey.equals(device.getProductKey())) return deny();
// 禁用的设备直接拒绝——档案级开关,比删密钥更快、可逆
if (DeviceService.STATUS_DISABLED.equals(device.getStatus())) return deny();
// 核心:拿提交的明文算同样的 SHA-256,跟库里比对
if (!DeviceService.sha256(password).equals(device.getSecretHash())) return deny();
return allow();
}
这里有一个我真实踩过的坑,值一次面试分数:EMQX 要求的响应格式是 {"result": "allow"} 或 {"result": "deny"},不是你项目里统一的 ApiResponse。我第一次配完认证器,接口返回了自定义格式的 JSON,结果 EMQX 解析不到 result 字段,一律按 deny 处理,所有设备(包括我自己的模拟设备)全部连不上 Broker,日志里还看不出明显报错。配完认证器"谁也连不上",先检查响应格式。
四、第二道门:ACL,把"伪造别人数据"的口子焊死
认证通过只说明"是自己人"。回到开头的问题:设备 A 认证通过后,往 up/{pk}/{B的id} 发消息,EMQX 管不管?默认不管。
所以还需要授权器(ACL)。配置方式与认证器类似(访问控制 → 授权 → HTTP Server):
代码同样只有一个接口,核心是最小权限:
@PostMapping("/acl")
public ResponseEntity<Map<String, String>> acl(@RequestBody Map<String, String> body) {
String topic = body.getOrDefault("topic", "");
String action = body.getOrDefault("action", "");
String username = body.getOrDefault("username", "");
if (SERVER_USERNAME.equals(username)) return allow(); // 服务端要订阅所有设备
String[] parts = username.split(":", 2);
if (parts.length != 2) return deny();
String productKey = parts[0];
String deviceId = parts[1];
// 只允许 PUBLISH 自己的遥测/状态主题;SUBSCRIBE 一律拒绝
if ("publish".equals(action)
&& (topic.equals("up/" + productKey + "/" + deviceId)
|| topic.equals("st/" + productKey + "/" + deviceId))) {
return allow();
}
log.warn("ACL 拒绝: username={} action={} topic={}", username, action, topic);
return deny();
}
三条规则,每条都有讲究:
- 设备只能发布自己的
up/{pk}/{id}和st/{pk}/{id}——设备 A 想伪造 B 的数据?EMQX 直接拒绝发布,消息根本到不了服务端。 - 订阅一律拒绝——设备端没有"看别人数据"的合理需求,直接封死,省得想各种越权场景。
- 未知用户 fail closed(拒绝)而不是放行——鉴权逻辑里,拿不准的一律 deny。宁可误伤一次重连,也不能漏放一个未知身份。
五、跑起来看看效果
模拟设备(Python 脚本按真机标准连 MQTT)连上后,EMQX 客户端列表里能看到两个连接:device-th-sensor-1(设备,用户名 th-sensor:1)和 iothub-server-001(服务端订阅器)——每台设备都是独立身份、独立密钥:
认证器概览页的统计也很诚实:允许 2 次(两台真实身份连上)、不匹配 5 次——那 5 次是我测试时故意发错密码的,全部被拒在了第一道门:
数据最终进库、上看板,链路和安全两不误:
六、还能再做什么
- 哈希升级:SHA-256 目前够用(密钥本身是 128 位随机数,不是弱口令,彩虹表打不动),但如果面试官追问,标准答案是设备类场景可换 bcrypt/加盐。
- 管理令牌:注册、轮换、禁用这些写操作我加了
X-Admin-Token请求头,比对用的是常数时间比较(防时序攻击)。 - 审计:ACL 拒绝时打 warn 日志(代码里已经有了),生产环境可以接告警——频繁的 ACL 拒绝往往意味着有设备在越权试探。
总结
| 问题 | 解法 | 落点 |
|---|---|---|
| 陌生人随便连 | 一机一密 + HTTP 认证器 | 注册只发一次明文,库里只有 SHA-256 |
| 自己人伪造数据 | HTTP 授权器(ACL)最小权限 | 只准 publish 自己的主题,订阅全拒 |
| 密钥泄露 | reset-secret 轮换、disable 禁用 | 旧密钥立即作废,禁用秒级生效 |
认证解决"你是谁",授权解决"你能干什么",fail closed 兜底。 这三句话就是物联网平台安全的骨架,也是面试里"你怎么设计设备鉴权"的标准答题框架。
作者:软件工程在读,正在从零搭一个物联网设备接入平台(Spring Boot + EMQX + MySQL),系列文章记录真实踩坑。下一篇计划写设备密钥轮换与禁用的状态机设计。