认证安全最佳实践

要点

  • 密码策略应该平衡安全性和用户体验,NIST 建议最小 8 字符
  • 密码存储必须用哈希算法(bcrypt、argon2),不能明文或可逆加密
  • Token 安全涉及:存储、传输、过期、撤销
  • 暴力破解防护:速率限制、账户锁定、验证码
  • 会话安全:CSRF、XSS、Session 固定攻击
  • 安全事件响应:密码泄露、Token 泄露、异常登录

1. 密码安全

1.1 密码策略

NIST(美国国家标准与技术研究院)建议:

  • 最小长度:8 个字符
  • 最大长度:至少 64 个字符
  • 复杂度:允许所有字符,不强制复杂度
  • 黑名单:阻止常见密码和已泄露密码
const passwordSchema = z.string()
  .min(8, 'Password must be at least 8 characters')
  .max(64, 'Password must be at most 64 characters')
  .refine(p => !isCommonPassword(p), 'Password is too common')

1.2 密码哈希

使用 bcrypt 或 argon2:

import { hash, compare } from 'bcrypt'
 
const SALT_ROUNDS = 12
 
// 哈希密码
const passwordHash = await hash(password, SALT_ROUNDS)
 
// 验证密码
const isValid = await compare(password, passwordHash)

1.3 密码泄露检测

使用 Have I Been Pwned API:

import { createHash } from 'crypto'
 
async function isPasswordPwned(password: string): Promise<boolean> {
  const hash = createHash('sha1').update(password).digest('hex').toUpperCase()
  const prefix = hash.slice(0, 5)
  const suffix = hash.slice(5)
 
  const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`)
  const text = await response.text()
 
  return text.split('\n').some(line => line.startsWith(suffix))
}
 
// 注册时检查
app.post('/auth/register', async (c) => {
  const { password } = await c.req.json()
 
  if (await isPasswordPwned(password)) {
    return c.json({
      error: 'This password has been compromised. Please choose a different one.',
    }, 400)
  }
 
  // ...
})

1.4 密码重置

安全的密码重置流程:

// 请求重置
app.post('/auth/forgot-password', async (c) => {
  const { email } = await c.req.json()
 
  const user = await db.query.users.findFirst({
    where: eq(users.email, email),
  })
 
  // 不泄露用户是否存在
  if (!user) {
    return c.json({ message: 'If the email exists, a reset link has been sent.' })
  }
 
  // 生成安全的随机 Token
  const resetToken = crypto.randomBytes(32).toString('hex')
 
  // Token 哈希存储
  const tokenHash = createHash('sha256').update(resetToken).digest('hex')
 
  await db.insert(passwordResetTokens).values({
    userId: user.id,
    tokenHash,
    expiresAt: new Date(Date.now() + 60 * 60 * 1000),  // 1 小时
  })
 
  // 发送重置邮件
  const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`
  await sendEmail({
    to: user.email,
    subject: 'Reset your password',
    html: `<a href="${resetUrl}">Click here to reset</a>`,
  })
 
  return c.json({ message: 'If the email exists, a reset link has been sent.' })
})

2. Token 安全

2.1 Token 存储

前端存储 Token 的方式:

// ❌ localStorage,容易被 XSS 攻击读取
localStorage.setItem('token', token)
 
// ✅ httpOnly Cookie,JavaScript 无法访问
setCookie(c, 'token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'Strict',
  maxAge: 60 * 60 * 24 * 7,
})
 
// ⚠️ 内存存储,刷新后丢失
let token = token  // 变量

推荐:

  • Web 应用:httpOnly Cookie(需要 CSRF 保护)
  • 移动应用:安全存储(Keychain、Keystore)
  • SPA:内存 + 自动刷新

2.2 Token 传输

Token 必须通过 HTTPS 传输:

// 生产环境强制 HTTPS
if (process.env.NODE_ENV === 'production') {
  app.use('*', async (c, next) => {
    if (c.req.protocol !== 'https') {
      return c.redirect(`https://${c.req.host}${c.req.path}`)
    }
    await next()
  })
}

2.3 Token 过期

短期 Access Token + 长期 Refresh Token:

// Access Token:15 分钟
const accessToken = await sign(
  { userId: user.id, type: 'access' },
  process.env.JWT_SECRET!,
  '15m'
)
 
// Refresh Token:7 天
const refreshToken = await sign(
  { userId: user.id, type: 'refresh' },
  process.env.JWT_SECRET!,
  '7d'
)

2.4 Token 撤销

用户登出时撤销 Token:

app.post('/auth/logout', async (c) => {
  const user = c.get('user')
  const token = c.req.header('Authorization')?.substring(7)
 
  if (token) {
    // 把 Token 加入黑名单
    const payload = await verify(token, process.env.JWT_SECRET!)
    const expiresIn = payload.exp - Math.floor(Date.now() / 1000)
 
    await redis.setex(`blacklist:${token}`, expiresIn, '1')
  }
 
  // 删除所有 Refresh Token
  await db
    .delete(refreshTokens)
    .where(eq(refreshTokens.userId, user.id))
 
  return c.json({ message: 'Logged out' })
})

3. 暴力破解防护

3.1 速率限制

import { rateLimit } from 'hono-rate-limiter'
 
app.post('/auth/login',
  rateLimit({
    windowMs: 15 * 60 * 1000,  // 15 分钟
    limit: 5,  // 每个 IP 最多 5 次
    standardHeaders: 'draft-7',
    legacyHeaders: false,
    message: 'Too many login attempts, please try again later',
  }),
  async (c) => {
    // ...
  }
)

3.2 账户锁定

连续失败后锁定账户:

app.post('/auth/login', async (c) => {
  const { email, password } = await c.req.json()
 
  // 检查是否被锁定
  const lockKey = `lockout:${email}`
  const isLocked = await redis.get(lockKey)
 
  if (isLocked) {
    const ttl = await redis.ttl(lockKey)
    return c.json({
      error: `Account locked. Try again in ${ttl} seconds`,
    }, 429)
  }
 
  // 验证密码
  const user = await db.query.users.findFirst({
    where: eq(users.email, email),
  })
 
  if (!user || !await compare(password, user.passwordHash)) {
    // 增加失败次数
    const failKey = `login_fail:${email}`
    const fails = await redis.incr(failKey)
    await redis.expire(failKey, 15 * 60)  // 15 分钟过期
 
    if (fails >= 5) {
      // 锁定账户
      await redis.setex(lockKey, 15 * 60, '1')
      return c.json({ error: 'Account locked. Try again in 15 minutes' }, 429)
    }
 
    return c.json({ error: 'Invalid credentials' }, 401)
  }
 
  // 登录成功,清除失败计数
  await redis.del(`login_fail:${email}`)
 
  // ...
})

3.3 验证码

高风险操作需要验证码:

import { createCanvas } from 'canvas'
 
app.get('/auth/captcha', async (c) => {
  // 生成验证码
  const code = Math.random().toString(36).substring(2, 8).toUpperCase()
 
  // 存储到 Redis,5 分钟过期
  const captchaId = crypto.randomUUID()
  await redis.setex(`captcha:${captchaId}`, 5 * 60, code)
 
  // 生成图片
  const canvas = createCanvas(200, 80)
  const ctx = canvas.getContext('2d')
 
  // 绘制验证码
  ctx.fillStyle = '#fff'
  ctx.fillRect(0, 0, 200, 80)
  ctx.fillStyle = '#000'
  ctx.font = 'bold 40px Arial'
  ctx.fillText(code, 50, 55)
 
  // 添加干扰线
  for (let i = 0; i < 5; i++) {
    ctx.strokeStyle = `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`
    ctx.beginPath()
    ctx.moveTo(Math.random() * 200, Math.random() * 80)
    ctx.lineTo(Math.random() * 200, Math.random() * 80)
    ctx.stroke()
  }
 
  return c.body(canvas.toBuffer(), 200, {
    'Content-Type': 'image/png',
  })
})
 
// 登录时验证
app.post('/auth/login', async (c) => {
  const { email, password, captchaId, captchaCode } = await c.req.json()
 
  // 验证验证码
  const storedCode = await redis.get(`captcha:${captchaId}`)
 
  if (!storedCode || storedCode !== captchaCode.toUpperCase()) {
    return c.json({ error: 'Invalid captcha' }, 400)
  }
 
  // 删除验证码(一次性)
  await redis.del(`captcha:${captchaId}`)
 
  // ...
})

4. 会话安全

4.1 CSRF 防护

import { csrf } from 'hono/csrf'
 
app.use('*', csrf({
  secret: process.env.CSRF_SECRET!,
}))

或 SameSite Cookie:

setCookie(c, 'sessionId', sessionId, {
  sameSite: 'Strict',  // 或 'Lax'
  secure: true,
  httpOnly: true,
})

4.2 XSS 防护

设置安全头:

import { secureHeaders } from 'hono/secure-headers'
 
app.use('*', secureHeaders())

4.3 Session 固定攻击

登录后重新生成 Session ID:

app.post('/auth/login', async (c) => {
  // 删除旧 Session
  const oldSessionId = getCookie(c, 'sessionId')
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`)
  }
 
  // 生成新 Session ID
  const newSessionId = crypto.randomUUID()
 
  // ...
})

5. 安全事件响应

5.1 密码泄露

如果检测到密码泄露:

// 强制用户重置密码
async function handlePasswordBreach(userId: string) {
  // 标记账户
  await db
    .update(users)
    .set({ passwordBreachAt: new Date() })
    .where(eq(users.id, userId))
 
  // 撤销所有 Token
  await revokeAllTokens(userId)
 
  // 发送通知邮件
  await sendEmail({
    to: user.email,
    subject: 'Security Alert: Password Breach Detected',
    html: 'Your password may have been compromised. Please reset it immediately.',
  })
}

5.2 Token 泄露

如果 Token 泄露:

// 立即撤销 Token
async function handleTokenLeak(token: string) {
  // 加入黑名单
  const payload = await verify(token, process.env.JWT_SECRET!)
  const expiresIn = payload.exp - Math.floor(Date.now() / 1000)
 
  await redis.setex(`blacklist:${token}`, expiresIn, '1')
 
  // 记录安全事件
  await db.insert(securityEvents).values({
    type: 'token_leak',
    userId: payload.userId,
    metadata: { token },
  })
}

5.3 异常登录检测

// 检测异常登录
async function detectAnomalousLogin(userId: string, ip: string, userAgent: string) {
  const recentLogins = await db.query.loginLogs.findMany({
    where: eq(loginLogs.userId, userId),
    orderBy: desc(loginLogs.createdAt),
    limit: 10,
  })
 
  // 检查新 IP
  const knownIPs = recentLogins.map(l => l.ip)
 
  if (!knownIPs.includes(ip)) {
    // 发送告警
    await sendSecurityAlert(userId, {
      type: 'new_ip_login',
      ip,
      userAgent,
    })
  }
 
  // 检查新地区
  const location = await getIPLocation(ip)
  const knownLocations = recentLogins.map(l => l.location)
 
  if (!knownLocations.includes(location)) {
    await sendSecurityAlert(userId, {
      type: 'new_location_login',
      location,
    })
  }
}

6. 安全检查清单

生产环境应该检查:

6.1 密码

  • 最小长度 8 字符
  • 使用 bcrypt 或 argon2 哈希
  • 工作因子 >= 12
  • 阻止常见密码
  • 密码泄露检测

6.2 Token

  • 短期 Access Token(15 分钟)
  • 长期 Refresh Token(7 天)
  • httpOnly Cookie 或安全存储
  • HTTPS 传输
  • Token 撤销机制

6.3 会话

  • CSRF Token 或 SameSite Cookie
  • Session 固定防护
  • 会话超时
  • 安全头

6.4 暴力破解

  • 速率限制
  • 账户锁定
  • 验证码(高风险操作)

6.5 监控

  • 登录日志
  • 异常登录检测
  • 安全事件告警
  • 定期安全审计

总结

认证安全是一个系统工程,涉及密码、Token、会话、暴力破解防护。

这一节涉及到的几个实践:

  1. 密码安全:NIST 建议、bcrypt/argon2、泄露检测
  2. Token 安全:存储、传输、过期、撤销
  3. 暴力破解:速率限制、账户锁定、验证码
  4. 会话安全:CSRF、XSS、Session 固定
  5. 事件响应:密码泄露、Token 泄露、异常登录
  6. 检查清单:生产环境安全检查

安全没有银弹,需要多层防护。密码哈希、Token 过期、速率限制、异常检测,每一层都能提高攻击成本。

下一篇看认证系统的测试——单元测试、集成测试、安全测试。