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!

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#前端工程