73
70

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Firebase (Hosting × Functions) × Nuxt.js (universal) で ユーザ認証のベストプラクティスを探る旅 その1

Last updated at Posted at 2019-04-30

はじめに

初めてQiitaに投稿させていただきます。沖縄で開発エンジニアをやってます。

半月ほど前から、独学でNuxt.jsとFirebaseを同時に触りはじめました。
これまではDjangoを利用した所謂クライアントサーバモデルのAPI開発がメインでしたので、Firebaseを触りはじめてすぐ 「サーバサイドレンダリングYABEEEE」 「CLIで一発デプロイなにこれ未来GOISUUUU」 と虜になってしまいました。
ただ、基本的なアーキテクチャ(NuxtによるSSRと、Firebase Functionsの仕組み)をいまいち理解していなかったため、ユーザログイン状態の管理をどうすれば良いかで10日ほど悶々としておりました。

Qiitaその他の諸師匠方の投稿を穴があくほど読みながら、行ったり来たりでようやくベストプラクティスらしきものに辿り着いたため、微力ながら少しでも同志諸氏に還元できればと思い投稿させていただく次第です。

なお、まだまだ今回初めてVSCodeを触りはじめたくらいのひよっこですので、諸先輩方の助言はいつでも大歓迎です。少しでも「あのさぁもっとこういうやり方あんだけど野暮ったい野暮ったい」と思われた方はお気軽に叱咤コメントいただければ嬉しいです。

目指したこと

今回、NuxtとFirebaseを触りはじめて、先ずは認証認可の部分をしっかり掴もうと思い、以下のようなプロトタイプページを作ることを目指して頑張りました。

  • NuxtはUniversalモードで起動する
  • Firebase HostingとFirebase FunctionsでサーバレスSSRを実現する
  • /account/login にレンダリングしたFirebaseUIからFirebase Authenticationでログインする
    • ログイン後は index にリダイレクトする
  • ログインユーザ情報をコンテンツ中で取得できるようにする
  • ユーザがログインしていない場合、どのページにアクセスしても /account/login にリダイレクトする
    • リダイレクト前に元ページのレンダリングはしない

ごく普通の認証認可の仕組みです。ログイン後はユーザデータをFirestoreから読み出す仕組みを今後実装する予定です。

辿り着いた答え

紆余曲折の末の自己ベスト構成です。

ポイント

  • ログイン状態はFirebase Authenticationを神とする
  • クライアントは認証が通ったらJWTトークンをCookieで保持しておく
  • サーバの認証処理はmiddlewareで行い、リクエスト毎にCookieのJWTをFirebase Admin SDKで検証する
  • ログインが必要なページは共通のlayoutを適用し、layoutのmiddlewareプロパティで一括設定する

プロジェクト構成

Nuxt と Firebase Functions を共存させる時に地味に迷ったので、現状のPJ構成とpackage.jsonを晒しておきます。
※ 無駄なのは省いてます。

ディレクトリツリー
.
├── firebase.json
├── functions     # Firebase Functions ルート
│   ├── index.js
│   ├── node_modules
│   ├── nuxt    # ビルド (src/.nuxtをコピーしたもの)
│   ├── package-lock.json
│   ├── package.json
│   └── yarn.lock
├── public    # Firebase Hosting ルート
└── src       # Nuxtプロジェクトルート
    ├── .nuxt
    ├── .babelrc
    ├── .editorconfig
    ├── .eslintrc.js
    ├── .gitignore
    ├── assets
    ├── components
    ├── layouts
    ├── middleware
    │   └── authenticated.js
    ├── node_modules
    ├── nuxt.config.js
    ├── package.json
    ├── pages
    ├── plugins
    │   ├── firebase-admin.js
    │   └── firebase.js
    ├── server
    ├── static
    ├── store
    └── test

nuxtのプロジェクトディレクトリとFirebase Functionsのディレクトリは、上記のように分けたほうがメンテもしやすいかと思います。

src/package.json
  "scripts": {
    "build": "nuxt build; rm -rf ../functions/nuxt; cp -R .nuxt ../functions/nuxt" ←これ
  },
  "dependencies": {
    "@fortawesome/fontawesome-free-webfonts": "^1.0.9",
    "@nuxtjs/axios": "^5.3.6",
    "@nuxtjs/pwa": "^2.6.0",
    "@nuxtjs/vuetify": "^0.5.5",
    "cookie-parser": "^1.4.4",
    "cookieparser": "^0.1.0",
    "cross-env": "^5.2.0",
    "express": "^4.16.4",
    "express-session": "^1.16.1",
    "firebase": "^5.10.0",
    "firebase-admin": "^7.3.0",
    "firebaseui": "^3.6.0",
    "firebaseui-ja": "^1.0.0",
    "js-cookie": "^2.2.0",
    "nuxt": "^2.4.0",
    "vuetify": "^1.5.5",
    "vuetify-loader": "^1.2.1",
    "vuex-persistedstate": "^2.5.4"
  }

packageについては、ポイントはbuildスクリプトで自動化している部分です。
srcディレクトリ内でビルドした後にfunctionsにコピーしていますが、functionsに直接ビルドするやり方では eslint 関連の何かでこけました。
functions配下に .eslintrc.js があれば良いようですが、それはそれで管理が面倒そうなので、まるっとビルドディレクトリをコピーする方式にしてます。

firebase-admin はローカルで run dev してテストする用に入れてます。初っ端からビルドして firebase serve で検証する場合はfunctionsの方にだけあれば良いですが、run dev と比較すると開発スピードが段違いに遅くなるので、基本的にこちらを選ぶと思います。

functions/package.json
  "dependencies": {
    "@fortawesome/fontawesome-free-webfonts": "^1.0.9",
    "@nuxtjs/axios": "^5.3.6",
    "@nuxtjs/pwa": "^2.6.0",
    "@nuxtjs/vuetify": "^0.5.5",
    "cookie-parser": "^1.4.4",
    "cookieparser": "^0.1.0",
    "cross-env": "^5.2.0",
    "eslint-config-prettier": "^4.1.0",
    "eslint-plugin-prettier": "^3.0.1",
    "express": "^4.16.4",
    "express-session": "^1.16.1",
    "firebase": "^5.10.0",
    "firebase-admin": "^7.3.0",
    "firebase-cookie-session": "^3.0.0",
    "firebase-functions": "^2.2.0",
    "firebaseui": "^3.6.0",
    "firebaseui-ja": "^1.0.0",
    "js-cookie": "^2.2.0",
    "nuxt": "^2.4.0",
    "prettier": "^1.17.0",
    "vuetify": "^1.5.5",
    "vuetify-loader": "^1.2.1",
    "vuex-persistedstate": "^2.5.4"
  }

package.jsonはfunctions配下にも置く必要があり、こちらはfirebase-functionsパッケージを入れておく必要があります。
dependanciesを丸コピした後yarn、さらにfirebase-functionsをyarn addでOKでした。

サンプルコード

plugins

plugins/firebase.js
import firebase from 'firebase'

if (!firebase.apps.length) {
  firebase.initializeApp({
    apiKey: 'your API Key',
    authDomain: 'your auth Domain',
    databaseURL: 'your database URL',
    projectId: 'your Project ID',
    storageBucket: 'your storage bucket',
    messagingSenderId: 'your messaging sender id'
  })
}

export default firebase

ここは基本、その他の方々が残している情報と同じです。
【参考】Firebase を JavaScript プロジェクトに追加する

plugins/firebase-admin.js
if (process.server) {
  var admin = require('firebase-admin')
  if (!admin.apps.length) {
    var serviceAccount = require('./path/to/your/key.json')
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
      databaseURL: 'https://your-database-url.firebaseio.com'
    })
  }
}

export default admin

サーバ側で実行されている場合のみ、firebase-admininitializeApp() します。
firebase-admin はNode上でしか動作しないため、クライアント側では import admin from 'firebase-admin' を実行することができません。このため、process.server の判定後にインポートを行なっています。
【参考】サーバーに Firebase Admin SDK を追加する

nuxt.config.js
  plugins: [
    "@/plugins/firebase",
    "@/plugins/firebase-admin"
  ]

components/layouts

components 配下にディレクトリを切ってコンポーネント化し、pagesはそれを読み込むだけの構成にしてますので、componentだけ晒します。こちらのコンポーネントが/account/loginで呼ばれていると思って読んでください。

components/FirebaseUI/index.vue
<template>
  <div id="firebaseui-auth-container" />
</template>

<script>
import firebase from 'firebase'
import Cookies from 'js-cookie'

export default {
  mounted() {
    const _this = this
    const auth = firebase.auth()
    auth.onAuthStateChanged( (user) => {
      if (!user) {
        const firebaseui = require('firebaseui-ja')
        const ui = firebaseui.auth.AuthUI.getInstance() || new firebaseui.auth.AuthUI(auth)
        const authProviders = {
          Google: firebase.auth.GoogleAuthProvider.PROVIDER_ID,
          Email: {
            provider: firebase.auth.EmailAuthProvider.PROVIDER_ID,
            signInMethod: firebase.auth.EmailAuthProvider.EMAIL_LINK_SIGN_IN_METHOD
          }
        }
        const config = {
          signInOptions: [
            authProviders.Email,
            authProviders.Google
          ],
          signInSuccessUrl: '/',
          tosUrl: '/tos',
          privacyPolicyUrl: '/privacy-policy',
          callbacks: {
            signInSuccessWithAuthResult: function (authResult) {
              authResult.user.getIdToken(true).then((token) => {
                Cookies.set('__session', token)
                return true
              })
            }
          }
        }
        ui.start('#firebaseui-auth-container', config)
      } else {
        user.getIdToken(true).then((token) => {
          Cookies.set('__session', token)
          _this.$router.push({name: 'index'})
        })
      }
    })
  }
}
</script>

クライアント側の認証の流れは以下です。

  • firebase.auth().onAuthStateChanged() で現在Firebaseにログインしているかどうか確認
  • user が帰ってきた場合、ログイン中なので user.getIdToken() でトークンを取得してCookieにセットしてリダイレクト
  • userがnullの場合、未ログインユーザなのでUIを表示してログイン → ログイン完了したらトークンを取得してCookieにセットしてリダイレクト

一番重要なポイントですが、 Cookieは「__session」というkeyでしか保存できません
これはCloud FunctionsでSSRを実現する上での制約で、これ以外のkeyでCookieを保存しようとしても保存されないので注意してください。(これで2〜3日悩まされました)
【参考】Cloud Functions による動的コンテンツの配信

その他気をつけたいポイントとしては、 firebaseui はNode上で実行することができないため、トップレベルでimportすると window is not defined のエラーがサーバから帰ってきます。このため require を使って mounted 中でインポートしています。

FirebaseUIの利用方法については、公式のREADMEが一番充実してました。
【参考】GitHub firebase/firebaseui-web

middleware

middleware/authenticated.js
export default (({ req, redirect }) => {
  if (process.server) {
    var admin = require('firebase-admin')
    const cookieparser = require('cookieparser')
    if (req.headers.cookie) {
      const token = cookieparser.parse(req.headers.cookie).__session
      admin.auth().verifyIdToken(token).then(() => {
      }).catch((error) => {
        console.error(error)
        redirect('/account/login')
      })
    } else {
      redirect('/account/login')
    }
  }
})
layouts/default.js
<script>
export default {
  middleware: 'authenticated'
}
</script>

middlewareでは、クライアントからのリクエストを元にサーバ側でユーザーを認証し、認証に通らなければ/account/loginにリダイレクトさせています。
これを実現した方法としては、コンテキストの req.headers.cookie からセットしたCookieの情報が取得できますので、 cookieparser でパースして __session に含まれるJWTトークンを展開、トークンを admin.auth().verifyIdToken() で検証して、問題がなければ何もしないという流れです。

このmiddlewareの部分が初学者としては相当曲者でした。
はじめはユーザ情報をvuexでstoreして、storeからユーザ情報を呼ぼうとしていたのですが、いくら console.log() しても undefined しか出力されず・・・。クライアント側のstoreをサーバ側から呼ぼうとしているので出なくて当たり前ですね。

  • クライアントにstoreが保存できない! → vuex-persistedstate なるものがある、これでいこう! → undefined
  • Cookieに書けばいいと言う情報がある。uidをCookieに書いて読ませよう! → Cookieどうやってサーバで受け取んねん? → てかそもそもCookie保存できてへん?
  • もうmiddlewareに頼らず、クライアント側でFirebaseSDK使って全部処理させればいいや! → ログイン検証する前に時間差でページ一瞬でるのどげんかせんといかんばってん

と右往左往しかなり悩みました。
時間はかかりましたが、SSRの考え方を身に着けるいい切っ掛けになったように思います。

終わりに

フロントエンドもろくに経験がないにも関わらず、カッコよさに憧れてFirebase×SSRに手を出し四苦八苦しましたが、得るものは大きかったように思います。

Firebaseについては、従来のようなRDBとWebフレームワークを利用するやり方とは一線を画す手軽さで利用できるところが大変素晴らしいです。とりわけ面倒な認証関連をUI含めて丸投げでき、しかも外部ID連携までついているのは神としか言えません。
NuxtはSPAでもSSRでも静的ページでもなんでも対応できるところは非常に良いフレームワークだと思いました。PVが増えてくればSSR×Functions一本ではコストが嵩むリスクを孕んでいるでしょうが、ぶっちゃけSSRの場合、Functionsが担っているのはただリクエストを右から左に流す役目だけなので、SSRの部分だけ乗り換えるのは結構簡単にできそうだなという印象です。
この2つをしっかりモノにすれば、アイデアをどんどん形にしていけそうだなと実感できたこの頃でした。

最後に、これまでQiita含め諸先生方の記事に大変助けられ壁を乗り越えることができております。
折角アカウントを作りましたので、今後も何かあれば随時記事を書き、少しでも還元していけたらと思います。
拙い記事ではありますが、ご精読有難うございました。コメントや助言、いつでも大歓迎です。


2019/5/2追記
「Service Workerを使う実装がオススメ」というコメントを頂きましたので、早速実践してみました。
正直、こちらの方が良さそうですので、第二弾の記事を併せてご覧ください。
Service Workerをサポートしていないブラウザまで範囲を広げるかどうかで、Cookieと使い分けると良さそうですね。

73
70
1

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
73
70

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?