NuxtJS+socketioで認証フィルタをはさむ(passport)

BoskampiによるPixabayからの画像 NuxtJS
BoskampiによるPixabayからの画像
この記事は約11分で読めます。

NuxtJSでサーバ側はexpress、websocketを使うのにsocket.ioを使っていて、ユーザー認証をpassportで行なう場合の覚書き。

今回試すこと

axiosを使ってサーバAPIでログインし、その後socket.ioでwebsocketに接続する。やりたいことは、サーバAPIで開始したセッションをsocket.ioで引き継ぎ利用することである。

これには、socket.ioのミドルウェアを使って開始済みのセッションを読み込めば、socket.ioのリクエストでセッションを扱える。気をつける点は、socket.ioのミドルウェア関数インターフェースがexpressのそれと異なっているので、正しく情報を引き継げるようミドルウェアをつないでやることである。具体的には、socket.ioのドキュメントにもあるとおり以下のようにすればよい。

const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);

socket.ioのロード

今回は、NuxtJSのmoduleのlistenフックを使ってsocket.ioサーバをnuxtのhttpサーバ に統合する。serverMiddlewareで、/apiにexpressのappをマウントする。また、Nuxtのlistenフックを使ってsocket.ioサーバを統合するための設定をmodulesに定義する。

// nuxt.config.js

  serverMiddleware: [{ path: '/api', handler: '~/server/index.js' }],

  // Modules: https://go.nuxtjs.dev/config-modules
  modules: [
    // https://go.nuxtjs.dev/axios
    '@nuxtjs/axios',
    '~/modules/socket-io.js',
  ],
// ~/modules/socket-io.js
import { attachSocketIO } from '../server/io'

export default function () {
  this.nuxt.hook('listen', (server, listener) => {
    attachSocketIO(server)
  })
}

サーバ側

今回は動作検証のため、セッションはメモリストアを使っている。socket.ioとセッションストアを共有するため、共通の定義を使うようにしている。

server/index.js

import express from 'express'
import passport from 'passport'
import LocalStrategy from 'passport-local'
import session from './session'

const app = express()

// 今回はDBを使わず簡易のリストデータを使う
const USERS = [
  {
    username: 'username1',
    password: 'password1',
  },
]

// セッションのMemoryStoreはsocket.ioと共有する
app.use(session)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
app.use(passport.initialize())
app.use(passport.session())
passport.use(
  new LocalStrategy(function (username, password, done) {
    const user = USERS.find(
      (data) => data.username === username && data.password === password
    )
    if (!user) {
      return done(null, false)
    }

    return done(null, { username: user.username })
  })
)

passport.serializeUser(function (user, done) {
  done(null, user)
})
passport.deserializeUser(function (user, done) {
  done(null, user)
})

app.post(
  '/login',
  passport.authenticate('local', {
    successRedirect: '/',
    failureRedirect: '/',
  }),
  (req, res, next) => {
    res.json(req.user)
  }
)

app.post('/logout', (req, res, next) => {
  req.logout()
  res.redirect('/')
})

app.get('/session', (req, res, next) => {
  res.json(req.user)
})

export default app

server/session.js

import session from 'express-session'

const ses = session({ secret: 'secret', resave: true, saveUninitialized: true })
export default ses

server/io.js

import socketIO from 'socket.io'
import passport from 'passport'
import session from './session'

// connect io middleware to express middleware
const wrap = (middleware) => (socket, next) =>
  middleware(socket.request, {}, next)

export function attachSocketIO(server) {
  const io = socketIO(server, { serveClient: false })

  // セッションのMemoryStoreはexpressと共通にする
  io.use(wrap(session))
  io.use(wrap(passport.initialize()))
  io.use(wrap(passport.session()))

  io.use((socket, next) => {
    if (socket.request.user) {
      next()
    } else {
      next(new Error('unauthorized'))
    }
  })

  io.on('connection', (socket) => {
    socket.emit('echo', {
      message: 'hello',
    })
  })

  return io
}

クライアント側

クライアント側は、ログインしてセッション確認するだけのためのインターフェースにする。以下のような感じ。

ログインしていない状態の時。

ログインしてsocket.ioにつながった時。

Viewテンプレートはveutifyを使う。

pages/index.vue

NuxtJSは、Universalモードで動かしているのでSSRを行なう。sessionは、asyncDataフックを使ってコンポーネントのdataに統合する。

<template>
  <v-app>
    <v-main>
      <v-card class="ma-5 pa-5" width="400" flat>
        <v-text-field
          v-model="username"
          label="username"
          required
        ></v-text-field>
        <v-text-field
          v-model="password"
          label="password"
          required
        ></v-text-field>
        <v-btn :disabled="!loggedIn" @click="submit"> ログイン </v-btn>

        <v-alert
          class="my-5"
          :type="socket && socket.connected ? 'success' : 'error'"
          dense
        >
          {{ socketStatus }}
        </v-alert>

        <v-dialog v-model="dialog" width="300">
          <pre class="pa-5">{{ session }}</pre>
        </v-dialog>
        <v-btn text :disabled="loggedIn" @click="getSession">session</v-btn>
        |
        <v-btn :disabled="loggedIn" text @click="logout">logout</v-btn>
      </v-card>
    </v-main>
  </v-app>
</template>

<script>
import { io } from 'socket.io-client'

export default {
  async asyncData({ app }) {
    const session = await app.$axios.$get('/api/session')
    return { session }
  },

  data() {
    return {
      dialog: false,
      username: 'username1',
      password: 'password1',
      socket: null,
      socketStatus: 'disconnected',
      session: false,
    }
  },

  computed: {
    loggedIn() {
      return !this.session
    },
  },

  mounted() {
    if (this.session) {
      this.connectWs()
    }
  },

  methods: {
    async submit() {
      await this.$axios
        .$post('/api/login', {
          username: this.username,
          password: this.password,
        })
        .then((user) => {
          this.session = user
          if (this.socket && this.socket.connected) {
            this.socket.close()
          }
          this.connectWs()
        })
    },

    async logout() {
      await this.$axios.$post('/api/logout')
      if (this.socket) {
        this.socket.close()
      }
      this.session = false
    },

    async getSession() {
      this.session = await this.$axios.$get('/api/session')
      this.dialog = true
    },

    connectWs() {
      this.socket = io({ transports: ['websocket'] })
      this.socket.on('connect_error', (data) => {
        this.socketStatus = data.message
      })
      this.socket.on('echo', (data) => {
        this.socketStatus = data.message
      })

      this.socket.on('disconnect', (reason) => {
        this.socketStatus = 'disconnected'
        if (reason === 'io server disconnect') {
          this.socket.connect()
        }
      })
    },
  },
}
</script>

検証用プログラムは以下

GitHub - moritetu/nuxt-with-socket.io-passport: sample nuxt program with socket.io and passport
sample nuxt program with socket.io and passport. Contribute to moritetu/nuxt-with-socket.io-passport development by crea...

参考リンク

コメント

タイトルとURLをコピーしました