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大核心痛点

  1. OAuth2集成复杂:Provider配置、回调处理、Token刷新、错误恢复,每个环节都是坑
  2. Session管理混乱:JWT存哪?Refresh Token怎么轮换?多标签页Session同步?
  3. 无密码认证落地难:WebAuthn API复杂、注册流程设计、跨设备同步
  4. 权限控制粗放:前端硬编码权限、API无鉴权、角色变更不及时
  5. 生产安全漏洞: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存储方式。


在线工具推荐

本站提供浏览器本地工具,免注册即可试用 →

#Nuxt4认证#OAuth2#Passkey#JWT#Session管理#2026#前端工程