用户认证与授权体系

认证(Authentication)回答"你是谁",授权(Authorization)回答"你能做什么"。这两个概念经常被混为一谈,但它们是完全不同的安全维度。一个用户可以通过认证(证明了身份),但没有授权(不允许访问某些资源)。Nuxt4 作为全栈框架,可以在一个项目内完整实现从登录到权限控制的全链路。本章从认证方案的底层取舍讲起,深入 JWT 双 Token 的设计哲学,再到 RBAC 权限模型的工程落地。

1. 认证方案选型:没有最好,只有最合适

1.1 Session vs JWT:有状态与无状态之争

这是 Web 认证领域最经典的架构选择,背后是一致性与可扩展性的权衡。

Session 方案:用户登录后,服务端生成一个 Session 对象存储在内存或数据库中,将 Session ID 通过 Cookie 发给浏览器。后续请求携带 Session ID,服务端查找对应 Session 来识别用户。

  • 优势:服务端完全可控。想踢掉某个用户?删掉 Session 即可,立即生效。
  • 劣势:有状态。单台服务器时没问题,但多实例部署时,Session 必须共享(通常用 Redis 存储)。每次请求都要查 Redis,引入一次网络 IO。

JWT 方案:用户登录后,服务端将用户信息编码到一个签名令牌(JWT)中发给客户端。后续请求携带 JWT,服务端只需验证签名即可,不需要查数据库。

  • 优势:无状态,天然适合分布式部署和微服务。验证只需 CPU 计算(验签),不需网络 IO。
  • 劣势:无法主动吊销。JWT 签发后,在过期之前一直有效——即使用户改了密码或被封号。要实现吊销,就需要黑名单机制(又变成有状态了)。

关键认知:JWT 并不比 Session "更先进"。选哪个取决于你的架构约束。如果你的应用是单体 Nuxt4 全栈应用,Session 方案(nuxt-auth-utils 内置的加密 Cookie Session)更简单可靠。如果你的 Nuxt4 是前端,后端是独立的微服务集群,JWT 更合适。

1.2 Token 存在哪:Cookie vs localStorage

存储方式XSS 风险CSRF 风险SSR 兼容
HttpOnly Cookie安全(JS 不可读)需要 CSRF 防护✅ SSR 请求自动携带
localStorage危险(XSS 可窃取)天然免疫❌ SSR 无法访问
内存(变量)安全(页面关闭即丢失)天然免疫❌ 刷新丢失

对 Nuxt4 SSR 应用,HttpOnly Cookie 是唯一正解。原因有二:

  1. SSR 阶段,服务端需要拿到认证信息来渲染个性化内容。Cookie 会自动随请求发送到 Nitro 服务端,而 localStorage 在服务端根本不存在。
  2. HttpOnly Cookie 对 XSS 免疫——即使攻击者注入了脚本,也无法通过 document.cookie 读取 Token。

1.3 推荐组合

对于绝大多数 Nuxt4 全栈应用:

  • 认证层nuxt-auth-utils(加密 Cookie Session)+ OAuth2 社交登录
  • 如果后端独立:JWT 双 Token + HttpOnly Cookie 存储
  • 授权层:RBAC 角色模型 + 路由中间件 + API 权限校验

2. nuxt-auth-utils:Nuxt 官方的认证方案

2.1 设计哲学

nuxt-auth-utils 由 Nuxt 核心团队维护,设计哲学是加密 Cookie Session——将用户信息加密后存在 Cookie 中,不需要 Redis 或数据库存储 Session。这让单体 Nuxt4 应用的认证变得极其简单。

工作原理:

  1. 用户登录成功 → 服务端调用 setUserSession() 将用户信息用 AES-256 加密后写入 Cookie
  2. 后续请求 → 服务端调用 getUserSession() 从 Cookie 解密出用户信息
  3. 客户端 → useUserSession() composable 自动同步 Session 状态

加密密钥由 NUXT_SESSION_PASSWORD 环境变量控制(至少 32 个字符),Cookie 对客户端来说是一段不可读的密文。

2.2 与 @sidebase/nuxt-auth 的对比

特性nuxt-auth-utils@sidebase/nuxt-auth
维护方Nuxt 核心团队社区
认证方式加密 Cookie SessionNextAuth.js 移植,支持多 Provider
复杂度极简,API 少功能全面,配置多
OAuth 集成内置 GitHub/Google/Discord 等通过 NextAuth Provider 生态
Session 存储加密 Cookie(无需数据库)可选数据库/JWT
适用场景中小型项目、全栈 Nuxt需要复杂认证流程的大型项目

选型建议:优先 nuxt-auth-utils,除非你需要 @sidebase/nuxt-auth 的某些特定 Provider 或高级功能(如 Session 数据库持久化、多因素认证)。

2.3 核心工作流

服务端设置 Session:登录成功后调用 setUserSession(event, { user: { id, name, role } }),模块自动加密写入 Cookie。获取当前用户:getUserSession(event) 解密 Cookie 返回 Session 对象。清除 Session:clearUserSession(event) 删除 Cookie。

客户端 composable:useUserSession() 返回 { loggedIn, user, session, clear }loggedIn 是响应式的 computed,user 直接拿到解密后的用户信息,clear() 调用登出 API。

OAuth 集成:模块提供 defineOAuthGitHubEventHandlerdefineOAuthGoogleEventHandler 等工厂函数,只需在 server/routes/auth/ 下创建对应文件,配置 onSuccess 回调(查找/创建用户 + 设置 Session),OAuth 的授权码交换、Token 获取、用户信息拉取全部自动完成。

3. JWT 双 Token 机制:为什么需要两个 Token

3.1 单 Token 的困境

JWT 的安全性与用户体验存在天然矛盾:

  • Token 有效期短(15 分钟):即使被窃取,攻击窗口也很小。但用户每 15 分钟就要重新登录,体验极差。
  • Token 有效期长(7 天):用户不需要频繁登录。但一旦 Token 泄露,攻击者可以冒充用户长达 7 天。

3.2 双 Token 的解法

用两个不同有效期的 Token 来解耦安全性和体验:

Token有效期用途安全属性
Access Token15-30 分钟每次 API 请求携带短命,泄露影响有限
Refresh Token7-30 天仅在续期时使用长命,但只发送到一个固定端点

核心流程

  1. 登录 → 同时获得 Access Token(AT)和 Refresh Token(RT)
  2. 正常请求 → 携带 AT → 服务端验证 → 响应
  3. AT 过期 → 客户端收到 401 → 自动用 RT 请求续期端点 → 获得新 AT
  4. RT 也过期 → 用户必须重新登录

为什么比单 Token 更安全?

  • AT 有效期短,即使泄露(比如通过日志),15 分钟后自动失效
  • RT 的 Cookie Path 限定为 /api/auth/refresh,只在续期请求时发送,暴露面极小
  • RT 支持 Rotation(下文详解),可以检测 Token 被盗用

3.3 Refresh Token Rotation:检测盗用

Rotation 的核心思想:每次用 Refresh Token 续期时,不仅返回新的 Access Token,同时返回一个新的 Refresh Token,旧的 Refresh Token 立即失效

为什么这能检测盗用?假设攻击者窃取了用户的 RT:

  1. 攻击者用被盗的 RT 续期 → 获得新 AT 和新 RT₂ → 旧 RT 失效
  2. 真实用户用旧 RT 续期 → 服务端发现 RT 已经被用过(不在存储中)→ 检测到异常
  3. 服务端立即吊销该用户的所有 Token → 用户和攻击者都需要重新登录

这种"一次性使用"的 RT 设计,让盗用行为在下一次续期时暴露。代价是服务端需要存储当前有效的 RT(通常用 Redis),这让 JWT 的"无状态"优势打了折扣——但安全性的提升是值得的。

3.4 客户端透明续期

双 Token 机制的用户体验关键:续期过程对用户完全透明。实现方式是封装一个 useAuthFetch composable,在 onResponseError 中拦截 401 响应,自动调用续期接口,续期成功后重试原始请求。用户感知不到 Token 过期了。

需要注意并发场景:如果多个请求同时收到 401,不能同时发起多个续期请求。常见做法是用一个 Promise 锁——第一个 401 触发续期,后续 401 等待同一个续期 Promise 的结果。

4. OAuth2 第三方登录:委托信任

4.1 授权码模式(Authorization Code Flow)

OAuth2 有多种授权模式,Web 应用应该使用授权码模式(最安全):

用户 → 你的应用 → 重定向到 GitHub 授权页
                                    ↓ 用户授权
GitHub → 重定向回你的应用,URL 带 code 参数
                    ↓
你的服务端用 code + client_secret 向 GitHub 换取 access_token
                    ↓
你的服务端用 access_token 向 GitHub 获取用户信息
                    ↓
创建/关联本地用户 → 设置 Session

关键安全点:codeaccess_token 的交换必须在服务端完成(因为需要 client_secret),client_secret 永远不能暴露到客户端。

4.2 多 Provider 账号关联的陷阱

用户可能用 GitHub 登录一次,下次又用 Google 登录——如果邮箱相同,你希望关联到同一个账号。但这里有个安全陷阱:

不能无条件信任第三方提供的邮箱。攻击者可以在自己的 GitHub 账号上设置目标用户的邮箱(GitHub 不验证邮箱唯一性),然后用 GitHub 登录你的应用,就可能关联到目标用户的账号。

安全的关联策略:

  • 只信任 Provider 标记为 verified 的邮箱
  • 关联时发送确认邮件给目标邮箱
  • 或者要求用户在已登录状态下手动绑定其他 Provider

4.3 OAuth 的 State 参数

OAuth 授权请求中应该包含一个随机 state 参数,回调时验证 state 一致。这是 OAuth 的 CSRF 防护——防止攻击者构造一个指向你回调 URL 的链接,让用户不知情地绑定攻击者的第三方账号。nuxt-auth-utils 自动处理了 state 的生成和验证。

5. RBAC 权限模型

5.1 角色层级

RBAC(Role-Based Access Control)的核心是用户→角色→权限的映射。对 AI 视频平台:

角色层级能力
visitor0浏览公开内容
user1visitor + 上传视频、发评论、管理自己的内容
creator2user + AI 视频生成、数据分析、收益提现
admin3所有能力 + 用户管理、内容审核、系统配置

层级设计的好处:权限判断变成数字比较。requireRole('creator') 只需检查 userLevel >= 2,admin 自动满足所有低级别角色的权限要求。

5.2 双重校验原则

权限校验必须在两个层同时执行:

前端层(路由中间件):控制页面访问。未登录用户访问 /dashboard 时重定向到登录页;非 admin 用户访问 /admin 时显示 403。这层的目的是用户体验——提前拦截,避免看到白屏或错误。

后端层(API 权限校验):控制数据和操作。即使前端中间件被绕过(攻击者直接调用 API),后端也能拒绝未授权的请求。这层才是真正的安全防线

一个常见错误:只在前端做权限判断。按钮用 v-if="isAdmin" 隐藏了,但 API 没有校验权限——攻击者可以直接调用 API 执行管理操作。前端权限是体验,后端权限是安全

5.3 资源级权限:所有权校验

角色权限解决"你能做什么类型的操作",但还需要"你能操作哪些资源"。典型场景:用户可以编辑自己的视频,但不能编辑别人的。

所有权校验的通用模式:

  1. 从认证信息中获取当前用户 ID
  2. 从数据库中获取资源的所有者 ID
  3. 比较两者是否匹配
  4. 额外检查:admin 角色可以跨越所有权限制

这个逻辑应该封装为 requireOwnership(event, resource.ownerId, { allowAdmin: true }) 这样的通用工具函数,避免每个 API 重复实现。

6. 密码安全:看起来简单,做错很致命

6.1 为什么不能用 MD5/SHA

MD5 和 SHA 系列是哈希算法,设计目标是快速计算。一张 RTX 4090 显卡每秒可以计算约 200 亿次 MD5 哈希——这意味着 8 位纯数字密码在 0.005 秒内就能被暴力破解。

密码哈希算法(bcrypt、argon2、scrypt)的设计目标恰恰相反——故意很慢。bcrypt 的 cost factor 为 12 时,单次哈希约需 250ms。暴力破解 8 位复杂密码需要几十年。

6.2 bcrypt 的工作原理

bcrypt 内置了三个关键特性:

  • 自动生成 salt:每次哈希都使用不同的随机盐值,相同密码产生不同的哈希,防止彩虹表攻击
  • cost factor:控制计算复杂度。每增加 1,计算时间翻倍。推荐值 12(约 250ms)
  • 固定长度输出:60 个字符,包含算法版本、cost、salt 和哈希值

验证时,bcrypt 从存储的哈希中提取 salt 和 cost,用同样的参数重新哈希输入的密码,比较结果。

6.3 密码策略的平衡

过于严格的密码策略反而降低安全性——用户会写在便签纸上或者用 Password1! 这种形式上满足但实际很弱的密码。

推荐策略:

  • 最小长度 8 个字符(NIST 最新指南建议)
  • 最大长度 128 个字符(防止 bcrypt 的 72 字节输入限制导致的截断攻击)
  • 检查是否在已泄露密码列表中(如 HaveIBeenPwned API)
  • 不要求特殊字符组合(!@#$% 等规则并不显著提升安全性)

7. 登出与 Token 生命周期管理

7.1 登出的完整性

一个"彻底"的登出需要清理所有认证状态:

清理项方式遗漏后果
服务端 Session / 黑名单clearUserSession() 或将 JWT 加入黑名单Token 仍然有效
CookiedeleteCookie() 指定正确的 Path下次请求仍然携带 Token
客户端状态Pinia store 重置、navigateTo('/login')UI 仍然显示登录状态

常见遗漏:deleteCookie('refresh_token') 时没有指定 path: '/api/auth/refresh'——浏览器按 Path 匹配 Cookie,不指定 Path 就删不掉。

7.2 JWT 黑名单:弥补无状态的缺陷

JWT 一旦签发就无法修改或吊销。当用户登出、修改密码或被封号时,已签发的 AT 在过期前仍然有效。

解决方案是Redis 黑名单:将被吊销的 JWT 的 jti(JWT ID)存入 Redis,TTL 设为 AT 的剩余有效期。验证 AT 时额外查一次黑名单。

这确实让 JWT "不那么无状态"了,但只有被吊销的 Token 需要查黑名单,正常请求仍然是无状态验证。黑名单的规模远小于 Session 存储——AT 有效期 15 分钟,黑名单中最多只有 15 分钟内被吊销的 Token。

7.3 "记住我"的实现

"记住我"本质上是控制 Refresh Token 的有效期:

  • 勾选"记住我" → RT 有效期 30 天
  • 不勾选 → RT 有效期为 Session Cookie(浏览器关闭即失效)

实现方式是在设置 RT Cookie 时,勾选了"记住我"就设置 maxAge: 30 * 24 * 60 * 60,不勾选就不设置 maxAge(默认为 Session Cookie)。

8. AI 视频平台的认证授权实践

8.1 业务场景分析

场景认证要求授权要求
浏览视频列表/详情无需登录公开内容,仅隐藏"未发布"视频
上传视频需要登录user 及以上
AI 视频生成需要登录creator 及以上 + 配额检查
编辑/删除视频需要登录视频作者 或 admin
用户管理需要登录admin only
Webhook 回调无需用户认证签名验证

8.2 设计决策

  • Session 方案nuxt-auth-utils)而非 JWT:AI 视频平台是单体 Nuxt4 应用,不需要 JWT 的分布式优势,Session 更简单且支持即时吊销
  • OAuth + 邮箱密码混合登录:GitHub/Google 降低注册门槛,邮箱密码作为兜底
  • 配额系统独立于角色:creator 的 AI 生成配额按订阅计划控制,不是角色权限的一部分
  • API 签名验证:第三方 Webhook(支付回调、AI 模型回调)不走 Session 认证,而是通过 HMAC 签名验证请求来源

本章小结

  • Session vs JWT:不是谁更先进,而是有状态(可控、简单)vs 无状态(可扩展、复杂)的取舍。Nuxt4 单体应用优先 Session
  • Token 存储:HttpOnly Cookie 是 Nuxt4 SSR 的唯一正解——SSR 兼容 + XSS 免疫
  • 双 Token 机制:Access Token 短命保安全,Refresh Token 长命保体验,Rotation 检测盗用
  • OAuth2:授权码模式最安全,state 防 CSRF,多 Provider 关联要验证邮箱真实性
  • RBAC:角色层级 + 数字比较简化权限判断,前端是体验后端是安全,必须双重校验
  • 资源权限:所有权校验确保用户只操作自己的资源,admin 可跨越
  • 密码安全:bcrypt(慢哈希)+ 合理密码策略 + 泄露检测,不用 MD5/SHA
  • Token 生命周期:登出清理三层(服务端 + Cookie + 客户端),JWT 黑名单弥补无状态缺陷