Firebase
のAuthentication
を使用しての認証機能の実装方法の紹介になります。
完成したプロジェクトは以下のリポジトリになります。
https://github.com/TAC/nuxt-firebase-auth-example
Firebaseの設定
最初にFirebase
の設定を行います。
Firebase
のアカウント作成や設定などは他のサイトなどで確認して下さい。
接続情報は.env/development.js
に記載してnuxt.config.ts
で読み込むようにします。
これはリポジトリに入れていないので、上記リポジトリを clone した場合は自分で作成してください。
.env/development.js
module.exports = {
apiKey: "<your apiKey>",
authDomain: "<your authDomain>",
databaseURL: "<your databaseURL>",
projectId: "<your projectId>",
storageBucket: "<your storageBucket>",
messagingSenderId: "<your api messagingSenderId>"
}
app/nuxt.config.ts
import NuxtConfiguration from '@nuxt/config'
import pkg from './package.json'
import envSet from '../.env/environment.js'
const nuxtConfig: NuxtConfiguration = {
mode: 'universal',
srcDir: 'src',
env: envSet,
...
}
設定値はprocess.env.apiKey
という形式で利用できます。
次にFirebase
の初期設定を行うplugins
を作成します。
app/src/plugins/firebase.js
import firebase from 'firebase/app'
if (!firebase.apps.length) {
firebase.initializeApp({
apiKey: process.env.apiKey,
authDomain: process.env.authDomain,
databaseURL: process.env.databaseURL,
projectId: process.env.projectId,
storageBucket: process.env.storageBucket,
messagingSenderId: process.env.messagingSenderId
})
}
export default firebase
FirebaseSDKを使用する箇所でこのプラグインを読み込んで使うことになります。
認証モジュール
Vuex.Store
に認証モジュールを作成します。
app/src/store/modules/auth.ts
import {
VuexModule,
Module,
action,
getRawActionContext
} from 'vuex-class-component'
import firebase from '@/plugins/firebase'
import 'firebase/auth'
@Module({ namespacedPath: 'modules/auth/', target: 'nuxt' })
class Store extends VuexModule {
@action({ mode: 'raw' })
public async signIn(provider) {
const context = getRawActionContext(this)
context.commit('models/users/UNSET_USER', null, { root: true })
await firebase
.auth()
.signInWithPopup(provider)
.then(result => {
if (result.user) {
context.commit('models/users/SET_USER', result.user, { root: true })
}
})
.catch(error => {
console.error(error)
throw error
})
}
@action()
public async signInGoogle() {
const provider = new firebase.auth.GoogleAuthProvider()
await this.signIn(provider)
}
@action({ mode: 'raw' })
public async signOut() {
const context = getRawActionContext(this)
await firebase
.auth()
.signOut()
.then(() => {
context.commit('models/users/UNSET_USER', null, { root: true })
})
}
@action({ mode: 'raw' })
public isSignIn() {
const context = getRawActionContext(this)
return new Promise(resolve => {
const unsubscribe = firebase.auth().onAuthStateChanged(user => {
unsubscribe()
if (user) {
context.commit('models/users/SET_USER', user, { root: true })
}
resolve(user || false)
})
})
}
}
export default Store.ExtractVuexModule(Store)
渡されたプロバイダーの認証を行う signIn
アクションと認証データを削除する signOut
アクション、サインイン状態の確認を行う isSignIn
アクションがあります。
プロバイダーに firebase.auth.GoogleAuthProvider()
を渡すとGoogleアカウントの認証ができます。
これを signInGoogle
アクションとして作成しておきます。
他にもTwitterやFacebookが認証プロバイダーとして用意されています。
https://firebase.google.com/docs/auth/web/start#next_steps
認証データの保存
firebase.auth().onAuthStateChanged()
で取得できるユーザデータを Vuex.Store
に保存しておきます。
最初、ユーザデータをそのまま Vuex.Store
に保存しようとしたのですが、エラーで動かなかったので修正しました。
参考記事 : Firebase AuthenticationとVuexの合わせ技バグでハマった
app/src/store/models/users.ts
import { VuexModule, Module, getter, mutation } from 'vuex-class-component'
interface User {
[key: string]: any
}
@Module({ namespacedPath: 'models/users/', target: 'nuxt' })
class Store extends VuexModule {
@getter user: User | null = null
get isAuthenticated() {
return !!this.user
}
@mutation
public SET_USER(payload) {
this.user = {
uid: payload.uid,
displayName: payload.displayName,
email: payload.email,
emailVerified: payload.emailVerified,
isAnonymous: payload.isAnonymous,
phoneNumber: payload.phoneNumber,
photoURL: payload.photoURL,
providerData: payload.providerData
}
}
@mutation
public UNSET_USER() {
this.user = null
}
}
export default Store.ExtractVuexModule(Store)
認証のテスト
FirebaseSDKをモック化してくれるモジュールをインストールします。(※ただし、このモジュールは残念なことに現在メンテされていません。)
yarn add -D firebase-mock
セットアップ手順をもとに以下のファイルを作成します。
app/tests/__mocks__/plugins/firebase.js
import firebasemock from 'firebase-mock'
const mockauth = new firebasemock.MockAuthentication()
const mockdatabase = new firebasemock.MockFirebase()
const mockfirestore = new firebasemock.MockFirestore()
const mockstorage = new firebasemock.MockStorage()
const mockmessaging = new firebasemock.MockMessaging()
const mocksdk = new firebasemock.MockFirebaseSdk(
// use null if your code does not use RTDB
(path) => {
return path ? mockdatabase.child(path) : mockdatabase
},
// use null if your code does not use AUTHENTICATION
() => {
return mockauth
},
// use null if your code does not use FIRESTORE
() => {
return mockfirestore
},
// use null if your code does not use STORAGE
() => {
return mockstorage
},
// use null if your code does not use MESSAGING
() => {
return mockmessaging
}
)
export default mocksdk
@/plugins/firebase
の読み込みがモックになるように jest.config.js
で設定します。
app/jest.config.js
moduleNameMapper: {
'^vue$': 'vue/dist/vue.common.js',
+ '^@/plugins/firebase$': '<rootDir>/tests/__mocks__/plugins/firebase.js',
'^@/(.*)$': '<rootDir>/src/$1',
'^~/(.*)$': '<rootDir>/src/$1'
},
これで Jest での実行時には @/plugins/firebase
の読み込みがすべてモックになります。
次に store/modules/auth.ts
のテストを書いていきます。
app/tests/store/modules/auth.test.js
import 'firebase/auth'
import Vuex from 'vuex'
import { cloneDeep } from 'lodash'
import { createLocalVue } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import firebase from '@/plugins/firebase'
import auth from '@/store/modules/auth.ts'
import users from '@/store/models/users.ts'
const localVue = createLocalVue()
localVue.use(Vuex)
describe('store/modules/auth.ts', () => {
let store
let user
beforeAll(() => {
firebase.auth().autoFlush()
})
beforeEach(() => {
// create store
store = new Vuex.Store({
modules: {
'modules/auth': cloneDeep(auth),
'models/users': cloneDeep(users)
}
})
// mock data
user = {
uid: 'alice',
email: 'alice@mail.com'
}
// initialize auth
firebase
.auth()
.createUser(user)
.then(user => {
firebase.auth().changeAuthState(user)
})
})
describe('actions', () => {
test('isSignIn', async () => {
await store.dispatch('modules/auth/isSignIn')
const result = store.getters['models/users/user']
expect(result).toMatchObject(user)
})
test('signOut', async () => {
await store.dispatch('modules/auth/signOut')
const result = store.getters['models/users/user']
expect(result).toBeNull()
})
test('signInGoogle', async () => {
firebase.auth().signInWithPopup = jest.fn(provider => {
return new Promise(resolve => {
user.providerData = [provider]
resolve({
user
})
})
})
await store.dispatch('modules/auth/signInGoogle')
await flushPromises()
const result = store.getters['models/users/user']
expect(result.providerData[0]).toMatchObject({
providerId: 'google.com'
})
})
})
})
ポイントとしては以下の2点になります。
beforeEach()
で初期化時にfirebase.auth().autoFlush()
を呼び出し、firebase.auth().createUser()
でモック用の認証データを作成しておくfirebase.auth().signInWithPopup
をモック化する
firebase.auth().autoFlush()
を呼び出すことで firebase-mock
でモック化された firebase.auth()
での変更が反映されるようになります。
また、firebase.auth().signInWithPopup
はすでにモックになってますが、期待通りの動きはしてくれません。
なので、正しく動作する形に再度モック関数を設定し直しています。
認証ボタンの作成
最後に作成したアクションを呼び出すボタンを pages/index.vue
に配置します。
app/src/pages/index.vue
<template lang="pug">
section.container
template(v-if='isAuthenticated')
ButtonsAction(@action='signOut')
template(v-slot:buttonText) SignOut
template(v-else)
ButtonsAction(@action='signInGoogle')
template(v-slot:buttonText) Sign-In Google
</template>
<script lang="ts">
import { Component, Vue, namespace } from 'nuxt-property-decorator'
import ButtonsAction from '@/components/buttons/Action.vue'
const Users = namespace('models/users')
const Auth = namespace('modules/auth')
@Component({
components: {
ButtonsAction
}
})
export default class extends Vue {
@Users.Getter('user') user
@Users.Getter('isAuthenticated') isAuthenticated
@Auth.Action('signInGoogle') signInGoogle
@Auth.Action('signOut') signOut
}
</script>
これで Sign-In Google
のボタンが表示され、押すとGoogleアカウント認証のウィンドウがポップアップされると思います。
そのウィンドウで任意のアカウントを選択すると認証が完了します。(GoogleChromeで認証済みの場合はパスワードの入力がスキップされると思います。)
認証が完了するとボタンが SignOut
に変わりユーザIDと名前が表示されるかと思います。
SSRに対応する
これでサインイン、サインアウトの実装は終わったのですが、サインインした状態でリロードするとボタンの表示やユーザ情報の表示がちらつくのに気づくと思います。
これはSSR時に認証情報が取得できないためです。
Firebase Authentication では認証情報を IndexedDB
に保存しているため、IndexedDB
にアクセスできないサーバ側の処理では認証情報を取得できないのです。
そこでサーバ側の処理でも認証情報を取得できるように cookie
に認証トークンを保存するように変更します。
Cookieの保存
まずは以下のモジュールを追加します。
yarn add cookie-universal-nuxt jwt-decode core-js@2.6.7
cookie-universal-nuxt
はフロント側でもサーバ側でも簡単に cookie
にアクセスできるようにしてくれるモジュールです。$cookies
をコンテキストに追加してくれます。
jwt-decode
はJWTトークンをデコードしてくれるそのまんまのモジュールです。Firebase Authentication で使う認証トークンがJWTトークンなのでこれでデコードします。
core-js
は後述するシリアライズ処理で必要になります。
(Nuxt.js 2.8.1 での問題かもしれませんが追加しないとエラーが出ます。)
[追記:2019-09-28]
上記の対応は正しくなかったようです。
詳しくはこちらの記事で説明しています。
次に cookie
を操作するヘルパー関数を作成します。
app/src/helpers/cookies.ts
const _OPTIONS = {
path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: true
}
export function getCookie(cookies, name) {
const data = deserialize(cookies.get('__session'))
return data[name]
}
export function setCookie(cookies, name, value, isLocalhost) {
const data = deserialize(cookies.get('__session'))
data[name] = value
_OPTIONS.secure = !isLocalhost
cookies.set('__session', serialize(data), _OPTIONS)
}
export function removeCookie(cookies, name, isLocalhost) {
const data = deserialize(cookies.get('__session'))
delete data[name]
_OPTIONS.secure = !isLocalhost
cookies.set('__session', serialize(data), _OPTIONS)
}
function serialize(obj) {
try {
const str = JSON.stringify(obj, function replacer(k, v) {
if (typeof v === 'function') {
return v.toString()
}
return v
})
return str
} catch (e) {
return {}
}
}
function deserialize(str) {
try {
const obj = JSON.parse(str, function reciever(k, v) {
if (typeof v === 'string' && v.startsWith('function')) {
return Function.call(this, 'return ' + v)()
}
return v
})
return obj
} catch (e) {
return {}
}
}
注意点は __session
という名前で保存する必要がある点です。Firebaseでホスティングする場合、これ以外の名前の cookie
は削除されてしまいます。
https://firebase.google.com/docs/hosting/manage-cache#using_cookies
__session
以外の名前が使えないので、この中に連想配列をシリアライズして登録します。
認証トークンの保存
以下に記載する箇所で認証トークンの保存と読み込みを行います。
app/src/pages/index.vue
private signInGoogle() {
const that = this as any
this.authSignInGoogle().then(() => {
const currentUser = firebase.auth().currentUser
if (currentUser) {
currentUser.getIdToken().then(token => {
setCookie(that.$cookies, 'token', token, that.$isLocalhost)
})
}
})
}
private signOut() {
const that = this as any
this.authSignOut().then(() => {
removeCookie(that.$cookies, 'token', that.$isLocalhost)
})
}
サインインした時に認証トークンを取得して保存、サインアウトした時に保存してある認証トークンを削除します。
app/src/plugins/auth.js
import firebase from '@/plugins/firebase'
import 'firebase/auth'
import { setCookie } from '@/helpers/cookies'
export default async function({ store, app }) {
await store.dispatch('modules/auth/isSignIn')
const currentUser = firebase.auth().currentUser
if (currentUser) {
currentUser.getIdToken().then(token => {
setCookie(app.$cookies, 'token', token, app.$isLocalhost)
})
}
}
認証プラグインを作成して、リロード時に認証トークンが保存されるようにします。
app/src/store/index.ts
import jwtDecode from 'jwt-decode'
import { getCookie } from '@/helpers/cookies'
export const actions = {
nuxtServerInit: ({ commit }, { app }) => {
const token = getCookie(app.$cookies, 'token')
if (token) {
commit('models/users/SET_USER', jwtDecode(token))
}
}
}
Vuex.store
の nuxtServerInit
処理時に保存してある認証トークンからユーザデータを取得します。(今回はユーザデータをFirestoreなどから取得していないのでこのような形ですが、サーバ側からデータ取得する際には認証トークンの検証が必要になります。)
認証トークンから取得できるユーザデータが onAuthStateChanged
とは若干違うのでユーザデータの保存処理も修正します。(ちょっと無理やりですが・・・)
app/src/store/models/users.ts
@mutation
public SET_USER(payload) {
const uid = payload.uid !== undefined ? payload.uid : payload.user_id
const displayName =
payload.displayName !== undefined ? payload.displayName : payload.name
const emailVerified =
payload.emailVerified !== undefined
? payload.emailVerified
: payload.email_verified
const photoURL =
payload.photoURL !== undefined ? payload.photoURL : payload.picture
this.user = {
uid,
displayName,
email: payload.email,
emailVerified,
isAnonymous: payload.isAnonymous,
phoneNumber: payload.phoneNumber,
photoURL,
providerData: payload.providerData
}
}
これでサインインしたあとにリロードしてもボタン等がちらつかなくなったと思います。
まとめ
ざっくりとですが Firebase Authentication を使った認証の実装の一例を紹介しました。
Firebase は他にも便利な機能があるのでもっと紹介してきたいと思います!
Written with StackEdit.