Nuxt 4全栈认证实战:从OAuth2到Passkey的5种生产模式
Nuxt 4全栈认证实战:从OAuth2到Passkey的5种生产模式
你的Nuxt应用还在用localStorage存token?OAuth2登录流程写得像意大利面条?Passkey无密码认证只停留在PPT上?2026年,Nuxt4全栈认证体系已经成熟——从OAuth2/OIDC到Passkey/WebAuthn,从JWT Session到RBAC权限,5种生产级认证模式让你的应用安全又好用。
背景知识
2026年Web认证技术栈全景
| 维度 | 传统方案 | 2026生产方案 |
|---|---|---|
| 身份验证 | 用户名+密码 | OAuth2/OIDC + Passkey |
| 会话管理 | Cookie/LocalStorage | JWT + Refresh Token + HttpOnly Cookie |
| 无密码认证 | 短信验证码 | WebAuthn/Passkey |
| 权限控制 | 前端v-if判断 | RBAC + Route Middleware |
| 安全防护 | 基础HTTPS | CSRF Token + Rate Limit + Security Headers |
| 多因素认证 | 可选 | 渐进式MFA |
Nuxt4认证生态核心模块
- Nuxt Auth Utils:官方认证工具集,内置OAuth2/OIDC Provider
- h3 Session:Nitro内置Session管理,支持加密签名Cookie
- WebAuthn API:浏览器原生Passkey支持,无需第三方SDK
- Route Middleware:Nuxt4增强的路由守卫,支持细粒度权限控制
问题分析
全栈认证的5大核心痛点
- OAuth2集成复杂:Provider配置、回调处理、Token刷新、错误恢复,每个环节都是坑
- Session管理混乱:JWT存哪?Refresh Token怎么轮换?多标签页Session同步?
- 无密码认证落地难:WebAuthn API复杂、注册流程设计、跨设备同步
- 权限控制粗放:前端硬编码权限、API无鉴权、角色变更不及时
- 生产安全漏洞:CSRF攻击、暴力破解、XSS窃取Token、安全头缺失
分步实操
Pattern 1:OAuth2/OIDC集成与Nuxt Auth Utils
npm install @nuxt/auth-utils
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/auth-utils'],
runtimeConfig: {
oauth: {
github: {
clientId: '',
clientSecret: '',
},
google: {
clientId: '',
clientSecret: '',
},
},
},
})
// server/routes/auth/github.ts
export default defineOAuthGitHubEventHandler({
config: {
emailRequired: true,
},
async onSuccess(event, { user, tokens }) {
const existingUser = await findUserByOAuth('github', user.id)
if (existingUser) {
await setUserSession(event, {
user: {
id: existingUser.id,
name: existingUser.name,
email: existingUser.email,
avatar: user.avatar_url,
role: existingUser.role,
},
})
} else {
const newUser = await createUserFromOAuth({
provider: 'github',
providerId: String(user.id),
name: user.name,
email: user.email,
avatar: user.avatar_url,
})
await setUserSession(event, {
user: {
id: newUser.id,
name: newUser.name,
email: newUser.email,
avatar: user.avatar_url,
role: newUser.role,
},
})
}
return sendRedirect(event, '/dashboard')
},
onError(event, error) {
console.error('GitHub OAuth error:', error)
return sendRedirect(event, '/login?error=oauth_failed')
},
})
// server/routes/auth/google.ts
export default defineOAuthGoogleEventHandler({
config: {
scope: ['openid', 'email', 'profile'],
},
async onSuccess(event, { user, tokens }) {
const existingUser = await findUserByOAuth('google', user.sub)
if (existingUser) {
await setUserSession(event, {
user: {
id: existingUser.id,
name: existingUser.name,
email: user.email,
avatar: user.picture,
role: existingUser.role,
},
})
} else {
const newUser = await createUserFromOAuth({
provider: 'google',
providerId: user.sub,
name: user.name,
email: user.email,
avatar: user.picture,
})
await setUserSession(event, {
user: {
id: newUser.id,
name: newUser.name,
email: user.email,
avatar: user.picture,
role: newUser.role,
},
})
}
return sendRedirect(event, '/dashboard')
},
onError(event, error) {
console.error('Google OAuth error:', error)
return sendRedirect(event, '/login?error=oauth_failed')
},
})
<!-- pages/login.vue -->
<script lang="ts" setup>
const { status, signIn } = useAuth()
const route = useRoute()
const errorMsg = computed(() => {
const error = route.query.error as string
if (error === 'oauth_failed') return 'OAuth登录失败,请重试'
if (error === 'session_expired') return '会话已过期,请重新登录'
return ''
})
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="w-full max-w-md p-8 bg-white rounded-xl shadow-lg">
<h1 class="text-2xl font-bold text-center mb-8">登录工具库</h1>
<div v-if="errorMsg" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{{ errorMsg }}
</div>
<div class="space-y-3">
<button
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
@click="signIn('github')"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub 登录
</button>
<button
class="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
@click="signIn('google')"
>
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google 登录
</button>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-gray-200" /></div>
<div class="relative flex justify-center text-sm"><span class="px-4 bg-white text-gray-500">或</span></div>
</div>
<NuxtLink
to="/login/passkey"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
Passkey 无密码登录
</NuxtLink>
</div>
</div>
</div>
</template>
Pattern 2:JWT + Refresh Token Session管理
// server/utils/jwt.ts
import { sign, verify } from 'hono/jwt'
const JWT_SECRET = process.env.JWT_SECRET!
const REFRESH_SECRET = process.env.REFRESH_SECRET!
const ACCESS_TOKEN_TTL = '15m'
const REFRESH_TOKEN_TTL = '7d'
interface AccessTokenPayload {
sub: string
email: string
role: string
type: 'access'
}
interface RefreshTokenPayload {
sub: string
tokenVersion: number
type: 'refresh'
}
export async function signAccessToken(payload: Omit<AccessTokenPayload, 'type' | 'exp' | 'iat'>): Promise<string> {
return await sign(
{ ...payload, type: 'access', exp: Math.floor(Date.now() / 1000) + 15 * 60 },
JWT_SECRET
)
}
export async function signRefreshToken(payload: Omit<RefreshTokenPayload, 'type' | 'exp' | 'iat'>): Promise<string> {
return await sign(
{ ...payload, type: 'refresh', exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60 },
REFRESH_SECRET
)
}
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
const payload = await verify(token, JWT_SECRET)
if (payload.type !== 'access') throw new Error('Invalid token type')
return payload as AccessTokenPayload
}
export async function verifyRefreshToken(token: string): Promise<RefreshTokenPayload> {
const payload = await verify(token, REFRESH_SECRET)
if (payload.type !== 'refresh') throw new Error('Invalid token type')
return payload as RefreshTokenPayload
}
// server/utils/session.ts
export async function createAuthSession(event: H3Event, user: { id: string; email: string; role: string }) {
const accessToken = await signAccessToken({
sub: user.id,
email: user.email,
role: user.role,
})
const refreshToken = await signRefreshToken({
sub: user.id,
tokenVersion: await getUserTokenVersion(user.id),
})
setCookie(event, 'access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 15 * 60,
path: '/',
})
setCookie(event, 'refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60,
path: '/api/auth/refresh',
})
await setUserSession(event, {
user: {
id: user.id,
email: user.email,
role: user.role,
},
})
}
export async function refreshAuthSession(event: H3Event) {
const refreshToken = getCookie(event, 'refresh_token')
if (!refreshToken) throw createError({ statusCode: 401, message: 'No refresh token' })
const payload = await verifyRefreshToken(refreshToken)
const currentVersion = await getUserTokenVersion(payload.sub)
if (payload.tokenVersion !== currentVersion) {
deleteCookie(event, 'access_token')
deleteCookie(event, 'refresh_token')
throw createError({ statusCode: 401, message: 'Token revoked' })
}
const user = await findUserById(payload.sub)
if (!user) throw createError({ statusCode: 401, message: 'User not found' })
await createAuthSession(event, user)
return { success: true }
}
// server/api/auth/refresh.post.ts
export default defineEventHandler(async (event) => {
return await refreshAuthSession(event)
})
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
if (session?.user?.id) {
await incrementUserTokenVersion(session.user.id)
}
deleteCookie(event, 'access_token', { path: '/' })
deleteCookie(event, 'refresh_token', { path: '/api/auth/refresh' })
await clearUserSession(event)
return { success: true }
})
// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/') || event.path.startsWith('/api/auth/')) return
const accessToken = getCookie(event, 'access_token')
if (!accessToken) {
const refreshToken = getCookie(event, 'refresh_token')
if (refreshToken && event.path !== '/api/auth/refresh') {
try {
await refreshAuthSession(event)
return
} catch {
throw createError({ statusCode: 401, message: 'Session expired' })
}
}
throw createError({ statusCode: 401, message: 'Unauthorized' })
}
try {
const payload = await verifyAccessToken(accessToken)
event.context.auth = payload
} catch {
const refreshToken = getCookie(event, 'refresh_token')
if (refreshToken) {
try {
await refreshAuthSession(event)
return
} catch {
throw createError({ statusCode: 401, message: 'Session expired' })
}
}
throw createError({ statusCode: 401, message: 'Invalid token' })
}
})
// composables/useAuthSession.ts
export function useAuthSession() {
const user = useUser()
const status = computed(() => (user.value ? 'authenticated' : 'unauthenticated'))
async function refresh() {
try {
await $fetch('/api/auth/refresh', { method: 'POST' })
} catch {
await logout()
}
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
navigateTo('/login')
}
return { user, status, refresh, logout }
}
Pattern 3:Passkey/WebAuthn无密码认证
// server/utils/webauthn.ts
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'
const RP_NAME = 'ToolsKu'
const RP_ID = process.env.NUXT_PUBLIC_SITE_URL ? new URL(process.env.NUXT_PUBLIC_SITE_URL).hostname : 'localhost'
const ORIGIN = process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000'
export async function createRegistrationOptions(userId: string, userEmail: string) {
const existingCredentials = await getUserCredentials(userId)
const options = await generateRegistrationOptions({
rpName: RP_NAME,
rpID: RP_ID,
userID: userId,
userName: userEmail,
attestationType: 'none',
excludeCredentials: existingCredentials.map(cred => ({
id: cred.credentialId,
type: 'public-key' as const,
transports: cred.transports as AuthenticatorTransport[],
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
authenticatorAttachment: 'platform',
},
})
await storeChallenge(userId, options.challenge)
return options
}
export async function verifyRegistration(userId: string, response: RegistrationResponseJSON) {
const challenge = await getChallenge(userId)
if (!challenge) throw new Error('Challenge expired')
const verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
})
if (verification.verified && verification.registrationInfo) {
await saveCredential(userId, {
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
credentialDeviceType: verification.registrationInfo.credentialDeviceType,
credentialBackedUp: verification.registrationInfo.credentialBackedUp,
transports: response.response.transports,
})
}
await deleteChallenge(userId)
return verification
}
export async function createAuthenticationOptions(userId?: string) {
const options = await generateAuthenticationOptions({
rpID: RP_ID,
allowCredentials: userId
? (await getUserCredentials(userId)).map(cred => ({
id: cred.credentialId,
type: 'public-key' as const,
transports: cred.transports as AuthenticatorTransport[],
}))
: undefined,
userVerification: 'preferred',
})
if (userId) {
await storeChallenge(userId, options.challenge)
} else {
await storeChallenge('anonymous', options.challenge)
}
return options
}
export async function verifyAuthentication(response: AuthenticationResponseJSON) {
const credential = await findCredentialById(response.id)
if (!credential) throw new Error('Credential not found')
const challenge = await getChallenge(credential.userId)
if (!challenge) throw new Error('Challenge expired')
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: ORIGIN,
expectedRPID: RP_ID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
},
})
if (verification.verified) {
await updateCredentialCounter(credential.credentialId, verification.authenticationInfo.newCounter)
}
await deleteChallenge(credential.userId)
return { verified: verification.verified, userId: credential.userId }
}
// server/api/auth/passkey/register-options.post.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
if (!session?.user?.id) {
throw createError({ statusCode: 401, message: 'Login required' })
}
const options = await createRegistrationOptions(session.user.id, session.user.email)
return options
})
// server/api/auth/passkey/register-verify.post.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
if (!session?.user?.id) {
throw createError({ statusCode: 401, message: 'Login required' })
}
const body = await readBody(event)
const verification = await verifyRegistration(session.user.id, body)
return { verified: verification.verified }
})
// server/api/auth/passkey/authenticate-options.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const options = await createAuthenticationOptions(body?.userId)
return options
})
// server/api/auth/passkey/authenticate-verify.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = await verifyAuthentication(body)
if (result.verified) {
const user = await findUserById(result.userId)
if (user) {
await createAuthSession(event, user)
}
}
return { verified: result.verified }
})
<!-- composables/usePasskey.ts -->
<script lang="ts">
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
export function usePasskey() {
const isSupported = ref(false)
const isRegistering = ref(false)
const isAuthenticating = ref(false)
onMounted(() => {
isSupported.value = window.PublicKeyCredential !== undefined
})
async function register() {
if (!isSupported.value || isRegistering.value) return
isRegistering.value = true
try {
const options = await $fetch<PublicKeyCredentialCreationOptionsJSON>('/api/auth/passkey/register-options', {
method: 'POST',
})
const credential = await startRegistration({ optionsJSON: options })
const result = await $fetch('/api/auth/passkey/register-verify', {
method: 'POST',
body: credential,
})
return result.verified
} catch (error) {
if (error instanceof Error && error.name === 'InvalidStateError') {
console.warn('Passkey already registered')
}
throw error
} finally {
isRegistering.value = false
}
}
async function authenticate() {
if (!isSupported.value || isAuthenticating.value) return
isAuthenticating.value = true
try {
const options = await $fetch<PublicKeyCredentialRequestOptionsJSON>('/api/auth/passkey/authenticate-options', {
method: 'POST',
})
const credential = await startAuthentication({ optionsJSON: options })
const result = await $fetch('/api/auth/passkey/authenticate-verify', {
method: 'POST',
body: credential,
})
return result.verified
} catch (error) {
throw error
} finally {
isAuthenticating.value = false
}
}
return { isSupported, isRegistering, isAuthenticating, register, authenticate }
}
</script>
<!-- pages/login/passkey.vue -->
<script lang="ts" setup>
const { isSupported, isAuthenticating, authenticate } = usePasskey()
const errorMessage = ref('')
async function handlePasskeyLogin() {
errorMessage.value = ''
try {
const verified = await authenticate()
if (verified) {
navigateTo('/dashboard')
}
} catch (error) {
errorMessage.value = 'Passkey登录失败,请重试'
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="w-full max-w-md p-8 bg-white rounded-xl shadow-lg">
<h1 class="text-2xl font-bold text-center mb-6">Passkey 无密码登录</h1>
<div v-if="!isSupported" class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800 text-sm">
你的浏览器不支持Passkey,请使用最新版Chrome/Safari/Edge。
</div>
<div v-if="errorMessage" class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{{ errorMessage }}
</div>
<button
:disabled="!isSupported || isAuthenticating"
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 transition-colors"
@click="handlePasskeyLogin"
>
<svg v-if="!isAuthenticating" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/></svg>
<svg v-else class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{{ isAuthenticating ? '验证中...' : '使用Passkey登录' }}
</button>
<div class="mt-6 text-center">
<NuxtLink to="/login" class="text-sm text-indigo-600 hover:text-indigo-500">返回其他登录方式</NuxtLink>
</div>
</div>
</div>
</template>
Pattern 4:RBAC权限控制与Route Middleware
// server/utils/rbac.ts
export type Role = 'superadmin' | 'admin' | 'editor' | 'viewer'
export const PERMISSIONS = {
superadmin: ['*'],
admin: ['users:read', 'users:write', 'users:delete', 'content:read', 'content:write', 'content:delete', 'settings:read', 'settings:write'],
editor: ['content:read', 'content:write', 'settings:read'],
viewer: ['content:read'],
} as const
export type Permission = typeof PERMISSIONS[Role][number]
export function hasPermission(role: Role, permission: string): boolean {
const perms = PERMISSIONS[role]
if (!perms) return false
if (perms.includes('*' as any)) return true
return (perms as readonly string[]).includes(permission)
}
export function hasAnyPermission(role: Role, permissions: string[]): boolean {
return permissions.some(p => hasPermission(role, p))
}
export function hasAllPermissions(role: Role, permissions: string[]): boolean {
return permissions.every(p => hasPermission(role, p))
}
// server/middleware/rbac.ts
export default defineEventHandler(async (event) => {
const auth = event.context.auth
if (!auth) return
const role = auth.role as Role
const path = event.path
const method = event.method
const routePermissions: Record<string, { methods: string[]; permission: string }[]> = {
'/api/users': [
{ methods: ['GET'], permission: 'users:read' },
{ methods: ['POST', 'PUT', 'PATCH'], permission: 'users:write' },
{ methods: ['DELETE'], permission: 'users:delete' },
],
'/api/content': [
{ methods: ['GET'], permission: 'content:read' },
{ methods: ['POST', 'PUT', 'PATCH'], permission: 'content:write' },
{ methods: ['DELETE'], permission: 'content:delete' },
],
'/api/settings': [
{ methods: ['GET'], permission: 'settings:read' },
{ methods: ['POST', 'PUT', 'PATCH'], permission: 'settings:write' },
],
}
for (const [routePath, rules] of Object.entries(routePermissions)) {
if (!path.startsWith(routePath)) continue
const matchedRule = rules.find(rule => rule.methods.includes(method))
if (matchedRule && !hasPermission(role, matchedRule.permission)) {
throw createError({ statusCode: 403, message: 'Insufficient permissions' })
}
}
})
// middleware/role.global.ts
export default defineNuxtRouteMiddleware((to) => {
const { data: session } = useNuxtApp().$auth
if (!session.value?.user) {
return navigateTo('/login')
}
const role = session.value.user.role as Role
const routeRoles = to.meta.roles as Role[] | undefined
if (routeRoles && !routeRoles.includes(role)) {
return navigateTo('/403')
}
})
// pages/dashboard/admin.vue
definePageMeta({
roles: ['superadmin', 'admin'],
})
// pages/dashboard/editor.vue
definePageMeta({
roles: ['superadmin', 'admin', 'editor'],
})
<!-- components/PermissionGuard.vue -->
<script lang="ts" setup>
const props = defineProps<{
permission: string
fallback?: boolean
}>()
const { data: session } = useNuxtApp().$auth
const hasAccess = computed(() => {
if (!session.value?.user?.role) return false
return hasPermission(session.value.user.role as Role, props.permission)
})
</script>
<template>
<slot v-if="hasAccess" />
<slot v-else name="fallback" />
</template>
<!-- 使用示例 -->
<template>
<div>
<h1>内容管理</h1>
<PermissionGuard permission="content:write">
<button class="px-4 py-2 bg-indigo-600 text-white rounded-lg">新建内容</button>
</PermissionGuard>
<PermissionGuard permission="content:delete" :fallback="false">
<button class="px-4 py-2 bg-red-600 text-white rounded-lg">删除内容</button>
<template #fallback>
<span class="text-gray-400 text-sm">无删除权限</span>
</template>
</PermissionGuard>
</div>
</template>
Pattern 5:生产安全(CSRF、Rate Limiting、Security Headers)
// server/middleware/csrf.ts
const CSRF_WHITELIST = ['/api/auth/github', '/api/auth/google', '/api/auth/passkey']
export default defineEventHandler(async (event) => {
if (event.method === 'GET' || event.method === 'HEAD' || event.method === 'OPTIONS') return
if (!event.path.startsWith('/api/')) return
if (CSRF_WHITELIST.some(path => event.path.startsWith(path))) return
const origin = getRequestHeader(event, 'origin') || getRequestHeader(event, 'referer')
const host = process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000'
if (!origin || !origin.startsWith(host)) {
throw createError({ statusCode: 403, message: 'CSRF check failed' })
}
})
// server/middleware/rate-limit.ts
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
export default defineEventHandler(async (event) => {
if (!event.path.startsWith('/api/auth/')) return
const clientIp = getRequestIP(event, { xForwardedFor: true }) || 'unknown'
const key = `rate:${clientIp}:${event.path}`
const now = Date.now()
const windowMs = 60 * 1000
const maxRequests = event.path.includes('/passkey') ? 10 : 30
const record = rateLimitMap.get(key)
if (!record || now > record.resetAt) {
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs })
return
}
record.count++
if (record.count > maxRequests) {
setResponseHeader(event, 'Retry-After', String(Math.ceil((record.resetAt - now) / 1000)))
throw createError({ statusCode: 429, message: 'Too many requests' })
}
})
// server/middleware/security-headers.ts
export default defineEventHandler((event) => {
setResponseHeader(event, 'X-Content-Type-Options', 'nosniff')
setResponseHeader(event, 'X-Frame-Options', 'DENY')
setResponseHeader(event, 'X-XSS-Protection', '0')
setResponseHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin')
setResponseHeader(event, 'Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
setResponseHeader(event, 'Content-Security-Policy', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.github.com https://accounts.google.com",
"frame-ancestors 'none'",
].join('; '))
})
// nuxt.config.ts - 安全配置汇总
export default defineNuxtConfig({
modules: ['@nuxt/auth-utils'],
runtimeConfig: {
jwtSecret: '',
refreshSecret: '',
oauth: {
github: { clientId: '', clientSecret: '' },
google: { clientId: '', clientSecret: '' },
},
},
nitro: {
compressPublicAssets: true,
},
routeRules: {
'/api/auth/**': { cors: false },
'/api/**': { headers: { 'Cache-Control': 'no-store' } },
},
})
完整代码:生产级认证系统
// server/api/users/index.get.ts
export default defineEventHandler(async (event) => {
const auth = event.context.auth
if (!auth) throw createError({ statusCode: 401, message: 'Unauthorized' })
if (!hasPermission(auth.role as Role, 'users:read')) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const users = await db.user.findMany({
select: { id: true, name: true, email: true, role: true, createdAt: true },
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
})
const total = await db.user.count()
return { users, pagination: { page, limit, total } }
})
// server/api/users/[id].patch.ts
export default defineEventHandler(async (event) => {
const auth = event.context.auth
if (!auth) throw createError({ statusCode: 401, message: 'Unauthorized' })
if (!hasPermission(auth.role as Role, 'users:write')) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const userId = getRouterParam(event, 'id')!
const body = await readBody(event)
const allowedFields = ['name', 'email', 'role']
const updateData: Record<string, any> = {}
for (const field of allowedFields) {
if (body[field] !== undefined) {
updateData[field] = body[field]
}
}
if (updateData.role && auth.role !== 'superadmin' && auth.role !== 'admin') {
delete updateData.role
}
const updated = await db.user.update({
where: { id: userId },
data: updateData,
select: { id: true, name: true, email: true, role: true },
})
return updated
})
// server/api/users/[id]/credentials.get.ts
export default defineEventHandler(async (event) => {
const auth = event.context.auth
if (!auth) throw createError({ statusCode: 401, message: 'Unauthorized' })
const userId = getRouterParam(event, 'id')!
if (auth.sub !== userId && !hasPermission(auth.role as Role, 'users:read')) {
throw createError({ statusCode: 403, message: 'Forbidden' })
}
const credentials = await db.credential.findMany({
where: { userId },
select: {
id: true,
credentialDeviceType: true,
createdAt: true,
lastUsedAt: true,
},
})
return credentials
})
<!-- pages/dashboard/index.vue -->
<script lang="ts" setup>
definePageMeta({
middleware: 'role',
roles: ['superadmin', 'admin', 'editor', 'viewer'],
})
const { data: session } = useNuxtApp().$auth
const { register: registerPasskey, isSupported: passkeySupported } = usePasskey()
async function handleRegisterPasskey() {
try {
const verified = await registerPasskey()
if (verified) {
useToast().add({ title: 'Passkey注册成功', color: 'green' })
}
} catch {
useToast().add({ title: 'Passkey注册失败', color: 'red' })
}
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold">欢迎回来,{{ session?.user?.name }}</h1>
<p class="text-gray-500 mt-1">角色:{{ session?.user?.role }}</p>
</div>
<button
v-if="passkeySupported"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors text-sm"
@click="handleRegisterPasskey"
>
注册Passkey
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<PermissionGuard permission="users:read">
<NuxtLink to="/dashboard/users" class="block p-6 bg-white rounded-xl border border-gray-200 hover:border-indigo-300 transition-colors">
<h3 class="font-semibold text-lg">用户管理</h3>
<p class="text-gray-500 text-sm mt-1">管理用户账号与权限</p>
</NuxtLink>
</PermissionGuard>
<PermissionGuard permission="content:read">
<NuxtLink to="/dashboard/content" class="block p-6 bg-white rounded-xl border border-gray-200 hover:border-indigo-300 transition-colors">
<h3 class="font-semibold text-lg">内容管理</h3>
<p class="text-gray-500 text-sm mt-1">编辑与发布内容</p>
</NuxtLink>
</PermissionGuard>
<PermissionGuard permission="settings:read">
<NuxtLink to="/dashboard/settings" class="block p-6 bg-white rounded-xl border border-gray-200 hover:border-indigo-300 transition-colors">
<h3 class="font-semibold text-lg">系统设置</h3>
<p class="text-gray-500 text-sm mt-1">配置系统参数</p>
</NuxtLink>
</PermissionGuard>
</div>
</div>
</template>
避坑指南
坑1:OAuth2回调在SSR模式下重复执行
现象:OAuth2 Provider回调后,服务端和客户端各执行一次session设置,导致Session不一致。
解决:OAuth2回调路由只在服务端执行,使用sendRedirect跳转到目标页面,不要在回调路由中返回Vue组件。确保回调URL在routeRules中配置为纯API路由。
坑2:JWT存localStorage被XSS窃取
现象:将Access Token存储在localStorage中,一旦存在XSS漏洞,Token立即被窃取。
解决:Access Token和Refresh Token都存储在HttpOnly Cookie中,前端通过/api/auth/session接口获取用户信息,而非直接读取Token。Cookie设置SameSite=Lax防止CSRF。
坑3:Passkey注册时Challenge过期
现象:用户在Passkey注册流程中停留过久,提交时Challenge已过期,验证失败。
解决:Challenge有效期设置为5分钟,注册页面添加超时提示。如果验证失败,自动重新获取Challenge并提示用户重试。
坑4:RBAC中间件顺序错误
现象:RBAC权限中间件在Auth中间件之前执行,event.context.auth为undefined导致500错误。
解决:确保server/middleware/中的文件命名保证执行顺序。Auth中间件命名为1.auth.ts,RBAC中间件命名为2.rbac.ts,Nitro按文件名字典序执行中间件。
坑5:Rate Limit在Edge Runtime下内存泄漏
现象:使用内存Map实现Rate Limit,部署到Cloudflare Workers后内存持续增长。
解决:生产环境使用Cloudflare KV或Redis存储Rate Limit计数器,内存Map仅用于开发环境。定期清理过期记录。
报错排查
| 序号 | 报错信息 | 原因 | 解决方法 |
|---|---|---|---|
| 1 | OAuth2 state mismatch |
回调state参数不匹配 | 检查Cookie是否被清除,确保SameSite配置正确 |
| 2 | jwt malformed |
Token格式错误 | 检查Token是否完整,是否被URL编码截断 |
| 3 | jwt expired |
Access Token过期 | 触发Refresh Token轮换,检查时钟同步 |
| 4 | Token revoked |
Token Version不匹配 | 用户已修改密码或管理员已吊销Token |
| 5 | WebAuthn is not supported |
浏览器不支持Passkey | 降级为OAuth2登录,提示用户升级浏览器 |
| 6 | Challenge expired |
Passkey Challenge过期 | 重新获取注册/认证选项 |
| 7 | Credential not found |
Passkey凭证不存在 | 用户未注册Passkey或已删除 |
| 8 | CSRF check failed |
Origin/Referer校验失败 | 检查请求来源,OAuth回调加入白名单 |
| 9 | 429 Too Many Requests |
请求频率超限 | 等待冷却时间,检查是否有异常请求 |
| 10 | Insufficient permissions |
RBAC权限不足 | 检查用户角色和所需权限配置 |
进阶优化
1. 多标签页Session同步
// composables/useSessionSync.ts
export function useSessionSync() {
const channel = ref<BroadcastChannel | null>(null)
onMounted(() => {
if (typeof BroadcastChannel !== 'undefined') {
channel.value = new BroadcastChannel('auth-session')
channel.value.onmessage = (event) => {
if (event.data.type === 'session-updated') {
refreshNuxtApp()
}
if (event.data.type === 'session-cleared') {
navigateTo('/login')
}
}
}
})
function notifySessionUpdate(type: 'session-updated' | 'session-cleared') {
channel.value?.postMessage({ type })
}
onUnmounted(() => {
channel.value?.close()
})
return { notifySessionUpdate }
}
2. 渐进式MFA
// server/middleware/mfa.ts
const MFA_REQUIRED_ROLES: Role[] = ['admin', 'superadmin']
const SENSITIVE_PATHS = ['/api/users', '/api/settings']
export default defineEventHandler(async (event) => {
const auth = event.context.auth
if (!auth) return
const requiresMfa = MFA_REQUIRED_ROLES.includes(auth.role as Role)
&& SENSITIVE_PATHS.some(p => event.path.startsWith(p))
if (!requiresMfa) return
const mfaVerified = getCookie(event, 'mfa_verified')
if (!mfaVerified) {
throw createError({ statusCode: 403, message: 'MFA required' })
}
})
3. Token自动刷新
// composables/useAutoRefresh.ts
export function useAutoRefresh() {
const REFRESH_INTERVAL = 14 * 60 * 1000
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
timer = setInterval(async () => {
try {
await $fetch('/api/auth/refresh', { method: 'POST' })
} catch {
clearInterval(timer!)
navigateTo('/login')
}
}, REFRESH_INTERVAL)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
}
4. 审计日志
// server/utils/audit.ts
export async function auditLog(event: {
userId: string
action: string
resource: string
resourceId?: string
ip: string
userAgent: string
status: 'success' | 'failure'
}) {
await db.auditLog.create({ data: event })
}
// server/api/users/[id].patch.ts 中使用
export default defineEventHandler(async (event) => {
const auth = event.context.auth
const userId = getRouterParam(event, 'id')!
try {
const result = await updateUser(userId, await readBody(event))
await auditLog({
userId: auth.sub,
action: 'update',
resource: 'user',
resourceId: userId,
ip: getRequestIP(event, { xForwardedFor: true }) || 'unknown',
userAgent: getRequestHeader(event, 'user-agent') || 'unknown',
status: 'success',
})
return result
} catch (error) {
await auditLog({
userId: auth.sub,
action: 'update',
resource: 'user',
resourceId: userId,
ip: getRequestIP(event, { xForwardedFor: true }) || 'unknown',
userAgent: getRequestHeader(event, 'user-agent') || 'unknown',
status: 'failure',
})
throw error
}
})
对比分析
| 维度 | Nuxt Auth Utils | NextAuth.js v5 | Lucia | Supabase Auth |
|---|---|---|---|---|
| OAuth2集成 | 内置多Provider | 内置多Provider | 需手动 | 内置 |
| Session管理 | h3 Session加密 | JWT/Database | Database | JWT |
| Passkey支持 | 需集成@simplewebauthn | 实验性 | 需手动 | 内置 |
| RBAC | 需自建 | 内置Callbacks | 需自建 | 内置RLS |
| 自托管 | 完全自托管 | 完全自托管 | 完全自托管 | 需Supabase |
| 学习曲线 | 低 | 中 | 中 | 低 |
| Nuxt4兼容 | 原生 | 不适用 | 需适配 | 需适配 |
| 生产就绪 | 2026成熟 | 成熟 | 成熟 | 成熟 |
总结展望
总结:Nuxt4全栈认证的5种生产模式——OAuth2/OIDC集成解决第三方登录、JWT+Refresh Token实现安全Session管理、Passkey/WebAuthn引领无密码认证趋势、RBAC中间件实现细粒度权限控制、生产安全防护堵住常见漏洞。核心原则:Token存HttpOnly Cookie、中间件按顺序执行、Passkey降级方案不可少、Rate Limit用外部存储、审计日志全程覆盖。建议新项目直接采用Nuxt Auth Utils + h3 Session + Passkey三件套,存量项目逐步迁移Session存储方式。
在线工具推荐
- JSON格式化(JWT调试):/zh-CN/json/format
- Hash计算(密码哈希):/zh-CN/encode/hash
- Base64编解码(Token解析):/zh-CN/encode/base64
本站提供浏览器本地工具,免注册即可试用 →