Nuxt: 认证 OAuth2 + JWT

最后更新:2026-08-26

Alice 需要注册账号购买商品,Bob 需要管理员登录后台。但 MegaShop 还没有认证系统——任何人都能访问管理接口。Charlie 需要同时支持邮箱注册和 Google/GitHub 社交登录,还要保证 API 安全。

1. 你将学到


2. 一个架构师的真实故事

(1) 痛点:无认证的致命安全漏洞

Alice 的购物车数据任何人都能读取,Bob 的管理接口没有鉴权,Charlie 发现攻击者可以伪造请求删除商品。MegaShop 必须有认证系统。

(2) JWT + OAuth2 的解法

JWT 无状态认证 + OAuth2 社交登录,兼顾安全与用户体验:

TYPESCRIPT
// Server: issue JWT after login
const token = jwt.sign({ userId: user.id, role: user.role }, secret, { expiresIn: '15m' })
setCookie(event, 'access-token', token, { httpOnly: true })

(3) 收益:安全 + 便捷

Alice 用 Google 一键登录,Bob 用管理员账号访问后台,API 请求携带 JWT 自动鉴权。


3. 认证方案对比

(1) 三种认证方案

维度 Session + Cookie JWT + Cookie JWT + localStorage
状态 有状态(服务端存储) 无状态 无状态
扩展性 🔴 需共享 Session 🟢 天然分布式 🟢 分布式
SSR 兼容 ✅ Cookie 自动携带 ✅ Cookie 自动携带 ❌ 需手动处理
CSRF 风险 🔴 需防护 🟢 httpOnly 防护 🟢 无 Cookie
XSS 风险 🟢 httpOnly 🟢 httpOnly 🔴 JS 可读取
注销 ✅ 即时删除 Session ⚠️ 需黑名单 ⚠️ 需黑名单
💡 提示: MegaShop 选择 JWT + httpOnly Cookie 方案——无状态适合分布式、httpOnly 防 XSS、Cookie 自动在 SSR 请求中携带。

(2) 认证流程时序图

100%
sequenceDiagram
    participant A as Alice/Browser
    participant N as Nuxt Server
    participant G as Google OAuth2
    participant DB as Database

    A->>N: Click "Sign in with Google"
    N->>G: Redirect to Google consent screen
    G-->>A: User grants permission
    A->>N: Callback with authorization code
    N->>G: Exchange code for user info
    G-->>N: User profile (email, name)
    N->>DB: Create/find user
    DB-->>N: User record
    N->>N: Sign JWT (access + refresh)
    N-->>A: Set httpOnly cookies
    A->>A: Redirect to dashboard

4. JWT 签发与验证

▶ 示例:JWT 工具函数

TYPESCRIPT
// server/utils/jwt.ts
import jwt from 'jsonwebtoken'

const config = useRuntimeConfig()

interface JwtPayload {
  userId: number
  email: string
  role: 'customer' | 'admin'
}

export function signAccessToken(payload: JwtPayload): string {
  return jwt.sign(payload, config.jwtAccessSecret, { expiresIn: '15m' })
}

export function signRefreshToken(payload: JwtPayload): string {
  return jwt.sign(payload, config.jwtRefreshSecret, { expiresIn: '7d' })
}

export function verifyAccessToken(token: string): JwtPayload {
  return jwt.verify(token, config.jwtAccessSecret) as JwtPayload
}

export function verifyRefreshToken(token: string): JwtPayload {
  return jwt.verify(token, config.jwtRefreshSecret) as JwtPayload
}

输出:

TEXT 📖 仅展示
// 执行成功

▶ 示例:邮箱注册 API

TYPESCRIPT
// server/api/auth/register.post.ts
export default defineEventHandler(async (event) => {
  const { email, password, name } = await readBody(event)

  if (!email || !password) {
    throw createError({ statusCode: 400, message: 'Email and password required' })
  }

  // Check if user exists
  const existing = mockUsers.find(u => u.email === email)
  if (existing) {
    throw createError({ statusCode: 409, message: 'Email already registered' })
  }

  // Create user
  const user = {
    id: mockUsers.length + 1,
    email,
    name: name || 'Customer',
    password: await hashPassword(password),
    role: 'customer' as const
  }
  mockUsers.push(user)

  // Issue tokens
  const payload = { userId: user.id, email: user.email, role: user.role }
  const accessToken = signAccessToken(payload)
  const refreshToken = signRefreshToken(payload)

  // Set httpOnly cookies
  setCookie(event, 'access-token', accessToken, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900 // 15m
  })
  setCookie(event, 'refresh-token', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 604800 // 7d
  })

  return { user: { id: user.id, email: user.email, name: user.name, role: user.role } }
})

输出:

TEXT 📖 仅展示
// 执行成功

▶ 示例:邮箱登录 API

TYPESCRIPT
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)

  const user = mockUsers.find(u => u.email === email)
  if (!user || !await verifyPassword(password, user.password)) {
    throw createError({ statusCode: 401, message: 'Invalid credentials' })
  }

  const payload = { userId: user.id, email: user.email, role: user.role }
  const accessToken = signAccessToken(payload)
  const refreshToken = signRefreshToken(payload)

  setCookie(event, 'access-token', accessToken, { httpOnly: true, secure: true, maxAge: 900 })
  setCookie(event, 'refresh-token', refreshToken, { httpOnly: true, secure: true, maxAge: 604800 })

  return { user: { id: user.id, email: user.email, name: user.name, role: user.role } }
})

输出:

TEXT 📖 仅展示
// 执行成功

5. OAuth2 社交登录

▶ 示例:Google OAuth2 登录

TYPESCRIPT
// server/api/auth/google.get.ts
export default defineEventHandler((event) => {
  const config = useRuntimeConfig()
  const redirectUri = `${config.public.apiBase}/auth/google/callback`
  const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
    `client_id=${config.googleClientId}&` +
    `redirect_uri=${redirectUri}&` +
    `response_type=code&` +
    `scope=openid email profile&` +
    `state=${generateState()}`

  return sendRedirect(event, googleAuthUrl)
})

输出:

TEXT 📖 仅展示
// 执行成功

▶ 示例:Google OAuth2 回调处理

TYPESCRIPT
// server/api/auth/google/callback.get.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const code = getQuery(event).code as string

  // Exchange code for tokens
  const tokenResponse = await $fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    body: {
      code,
      client_id: config.googleClientId,
      client_secret: config.googleClientSecret,
      redirect_uri: `${config.public.apiBase}/auth/google/callback`,
      grant_type: 'authorization_code'
    }
  })

  // Get user info from Google
  const userInfo = await $fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
    headers: { Authorization: `Bearer ${tokenResponse.access_token}` }
  })

  // Find or create user
  let user = mockUsers.find(u => u.email === userInfo.email)
  if (!user) {
    user = {
      id: mockUsers.length + 1,
      email: userInfo.email,
      name: userInfo.name,
      avatar: userInfo.picture,
      provider: 'google',
      role: 'customer'
    }
    mockUsers.push(user)
  }

  // Issue JWT tokens
  const payload = { userId: user.id, email: user.email, role: user.role }
  setCookie(event, 'access-token', signAccessToken(payload), { httpOnly: true, secure: true, maxAge: 900 })
  setCookie(event, 'refresh-token', signRefreshToken(payload), { httpOnly: true, secure: true, maxAge: 604800 })

  return sendRedirect(event, '/')
})

输出:

TEXT 📖 仅展示
// 执行成功

6. 双令牌轮换机制

(1) Token 生命周期

Token 有效期 存储 用途
Access Token 15 分钟 httpOnly Cookie API 鉴权
Refresh Token 7 天 httpOnly Cookie 刷新 Access Token

▶ 示例:刷新令牌 API

TYPESCRIPT
// server/api/auth/refresh.post.ts
export default defineEventHandler((event) => {
  const refreshToken = getCookie(event, 'refresh-token')

  if (!refreshToken) {
    throw createError({ statusCode: 401, message: 'No refresh token' })
  }

  try {
    const payload = verifyRefreshToken(refreshToken)
    const newAccessToken = signAccessToken({
      userId: payload.userId,
      email: payload.email,
      role: payload.role
    })

    setCookie(event, 'access-token', newAccessToken, {
      httpOnly: true, secure: true, sameSite: 'lax', maxAge: 900
    })

    return { message: 'Token refreshed' }
  } catch {
    // Refresh token expired - force login
    setCookie(event, 'access-token', '', { maxAge: 0 })
    setCookie(event, 'refresh-token', '', { maxAge: 0 })
    throw createError({ statusCode: 401, message: 'Refresh token expired' })
  }
})

输出:

TEXT 📖 仅展示
// 执行成功

▶ 示例:API 鉴权中间件

TYPESCRIPT
// server/middleware/auth.ts
export default defineEventHandler((event) => {
  const url = getRequestURL(event)
  if (!url.pathname.startsWith('/api/admin') && !url.pathname.startsWith('/api/cart')) return

  const token = getCookie(event, 'access-token')
  if (!token) {
    throw createError({ statusCode: 401, message: 'Authentication required' })
  }

  try {
    const payload = verifyAccessToken(token)
    event.context.user = payload

    // Admin-only routes
    if (url.pathname.startsWith('/api/admin') && payload.role !== 'admin') {
      throw createError({ statusCode: 403, message: 'Admin access required' })
    }
  } catch {
    throw createError({ statusCode: 401, message: 'Invalid or expired token' })
  }
})

输出:

TEXT 📖 仅展示
// 执行成功

7. 综合示例:MegaShop 认证流程

TYPESCRIPT
// nuxt.config.ts - JWT secrets in runtimeConfig
export default defineNuxtConfig({
  runtimeConfig: {
    jwtAccessSecret: process.env.JWT_ACCESS_SECRET || 'dev-access-secret',
    jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret',
    googleClientId: process.env.GOOGLE_CLIENT_ID,
    googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
    public: {
      apiBase: process.env.API_BASE || 'http://localhost:3000/api'
    }
  }
})
VUE
<!-- pages/login.vue -->
<template>
  <div class="login-page">
    <h1>Sign In to MegaShop</h1>

    <!-- Email login form -->
    <form @submit.prevent="loginWithEmail">
      <input v-model="email" type="email" placeholder="Email" required />
      <input v-model="password" type="password" placeholder="Password" required />
      <button type="submit">Sign In</button>
    </form>

    <!-- Social login -->
    <div class="social-login">
      <p>Or sign in with:</p>
      <a href="/api/auth/google" class="btn-google">Sign in with Google</a>
      <a href="/api/auth/github" class="btn-github">Sign in with GitHub</a>
    </div>
  </div>
</template>

<script setup lang="ts">
const email = ref('')
const password = ref('')

async function loginWithEmail() {
  await $fetch('/api/auth/login', {
    method: 'POST',
    body: { email: email.value, password: password.value }
  })
  navigateTo('/')
}
</script>

❓ 常见问题

Q JWT 存 Cookie 还是 localStorage?
A 推荐 httpOnly Cookie——防止 XSS 窃取 Token,SSR 自动携带。localStorage 容易被 XSS 攻击。
Q Access Token 为什么只有 15 分钟?
A 缩短有效期降低 Token 泄露的风险。配合 Refresh Token 实现无感刷新,用户不会频繁重新登录。
Q Refresh Token 泄露了怎么办?
A 实现 Refresh Token 轮换——每次用 Refresh Token 换新 Access Token 时,同时发新的 Refresh Token,旧的一次性失效。
Q OAuth2 的 state 参数有什么用?
A 防 CSRF 攻击。state 是随机字符串,回调时验证是否匹配,确保请求来自你的应用而非攻击者。
Q SSR 时怎么读取 Cookie 进行鉴权?
A useCookie('access-token') 在 SSR 时从请求的 Cookie 头中读取,客户端时从 document.cookie 读取,两端一致。
Q JWT 无法主动注销怎么办?
A 短有效期(15m)降低影响。如需即时注销,维护一个 Token 黑名单(存 Redis),检查黑名单的中间件开销很小。

📖 小节


📝 作业

  1. 基础题(难度⭐):实现邮箱注册和登录 API,用 httpOnly Cookie 存储 JWT
  2. 进阶题(难度⭐⭐):实现 Refresh Token 刷新机制,Access Token 过期后自动刷新,用户无感
  3. 挑战题(难度⭐⭐⭐):集成 Google OAuth2 社交登录,实现完整的注册/登录/刷新/注销流程

---|

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏