Nuxt.js + Express + Passport での ユーザー認証

imatomix
2018年10月6日 0:00

概要

  • ざっくり以下をやる
  • サーバーサイド
  • ログイン機能
  • ログアウト機能
  • セッション管理
  • API 処理内でのログインユーザーからのリクエストかどうかの認証
  • フロント
  • ログイン後、ストアでログインユーザー情報の保持
  • ストア情報をもとにフロント内でのログインユーザーかどうかの認証
  • 自分でもよくわからなくなるので、振り返りのためにまとめるが、わかりやすくまとめる方法もよくわからないので、見返す度にちょくちょく書き直して行く。

環境

Passport

Node.js のための認証ミドルウェア。認証リクエストをおこなうための必要最低限の機能をもつように設計されているとのこと。確かに簡単で使いやすかった。

認証設定

基本公式通り。まず、以下を行う。
  • 認証用ストラテジー設定
  • セッション管理設定

認証用ストラテジー設定

passport-local を使用して、ユーザ名とパスワードによるローカル認証設定を行う。
const LocalStrategy = require('passport-local').Strategy; passport.use( new LocalStrategy((username, password, done) => { // username と password で認証を確認して結果を返す }));
認証結果は以下のように返す
  • 認証が成功した時
return done( null, user )
  • 認証が失敗した時
return done( null, false, { message: メッセージをつけることも可能です。})
  • エラー時
return done( error )

セッション管理設定

セッションを管理するために Passport で以下のことを行う。
  • serializeUserにて、ユーザー情報 ( ここではID ) をシリアライズしてセッションに埋め込む。
  • deserializeUserにて、リクエスト受取り時にIDからユーザーを特定し、req.user 内に格納する。
// シリアライズ passport.serializeUser((user, done) => { done(null, user.id) }) // デシリアライズ passport.deserializeUser((id, done) => { User.findById(id, (err, user) => { done(err, user); }) })

まとめると、こんな感じ

server/passport_auth.js
import db from './models' // Sequelize const LocalStrategy = require('passport-local').Strategy module.exports = (passport) => { passport.serializeUser((user, done) => { done(null, user.id) }) passport.deserializeUser((id, done) => { db.users .findOne({ where: { id: id } }) .then(user => { done(null, user) }) .catch(error => { done(error, null) }) }) passport.use( new LocalStrategy((username, password, done) => { db.users .findOne({ where: { username: username } }) .then(user => { if (!user) { return done(null, false, { message: 'アカウント名が正しくありません。' }) } if (!user.authenticate(password)) { return done(null, false, { message: 'パスワードが正しくありません。' }) } return done(null, user) }) .catch(error => { return done(error) }) }) ) }

Express

API周り

以下のようにする。
  • /login に POST でログイン
  • /logout に GET でログアウト
  • /me にGETでログインユーザー(自分)の情報を取得

ログイン

passport.authenticate にてユーザー認証を実行し、レスポンスを返す
passport.authenticate('local', (err, user, info) => { if (err) { return next(err) } if (!user) { // userが存在しない return res.status(401).send(info) } req.login(user, (err) => { if (err) { return next(err) } return res.status(200).json(user) }) })(req, res, next)

ログアウト

req.logout() すればいいだけ、とてもシンプル。
req.logout() res.status(200).send()

その他の認証

APIアクセス時に、リクエストに対してreq.isAuthenticated()で認証済みかどうか確認できる
router.get('/me', checkAuthentication, (req, res) => { return res.status(200).json(req.user) }) function checkAuthentication(req, res, next) { if (req.isAuthenticated()) { next() // 認証済みなら進む } else { res.status(204).send() // 認証されてないなら 204 (No Content) を返す } }

まとめると、こんな感じ

api/v1/auth.js
import { Router } from 'express' import passport from 'passport' const router = Router() router.post('/login', (req, res, next) => { passport.authenticate('local', (err, user, info) => { if (err) { return next(err) } if (!user) { return res.status(401).send(info) } req.login(user, (err) => { if (err) { return next(err) } return res.status(200).json(user) }) })(req, res, next) }) router.get('/logout', (req, res) => { req.logout() res.status(200).send() }) router.get('/me', checkAuthentication, (req, res) => { return res.status(200).json(req.user) }) function checkAuthentication(req, res, next) { if (req.isAuthenticated()) { next() } else { res.status(204).send() } } export default router

その他サーバーサイド

Passport の初期化

初期化のために以下のメソッドを実行する必要がある。
  • passport.initialize() :Passport の初期化
  • passport.session() :ログイン後のセッション管理
  • 正しい順番でログインセッションを処理するために passport.session()express.session() よりも後に記述する

諸々の読み込みと一緒にまとめると、こんな感じ

server/index.js
import express from 'express' import cookieParser from 'cookie-parser' import bodyParser from 'body-parser' import session from 'express-session' import passport from 'passport' import { Nuxt, Builder } from 'nuxt' import api from './api/v1' import db from './models' const app = express() app.use(express.static('public')) app.use(cookieParser()) app.use(bodyParser.json()) app.use( session({ secret: 'your secret keyword' }) ) app.use(passport.initialize()) app.use(passport.session()) // Setup DB db.sequelize .authenticate() .then(() => { console.log('Connection has been established successfully.') }) .catch(err => { console.error('Unable to connect to the database:', err) }) // Setup Passport require('./passport_auth')(passport) // API Routes app.use('/api/v1', api) // Build Nuxt const config = require('../nuxt.config.js') const nuxt = new Nuxt(config) // 開発環境の場合にライブビルドとライブリロードを有効化 if (nuxt.options.dev) { new Builder(nuxt).build() } app.use(nuxt.render)

Nuxt.js

ストア ( Vuex )

ログインしたらログインユーザーの必要な情報をストアに保持する

ステート

ストアが保持するオブジェクト
  • loginUserにログインユーザーの情報をいれて行く。
  • デフォルトは null
const state = { loginUser: null }

ゲッター

ストアの状態を取得する。
  • isAuthenticated()で認証済みかどうかを true/false で返すようにする
  • loginUserでログインユーザー情報を返すようにする
const getters = { isAuthenticated (state) { return !!state.loginUser }, loginUser(state) { return state.loginUser } }

ミューテーション

Vuex のストアの状態を変更処理を行う。
  • ログイン時にloginUserにユーザー情報を入れる
  • ログアウト時はloginUsernullを入れる
const mutations = { login (state, user) { state.loginUser = user }, logout (state) { state.loginUser = null } }

アクション と nuxtServerInit

アクションはミューテーションと似ていて、使わなくてもいい場合もある。ただ、以下の点でミューテーションとは異なる。
  • アクションは、状態を変更するのではなく、ミューテーションをコミットするもの。
  • アクションは非同期処理を含むことができる。
  • ログイン後、必要なログインユーザーのデータを取得し、ミューテーションをコミットするようにする。
  • Nuxtの場合、nuxtServerInit() に任意の処理を追加できる。
  • nuxtServerInit() アクションは引数でコンテキストを渡して呼び出されるので、アプリケーションがロードされたときは、サーバーから取得できるデータが既にストアに入っている状態にできるようになる。
  • なのでreq.userから、必要なミューテーションをコミットする。
const actions = { nuxtServerInit({ commit }, { req }) { if (req.user) { return commit('login', req.user) } return commit('logout') }, fetchLogin({ commit }) { return new Promise((resolve, reject) => { axios .get('/me') .then(response => { if (response.status === 200) { commit('login', response.data) } else { commit('logout') } resolve() }) .catch(error => { reject(error) }) }) } }

ストアの処理をまとめると、こんな感じ

store/auth.js
import axios from '~/plugins/axios' const state = { loginUser: null } const getters = { isAuthenticated (state) { return !!state.loginUser }, loginUser(state) { return state.loginUser } } const mutations = { login (state, user) { state.loginUser = user }, logout (state) { state.loginUser = null } } const actions = { nuxtServerInit({ commit }, { req }) { if (req.user) { return commit('login', req.user) } return commit('logout') }, fetchLogin({ commit }) { return new Promise((resolve, reject) => { axios .get('/me') .then(response => { if (response.status === 200) { commit('login', response.data) } else { commit('logout') } resolve() }) .catch(error => { reject(error) }) }) } } export default { state, getters, mutations, actions }

認証middleware

ページ遷移前に、フロント側でそのページを表示する or リダイレクトする の認証処理を行うミドルウェアを用意する。 処理の内容は簡単
  • ストアのゲッターで定義したisAuthenticated ()からリダイレクトするかどうかを決めるだけ
  • 例えば、ログイン画面などで、ログイン済み場合はトップ画面へリダイレクトさせる処理は以下のようになる
middleware/authenticated.js
export default function ({ store, route, redirect }) { if (store.getters.isAuthenticated) { return redirect('/') } }

ログイン画面

画面を組んで、これまでに用意したものを使うだけ
login.vue
<template> <article class="layout-login"> <section id="login"> <form @submit.prevent="authenticate"> <input id="username" name="username" placeholder="Username" type="text" autocomplete="username" v-model="auth.username"> <input id="password" name="password" placeholder="Password" type="password" autocomplete="current-password" v-model="auth.password"> <div class="error" v-if="message">{{message}}</div> <input id="submit" name="submit" type="submit" value="Login"> </form> </section> </article> </template> <script> import axios from '~/plugins/axios' export default { middleware: 'authenticated', // 認証ミドルウェアを使用 data () { return { auth: { username: '', password: '', remember: true }, message: null } }, methods: { authenticate () { axios .post('login', this.auth) // ログインAPIへ入力情報をPOST .then(response => { this.$store.dispatch('fetchLogin') // ログインできたらユーザー情報を取得し、ストアに保持する }) .catch(error => { if (error.response.status === 401) { this.message = error.response.data.message } console.error(error.message) }) } } } </script>

これで完成。かと思いきや。。。

上記だけではまだ不十分だった。以下の場合で不具合が起きる。
  • 内部で認証が必要なAPIを、フロントの asyncData() 内で叩く時、そのページへのアクセスが、URL直打ちやページ上のリロード時は以下の理由で認証されない。
  • req.user が存在しない。
  • req.isAuthenticated() も false なる。
これを回避するために、リロード時のasyncData()での APIリクエストにもユーザー情報を加える。 やるのは以下の通り
  • nuxtServerInit()内で、req.userがいれば、axios のヘッダ情報に リクエストヘッダのクッキー情報 req.headers.cookieを入れる
nuxtServerInit({ commit }, { req }) { if (req.user) { axios.defaults.headers.common.cookie = req.headers.cookie return commit('login', req.user) } return commit('logout') },

余談

  • nuxt-community/express-templateでは、上記の処理用の middleware を作成し、nuxt.config.jsrouter に登録しているが、このやり方だと、ogp が正しく取得できなくなるので要注意。
以上。もう少しうまくまとめられるようになりたいところです。