透過 100 多個技巧學習 Nuxt!

nuxt-auth-utils

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

Nuxt Auth Utils

npm versionnpm downloadsLicenseNuxt

使用安全且密封的 Cookie 會話將身份驗證新增至 Nuxt 應用程式。

功能

它幾乎沒有依賴項(僅來自 UnJS),可在多個 JS 環境(Node、Deno、Workers)中執行,並完全使用 TypeScript 進行類型標註。

需求

此模組僅適用於作為伺服器 API 路由執行的 Nuxt 伺服器 (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, openInPopup } = 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>
    <!-- or open the OAuth route in a popup -->
    <button @click="openInPopup('/auth/github')">Login with GitHub</button>
  </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>
  /**
   * Open the OAuth route in a popup that auto-closes when successful.
   */
  openInPopup: (route: string, size?: { width?: number, height?: number }) => 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

Provider 為大寫 (GITHUB、GOOGLE 等)

支援的 OAuth 提供者

  • Apple
  • Atlassian
  • Auth0
  • Authentik
  • AWS Cognito
  • Battle.net
  • Bluesky (AT Protocol)
  • Discord
  • Dropbox
  • Facebook
  • GitHub
  • GitLab
  • Gitea
  • Google
  • Hubspot
  • Instagram
  • Keycloak
  • Line
  • 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 環境變數來覆寫預設的重新導向 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
      }
    }
  }
})

AT Protocol

依賴 AT Protocol 的社群網路(例如 Bluesky)與一般的 OAuth 流程略有不同。

若要啟用與 AT Protocol 的 OAuth,您需要

  1. 安裝對等相依性
npx nypm i @atproto/oauth-client-node @atproto/api
  1. 在您的 nuxt.config.ts 中啟用它
export default defineNuxtConfig({
  auth: {
    atproto: true
  }
})

WebAuthn (通行金鑰)

WebAuthn (Web Authentication) 是一種 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 !== userBody.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 不會在預先渲染期間擷取使用者會話,而是在用戶端(在 hydration 之後)擷取。

這是因為使用者會話儲存在安全的 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 以支援用戶端擷取使用者會話。

WebSocket 支援

Nuxt Auth Utils 與 Nitro WebSockets 相容。

請務必在您的 nuxt.config.ts 中啟用 experimental.websocket 選項

export default defineNuxtConfig({
  nitro: {
    experimental: {
      websocket: true
    }
  }
})

您可以使用 requireUserSession 函數在 upgrade 函數中檢查使用者是否已通過身份驗證,然後再升級 WebSocket 連線。

// server/routes/ws.ts
export default defineWebSocketHandler({
  async upgrade(request) {
    // Make sure the user is authenticated before upgrading the WebSocket connection
    await requireUserSession(request)
  },
  async open(peer) {
    const { user } = await requireUserSession(peer)

    peer.send(`Hello, ${user.name}!`)
  },
  message(peer, message) {
    peer.send(`Echo: ${message}`)
  },
})

然後,在您的應用程式中,您可以使用 useWebSocket 組合式函式來連線到 WebSocket

<script setup>
const { status, data, send, open, close } = useWebSocket('/ws', { immediate: false })

// Only open the websocket after the page is hydrated (client-only)
onMounted(open)
</script>

<template>
  <div>
    <p>Status: {{ status }}</p>
    <p>Data: {{ data }}</p>
    <p>
      <button @click="open">Open</button>
      <button @click="close(1000, 'Closing')">Close</button>
      <button @click="send('hello')">Send hello</button>
    </p>
  </div>
</template>

設定

我們利用 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
pnpm install

# Generate type stubs
pnpm run dev:prepare

# Develop with the playground
pnpm run dev

# Build the playground
pnpm run dev:build

# Run ESLint
pnpm run lint

# Run Vitest
pnpm run test
pnpm run test:watch

# Release new version
pnpm run release