用户认证模块
认证流程总览
┌─────────── 登录/注册请求 ───────────┐
│ │
│ 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(短期) | 泄露窗口小 | 用户频繁重新登录,体验差 |
| 双 Token | access_token 短期 + refresh_token 长期 | 实现稍复杂,但兼顾安全与体验 |
双 Token 的核心思路:access_token 有效期短(7 天),即使泄露影响有限;refresh_token 有效期长(30 天),但只在刷新时使用,可以做到"只用一次"(刷新后旧 refresh_token 失效)。
JWT 工具函数
// 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。刷新时会重新查库获取最新角色:
// 刷新时重新查库,确保角色是最新的
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 是否仍然有效。
密码工具函数
// 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
// 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
// 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 } }
})Cookie 设置详解
setCookie(event, 'auth-token', token, {
httpOnly: true, // JavaScript 无法读取 → 防 XSS 窃取 Token
secure: true, // 仅 HTTPS 传输 → 防中间人截获
maxAge: 604800, // 7 天(单位:秒)
sameSite: 'lax', // 防 CSRF:跨站请求不自动携带 Cookie
})| 选项 | 作用 | 不设置的后果 |
|---|---|---|
httpOnly: true | JS 无法 document.cookie 读取 | XSS 攻击可窃取 Token |
secure: true | 仅 HTTPS 下发送 | HTTP 下 Token 被明文传输 |
sameSite: 'lax' | 跨站 POST 不携带 Cookie | CSRF 攻击可冒充用户操作 |
为什么 sameSite 选 lax 而不是 strict?
strict 会阻止从外部链接跳转到管理后台时携带 Cookie(用户需要重新登录)。lax 允许顶级导航携带 Cookie,用户体验更好,同时仍然防止 CSRF POST 请求。
登录 API
// 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 },
}
})登录错误信息的安全考量
注意
示例中 "用户不存在" 和 "密码错误" 是不同的错误消息。在生产环境中,建议统一返回"手机号或密码错误",避免攻击者通过不同错误消息判断手机号是否已注册(用户枚举攻击)。
// ❌ 信息泄露:攻击者可以判断手机号是否注册
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
// 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 反复用"更安全。
认证中间件
// 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 header 再检查 Cookie?
当前的实现只检查了 Authorization: Bearer,未检查 Cookie。完整版应该是:
// 完整双模式认证中间件
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。支付回调的安全性通过签名验证保证——微信支付回调会附带签名,你需要验证签名是否合法:
// server/api/payments/wechat/notify.post.ts
// 微信回调会携带签名,验证签名即可确认请求来自微信,不需要 JWT阿里云 SMS 替代方案
// 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) | localStorage | Cookie 自动携带 + 防 XSS,refresh_token 在 JS 中管理即可(刷新频率低) |
为什么移动端不用 localStorage?
Flutter 不是浏览器,没有 localStorage。即使 Web 端,access_token 也不建议存 localStorage——XSS 可直接读取。Cookie 的 httpOnly 属性可以防止 XSS 读取。
2. 登出怎么实现?
JWT 是无状态的,服务端无法主动使 Token 失效。登出需要用 Redis 黑名单:
// 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: '已登出' }
})同时在认证中间件中检查黑名单:
// 在 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)反而降低安全性——哈希值本身就是"密码",被截获后同样可以伪造请求