nuxt-auth-utils

Nuxt 的极简主义 Auth 模块,支持 SSR。

Nuxt Auth Utils

npm versionnpm downloadsLicenseNuxt

为 Nuxt 应用程序添加身份验证,带有安全且密封的 Cookie 会话。

功能

它依赖项少(仅来自 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 时没有设置 NUXT_SESSION_PASSWORD,Nuxt Auth Utils 会为您生成一个。

  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 类型,从而定义用户会话的类型。

!注意 如果您使用的是 Nuxt >=4.0.0 或兼容版本 4,请将 auth.d.ts 文件添加到 shared 目录,以在服务器和客户端获取正确的类型。

// 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 提供商

  • Apple
  • Atlassian
  • Auth0
  • Authentik
  • AWS Cognito
  • Azure B2C
  • Battle.net
  • Bluesky (AT Protocol)
  • Discord
  • Dropbox
  • Facebook
  • GitHub
  • GitLab
  • Gitea
  • Google
  • Heroku
  • Hubspot
  • Instagram
  • Kick
  • Keycloak
  • Line
  • Linear
  • LinkedIn
  • LiveChat
  • Microsoft
  • Okta
  • Ory
  • PayPal
  • Polar
  • Salesforce
  • Seznam
  • Slack
  • 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
      }
    }
  }
})

AT 协议

依赖于 AT 协议的社交网络(例如 Bluesky)与常规 OAuth 流程略有不同。

要启用 AT 协议的 OAuth,您需要

  1. 安装对等依赖项
npx nypm i @atproto/oauth-client-node @atproto/api
  1. nuxt.config.ts 中启用它。
export default defineNuxtConfig({
  auth: {
    atproto: true
  }
})

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 !== 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(),则必须在 SSR 期间使用 useRequestFetch() 进行经过身份验证的请求。

<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 中,在预渲染期间无法访问。

这意味着您在预渲染期间不应依赖用户会话。

您还可以选择指示 Nuxt Auth Utils 仅在客户端获取用户会话,使用您的 nuxt.config.ts 中的 loadStrategy 选项。

export default defineNuxtConfig({
  auth: {
    loadStrategy: 'client-only'
  }
})

当使用 client-only 加载策略时,仍然可以通过从 useUserSession 可组合项调用 fetch,在服务器端手动获取用户会话。

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

如果页面已缓存或预渲染,或者加载策略设置为 client-only,则在客户端获取用户会话之前,不会渲染任何内容。

您可以使用 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
    }
  }
})

您可以在 upgrade 函数中使用 requireUserSession 函数来检查用户是否已通过身份验证,然后再升级 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