Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
162
Help us understand the problem. What is going on with this article?
@masatomix

Cloud Functions で構築したREST APIをFirebase認証で保護する。そして自前RESTサーバのAPIにもFirebase認証を適用する。

More than 1 year has passed since last update.

前回 は Cloud Functions for Firebase というサーバレス環境に配置したロジックをHTTPS経由で呼び出したのでした。

さてHTTPS呼び出しについてですが、その際につかったfunctions.https.onRequest:

index.ts
export const echo = functions.https.onRequest((request, response) => {
  const task = request.body
  response.send(JSON.stringify(task))
})

とは別に functions.https.onCall という方式もあるようで、今回はそちらをつかってみます。そしてそれをスタート地点にして、Functions上に構築したREST API を、Firebaseの認証機能で保護するやり方を考えてみようとおもいます。

ついでに、自前で構築したRESTサーバにも、Firebaseの認証を組み込んでみようと思います。

TL;DR

functions.https.onRequest でRESTなサービスを作ったばあい認証チェックなどは自動では行われない。従って作り込みが必要ではあるが、Authorization ヘッダにFirebaseのIDトークンを載せることで、Firebase認証されたユーザのみ使用可能とすることができる。

また、IDトークンは改ざんチェックが可能なJWT形式で送られてくるので、関数側がFirebaseに問い合わせることで正当性をチェックしたり、Firebaseが採番したユーザIDを取り出したりすることもできる。

自前で作成したRESTサーバについてもSDKを入れることでFirebaseにIDトークンの正当性をチェックしてもらうことができるし、SDKを入れられないとしても、いわゆるJWTのトークンのライブラリなどで正当性のチェックが可能である。
SDKと自前どちらもやってみたけど、SDK入れるのはそこそこ環境設定がめんどくさく、コードがかけるならライブラリを用意して自前でチェックする方が簡単なのかなーとおもいました。

図にしてみるとこんな感じ。

image.png

image.png

今回はWEBアプリのサーバへのデプロイはやってませんが、Firebase Hostingにデプロイする想定で書きました。またCORS(Cross-Origin Resource Sharing)の対応要否は今回ちゃんと記事として記述していませんが、onCallはCORS対応されてるっぽいですね。

まずはFirebase認証ナシでやってみる

さて functions.https.onCall をつかってみます。前回のプロジェクトに機能を追加したいので、前回のプロジェクトをGithubから取得します。

$ git clone --branch restsamples000 https://github.com/masatomix/fb_function_samples.git
$ cd fb_function_samples/functions/
$ npm install
$ cd ../

$ firebase use --add   // で自分のプロジェクトを選択してください

さて index.ts に以下を追加してビルドしましょう。

index.ts
export const echo_onCall = functions.https.onCall((data, context) => {
  console.log('data: ' + JSON.stringify(data))
  console.log('context.auth: ' + JSON.stringify(context.auth))
  if (context.auth) {
    console.log('context.auth.uid: ' + context.auth.uid)
  }
  return data
})

ビルド:

$ firebase deploy --only functions

コマンドラインからCurlで呼び出す

まずは復習。もともとあった const hello = functions.https.onRequest((req, res)... は以下の方法で呼び出す事が出来ました。

$ cat request.json
{
  "id": "001",
  "name": "こんにちは",
  "isDone": true
}

$ curl -X POST -H "Content-Type:application/json" \
  --data-binary  @request.json \
  https://us-central1-xxxxx-xxxxxxx.cloudfunctions.net/echo | jq

{
  "id": "001",
  "name": "こんにちは",
  "isDone": true
}
$ (ふつうにechoされました)

さて const echo_onCall = functions.https.onCall((data, context)...こちらについては、呼び出しのフォーマットがある程度決まっているようです。

詳細は

をみればよいのですが、curlから以下のように呼び出す事ができます。

$ cat post_data.json
{
  "data": { // data でくるんでおいて、、
    "id": "001",
    "name": "こんにちは",
    "isDone": true
  }
}




$ curl -X POST -H "Content-Type:application/json" \
  --data-binary @post_data.json \
  https://us-central1-xxxxx-xxxxxxx..cloudfunctions.net/echo_onCall | jq

{
  "result": { // result でくるまれて返ってきた
    "id": "001",
    "name": "こんにちは",
    "isDone": true
  }
}

あたらしく登録した関数は、単純に引数のdataをreturnするだけですが、POSTしたデータのうち、dataプロパティでくるまれたところが取り出され、resultプロパティにセットされて返ってきました。

うーん、分かりづらいですが、、仕組みは理解できましたorz。。

Vue.jsプロジェクトを構築して、SDK経由で呼び出してみる

さて functions.https.onCall((data, context)... ですが、この関数はWEBから呼ぶ場合は、FirebaseのSDK(Firebase JavaScript SDKなど) から呼び出されることを想定してるようですね。。

ってことで、つぎは Vue.jsで構築したアプリを使って、SDK経由で呼び出してみます。

Vue.js の主要な機能をざっくりとつかってみたときのメモ(Firebase認証・認可編) でつくった、Firebase認証をいれたToDoアプリがあるのでそれを使います。また、この記事に従ってFirebase側のAuthenticationなどを有効化しておいてください。あとから認証をつかいますんで。

さてVue.jsアプリの構築方法はこちら。

$ git clone --branch idToken001 https://github.com/masatomix/todo-examples.git
$ cd todo-examples/
$ npm install

src/firebaseConfig.js を自分の設定に書き換え

$ npm run dev

さあサーバが起動したので、http://localhost:8080/#/token/ にアクセスしてみてください。
image.png

この画面はFirebase認証で保護してないので、ボタンを配置したWEB画面が表示されます。そのボタンをクリックしてみてください。
image.png

なにかデータが返ってきましたね。

さて、このソースは以下の通りとなっています。

Token.vue
<template>
  <main class="container">
    <div>HTTP 呼び出し!</div>
    <button class="btn btn-primary" @click="checkToken">HTTP 呼び出し!</button>
  </main>
</template>

<script>
import firebase from "firebase";

export default {
  name: "Token",
  methods: {
    checkToken() {
      if (firebase.auth().currentUser) {
        firebase.auth().currentUser.getIdToken()
          .then(token => console.log(token));
      } else {
        console.log("currentUser is null");
      }

      const value = {
        id: "001",
        name: "こんにちは",
        isDone: true
      };
      // HTTP呼び出し
      const echo_onCall = firebase.functions().httpsCallable("echo_onCall");
      echo_onCall(value).then(result => alert(JSON.stringify(result)));
    }
  }
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

Firebase のFunctionsに登録した echo_onCall 関数は、引数をただreturnするようにしていましたが、ボタンを押下すると、SDKの関数const echo_onCall = firebase.functions().httpsCallable("echo_onCall") に、echo_onCall(value) って渡した value が、コールバックで dataプロパティにくるまれて返ってきたようです。

ブラウザで登り電文、下り電文を見てみます。

登り電文
image.png

下り電文
image.png

SDKは {'data':value} って画面に表示していますが、実際はcurlで送受信したデータと等価なモノが返ってきていることが分かります。

以上で、先のcurlの例の電文フォーマットが、SDKの呼び出し方と等価であることが確認出来ました。

つぎは、Firebase認証した状態でやってみる

さてこのへんから徐々に、認証の話になります。

つぎは http://localhost:8080/#/token_auth にアクセスしてみてください。この画面はFirebase認証で保護するように作り込んであるので、ログイン画面にリダイレクトされます。
image.png
GoogleアカウントでログインするリンクやID/Passの認証を配置してあるので、自分のFirebase環境にあったやりかたで適宜ログインしてみてください。

ログイン状態になると、先ほどのボタンがある画面とおなじ画面(Token.vue)に遷移すると思います。

同じくボタンをクリックしてFunctionsの関数をSDK経由でコールすると、まあおなじ結果が得られるのですが、、、Firebase認証済みのときと認証されていないときでは、電文に違いがあります。
具体的には、Firebase認証済みの時のには、HTTPのリクエストヘッダに、

Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyZTQ2MGZmM2EzZDQ2ZGZlYzcyNGQ4NDg0ZjczNDc2YzEzZTIwY2YiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb....

と Authorization ヘッダにBearerトークンがセットされています。
image.png

つまりFirebase SDKをつかって サーバのHTTPSのFunctionをコールした場合、Firebase認証している時はAuthorization ヘッダにトークンがセットされる という事が分かりました。

ちなみにこのトークンは、

firebase.auth().currentUser.getIdToken().then(token => console.log(`Bearer ${token}`));

のtokenの値です。

トークンの値のサンプル

実際の値のサンプルはこんな感じ。

eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyZTQ2MGZmM2EzZDQ2ZGZlYzcyNGQ4NDg0ZjczNDc2YzEzZTIwY2YiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20veHh4eHh4LXNhbXBsZXMiLCJhdWQiOiJ4eHh4eHgtc2FtcGxlcyIsImF1dGhfdGltZSI6MTU0OTU5MzgxMCwidXNlcl9pZCI6IlVOWHBENDBZNUVZZkt4eHh4eHh4Iiwic3ViIjoiVU5YcEQ0MFk1RVlmS3h4eHh4eHgiLCJpYXQiOjE1NDk1OTM4MTAsImV4cCI6MTU0OTU5NzQxMCwiZW1haWwiOiJob2dlaG9nZUBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJob2dlaG9nZUBleGFtcGxlLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19Cg.D3ZDm-UPDGRqezz6Q9QKpZtSVovxlWZNt-pyArLlruo1DqfkmaYkXuTjMpxIbB0sCySDAme3ZeenRYxgBrQJhqZiwFx_mTNfjNoQSUVbRjLrSYdtXLBtgy6OvJGsN93UfFfhb2kAeBjDtOPTE6WOWyJ7wDRK0bmkYvYLZ9NMgFsc9-ELfqew7jOVnZTsem3dwkhfQ-_qHJnRD7xkLmEu2CA0yUSbajVwy-rDpC5eRVZVjnnFpgghJckBpQTdxXesM58aRF5uiSLsIi6KYimDyqV_cQL_oAojW0fR-X-Q0GqD4FYsGmk1hMy-n5ClOUmCKvHLcN6tAWQKScdvYAx3cA

で、よーく見るとドットで3つに区切られています。

そしてドットで区切ったところで前半二つをそれぞれ、下記の通りbase64デコードしてみると、、、、、

(ココだけlinuxで叩いてます。微妙にオプションとか違うかも)
$ echo eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyZTQ2MGZmM2EzZDQ2ZGZlYzcyNGQ4NDg0ZjczNDc2YzEzZTIwY2YiLCJ0eXAiOiJKV1QifQ | base64 -d | jq
{
  "alg": "RS256",
  "kid": "b2e460ff3a3d46dfec724d8484f73476c13e20cf",
  "typ": "JWT"
}

なんだかJWTとか鍵のアルゴリズムRS256とかが書いてあります。そして二つ目はこちら。

(ココだけlinuxで叩いてます。微妙にオプションとか違うかも)
$ echo eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20veHh4eHh4LXNhbXBsZXMiLCJhdWQiOiJ4eHh4eHgtc2FtcGxlcyIsImF1dGhfdGltZSI6MTU0OTU5MzgxMCwidXNlcl9pZCI6IlVOWHBENDBZNUVZZkt4eHh4eHh4Iiwic3ViIjoiVU5YcEQ0MFk1RVlmS3h4eHh4eHgiLCJpYXQiOjE1NDk1OTM4MTAsImV4cCI6MTU0OTU5NzQxMCwiZW1haWwiOiJob2dlaG9nZUBleGFtcGxlLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJob2dlaG9nZUBleGFtcGxlLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19Cg | base64 -d | jq
{
  "iss": "https://securetoken.google.com/xxxxxx-samples",
  "aud": "xxxxxx-samples",
  "auth_time": 1549593810,
  "user_id": "UNXpD40Y5EYfKxxxxxxx",
  "sub": "UNXpD40Y5EYfKxxxxxxx",
  "iat": 1549593810,
  "exp": 1549597410,
  "email": "hogehoge@example.com",
  "email_verified": false,
  "firebase": {
    "identities": {
      "email": [
        "hogehoge@example.com"
      ]
    },
    "sign_in_provider": "password"
  }
}

のように、なにやら認証情報が入っていることが分かります。3つ目のブロックは署名データなのですが、、、あとで書きますので次に行きましょう。。

functions.https.onCall はトークンをVerifyしてくれる

さて、const echo_onCall = functions.https.onCall((data, context)... で登録された関数をSDK経由で呼び出したとき、Firebase認証済みの場合はBearerトークンがついたリクエストを送信していることが分かりました。そしてこのonCall関数ですが、Bearerトークン付きのリクエストを受信すると、そのトークンのVerifyも自動で行ってくれるようです。

実際、トークンを適当に書き換えてcurlでリクエストしてみると、、、

$ curl -X POST -H "Content-Type:application/json" \
   --data-binary @post_data.json \
   -H 'Authorization: Bearer 適当' \
   https://us-central1-xxxxx-xxxxxxx..cloudfunctions.net/echo_onCall  | jq

  {
  "error": {
    "status": "UNAUTHENTICATED",
    "message": "Unauthenticated"
  }
}

Authorizationヘッダをつけたときは、トークンの正当性チェックをしてくれることが分かりました。一応、正しいトークンでもリクエストしておきましょう。

$ curl -X POST -H "Content-Type:application/json" \
   --data-binary @post_data.json \
   -H 'Authorization: Bearer さっき戻ってきた正しいヤツ' \
   https://us-central1-xxxxx-xxxxxxx..cloudfunctions.net/echo_onCall

{"result":{"id":"001","name":"こんにちは","isDone":true}}
$

ちゃんと正しいトークンをつけたばあいは、Authorizationヘッダがなかった場合とおなじ結果が得られていますね。

functions.https.onRequest はトークンをVerifyしない

つづいて、functions.https.onRequest のほうですが、こちらは

$ curl -X POST  -H "Content-Type:application/json"   \
 -H 'Authorization: Bearer 適当' \
 --data-binary  @request.json \
 https://us-central1-xxxxx-xxxxxxx..cloudfunctions.net/echo


{
  "id": "001",
  "name": "こんにちは",
  "isDone": true
}
$ (ふつうにechoされちゃった..)

と適当な トークンをセットしても値は取得できてしまいました。ようするにfunctions.https.onRequest はAuthorizationヘッダは自動ではチェックしてくれないという事が分かりました。

いったんまとめ

以上から、下記の通りに整理できそうです。

functions.https.onCall で作成したREST

こちらは、AuthorizationヘッダのBearerトークンの正当性を自動でチェックしてくれるので、

firebase.auth().currentUser.getIdToken().then(token => console.log(token));

でトークンを取得してAuthorizationヘッダにセットして投げれば、Firebase認証してるユーザにのみ関数を使用させることができる(そもそも Authorization ヘッダがついてない場合はエラーにする、って処理は必要だけど)。
ただしリクエスト・レスポンスの電文仕様はある程度、固定的となる。

と整理できそうです。
とくにFirebase SDKからコールされる前提であれば、SDKはAuthorizationヘッダにトークンを自動でセットしてくれるし、こちらの方がシンプルで簡単ですね。

functions.https.onRequest で作成したREST

こちらは、AuthorizationヘッダのBearerトークンの正当性は自動ではチェックしてくれないので、まずクライアント(WEBアプリやcurl)からは

firebase.auth().currentUser.getIdToken().then(token => console.log(token));

でトークンを取得してAuthorizationヘッダにセットしてなげるようにします。そして、サーバ側の関数はそのトークンの正当性をFirebaseに問い合わせることで、Firebase認証しているユーザにのみ関数を使用させることができそうです。(そもそも Authorization ヘッダがついてない場合はエラーにしないといけない)。
そして、リクエスト・レスポンスの電文仕様も任意でOK。

と整理できそうです。
Firebase認証されたユーザに対して、ふつうにRESTなサーバを提供するばあいは、こちらのほうが汎用的でよさそうです。

Functionsに登録された関数で、トークンの正当性をFirebaseに問い合わせる

さて、あとはサーバ側の functions.https.onRequest 関数で、トークンの正当性をFirebaseに問い合わせるやりかたですが、たとえば下記のようにリクエストのAuthorizationヘッダの値を取得する関数を定義し、

index.ts(getIdToken)
function getIdToken(request, response) {
  if (!request.headers.authorization) {
    throw new Error('Authorization ヘッダが存在しません。')
  }
  const match = request.headers.authorization.match(/^Bearer (.*)$/)
  if (match) {
    const idToken = match[1]
    return idToken
  }
  throw new Error(
    'Authorization ヘッダから、Bearerトークンを取得できませんでした。',
  )
}

functions.https.onRequest でその関数を呼び出してIDトークンを取得し、提供されるverifyIdTokenメソッドで、IDトークンの正当性チェックを行えばOKです。

index.ts(functions.https.onRequest)
import * as corsLib from 'cors'
const cors = corsLib()

export const echo = functions.https.onRequest((request, response) => {
  return cors(request, response, async () => {
    const task = request.body
    console.log(JSON.stringify(task))

    try {
      const idToken = getIdToken(request, response) // Bearerトークン取れるかチェック
      const decodedToken = await admin.auth().verifyIdToken(idToken)

      console.log(decodedToken.uid) // Firebase Authentication 上のユーザUID
      response.send(JSON.stringify(task))
    } catch (error) {
      console.log(error.message)
      response.status(401).send(error.message)
    }
  })
})

Functionsにデプロイして、その関数をcurlなどからIDトークンを色々値を変えて呼び出してみて、動作を確認してみてください。Authorization ヘッダのBearerにのってくるIDトークンが正しい値のときだけ、JSONデータが返ってくることが確認できると思います。

--2020/02/01 追記--
getIdToken関数はresponseで画面を返すよりも例外をThrowした方がスマートなので変更しました。
echo関数はWEBブラウザからのリクエストの際はCORSの考慮が必要なので、CORS対応しました。
--2020/02/01 追記 以上--

そもそもこのトークンってなに?そして改ざんや暗号化について

さきほどIDトークンのサンプルの値を示しましたが、このIDトークンってのは OpenID Connectなどの認証基盤の世界でよく出てくるJWT(JSON Web Tokens)という技術が用いられています。詳細は詳しい人に譲るとして :-) JWTとは、JSONデータをネットワークに送信する場合に、

Header部.Payload(JSON本体).Signature(署名)

とドットで分割したフォーマットにして送信する仕組みです(各部はそれぞれBase64エンコードされてる)。
署名は、Header部に記載された署名方式(通常、作成者の秘密鍵で署名するのが多い)によって、Header部とPayload部を署名したデータになっていて、従って作成者の公開鍵でHeader/Payloadの改ざんチェックが可能です。

すなわち、たとえば次にやりますが、自前で立てたRESTサーバの認証・認可基盤としてFirebase認証を用いようと思った場合 このIDトークンをもらうことで、IDトークンの発行者であるFirebaseシステムの公開鍵を取得してJWTデータの正当性をチェックすることが可能ということです。

またIDトークンからは最終的にFirebaseのユーザIDが取得できますが、その改ざんは検知可能ということになるので、なりすましやデータ改ざんの防止にもなるわけですね。

ついでに、Payload部のJSONデータには

{
  "iss": "https://securetoken.google.com/xxxxxx-samples",
  "aud": "xxxxxx-samples",
  "auth_time": 1549593810,
  "user_id": "UNXpD40Y5EYfKxxxxxxx",
  "sub": "UNXpD40Y5EYfKxxxxxxx", // FirebaseのユーザID
  "iat": 1549593810,
  "exp": 1549597410,  //  <- Fri Feb 08 12:43:30 JST 2019 有効期限
  "email": "hogehoge@example.com",
  "email_verified": false,
  "firebase": {
    "identities": {
      "email": [
        "hogehoge@example.com"
      ]
    },
    "sign_in_provider": "password"
  }
}

というように有効期限が記載されているので、この日時をチェックすることで、認証された正当なリクエストかどうかをチェックすることができます。

ただJWTはただのBase64エンコードされたデータのため、暗号化はされていません。従って内容を見られたくない場合はちゃんとSSLを通す必要があります。まあいまどきRESTはSSLで作成するので問題ないでしょう。。

Functionsからでなく、自前のRESTサーバからトークンの正当性チェックを行いたい

さて次にやるといいましたが自前で立てたRESTサーバの認証・認可基盤としてFirebase認証をつかってみます
具体的には、さきほどは FirebaseのFunctionsに登録された関数からIDトークンの正当性チェックを行いましたが、今回は自前で構築したRESTサーバからIDトークンの正当性チェック(署名の検証によるデータ改ざんの検証と、有効期限の確認)をおこなうことになります。

ちなみに厳密には、

このサイトに、こんだけチェックしなさいね、というのが詳細に説明されています。

自前RESTサーバの構築

というわけで FunctionsとかFirebaseとか全く関係ない環境に、RESTサーバを構築します。

すでに http://localhost:8081/echo で立ち上がるJavaのサーバを作成してコミット済みなので、落としてきて環境を構築してみましょう。

あ、、、ビルドやサーバ起動には Apache Mavenのインストールが必要ですが、Mavenのセットアップ手順は割愛します。。。

$ git clone --branch 0.0.1 https://github.com/masatomix/spring-boot-sample-tomcat.git 
$ cd spring-boot-sample-tomcat
$ mvn eclipse:clean eclipse:eclipse
$ mvn spring-boot:run

で起動するはずです。

Curlから呼ぶ

このRESTサーバは、curlからは

$ cat post_data.json
{
    "id": "001",
    "name": "こんにちは",
    "isDone": true
}:

$ curl -X POST -H "Content-Type:application/json" \
  --data-binary @post_data.json \
  -H 'Authorization: Bearer eyJhbGciOiJSUzI1xxxxxxxxxx'\
  http://localhost:8081/echo

で呼び出すことができます。例によってIDトークンは適宜、ブラウザの開発ツールなどを使ってとりだしてみてください。

image.png

RESTを呼び出すWEB画面の追加

WEB画面からの呼び出しについては、先のVue.jsで作成したプロジェクトの Token.vue を下記に差し替えてもらうと、もう一つボタンが出てきます。そのボタンを押すことでRESTのリクエストを送信できますので、確認してみてください。このコードはREST呼び出しをする際の、Authorization ヘッダへのIDトークンを受け渡すサンプルコードになっています。

Token.vue
<template>
  <main class="container">
    <div>HTTP 呼び出し!</div>
    <button class="btn btn-primary" @click="checkToken">HTTP 呼び出し!</button>

    <hr>
    <div>HTTP 呼び出し!</div>
    <button class="btn btn-primary" @click="checkToken_without_sdk">HTTP 呼び出し(自前サーバ)!</button>
  </main>
</template>

<script>
import firebase from "firebase";
import axios from "axios";

export default {
  name: "Token",
  methods: {
    checkToken() {
      if (firebase.auth().currentUser) {
        firebase.auth().currentUser.getIdToken()
          .then(token => console.log(token));
      } else {
        console.log("currentUser is null");
      }

      const value = {
        id: "001",
        name: "こんにちは",
        isDone: true
      };
      // HTTP呼び出し
      const echo_onCall = firebase.functions().httpsCallable("echo_onCall");
      echo_onCall(value).then(result => alert(JSON.stringify(result)));
    },

    checkToken_without_sdk() {
      if (firebase.auth().currentUser) {
        firebase.auth().currentUser.getIdToken()
          .then(token => {
            console.log(token);

            const value = {
              id: "001",
              name: "こんにちは",
              isDone: true
            };
            const config = {
              url: "http://localhost:8081/echo",
              method: "POST",
              headers: {
                "Content-type": "application/json",
                Authorization: `Bearer ${token}`
              },
              data: value,
              json: true
            };
            axios(config)
              .then(response => alert(JSON.stringify(response.data)))
              .catch(error => alert(error.message));
          });
      } else {
        console.log("currentUser is null");
      }
    }
  }
};
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

image.png

自前RESTサーバが、IDトークンの正当性チェックを行うコード

最後にRESTサーバのJavaのコードです。Nimbus JOSE + JWT というJWTのライブラリを用いて、IDトークンの正当性チェックを行っています。

SampleController.java
package nu.mine.kino.web;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import nu.mine.kino.service.Hello;

@Controller
@RequestMapping("/echo")
public class SampleController {
    private static final Pattern CHALLENGE_PATTERN = Pattern
            .compile("^Bearer *([^ ]+) *$", Pattern.CASE_INSENSITIVE);

    @ResponseBody
    @CrossOrigin
    @RequestMapping(produces = "application/json; charset=utf-8", method = RequestMethod.POST)
    public Hello helloWorld(
            @RequestHeader(value = "Authorization", required = true) String authorization,
            @RequestBody Hello hello) throws UNAUTHORIZED_Exception {

        Matcher matcher = CHALLENGE_PATTERN.matcher(authorization);
        if (matcher.matches()) {
            String id_token = matcher.group(1);
            if (JWTUtils.checkIdToken(id_token)) {
                return hello;
            } else {
                throw new UNAUTHORIZED_Exception("ID Token is invalid.");
            }
        }
        throw new UNAUTHORIZED_Exception(
                "Authorization ヘッダから、Bearerトークンを取得できませんでした。");
    }

    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    class UNAUTHORIZED_Exception extends Exception {
        private static final long serialVersionUID = -6715447914841144335L;

        public UNAUTHORIZED_Exception(String message) {
            super(message);
        }
    }

    // private void snippet() throws IOException, FirebaseAuthException {
    // String id_token = "";
    // ClassLoader loader = Thread.currentThread().getContextClassLoader();
    // // resources に置いちゃう
    // InputStream serviceAccount = loader.getResourceAsStream(
    // "xxxxxxxx-firebase-adminsdk-xxxxxxxxx.json");
    // FirebaseOptions options = new FirebaseOptions.Builder()
    // .setCredentials(GoogleCredentials.fromStream(serviceAccount))
    // .setDatabaseUrl("https://xxxxxxxx.firebaseio.com").build();
    // FirebaseApp.initializeApp(options);
    //
    // FirebaseToken decodedToken = FirebaseAuth.getInstance()
    // .verifyIdToken(id_token);
    // String uid = decodedToken.getUid();
    //
    // }
}

if (JWTUtils.checkIdToken(id_token)) .. で呼び出してるコードは以下の通りです。

JWTUtils.java
package nu.mine.kino.web;

import java.io.IOException;
import java.security.cert.X509Certificate;
import java.text.ParseException;
import java.util.Date;
import java.util.Map;

import javax.ws.rs.client.Client;
import javax.ws.rs.client.ClientBuilder;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.X509CertUtils;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;

import lombok.extern.slf4j.Slf4j;

/**
 * @author Masatomi KINO
 * @version $Revision$
 */
@Slf4j
public class JWTUtils {
    public static boolean checkIdToken(String id_token) {
        try {
            SignedJWT decodeObject = SignedJWT.parse(id_token);
            log.debug("Header : " + decodeObject.getHeader());
            log.debug("Payload: " + decodeObject.getPayload());
            log.debug("Sign   : " + decodeObject.getSignature());

            JWSAlgorithm algorithm = decodeObject.getHeader().getAlgorithm();
            JWTClaimsSet set = decodeObject.getJWTClaimsSet();
            log.debug("Algorithm: {}", algorithm.getName());
            log.debug("ExpirationTime(有効期限): {}", set.getExpirationTime());
            log.debug("IssueTime(発行日時): {}", set.getIssueTime());
            log.debug("Subject(key): {}", set.getSubject());
            log.debug("Issuer(発行元): {}", set.getIssuer());
            log.debug("Audience: {}", set.getAudience());
            log.debug("Nonce: {}", set.getClaim("nonce"));
            log.debug("auth_time(認証日時): {}", set.getDateClaim("auth_time"));
            log.debug("鍵アルゴリズム({})", algorithm.getName());
            boolean isExpired = new Date().after(set.getExpirationTime());
            log.debug("now after ExpirationTime?: {}", isExpired);
            if (isExpired) {
                log.warn("有効期限が切れています。");
                return false;
            }
            String jwks_uri = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com";
            return checkRSSignature(decodeObject, jwks_uri);

        } catch (ParseException e) {
            log.warn("サーバの公開鍵の取得に失敗しています.{}", e.getMessage());
        } catch (IOException e) {
            log.warn("サーバの公開鍵の取得に失敗しています.{}", e.getMessage());
        } catch (JOSEException e) {
            log.warn("Verify処理に失敗しています。{}", e.getMessage());
        }
        return false;
    }

    private static boolean checkRSSignature(SignedJWT decodeObject,
            String jwks_uri) throws JOSEException, IOException, ParseException {
        // Headerから KeyIDを取得して、
        String keyID = decodeObject.getHeader().getKeyID();
        log.debug("KeyID: {}", keyID);

        Map<String, Object> resource = getResource(jwks_uri);
        String object = resource.get(keyID).toString();
        X509Certificate cert = X509CertUtils.parse(object);
        RSAKey rsaKey = RSAKey.parse(cert);

        JWSVerifier verifier = new RSASSAVerifier(rsaKey);
        boolean verify = decodeObject.verify(verifier);
        log.debug("valid?: {}", verify);
        return verify;
    }

    private static Map<String, Object> getResource(String target)
            throws IOException {
        Client client = ClientBuilder.newClient();
        Response restResponse = client.target(target)
                .request(MediaType.APPLICATION_JSON_TYPE).get();
        String result = restResponse.readEntity(String.class);
        log.debug(result);
        return json2Map(result);
    }

    private static Map<String, Object> json2Map(String result)
            throws IOException {
        ObjectMapper mapper = new ObjectMapper();
        return mapper.readValue(result,
                new TypeReference<Map<String, Object>>() {
                });
    }
}

Javaのコードは長くてうーんってなりますが、やってることはだいたいこんな感じ。。

  • http://localhost:8081/echo にアクセスすると、SampleController#helloWorld が呼ばれる
  • Authorization ヘッダのBearer部分からIDトークン(id_token)を取り出す
  • JWTUtils.checkIdToken(id_token)を呼び出してOKだったら、正当なリクエストとして、後続の処理を実施
  • JWTUtils.checkIdToken(id_token)の処理は
    • Nimbus JOSE + JWT ライブラリの SignedJWT オブジェクトを作って、Header/Payload/Sign を取り出す
    • Header部の情報から、署名のアルゴリズムを判定(は実はサボって、RS256 キメウチ)
    • PayloadのJSON部の、ExpirationTime(expプロパティ)を見て、有効期限切れチェック
    • checkRSSignature メソッド呼び出し
      • 所定のURL(jwks_uri)から、Firebaseの公開鍵(s)を取得
      • ちなみにJWKのURL(公開鍵を取得するサイト)などは ID トークンを確認する ココに載ってたりします
      • ヘッダのKeyIDに該当する公開鍵を選んで、RSASSAVerifierクラスをつかってHeader/Payloadの署名検証を実施

こうやることで、IDトークンの正当性チェック完了です!

今回はJavaで自前でIDトークンのチェックを行いましたが、じつは、主要な言語にはFirebase提供のSDKがあります。

JavaのSDKもあって、RESTサーバ上で初期化して使用する事ができましたが(コードもコメントとして置いておきました)、「サービスアカウント画面で秘密鍵を作成しろ」とか「サービス アカウントの認証情報が設定された設定ファイルが必要」とか、いろいろとメンドクサイので、手順は省略します。

あー、、つかれました。

まとめ

Firebaseで認証したときの認証情報はIDトークンと呼ばれる改ざんチェック可能なJWTという仕様のフォーマットになっていました。で、RESTサーバで公開するAPIを認証で保護するにあたっては、実装方法(FirebaseのFunctionsだとか、自前のRESTサーバだとか)にかかわらず、そのIDトークンをAPI呼び出し時にあわせて送信してもらう事で、正当なFirebaseログイン済みユーザであるかどうかを確認することができる、という事が分かりました。

また、IDトークンにはFirebase認証のキー値であるユーザID(JWTの'sub'プロパティ)が含まれているので、IDトークンチェック後その値を用いることで、自前データベースのユーザデータと紐付けることができそうですね。

以上です。おつかれさまでしたー。。。

関連リンク(ソース)

コード追加完了後のFunctionsにデプロイするプロジェクトのソース。

$ git clone --branch restsamples001 https://github.com/masatomix/fb_function_samples.git
$ cd fb_function_samples/functions/
$ npm install
$ cd ../

$ firebase use --add   // で自分のプロジェクトを選択してください
$ firebase deploy --only functions

コード追加完了後のVue.jsで作成したWEBアプリのソース

$ git clone --branch idToken002 https://github.com/masatomix/todo-examples.git
$ cd todo-examples/
$ npm install

src/firebaseConfig.js を自分の設定に書き換え

$ npm run dev

Javaで作成した、自前RESTサーバのサンプルソース

$ git clone --branch 0.0.1 https://github.com/masatomix/spring-boot-sample-tomcat.git 
$ cd spring-boot-sample-tomcat
$ mvn eclipse:clean eclipse:eclipse
$ mvn spring-boot:run

関連リンク

162
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
masatomix
JavaEEの開発やリッチクライアント開発のアーキテクトが専門でしたが、最近はRPAとAIがメイン。。。RPAはUiPathとOrchestratorの構築が中心です。 FirebaseとかOAuth/OIDCなど新しいモノ、あと数学もすき。 最近は UiPath Friends っていうユーザコミュニティにも関わってます。 あ、UiPath Japan MVP 2019,2020 す。
primebrains
プライム・ブレインズは、マネジメントスキルだけでなく、最新のITスキルを兼ね備えた技術者がお客様の立場でお客様と共に、成功に向けてプロジェクトを推進します。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
162
Help us understand the problem. What is going on with this article?