Skip to content

用户认证模块

认证流程总览

text
┌─────────── 登录/注册请求 ───────────┐
│                                      │
│  1. Zod 校验输入                     │
│  2. 验证码校验(如需)               │
│  3. 查询用户 / 创建用户              │
│  4. 签发 access_token (7天)          │
│  5. 签发 refresh_token (30天)        │
│  6. 设置 Cookie(管理后台用)         │
│  7. 返回 Token(移动端用)            │
│                                      │
└──────────────────────────────────────┘

┌─────────── 后续 API 请求 ───────────┐
│                                      │
│  请求 → 认证中间件                   │
│    ├─ 检查 Authorization: Bearer     │  ← 移动端
│    ├─ 检查 Cookie: auth-token        │  ← 管理后台
│    ├─ 验证 JWT 签名                  │
│    ├─ 检查黑名单(如已登出)          │
│    └─ 写入 event.context             │
│                                      │
└──────────────────────────────────────┘

┌─────────── Token 过期 ──────────────┐
│                                      │
│  401 → 前端用 refresh_token          │
│    → POST /api/auth/refresh          │
│    → 返回新的 access_token           │
│    + 新的 refresh_token              │
│                                      │
└──────────────────────────────────────┘

为什么是双 Token 机制?

方案优点缺点
单 Token(长期)简单泄露后无法撤销,只能等过期
单 Token(短期)泄露窗口小用户频繁重新登录,体验差
双 Tokenaccess_token 短期 + refresh_token 长期实现稍复杂,但兼顾安全与体验

双 Token 的核心思路:access_token 有效期短(7 天),即使泄露影响有限;refresh_token 有效期长(30 天),但只在刷新时使用,可以做到"只用一次"(刷新后旧 refresh_token 失效)。

JWT 工具函数

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

export const signToken = (payload: { userId: number; role: string }) => {
  const config = useRuntimeConfig()
  return jwt.sign(payload, config.jwtSecret, { expiresIn: '7d' })
}

export const signRefreshToken = (payload: { userId: number }) => {
  const config = useRuntimeConfig()
  return jwt.sign(payload, config.jwtSecret, { expiresIn: '30d' })
}

export const verifyToken = (token: string) => {
  const config = useRuntimeConfig()
  return jwt.verify(token, config.jwtSecret) as { userId: number; role: string }
}

为什么 refresh_token 不包含 role?

signRefreshToken 只签入 userId,不包含 role。刷新时会重新查库获取最新角色:

ts
// 刷新时重新查库,确保角色是最新的
const user = await db.select().from(users).where(eq(users.id, payload.userId))
const newToken = signToken({ userId: user[0].id, role: user[0].role })

原因:如果 refresh_token 包含 role,管理员降级后旧 Token 仍携带 admin 角色,需要等到 Token 过期才会生效。不包含 role 可以确保每次刷新都读取最新权限。

更进一步

如果需要更强的即时撤销能力,可以给每个用户加一个 tokenVersion 字段,每次修改角色或密码时递增。JWT 签发时包含 tokenVersion,验证时对比数据库即可判断 Token 是否仍然有效。

密码工具函数

ts
// server/utils/password.ts
import bcrypt from 'bcryptjs'

export const hashPassword = (password: string) => bcrypt.hash(password, 10)
export const verifyPassword = (password: string, hash: string) => bcrypt.compare(password, hash)

bcrypt.hash(password, 10) 中的 10 是什么?

是 salt rounds(盐轮数),值越大计算越慢、安全性越高。10 表示 2^10 = 1024 次迭代,单次哈希约 100ms,是安全与性能的平衡点。生产环境建议 10-12,不要超过 14(过慢影响登录体验)。

发送验证码 API

ts
// server/api/auth/sms/send.post.ts
import { z } from 'zod'

const schema = z.object({
  phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
  purpose: z.enum(['register', 'login', 'resetPassword']),
})

export default defineEventHandler(async (event) => {
  const { phone, purpose } = await readValidatedBody(event, schema.parse)

  // 检查发送频率(60秒内不能重复发送)
  const recent = await db.select().from(smsCodes)
    .where(eq(smsCodes.phone, phone))
    .limit(1)
  if (recent[0] && Date.now() - recent[0].createdAt.getTime() < 60_000) {
    throw createError({ statusCode: 429, statusMessage: 'Too Many Requests', message: '发送太频繁,请稍后再试' })
  }

  // 生成6位验证码
  const code = String(Math.floor(100000 + Math.random() * 900000))

  // 存储验证码
  await db.insert(smsCodes).values({
    phone,
    code,
    expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分钟过期
  })

  // 发送短信
  const config = useRuntimeConfig()
  // 腾讯云 SMS
  const client = new SmsClient({
    credential: { secretId: config.smsSecretId, secretKey: config.smsSecretKey },
    region: 'ap-guangzhou',
  })
  await client.SendSms({
    PhoneNumberSet: [`+86${phone}`],
    SmsSdkAppId: 'your-app-id',
    SignName: '你的签名',
    TemplateId: 'your-template-id',
    TemplateParamSet: [code, '5'],
  })

  return { message: '验证码已发送' }
})

验证码安全要点

安全措施代码位置原因
60 秒发送间隔Date.now() - ... < 60_000防止短信轰炸,1 分钟只能发 1 条
5 分钟过期expiresAt: new Date(Date.now() + 5 * 60 * 1000)验证码有时效性,防止长期有效被暴力破解
used 标记注册/登录时标记 used: true一次性使用,防止重放攻击
Zod 校验手机号z.string().regex(/^1[3-9]\d{9}$/)防止传入非法格式绕过检查

注意

当前频率检查只查最新一条记录,如果用户 60 秒前发过验证码但未使用,旧记录不会失效。生产环境建议同时将未使用的旧验证码标记为 used: true,确保只有最新的验证码有效。

注册 API

ts
// server/api/auth/register.post.ts
import { z } from 'zod'

const schema = z.object({
  phone: z.string().regex(/^1[3-9]\d{9}$/),
  code: z.string().length(6),
  password: z.string().min(6).max(20),
})

export default defineEventHandler(async (event) => {
  const { phone, code, password } = await readValidatedBody(event, schema.parse)

  // 验证验证码
  const smsRecord = await db.select().from(smsCodes)
    .where(and(eq(smsCodes.phone, phone), eq(smsCodes.code, code), eq(smsCodes.used, false)))
    .limit(1)

  if (!smsRecord[0] || smsRecord[0].expiresAt < new Date()) {
    throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '验证码无效或已过期' })
  }

  // 标记验证码已使用
  await db.update(smsCodes).set({ used: true }).where(eq(smsCodes.id, smsRecord[0].id))

  // 检查手机号是否已注册
  const existing = await db.select().from(users).where(eq(users.phone, phone)).limit(1)
  if (existing[0]) {
    throw createError({ statusCode: 409, statusMessage: 'Conflict', message: '该手机号已注册' })
  }

  // 创建用户
  const passwordHash = await hashPassword(password)
  const [user] = await db.insert(users).values({
    phone,
    passwordHash,
    nickname: `用户${phone.slice(-4)}`,
  }).returning()

  // 生成 Token
  const token = signToken({ userId: user.id, role: user.role })
  const refreshToken = signRefreshToken({ userId: user.id })

  // 设置 Cookie(管理后台 Web 端用)
  setCookie(event, 'auth-token', token, {
    httpOnly: true,
    secure: true,
    maxAge: 60 * 60 * 24 * 7,
    sameSite: 'lax',
  })

  return { token, refreshToken, user: { id: user.id, phone: user.phone, nickname: user.nickname } }
})
ts
setCookie(event, 'auth-token', token, {
  httpOnly: true,   // JavaScript 无法读取 → 防 XSS 窃取 Token
  secure: true,     // 仅 HTTPS 传输 → 防中间人截获
  maxAge: 604800,   // 7 天(单位:秒)
  sameSite: 'lax',  // 防 CSRF:跨站请求不自动携带 Cookie
})
选项作用不设置的后果
httpOnly: trueJS 无法 document.cookie 读取XSS 攻击可窃取 Token
secure: true仅 HTTPS 下发送HTTP 下 Token 被明文传输
sameSite: 'lax'跨站 POST 不携带 CookieCSRF 攻击可冒充用户操作

为什么 sameSitelax 而不是 strict

strict 会阻止从外部链接跳转到管理后台时携带 Cookie(用户需要重新登录)。lax 允许顶级导航携带 Cookie,用户体验更好,同时仍然防止 CSRF POST 请求。

登录 API

ts
// server/api/auth/login.post.ts
import { z } from 'zod'

const schema = z.object({
  phone: z.string().regex(/^1[3-9]\d{9}$/),
  password: z.string(),
  code: z.string().length(6).optional(), // 短信验证码登录
})

export default defineEventHandler(async (event) => {
  const { phone, password, code } = await readValidatedBody(event, schema.parse)

  const [user] = await db.select().from(users).where(eq(users.phone, phone)).limit(1)

  if (!user) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '用户不存在' })
  }

  // 密码登录
  if (!code) {
    const valid = await verifyPassword(password, user.passwordHash)
    if (!valid) {
      throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '密码错误' })
    }
  } else {
    // 验证码登录
    const smsRecord = await db.select().from(smsCodes)
      .where(and(eq(smsCodes.phone, phone), eq(smsCodes.code, code), eq(smsCodes.used, false)))
      .limit(1)
    if (!smsRecord[0] || smsRecord[0].expiresAt < new Date()) {
      throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '验证码无效' })
    }
    await db.update(smsCodes).set({ used: true }).where(eq(smsCodes.id, smsRecord[0].id))
  }

  if (user.status === 0) {
    throw createError({ statusCode: 403, statusMessage: 'Forbidden', message: '账号已被禁用' })
  }

  const token = signToken({ userId: user.id, role: user.role })
  const refreshToken = signRefreshToken({ userId: user.id })

  setCookie(event, 'auth-token', token, {
    httpOnly: true,
    secure: true,
    maxAge: 60 * 60 * 24 * 7,
    sameSite: 'lax',
  })

  return {
    token,
    refreshToken,
    user: { id: user.id, phone: user.phone, nickname: user.nickname, role: user.role },
  }
})

登录错误信息的安全考量

注意

示例中 "用户不存在""密码错误" 是不同的错误消息。在生产环境中,建议统一返回"手机号或密码错误",避免攻击者通过不同错误消息判断手机号是否已注册(用户枚举攻击)。

ts
// ❌ 信息泄露:攻击者可以判断手机号是否注册
if (!user) throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '用户不存在' })
if (!valid) throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '密码错误' })

// ✅ 统一错误消息
if (!user || !valid) throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '手机号或密码错误' })

Token 刷新 API

ts
// server/api/auth/refresh.post.ts
export default defineEventHandler(async (event) => {
  const { refreshToken } = await readBody(event)
  if (!refreshToken) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '缺少 refresh token' })
  }

  try {
    const payload = verifyToken(refreshToken)
    const user = await db.select().from(users).where(eq(users.id, payload.userId)).limit(1)
    if (!user[0]) {
      throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '用户不存在' })
    }

    const newToken = signToken({ userId: user[0].id, role: user[0].role })
    const newRefreshToken = signRefreshToken({ userId: user[0].id })

    return { token: newToken, refreshToken: newRefreshToken }
  } catch {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'Token 无效' })
  }
})

为什么刷新后返回新的 refresh_token?

这是"刷新轮转"策略——每次刷新后旧的 refresh_token 即失效。即使旧 refresh_token 被截获,因为已被使用过一次,攻击者无法再次使用。这比"一个 refresh_token 反复用"更安全。

认证中间件

ts
// server/middleware/auth.ts
export default defineEventHandler((event) => {
  const url = getRequestURL(event)

  // 不需要认证的路由
  const publicRoutes = ['/api/auth/login', '/api/auth/register', '/api/auth/sms/send', '/api/auth/refresh', '/api/payments']
  if (publicRoutes.some(route => url.pathname.startsWith(route))) return

  if (!url.pathname.startsWith('/api/')) return

  const auth = getRequestHeader(event, 'authorization')
  if (!auth?.startsWith('Bearer ')) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '未登录' })
  }

  try {
    const token = auth.replace('Bearer ', '')
    const payload = verifyToken(token)
    event.context.userId = payload.userId
    event.context.userRole = payload.role
  } catch {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'Token 无效' })
  }
})

中间件设计要点

为什么用白名单而不是黑名单?

白名单(列出不需要认证的路由)比黑名单(列出需要认证的路由)更安全——新增 API 时默认需要认证,不会因为忘记添加而暴露接口。

当前的实现只检查了 Authorization: Bearer,未检查 Cookie。完整版应该是:

ts
// 完整双模式认证中间件
export default defineEventHandler((event) => {
  const url = getRequestURL(event)

  // 不需要认证的路由
  const publicRoutes = ['/api/auth/login', '/api/auth/register', '/api/auth/sms/send', '/api/auth/refresh', '/api/payments']
  if (publicRoutes.some(route => url.pathname.startsWith(route))) return
  if (!url.pathname.startsWith('/api/')) return

  let token: string | undefined

  // 1. 优先检查 Authorization header(移动端)
  const auth = getRequestHeader(event, 'authorization')
  if (auth?.startsWith('Bearer ')) {
    token = auth.replace('Bearer ', '')
  }

  // 2. 其次检查 Cookie(管理后台)
  if (!token) {
    token = getCookie(event, 'auth-token')
  }

  if (!token) {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: '未登录' })
  }

  try {
    const payload = verifyToken(token)
    event.context.userId = payload.userId
    event.context.userRole = payload.role
  } catch {
    throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'Token 无效' })
  }
})

优先级理由:移动端明确传 Bearer Token,语义清晰;Cookie 是浏览器自动携带,可能存在过期 Token。优先 Bearer 可确保移动端请求不被旧 Cookie 干扰。

为什么支付回调不需要认证?

/api/payments 在白名单中,因为微信/支付宝回调服务器无法携带你的 JWT。支付回调的安全性通过签名验证保证——微信支付回调会附带签名,你需要验证签名是否合法:

ts
// server/api/payments/wechat/notify.post.ts
// 微信回调会携带签名,验证签名即可确认请求来自微信,不需要 JWT

阿里云 SMS 替代方案

ts
// server/utils/sms-aliyun.ts
import Core from '@alicloud/pop-core'

export const sendAliyunSms = async (phone: string, code: string) => {
  const config = useRuntimeConfig()
  const client = new Core({
    accessKeyId: config.aliyunAccessKeyId,
    accessKeySecret: config.aliyunAccessKeySecret,
    endpoint: 'https://dysmsapi.aliyuncs.com',
    apiVersion: '2017-05-25',
  })

  await client.request('SendSms', {
    PhoneNumbers: phone,
    SignName: '你的签名',
    TemplateCode: 'SMS_123456',
    TemplateParam: JSON.stringify({ code }),
  }, { method: 'POST' })
}

常见问题

1. Token 存储在哪里?

客户端存储 access_token存储 refresh_token原因
Flutter 移动端内存(状态管理)flutter_secure_storage内存中 Token 关闭 App 即清除,secure_storage 用 Keychain/Keystore 加密存储
Web 管理后台Cookie(httpOnly)localStorageCookie 自动携带 + 防 XSS,refresh_token 在 JS 中管理即可(刷新频率低)

为什么移动端不用 localStorage?

Flutter 不是浏览器,没有 localStorage。即使 Web 端,access_token 也不建议存 localStorage——XSS 可直接读取。Cookie 的 httpOnly 属性可以防止 XSS 读取。

2. 登出怎么实现?

JWT 是无状态的,服务端无法主动使 Token 失效。登出需要用 Redis 黑名单:

ts
// server/api/auth/logout.post.ts
export default defineEventHandler(async (event) => {
  const auth = getRequestHeader(event, 'authorization')
  if (auth?.startsWith('Bearer ')) {
    const token = auth.replace('Bearer ', '')
    // 将 Token 加入黑名单,过期时间与 JWT 一致
    await redis.set(`token:blacklist:${token}`, '1', 'EX', 7 * 24 * 3600)
  }

  // 清除 Cookie
  deleteCookie(event, 'auth-token')

  return { message: '已登出' }
})

同时在认证中间件中检查黑名单:

ts
// 在 verifyToken 之后加入黑名单检查
const isBlacklisted = await redis.get(`token:blacklist:${token}`)
if (isBlacklisted) {
  throw createError({ statusCode: 401, statusMessage: 'Unauthorized', message: 'Token 已失效' })
}

3. 密码传输安全

注册/登录 API 传输的密码是明文吗?是的,但这是安全的:

  • HTTPS 加密传输:生产环境必须启用 HTTPS,密码在传输层已加密
  • 服务端哈希存储hashPassword 使用 bcrypt 哈希后存储,数据库中不存明文
  • 不需要前端加密:前端 JS 加密(如 MD5/SHA)反而降低安全性——哈希值本身就是"密码",被截获后同样可以伪造请求

基于 Nuxt 4 官方文档整理编写