通过 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 Composable

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
  • 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 设置为 <您的域名>/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
      }
    }
  }
})

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 !== 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上找到。

扩展会话

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

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