Nuxt: 认证 OAuth2 + JWT
最后更新:2026-08-26
Alice 需要注册账号购买商品,Bob 需要管理员登录后台。但 MegaShop 还没有认证系统——任何人都能访问管理接口。Charlie 需要同时支持邮箱注册和 Google/GitHub 社交登录,还要保证 API 安全。
1. 你将学到
- 认证架构设计:Session vs JWT vs Cookie 方案对比
- JWT 实现:签发/验证 + httpOnly Cookie 安全存储
- OAuth2 集成:Google/GitHub 社交登录流程
- 刷新令牌机制:Access Token + Refresh Token 双令牌轮换
- MegaShop 认证实战:Alice 注册登录 + Bob 管理员 OAuth2
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) 认证流程时序图
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),检查黑名单的中间件开销很小。
📖 小节
- MegaShop 选择 JWT + httpOnly Cookie:无状态、防 XSS、SSR 友好
- 双令牌轮换:Access Token 15m + Refresh Token 7d,无感刷新
- OAuth2 流程:重定向到 Google → 用户授权 → 回调换 Token → 创建用户 → 签发 JWT
- Server middleware 鉴权:从 Cookie 读取 JWT → 验证 → 注入 event.context.user
- 所有密钥放 runtimeConfig 私有字段,绝不暴露到客户端
📝 作业
- 基础题(难度⭐):实现邮箱注册和登录 API,用 httpOnly Cookie 存储 JWT
- 进阶题(难度⭐⭐):实现 Refresh Token 刷新机制,Access Token 过期后自动刷新,用户无感
- 挑战题(难度⭐⭐⭐):集成 Google OAuth2 社交登录,实现完整的注册/登录/刷新/注销流程
---|