了解如何存储Web推送终端节点

19
我正在尝试在我的应用程序中实现Web Push。在我找到的示例中,客户端的端点URL通常存储在内存中,并带有以下注释:

在生产环境中,您将在数据库中存储此信息...

由于只有我应用程序的注册用户才能/将能够接收到推送通知,所以我的计划是将端点URL存储在我的数据库中用户的元数据中。目前为止,一切都好。
当我想要允许同一用户在多个设备上接收通知时,问题就来了。理论上,我将为用户使用的每个设备添加一个新的端点到数据库中。然而,在测试中,我注意到端点会在同一设备上的每次订阅/取消订阅时更改。因此,如果用户在同一设备上连续几次订阅/取消订阅,我会得到为该用户保存的多个端点(其中除一个之外的所有端点都是无效的)。
我读过的内容来看,没有可靠的方法可以在用户取消订阅或端点无效时通知我。那么,我如何知道在添加新端点之前是否应删除旧端点?
什么能阻止用户通过重复订阅/取消订阅来填充我的数据库以实际上发起拒绝服务攻击?

这更多是开玩笑的(我显然可以限制给定用户的总端点数),但我看到的问题是,当发送通知时,我将向无效端点发送数百个通知服务。


我希望服务器上的订阅逻辑是:

  1. 检查是否已经为该用户/设备组合保存了端点
  2. 如果没有,请添加;如果有,请更新

问题在于我无法可靠地完成第一步。

3个回答

12

我只需要为用户订阅的每个设备在数据库中添加一个新的端点

最好的方法是创建这样一张表:

endpoint | user_id
  • endpoint上添加唯一约束(或主键):您不希望将同一浏览器与多个用户关联,因为这会很混乱(如果已经存在一个端点但它具有不同的user_id,只需更新与之关联的user_id
  • user_id是指向您的用户表的外键

如果一个用户在同一设备上连续订阅/取消订阅多次,我最终会保存该用户的多个端点(除了一个以外都是错误的)。

是的,不幸的是,推送API有一个疯狂的取消订阅机制,你必须处理它。

端点可能会过期或无效(甚至可能是恶意的,例如android.chromlum.info)。当您尝试从应用程序服务器发送推送消息时,您需要检测失败(使用HTTP状态代码、超时等)。然后,对于某些类型的失败(例如过期的永久性失败),您需要删除端点。

有什么办法可以防止用户通过重复订阅/取消订阅来有效地发动拒绝服务攻击并用端点填满我的数据库吗?

正如我上面所述,您需要适当地删除无效的端点,一旦您意识到它们已过期或无效。基本上,它们最多会产生一个无效请求。此外,如果您具有高吞吐量,则您的服务器仅需几秒钟就可以为数千个端点进行请求。

我的建议是基于我开发Pushpad时所做的大量实验和思考。


1
+1,感谢您提供有关不允许同一浏览器的多个用户推送通知的提示。这肯定会很混乱。所以,总的来说,在这里的答案基本上是:“你不能做你想做的事情。” 我只需要接受多个端点,然后在尝试使用它们时对其进行修剪。 - Dominic P
1
@DominicP 依我之见,是的,这会变得很混乱:我不得不改变这一点过了一段时间。而且,事先没有办法知道一个端点是否有效。你唯一能做的是根据主机名将端点列入黑名单或白名单中。 - collimarco
谢谢你提供黑名单/白名单的提示和分享。我很感激。 - Dominic P

2
另一种方法是在您的服务器上设置一个保持连接字段,并在服务工作者接收到推送通知时更新它。然后定期清除最近未响应的终端点。

0

我猜在现实世界中,大多数用户不会经常取消订阅和重新订阅。然而,推送订阅变得陈旧可能是一个问题。如果您的服务器正在使用最新的推送订阅,而用户同时返回到一个订阅较旧但仍然有效的设备上,则拥有多个设备的用户可能会在错误的设备上收到通知。

一种处理这个问题的方法是,每次用户启动客户端时都重新订阅用户。然而,我找不到来自Apple/Google等(大型浏览器推送服务提供商)的任何信息,以了解是否存在任何限制或者这是否是一种不好的做法。我们不想被列入黑名单... [顺便说一下,苹果将推送请求作为JSON发送到响应内容中,而谷歌则发送纯文本。在Safari测试时编写代码后,在Chrome客户端上打破了我的服务器代码。]

因此,与其简单地将最新的订阅插入到用户数据库表中的某一列中,也许应该将其插入到一个新表中,该表的主键由user_id和browser_id组成。我将展示browser_id可能是什么的示例。然后,当用户从特定浏览器的特定设备进入时,查找相应的订阅并在端点处触发。

[另外一个选择是将完整的订阅 JSON 存储在 cookie 或 localStorage 中,并经常将其发送到服务器,但每个 HTTP 请求上多几百个字节似乎是一种浪费。大家有什么想法吗?]

无论如何,这是我对于制作更小的 browser_id 并与 user_id 一起在服务器端查找订阅 JSON 的看法(注释有点多余;您可能只想看代码):

    /*
        This is to help the server use the right web Push API subscription if the user switches
        between different browsers; e.g. say a user is on browser A and the server caches a push
        subscription for them, and then they go to browser B and we cache a new subscription for
        them, and then they go back to browser A and we don't cache a new subscription for them
        because browser A already has a subscription going because the service worker is still
        running from their previous session; then the server wants to send them a notification
        and it uses the browser B subscription instead of the browser A one; clearly the 
        subscriptions need to be cached per user, per browser -- but how to identify each browser
        uniquely?  Upon first visiting the site, we'll create a browser_id that combines the
        users initial IP address with a timestamp; if the visitor is later issued a user_id, 
        we will store any Push subscriptions in a db table with primary key of user_id and 
        browser_id.     Sample browser_id: 10.0.0.255-1680711958431
    */
    async function set_browser_id() {

        let browser_id = Cookies.get('browser_id') || localStorage.browser_id;

        if ( ! browser_id) {

            /*
            const response = await fetch("https://api.ipify.org?format=json");
            const jsonData = await response.json();
            const ip_addr = jsonData.ip;
            */
            const timestamp = Date.now();

            browser_id = `${ip_addr}-${timestamp}`; // server set JS const ip_addr earlier

            console.log(`NEW browser_id: ${browser_id}`);
        }
        else { console.log(`EXISTING browser_id: ${browser_id}`); }

        // set whether new or existing to keep it from ever expiring
        Cookies.set('browser_id', browser_id, 
                { path: '/', domain: `.${hostname}`, expires: 365, secure: true });
        
        localStorage.browser_id = browser_id; // backup in persistent storage
    }

    window.onload = set_browser_id; // do it after everything else

browser_id 应该是相当持久的,因为它有 cookie 的 localStorage 备份(并且可能对于分析目的很有用)。现在,您的服务器可以获得一个短小的 browser_id cookie,并使用它来查找更长的 Push 订阅 JSON。实际上,您可能可以跳过 db 表中的 user_id,因为 browser_id 命名空间冲突的机会是微不足道的。当然,每当用户生成新的订阅时,您需要客户端将其发送过来,以便服务器可以更新 db 表。

我很想知道其他人对这个计划的看法;我正在实施它。还有关于在 cookie 中重复传递完整的订阅 JSON 的任何安全问题 - 发送通知需要您的私有 VAPID 密钥,对吧?但是....

p.s. 我现在正在谈论这个问题,因为苹果终于在 iOS 16.4 中启用了 Push API - 耶!是时候了。


1
总体计划看起来合理,尽管我并不是 Web Push 方面的专家。一个小问题是我认为您不需要调用 API 来获取用户的 IP。一个简单的随机字符串更容易生成,更有利于隐私,并且应该能够达到同样的目的。 - Dominic P
@Dominic P 理解你的观点。我只是认为这很有趣,因为我的服务器已经在发送IP地址,并且它将限制命名空间冲突的机会,只有当两个用户在同一毫秒内首次访问我的网站时才会发生;) - monist

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接