Web PushをFCMとVAPIDで認証してブラウザにプッシュ通知を送る


Web Pushを試している。調べていく過程で2つの認証方式を用いてプッシュ通知を送信できることが分かった。1つはFirebase Cloud Messaging(FCM)を使い取得したサーバーキーを認証に使い送信する方法とVoluntary Application Server Identification for Web Push (VAPID)で認証をする方法である。

2つの方法としたがWeb Pushが標準化する過程で整理された認証方法であり、VAPIDのほうが後発となりFirebaseのサーバーキーを必要としない認証方式である。 VAPIDはFirebaseのプロジェクト登録が不要となるだけでプッシュサーバはFirebase Cloud Messagingが担っている。

今回のエントリではFCMとVAPIDそれぞれのWeb Pushのプッシュ通知方法をまとめていく。 また試したブラウザはChromeのみである。

Firebase Cloud Messaging(FCM)のWeb Push

事前にFirebaseでプロジェクトを作成しサーバキー送信者IDが必要となる。
※プロジェクトを作成済みであればFirebaseのコンソールから「Overview」→「プロジェクトの設定」→「クラウドメッセージング」からそれぞれ参照できる。

ここからはServiceWorkerなどフロントエンド(クライアント)とサーバに分けてプッシュ通知方法をまとめていく。

クライアント

クライアントはServiceWorkerの登録とWeb Push購読時に取得できる各種変数をサーバ側へ送信する。実際にプッシュサーバへプッシュ通知をリクエストするのはサーバである。

manifest.json

Firebaseプロジェクトから取得した送信者IDmanifest.jsonで利用するので登録しておく。

1
2
3
4
5
{
  "name": "FCM Web-Push",
・・・省略
  "gcm_sender_id": "Firebaseプロジェクトから取得した送信者ID"
}

ServiceWorker登録イベントとWeb Push購読イベント

クライアント側ではServiceWorkerを登録して、ブラウザでWeb Pushの購読が完了するとエンドポイントとPayload を暗号化するためのブラウザの公開鍵と鍵生成の複雑生成を増すための乱数が取得できる。これらの変数をサーバへ送信する。サーバはその鍵を利用することで通知メッセージを暗号化してPayloadに乗せることができる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
let subscription = null;

// ServiceWorkerが登録されると`serviceWorkerReady(registration)`がコールされるようにしている
function serviceWorkerReady(registration) {
    if('pushManager' in registration) {
        registration.pushManager.getSubscription().then(getSubscription);
    }
}

// Web Pushの購読イベント時に`requestPushSubscription(registration)`がコールされるようにしている
function requestPushSubscription(registration) {
    let opt = {
        userVisibleOnly: true
    };
    return registration.pushManager.subscribe(opt).then(getSubscription);
}

function getSubscription(sub) {
    subscription = sub;
}

subscriptionエンドポイントブラウザの公開鍵(p256dh)乱数(auth)が格納されている。

プッシュ通知送信時にサーバにエンドポイントブラウザの公開鍵(p256dh)乱数(auth)を送信する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function requestPushNotification() {

    if (subscription) {
        fetch(appServerURL, {
            credentials: 'include',
            method: 'POST',
            headers: {'Content-Type': 'application/json; charset=UTF-8'},
            body: JSON.stringify({
                endpoint: subscription.endpoint,
                key: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh'))))
                    .replace(/\+/g, '-').replace(/\//g, '_'),
                auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth'))))
                    .replace(/\+/g, '-').replace(/\//g, '_'),
                message: _('message').value || '(empty)'
            })
        });
    }
}

プッシュ通知受信イベント時の処理

プッシュ通知を受け取ったイベントはServiceWorkerで処理する

 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
function showNotification(data) {
    return self.registration.showNotification('FCM/GCM WebPush Test', {
        icon: data.icon,
        body: data.body || '(with empty payload)',
        data: data.url,
        vibrate: [400,100,400]
    });
}

function receivePush(event) {
    var data = '';
    if(event.data) {
        data = event.data.json();
    }

    if('showNotification' in self.registration) {
        event.waitUntil(showNotification(data));
    }
}

function notificationClick(event) {
    event.notification.close();

    event.waitUntil(
        clients.openWindow(event.notification.data)
    );
}

self.addEventListener('push', receivePush, false);
self.addEventListener('notificationclick', notificationClick, false);

サーバ

サーバ側ではクライアントから送信されたエンドポイントブラウザの公開鍵(p256dh)乱数(auth)をもとに通知メッセージを暗号化する。 暗号化のライブラリはMartijnDwars/web-pushをつかった。

暗号化はライブラリがほとんど処理してくれるためサーバ側のコードはシンプルである。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@PostMapping
fun post(@RequestBody req: Request): ResponseEntity<Boolean> {

    val payload  = objectMapper().writeValueAsString(Payload(req.message, req.tag, req.icon, req.url))

    Security.addProvider(BouncyCastleProvider())
    val push = app.push.PushService()
    push.setGcmApiKey(appProperties.serverKey)

    push.send(Notification(req.endpoint, req.key, req.auth, payload))

    return ok().json().body(true)
}

push.setGcmApiKey(appProperties.serverKey) でFirebaseで取得したサーバキーを指定している。

暗号化の詳細については次のエントリが参考になるのでオススメする。

Web Pushでブラウザにプッシュ通知を送ってみる - Qiita

プッシュサーバへ送信時のヘッダーとエンドポイントURL

次のVAPIDのWeb Pushと比較したいためプッシュサーバ送信時のヘッダーエンドポイントURL をまとめていきたい。

1
2
3
4
-H "Authorization: key={Firebaseのサーバキー}" \
-H "Encryption: keyid=p256dh;salt={乱数、salt}" \
-H "Crypto-Key: keyid=p256dh;dh={共有鍵}" \
-H "Ttl: 2419200"

AuthorizationにはFirebaseのサーバキーを指定している。クライアントから送信された公開鍵とauthからEncryptionとCrypto-Keyを生成している。 ブラウザでは暗号化されたPayloadを復号する。

1
エンドポイントURL: https://android.googleapis.com/gcm/send/{registration_id}

エンドポイントURLのOriginはGoogle Cloud Messaging(GCM)である。

サンプルコード

これまでFCMのWeb Pushをまとめてきたがコードの断片のみで参考にならない。 動作確認ができるコード一式をgithubに公開しているので参照してほしい。

VAPIDのWeb Push

つぎにVAPIDのWeb Pushをまとめていこう。VAPIDの全体の流れは次のエントリが参考になるのでオススメする。(同じ作者である。一貫してまとめていただいているので大変助かりました。)

GCMの登録が不要になったChromeのWeb Pushを試してみる - Qiita

FCMのほうではFirebaseのプロジェクト登録が必要であったがVAPIDでは必要としない。 クライアント側ではsubscription取得時にサーバ側から取得した公開鍵を用いる。

ここからはクライアントとサーバに分けてFCMとの違いについてまとめていく。

クライアント

クライアントはWeb Push購読時の処理に変更が入っている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function requestPushSubscription(registration) {
    return fetch(appServerPublicKeyURL).then(function(response) {
        return response.text();
    }).then(function(key) {
        let opt = {
            userVisibleOnly: true,
            applicationServerKey: decodeBase64URL(key)
        };

        return registration.pushManager.subscribe(opt).then(getSubscription, errorSubscription);
    });
}

サーバ側から公開鍵を取得し購読リクエストに含めている。 そのほかの変更点はmanifest.jsonからgcm_sender_idが取り除かれたのみである。

サーバ

サーバ側では公開鍵をクライアントに提供するためのAPIを追加している。

1
2
3
4
@GetMapping("public-key")
fun get(): String {
    return publicKey
}

暗号化のライブラリはFCMと同じくMartijnDwars/web-pushをつかっている。VAPIDもサポートしてくれている。 通知処理のAPIには公開鍵と秘密鍵を含めてPushServiceオブジェクトを生成している。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@PostMapping
fun post(@RequestBody req: Request): ResponseEntity<Boolean> {

    val payload  = objectMapper().writeValueAsString(Payload(req.message, req.tag, req.icon, req.url))

    Security.addProvider(BouncyCastleProvider())
    val push = app.push.PushService(publicKey, privateKey, "http://localhost")
    push.send(Notification(req.endpoint, req.key, req.auth, payload))

    return ok().json().body(true)
}

プッシュサーバへ送信時のヘッダーとエンドポイントURL

VAPIDに変わると次のようにヘッダーとエンドポイントが変わっている。

1
2
3
4
5
-H "Authorization: WebPush {JWT形式の署名トークン}" \
-H "Encryption: keyid=p256dh;salt={乱数、salt ※ここは変わらない}" \
-H "Crypto-Key: keyid=p256dh;dh={共有鍵};p256ecdsa={サーバの公開鍵}" \
-H "Content-Type: application/octet-stream" \
-H "Ttl: 2419200" \

AuthorizationCrypto-Keyにそれぞれ変更と追加がある。

1
エンドポイントURL: https://fcm.googleapis.com/fcm/send/{registration_id}

エンドポイントURLのOriginはFirebase Cloud Messagingに変更されている。

サンプルコード

同様にVAPIDのほうもgithubにコード一式を公開したので参照してほしい。

まとめ

関連エントリ

FCMでWeb Push。Firebase Javascript SDKを使ったプッシュ通知とトピック送信を試した。 - 平日インプット週末アウトプットぶろぐ