🙌

Nuxt3でセッションを使用したログイン認証機能を作る

2022/11/03に公開

はじめに

Nuxt3も安定してきたのでクッキーを使ってセッション管理するログイン認証機能を作ってみます。必要最小限ですがDBを使用した本格的な認証機能です。

主な仕様

主な仕様は以下のとおりです。

  • Nuxt3がベース
  • MongoDBによるユーザ情報(ログイン情報)の管理
  • Redisによるセッション情報の管理

動作環境

動作させるプラットフォームはWindows10です。WSL2も使用します。
デモでは以下のソフトウェアが必要です。

  • Node v16.0以上
  • MongoDB v6.0(WSL2にインストール)
  • Redis v7.0(WSL2にインストール)

デモ

簡単なデモプログラムを作成しました。GitHubのリポジトリをZipファイルでダウンロードしてください。

https://github.com/czbone/nuxt3-auth-demo

ディレクトリ構成

Zipを解凍すると以下の構成になっています。

.
├── .vscode
│   └── settings.json
├── components
│   └── SigninForm.vue
├── composables
│   ├── auth
│   │   ├── useAdmin.ts
│   │   ├── useAuth.ts
│   │   └── useAuthUser.ts
│   ├── auth.ts
│   └── useSigninDialog.ts
├── data
│   └── user.json
├── layouts
│   └── default.vue
├── middleware
│   └── route.global.ts
├── models
│   ├── types
│   │   └── role.js
│   ├── index.ts
│   └── user.ts
├── pages
│   ├── admin
│   │   └── index.vue
│   └── index.vue
├── plugins
│   └── auth.ts
├── server
│   ├── api
│   │   ├── admin
│   │   │   └── users
│   │   │       └── index.get.ts
│   │   └── auth
│   │       ├── login.post.ts
│   │       ├── logout.post.ts
│   │       └── me.get.ts
│   ├── middleware
│   │   ├── log.ts
│   │   └── session.ts
│   └── index.ts
├── server-middleware
│   └── ac.ts
├── types
│   └── user.ts
├── utils
│   ├── password.ts
│   ├── redis-session.js
│   └── session.ts
├── .env
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc
├── LICENSE
├── PowerShell起動.lnk
├── README.md
├── app.vue
├── nuxt.config.ts
├── package.json
├── tailwind.config.js
└── tsconfig.json

準備

1. Nodeインストール

WindowsインストーラをダウンロードしてNodeをインストールします。
https://nodejs.org/ja/download/

yarnコマンドもインストールしておきます。

npm install --global yarn

2. データインストール

data/user.jsonファイルを使用してMongoDBにユーザ情報をインストールします。sampleDBの部分は任意に変更しても問題ありません。

mongoimport --drop --db sampleDB --collection user --file user.json

3. MongoDB、Redisの接続情報を確認

MongoDBやRedisの接続先がlocalhostでない場合は、.envファイルのホスト名やポートNoを修正します。デフォルトの接続情報は以下になっています。

.env
SESSION_REDIS_URL=redis://localhost:6379/
MONGO_URL=mongodb://localhost:27017/sampleDB

実行

以下のコマンドで実行します。

yarn install
yarn dev

起動が正常に終わると以下のような表示が出ます。

Nuxi 3.0.0-rc.12                                                                                              23:16:41
Nuxt 3.0.0-rc.12 with Nitro 0.6.0                                                                             23:16:41
                                                                                                              23:16:50
  > Local:    http://localhost:3000/
  > Network:  http://172.22.144.1:3000/
  > Network:  http://172.24.0.1:3000/
  > Network:  http://192.168.1.10:3000/
  > Network:  http://192.168.139.1:3000/
  > Network:  http://192.168.241.1:3000/
  > Network:  http://192.168.33.1:3000/
  > Network:  http://192.168.56.1:3000/
  > Network:  http://[240d:2:d618:fe00:496c:42b8:77fc:d31b]:3000/
  > Network:  http://[240d:2:d618:fe00:d51a:ccde:e543:f35d]:3000/

i Using default Tailwind CSS file from runtime/tailwind.css                                  nuxt:tailwindcss 23:16:50
i Tailwind Viewer: http://[::]:3000/_tailwind/                                               nuxt:tailwindcss 23:16:50
                                                                                                              23:16:53
🌼 daisyUI components 2.33.0  https://github.com/saadeghi/daisyui                                             23:16:53
✔︎ Including:  base, components, themes[29], utilities                                                        23:16:53
                                                                                                              23:16:53
i Vite client warmed up in 2174ms                                                                             23:16:54
√ Nitro built in 936 ms                                                                                 nitro 23:16:55
=> Debug mode starting
MongoDB connection established.

DBに接続できないなど問題があった場合は以下のような表示が出ます。

# Redis接続エラー例
[ioredis] Unhandled error event: Error: connect ECONNREFUSED 127.0.0.1:6379

# MongoDB接続エラー例
MongoDB connection failed. MongooseServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017

正常に起動できれば、Webブラウザでhttp://localhost:3000にアクセスします。
正常にアクセスできるとコンソールには以下のような表示が続きます。

New request: /
New request: /api/auth/me

Webブラウザではホーム画面が表示されます。サインインの情報が表示されます。

ホーム画面上のサインインの情報を使って、右上のサインインボタンからサインインします。
サインアウトは右上のサインアウトボタンで行います。

管理者(admin@example.com)でサインインすると管理画面にアクセスできます。

アクセスできる画面はホーム管理画面の2画面です。画面上部中央のボタンで切り替えます。
ユーザは管理画面にアクセスできるユーザ(管理者)サインインのみのユーザ(一般ユーザ) の2種類です。サインインの状態はホーム画面に表示されます。

プログラム解説

プログラムのポイントをざっと解説します。

サーバ初期処理

Nuxt3構築されたWebアプリケーションはNitroというサーバエンジンで動作します。
サーバ起動時に1回だけサーバ初期処理を行います。サーバ初期処理ではRedis、MongoDBに対して初期処理を行います。サーバ初期処理のために、nuxt.config.tsNitroプラグイン(server/index.ts)を登録しておきます。

nuxt.config.ts
import { defineNuxtConfig } from 'nuxt'

export default defineNuxtConfig({
...省略...
  runtimeConfig: {
    sessionCookieName: process.env.SESSION_COOKIE_NAME || '__session',   // クッキー作成用シークレットコード
    sessionCookieSecret: process.env.SESSION_COOKIE_SECRET || 'secret',
    sessionExpires: parseInt(process.env.SESSION_EXPIRES || '60 * 30', 10), // 30分
    sessionIdPrefix: process.env.SESSION_ID_PREFIX || 'sess:', // Redisセッション保存用セッションIDプレフィックス
    sessionRedisUrl: process.env.SESSION_REDIS_URL || 'redis://localhost:6379/',

    mongoUrl: process.env.MONGO_URL || 'mongodb://localhost:27017',
  },
  nitro: {
    plugins: [
      "~/server/index.ts"
    ]
  },
...省略...
})
server/index.ts
import Redis from 'ioredis'
import mongoose from 'mongoose'
import RedisSession from '~/utils/redis-session.js'
const config = useRuntimeConfig()

export default async (_nitroApp: Nitro) => {
...省略...
	// セッション管理用のRedisクライアント作成
	const redis = new Redis(config.sessionRedisUrl)
	const redisSession = new RedisSession({ client: redis, ttl: config.sessionExpires })
	_nitroApp.session = redisSession

	// MongoDB初期接続
	try {
		await mongoose.connect(config.mongoUrl)
		console.log('MongoDB connection established.')
	} catch (err) {
		console.error('MongoDB connection failed.', err)
	}
}

1. サーバ定義値

nuxt.config.tsで設定されているサーバ定義値(runtimeConfig)は、.envファイルの内容が優先されます。.envに値がなければ、nuxt.config.tsのデフォルト値が使用されます。

2. Redis初期処理

セッション管理用のRedisオブジェクトはRedisアクセス時に必要になります。Nitroインスタンスに設定するとうまくいきました。Nitroオブジェクトのsessionプロパティに格納しておきます。

_nitroApp.session = redisSession

セッション情報をやり取りする場所では、useNitroApp()でNitroオブジェクトを取得してRedisオブジェクトを使用します。

utils/session.ts
import cookieSignature from 'cookie-signature'
import type { CompatibilityEvent } from 'h3'

...省略...
export async function getSession(event: CompatibilityEvent) {
	const app = useNitroApp()
	const config = useRuntimeConfig()

	// クッキー取得
	const cookie = useCookies(event)[config.sessionCookieName]
	if (!cookie) return null

	// セッションID取得
	const unsignedSession = unsign(cookie, config.sessionCookieSecret)
	if (!unsignedSession) return null

	// セッション情報取得
	const user = await app.session.get(config.sessionIdPrefix + unsignedSession)
	if (user) {
		// セッションクッキーの有効期限更新
		setCookie(event, config.sessionCookieName, cookie, {
			httpOnly: true,
			path: '/',
			sameSite: 'strict',
			secure: process.env.NODE_ENV === 'production',
			expires: new Date(Date.now() + config.sessionExpires * 1000)
		})

		// セッション情報の有効期限更新
		await app.session.touch(config.sessionIdPrefix + unsignedSession)
	}
	return user
}

3. MongoDB初期処理

MongoDBへの接続はmongooseモジュールを使用します。接続オブジェクトを意識しないでDBに接続できるインターフェイスなので初期接続だけ行います。

セッション管理

セッション管理のしくみはPHPなどと同じ一般的な方法で行っています。ログインに成功すると、ユーザ情報を結びつけたセッションIDを生成し、セッションIDが入ったクッキー(セッションクッキー)をクライアントに発行してセッション管理する方法です。
PHPではセッションの情報をデフォルトでファイルに保存していますが、Redisでセッション情報を管理しています。Redisではデータ単位でデータの有効期限が設定でき、期限が来ると自動削除されます。使用していないデータは残らない利点があります。

1. セッションの作成・破棄

クライアント(Webブラウザ)からコンポーザブルを使用して、サーバAPI経由でユーザのログイン・ログアウト処理を行います。ログイン・ログアウト時にRedisのセッション情報セッションクッキーの作成、破棄が行われます。セッションはセッション情報(サーバ側)とセッションクッキー(クライアント側)の両者で維持されます。

ログイン時の処理です。
クライアントから送信されたアカウント(Eメール)とパスワードがDBのユーザ情報に合致すれば、RedisにセッションIDがキーのセッション情報を追加し、セッションIDのクッキーをクライアントに発行します。

server/api/login.post.ts
import { v4 as uuidv4 } from 'uuid'
import { User } from '~/models'
import { verify } from '~/utils/password'
import { sign } from '~/utils/session'

export default defineEventHandler(async (event) => {
	const body = await useBody<{ email: string; password: string; rememberMe: boolean }>(event)

	const { email, password, rememberMe } = body

	if (!email || !password) {
		return createError({
			statusCode: 400,
			message: 'Email address and password are required'
		})
	}

	// ユーザ情報取得
	const userWithPassword = await User.getUserByEmail(email)
	if (!userWithPassword) {
		return createError({
			statusCode: 401,
			message: 'Bad credentials'
		})
	}

	// ユーザ認証
	const verified = await verify(password, userWithPassword.password)
	if (!verified) {
		return createError({
			statusCode: 401,
			message: 'Bad credentials'
		})
	}

	const config = useRuntimeConfig()
	const sessionId = uuidv4()
	const signedSessionId = sign(sessionId, config.sessionCookieSecret)

	// クッキー作成
	setCookie(event, config.sessionCookieName, signedSessionId, {
		httpOnly: true,
		path: '/',
		sameSite: 'strict',
		secure: process.env.NODE_ENV === 'production',
		expires: new Date(Date.now() + config.sessionExpires * 1000)
	})

	// セッション作成
	const app = useNitroApp()
	await app.session.set(config.sessionIdPrefix + sessionId, {
		id: userWithPassword.id,
		email: userWithPassword.email,
		name: userWithPassword.name,
		role: userWithPassword.role
	})

	return {
		user: {
			id: userWithPassword.id,
			email: userWithPassword.email,
			name: userWithPassword.name,
			role: userWithPassword.role
		}
	}
})

ログアウト時の処理です。
Redisのセッション情報セッションクッキーを削除します。

server/api/logout.post.ts
import { unsign } from '~/utils/session'

export default defineEventHandler(async (event) => {
	const app = useNitroApp()
	const config = useRuntimeConfig()

	// セッションID取得
	const cookie = useCookies(event)[config.sessionCookieName]
	if (cookie) {
		const unsignedSession = unsign(cookie, config.sessionCookieSecret)
		if (unsignedSession) {
			// セッション破棄
			await app.session.destroy(config.sessionIdPrefix + unsignedSession)
		}
	}

	// クッキー破棄
	deleteCookie(event, config.sessionCookieName, {
		httpOnly: true,
		path: '/',
		sameSite: 'strict',
		secure: process.env.NODE_ENV === 'production'
	})

	return {
		user: null
	}
})

2. セッションの更新

HTTPリクエストが来るたびにRedisのセッション情報セッションクッキーの有効期限を更新しています。Redisのセッション情報とクッキーの有効期限は同じにしています。
sessionサーバミドルウェア(server/middleware/session.ts)から実行されます。

utils/session.ts
...省略...
		// セッションクッキーの有効期限更新
		setCookie(event, config.sessionCookieName, cookie, {
			httpOnly: true,
			path: '/',
			sameSite: 'strict',
			secure: process.env.NODE_ENV === 'production',
			expires: new Date(Date.now() + config.sessionExpires * 1000)
		})

		// セッション情報の有効期限更新
		await app.session.touch(config.sessionIdPrefix + unsignedSession)
...省略...

3. ユーザ認証

セッションが維持されている状態とはユーザが認証されている状態です。
このときクライアントが認証状態を取得する処理フローは以下の図のようになります。太い矢印はデータの流れです。
クライアントからHTTPリクエストがあると、最初にsessionサーバミドルウェアセッションクッキーを確認します。
ユーザ認証されイベントコンテキストuserにユーザ情報が入っていると、サーバAPIからデータが取得できます。authコンポーザブルにデータが入って、クライアント側で自分自身の情報やアクセス権限が参照できるようになります。
ユーザ認証で不正の場合はイベントコンテキストuserにユーザ情報を格納しないので、サーバAPIからのデータ取得はできません。

ユーザ認証を行うsessionサーバミドルウェアとデータ配信を行うサーバAPIの異なる機能が、イベントコンテキストuserで連携するしくみになっています。

プラグインコンポーザブルが実行される場合は、サーバ側で初期処理を行い、その後クライアント側での処理が行われます。
authコンポーザブルは初期処理でサーバAPIのフェッチ処理($fetch('/api/auth/me'))でユーザ情報を取得します。サーバ側での$fetch()の実行は、実際にはHTTPリクエストを使用しないサーバ内部でのAPI直接呼び出しです。API直接の呼び出しですが、HTTPリクエストの場合と同じようにサーバミドルウェアを経由します。
デフォルトでは$fetch()は実行時にクッキーが付加されていません。クッキーを付加してsessionサーバミドルウェアでユーザ認証が通るようにします。
以下のようにクッキーを付加してフェッチ処理を行います。

composables/auth/useAuth.ts
import { useAuthUser } from './useAuthUser'

export const useAuth = () => {
	const authUser = useAuthUser()

	const setUser = (user: any) => {
		authUser.value = user
	}

	const setCookie = (cookie: any) => {
		cookie.value = cookie
	}
	
...省略...
	const me = async () => {
		if (!authUser.value) {
			try {
				const data = await $fetch('/api/auth/me', {
					headers: useRequestHeaders(['cookie']) as HeadersInit
				})
				setUser(data.user)
			} catch (error) {
				setCookie(null)
			}
		}
		return authUser
	}

	return {
		login,
		logout,
		me
	}
}

アクセス制御

厳密にはユーザの権限でデータのアクセス制御ができる場所はサーバAPIのみです。
すべてのページコンポーネントはクライアントにあらかじめダウンロードされるので、制限すべきデータはページに静的に組み込むべきではありません。
管理画面のコンテンツはサーバAPIで取得し、管理画面の表示制御自体はリダイレクトで制御することにしました。
アクセス制御は以下の2パターンで行うこととします。

  1. サーバAPIのディレクトリで制御
  2. ページコンポーネント(vueファイル)のディレクトリで制御

1. サーバAPIのアクセス制御

acサーバミドルウェア(server-middleware/ac.ts)でサーバAPIに制限をかけます。
http://localhost:3000/api/admin/… にアクセスがあった場合はアクセスを制限します。イベントコンテキストuserを見て、管理権限がない場合はアクセスをブロックし401エラーを返します。

nuxt.config.ts
...省略...
export default defineNuxtConfig({
...省略...
  serverMiddleware: [
    { path: '/api', handler: '~/server-middleware/ac.ts' }  // APIにアクセス制限を掛ける
  ],
...省略...
})
server-middleware/ac.ts
export default defineEventHandler(async (event) => {
	const user = event.context.user

	// 管理用APIの場合は管理権限を確認する
	if (event.req.originalUrl.indexOf('/api/admin/') === 0) {
		if (!(user && user.role === 'admin')) {
			sendError(event, createError({ statusCode: 401, statusMessage: 'Unauthenticated' }))
		}
	}
})

2. ページでのアクセス制御

管理権限なしにadminディレクトリにアクセスした場合はトップページに遷移します。

middleware/route.glogal.ts
export default defineNuxtRouteMiddleware((to, from) => {
	const isAdmin = useAdmin()

	// 管理権限なしで/adminにアクセスがあった場合はルートに遷移
	if ((to.name === 'admin' || !to.path.indexOf('/admin/')) && !isAdmin.value) {
		return navigateTo('/')
	}
})

ユーザ情報の取得

MongoDBからのユーザ情報の取得部分です。mongooseモジュールを使った基本的な処理です。

models/user.ts
import mongoose from 'mongoose'
import { userRoles } from './types/role'
const { roles } = userRoles()

const schema = new mongoose.Schema(
	{
		id: { type: Number, required: true, unique: true },
		email: { type: String, required: true, unique: true, trim: true },
		password: { type: String, required: true, trim: true },
		name: { type: String, required: true, unique: true, trim: true },
		role: {
			type: String,
			enum: roles,
			default: 'user'
		}
	},
	{ timestamps: true, strict: true, strictQuery: true }
)

schema.statics.getUserByEmail = async (email: string) => {
	const user = await User.findOne({ email: email })
	return user
}
schema.statics.getUsers = async () => {
	const users = await User.find()
	return users
}

const User = mongoose.model('User', schema, 'user' /* MongoDBのコレクション名 */)
export default User

おわりに

ポイントとなるのは主にサーバ側なので、サーバのプログラムを中心に解説しました。

Redisで管理するデータはユーザ情報だけですが、情報を追加していけばクライアントの状態管理も可能です。
WebRTCとかリアルタイムチャットなど、リアルタイム系のWebアプリケーションではクライアントのリアルな状態をサーバ側で常に把握する必要が出てきます。
こういう場合にはセッション管理が必要になってきます。

Nuxt3はまだ情報がほとんどないので開発も手探りですね。
ログイン認証機能が安定して動くようになったので公開しました。

Discussion