通过 100+ 技巧学习 Nuxt!

nuxt-auth-utils

用于 Nuxt 的极简主义 Auth 模块,支持 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

提供商为大写 (GITHUB, GOOGLE, etc.)

支持的 OAuth 提供商

  • Apple
  • Atlassian
  • Auth0
  • Authentik
  • AWS Cognito
  • Battle.net
  • Bluesky (AT 协议)
  • 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 环境变量来覆盖默认值。

密码哈希

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 (passkey)

WebAuthn (Web Authentication) 是一种 Web 标准,通过使用公钥密码学用 passkey 替换密码来增强安全性。用户可以使用生物特征数据(如指纹或面部识别)或物理设备(如 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 表,拥有唯一的标识符(例如用户名或电子邮件)非常重要(此处我们使用电子邮件)。创建新凭据时,需要此标识符,并将其与用户设备、密码管理器或身份验证器上的 passkey 一起存储。
  • 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 上找到。

扩展会话

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

// 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 以支持客户端获取用户会话。

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 函数的第 3 个参数传递来覆盖会话配置

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