皆さんこんにちわ。Chat Bot作ってますか?まだApps Scriptで作ってますか?
このごろは外部のG SuiteテナントユーザーともやりとりできるようになったGoogle Chat。
Apps Scriptのままじゃテナントまたげません1。
やっぱりFirebase Functionsでしょう! (Cloud Functionsもほぼ同じ)
…ってことでCloud Functionsをnode.jsで作り始めると、開発ドキュメントの「Verifying bot authenticity」でGoogle Chatからのリクエストかどうかを検証するためのサンプルコードが書かれていることに気づきます。
この対策をしないと、Bot Function URLが知らんやつに知られたら、Bot messageのsender情報を偽装されちゃうかもしれません。Botでユーザー情報を保持していてメッセージよって返すようにしていたら、その情報が抜かれちゃいます。そりゃまずいってことでサンプルのとおりに実装しようとしたところ…
node.jsでBot Function作ってるのに、JavaとPythonのサンプルしか書かれていない!2
まじありえんと…そこから調べるのいろいろ苦労したんですがシンプルな方法で解決したので記録します。
以下ざっくりとした手順です。
- まずありがたい、
google-id-token
を使わせてもらう。 - node_modules/google-id-token/Readme.md をざっと読む。
- Readme.mdに記載のサンプルコードを、まるっとfunctionにする。
- 好みの問題もあるけど
required('request')
は使いづらいので、node-fetch
で代替しちゃう。 - Firebaseプロジェクトに紐付くGCPのプロジェクト番号を、firebaseコンフィグにセット。
- BOT URLとなるエンドポイント関数のド頭で、まるっと作ったfunctionに、req.headers.authorizationを渡して評価させてNGなら「botから呼べ!」と怒るコードを書く。
では手順の詳細。
firebase initしたプロジェクトディレクトリ内には、functionsディレクトリが作成されて、その中にindex.jsやpackage.jsonが作られてます。プロジェクトディレクトリ内はfirebaseコマンドを叩く場所。functionsディレクトリ内はFirebase Functionsのパッケージ構成する場所。…としっかり分けて考えましょう。たまに私もnpmコマンドをプロジェクトディレクトリ内で叩いて「うわーまたやっちまったー」と泣いてます。注意しましょう。
functionsディレクトリ内で、手順 1.と手順 4.をいっぺんにやっちゃいます。
# npm i google-id-token
# npm i node-fetch
まるっとfunctionにしたものは、verifyChatIdToken.jsとでもしておきます。
まずReadme.mdにある、googleIdToken関数の呼び出しで渡しているオプションの、getKeysにセットされているコールバック関数ですが、署名のURLが'https://www.googleapis.com/oauth2/v1/certs'
となっていますが、chatの署名は開発ドキュメントのJava/Pythonのサンプルにも書かれているURLの'https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com'
に差し替える必要があります。
これをしないとJWTのデコード自体はできるものの、デコード結果に含まれる信頼可能かどうか(isAuthentic)はtrueになりません。
他はまるっとコピーしてfunctionにして、検証成功はtrue、失敗はfalseとしたものがこちら。
'use strict'constfunctions=require('firebase-functions')constfetch=require('node-fetch')constgoogleIdToken=require('google-id-token')constPROJECT_NUMBER=functions.config().project.numberconstCHAT_SERVICE_ISS="chat@system.gserviceaccount.com"constCHAT_SERVICE_CERTS_URI=`https://www.googleapis.com/service_accounts/v1/metadata/x509/${CHAT_SERVICE_ISS}`functiongetChatCerts(kid,callback){console.log("kid="+kid)fetch(CHAT_SERVICE_CERTS_URI).then(res=>res.json()).then(certs=>{callback(null,certs[kid])}).catch(err=>{callback(err,{})})}module.exports=asyncfunctionverifyChatIdToken(authorization){constBEARER_STARTS="Bearer "if(!authorization){console.log("authorization not found")returnfalse}if(!authorization.startsWith(BEARER_STARTS)){console.log(`authorization is not a ${BEARER_STARTS}idtoken`)returnfalse}constidToken=authorization.substring(BEARER_STARTS.length)constparser=newgoogleIdToken({getKeys:getChatCerts})returnnewPromise((resolve)=>{parser.decode(idToken,function(err,token){letresult=falseif(err||!token){console.log("error while parsing the google token: "+err)}else{console.log("parsed id_token is:\n"+JSON.stringify(token))if(!token.isAuthentic){console.log("failed verify by signature\n")}else{result=token.header&&token.data&&PROJECT_NUMBER==token.data.aud&&CHAT_SERVICE_ISS==token.data.iss}}resolve(result)})})}
リクエストヘッダからのJWTの切り出しと、requestのfetchへの差し替えもついでにやっています。
それと、Bot URLとなるFirebase Functionが属するGCPプロジェクトのプロジェクト番号をFirebase Function CONFIGから参照するようにしていますので、手順 5.をやっちゃいます。GCPのプロジェクトダッシュボードでプロジェクトIDに並んでプロジェクト番号が記載されていますのでコピってきて、firebaseプロジェクトディレクトリで設定します。
# firebase functions:config:set project.number="{コピってきたプロジェクト番号}"
これであとは手順 6.のみ。ド頭でチェックしてGoogle Chatから呼ばれていない場合は怒ってあげるだけです。
constverifyChatIdToken=require('./verifyChatIdToken')exports.{BOT名}=functions.https.onRequest(async(req,res)=>{if(req.method!=='POST'||!req.body||!req.body.message||req.headers["user-agent"]!=="Google-Dynamite"||!(awaitverifyChatIdToken(req.headers.authorization))){res.status(400).send('Hello! This function is meant to be used in a Google Chat Room or DM.\n')return}:// ボットの主処理:})
以上でございます。
これだけで安心・安全なボットにグレードアップしますよ! お試しください。