项目背景

最近公司启动了一个新的物联网项目,使用MQTT协议与设备通信,在比较了各大MQTT服务后,决定选用开源的RabbitMQ搭建我们的服务端。我们的目标是能够支撑10万台设备同时在线,因此比较看重集群和高可用功能,RabbitMQ在这方面十分优异,同时RabbitMQ也能够兼顾项目中的消息中间件功能,缺点是仅支持3.1.1版本的协议,但对于我们这个项目来说够用。
在设计初期考虑给每一条通讯信息加密来保证安全性,但考虑到10万台设备并发量巨大,每一条消息都加解密会导致服务器计算压力过大,因此决定在设备连接、发布消息以及监听主题时来控制权限。


一、部署RabbiqMQ

在实际生产中我们部署的是集群,在本文简化为单机模式。建议通过RPM安装RabbiqMQ,具体方法不是本文重点,可以参考这篇文章

在安装成功后需要开启MQTT插件:

rabbitmq-plugins enable rabbitmq_mqtt

在安装RabbitMQ后默认只有guest用户,我们一般会创建一个admin用户,使用MQTTX这个工具来测试我们的MQTT服务端是否工作正常,使用admin用户连接,注意将MQTT版本改为3.1.1:
在这里插入图片描述
由于本文的重点在于设备自定义鉴权,因此服务搭建过程较为简略,如果遇到问题无法连接,请参考其他文章逐步排查。

《使用RabbitMQ搭建MQTT服务》

二、设备连接鉴权

1.开启插件

想让RabbiqMQ走我们自定义的鉴权接口,需要先开启 rabbitmq_auth_backend_http 插件,同时该插件还推荐配合 rabbitmq_auth_backend_cache 通过缓存减轻授权认证服务器压力。

rabbitmq-plugins enable rabbitmq_auth_backend_http
rabbitmq-plugins enable rabbitmq_auth_backend_cache

另外使用 rabbitmq-plugins list 命令可以查看已经开启的插件和版本。

2.修改配置

然后我们可以去下载官方示例,打开示例项目的 AuthBackendHttpController 类,并将默认用户从guest改为admin:

    private final Map<String, User> users = new HashMap<String, User>() {{
        put("admin", new User("admin", "admin", asList("administrator", "management")));
        put("springy", new User("springy", "springy", asList("administrator", "management")));
    }};

然后启动服务,稍后我们会在官方示例的基础上进行鉴权逻辑的开发。

在服务启动成功后,我们需要到服务器上修改RabbitMQ的配置以告知我们的接口地址,使用RPM安装的配置文件应该在 /etc/rabbitmq/rabbitmq.config,如果没有也可以自己创建。
使用 rabbitmqctl environment 可以看到当前默认配置:

{rabbit,
     [{auth_backends,[rabbit_auth_backend_internal]},
      {auth_mechanisms,['PLAIN','AMQPLAIN']},
...
{rabbitmq_auth_backend_cache,
     [{cache_module,rabbit_auth_cache_ets},
      {cache_module_args,[]},
      {cache_refusals,false},
      {cache_ttl,15000},
      {cached_backend,rabbit_auth_backend_internal}]},
 {rabbitmq_auth_backend_http,
     [{http_method,get},
      {resource_path,"http://localhost:8000/auth/resource"},
      {topic_path,"http://localhost:8000/auth/topic"},
      {user_path,"http://localhost:8000/auth/user"},
      {vhost_path,"http://localhost:8000/auth/vhost"}]},
...

我们需要覆盖默认配置,在 rabbitmq.config 中找到 auth_backends 项,在数组中增加 rabbit_auth_backend_cache,把 rabbitmq_auth_backend_http 这一项中四个接口地址改为正确的地址(localhost改为你的IP)。
修改成功后重启服务:

service rabbitmq-server restart 

再次使用 rabbitmqctl environment 查看配置是否生效。

3.连接鉴权

此时再使用admin/admin账号通过MQTTX工具连接服务,因该能够看到接口打印的日志“认证通过”。

下面我们开始着手改造示例中的 user 方法,我们的设备都会有自己的设备名称(deviceName)以及产品ID(productId),每个产品又会有自己的密钥(productKey),其中每台设备的设备名称不允许重复,因此我们使用产品ID和设备名称结合作为连接的用户名({deviceName}@{productId}),密码则使用上述三个字段做MD5生成。

注:实际生产中密码的生成规则会更加复杂,此处仅作示例。

假定上述三个字段分别如下:

        String deviceName = "87654321";
        String productId = "123456";
        String productKey = "abcdef";

执行代码:

        String password = Md5Utils.hash(deviceName + productId + productKey);
        System.out.println(password);

输出结果为:5b0e93055c3bf7db0fbc1eb19f2a3777

综上所述我们期望设备连接的正确用户名为:

87654321@123456

连接密码为:

5b0e93055c3bf7db0fbc1eb19f2a3777

改造后的完整代码如下:

    private final static String DEFAULT_USERNAME = "admin";

    private final static String DEFAULT_PASSWORD = "admin";

    private final static String ALLOW = "allow";

    private final static String DENY = "deny";

    private final static String PRODUCT_KEY = "abcdef";

    private final Map<String, User> users = new HashMap<String, User>() {{
        put(DEFAULT_USERNAME, new User(DEFAULT_USERNAME, DEFAULT_PASSWORD, asList("administrator", "management")));
        put("springy", new User("springy", "springy", asList("administrator", "management")));
    }};

    @RequestMapping("user")
    public String user(@RequestParam("username") String username,
                       @RequestParam("password") String password) {
        LOGGER.info("Trying to authenticate user {} password {}", username, password);
        User user = users.get(username);
        //系统默认用户直接放过,用于服务端访问及测试
        if (user != null && user.getPassword().equals(password)) {
            LOGGER.info("认证通过");
            return "allow " + collectionToDelimitedString(user.getTags(), " ");
        } else {
            //设备(客户端)用户
            if (username.contains("@")) {
                String[] array = username.split("@");
                if (array.length < 2) {
                    return DENY;
                }
                String deviceName = array[0];
                String productId = array[1];
                //开始验证密码
                String myPassword = this.genPassword(deviceName, productId);
                if (myPassword.equals(password)) { //密码验证通过
                	LOGGER.info("用户{}认证通过", username);
                    //去数据库验证是否存在该产品,如果设备是先注册到平台才允许连接,那么还需要校验deviceName,具体代码省略
                    return this.checkProduct(deviceName, productId) ? ALLOW : DENY;
                } else {
                    LOGGER.warn("用户{}认证失败", username);
                    return DENY;
                }
            } else {
                return DENY;
            }
        }
    }
    
    private String genPassword(String deviceName, String productId) {
        return Md5Utils.hash(deviceName + productId + PRODUCT_KEY);
    }

重启服务,使用MQTTX进行连接测试,如果用户名或密码错误,将会弹出提示:

Error: Connection refused: Bad username or password

如果连接正常,则会打印日志:

用户87654321认证通过

仔细观察日志会发现vhost和resource方法也被调用了,由于我们只有一个默认vhost:“/”,因此没有必要验证,全部返回allow即可,根据我们的业务resource也不需要处理,返回allow。

4.消息鉴权

在改造代码前我们先简单说一说MQTT中的主题设计,当若干设备把消息发送到RabbitMQ后,RabbitMQ会根据订阅情况把消息转发给订阅者,假设我们所有设备都使用同一个主题发送消息,那么我们订阅者(服务端)只能通过解析报文后获取设备名称来区分消息的发送者,因此,任何一台设备都可以通过修改报文来模拟成其他任意一台设备,这属于一个潜在风险。

为了避免上述风险,我们希望能够限制设备连接到RabbitMQ后的行为,避免设备发送和获取自己权限外的数据,具体的方法就是每台设备使用自己的主题与订阅者(服务端)交互,MQTT协议中主题并不是预设的,是可以在运行时任意创建的,因此可以使用设备名称和产品ID动态创建主题。

我们假设设备上送消息的主题为:

topic/{deviceName}/{productId}/upload

服务端响应的主题为:

topic/{deviceName}/{productId}/upload/reply

下面我们着手改造topic方法,topic方法有一个参数TopicCheck类,其中对我们有用的属性是routing_key、username、permission。
设备发送消息到 topic/87654321/123456/upload 这个主题时,routing_key参数的值为 topic.87654321.123456.upload,把".“替换成”/"则可以还原主题。username是连接时的用户名,我们可以从中获取设备名称和产品ID,permission分为读和写,对应的是消息的监听和发送,这两个主题是不同的。

改造后代码如下:

//设备允许订阅的主题
    private static final List<String> deviceReadAllowTopic = Arrays.asList("topic/%s/%s/upload/reply")
    //设备允许发布的主题
    private static final List<String> deviceWriteAllowTopic = Arrays.asList("topic/%s/%s/upload")

    @RequestMapping("topic")
    public String topic(TopicCheck check) {
        LOGGER.info("校验topic={} ", check.getRouting_key());
        String username = check.getUsername();
        if (DEFAULT_USERNAME.equals(username)) { //默认管理员账户直接放过
            return ALLOW;
        }
        String permission = check.getPermission();
        String[] array = username.split("@");
        String deviceName = array[0];
        String productId = array[1];
        String routingKey = check.getRouting_key();
        String topic = routingKey.replaceAll("\\.", "/");
        if ("read".equals(permission)) {
            for (String s : deviceReadAllowTopic) {
                String f = String.format(s, deviceName, productId);
                if (f.equals(topic)) { //匹配上了则放过
                    return ALLOW;
                }
            }
            LOGGER.warn("设备{}订阅主题{}不在允许的列表内", username, topic);
            return DENY;
        } else if ("write".equals(permission)) {
            for (String s : deviceWriteAllowTopic) {
                String f = String.format(s, deviceName, productId);
                if (f.equals(topic)) { //匹配上了则放过
                    return ALLOW;
                }
            }
            LOGGER.warn("设备{}发布主题{}不在允许的列表内", username, topic);
            return DENY;
        } else {
            return ALLOW;
        }
    }

使用MQTTX测试,会看到如果尝试给权限范围外的主题发送消息或者订阅没有权限的主题则会断开连接。

总结

至此,对RabbitMQ的权限控制就完成了,同学们可以根据项目的实际情况来修改鉴权方式,如果设备数量较多,则务必使用缓存缓解并发压力。

源码下载

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐