Nuxt3でセッションを使用したログイン認証機能を作る
はじめに
Nuxt3も安定してきたのでクッキーを使ってセッション管理するログイン認証機能を作ってみます。必要最小限ですがDBを使用した本格的な認証機能です。
主な仕様
主な仕様は以下のとおりです。
- Nuxt3がベース
- MongoDBによるユーザ情報(ログイン情報)の管理
- Redisによるセッション情報の管理
動作環境
動作させるプラットフォームはWindows10です。WSL2も使用します。
デモでは以下のソフトウェアが必要です。
- Node v16.0以上
- MongoDB v6.0(WSL2にインストール)
- Redis v7.0(WSL2にインストール)
デモ
簡単なデモプログラムを作成しました。GitHubのリポジトリをZipファイルでダウンロードしてください。
ディレクトリ構成
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をインストールします。
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を修正します。デフォルトの接続情報は以下になっています。
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.ts
にNitroプラグイン(server/index.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"
]
},
...省略...
})
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オブジェクトを使用します。
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のクッキーをクライアントに発行します。
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のセッション情報とセッションクッキーを削除します。
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
)から実行されます。
...省略...
// セッションクッキーの有効期限更新
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サーバミドルウェアでユーザ認証が通るようにします。
以下のようにクッキーを付加してフェッチ処理を行います。
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パターンで行うこととします。
- サーバAPIのディレクトリで制御
- ページコンポーネント(vueファイル)のディレクトリで制御
1. サーバAPIのアクセス制御
acサーバミドルウェア(server-middleware/ac.ts
)でサーバAPIに制限をかけます。
http://localhost:3000/api/admin/… にアクセスがあった場合はアクセスを制限します。イベントコンテキストuserを見て、管理権限がない場合はアクセスをブロックし401エラーを返します。
...省略...
export default defineNuxtConfig({
...省略...
serverMiddleware: [
{ path: '/api', handler: '~/server-middleware/ac.ts' } // APIにアクセス制限を掛ける
],
...省略...
})
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
ディレクトリにアクセスした場合はトップページに遷移します。
export default defineNuxtRouteMiddleware((to, from) => {
const isAdmin = useAdmin()
// 管理権限なしで/adminにアクセスがあった場合はルートに遷移
if ((to.name === 'admin' || !to.path.indexOf('/admin/')) && !isAdmin.value) {
return navigateTo('/')
}
})
ユーザ情報の取得
MongoDBからのユーザ情報の取得部分です。mongooseモジュールを使った基本的な処理です。
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