认证安全最佳实践
要点
- 密码策略应该平衡安全性和用户体验,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、会话、暴力破解防护。
这一节涉及到的几个实践:
- 密码安全:NIST 建议、bcrypt/argon2、泄露检测
- Token 安全:存储、传输、过期、撤销
- 暴力破解:速率限制、账户锁定、验证码
- 会话安全:CSRF、XSS、Session 固定
- 事件响应:密码泄露、Token 泄露、异常登录
- 检查清单:生产环境安全检查
安全没有银弹,需要多层防护。密码哈希、Token 过期、速率限制、异常检测,每一层都能提高攻击成本。
下一篇看认证系统的测试——单元测试、集成测试、安全测试。