はじめに
初めて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のディレクトリは、上記のように分けたほうがメンテもしやすいかと思います。
"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
と比較すると開発スピードが段違いに遅くなるので、基本的にこちらを選ぶと思います。
"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
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 プロジェクトに追加する
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-admin
を initializeApp()
します。
firebase-admin
はNode上でしか動作しないため、クライアント側では import admin from 'firebase-admin'
を実行することができません。このため、process.server
の判定後にインポートを行なっています。
【参考】サーバーに Firebase Admin SDK を追加する
plugins: [
"@/plugins/firebase",
"@/plugins/firebase-admin"
]
components/layouts
components
配下にディレクトリを切ってコンポーネント化し、pagesはそれを読み込むだけの構成にしてますので、componentだけ晒します。こちらのコンポーネントが/account/loginで呼ばれていると思って読んでください。
<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
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')
}
}
})
<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と使い分けると良さそうですね。