谁都能伪造别人的设备数据?物联网平台安全两道门:一机一密认证 + ACL 权限(Spring Boot + EMQX 实战)

0 阅读6分钟

谁都能伪造别人的设备数据?物联网平台安全两道门:一机一密认证 + ACL 权限(Spring Boot + EMQX 实战)

这是"物联网设备接入平台"系列的第 6 篇。前几篇我们把设备接入、数据入库、实时看板都跑通了,但一直有个安全隐患没处理:任何一台设备都能伪造另一台设备的数据。这篇把它关死。

一、没有鉴权的平台,等于没锁门

先看我们之前的架构有什么问题。

MQTT Broker(EMQX)默认是"谁都能连、连上啥都能发"的。如果什么都不做,会出现两个级别的风险:

  1. 陌生人随便连:任何拿到 Broker 地址的人都能连上来,发布、订阅任意主题。
  2. 自己人互相伪造:这个更隐蔽也更致命——认证只解决"是不是自己人",不解决"你是哪个设备"。设备 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();
}

三条规则,每条都有讲究:

  1. 设备只能发布自己的 up/{pk}/{id} 和 st/{pk}/{id}——设备 A 想伪造 B 的数据?EMQX 直接拒绝发布,消息根本到不了服务端。
  2. 订阅一律拒绝——设备端没有"看别人数据"的合理需求,直接封死,省得想各种越权场景。
  3. 未知用户 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),系列文章记录真实踩坑。下一篇计划写设备密钥轮换与禁用的状态机设计。