🔐

Nuxt Auth Utils でサーバーサイドの認証を(OAuth・マジックリンク・ワンタイムパスワード)

2024/09/01に公開

Nuxt 3 では Universal Rendering (SSR) によるアプリケーションの開発が難なく可能です。
Nitro というサーバーサイドのフレームワークにより、Nuxt 3 はサーバー側の処理も簡潔に記述できるようになっています。

Nuxt Auth Utils はそんな Nuxt の特徴を生かした、SSR のアプリケーションに OAuth 認証を実装できる Nuxt モジュールです。
執筆時点で GitHub, Google, Discord など 17 の OAuth プロバイダーに対応していますので、通常用途では充分だと思います。

https://github.com/atinux/nuxt-auth-utils

さて当記事では、そんな Nuxt Auth Utils を使った実装と、後半では OAuth 以外の認証方法であるマジックリンク認証とワンタイムパスワード認証を追加実装する方法を解説します。

完成形のイメージ

完成したログインフォームはこのようになります。

ログインフォーム

フォームの実装は Nuxt UI Pro を使用していますが <input><button> でも問題ありません。

OAuth 認証実装の全体的な流れと構成

OAuth 認証のフローについて詳しくなくても、Nuxt Auth Utils を使えば OAuth 認証を簡潔に実装できます。
以下、GitHub での例を記載いたしますが、他の OAuth プロバイダーでも同様の手順で実装できます。

  1. 事前に GitHub で clientId と clientSecret を取得しておく
  2. GitHub ログインへのボタンをログインページに配置し、クリックで認証ページにリダイレクトする処理を実装
  3. GitHub 認証後のコールバックを処理するサーバー側の処理を実装(ユーザーデータを取得し利用可能にする)
  4. Route Middleware で必要に応じてログインページへのリダイレクトを実装

以上の手順で、OAuth 認証を実装することができます。

Nuxt Auth Utils がしてくれること

Nuxt Auth Utils は、Nuxt サーバーサイドでセッションを保存したりクッキーを発行したりということをしてくれます。
OAuth 認証における Access Token や Refresh Token なども取り扱ってもらえます。
(現時点では Access Token の期限が切れた場合のリフレッシュ処理は自前で実装する必要があるようです)

インストールと設定

Nuxt Auth Utils をインストールし nuxt.config.tsmodules に追加します。
設定は runtimeConfig に記述します。

npm i nuxt-auth-utils
nuxi を使用し CLI でインストールする場合
npx nuxi@latest module add auth-utils
nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    'nuxt-auth-utils', // 追加
  ],
  runtimeConfig: {
    oauth: {
      github: {
        clientId: 'Ov23li01234567890fMZ', // 開発用の clientId
        clientSecret: '8b0629b1abcdefabcdefabcdef6d38e22addd8d9',
      },
    },
  },
})

.env で、本番用の GitHub App の clientId と clientSecret を設定しておくとよいでしょう。
NUXT_SESSION_PASSWORD はセッションの暗号化に使用されます(自動生成されますが、記述しておくとよいでしょう)

NUXT_SESSION_PASSWORD=password-with-at-least-32-characters
NUXT_OAUTH_GITHUB_CLIENT_ID=Ov23li09999999990fMZ
NUXT_OAUTH_GITHUB_CLIENT_SECRET=8b0629b1abcdef999999abcdef6d38e22addd8d9

ログインボタン

OAuth 認証だけであれば、ログインページは <button> 要素を配置するのみです。

たとえばつぎのように記述します。

app/pages/login.vue
<script setup lang="ts">
const loginWithGitHub = async () => {
  // GitHub の認証ページにリダイレクト
  await navigateTo('/auth/github', { external: true })
}
</script>

<template>
  <div>
    <button @click="loginWithGitHub">
      GitHub でログイン
    </button>
  </div>
</template>

今回は Nuxt UI Pro の <UAuthForm> を使用しました。

app/pages/login.vue
<script setup lang="ts">
const providers = [{
  label: 'GitHub',
  icon: 'i-simple-icons-github',
  color: 'gray',
  // クリックイベントで GitHub の認証ページにリダイレクト
  click: async () => {
    await navigateTo('/auth/github', { external: true })
  },
}]
</script>

<template>
  <UCard>
    <UAuthForm
      title="Login"
      icon="i-heroicons-user-circle"
      :providers
    />
  </UCard>
</template>

コールバックの処理

GitHub ログインが成功すると /auth/github にリダイレクトするよう、GitHub App の設定を行っておいてください。
コールバックで必要な処理(セッションに保存し http only なCookieを発行するなど)の記述は、特に必要ありません。

このログイン成功のタイミングで、セッションにユーザーデータを保存しておくと、アプリケーション内で扱うことが可能です。
(セッションの情報は Nitro がサーバー側で保存してくれるため、とくに意識する必要はありません)

server/routes/auth/github.get.tsoauthGitHubEventHandler を呼び出すことで、GitHub の OAuth 認証を実装できます。
(使用するプロバイダーに応じて適宜ファイル名とイベントハンドラー名を変更してください)

onSuccess でログイン成功時の処理、onError でログイン失敗時の処理を記述します。
下記の例では前回の記事で紹介した Drizzle を使用してユーザーデータを取得しています。

server/routes/auth/github.get.ts
export default oauthGitHubEventHandler({
  config: {
    emailRequired: true, // ユーザーのメールアドレスを取得する
  },
  async onSuccess(event, { user, tokens }) {
    const githubId = user.id
    const userFromDB = await useDrizzle().select().from(tables.users).where(eq(tables.users.githubId, githubId)).get()

    // セッションに追加で保存するユーザー情報
    await setUserSession(event, {
      user: {
        userId: userFromDB.id,
        name: userFromDB.name,
        email: userFromDB.email,
        role: userFromDB.role,
      },
      loggedInAt: new Date(),
    })
    const to = getCookie(event, 'redirect') || '/'
    setCookie(event, 'redirect', null)
    return sendRedirect(event, to)
  },

  onError(event, error) {
    console.error('GitHub OAuth error:', error)
    return sendRedirect(event, '/')
  },
})

また、SSR 中に認証情報を確認する処理がモジュール側で行われますが、その際に付随して行う処理を Nitro Plugin に記述します。

server/plugins/session.ts
export default defineNitroPlugin(() => {
  // SSR 中に plugins/session.server.ts で useUserSession().fetch() が呼ばれた際
  // もしくは明示的に useUserSession().fetch() を呼び出した際
  // DB から情報を取得し、UserSession を拡張する
  sessionHooks.hook('fetch', async (session, event) => {
    const userFromDB = await useDrizzle().select().from(tables.users).where(eq(tables.users.id, session.user.userId)).get()
    // マージする場合(入れ替える場合は replaceUserSession を使用)
    await setUserSession(event, {
      user: {
        // 変更される可能性のある情報を更新
        name: userFromDB.name,
        role: userFromDB.role,
      },
      lastAccessedAt: new Date(),
    })
  })

  // SSR 中に clearUserSession(event) を呼び出した際
  // もしくは明示的に useServerSession().clear() を呼び出した際
  sessionHooks.hook('clear', async (session, event) => {
    // セッションを削除する前に行う処理
    console.log('logout', { session, event })
  })
})

認証が必要なページはログインページへ

Route Middleware を使用して、認証が必要なページへのアクセスを制限します。
ログイン後のリダイレクト先として、ログイン前にアクセスしようとしたページを記録しておきます。
サーバー側で参照する必要があるため Session Storage ではなく Cookie に保存します。

app/middleware/auth.ts
export default defineNuxtRouteMiddleware(async (to, from) => {
  const { loggedIn } = useUserSession()

  if (!loggedIn.value) {
    const redirect = useCookie('redirect')
    redirect.value = to.fullPath // クッキーにログイン後のリダイレクト先を保存
    return navigateTo('/login', { replace: true })
  }
})

認証が必要なページで、この Route Middleware を使用します。
(ページコンポーネントに記述)

app/pages/mypage.vue
<script setup lang="ts">
definePageMeta({
  middleware: ['auth'],
})
</script>

これで完了です。
OAuth 認証だけであれば、以上の手順で実装が完了します。

<AuthState> コンポーネントによる表示の切り替えを行う

という便利なコンポーネントも用意されています。
ログイン状態に応じて表示を切り分けたい箇所を簡潔に記述可能です。

app/components/AppLoginLink.vue
<template>
  <div>
    <AuthState>
      <template #default="{ loggedIn, clear }">
        <button v-if="loggedIn" @click="clear">ログアウト</button>
        <NuxtLink v-else to="/login">ログイン</NuxtLink>
      </template>
      <template #placeholder>
        <button disabled>Loading...</button>
      </template>
    </AuthState>
  </div>
</template>

マジックリンク認証・ワンタイムパスワード認証を追加する

ここからは、OAuth 認証以外の認証方法であるマジックリンク認証とワンタイムパスワード認証を追加実装する方法を解説します。

マジックリング認証は、メールに記載されたリンクをクリックすることで認証を行います。
ワンタイムパスワード認証(以下 OTP)は、メールに記載された6桁の数値等を入力することで認証を行います。

それぞれ別々に実装することも可能ですが、今回は共通のフローでメール送信を行い、そこにURLもOTPも記載することにします。

ログイン画面にメールアドレス入力フォームを追加

おなじく Nuxt UI Pro の <UAuthForm> を使用して、メールアドレス入力フォームを追加します。

app/pages/login.vue
<script setup lang="ts">
// 中略
// メールアドレス入力フォームのフィールド
const fields = [{
  type: 'email',
  name: 'email',
  label: 'メールアドレス',
  placeholder: 'Enter your email',
  color: 'gray',
}]
// バリデーション
const validate = (state: MailLoginState): FormError[] => {
  const errors: FormError[] = []
  if (!state.email) {
    errors.push({
      path: 'email',
      message: 'メールアドレスを入力してください',
    })
  }
  return errors
}
// 送信処理
const send = async (data: MailLoginState) => {
  const { success, error } = await $fetch('/api/auth/email/login', {
    method: 'POST',
    body: data,
  }).catch((error) => ({ error, success: false }))

  if (!success) {
    console.error({ error })
    return
  }
  // メール送信に成功したら OTP 入力ページにリダイレクトする
  await navigateTo({ path: '/auth/verify', query: { email: data.email } }, { replace: true })
}
</script>

<template>
  <UCard>
    <UAuthForm
      :fields
      :validate
      :providers
      title="Login"
      description="マジックリンクとワンタイムパスワードを送信"
      align="top"
      icon="i-heroicons-user-circle"
      :submitButton="{ icon: 'i-heroicons-envelope-20-solid' }"
      @submit="send"
    />
  </UCard>
</template>

送信後は、OTP の入力ページに遷移しておきます。
マジックリンク認証の場合は、メールに記載されたリンクをクリックするため、とくに考慮する必要はありません。

メール送信処理

メール送信処理は、サーバーサイドで行います。
Resend を使用していますが、他の方法でももちろん構いません)

ここでは users テーブルと userTokens テーブルがある想定です。

server/api/auth/email/login.post.ts
import { randomUUID } from 'uncrypto'
import { sendEmail } from '@@/server/utils/resend'

export default defineEventHandler(async (event) => {
  try {
    const body = await readBody(event)
    const email: string = body.email

    const user = await useDrizzle().select().from(tables.users).where(eq(tables.users.email, email)).get()

    // 認証情報を作成
    const userId = user.id
    const { token, link, otp } = createCodes(getRequestURL(event))

    // 認証情報を保存(この値で検証する)
    await saveToken(email, token, otp)

    // メールを送信する
    const results = await sendEmail({
      from: 'no-reply@coworking.tokyo.jp',
      to: email,
      subject: '[Co-Edo]: ログイン情報',
      text: buildMail({ email, link, otp }),
    })

    return {
      success: true,
      error: null,
    }
  } catch (error) {
    const message = error instanceof Error ? error.message : ''
    throw createError({
      statusCode: 400,
      statusMessage: `post message error: ${message}`,
    })
  }
})

const createCodes = (url: URL) => {
  // 認証トークンを生成
  const token = randomUUID()
  // 認証用URL
  const link = `${url.origin}/auth/token/${token}`
  // 認証コード (OTP) として 6桁の数字を生成
  const otp = Math.random().toString().slice(-6)
  return { token, link, otp }
}

const saveToken = async (email: string, token: string, otp: string) => {
  // 認証情報を保存(この値で検証する)
  const newData = await useDrizzle().insert(tables.userTokens).values({ email, token, otp, createdAt: new Date() }).returning().get()

  if (newData == null) {
    throw createError({ statusCode: 500, statusMessage: 'Failed to save token' })
  }
  // 古いデータを削除
  await useDrizzle().delete(tables.userTokens).where(and(eq(tables.userTokens.email, email), ne(tables.userTokens.id, newData.id))).returning()
}

const buildMail = (data: { email: string, link: string, otp: string }) => `
認証コードと認証リンクです。

${data.otp}

${data.link}

この認証コードおよび認証リンクの有効期限は 4 時間です。
このメールに心当たりがない場合は、このメールを無視してください。
--
コワーキングスペース茅場町 Co-Edo
`.trim()

OTP 入力ページ

OTP 入力ページは、メールに記載された6桁の数字を入力するページです。
ログインページから /auth/verify にリダイレクトされることを前提としています。

app/pages/auth/verify.vue
<script setup lang="ts">
const route = useRoute()
const email = route.query.email as string

const state = reactive<{ otp: string }>({ otp: '' })

const form = ref<HTMLFormElement | null>(null)

const send = async ({ data }: { data: { otp: string } }) => {
  const { success, error } = await $fetch('/api/auth/verify/otp', {
    method: 'POST',
    body: {
      email,
      otp: data.otp,
    },
  }).catch((error) => ({ error, success: false }))

  if (!success) {
    console.error('認証できませんでした')
    return
  }

  // 認証成功時の処理(middlewareでセットした遷移先にリダイレクトする)
  const loginRedirect = useCookie<string | null>('loginRedirect', {
    maxAge: 60 * 60 * 24,
  })
  const redirectTo = loginRedirect.value || '/'
  loginRedirect.value = null // リダイレクト先をクリア
  await navigateTo(redirectTo, { external: true })
}
</script>

<template>
  <UForm
    ref="form"
    :state
    class="space-y-4 max-w-2xl"
    @submit="send"
  >
    <UFormGroup
      label="ワンタイムパスワード"
      name="otp"
    >
      <UInput
        v-model="state.otp"
        size="xl"
        maxlength="6"
        :ui="{ base: 'text-center tracking-[0.75em]' }"
      />
    </UFormGroup>
    <div class="text-center">
      <UButton type="submit">
        ログイン
      </UButton>
    </div>
  </UForm>
</template>

/server/api で OTP を検証する

OTP の検証はサーバーサイドで行います。
指定した期限内のデータがあれば認証成功とし、セッションにユーザーデータを保存します。

server/api/auth/verify/otp.post.ts
import { gt } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  try {
    const body = await readBody(event)
    const email: string = body.email
    const otp: string = body.otp

    const userToken = await useDrizzle().select().from(tables.userTokens)
      .where(and(
        eq(tables.userTokens.email, email),
        eq(tables.userTokens.otp, otp),
        gt(tables.userTokens.createdAt, new Date(Date.now() - 1000 * 60 * 60 * 4)), // 4 時間以内
      )).get()

    if (userToken == null) {
      throw createError({
        statusCode: 400,
        statusMessage: `Not registered: ${email}`,
      })
    }

    // 認証情報を削除
    await useDrizzle().delete(tables.userTokens).where(eq(tables.userTokens.id, userToken.id)).returning()

    // 成功→リダイレクト
    const user = await useDrizzle().select().from(tables.users).where(eq(tables.users.email, email)).get()

    if (user == null) {
      throw createError({
        statusCode: 400,
        statusMessage: `Something wrong, user not found: ${email}`,
      })
    }

    await setUserSession(event, {
      user: {
        name: user.name,
        email,
        role: user.role ?? 0,
      },
      loggedInAt: new Date(),
    })

    return {
      success: true,
      error: null,
    }

  } catch (error) {
    const message = error instanceof Error ? error.message : ''
    throw createError({
      statusCode: 400,
      statusMessage: `post message error: ${message}`,
    })
  }
})

これで OTP 認証はできました。

マジッククリンク認証の実装

マジックリンク認証は、メールに記載されたリンクをクリックすることで認証を行います。
/auth/token/:token にアクセスしてくるようにメールにURLを記述していますので、つぎのように実装します。

server/routes/auth/token/[token].ts
import { gt } from 'drizzle-orm'

export default defineEventHandler(async (event) => {
  try {
    const token = getRouterParam(event, 'token')
    // 保存した認証トークンと照合する
    const userToken = await useDrizzle().select().from(tables.userTokens)
      .where(and(
        eq(tables.userTokens.token, token),
        gt(tables.userTokens.createdAt, new Date(Date.now() - 1000 * 60 * 60 * 4)), // 4 時間以内
      )).get()
    if (userToken == null) {
      throw createError({
        statusCode: 400,
        statusMessage: `Not found: ${token}`,
      })
    }
    // 認証トークンを削除
    await useDrizzle().delete(tables.userTokens).where(eq(tables.userTokens.id, userToken.id)).returning()
    // セッションにユーザーデータを保存
    const user = await useDrizzle().select().from(tables.users).where(eq(tables.users.email, userToken.email)).get()
    if (user == null) {
      throw createError({
        statusCode: 400,
        statusMessage: `Something wrong, user not found: ${userToken.email}`,
      })
    }
    await setUserSession(event, {
      user: {
        name: user.name,
        email: userToken.email,
        role: user.role ?? 0,
      },
      loggedInAt: new Date(),
    })
    // 認証成功→リダイレクト
    const loginRedirect = getCookie(event, 'loginRedirect') || '/'
    deleteCookie(event, 'loginRedirect')
    return sendRedirect(event, loginRedirect)

  } catch (error) {
    const message = error instanceof Error ? error.message : ''
    throw createError({
      statusCode: 400,
      statusMessage: `post message error: ${message}`,
    })
  }
})

同じように最後にリダイレクトすれば完了です。

終わりに

以上が Nuxt Auth Utils を使用したサーバーサイドでの認証機能です。
Nuxt は CDN のエッジでも動作するため Universal Rendering (SSR) によるアプリケーション開発を行う機会は多いと思います。

従来サーバーサイドの言語で実装していたようなセッションとCookieを使用した認証処理ですが、中心的な処理はすべて Nuxt Auth Utils に任せられるため、アプリケーション側の開発内容に集中できました。

OAuth・マジックリンク・ワンタイムパスワード認証の実装例として、ぜひ何かのお役に立てれば幸いです。

記事の内容には充分に気を配っていますが、間違いや認識違いがある可能性があります。
正確な情報を記載することを大切にしていますので、コメント欄等で遠慮なくご指摘ください。

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion