LoginSignup
12
13

More than 3 years have passed since last update.

FirebaseアプリからSlackへ通知を行う。アクセストークンも自動連携する編。

Last updated at Posted at 2019-03-18

前回より、あるWEBサービスのユーザへの通知の方法として、そのユーザのSlackに通知が飛ばせたらイイねという件に着手しています。

前回はHello World 的な Slack App を作成しました。つぎはそのSlack Appへのアクセストークンの受け渡しをどうするかについて書くといったので、今回はそれを整理していきます。

今回目指す処理シーケンスは下記の通りとなります。
image.png

  • アクセストークン(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 を有効にしておいてください。これらは下記情報を参考にすればよいかもしれません。

あとは

ココの「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をひかえておきます。

image.png

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の値となります。

image.png

準備が長くてスイマセン。。けっきょく、

項目
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」ボタンが配置されている画面が表示されると思います。

image.png

ボタンをクリックすると、ウィンドウが開き、Slackの認可サーバへリダイレクトされます。すでにWEBブラウザでSlackを使っていれば、下記の通り、前回記事と同様の認可画面が表示されます。

image.png
(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へアクセストークンが保存されるとおもいます。

後述しますが、
image.png
こんな感じにFirestoreに保存されるはずです。

chat関数を呼び出す

さて、保存したSlackのアクセストークンを取り出して使用する処理を動かすために、chat関数を呼び出します。

$ curl http://client.example.com:5001/##PROJECT ID##/us-central1/chat
ok
$

認可をおこなったSlackに通知が飛んだと思います!
image.png

サンプルアプリによる動作の紹介は以上です。

各ソースの説明

各ソースの主要なとこだけ紹介します。

Add to Slack ボタンを配置してあるVue.jsのWEBアプリ

まずはリンクを配置するWEBアプリから。

WEBアプリはVue.jsで構築され、Firebase認証でログイン出来るようにしてあります。
ログインすると表示される、Add to Slack のボタンがある Slack.vue ファイルのソースは下記の通り。

src/components/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」とは、コレのことですね。
image.png

idTokenにどんなフォーマットでユーザUID(user_id)が格納されているか」また「そのidTokenが改ざん出来ない仕組みになっている」などについては下記の記事に整理しましたので、適宜こちらをご参照ください。

参考: REST APIをFirebase認証で保護する。

さて読み込んでいる設定ファイル restConfig.jsは以下。

src/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関数を見てみます。

functions/src/index.ts(ほぼ全部)
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))
}
session.ts
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」へリダイレクト。
  • codeが入っている

    • → Slack認可サーバから認可コードが渡ってきたとみなし、Slack認可サーバ「https://slack.com/api/oauth.access」にアクセスしてアクセストークンを取得し、Firestoreへアクセストークンを保存する処理を実行

という動きをします。

もう少し丁寧に書くと、

  • codeが入っていない
  1. WEBアプリ側が発行したidToken(くどいですがFirebaseのユーザUIDが含まれている) をクエリパラメタから取得
  2. idTokenが「有効かつ改ざんされていない」か、また「idTokenに含まれているPROJECT IDをみて、想定通りのWEBアプリが発行したidTokenである」かをチェック(verifyIdToken関数のところ)
  3. OKなら、そのidTokenをCookieにセット(今後のリクエストでidTokenを用いるため)
  4. randomな文字列(state値)を生成し、Slack認可サーバにクエリパラメタstateで連係します。あわせてその値はidTokenをキーにFirestoreに保存しておきます。
  5. Slack認可サーバへリダイレクト
  • codeが入っている
  1. CookieからidTokenを取り出します。
  2. verifyIdToken関数でidTokenの正当性をチェックします。
  3. idTokenをキーにFirestoreからstate値を取得します。
  4. クライアントからは、「認可サーバからのレスポンス時に渡ってくる(返ってくる)さっきのstateの値」が渡ってきているはずなので、クエリからもstateパラメタの値を取得
  5. codeが入ってないリクエストと、codeが入っているリクエストがおなじセッションであれば、上記二つのstateの値は一致するはずなので、二つの値の一致を確認します(CSRF対策)。
  6. OKだったら、認可コード(code)と、client_id/client_secretを使ってSlack認可サーバ「https://slack.com/api/oauth.access」へアクセストークンを要求。Slack認可サーバは、client_idによって「自分が認可コードを渡したかったクライアントかな?」という判定client_secretによって「(接続を許可したWEBアプリだという)正当なヤツからのアクセストークン要求かな?」という判定をして、認可コードに紐付くアクセストークンを発行して返す
  7. つづいてcookieから取得できるidTokenからFirebaseのユーザUID を取得、それをキー値にして、さきほど取得したSlackのアクセストークンをFirestoreの「slackToken」テーブルに格納する。
  8. 関数は、画面に「登録完了。ブラウザ閉じちゃってください。」と表示して、完了する。

となります。

上記によってFirestoreには下記のような形式でアクセストークンが格納されます
image.png

Firebase Functionsのchat関数

さてWEBアプリから 「Add to Slack」ボタンを押したあとSlackでの認証・認可をおこなうことで、Firebase Firestoreにアクセストークンが格納されました。あとは Functionsから周期的に、この値を取り出してAPI経由でSlackへ投稿をおこなえば完成です。

今回は WEBから呼び出せるchat関数をつくってあり、それ経由でAPIを呼び出します。コードは、以下の通り。

functions/src/index.ts(ほぼ全部といった、残り)
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追記以上--

おつかれ様でしたー。。

関連リンク


  1. もひとつOAuth認可のための処理シーケンスのセキュリティ対策でCSRF対策があり、セッションを繋いでおく必要があるのですが、そのためのセッションIDのような役割も持たせています。 

12
13
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
13