2019/02/27 追記
SSR(サーバーサイドレンダリング)を利用した方が実装しやすそうなので、SSRを利用する形の記事も書く予定です。
そんなに差分なかったので、ブランチとして実装しておきました。
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とし、他のマイクロサービスとの通信を行う想定とする
方針
- フロントエンドには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
サンプルコード
プロジェクトの作成
まずは、Create Nuxt Appを利用してプロジェクトを作成します。
npxでもyarnでも実行可能ですが、この記事ではyarnを利用します。
プロジェクト名はnuxt-bff-sample
としています。
$ 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 へアクセスしてみましょう。
GitHub認証の準備
GitHubを利用したOAuth認証を行うためにGitHub側で設定を作成します。
- GitHubのOAuth application作成ページを開きます。
- 下記画像を参考に各項目を設定します。
- Application name: 任意
- Homepage URL: 任意
- Application description: 任意
- Authentication callback URL: http://localhost:3000/callback
- Register applicationボタンをクリックし、アプリケーションを作成します。
- 作成後、Client IDとClient Secretが表示されるので、控えておきましょう。
serverMiddlewareの設定
BFFとセッション管理を行うexpressサーバーを追加します。
Nuxt.jsのフレームワーク内で管理したいので、serverMiddlewareとして設定します。
まず、express
をプロジェクトへ追加します。
$ yarn add express
次に、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を設定します。
...
serverMiddleware: [
'~/server'
],
...
これらの実装を追加することで、http://localhost:3000/api 以下のリクエストがserver/index.js
で処理されるようになります。
サーバーを起動し、http://localhost:3000/api/hello へアクセスしてみましょう。
worldと表示されるはずです。
Passport.jsによる認証の実装
expressサーバー上でGitHubのOAuth認証を扱うためにPassport.jsを導入します。
(サーバーサイドで簡単にユーザー情報を取得できるようにするため、Nuxt.jsのauth-moduleではなく、Passport.jsを採用しています)
まずは必要なモジュールをプロジェクトに追加します。
$ yarn add express-session passport passport-github2 body-parser
次にサーバーサイドを実装していきます。
概ねPassport.jsのドキュメントにしたがって実装しています。
GITHUB_CLIENT_ID
とGITHUB_CLIENT_SECRET
は環境変数から受け取るように実装しています。
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
を指定しています。
<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
オブジェクトへのアクセスが可能となります。
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に格納します。
<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
で認証状態をチェックすることで、未認証時はルートへリダイレクトしています。
また、ログアウトボタンも実装しています。
<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を環境変数から取得すように実装したので、下記のコマンドで起動します。
$ GITHUB_CLIENT_ID=<your_client_id> GITHUB_CLIENT_SECRET=<your_client_secret> yarn dev
起動できたら、http://localhost:3000 へアクセスし確認してみましょう。
下記画像のように表示されているでしょうか?
ログアウトしてから、URL直指定でhttp://localhost:3000/home へアクセスすると、未認証なので/
へリダイレクトされることが確認できます。
リロード時にsessionからユーザー情報をvuexのstoreに格納
さて、ログインした状態でhttp://localhost:3000/home でページをリロードすると何が起こるでしょうか?
現在の実装では、リロード後、未認証とみなされ、/
へリダイレクトされてしまいます。
これはvuex(Nuxt.js)のstoreに格納してあるデータがリセットされるためです。
storeのデータをlocalStorageに保存する方法もあるのですが、セキュリティの観点からあまりよろしくない方法なので、サーバーサイドのsessionからユーザー情報を取得するように修正していきます。
まずはサーバーサイド側にsessionからuserのオブジェクトを取得して返すapiを追加します。
...
app.get('/session', (req, res) => {
res.json({ user: req.user })
})
...
次に、未認証状態であった場合、上記apiを利用してサーバーサイドのsessionからuser情報を取得する機能をmiddlewareとして追加します。
Nuxt.jsのmiddlewareは(ざっくり言うと)任意の処理を各ページ内で行うように設定することが可能な機能です。
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を登録します。
...
router: {
middleware: 'session'
},
...
では実際に確認してみましょう。
- http://localhost:3000 にアクセスし、ログインする
-
/home
にリダイレクトされる
- ブラウザをリロードする
- 先ほどは
/
へリダイレクトされてしまったが、今回の修正で/home
のままである
以下も確認できるはずです。
- cookieを削除してリロードすると、未認証と判定され
/
へリダイレクトされる - 認証済みの状態で
/
へアクセスすると/home
へリダイレクトされる
BFFの実装
server/index.js
以下に実装していきます。
axiosを用いた普通の実装になると思われるので、今回は割愛します。
所感
Node.jsで簡単なマイクロサービスを構築してみようと思ったら、意外とフロントエンドと認証とBFFの部分で悩んだんので記事にしてみました。
「こういったやり方もある」とか「こうした方がいい」とかあればコメントに書いていただけると参考になります。
たぶんセキュリティ的にもっと考慮すべきポイントがありそうです。