MENU

問い合わせ


    【Nuxtで作るPDF管理システム 第4回】ログイン機能を入れる

    社内の見積書や契約書を扱うPDF管理システムで、「URLを開けば誰でも一覧が見える」状態のままでは本番に出せません。今回は、ID・パスワードでのログイン機能を追加し、ログインしていない人は画面もAPIも使えないようにします。

    前回の第3回:検索・絞り込み・ページングをつけるでは、ファイル一覧にキーワード・タグ・期間での絞り込みとページングを追加しました。今回はその一覧とアップロードを、ログインした人だけが使えるようにします。あわせて、空欄だった一覧の「登録者」列も埋まるようになります。

    「ログイン画面を作って、ログインしていなければ画面を見せない。それで十分じゃないの?」

    結論から言うと、ログイン機能で本当に守るべきなのは「画面」ではなく「API(データの出入口)」です。画面の表示を切り替えるだけでは、APIのURLを直接呼ばれるとデータが取り出せてしまいます。この回では、Nuxt 4 の認証モジュール nuxt-auth-utils(執筆時点で 0.5 系)を使い、「APIはログイン必須を既定にする」形で実装します。

    目次

    ログインの仕組み:暗号化Cookieのセッション

    今回できるようになること

    操作結果
    未ログインで /files などの画面を開く/login に移動し、ログイン後に元の画面へ戻る
    未ログインで /api/files などのAPIを呼ぶ401(未認証)で断る
    正しいID・パスワードでログイン一覧・アップロードが使える。ヘッダーに名前とログアウトボタン
    アップロード登録者としてログイン中の利用者が記録され、一覧に表示される
    ログアウト再びログインが必要になる

    nuxt-auth-utils を選んだ理由

    nuxt-auth-utils は、GitHub の atinux/nuxt-auth-utils で公開されている Nuxt 用の認証モジュールです。ログイン状態(セッション=「この人はログイン済み」という情報)を暗号化してCookieに入れる方式で、次の機能がそろっています。

    • セッションの保存・取得・削除(setUserSession、requireUserSession、clearUserSession など)
    • パスワードのハッシュ化(hashPassword/verifyPassword。scrypt という計算に時間がかかる方式)
    • 画面側でログイン状態を参照する useUserSession()
    • 後から Google・Microsoft などの外部ID(OAuth)でのログインを追加できる

    📰 出典:nuxt-auth-utils(GitHub・README)

    セッションをサーバー側の保存領域ではなくCookie自体に持つため、DBやRedisなどの追加の仕組みがいらず、小さく始められます。一方で、Cookieの大きさには上限(4096バイト)があるため、セッションには利用者を識別する最小限の情報だけを入れます。

    守る場所は2か所

    場所役割ファイル
    サーバーのミドルウェア/api/ 配下をログイン必須にする(本当の防御)server/middleware/auth.ts
    画面のルートミドルウェア未ログインなら画面をログインページへ移動(使い勝手)app/middleware/auth.global.ts

    画面側のチェックは、ブラウザの中で動く以上、利用者が回避できます。データを守る役割はサーバー側に持たせるのが原則です。

    パッケージと設定を追加する

    npm install --save-exact nuxt-auth-utils@0.5.30

    nuxt.config.ts(追加部分)

    export default defineNuxtConfig({
      // ログイン(暗号化Cookieセッション・パスワードハッシュ)
      modules: ['nuxt-auth-utils'],
      runtimeConfig: {
        // …(第3回までの設定)
        // 最初の管理者(users テーブルが空のときだけ、起動時に作成する)
        initialAdminLoginId: '',
        initialAdminPassword: '',
        // nuxt-auth-utils のセッション設定。鍵(password)は NUXT_SESSION_PASSWORD で渡す
        session: {
          maxAge: 60 * 60 * 8, // 8時間でログアウト扱い
        },
      },
    })

    セッションを暗号化する鍵は、環境変数 NUXT_SESSION_PASSWORD(32文字以上)で渡します。開発時に未設定だと、nuxt-auth-utils が自動で生成して .env に書き込みます。本番では必ず自分で生成した値を環境変数として設定してください。

    .env.example(追加部分)

    # セッションCookieの暗号化鍵(32文字以上のランダムな値。本番では必ず設定する)
    NUXT_SESSION_PASSWORD=
    
    # 最初の利用者(users テーブルが空のときだけ起動時に作成。パスワードは12文字以上)
    NUXT_INITIAL_ADMIN_LOGIN_ID=
    NUXT_INITIAL_ADMIN_PASSWORD=

    maxAge はセッションの有効期間(秒)です。既定では有効期間の指定がないため、業務システムとして「1日の勤務時間程度」の8時間にしました。

    利用者テーブルを追加する

    server/db/schema.ts(追加部分)

    /** ログインできる利用者。パスワードはハッシュ値だけを保存する */
    export const users = sqliteTable('users', {
      /** UUID。セッションや files.uploaded_by に入れるID */
      id: text('id').primaryKey(),
      /** ログインID(社員番号やメールアドレスなど)。一意 */
      loginId: text('login_id').notNull().unique(),
      /** 画面に表示する名前 */
      displayName: text('display_name').notNull(),
      /** hashPassword(scrypt)で作ったハッシュ値。平文のパスワードは保存しない */
      passwordHash: text('password_hash').notNull(),
      /** 登録日時 */
      createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
    })
    npm run db:generate -- --name users   # server/db/migrations/0002_users.sql
    npm run db:migrate

    パスワードは、元に戻せない形に変換した「ハッシュ値」だけを保存します。万一DBが漏れても、パスワードそのものは分からないようにするためです。

    第2回から用意していた files.uploaded_by 列には、この users.id を入れます。第3回までに登録したファイルは登録者が空(null)のままになるため、今回は外部キー制約(=存在しない利用者を入れられないルール)は付けず、一覧では結合して表示名を出す形にしました。

    最初の利用者を作る

    業務システムでは、誰でも自由に登録できる「新規会員登録」は通常作りません。今回は最初の1人を、起動時に環境変数から作る仕組みにしました(利用者の追加画面は権限管理とあわせて扱います)。

    server/plugins/initial-admin.ts(抜粋)

    export default defineNitroPlugin(async () => {
      const { initialAdminLoginId, initialAdminPassword } = useRuntimeConfig()
      if (!initialAdminLoginId || !initialAdminPassword) return
      if (initialAdminPassword.length < 12) {
        console.warn('[initial-admin] パスワードは12文字以上にしてください。作成をスキップしました')
        return
      }
      try {
        const db = useDb()
        const existing = db.select({ value: count() }).from(users).get()?.value ?? 0
        if (existing > 0) return
        db.insert(users).values({
          id: randomUUID(),
          loginId: initialAdminLoginId,
          displayName: '管理者',
          passwordHash: await hashPassword(initialAdminPassword),
          createdAt: new Date(),
        }).run()
        console.info(`[initial-admin] 最初の利用者 ${initialAdminLoginId} を作成しました`)
      } catch (err) {
        // テーブルが無い(npm run db:migrate 前)など
        console.error('[initial-admin] 最初の利用者を作成できませんでした', err)
      }
    })

    server/plugins/ に置いたファイルは、サーバー起動時に一度だけ実行されます。利用者が1人もいないときだけ作るので、2回目以降の起動では何もしません。作成できたら、環境変数からパスワードを消しておくと安心です。

    ログインAPIを作る

    server/utils/login.ts

    import { z } from 'zod'
    
    /** ログインAPIの入力 */
    export const loginBodySchema = z.object({
      loginId: z.string().trim().min(1).max(64),
      password: z.string().min(1).max(128),
    })
    
    let dummyHash: string | undefined
    
    /**
     * 存在しないログインIDのときも、パスワード照合と同じくらいの時間をかけるためのハッシュ値。
     * 応答時間の差から「そのIDが存在するか」を推測されにくくする。
     */
    export async function getDummyPasswordHash(): Promise<string> {
      dummyHash ??= await hashPassword('dummy-password-for-timing')
      return dummyHash
    }

    server/api/auth/login.post.ts

    import { eq } from 'drizzle-orm'
    import { users } from '../../db/schema'
    
    /** ID・パスワードでログインし、暗号化Cookieのセッションを発行する */
    export default defineEventHandler(async (event) => {
      // 1. 入力を検証(形式が不正でも、理由の詳細は返さない)
      const body = await readValidatedBody(event, (b) => loginBodySchema.safeParse(b))
      if (!body.success) {
        throw createError({ statusCode: 400, statusMessage: 'ログインIDとパスワードを入力してください' })
      }
      const { loginId, password } = body.data
    
      // 2. 利用者を探し、パスワードを照合(IDが無い場合もダミーで照合して時間を揃える)
      const user = useDb().select().from(users).where(eq(users.loginId, loginId)).get()
      const ok = await verifyPassword(user?.passwordHash ?? await getDummyPasswordHash(), password)
      if (!user || !ok) {
        // 「IDが違う」「パスワードが違う」を区別しない
        throw createError({ statusCode: 401, statusMessage: 'ログインIDまたはパスワードが違います' })
      }
    
      // 3. セッションを作り直して保存(以前のセッション内容は引き継がない)
      await replaceUserSession(event, {
        user: { id: user.id, loginId: user.loginId, displayName: user.displayName },
        loggedInAt: new Date().toISOString(),
      })
      return { ok: true }
    })
    • エラーメッセージを区別しない:「そのIDは存在しません」と返すと、存在するIDを探す手がかりになります。IDとパスワードのどちらが違っても同じ文言にします。
    • replaceUserSession を使う:setUserSession は既存のセッション内容と合成(マージ)します。ログイン時は前の内容を持ち越さないよう、置き換える方を使いました。
    • hashPassword・verifyPassword・replaceUserSession は nuxt-auth-utils がサーバー側に自動インポートする関数です。

    セッションに入れる利用者情報の型は、shared/types/auth.d.ts で宣言します(README の方法どおり、#auth-utils の User 型を拡張します)。

    shared/types/auth.d.ts

    declare module '#auth-utils' {
      interface User {
        id: string
        loginId: string
        displayName: string
      }
      interface UserSession {
        loggedInAt: string
      }
    }
    
    export {}

    APIを「ログイン必須」にする

    server/middleware/auth.ts

    /**
     * /api/ 配下は「ログイン必須」を既定にする。
     * 新しいAPIを追加したときに認証チェックを書き忘れても、未ログインでは呼べない。
     */
    const PUBLIC_API_PATHS = new Set([
      '/api/auth/login', // ログインAPI自体
      '/api/_auth/session', // nuxt-auth-utils のセッション取得・削除(ログアウト)
    ])
    
    export default defineEventHandler(async (event) => {
      const path = getRequestURL(event).pathname
      if (!path.startsWith('/api/') || PUBLIC_API_PATHS.has(path)) return
      // セッションに user が無ければ 401 を投げる
      await requireUserSession(event, { message: 'ログインしてください' })
    })

    server/middleware/ のファイルは、すべてのリクエストでAPIの処理より先に実行されます。ここで「ログイン不要なAPIだけを例外として列挙し、それ以外はすべてログイン必須」にしておくと、今後APIを増やしたときの書き忘れを防げます。いわば「原則立入禁止、例外だけ許可」の考え方です。

    📰 出典:Nuxt 公式ドキュメント server ディレクトリ(Server Middleware)

    nuxt-auth-utils は /api/_auth/session をセッションの取得とログアウトに使うため、例外に入れています(README にも、このパスをミドルウェアで妨げないよう注意書きがあります)。

    アップロードAPIでは、ログイン中の利用者のIDを登録者として保存します。

    server/api/files.post.ts(変更部分)

    export default defineEventHandler(async (event) => {
      // ログイン中の利用者(server/middleware/auth.ts で確認済み。ここでは user を取り出すために呼ぶ)
      const { user } = await requireUserSession(event)
      // …(検証・保存は前回と同じ)
      const row = {
        // …
        uploadedBy: user.id,
        createdAt: new Date(),
      }

    一覧API(server/api/files.get.ts)は、users テーブルを leftJoin して、uploadedBy に表示名を返すように変えました。

    画面側:ログインページとルートミドルウェア

    app/middleware/auth.global.ts

    export default defineNuxtRouteMiddleware((to) => {
      const { loggedIn } = useUserSession()
    
      if (to.path === '/login') {
        if (loggedIn.value) return navigateTo('/')
        return
      }
      if (!loggedIn.value) {
        // ログイン後に元の画面へ戻れるよう、行き先をクエリで渡す
        return navigateTo({ path: '/login', query: { redirect: to.fullPath } })
      }
    })

    ファイル名の末尾を .global にすると、すべての画面遷移で実行されます。サーバー描画のときにも動くため、未ログインで /files を開くと、サーバーの時点で /login への転送(302)が返ります。

    📰 出典:Nuxt 公式ドキュメント middleware ディレクトリ

    app/pages/login.vue(抜粋)

    <script setup lang="ts">
    const route = useRoute()
    const { fetch: refreshSession } = useUserSession()
    
    const form = reactive({ loginId: '', password: '' })
    const submitting = ref(false)
    const message = ref('')
    
    /** ログイン後の移動先。自サイト内のパス(/ で始まり // で始まらない)だけを許可する */
    function redirectTarget(): string {
      const r = route.query.redirect
      return typeof r === 'string' && r.startsWith('/') && !r.startsWith('//') ? r : '/'
    }
    
    async function login() {
      submitting.value = true
      message.value = ''
      try {
        await $fetch('/api/auth/login', { method: 'POST', body: form })
        // Cookie に入ったセッションを画面側の状態(useUserSession)にも読み込む
        await refreshSession()
        await navigateTo(redirectTarget())
      } catch (err: unknown) {
        const statusMessage = (err as { data?: { statusMessage?: string } }).data?.statusMessage
        message.value = statusMessage ?? 'ログインに失敗しました'
        form.password = ''
      } finally {
        submitting.value = false
      }
    }
    </script>
    
    <template>
      <section class="login">
        <h1>ログイン</h1>
        <form @submit.prevent="login">
          <p><label>ログインID<br><input v-model="form.loginId" type="text" autocomplete="username" required maxlength="64"></label></p>
          <p><label>パスワード<br><input v-model="form.password" type="password" autocomplete="current-password" required maxlength="128"></label></p>
          <button type="submit" :disabled="submitting">{{ submitting ? '確認中…' : 'ログイン' }}</button>
        </form>
        <p v-if="message" class="login__error">{{ message }}</p>
      </section>
    </template>

    ログイン後の戻り先 redirect は、URLの一部なので書き換えられます。https:// や // で始まる外部サイトを指定されると、ログイン直後に偽サイトへ誘導される(オープンリダイレクト)おそれがあるため、自サイト内のパスだけを許可しています。

    ヘッダーに名前とログアウトボタン

    app/layouts/default.vue(変更部分)

    <script setup lang="ts">
    const config = useRuntimeConfig()
    const { loggedIn, user, clear } = useUserSession()
    
    async function logout() {
      // nuxt-auth-utils の DELETE /api/_auth/session を呼び、セッションCookieを消す
      await clear()
      await navigateTo('/login')
    }
    </script>
    
    <template>
      <!-- … -->
          <nav v-if="loggedIn" class="layout__nav">
            <NuxtLink to="/">ホーム</NuxtLink>
            <NuxtLink to="/files">ファイル一覧</NuxtLink>
            <NuxtLink to="/files/upload">アップロード</NuxtLink>
            <span class="layout__user">{{ user?.displayName }}</span>
            <button type="button" class="layout__logout" @click="logout">ログアウト</button>
          </nav>
      <!-- … -->
    </template>

    ログアウトは nuxt-auth-utils が用意している clear() を使いました。計画段階では専用のログアウトAPIを作る予定でしたが、同じ処理が組み込まれているため自作していません。

    動作確認の方法

    # .env に NUXT_SESSION_PASSWORD と NUXT_INITIAL_ADMIN_LOGIN_ID / NUXT_INITIAL_ADMIN_PASSWORD を設定
    npm run db:migrate
    npm run build
    node .output/server/index.mjs   # 本番ビルドは .env を読まないので、環境変数として渡す

    筆者の環境で、本番ビルドを起動して curl で確認した結果です。

    リクエスト結果
    起動時(利用者0人)ログに「最初の利用者 admin を作成しました」
    Cookieなしで GET /api/files401
    Cookieなしでアップロード(POST /api/files)401
    Cookieなしで存在しない /api/nothing401(404より先に認証で止まる)
    Cookieなしで /files を開く302 → /login?redirect=/files
    パスワード違い/存在しないIDどちらも 401「ログインIDまたはパスワードが違います」
    ID・パスワードが空400
    正しいID・パスワード200。Set-Cookie: nuxt-session=…; HttpOnly; Secure; SameSite=Lax
    ログイン後に一覧・アップロード200 / 201。一覧の登録者に「管理者」
    ログイン後に /files を開くサーバー描画のHTMLに一覧・名前・ログアウトボタン
    ログイン後に /login を開く302 → /
    ログアウト(DELETE /api/_auth/session)後に一覧401

    ブラウザでログインフォームを操作する流れ(入力→送信→元の画面へ戻る)は筆者の環境では実施していません。お手元で確認してください。

    つまずきやすい点・セキュリティ上の注意

    • ログアウト後も、古いCookieの値そのものは有効期限まで使える:筆者の環境で、ログアウト前にコピーしておいたCookieの値で一覧APIを呼ぶと 200 が返りました。暗号化Cookie方式はサーバー側に「発行済みセッションの一覧」を持たないため、ログアウトは「ブラウザからCookieを消す」処理です。有効期間(今回は8時間)を短めにする、利用者を無効化したらDBで確認する(第5回で扱います)といった対策と組み合わせます。
    • Cookieには Secure が付く:HTTPS 以外では送られない設定です。localhost は多くのブラウザで例外として扱われますが、社内LANで http://192.168.… のようにIPアドレスで開くとログインできないことがあります。本番は HTTPS で運用してください。
    • 鍵(NUXT_SESSION_PASSWORD)を変えると全員ログアウトになる:サーバーを複数台にする場合は、全台で同じ値にします。
    • 総当たり攻撃への対策は未実装:本連載では、ログイン試行回数の制限やアカウントロック、多要素認証は扱いません。インターネットに公開するなら必須の検討項目です。
    • 本番ビルドは .env を読まない:第0回で触れたとおり、node .output/server/index.mjs で起動する場合は環境変数として渡します。

    📰 出典:IPA 安全なウェブサイトの作り方

    発注者向けメモ:「ログイン」の工数は、社内IDとの連携で大きく変わる

    「ログイン機能」と一言で言っても、どこまで求めるかで工数と運用が大きく変わります。

    方式内容工数・運用の勘所
    システム独自のID・パスワード(今回)システムの中に利用者とパスワードを持つ小さく始めやすい。一方でパスワード忘れ・変更・退職者の削除などの運用画面が別途必要
    社内ID連携(シングルサインオン)Microsoft Entra ID や Google Workspace のアカウントでログイン利用者管理を社内IDに寄せられる。情シス側での設定作業や、ID基盤の管理者との調整が発生

    nuxt-auth-utils は後から外部ID(OAuth)でのログインを追加できる作りですが、「どのID基盤とつなぐか」は要件定義の早い段階で決めておくと手戻りが減ります。

    • 誰が利用者を登録・削除するか:退職・異動のときに誰がいつアカウントを止めるのか、運用の流れを決めておきます。
    • パスワードを忘れたときの手順:メールで再設定するのか、管理者がリセットするのか。メール送信の仕組みが必要かどうかで工数が変わります。
    • 社外からのアクセスの有無:社内ネットワークだけで使うのか、外出先からも使うのかで、求める強度(多要素認証など)が変わります。
    • ログイン状態の保持時間:「毎朝ログインし直す」で良いか、「ブラウザを閉じてもログインしたまま」にしたいかは、利便性と安全性のトレードオフです。

    打ち合わせでは、次のように聞いてみてください。

    • 「ログインは独自のID・パスワードですか?社内のMicrosoftやGoogleのアカウントと連携できますか?」
    • 「APIを直接呼ばれた場合も、ログインしていなければ断られる作りになっていますか?」
    • 「パスワードはどのような形で保存されますか?」
    • 「ログインの失敗が続いたときの対策(回数制限・ロック・通知)はありますか?」
    • 「退職者のアカウントを止めたとき、すでにログイン中の画面はどうなりますか?」

    まとめと次回予告

    第4回では、nuxt-auth-utils を使ってID・パスワードでのログイン機能を追加しました。

    • セッションは暗号化Cookieに入れ、利用者を識別する最小限の情報だけを持つ
    • パスワードはハッシュ値だけを保存し、エラー文言でIDの存在を推測させない
    • server/middleware/ で /api/ 配下をログイン必須にし、例外だけを列挙する
    • 画面側のルートミドルウェアは使い勝手のため。データの保護はサーバー側で行う

    次回は「権限管理と「認可つきダウンロード」」です。閲覧のみ・編集可・管理者の3つの役割(ロール)を追加し、役割に応じて削除などの操作を制限します。あわせて、PDFを日本語のファイル名のままダウンロードできるようにします。

    この連載の記事一覧

    この記事は連載「Nuxtで作るPDF管理システム」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次