Nuxt Auth Utils
为 Nuxt 应用程序添加身份验证,带有安全且密封的 Cookie 会话。
功能
- 混合渲染支持 (SSR / CSR / SWR / 预渲染)
- 40 多个 OAuth 提供商
- 密码哈希
- WebAuthn (通行密钥)
useUserSession()Vue 可组合项- 可进行 Tree-shaking 的服务器工具
<AuthState>组件- 可通过 Hook 扩展
- WebSocket 支持
它依赖项少(仅来自 UnJS),可在多种 JS 环境(Node、Deno、Workers)中运行,并使用 TypeScript 完全类型化。
要求
此模块仅适用于运行 Nuxt 服务器时,因为它使用服务器 API 路由(nuxt build)。
这意味着您不能将此模块与 nuxt generate 一起使用。
无论如何,您可以使用 混合渲染 来预渲染应用程序页面或完全禁用服务器端渲染。
快速设置
- 在您的 Nuxt 项目中添加
nuxt-auth-utils
npx nuxi@latest module add auth-utils
- 在
.env中添加一个至少 32 个字符的NUXT_SESSION_PASSWORD环境变量。
# .env
NUXT_SESSION_PASSWORD=password-with-at-least-32-characters
如果在首次在开发中运行 Nuxt 时没有设置 NUXT_SESSION_PASSWORD,Nuxt Auth Utils 会为您生成一个。
- 就是这样!您现在可以为您的 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。
该辅助函数返回一个事件处理程序,该处理程序会自动重定向到提供商授权页面,然后根据结果调用 onSuccess 或 onError。
config 可以直接从您的 nuxt.config.ts 中的 runtimeConfig 定义。
export default defineNuxtConfig({
runtimeConfig: {
oauth: {
// provider in lowercase (github, google, etc.)
<provider>: {
clientId: '...',
clientSecret: '...'
}
}
}
})
它也可以使用环境变量设置
NUXT_OAUTH_<PROVIDER>_CLIENT_IDNUXT_OAUTH_<PROVIDER>_CLIENT_SECRET
提供商名称为大写(GITHUB、GOOGLE 等)
支持的 OAuth 提供商
- Apple
- Atlassian
- Auth0
- Authentik
- AWS Cognito
- Azure B2C
- Battle.net
- Bluesky (AT Protocol)
- Discord
- Dropbox
- GitHub
- GitLab
- Gitea
- Heroku
- Hubspot
- Kick
- Keycloak
- Line
- Linear
- 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 提供了密码哈希工具,如 hashPassword 和 verifyPassword,用于使用 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,您需要
- 安装对等依赖项
npx nypm i @atproto/oauth-client-node @atproto/api
- 在
nuxt.config.ts中启用它。
export default defineNuxtConfig({
auth: {
atproto: true
}
})
WebAuthn (通行密钥)
WebAuthn(Web 身份验证)是一种网络标准,它通过使用公钥加密将密码替换为通行密钥来增强安全性。用户可以使用生物识别数据(如指纹或面部识别)或物理设备(如 USB 密钥)进行身份验证,从而降低网络钓鱼和密码泄露的风险。这种方法提供了更安全和用户友好的身份验证方法,并受到主流浏览器和平台的支持。
要启用 WebAuthn,您需要
- 安装对等依赖项
npx nypm i @simplewebauthn/server@11 @simplewebauthn/browser@11
- 在
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 使用挑战来防止重放攻击。默认情况下,此模块不使用此功能。如果您想使用挑战(强烈推荐),则提供了
storeChallenge和getChallenge函数。每次身份验证请求都会创建一个尝试 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 ORM 和 NuxtHub。
该演示的源代码可在 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.session 为 h3 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'
}
}
您还可以通过将配置作为 setUserSession 和 replaceUserSession 函数的第三个参数来覆盖会话配置。
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