Nuxt.jsでfirestoreのデータ取得をSSRで行うには?~serverMiddleware編~



目的

前回の記事ではNuxt.jsでfirestoreのデータの取得をサーバーサイドで行う際、
firebase-adminモジュールを使用して直接firestoreのデータを取得していました。
今回はfirebaseモジュールのfirestoreでデータ取得をするのをserverMiddlewareを使うことで可能にしていきます。

この記事ではfirebase-adminを用いてセッションクッキーを用いてデータの取得を行っていました。
しかし実際はserverMiddleware上でsignInWithEmailAndPasswordを使ってログインすればfirestoreのデータの取得も可能です。
次回別の記事に書くかこの記事に訂正として追加する予定です。


環境

OS: Windows10 home
エディタ: Visual Studio Code


ディレクトリ構成

nuxt_project/
     ├ pages/
     │     ├ auth/
     │     │   └ signin.vue
     │     │   
     │     └ protected/
     │          ├ index.vue
     │          └ users.vue
     │   
     ├ server/
     │     ├ utils/
     │     │   └ serviceAccountKey.json 
     │     └ index.ts
     │
     └ nuxt.config.js

※今回扱うファイルのみ表示しています。

今回取得するデータ

f:id:fetchkun:20200518182748p:plain


今回ログインするアカウントをfirebaseに登録します。

firebaseのAuthenticationの画面のユーザ追加で追加してください。

f:id:fetchkun:20200518182813p:plain

f:id:fetchkun:20200518182826p:plain


サインインする機能をまず実装

画面の作成

pages/auth/signin.vue

<template>
  <section class="container">
    <div>
      <form @submit.prevent="signin">
        <label for="usernameTxt">Username:</label>
        <input id="usernameTxt" type="email" v-model="email">
        <label for="passwordTxt">Password:</label>
        <input id="passwordTxt" type="password" v-model="password">
        <button type="submit">sign in</button>
      </form>
      <button @click="cannot_get_data()">cannot get data</button>
      <br>
      {{ data }}
    </div>
  </section>
</template>

<script lang="ts">
import Vue from 'vue'
import { mapActions } from 'vuex'
import axios from 'axios'

export default Vue.extend({
  data () {
    return {
      email: '',
      password: '',
      data: []
    }
  },
  methods: {
    async signin () {
      await axios.post('http://localhost:3000/server/signin', {   // No1
        email: this.email,
        password: this.password
      })
      .then ((res: any) => {
        if (res.data == 'auth/user-not-found') {  // No2
          this.$router.push('/auth/signup')
        } else {
          this.$router.push('/protected')
        }
      })
    },
    async cannot_get_data () {
      axios.post('http://localhost:3000/server/users')
      .then ((res: any) => {
        this.data = res.data.concat()
      })
    }
  }
})
</script>

  • No1: ここではserverMiddlewareの/server/signinにアクセスする際にリクエストボディの中に使用するデータ[email, password]を含めて送信しています。

このデータを元に後に出てくるsignInWithEmailAndPasswordでサインインします。

  • No2: ここでは仮に、firebaseのAutenticationに登録されていないアカウントでサインイン使用とした際、

エラーのステータスコード'auth/user-not-found'が返ってきます。
なので'auth/user-not-found'が返ってきたときにはアカウントを登録する画面に遷移させます。※今回は作成していません。


pages/protected/index.vue

<template>
  <section>
    <div>
      Protected folder
    </div>
    <nuxt-link to="/protected/users">firestoreから取得したデータの一覧</nuxt-link>
    <div>
      <button v-on:click="signout">signout</button>
    </div>
  </section>
</template>

<script lang="ts">
import Vue from 'vue'
import axios from 'axios'

export default Vue.extend({
  methods: {
    async signout () {
      await axios.post('http://localhost:3000/server/signout')
      this.$router.push('/auth/signin')
    },
  },
})
</script>


pages/protected/users.vue

<template>
  <section>
    <div>
      {{ view }}
    </div>
    <div>
      <nuxt-link to="/protected">Protectedのメインページ</nuxt-link>
    </div>
  </section>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  async asyncData (context: any) {
    let items: any[] = []
    await context.$axios.post('http://localhost:3000/server/users')  // No3
    .then ((res: any) => {
      items = res.data.concat()
    })
    return {
      view: items
    }
  },
})
</script>

  • No3:ここではusersページに遷移する際、ページが生成される前にasyncDataから

/server/usersでデータを取得しています。

サインイン、データ取得はserverMiddlewareを使います。
今回、firebase-adminを用いるのでfirebase authenticationの秘密鍵を使うのですが、
使いかたは前回の「Nuxt.jsでfirestoreのデータ取得をSSRで行うには?」でまとめているのでそちらを参考にしてください。
今回は省略します。


server/index.ts

import * as fb from 'firebase/app'
import 'firebase/auth'
import 'firebase/firestore'
const cookieParser = require('cookie-parser')
const admin = require('firebase-admin')
const express = require('express')
const bodyParser = require('body-parser')
const app = express()
app.use(cookieParser())
app.use(bodyParser.json())

// firebaseの初期化
const firebase = fb.initializeApp({
  ~ 省略します ~
})

// firebase-adminの初期化
const serviceAccount = require('./utils/serviceAccountKey.json')
admin.initializeApp({
  ~ 省略します ~
})

// セッションクッキーの有効期限は1日に設定
const expiresIn = 60*60*24*1*1000

// ここでサインインをしてセッションクッキーを取得
app.post('/signin', (req: any, res: any) => {
  console.log('signin')
  const email = req.body.email
  const password = req.body.password
  const sessionCookie = req.cookies.__session

  firebase.auth().signInWithEmailAndPassword(email, password)
  .then(async (data: any) => {
    if (!sessionCookie) {
      const token = await data.user.getIdToken()
      admin.auth().createSessionCookie(token, {expiresIn})
      .then((sessionCookie: any) => {
        res.cookie('__session', sessionCookie, {maxAge: expiresIn})
        res.send(null)
      })
      .catch((error: Error) => {
        console.log('error: ', error)
        res.send(error)
      })
    } else {
      res.send(null)
    }
  })
  .catch((error) => {
    res.send(error.code)
  })
})

app.post('/signout', (req: any, res: any) => {
  console.log('signout')
  await firebase.auth().signOut()
  res.clearCookie('__session')
  res.send(null)
})

app.post('/users', async (req: any, res: any) => {
  console.log('users')
  const items: any[] = []
  await firebase.firestore().collection('users').get()
    .then((query: any) => {
      query.forEach((doc: any) => {
        const item = {
          id: doc.id,
          data: doc.data()
        }
        items.push(item)
      })
    })
  res.send(items)
})

module.exports = {
  path: '/server/',
  handler: app,
}


nuxt.config.js

export default {
  mode: 'universal',
  ~~~~~~~~~~~~~~~
  省略
  ~~~~~~~~~~~~~~
  buildModules: [
    '@nuxt/typescript-build',
    '@nuxtjs/dotenv',
  ],
  modules: [
    '@nuxtjs/axios',
  ],
  dotenv: {
    filename: `.env.${process.env.NODE_ENV}`
  },
  axios: {
  },
  serverMiddleware: [
    '~/server'                 <======= これを追加
  ],
}


動作の確認

以上で一通りのファイルが出来上がりました。
そしたら、ターミナルで今回作成したフォルダに移動し、下のコマンドを実行してください

PS C:\nuxt_project > npm run dev

http://localhost:3000/auth/signin
上のURLを開くと下の画面になるはずです。

f:id:fetchkun:20200518182913p:plain

今の段階でcannot get dataボタンを押すとまだログインしていないので
下のエラーが出るはず、これは前回の時にも見たエラーですね。

 ERROR  Missing or insufficient permissions.                                                 22:53:12

  at new FirestoreError (node_modules\@firebase\firestore\src\util\error.ts:166:5)
  at JsonProtoSerializer.fromRpcStatus (node_modules\@firebase\firestore\src\remote\serializer.ts:130:12)
  at JsonProtoSerializer.fromWatchChange (node_modules\@firebase\firestore\src\remote\serializer.ts:433:40)
  at PersistentListenStream.onMessage (node_modules\@firebase\firestore\src\remote\persistent_stream.ts:568:41)
  at node_modules\@firebase\firestore\src\remote\persistent_stream.ts:448:21
  at node_modules\@firebase\firestore\src\remote\persistent_stream.ts:501:18
  at node_modules\@firebase\firestore\src\util\async_queue.ts:358:14
  at runMicrotasks (<anonymous>)
  at processTicksAndRejections (internal/process/task_queues.js:93:5)

次にAuthenticationに追加したアカウントでログインしてみます。
すると下の画面になります。

f:id:fetchkun:20200518182937p:plain

そして、firestoreから取得したデータの一覧というリンクをクリックして画面遷移をすると
下の画面に取得したデータが表示されます。

f:id:fetchkun:20200518182953p:plain

また、ターミナル上にも下のように表示されているのでサーバーサイドでデータを取得することが
できていることが分かります。

/users:  [                 
  {                        
    id: 'a',               
    data: {                
      email: 'b@gmail.com' 
    }                      
  },                       
  {                        
    id: 'c',               
    data: {                
      email: 'd@gmail.com' 
    }                      
  }                        
]                          


まとめ

前回はfirebase-adminでデータを取得してみたのですが今回はfirebaseモジュールのfirestoreでサーバーサイドでデータを取得してみました。
今回の構成に関してとてもシンプルなものにしました。
そのためクロスサイト リクエスト フォージェリ(CSRF)攻撃
ID トークンが盗用された場合等のさまざまなセキュリティに関する問題には対応していません。
他にも今回の構成のままだとURLで直接protectedの画面に移動できてしまうのでその部分に関しては次の機会にまとめようと思います。