用户认证与授权体系
认证(Authentication)解决"你是谁",授权(Authorization)解决"你能做什么"。Nuxt4 作为全栈框架,可以在一个项目中完整实现从登录到权限控制的全链路。本章从 JWT 双 Token 机制讲起,到 OAuth2 第三方登录,再到 RBAC 权限模型,构建一套生产级的认证授权体系。
1. 认证方案选型
1.1 常见认证方案对比
| 方案 | 原理 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| Session + Cookie | 服务端存储会话,Cookie 传 Session ID | 服务端可控,随时吊销 | 有状态,多实例需共享 Session | 传统 Web 应用 |
| JWT | 无状态令牌,客户端存储 | 无状态,天然支持分布式 | 无法主动吊销(除非黑名单) | API 服务、微服务 |
| JWT + Refresh Token | 短期 Access + 长期 Refresh | 安全性好,可续期 | 实现复杂度稍高 | 生产级 Web 应用(推荐) |
| OAuth2 / OIDC | 委托第三方认证 | 用户无需注册 | 依赖第三方 | 社交登录 |
1.2 推荐架构
对于 AI 视频平台这类 Nuxt4 全栈应用,推荐 JWT 双 Token + OAuth2 社交登录 的组合:
┌──────────────────────────────────────┐
│ 客户端 │
│ Cookie: access_token (HttpOnly) │
│ Cookie: refresh_token (HttpOnly) │
├──────────────────────────────────────┤
│ 服务端 API │
│ 验证 Access Token → 处理请求 │
│ Access Token 过期 → 用 Refresh 续期 │
│ Refresh Token 过期 → 要求重新登录 │
└──────────────────────────────────────┘
2. nuxt-auth-utils 集成
2.1 安装与配置
Nuxt 官方推荐使用 nuxt-auth-utils(由 Nuxt 核心团队维护):
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-auth-utils'],
runtimeConfig: {
session: {
maxAge: 60 * 60 * 24 * 7, // 7 天
},
oauth: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
},
},
})2.2 核心 API
nuxt-auth-utils 提供了一套简洁的 Session 管理 API:
// 服务端:设置用户 Session
// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
const user = await verifyCredentials(email, password)
if (!user) throw createError({ statusCode: 401, message: '邮箱或密码错误' })
// 设置 Session(自动管理加密 Cookie)
await setUserSession(event, {
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
},
})
return { success: true }
})// 服务端:获取当前用户
// server/api/user/me.get.ts
export default defineEventHandler(async (event) => {
const session = await getUserSession(event)
if (!session.user) throw createError({ statusCode: 401 })
return session.user
})<!-- 客户端:使用 Session -->
<script setup>
const { loggedIn, user, session, clear: logout } = useUserSession()
</script>
<template>
<div v-if="loggedIn">
<p>欢迎,{{ user.name }}</p>
<button @click="logout()">退出登录</button>
</div>
<NuxtLink v-else to="/login">登录</NuxtLink>
</template>2.3 Session 保护中间件
// server/utils/auth.ts
export async function requireAuth(event: H3Event) {
const session = await getUserSession(event)
if (!session.user) {
throw createError({ statusCode: 401, message: '请先登录' })
}
return session.user
}
export async function requireRole(event: H3Event, role: string) {
const user = await requireAuth(event)
if (user.role !== role) {
throw createError({ statusCode: 403, message: '权限不足' })
}
return user
}// server/api/admin/users.get.ts
export default defineEventHandler(async (event) => {
await requireRole(event, 'admin')
return db.select().from(usersTable)
})3. JWT 双 Token 机制
3.1 为什么需要双 Token
单 Token 方案的困境:
- Token 有效期短(如 15 分钟):安全但用户频繁需要重新登录
- Token 有效期长(如 7 天):体验好但 Token 泄露后风险窗口大
双 Token 解决方案:
| Token | 有效期 | 用途 | 存储 |
|---|---|---|---|
| Access Token | 15-30 分钟 | API 鉴权 | HttpOnly Cookie |
| Refresh Token | 7-30 天 | 续期 Access Token | HttpOnly Cookie |
3.2 工作流程
1. 用户登录 → 服务端返回 Access Token + Refresh Token
2. 客户端请求 → 携带 Access Token → 服务端验证 → 正常响应
3. Access Token 过期 → 服务端返回 401
4. 客户端自动用 Refresh Token 请求续期 → 获得新 Access Token
5. Refresh Token 过期 → 用户重新登录
3.3 服务端实现
// server/utils/jwt.ts
import jwt from 'jsonwebtoken'
const config = useRuntimeConfig()
interface TokenPayload {
userId: string
role: string
}
export function generateTokens(payload: TokenPayload) {
const accessToken = jwt.sign(payload, config.jwtSecret, { expiresIn: '15m' })
const refreshToken = jwt.sign(
{ userId: payload.userId, type: 'refresh' },
config.jwtRefreshSecret,
{ expiresIn: '7d' }
)
return { accessToken, refreshToken }
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, config.jwtSecret) as TokenPayload
}
export function verifyRefreshToken(token: string) {
return jwt.verify(token, config.jwtRefreshSecret) as { userId: string; type: string }
}// server/api/auth/login.post.ts
export default defineEventHandler(async (event) => {
const { email, password } = await readBody(event)
const user = await verifyCredentials(email, password)
if (!user) throw createError({ statusCode: 401, message: '邮箱或密码错误' })
const { accessToken, refreshToken } = generateTokens({
userId: user.id,
role: user.role,
})
// Access Token:短期,HttpOnly
setCookie(event, 'access_token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 15, // 15 分钟
path: '/',
})
// Refresh Token:长期,HttpOnly,限定路径
setCookie(event, 'refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 天
path: '/api/auth/refresh', // 只在续期接口携带
})
return { user: { id: user.id, name: user.name, role: user.role } }
})// server/api/auth/refresh.post.ts
export default defineEventHandler(async (event) => {
const refreshToken = getCookie(event, 'refresh_token')
if (!refreshToken) throw createError({ statusCode: 401, message: '请重新登录' })
try {
const payload = verifyRefreshToken(refreshToken)
const user = await getUserById(payload.userId)
if (!user) throw createError({ statusCode: 401 })
const { accessToken, refreshToken: newRefreshToken } = generateTokens({
userId: user.id,
role: user.role,
})
// 轮换 Refresh Token(Rotation)—— 每次续期都生成新的 Refresh Token
setCookie(event, 'access_token', accessToken, {
httpOnly: true, secure: true, sameSite: 'lax',
maxAge: 60 * 15, path: '/',
})
setCookie(event, 'refresh_token', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, path: '/api/auth/refresh',
})
return { success: true }
} catch {
throw createError({ statusCode: 401, message: '请重新登录' })
}
})3.4 客户端自动续期
// app/composables/useAuthFetch.ts
export function useAuthFetch<T>(url: string, opts?: any) {
return useFetch<T>(url, {
...opts,
async onResponseError({ response }) {
// Access Token 过期,自动续期
if (response.status === 401) {
const refreshResult = await $fetch('/api/auth/refresh', { method: 'POST' })
.catch(() => null)
if (refreshResult) {
// 续期成功,重试原始请求
return $fetch(url, opts)
} else {
// Refresh Token 也过期了,跳转登录
navigateTo('/login')
}
}
},
})
}3.5 Refresh Token Rotation
每次使用 Refresh Token 续期时,生成新的 Refresh Token 并使旧的失效。这样即使 Refresh Token 被窃取,攻击者和真实用户不会同时持有有效的 Refresh Token——下一次续期时服务端能检测到异常。
// server/utils/token-store.ts
// 将 Refresh Token 存入 Redis,续期时验证并替换
export async function storeRefreshToken(userId: string, token: string) {
const storage = useStorage('cache')
await storage.setItem(`refresh:${userId}`, token, { ttl: 60 * 60 * 24 * 7 })
}
export async function validateAndRotate(userId: string, oldToken: string) {
const storage = useStorage('cache')
const stored = await storage.getItem<string>(`refresh:${userId}`)
if (stored !== oldToken) {
// Token 不匹配 → 可能被盗用,清除所有 Token
await storage.removeItem(`refresh:${userId}`)
throw new Error('Token rotation violation')
}
// 通过验证,存入新 Token
const newToken = generateRefreshToken(userId)
await storeRefreshToken(userId, newToken)
return newToken
}4. OAuth2 第三方登录
4.1 OAuth2 授权码流程
1. 用户点击"GitHub 登录" → 跳转 GitHub 授权页
2. 用户授权 → GitHub 重定向回 callback URL,携带 code
3. 服务端用 code 换取 access_token → 获取用户信息
4. 创建/关联本地用户 → 设置 Session
4.2 nuxt-auth-utils 的 OAuth 支持
nuxt-auth-utils 内置了主流 OAuth Provider 的支持:
// server/routes/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
config: {
scope: ['user:email'],
},
async onSuccess(event, { user: githubUser, tokens }) {
// 查找或创建本地用户
let user = await findUserByGithubId(githubUser.id)
if (!user) {
user = await createUser({
name: githubUser.login,
email: githubUser.email,
avatar: githubUser.avatar_url,
githubId: githubUser.id,
role: 'user',
})
}
// 设置 Session
await setUserSession(event, {
user: { id: user.id, name: user.name, email: user.email, role: user.role },
})
return sendRedirect(event, '/')
},
onError(event, error) {
console.error('GitHub OAuth error:', error)
return sendRedirect(event, '/login?error=oauth_failed')
},
})<!-- pages/login.vue -->
<template>
<div class="flex flex-col gap-4 max-w-sm mx-auto mt-20">
<h1 class="text-2xl font-bold">登录</h1>
<!-- 第三方登录 -->
<a href="/auth/github" class="btn btn-outline">
GitHub 登录
</a>
<a href="/auth/google" class="btn btn-outline">
Google 登录
</a>
<!-- 邮箱密码登录 -->
<form @submit.prevent="handleLogin">
<input v-model="email" type="email" placeholder="邮箱" />
<input v-model="password" type="password" placeholder="密码" />
<button type="submit">登录</button>
</form>
</div>
</template>4.3 多 Provider 账号关联
用户可能先用 GitHub 登录,后来又用 Google 登录——需要关联到同一个账号:
async function findOrCreateUser(provider: string, profile: OAuthProfile) {
// 1. 先按 Provider ID 查找
let user = await findUserByProvider(provider, profile.id)
if (user) return user
// 2. 按邮箱查找(可能已经用其他方式注册)
if (profile.email) {
user = await findUserByEmail(profile.email)
if (user) {
// 关联新的 Provider
await linkProvider(user.id, provider, profile.id)
return user
}
}
// 3. 创建新用户
return createUser({
name: profile.name,
email: profile.email,
avatar: profile.avatar,
providers: [{ provider, providerId: profile.id }],
})
}5. RBAC 权限控制
5.1 权限模型
RBAC(Role-Based Access Control)基于角色的访问控制:
用户 → 角色 → 权限
| 角色 | 权限 |
|---|---|
visitor | 浏览公开内容 |
user | 上传视频、发评论、管理自己的内容 |
creator | user 权限 + AI 视频生成、数据分析 |
admin | 所有权限 + 用户管理、内容审核 |
5.2 路由中间件
// middleware/auth.ts — 全局认证中间件
export default defineNuxtRouteMiddleware(async (to) => {
const { loggedIn } = useUserSession()
// 需要登录的页面
const authRequired = ['/dashboard', '/upload', '/settings']
if (authRequired.some(path => to.path.startsWith(path)) && !loggedIn.value) {
return navigateTo(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
}
})// middleware/admin.ts — 管理员权限中间件
export default defineNuxtRouteMiddleware(async () => {
const { user } = useUserSession()
if (user.value?.role !== 'admin') {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
})<!-- pages/admin/index.vue -->
<script setup>
definePageMeta({
middleware: ['auth', 'admin'], // 先验证登录,再验证角色
})
</script>5.3 服务端 API 权限校验
// server/utils/permissions.ts
type Role = 'visitor' | 'user' | 'creator' | 'admin'
const ROLE_HIERARCHY: Record<Role, number> = {
visitor: 0,
user: 1,
creator: 2,
admin: 3,
}
export async function requireRole(event: H3Event, minRole: Role) {
const session = await getUserSession(event)
if (!session.user) {
throw createError({ statusCode: 401, message: '请先登录' })
}
const userLevel = ROLE_HIERARCHY[session.user.role as Role] ?? 0
const requiredLevel = ROLE_HIERARCHY[minRole]
if (userLevel < requiredLevel) {
throw createError({ statusCode: 403, message: `需要 ${minRole} 及以上权限` })
}
return session.user
}// server/api/ai/generate.post.ts
export default defineEventHandler(async (event) => {
const user = await requireRole(event, 'creator') // 至少 creator 角色
// ... AI 视频生成逻辑
})5.4 前端权限控制
// app/composables/usePermission.ts
export function usePermission() {
const { user } = useUserSession()
const ROLE_HIERARCHY: Record<string, number> = {
visitor: 0, user: 1, creator: 2, admin: 3,
}
function hasRole(minRole: string): boolean {
if (!user.value) return false
const userLevel = ROLE_HIERARCHY[user.value.role] ?? 0
const requiredLevel = ROLE_HIERARCHY[minRole] ?? Infinity
return userLevel >= requiredLevel
}
const isAdmin = computed(() => hasRole('admin'))
const isCreator = computed(() => hasRole('creator'))
return { hasRole, isAdmin, isCreator }
}<template>
<div>
<!-- 所有登录用户可见 -->
<button @click="uploadVideo">上传视频</button>
<!-- 仅 creator 及以上可见 -->
<button v-if="isCreator" @click="generateAIVideo">AI 生成</button>
<!-- 仅 admin 可见 -->
<NuxtLink v-if="isAdmin" to="/admin">管理后台</NuxtLink>
</div>
</template>
<script setup>
const { isAdmin, isCreator } = usePermission()
</script>6. 资源级权限
6.1 所有权校验
除了角色权限,还需要校验资源所有权——用户只能编辑自己的视频:
// server/api/videos/[id].put.ts
export default defineEventHandler(async (event) => {
const user = await requireRole(event, 'user')
const videoId = getRouterParam(event, 'id')
const video = await getVideoById(videoId)
if (!video) throw createError({ statusCode: 404 })
// 所有权校验:只有作者或管理员可以编辑
if (video.authorId !== user.id && user.role !== 'admin') {
throw createError({ statusCode: 403, message: '无权编辑此视频' })
}
const body = await readBody(event)
return updateVideo(videoId, body)
})6.2 通用权限校验工具
// server/utils/ownership.ts
export async function requireOwnership(
event: H3Event,
resourceOwnerId: string,
options?: { allowAdmin?: boolean }
) {
const user = await requireRole(event, 'user')
const isOwner = resourceOwnerId === user.id
const isAdmin = options?.allowAdmin && user.role === 'admin'
if (!isOwner && !isAdmin) {
throw createError({ statusCode: 403, message: '无权操作此资源' })
}
return user
}7. 密码安全
7.1 哈希存储
// server/utils/password.ts
import bcrypt from 'bcrypt'
const SALT_ROUNDS = 12
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS)
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash)
}永远不要:
- 明文存储密码
- 使用 MD5/SHA 做密码哈希(太快,易暴力破解)
- 自己实现加密算法
7.2 密码策略
import { z } from 'zod'
const passwordSchema = z.string()
.min(8, '密码至少 8 个字符')
.max(128, '密码最多 128 个字符')
.regex(/[A-Z]/, '需要包含大写字母')
.regex(/[a-z]/, '需要包含小写字母')
.regex(/[0-9]/, '需要包含数字')8. 登出与 Token 吊销
8.1 登出实现
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
// 清除 Session
await clearUserSession(event)
// 如果使用 JWT,清除 Cookie
deleteCookie(event, 'access_token', { path: '/' })
deleteCookie(event, 'refresh_token', { path: '/api/auth/refresh' })
return { success: true }
})8.2 JWT 黑名单
JWT 无状态的特性意味着无法直接"吊销"一个 Token。需要黑名单机制:
// server/utils/token-blacklist.ts
export async function blacklistToken(jti: string, expiresIn: number) {
const storage = useStorage('cache')
await storage.setItem(`blacklist:${jti}`, true, { ttl: expiresIn })
}
export async function isTokenBlacklisted(jti: string): Promise<boolean> {
const storage = useStorage('cache')
return !!(await storage.getItem(`blacklist:${jti}`))
}在验证 Token 时检查黑名单:
export async function verifyAndCheckToken(token: string) {
const payload = verifyAccessToken(token)
if (payload.jti && await isTokenBlacklisted(payload.jti)) {
throw new Error('Token has been revoked')
}
return payload
}本章小结
- 方案选型:JWT 双 Token(Access 15min + Refresh 7d)适合 Nuxt4 全栈应用
- nuxt-auth-utils:Nuxt 官方 Session 管理,内置 OAuth Provider 支持
- 双 Token 机制:Access Token 短期鉴权,Refresh Token 长期续期,Rotation 防窃取
- OAuth2:
defineOAuthGitHubEventHandler快速集成第三方登录,邮箱关联多 Provider - RBAC:角色层级模型(visitor → user → creator → admin),中间件 + API 双重校验
- 资源权限:所有权校验确保用户只能操作自己的资源,admin 可跨越
- 密码安全:bcrypt 哈希 + Zod 密码策略 + 限流防暴力破解
- Token 吊销:Redis 黑名单机制弥补 JWT 无状态的缺陷