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