前回より、あるWEBサービスのユーザへの通知の方法として、そのユーザのSlackに通知が飛ばせたらイイねという件に着手しています。
前回はHello World 的な Slack App を作成しました。つぎはそのSlack Appへのアクセストークンの受け渡しをどうするかについて書くといったので、今回はそれを整理していきます。
- アクセストークン(Access Token)は Slackが用意した Slack Appの開発画面で(目視で)確認するのではなく、DBMSやFirebase Firestoreなどへ自分で永続化する。今回は Firestoreへ保存します。
- Firestoreへ保存する処理を動かすために、OAuth認可サーバ(上図のSlack認可サーバ)からのリダイレクト先として(Slack Appの開発画面ではなく)、自前の処理を動かせるところを指定する。上図では「Firebase Functionsのoauth関数」をリダイレクト先にしている。
- 前回 Curlで実行した箇所は 今回は Firebase Functionsなどでスケジュール実行させる。上図では「Firebase Functionsのchat関数」がその役割で、chat関数はFirestoreからアクセストークンを取り出し、API(/api/chat.postMessage)を呼び出すことで、Slackへ投稿を行う
やってみる
さあやってみます。がその前に準備や設定などを。
前提の環境
$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.14.3
BuildVersion: 18D42
$ node --version
v10.14.2 <-ホントはFunctionsとかのバージョンに合わせるべきなんだけどいったん気にしない :-)
Firebaseのサインアップと準備
まずはFirebaseのサインアップなどですが、別途記事にまとめました。
Firebase や Google Cloud Platformの初回のサインアップその他の備忘メモ
実施すると、いわゆる
var config = {
apiKey: "##FIREBASE API KEY##",
authDomain: "##FIREBASE AUTH DOMAIN##",
databaseURL: "https://##PROJECT ID##.firebaseio.com",
projectId: "##PROJECT ID##",
storageBucket: "##PROJECT ID##.appspot.com",
messagingSenderId: "YOUR-SENDER-ID"
};
を取得するところまでは行けるとおもいます。
あとは、Firestoreを有効にして、そしてAuthentication機能を有効にし、ログインプロバイダとしてGoogle を有効にしておいてください。これらは下記情報を参考にすればよいかもしれません。
- FirestoreとVue.jsで連絡先管理アプリを作ってみる
- firebaseのチュートリアルで、Error: Missing or insufficient permissions.と言われた時の解決法メモ
- Vue.js の主要な機能をざっくりとつかってみたときのメモ(Firebase認証・認可編)
あとは
ココの**「firebase-tools のインストール」「Firebaseにログイン」**を実施しておいてください。
Host名を設定する/Firebaseへ承認済みドメインの追加
今回動かそうとしているWEBアプリは、Cookieを用いているのですがその関係上、WEBアプリにはlocalhostではなくホスト名 client.example.com でアクセスしたいです。なので /etc/hosts などで名前解決しておきます。Macの例ですがこんな感じ。
$ cat /etc/hosts
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting. Do not change this entry.
##
127.0.0.1 localhost
255.255.255.255 broadcasthost
::1 localhost
127.0.0.1 client.example.com
さらに、URLをlocalhostではなく client.example.com でアクセスすることになるので、FirebaseのAuthenticationの設定にこのドメインを登録する必要があります。承認済みドメインを追加する の手順に従って、承認済みドメインに「client.example.com」 という値を追加してください(コレを行わないと認証を行う firebase.auth().signInWithPopup
関数がexception
となるみたいですね。)。
Slack App のCredentials確認
前回の記事で紹介したSlack Appの管理画面 https://api.slack.com/apps より該当するSlack Appを探し、App Credentials にあるClient ID/Client Secretをひかえておきます。
Slack App のRedirect URLsの設定追加
Slack Appでもう一つ。今回**「Slack認可サーバからのリダイレクト先として、Slack Appの開発画面ではなく Firebase Functionsのoauth関数をリダイレクト先にする」** ことにしましたが、OAuthではそのRedirect先のURLを登録しておく必要があります。
その画面は Slack Appの開発画面にアクセスして、「OAuth & Permissions」を開きます。Redirect URLs を設定出来る箇所があるので下記のとおり http://client.example.com:5001/##PROJECT ID##/us-central1/oauth
を追加し Save URLs をクリックして保存しましょう。「##PROJECT ID##」は、Firebaseの設定値 projectIdの値となります。
準備が長くてスイマセン。。けっきょく、
項目 | 値 |
---|---|
Firebaseプロジェクト名 | ##PROJECT ID## |
WEBアプリのアクセスURL | http://client.example.com:5000/ |
Functions上の関数(oauth) | http://client.example.com:5001/##PROJECT ID##/us-central1/oauth |
Functions上の関数(chat) | http://client.example.com:5001/##PROJECT ID##/us-central1/chat |
投稿先のSlackワークスペース | Slack Appを開発しているワークスペース |
Slack App のclient_id | ##SLACK CLIENT ID## |
Slack App のclient_secret | ##SLACK CLIENT SECRET## |
認可後の、Redirect URLs | http://client.example.com:5001/##PROJECT ID##/us-central1/oauth |
などを準備した感じです。。
ビルドする
さて、説明のためのコードをつくってGitHubにおいてあるので、下記の通り落としてビルドしていきます。
$ git clone --branch for_qiita_slack000 https://github.com/masatomix/todo-examples.git
まずはWEBアプリ。
$ cd todo-examples/
$ npm install
src/firebaseConfig.js を自分の設定に書き換え
$ cat src/firebaseConfig.js
export default { ↓さきほどひかえておいた値を設定
apiKey: '##FIREBASE API KEY##',
authDomain: '##FIREBASE AUTH DOMAIN##',
databaseURL: 'https://##PROJECT ID##.firebaseio.com',
projectId: '##PROJECT ID##',
storageBucket: '##PROJECT ID##.appspot.com',
messagingSenderId: 'YOUR-SENDER-ID'
}
src/restConfig.js を自分の設定に書き換え
$ cat src/restConfig.js
export default {
##PROJECT ID## 書き換え(上記のprojectIdの値)
apiUri: 'http://client.example.com:5001/##PROJECT ID##/us-central1/oauth'
}
$ npm run build
つづいて、Firebase Functionsのビルド。
$ cd functions/
$ npm install
functions/src/oauthConfig.ts を自分の設定に書き換え
$ cat src/oauthConfig.ts
export default {
client_id: '##SLACK CLIENT ID##', ← さきほどひかえておいたSlackのClient IDの値を設定
client_secret: '##SLACK CLIENT SECRET##',← さきほどひかえておいたSlackのClient Secretの値を設定
authorization_endpoint: 'https://slack.com/oauth/authorize', ←ココはこのまま
token_endpoint: 'https://slack.com/api/oauth.access', ←ココはこのまま
redirect_uri: 'http://client.example.com:5001/##PROJECT ID##/us-central1/oauth', ##PROJECT ID## 書き換え(上記のprojectIdの値)
scope: 'chat:write:user' ←ココはこのまま
}
functions/src/oidcConfig.ts を自分の設定に書き換え
$ cat src/oidcConfig.ts
export default {
iss: 'https://securetoken.google.com/##PROJECT ID##',
aud: '##PROJECT ID##'
}
$ npm run build
$ cd ../
つづいて下記コマンドで、このコード群がデフォルトで使用するFirebaseプロジェクト名を指定します。
$ firebase use --add
? Which project do you want to add? xxxxxxxxxxx ← 複数選択肢が表示された場合は、上記の「 ##PROJECT ID## 」の値を選びます
? What alias do you want to use for this project? (e.g. staging) default
Created alias default for xxxxxxxxxxx.
Now using alias default (xxxxxxxxxxx)
$
参考: FirebaseとGoogle Cloud Platform をさわれる環境を構築する
動かしてみる
さあ、ローカルでWEBアプリとFunctionsを起動してみます。
$ firebase serve --only hosting,functions
さて、ブラウザで http://client.example.com:5000/ にアクセスしてください。ログイン画面が表示されるとおもいます。Googleアカウントでログインできるようにしてあるのでログインしましょう。ログインできると「Add to Slack」ボタンが配置されている画面が表示されると思います。
ボタンをクリックすると、ウィンドウが開き、Slackの認可サーバへリダイレクトされます。すでにWEBブラウザでSlackを使っていれば、下記の通り、前回記事と同様の認可画面が表示されます。
(WEBブラウザでSlackを使っていない場合は、ワークスペースを選択したりログインしたりする画面が表示されたのち、上記画面が表示されると思います。)
さて「許可する」をクリックすると、前回の記事では「Slack Appの開発画面」にリダイレクトされましたが、今回はRedirect URLsで設定追加した、 http://client.example.com:5001/##PROJECT ID##/us-central1/oauth
へリダイレクトされるはずです。 firebase serve --only hosting,functions
によって、ローカルで Firebase Functionsも起動しているので、ローカルで oauth 関数が動いた結果、Firestoreへアクセストークンが保存されるとおもいます。
後述しますが、
こんな感じにFirestoreに保存されるはずです。
chat関数を呼び出す
さて、保存したSlackのアクセストークンを取り出して使用する処理を動かすために、chat関数を呼び出します。
$ curl http://client.example.com:5001/##PROJECT ID##/us-central1/chat
ok
$
サンプルアプリによる動作の紹介は以上です。
各ソースの説明
各ソースの主要なとこだけ紹介します。
Add to Slack ボタンを配置してあるVue.jsのWEBアプリ
まずはリンクを配置するWEBアプリから。
WEBアプリはVue.jsで構築され、Firebase認証でログイン出来るようにしてあります。
ログインすると表示される、Add to Slack のボタンがある Slack.vue ファイルのソースは下記の通り。
<template>
<main v-if="$store.state.loginStatus" class="container">
<h1>
<img
alt="Add to Slack"
height="40"
width="139"
src="https://platform.slack-edge.com/img/add_to_slack.png"
srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x"
@click="popup()"
style="cursor:pointer"
>
</h1>
</main>
</template>
<script>
import restConfig from '@/restConfig'
import firebase from 'firebase'
export default {
name: 'Slack',
methods: {
async popup () {
const token = await firebase.auth().currentUser.getIdToken()
console.log(token)
// const user= JSON.parse(JSON.stringify(this.$store.state.user))
// console.log(user.stsTokenManager.accessToken)
const url = [
restConfig.apiUri,
'?idToken=',
token
].join('')
window.open(
url,
'pop',
() =>
`toolbar=0,status=0,top=100,left=200,width=700,height=600,modal=yes,alwaysRaised=yes`
)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h1 {
border-bottom: 1px solid #ddd;
padding: 16px 0;
}
</style>
コード中の firebase.auth().currentUser.getIdToken()
という値は、FirebaseのユーザUIDがはいっているいわゆるidToken
です。ちなみに「FirebaseのユーザUID」とは、コレのことですね。
「idToken
にどんなフォーマットでユーザUID(user_id
)が格納されているか」また「そのidToken
が改ざん出来ない仕組みになっている」などについては下記の記事に整理しましたので、適宜こちらをご参照ください。
さて読み込んでいる設定ファイル restConfig.js
は以下。
export default {
// ##PROJECT ID## 書き換え(上記のprojectIdの値)
apiUri: 'http://client.example.com:5001/##PROJECT ID##/us-central1/oauth'
}
この値が、Add to Slackボタンを押したときに別ウィンドウで開かれるブラウザのURLに設定してあります。よって別ウィンドウで http://client.example.com:5001/##PROJECT ID##/us-central1/oauth?idToken=[FirebaseのユーザUIDが入ったidToken]
が開かれます。
クエリパラメタに(FirebaseのユーザUIDを含んだ)idTokenを渡しているのは、ずっとあとでSlack側で認可処理が完了してSlackのアクセストークンを取得できた後に「FirebaseのユーザUIDをキー」に「Slackのアクセストークン」をFirestoreに格納したいからです1。
Firebase Functionsのoauth関数
つづいて上記で呼ばれたFunctions のoauth関数を見てみます。
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as request from 'request'
import * as cookie from 'cookie'
import session from './session'
import oauthConfig from './oauthConfig'
import oidcConfig from './oidcConfig'
admin.initializeApp()
export const oauth = functions.https.onRequest(async (req, res) => {
// errorでリダイレクトされたとき
// ユーザがキャンセルしたときはココなので、そこそこちゃんと実装しないと。。(今んとこ適当実装)
if (req.query.error) {
res.setHeader('Content-Type', 'text/plain;charset=UTF-8')
const message = `
error: ${req.query.error}
error_uri: ${req.query.error_uri}
error_description: ${req.query.error_description}
`
res.send(message)
return
}
const code = req.query.code
// codeがなかったとき、まずは認可画面へ遷移
if (!code) {
const reqIdToken = req.query.idToken
if (!reqIdToken) {
console.log('codeがないのに、req.query.idToken もない')
res.status(400).send('req.query.idToken が取れませんでした')
return
}
// idTokenをチェックする必要あり
try {
await verifyIdToken(reqIdToken)
} catch (error) {
console.log(error.message)
res.status(400).send('req.query.idToken が正しくありません<br />' + error.message)
return
}
addCookie(res, 'idToken', reqIdToken)
const randomValue = getRandomString()
console.log('randomValue: ' + randomValue)
const authorization_endpoint_uri = [
oauthConfig.authorization_endpoint,
'?client_id=',
oauthConfig.client_id,
'&redirect_uri=',
oauthConfig.redirect_uri,
'&state=',
randomValue,
'&response_type=code',
'&scope=',
oauthConfig.scope
].join('')
session.setAttributeById(reqIdToken, 'state', randomValue)
res.redirect(authorization_endpoint_uri)
} else {
// そもそもidTokenがなかったら後続を続ける意味がないので、正当性チェック verifyIdToken もここで実施
const cookies = cookie.parse(req.headers.cookie || '')
const idToken = cookies.idToken
let userId = ''
// idTokenをチェックする必要あり
try {
userId = await verifyIdToken(idToken)
} catch (error) {
console.log(error.message)
res.status(400).send('cookies.idToken が正しくありません。そもそも取得できなかったかも。<br />' + error.message)
return
}
const csrf = await checkCSRF(req, res, idToken)
if (!csrf) {
res
.status(400)
.send('前回のリクエストと今回のstate値が一致しないため、エラー。')
return
}
const formParams = {
redirect_uri: oauthConfig.redirect_uri,
client_id: oauthConfig.client_id,
client_secret: oauthConfig.client_secret,
grant_type: 'authorization_code',
code: code
}
const options = {
uri: oauthConfig.token_endpoint,
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
form: formParams,
json: true
}
const body: any = await doRequest(options)
console.log(userId)
admin
.firestore()
.collection('slackToken')
.doc(userId)
.set(body)
res.send('登録完了。ブラウザ閉じちゃってください。')
}
})
function doRequest(option) {
return new Promise((resolve, reject) => {
request(option, (error, response, body) => {
if (!error && response.statusCode == 200) {
resolve(body)
} else {
reject(error)
}
})
})
}
// https://qiita.com/fukasawah/items/db7f0405564bdc37820e 感謝!
function getRandomString() {
var S = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
var N = 50
const randomValue = Array.from(Array(N))
.map(() => S[Math.floor(Math.random() * S.length)])
.join('')
return randomValue
}
async function verifyIdToken(idToken) {
const decodedToken = await admin.auth().verifyIdToken(idToken)
const iss_aud_check =
decodedToken.iss == oidcConfig.iss && decodedToken.aud == oidcConfig.aud
if (!iss_aud_check) {
console.log(`iss(Expected): ${oidcConfig.iss}`)
console.log(`iss(Actual ): ${decodedToken.iss}`)
console.log(`aud(Expected): ${oidcConfig.aud}`)
console.log(`aud(Actual ): ${decodedToken.aud}`)
throw new Error('issもしくはaudが想定外でした')
}
return decodedToken.uid
}
async function checkCSRF(req, res, idToken) {
const state = req.query.state
const sessionState = await session.getAttributeById(idToken, 'state')
session.invalidate(idToken)
console.log('requestState: ' + state)
console.log('sessionState: ' + sessionState)
return state === sessionState
}
function addCookie(res, key, value) {
res.setHeader('Cache-Control', 'private') // Hosting経由だと、これがないとset cookieが削除される
const expiresIn = 60 * 60 * 24
const options = { maxAge: expiresIn, httpOnly: true }
// const options = { maxAge: expiresIn, httpOnly: true, secure: true }
res.setHeader('Set-Cookie', cookie.serialize(key, value, options))
}
import * as admin from 'firebase-admin'
const me = {
setAttributeById (sessionId: string, key: string, value: string) {
const ref = admin
.firestore()
.collection('session')
.doc(sessionId)
ref.get().then(docref => {
if (!docref.exists) {
const target: any = {}
target[key] = value
// admin.firestore().collection('session').add(target)
admin
.firestore()
.collection('session')
.doc(sessionId)
.set(target)
} else {
const target: any = docref.data()
target[key] = value
ref.set(target)
}
})
},
async getAttributeById (sessionId: string, key: string) {
const docref = await admin
.firestore()
.collection('session')
.doc(sessionId)
.get()
// const docref = await ref.get()
let returnValue: any = {}
if (!docref.exists) {
return
} else {
returnValue = docref.data()
}
return returnValue[key]
},
async invalidate (sessionId: string) {
await admin
.firestore()
.collection('session')
.doc(sessionId).delete()
}
}
export default me
超ザックリいうと、クエリパラメタに「code
」が入っているかで場合分けしていて
-
code
が入っていない- → (OAuthのトークン取得でいう)初回のリクエストと見なし、設定ファイル(
functions/src/oauthConfig.ts
)よりクエリパラメタを生成しながら、Slack認可サーバ「https://slack.com/oauth/authorize
」へリダイレクト。
- → (OAuthのトークン取得でいう)初回のリクエストと見なし、設定ファイル(
-
code
が入っている- → Slack認可サーバから認可コードが渡ってきたとみなし、Slack認可サーバ「
https://slack.com/api/oauth.access
」にアクセスしてアクセストークンを取得し、Firestoreへアクセストークンを保存する処理を実行
- → Slack認可サーバから認可コードが渡ってきたとみなし、Slack認可サーバ「
という動きをします。
もう少し丁寧に書くと、
-
code
が入っていない
- WEBアプリ側が発行した
idToken
(くどいですがFirebaseのユーザUIDが含まれている) をクエリパラメタから取得 -
idToken
が「有効かつ改ざんされていない」か、また「idToken
に含まれているPROJECT ID
をみて、想定通りのWEBアプリが発行したidToken
である」かをチェック(verifyIdToken
関数のところ) - OKなら、その
idToken
をCookieにセット(今後のリクエストでidToken
を用いるため) -
randomな文字列(
state
値)を生成し、Slack認可サーバにクエリパラメタstate
で連係します。あわせてその値は**idToken
をキーにFirestoreに保存**しておきます。 - Slack認可サーバへリダイレクト
-
code
が入っている
- Cookieから
idToken
を取り出します。 -
verifyIdToken
関数でidToken
の正当性をチェックします。 -
idToken
をキーにFirestoreからstate
値を取得します。 - クライアントからは、「認可サーバからのレスポンス時に渡ってくる(返ってくる)さっきの
state
の値」が渡ってきているはずなので、クエリからもstate
パラメタの値を取得。 -
code
が入ってないリクエストと、code
が入っているリクエストがおなじセッションであれば、上記二つのstate
の値は一致するはずなので、二つの値の一致を確認します(CSRF対策)。 - OKだったら、認可コード(
code
)と、client_id/client_secret
を使ってSlack認可サーバ「https://slack.com/api/oauth.access
」へアクセストークンを要求。Slack認可サーバは、client_id
によって「自分が認可コードを渡したかったクライアントかな?」という判定とclient_secret
によって「(接続を許可したWEBアプリだという)正当なヤツからのアクセストークン要求かな?」という判定をして、認可コードに紐付くアクセストークンを発行して返す - つづいてcookieから取得できる**
idToken
からFirebaseのユーザUID を取得**、それをキー値にして、さきほど取得したSlackのアクセストークンをFirestoreの「slackToken」テーブルに格納する。 - 関数は、画面に「登録完了。ブラウザ閉じちゃってください。」と表示して、完了する。
となります。
上記によってFirestoreには下記のような形式でアクセストークンが格納されます
Firebase Functionsのchat関数
さてWEBアプリから 「Add to Slack」ボタンを押したあとSlackでの認証・認可をおこなうことで、Firebase Firestoreにアクセストークンが格納されました。あとは Functionsから周期的に、この値を取り出してAPI経由でSlackへ投稿をおこなえば完成です。
今回は WEBから呼び出せるchat関数をつくってあり、それ経由でAPIを呼び出します。コードは、以下の通り。
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as request from 'request'
import * as cookie from 'cookie'
import session from './session'
import oauthConfig from './oauthConfig'
import oidcConfig from './oidcConfig'
admin.initializeApp()
export const chat = functions.https.onRequest(async (req, res) => {
await sendSlack()
res.send('ok')
})
export const chat_pub = functions.pubsub
.topic('slackChatTopic')
.onPublish(async message => {
await sendSlack()
})
// $ gcloud pubsub topics publish slackChatTopic --message '{"name":"Xenia"}'
async function sendSlack () {
const querySnapshot = await admin.firestore()
.collection('slackToken')
.get()
querySnapshot.forEach(doc => {
const fbUserId = doc.id
const jsonData = doc.data()
const option = {
url: 'https://slack.com/api/chat.postMessage',
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=UTF-8',
Authorization: `Bearer ${jsonData.access_token}`
},
json: {
channel: '#general',
text: `${fbUserId} です、今日は!`
}
}
request(option, (error, response, body) => {
if (error) {
console.log('error:', error)
return
}
if (response && body) {
console.log('status Code:', response && response.statusCode)
console.log(body)
}
})
})
}
async function sendSlack ()
では、FirestoreのslackTokenテーブルのデータを全件取得し、アクセストークンを取りだします。そのアクセストークンを Authorization ヘッダの Bearer トークンとしてセットし「https://slack.com/api/chat.postMessage
」へPOSTすることで、該当のアクセストークンが紐付いたSlackへ、メッセージが投稿されます。
今回はchat関数をWEB経由で起動しましたが、本来はスケジューラから起動したいですよね。じつはすでに Firebase の関数をスケジューラから定期的に呼び出す の記事で用いた形式の関数 chat_pub を作成済みなので、次回は
- WEBアプリとFunctionsの、本番へのデプロイ
- スケジューラから chat関数(chat_pub関数)を呼び出す事で、よりSlack上で動くアプリっぽくする
- そのための諸々の環境設定
をやっていきます。
--2019/09/24追記--
次回記事を書きました。
FirebaseアプリからSlackへ通知を行う。Slack AppのWEBへのデプロイ編。
--2019/09/24追記以上--
おつかれ様でしたー。。
関連リンク
- FirebaseアプリからSlackへ通知を行う。アクセストークンは手動で設定する編。 前回記事
- Vue.js とCloud Functions for Firebase をFirebase Hostingへデプロイするための環境構築手順 Vue.jsやFirebase Functions をサーバに配置する際の基本的な知識を整理
- Cloud Functions for Firebase の関数をスケジューラから定期的に呼び出す 定期実行の基礎知識
- Vue.js の主要な機能をざっくりとつかってみたときのメモ(Firebase認証・認可編) Firbaseの認証機能の基礎知識
- Vue.js の主要な機能をざっくりとつかってみたときのメモ(Firebase編)Firestoreの基礎知識
-
もひとつOAuth認可のための処理シーケンスのセキュリティ対策でCSRF対策があり、セッションを繋いでおく必要があるのですが、そのためのセッションIDのような役割も持たせています。 ↩