用户认证与授权体系

认证(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 Token15-30 分钟API 鉴权HttpOnly Cookie
Refresh Token7-30 天续期 Access TokenHttpOnly 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上传视频、发评论、管理自己的内容
creatoruser 权限 + 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 防窃取
  • OAuth2defineOAuthGitHubEventHandler 快速集成第三方登录,邮箱关联多 Provider
  • RBAC:角色层级模型(visitor → user → creator → admin),中间件 + API 双重校验
  • 资源权限:所有权校验确保用户只能操作自己的资源,admin 可跨越
  • 密码安全:bcrypt 哈希 + Zod 密码策略 + 限流防暴力破解
  • Token 吊销:Redis 黑名单机制弥补 JWT 无状态的缺陷