Nuxt.jsとPassport.jsでマイクロサービス用の認証機能をもったフロントエンド + BFFを構築する

## 2019/02/27 追記
SSR(サーバーサイドレンダリング)を利用した方が実装しやすそうなので、SSRを利用する形の~~記事も書く予定です。~~
そんなに差分なかったので、ブランチとして実装しておきました。
SSR版は[こちら](https://github.com/reireias/nuxt-bff-sample/tree/ssr)

変更点

- mode: spaを削除
- storeに`nuxtServerInit`を追加
- 未認証時のリダイレクトロジックを修正

## tl;dr
- マイクロサービスのフロントエンドとBFF(Backends for Frontends)をNuxt.js + Express.jsで実装したかった
- Express.js側で認証するのが都合が良かったのでPassport.jsを採用した
- リロード時にsessionからログイン情報を再取得するように実装してみた

## 構成
構成は下記のようになります。

- Nuxt.jsのserverMiddlewareとしてExpress.jsを組み込む
- `/api`以下のパスはExpress.jsへルーティングし、他はNuxt.jsのSPAで画面を表示する
- 認証のパスは`/api/auth`以下に実装し、Express.js上のPassport.jsで行う
- Express.jsをBFFとし、他のマイクロサービスとの通信を行う想定とする
![node_bff.png](https://qiita-image-store.s3.amazonaws.com/0/214526/05572ab2-3a93-83b0-d049-78372ea3458d.png)



## 方針
- フロントエンドにはNuxt.jsを利用
- なるべくNuxt.jsのフレームワーク、モジュールを利用する
- BFFとして利用するために、サーバーサイドで認証

## 環境
- node 11.9.0
- yarn 1.13.0
- nuxt 2.4.0
- express 4.16.4
- passport 0.4.0

## サンプルコード
https://github.com/reireias/nuxt-bff-sample

## プロジェクトの作成
まずは、[Create Nuxt App](https://github.com/nuxt/create-nuxt-app)を利用してプロジェクトを作成します。

npxでもyarnでも実行可能ですが、この記事ではyarnを利用します。
プロジェクト名は`nuxt-bff-sample`としています。

```bash
 $ yarn create nuxt-app nuxt-bff-sample
```

対話形式で行うプロジェクトの設定は下記のように行います。

```
? Project name nuxt-bff-sample
? Project description microservice front + BFF sample app.
? Use a custom server framework none
? Choose features to install Linter / Formatter, Axios
? Use a custom UI framework none
? Use a custom test framework none
? Choose rendering mode Single Page App
? Author name reireias
? Choose a package manager yarn
```

作成したプロジェクトのディレクトリへ移動し、アプリをサーバーをdevelopment環境として起動してみます。
(Nuxt.jsはdevelopment環境ではホットリロードが有効になっており、ソースファイルが更新されると自動でブラウザ側に反映されるようになっています)

```
$ cd nuxt-bff-sample
$ yarn dev
```

起動したら http://localhost:3000 へアクセスしてみましょう。
![nuxt-01.png](https://qiita-image-store.s3.amazonaws.com/0/214526/b538622d-4920-ffac-6647-192cd987e411.png)


## GitHub認証の準備
GitHubを利用したOAuth認証を行うためにGitHub側で設定を作成します。

1. GitHubのOAuth application作成ページを開きます。
  - https://github.com/settings/applications/new

2. 下記画像を参考に各項目を設定します。
  - Application name: 任意
  - Homepage URL: 任意
  - Application description: 任意
  - Authentication callback URL: http://localhost:3000/callback
![github02.png](https://qiita-image-store.s3.amazonaws.com/0/214526/298cfe59-3f1d-a419-f6ca-b4af6cd9c248.png)

3. Register applicationボタンをクリックし、アプリケーションを作成します。
4. 作成後、**Client ID**と**Client Secret**が表示されるので、控えておきましょう。

## serverMiddlewareの設定
BFFとセッション管理を行うexpressサーバーを追加します。
Nuxt.jsのフレームワーク内で管理したいので、serverMiddlewareとして設定します。

まず、`express`をプロジェクトへ追加します。

```bash
$ yarn add express
```

次に、`server`ディレクトリを作成し、`index.js`ファイルを下記のように実装します。

```js:server/index.js
import express from 'express'

const app = express()

app.get('/hello', (req, res) => {
  res.send('world')
})

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

`nuxt.config.js`を修正してserverMiddlewareを設定します。

```js:nuxt.config.js
...
  serverMiddleware: [
    '~/server'
  ],
...
```

これらの実装を追加することで、http://localhost:3000/api 以下のリクエストが`server/index.js`で処理されるようになります。

サーバーを起動し、http://localhost:3000/api/hello へアクセスしてみましょう。
worldと表示されるはずです。

## Passport.jsによる認証の実装
expressサーバー上でGitHubのOAuth認証を扱うために[Passport.js](https://github.com/jaredhanson/passport)を導入します。

(サーバーサイドで簡単にユーザー情報を取得できるようにするため、Nuxt.jsの[auth-module](https://github.com/nuxt-community/auth-module)ではなく、Passport.jsを採用しています)

まずは必要なモジュールをプロジェクトに追加します。

```bash
$ yarn add express-session passport passport-github2 body-parser
```

次にサーバーサイドを実装していきます。
概ねPassport.jsのドキュメントにしたがって実装しています。
`GITHUB_CLIENT_ID`と`GITHUB_CLIENT_SECRET`は環境変数から受け取るように実装しています。

```js:server/index.js
import express from 'express'
import session from 'express-session'
import passport from 'passport'
import { Strategy } from 'passport-github2'
import bodyParser from 'body-parser'

const app = express()

// requestでjsonを扱えるように設定
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json())

// sessionの設定
app.use(session({
  secret: process.env.SESSION_SECRET || 'secret',
  resave: true,
  saveUninitialized: true,
  cookie: {
    secure: 'auto'
  }
}))

// Passport.jsの設定
app.use(passport.initialize())
app.use(passport.session())

passport.use(new Strategy(
  {
    clientID: process.env.GITHUB_CLIENT_ID,
    clientSecret: process.env.GITHUB_CLIENT_SECRET,
    callbackURL: 'http://localhost:3000/callback'
  },
  (accessToken, refreshToken, profile, done) => {
    process.nextTick(() => {
      return done(null, profile)
    })
  }
))

passport.serializeUser((user, done) => {
  done(null, {
    id: user.id,
    name: user.username,
    avatarUrl: user.photos[0].value
  })
})
passport.deserializeUser((obj, done) => {
  done(null, obj)
})

app.get('/auth/login', passport.authenticate('github', { scope: ['user:email'] }))
app.get('/auth/callback',
  passport.authenticate('github'),
  (req, res) => {
    res.json({ user: req.user })
  }
)
app.get('/auth/logout', (req, res) => {
  req.logout()
  res.redirect('/')
})

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

続いて`page/index.vue`(ページ的には http://localhost:3000/ )にログインリンクを表示するように修正します。
遷移先はサーバーサイドのログインURLである`/api/auth/login`を指定しています。

```vue:page/index.vue
<template>
  <section class="container">
    <div>
      <logo />
      <h1 class="title">
        nuxt-bff-sample
      </h1>
      <h2 class="subtitle">
        microservice front + BFF sample app.
      </h2>
      <div class="links">
        <a
          href="/api/auth/login"
          class="button--green"
        >Login with GitHub</a>
      </div>
    </div>
  </section>
</template>

...
```

次はログイン後のユーザー情報を格納するvuexのstoreを実装します。
storeに格納することで、SPAの任意のページからこの`user`オブジェクトへのアクセスが可能となります。

```js:store/index.js
export const state = () => ({
  user: null,
  auth: false
})

export const mutations = {
  login(state, payload) {
    state.auth = true
    state.user = payload
  },
  logout(state) {
    state.auth = false
    state.user = null
  }
}
```

続いて、callbackページの実装です。
ここはGitHubにCallback URLとして設定したパスに相当します。
mountedでページが読み込まれたら、express側の`/api/auth/callback`を呼び出し、ユーザーデータをPassportから取得し、上で用意したvuexのstoreに格納します。

```vue:page/callback.vue
<template>
  <p>callback</p>
</template>

<script>
export default {
  data() {
    return {
      user: null
    }
  },
  async mounted() {
    const res = await this.$axios.get('/api/auth/callback', {
      params: this.$route.query
    })
    const user = {
      id: res.data.user.id,
      name: res.data.user.username,
      avatarUrl: res.data.user.photos[0].value
    }
    this.$store.commit('login', user)
    this.$router.push('/home')
  }
}
</script>
```

最後にログイン時しか表示できないHomeページを実装します。
`fetch`で認証状態をチェックすることで、未認証時はルートへリダイレクトしています。
また、ログアウトボタンも実装しています。

```vue:page/home.vue
<template>
  <div>
    <h1>Home</h1>
    <p>name: {{ $store.state.user.name }}</p>
    <img :src="$store.state.user.avatarUrl" width="100px" height="100px">
    <br>
    <a
      href="/api/auth/logout"
      class="button--green"
    >Logout</a>
  </div>
</template>

<script>
export default {
  fetch({ store, redirect }) {
    // 未認証の場合、リダイレクトする
    if (!store.state.auth) {
      return redirect('/')
    }
  }
}
</script>
```

さて、ここまでの実装でサーバーを起動してみましょう。
GitHubのClient IDとCrient Secretを環境変数から取得すように実装したので、下記のコマンドで起動します。

```bash
$ GITHUB_CLIENT_ID=<your_client_id> GITHUB_CLIENT_SECRET=<your_client_secret> yarn dev
```

起動できたら、http://localhost:3000 へアクセスし確認してみましょう。
下記画像のように表示されているでしょうか？

1. ルートページ  
   ![login_01.png](https://qiita-image-store.s3.amazonaws.com/0/214526/ce233a7e-6db1-c17b-8a21-60045a49da0d.png)
2. OAuth認証ページ  
   ![login_02.png](https://qiita-image-store.s3.amazonaws.com/0/214526/9bad13d1-f9da-b94e-b1a8-f634eac9215c.png)
3. ログイン後の`/home`ページ  
  ![login_04.png](https://qiita-image-store.s3.amazonaws.com/0/214526/16418661-6e2e-6dee-a86e-3ce6974dc910.png)


ログアウトしてから、URL直指定でhttp://localhost:3000/home へアクセスすると、未認証なので`/`へリダイレクトされることが確認できます。

## リロード時にsessionからユーザー情報をvuexのstoreに格納
さて、ログインした状態でhttp://localhost:3000/home でページをリロードすると何が起こるでしょうか？
現在の実装では、リロード後、未認証とみなされ、`/`へリダイレクトされてしまいます。
これはvuex(Nuxt.js)のstoreに格納してあるデータがリセットされるためです。

storeのデータをlocalStorageに保存する方法もあるのですが、セキュリティの観点からあまりよろしくない方法なので、サーバーサイドのsessionからユーザー情報を取得するように修正していきます。

まずはサーバーサイド側にsessionからuserのオブジェクトを取得して返すapiを追加します。

```js:server/index.js
...
app.get('/session', (req, res) => {
  res.json({ user: req.user })
})
...
```

次に、未認証状態であった場合、上記apiを利用してサーバーサイドのsessionからuser情報を取得する機能をmiddlewareとして追加します。
Nuxt.jsのmiddlewareは(ざっくり言うと)任意の処理を各ページ内で行うように設定することが可能な機能です。

```js:middleware/session.js
import axios from 'axios'

export default async ({ store, route, redirect }) => {
  // 認証済みの場合は何もしない
  if (store.state.auth) {
    return
  }

  if (route.path !== '/callback') {
    // サーバーのsessionからuser情報を取得する
    const res = await axios.get('/api/session')
    if (res.data.user) {
      store.commit('login', res.data.user)
      if (route.path === '/') {
        return redirect('/home')
      }
    } else if (route.path !== '/') {
      // 無限リダイレクトにならないように、パスが"/"の場合は何もしない
      return redirect('/')
    }
  }
}
```

最後に`nuxt.config.js`にて、上記のmiddlewareを登録します。

```js:nuxt.config.js
...
  router: {
    middleware: 'session'
  },
...
```

では実際に確認してみましょう。

1. http://localhost:3000 にアクセスし、ログインする
  - `/home`にリダイレクトされる
2. ブラウザをリロードする
  - 先ほどは`/`へリダイレクトされてしまったが、今回の修正で`/home`のままである

以下も確認できるはずです。

- cookieを削除してリロードすると、未認証と判定され`/`へリダイレクトされる
- 認証済みの状態で`/`へアクセスすると`/home`へリダイレクトされる

## BFFの実装
`server/index.js`以下に実装していきます。
axiosを用いた普通の実装になると思われるので、今回は割愛します。

## 所感
Node.jsで簡単なマイクロサービスを構築してみようと思ったら、意外とフロントエンドと認証とBFFの部分で悩んだんので記事にしてみました。
「こういったやり方もある」とか「こうした方がいい」とかあればコメントに書いていただけると参考になります。
たぶんセキュリティ的にもっと考慮すべきポイントがありそうです。