透過超過 100 個訣竅學習 Nuxt!

nuxt-auth-utils

適用於 Nuxt 且支援 SSR 的極簡驗證模組。

Nuxt Auth Utils

npm versionnpm downloadsLicenseNuxt

使用安全且密封的 Cookie 工作階段將驗證功能新增至 Nuxt 應用程式。

功能

它只有少數的相依性 (僅來自 UnJS),可在多個 JS 環境 (Node、Deno、Workers) 中執行,並完全使用 TypeScript 類型化。

需求

這個模組僅適用於執行中的 Nuxt 伺服器,因為它使用伺服器 API 路由 (nuxt build)。

這表示您無法將此模組與 nuxt generate 搭配使用。

無論如何,您可以使用混合渲染來預先渲染應用程式的頁面,或完全停用伺服器端渲染。

快速設定

  1. 在您的 Nuxt 專案中新增 nuxt-auth-utils
npx nuxi@latest module add auth-utils
  1. .env 中新增至少包含 32 個字元的 NUXT_SESSION_PASSWORD 環境變數。
# .env
NUXT_SESSION_PASSWORD=password-with-at-least-32-characters

如果未設定 NUXT_SESSION_PASSWORD,Nuxt Auth Utils 會在第一次於開發環境中執行 Nuxt 時為您產生一個。

  1. 就這樣!您現在可以將驗證功能新增至您的 Nuxt 應用程式了 ✨

Vue 可組合函式

Nuxt Auth Utils 會自動新增一些外掛程式來擷取目前的使用者工作階段,讓您可以從您的 Vue 元件存取它。

使用者工作階段

<script setup>
const { loggedIn, user, session, fetch, clear } = useUserSession()
</script>

<template>
  <div v-if="loggedIn">
    <h1>Welcome {{ user.login }}!</h1>
    <p>Logged in since {{ session.loggedInAt }}</p>
    <button @click="clear">Logout</button>
  </div>
  <div v-else>
    <h1>Not logged in</h1>
    <a href="/auth/github">Login with GitHub</a>
  </div>
</template>

TypeScript 簽章

interface UserSessionComposable {
  /**
   * Computed indicating if the auth session is ready
   */
  ready: ComputedRef<boolean>
  /**
   * Computed indicating if the user is logged in.
   */
  loggedIn: ComputedRef<boolean>
  /**
   * The user object if logged in, null otherwise.
   */
  user: ComputedRef<User | null>
  /**
   * The session object.
   */
  session: Ref<UserSession>
  /**
   * Fetch the user session from the server.
   */
  fetch: () => Promise<void>
  /**
   * Clear the user session and remove the session cookie.
   */
  clear: () => Promise<void>
}

!重要 Nuxt Auth Utils 使用 /api/_auth/session 路由進行工作階段管理。請確保您的 API 路由中介軟體不會干擾此路徑。

伺服器工具

下列輔助程式會在您的 server/ 目錄中自動匯入。

工作階段管理

// Set a user session, note that this data is encrypted in the cookie but can be decrypted with an API call
// Only store the data that allow you to recognize a user, but do not store sensitive data
// Merges new data with existing data using unjs/defu library
await setUserSession(event, {
  // User data
  user: {
    login: 'atinux'
  },
  // Private data accessible only on server/ routes
  secure: {
    apiToken: '1234567890'
  },
  // Any extra fields for the session data
  loggedInAt: new Date()
})

// Replace a user session. Same behaviour as setUserSession, except it does not merge data with existing data
await replaceUserSession(event, data)

// Get the current user session
const session = await getUserSession(event)

// Clear the current user session
await clearUserSession(event)

// Require a user session (send back 401 if no `user` key in session)
const session = await requireUserSession(event)

您可以透過在您的專案中建立類型宣告檔案 (例如,auth.d.ts) 來擴充 UserSession 類型,以定義您的使用者工作階段類型。

// auth.d.ts
declare module '#auth-utils' {
  interface User {
    // Add your own fields
  }

  interface UserSession {
    // Add your own fields
  }

  interface SecureSessionData {
    // Add your own fields
  }
}

export {}

!重要 由於我們加密工作階段資料並將其儲存在 Cookie 中,因此我們受到 4096 位元組的 Cookie 大小限制約束。請僅儲存必要資訊。

OAuth 事件處理常式

所有處理常式都可以自動匯入,並在您的伺服器路由或 API 路由中使用。

模式為 defineOAuth<Provider>EventHandler({ onSuccess, config?, onError? }),例如:defineOAuthGitHubEventHandler

此輔助程式會傳回一個事件處理常式,該常式會自動重新導向至提供者授權頁面,然後根據結果呼叫 onSuccessonError

config 可以直接從您 nuxt.config.ts 中的 runtimeConfig 定義。

export default defineNuxtConfig({
  runtimeConfig: {
    oauth: {
      // provider in lowercase (github, google, etc.)
      <provider>: {
        clientId: '...',
        clientSecret: '...'
      }
    }
  }
})

也可以使用環境變數設定

  • NUXT_OAUTH_<PROVIDER>_CLIENT_ID
  • NUXT_OAUTH_<PROVIDER>_CLIENT_SECRET

提供者為大寫 (GITHUB、GOOGLE 等)

支援的 OAuth 提供者

  • Auth0
  • Authentik
  • AWS Cognito
  • Battle.net
  • Discord
  • Dropbox
  • Facebook
  • GitHub
  • GitLab
  • Google
  • Hubspot
  • Instagram
  • Keycloak
  • Linear
  • LinkedIn
  • Microsoft
  • PayPal
  • Polar
  • Seznam
  • Spotify
  • Steam
  • Strava
  • TikTok
  • Twitch
  • VK
  • WorkOS
  • X (Twitter)
  • XSUAA
  • Yandex
  • Zitadel

您可以透過在 src/runtime/server/lib/oauth/ 中建立新檔案來新增您最愛的提供者。

範例

範例:~/server/routes/auth/github.get.ts

export default defineOAuthGitHubEventHandler({
  config: {
    emailRequired: true
  },
  async onSuccess(event, { user, tokens }) {
    await setUserSession(event, {
      user: {
        githubId: user.id
      }
    })
    return sendRedirect(event, '/')
  },
  // Optional, will return a json error and 401 status code by default
  onError(event, error) {
    console.error('GitHub OAuth error:', error)
    return sendRedirect(event, '/')
  },
})

請確保在您的 OAuth 應用程式設定中,將回呼 URL 設定為 <your-domain>/auth/github

如果在生產環境中重新導向 URL 不符,表示模組無法猜測正確的重新導向 URL。您可以設定 NUXT_OAUTH_<PROVIDER>_REDIRECT_URL 環境變數來覆寫預設值。

密碼雜湊

Nuxt Auth Utils 提供密碼雜湊工具,例如 hashPasswordverifyPassword,以使用 scrypt 雜湊和驗證密碼,因為它在許多 JS 執行環境中都受到支援。

const hashedPassword = await hashPassword('user_password')

if (await verifyPassword(hashedPassword, 'user_password')) {
  // Password is valid
}

您可以在 nuxt.config.ts 中設定 scrypt 選項

export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  auth: {
    hash: {
      scrypt: {
        // See https://github.com/adonisjs/hash/blob/94637029cd526783ac0a763ec581306d98db2036/src/types.ts#L144
      }
    }
  }
})

WebAuthn (通行金鑰)

WebAuthn (Web 驗證) 是一種網路標準,透過使用公鑰密碼術的通行金鑰取代密碼來增強安全性。使用者可以使用生物特徵資料 (例如指紋或臉部辨識) 或實體裝置 (例如 USB 金鑰) 進行驗證,從而降低網路釣魚和密碼洩露的風險。此方法提供更安全且使用者友善的驗證方法,受到主要瀏覽器和平台的支援。

若要啟用 WebAuthn,您需要

  1. 安裝對等相依性
npx nypm i @simplewebauthn/server@11 @simplewebauthn/browser@11
  1. 在您的 nuxt.config.ts 中啟用它
export default defineNuxtConfig({
  auth: {
    webAuthn: true
  }
})

範例

在此範例中,我們將實作註冊和驗證憑證的基本步驟。

完整的程式碼可以在playground中找到。此範例使用具有下列最小資料表的 SQLite 資料庫

CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS credentials (
  userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE,
  id TEXT UNIQUE NOT NULL,
  publicKey TEXT NOT NULL,
  counter INTEGER NOT NULL,
  backedUp INTEGER NOT NULL,
  transports TEXT NOT NULL,
  PRIMARY KEY ("userId", "id")
);
  • 對於 users 資料表,必須具有唯一的識別碼,例如使用者名稱或電子郵件 (在此我們使用電子郵件)。建立新憑證時,需要此識別碼並與使用者裝置、密碼管理員或驗證器上的通行金鑰一起儲存。
  • credentials 資料表儲存
    • 來自 users 資料表的 userId
    • 憑證 id (作為唯一索引)
    • 憑證 publicKey
    • counter。每次使用憑證時,計數器都會遞增。我們可以使用此值來執行額外的安全性檢查。有關 counter 的詳細資訊,請參閱此處。對於此範例,我們不會使用計數器。但是您應該使用新值更新資料庫中的計數器。
    • backedUp 旗標。通常,憑證會儲存在產生憑證的裝置上。當您使用密碼管理員或驗證器時,憑證會「備份」,因為它可以在多個裝置上使用。如需詳細資訊,請參閱此章節
    • 憑證 transports。它是一個字串陣列,指示憑證如何與用戶端通訊。它用於顯示正確的 UI,讓使用者可以使用憑證。同樣,如需詳細資訊,請參閱此章節

下列程式碼不包含實際的資料庫查詢,但顯示要遵循的一般步驟。完整的範例可以在 playground 中找到:註冊驗證資料庫設定

// server/api/webauthn/register.post.ts
import { z } from 'zod'
export default defineWebAuthnRegisterEventHandler({
  // optional
  async validateUser(userBody, event) {
    // bonus: check if the user is already authenticated to link a credential to his account
    // We first check if the user is already authenticated by getting the session
    // And verify that the email is the same as the one in session
    const session = await getUserSession(event)
    if (session.user?.email && session.user.email !== body.userName) {
      throw createError({ statusCode: 400, message: 'Email not matching curent session' })
    }

    // If he registers a new account with credentials
    return z.object({
      // we want the userName to be a valid email
      userName: z.string().email() 
    }).parse(userBody)
  },
  async onSuccess(event, { credential, user }) {
    // The credential creation has been successful
    // We need to create a user if it does not exist
    const db = useDatabase()

    // Get the user from the database
    let dbUser = await db.sql`...`
    if (!dbUser) {
      // Store new user in database & its credentials
      dbUser = await db.sql`...`
    }

    // we now need to store the credential in our database and link it to the user
    await db.sql`...`

    // Set the user session
    await setUserSession(event, {
      user: {
        id: dbUser.id
      },
      loggedInAt: Date.now(),
    })
  },
})
// server/api/webauthn/authenticate.post.ts
export default defineWebAuthnAuthenticateEventHandler({
  // Optionally, we can prefetch the credentials if the user gives their userName during login
  async allowCredentials(event, userName) {
    const credentials = await useDatabase().sql`...`
    // If no credentials are found, the authentication cannot be completed
    if (!credentials.length)
      throw createError({ statusCode: 400, message: 'User not found' })

    // If user is found, only allow credentials that are registered
    // The browser will automatically try to use the credential that it knows about
    // Skipping the step for the user to select a credential for a better user experience
    return credentials
    // example: [{ id: '...' }]
  },
  async getCredential(event, credentialId) {
    // Look for the credential in our database
    const credential = await useDatabase().sql`...`

    // If the credential is not found, there is no account to log in to
    if (!credential)
      throw createError({ statusCode: 400, message: 'Credential not found' })

    return credential
  },
  async onSuccess(event, { credential, authenticationInfo }) {
    // The credential authentication has been successful
    // We can look it up in our database and get the corresponding user
    const db = useDatabase()
    const user = await db.sql`...`

    // Update the counter in the database (authenticationInfo.newCounter)
    await db.sql`...`

    // Set the user session
    await setUserSession(event, {
      user: {
        id: user.id
      },
      loggedInAt: Date.now(),
    })
  },
})

!重要 Webauthn 使用挑戰來防止重播攻擊。依預設,此模組不會使用此功能。如果您想要使用挑戰 (強烈建議),則會提供 storeChallengegetChallenge 函式。會建立嘗試 ID 並隨每個驗證要求一起傳送。您可以使用此 ID 將挑戰儲存在資料庫或 KV 儲存區中,如下列範例所示。

export default defineWebAuthnAuthenticateEventHandler({
  async storeChallenge(event, challenge, attemptId) {
    // Store the challenge in a KV store or DB
    await useStorage().setItem(`attempt:${attemptId}`, challenge)
  },
  async getChallenge(event, attemptId) {
    const challenge = await useStorage().getItem(`attempt:${attemptId}`)

    // Make sure to always remove the attempt because they are single use only!
    await useStorage().removeItem(`attempt:${attemptId}`)

    if (!challenge)
      throw createError({ statusCode: 400, message: 'Challenge expired' })

    return challenge
  },
  async onSuccess(event, { authenticator }) {
    // ...
  },
})

在前端,它就像這樣簡單

<script setup lang="ts">
const { register, authenticate } = useWebAuthn({
  registerEndpoint: '/api/webauthn/register', // Default
  authenticateEndpoint: '/api/webauthn/authenticate', // Default
})
const { fetch: fetchUserSession } = useUserSession()

const userName = ref('')
async function signUp() {
  await register({ userName: userName.value })
    .then(fetchUserSession) // refetch the user session
}

async function signIn() {
  await authenticate(userName.value)
    .then(fetchUserSession) // refetch the user session
}
</script>

<template>
  <form @submit.prevent="signUp">
    <input v-model="userName" placeholder="Email or username" />
    <button type="submit">Sign up</button>
  </form>
  <form @submit.prevent="signIn">
    <input v-model="userName" placeholder="Email or username" />
    <button type="submit">Sign in</button>
  </form>
</template>

請參閱WebAuthnModal.vue 以取得完整的範例。

示範

可以在 https://todo-passkeys.nuxt.dev 上找到完整示範,該示範使用 Drizzle ORMNuxtHub

此示範的原始碼可在 https://github.com/atinux/todo-passkeys 上取得。

擴充工作階段

我們利用 Hook 讓您可以使用自己的資料擴充工作階段資料,或在使用者清除工作階段時進行記錄。

// server/plugins/session.ts
export default defineNitroPlugin(() => {
  // Called when the session is fetched during SSR for the Vue composable (/api/_auth/session)
  // Or when we call useUserSession().fetch()
  sessionHooks.hook('fetch', async (session, event) => {
    // extend User Session by calling your database
    // or
    // throw createError({ ... }) if session is invalid for example
  })

  // Called when we call useUserSession().clear() or clearUserSession(event)
  sessionHooks.hook('clear', async (session, event) => {
    // Log that user logged out
  })
})

伺服器端渲染

您可以從用戶端和伺服器提出經過驗證的要求。但是,如果您未使用 useFetch(),則必須使用 useRequestFetch() 在 SSR 期間提出經過驗證的要求。

<script setup lang="ts">
// When using useAsyncData
const { data } = await useAsyncData('team', () => useRequestFetch()('/api/protected-endpoint'))

// useFetch will automatically use useRequestFetch during SSR
const { data } = await useFetch('/api/protected-endpoint')
</script>

在 Nuxt 中,有一個未解決的問題,要將憑證包含在 $fetch 中。

混合渲染

當使用Nuxt routeRules預先渲染或快取頁面時,Nuxt Auth Utils 不會在預先渲染期間擷取使用者工作階段,而是在用戶端 (水合後) 擷取。

這是因為使用者工作階段儲存在安全的 Cookie 中,並且在預先渲染期間無法存取。

這表示您在預先渲染期間不應依賴使用者工作階段。

<AuthState> 元件

您可以使用 <AuthState> 元件,以在您的元件中安全地顯示與驗證相關的資料,而不必擔心渲染模式。

標頭中登入按鈕的一個常見使用案例

<template>
  <header>
    <AuthState v-slot="{ loggedIn, clear }">
      <button v-if="loggedIn" @click="clear">Logout</button>
      <NuxtLink v-else to="/login">Login</NuxtLink>
    </AuthState>
  </header>
</template>

如果頁面已快取或預先渲染,則在用戶端擷取使用者工作階段之前,不會渲染任何內容。

您可以使用 placeholder 插槽在伺服器端顯示預留位置,並在用戶端擷取預先渲染頁面的使用者工作階段時顯示。

<template>
  <header>
    <AuthState>
      <template #default="{ loggedIn, clear }">
        <button v-if="loggedIn" @click="clear">Logout</button>
        <NuxtLink v-else to="/login">Login</NuxtLink>
      </template>
      <template #placeholder>
        <button disabled>Loading...</button>
      </template>
    </AuthState>
  </header>
</template>

如果您使用 routeRules 快取您的路由,請務必使用 Nitro >= 2.9.7 以支援客戶端提取使用者會話。

設定

我們利用 runtimeConfig.sessionh3 useSession 提供預設選項。

您可以在您的 nuxt.config.ts 中覆寫這些選項

export default defineNuxtConfig({
  modules: ['nuxt-auth-utils'],
  runtimeConfig: {
    session: {
      maxAge: 60 * 60 * 24 * 7 // 1 week
    }
  }
})

我們的預設值為

{
  name: 'nuxt-session',
  password: process.env.NUXT_SESSION_PASSWORD || '',
  cookie: {
    sameSite: 'lax'
  }
}

您也可以透過將會話設定作為 setUserSessionreplaceUserSession 函數的第三個參數傳遞來覆寫會話設定

await setUserSession(event, { ... } , {
  maxAge: 60 * 60 * 24 * 7 // 1 week
})

請查看 SessionConfig 以取得所有選項。

更多

  • nuxt-authorization: 用於管理 Nuxt 應用程式內權限的授權模組,與 nuxt-auth-utils 相容

開發

# Install dependencies
npm install

# Generate type stubs
npm run dev:prepare

# Develop with the playground
npm run dev

# Build the playground
npm run dev:build

# Run ESLint
npm run lint

# Run Vitest
npm run test
npm run test:watch

# Release new version
npm run release