Progressive Web App 網站推播通知 原理解密前後端實作說明

me
林彥成
2021-09-26 | 6 min.
文章目錄
  1. 1. 什麼是網站推播通知
  2. 2. 怎麼使用網站推播通知
    1. 2.1. 客戶端訂閱通知
    2. 2.2. 伺服器發送推播訊息
    3. 2.3. 用戶端推播事件處理
  3. 3. 網頁推播通知伺服器實作
  4. 4. 網頁推播通知用戶端實作
  5. 5. 推播通知協定
    1. 5.1. 金鑰對 Application server keys
    2. 5.2. 傳輸內容加密
    3. 5.3. Header 參數配置

什麼是網站推播通知

推播通知不管對 App 或是網站來說都是一種重新吸引用戶來使用 App 的方法,所以目標是即使網站、App 關閉時也要能在背景接收推播。推播的目的是為了增加 engagement,在行銷領域中 engagement 這個常見的指標就是指用戶在網站或 APP 上的互動程度或者參與度。

怎麼使用網站推播通知

因為主要是想在背景接受推播,所以實作上是基於可以背景執行的 Service Worker 來協助相關邏輯撰寫。

那實際上就是分成推送和接收兩方面,在網站中推送和通知使用不同但互補的 API:

  • 推送: 當服務器向 Service Worker 提供訊息
  • 通知: 顯示信息給用戶的服務人員或網頁腳本的作用

實現推送的三個關鍵步驟是:

  1. 用戶端訂閱通知: 加入註冊訂閱邏輯
  2. 伺服器發送推播訊息: 透過後端叫用相關 API 向用戶設備推播訊息
  3. 用戶端推播事件處理: 當推送到達設備時,透過 Service Worker 的 “推播事件” 來處理後續相關邏輯

底下連結提供基本的推播示範:
https://linyencheng-push-notification.herokuapp.com/

客戶端訂閱通知

前端在實作概念上主要是四個步驟:

  1. 獲得向用戶發送推播消息的許可
  2. 拿到伺服器的 VAPID 公開金鑰
  3. 透過 Push API 生成一個 PushSubscription 需要的所有相關訊息,這裡可以把 PushSubscription 當成用戶的 ID
  4. 對伺服器發送訂閱請求

伺服器發送推播訊息

在實作後端的時候會遇到三個問題:

  1. 什麼是推播服務、要怎麼執行?

    收到訂閱通知的請求時,將請求內容驗證後就將消息推播到瀏覽器,若是瀏覽器離線,訊息就會排隊儲存起來等連線恢復後繼續傳送

  2. 相關 API 傳送協定和格式?

    瀏覽器會依照符合 IETF 標準的推播協議實作,伺服器原則上就是使用套件只要提供一個 endpoint 並且處理 PushSubscription 即可。

  3. API 可以做到什麼程度

    API 提供了一種向裝置發送訊息的方法

只要從伺服器發了推播消息,推播服務會將訊息保留,直到發生以下事件之一:

  • 裝置上線,推播服務推播訊息成功
  • 訊息過期,推播服務會將從 queue 刪除

用戶端推播事件處理

當推播服務成功推播後,瀏覽器會:

  1. 接收到訊息
  2. 解密數據
  3. 觸發 Service Worker 中的事件

透過 Service Worker 瀏覽器可以在不打開頁面的情況下去監聽相關事件,所以 App 在 Service Worker 收到 “推播” 事件後,就可以執行任何背景的任務,像是偷偷傳送分析資料、離線快取資源下載、顯示通知等等。

網頁推播通知伺服器實作

在還沒實際看教學文件前,會發現如果要從零開始依照推播協議的標準開始實作流程是相對複雜:

  1. 了解並按照協議去處理相關的內文傳輸格式
  2. 了解什麼是 VAPID 金鑰對的概念以及生成方式
  3. 數據加解密流程

再來就是觸發推播如果遇到問題,會較困難去排除問題。當然隨著開發者工具的進步會慢慢改善,但還是建議使用現成的套件工具來處理推播,Google 提供的 Firebase 就是不錯的方案,如果還是想要體驗自行實作,也可以使用 web-push 這個套件,來縮短開發時間。

接下來就來示範用 web-push 實作推播伺服器的主要步驟:

  1. 透過現有的推播套件產生並配置相關的 VAPID 金鑰對
  2. 建立一個 API 接收網頁的訂閱請求
  3. 從透過推播套件從後端針對剛剛的訂閱請求推播消息

可以發現如果是使用套件,底層標準的部分就都不需要實作,可以著重在商業邏輯的開發,透過 node.js 最簡單的版本推播實作程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const express = require("express");
const webPush = require("web-push");

const app = express();
const webPush = require("web-push");

// 1. 透過現有的推播套件配置相關的 VAPID 金鑰對
const publicVapidKey = process.env.PUBLIC_VAPID_KEY;
const privateVapidKey = process.env.PRIVATE_VAPID_KEY;
webPush.setVapidDetails(
"mailto:test@example.com",
publicVapidKey,
privateVapidKey
);

// 2. 建立一個 API 接收網頁的訂閱請求
app.post("/subscribe", (req, res) => {
// 收到訂閱請求
const subscription = req.body;

res.status(201).json({});

const payload = JSON.stringify({
title: "推播訊息內容",
});

// 3. 從透過推播套件從後端針對剛剛的訂閱請求推播消息
webPush
.sendNotification(subscription, payload)
.catch((error) => console.error(error));
});

如果需要進階一點的使用,可以多實作更多相關邏輯:

  1. 實作訂閱請求的儲存分類等機制: 把每次訂閱的 const subscription = req.body; 儲存起來,方便後續進階操作
  • 舉例來說訂閱時可以多帶參數 /subscribe?device=android&time=morning
  1. 實做 API 去處理要推播訊息: 訊息可以針對裝置或是用戶做分眾,或是取消推播等等

這次小編其實在後端也沒有多做複雜的處理,目的是為了測試關閉頁面後仍然可以推播,所以補上了一分鐘後再多送一次的邏輯。

1
2
3
4
5
setTimeout(() => {
webPush
.sendNotification(subscription, payload30)
.catch((error) => console.error(error));
}, 60 * 1000);

網頁推播通知用戶端實作

後端可以選擇自己實作也可以直接串接使用現有的服務,程式撰寫上相關操作的 JavaScript API 也相對簡單,接下來就來一步步完成最基本的推播吧。

  1. 檢查支援度,然後針對支援的部分做漸進式增強
  • serviceWorker: 背景也要可以收資料
  • PushManager: 串接推播
1
2
3
4
5
6
7
8
9
if (!("serviceWorker" in navigator)) {
// Service Worker 不支援
return;
}

if (!("PushManager" in window)) {
// Push API 不支援
return;
}
  1. 請求權限,瀏覽器需要允許推播通知
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function askPermission() {
return new Promise(function (resolve, reject) {
const permissionResult = Notification.requestPermission(function (result) {
resolve(result);
});

if (permissionResult) {
permissionResult.then(resolve, reject);
}
}).then(function (permissionResult) {
if (permissionResult !== "granted") {
throw new Error("We weren't granted permission.");
}
});
}
  1. 註冊 serviceWorker: 相關處理會寫在這邊
  2. 使用 PushManager 訂閱推播: 其中的參數 userVisibleOnly 目前 Chrome 只支援每次收到推送時顯示通知,這代表沒有辦法在背景偷偷做事情,applicationServerKey 則是會用到後端給的 Public Vapid Key,產生出來的 subscription 可以看成是 client 端的 ID。
  3. 向 Sever 發送訂閱: 把剛剛產生的 subscription 傳送到後端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if ("serviceWorker" in navigator) {
// 3. 註冊 serviceWorker
const register = await navigator.serviceWorker.register("/sw.js", {
scope: "/",
});

// 4. 使用 PushManager 訂閱推播
const subscription = await register.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey),
});

// 5. 向 Sever 發送訂閱
await fetch("/subscribe", {
method: "POST",
body: JSON.stringify(subscription),
headers: {
"Content-Type": "application/json",
},
});
}
  1. 在 serviceWorker 中依照資料格式處理 push 事件,這裡的例子為顯示通知
  • 字串: event.data.text()
  • JSON: event.data.json()
  • blob: event.data.blob()
  • arrayBuffer: event.data.arrayBuffer()
  1. 透過 event.waitUntil() 等待通知執行完成
1
2
3
4
5
6
7
8
9
self.addEventListener("push", (event) => {
const data = event.data.json();

const promiseChain = self.registration.showNotification(data.title, {
body: "Yay it works!",
});

event.waitUntil(promiseChain);
});

推播通知協定

之前已經可以用後端的套件去實作推播的伺服器,但那個套件實際上做了哪些事情?

  • 金鑰對 Application server keys
  • 傳輸內容加密 Payload Encryption
  • Header 參數配置

流程如下圖,相關定義可參考連結:
https://datatracker.ietf.org/doc/html/draft-ietf-webpush-encryption

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+-------+           +--------------+       +-------------+
| UA | | Push Service | | Application |
+-------+ +--------------+ +-------------+
| | |
| Setup | |
|<====================>| |
| Provide Subscription |
|-------------------------------------------->|
| | |
: : :
| | Push Message |
| Push Message |<---------------------|
|<---------------------| |
| | |

也因為定義了標準協議的關係,所以有了各語言的實作版本:

金鑰對 Application server keys

  • 密鑰: 推播服務使用
  • 公鑰: 用戶端使用

密鑰會用來檢查訂閱的用戶身分公鑰是否符合,pushManager.subscribe() 會用公鑰來檢查接收到的簽章訊息是否由與公鑰相關的私鑰簽出來的。

簽章訊息的傳遞會透過 JWT 放在 header 進行資料交換,一個 JWT 會由三段字串組成並由 . 號分隔 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCJ9.zTWqeQdfDM0WKGBFig2-VmUpTLkIQ4DvAJN6_LzDZzU,前兩個字串(JWT info 和 JWT data)是經過 base64 編碼的 JSON ,所以其實是公開可閱讀的。

想要解密 JWT,可以直接用官方網站提供的介面。
https://jwt.io/

  1. JWT 訊息: 會記錄用哪種算法簽章
1
2
3
4
5
{
"//": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
"alg": "HS256",
"typ": "JWT"
}
  1. JWT 資料: 看程式設計上想要帶什麼資料會放在這邊,推播的話會有三個欄位 aud、exp、sub,透過這三個參數和 vapid 產生工具就能夠產生金鑰對
  • aud: 推播服務來源
  • exp: JWT 到期時間
  • sub: 推播訊息的人的聯絡訊息

https://github.com/web-push-libs/vapid

1
2
3
4
5
6
{
"//": "`eyJuYW1lIjoidGVzdCJ9",
"aud": "https://YourSiteHere.example",
"sub": "mailto://admin@YourSiteHere.example",
"exp": 1457718878
}

最後的 header 就會像是 Authorization: 'WebPush <JWT Info>.<JWT Data>.<Signature>'

傳輸內容加密

推播的訊息不能赤裸裸的傳遞,所以在加密上也有定義相關規範,我也看不是很懂,總體來說就是套了加密、加鹽、金鑰對搭配使用,執行加密的時候最後也加上填充,避免被用長度推斷。

  • ECDH (elliptic-curve Diffie-Hellman): 透過運算去發現原來我們是天生一對,數學假設如下
    • y^2 = x^3 + ax + b
    • 4a^3 + 27b^2 != 0

看不懂也沒關係可以看影片,但我相信看完影片可能還是只懂概念。
https://youtu.be/F3zzNa42-tQ

1
2
3
4
5
const keyCurve = crypto.createECDH("prime256v1");
keyCurve.generateKeys();

const publicKey = keyCurve.getPublicKey();
const privateKey = keyCurve.getPrivateKey();
  • HKDF (Hashed Message Authentication Code): SHA-256
    • Salt: 16 byte authentication secret
    • IKM: shared secret
    • info
    • length
  • Nonce: 加密通信只能使用一次的數字,可能是一個隨機或偽隨機數,以避免重送攻擊
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hkdf(salt, ikm, info, length) {
// ikm 加鹽
const keyHmac = crypto.createHmac("sha256", salt);
keyHmac.update(ikm);
const key = keyHmac.digest();

// info 加密
const infoHmac = crypto.createHmac("sha256", key);
infoHmac.update(info);

const ONE_BUFFER = new Buffer(1).fill(1);
infoHmac.update(ONE_BUFFER);

// 長度控制
return infoHmac.digest().slice(0, length);
}

const nonce = hkdf(salt, prk, nonceInfo, 12);
const contentEncryptionKey = hkdf(salt, prk, cekInfo, 16);
  • 使用者裝置:

    • ecdh_secret = ECDH(ua_private, as_public)
    • auth_secret = random(16)
    • salt = header 來的
  • 伺服器:

    • ecdh_secret = ECDH(as_private, ua_public)
    • auth_secret = user agent 來的
    • salt = random(16)

Header 參數配置

  • TTL header (time to live): 訊息能在推播服務上存活多久
  • Topic: 同個主題下可以實作舊訊息取代新訊息
  • Urgency: 訊息的重要程度

喜歡這篇文章,請幫忙拍拍手喔 🤣

share