Nuxt Nation 大会即将到来。加入我们,时间为 11 月 12 日至 13 日。

nuxt-auth-utils

用于 Nuxt 的极简主义身份验证模块,支持服务器端渲染。

Nuxt 身份验证实用程序

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 组合式 API

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

可以在 nuxt.config.ts 中的 runtimeConfig 中直接定义 config

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
  • AWS Cognito
  • Battle.net
  • Discord
  • Dropbox
  • Facebook
  • GitHub
  • GitLab
  • Google
  • Instagram
  • Keycloak
  • Linear
  • LinkedIn
  • Microsoft
  • PayPal
  • Polar
  • Spotify
  • Steam
  • TikTok
  • Twitch
  • VK
  • X(Twitter)
  • XSUAA
  • Yandex

您可以通过在 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 身份验证)是一种 Web 标准,它通过使用公钥加密用密钥替换密码来增强安全性。用户可以使用生物识别数据(如指纹或面部识别)或物理设备(如 USB 密钥)进行身份验证,从而降低网络钓鱼和密码泄露的风险。这种方法提供了一种更安全、更用户友好的身份验证方法,受主要浏览器和平台支持。

要启用 WebAuthn,您需要

  1. 安装对等依赖项
npx nypm i @simplewebauthn/server@11 @simplewebauthn/browser@11
  1. nuxt.config.ts 中启用它
export default defineNuxtConfig({
  auth: {
    webAuthn: true
  }
})

示例

在此示例中,我们将实现注册和验证凭据的最基本步骤。

完整的代码可以在 游乐场 中找到。该示例使用 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 以利用凭据。同样,有关更多详细信息,请参阅 此部分

以下代码不包含实际的数据库查询,但显示了要遵循的常规步骤。完整的示例可以在游乐场中找到:注册身份验证 以及 数据库设置

// server/api/webauthn/register.post.ts
import { z } from 'zod'
export default defineWebAuthnRegisterEventHandler({
  // optional
  validateUser: z.object({
    // we want the userName to be a valid email
    userName: z.string().email() 
  }).parse,
  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 上获取。

扩展会话

我们利用钩子让您能够使用自己的数据扩展会话数据,或者在用户清除会话时记录日志。

// 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