超过 80% 的 Web 应用安全漏洞源于认证系统的设计缺陷。根据 OWASP 2025 年 Top 10 报告,**Broken Authentication(认证失效)**连续三年位列高危漏洞前三。你可能已经知道 JWT 怎么签发,也知道 TOTP 是什么——但从「会用」到「构建一个生产可用的完整认证系统」之间,隔着 Token 轮转、会话劫持防护、双因素验证流程编排、多设备并发控制等一系列工程难题。本文不会重复「JWT 由 Header、Payload、Signature 三部分组成」这种入门内容,而是直击生产环境中的核心挑战,用完整的 TypeScript 代码带你构建一套可直接部署的认证系统。
📌 记住: 认证系统是整个应用的安全基石。它的每一个设计决策都直接影响用户数据安全——宁可多花时间做好,也不要上线后再补救。
🔐 一、双 Token 架构:Access Token + Refresh Token 的设计哲学
1.1 为什么单 Token 方案在生产中不可行
很多教程教你只用一个 JWT Token 做认证:用户登录后签发一个有效期 7 天的 Token,之后每次请求都带上它。这个方案在 Demo 里跑得很好,但在生产环境中有两个致命问题:
安全性:JWT 一旦签发就无法撤销。如果 Token 被窃取(XSS 攻击、日志泄露、中间人截获),在过期之前攻击者可以持续访问系统。你无法像 Session 那样在服务端直接失效它。
体验:如果把有效期设得很短(比如 15 分钟),用户每隔 15 分钟就要重新登录;设得很长(比如 30 天),被窃取后的攻击窗口就太长。
// ❌ 单 Token 方案:安全性与体验不可兼得
const token = jwt.sign(
{ userId: user.id, role: user.role },
SECRET_KEY,
{ expiresIn: '7d' } // 太长 → 被窃取后攻击窗口大
// { expiresIn: '15m' } // 太短 → 用户频繁重新登录
)
双 Token 架构解决了这个矛盾:Access Token 有效期短(15 分钟),用于 API 认证;Refresh Token 有效期长(30 天),用于静默刷新 Access Token。Access Token 泄露后最多 15 分钟就会失效,而 Refresh Token 存储在 HttpOnly Cookie 中,JavaScript 无法读取,大幅降低 XSS 攻击的风险。
1.2 双 Token 架构完整流程
整个认证流程包含 5 个关键环节,每个环节都有安全考量:
| 环节 | Access Token | Refresh Token | 存储位置 |
|---|---|---|---|
| 签发 | 有效期 15min | 有效期 30d | AT: 内存 / RT: HttpOnly Cookie |
| 使用 | Authorization Header | 不参与业务请求 | AT: 每次 API 调用 |
| 刷新 | 新 AT 签发 | 可选轮转(Rotation) | RT: 保持不变或更新 |
| 撤销 | 不需要(自动过期) | Redis 黑名单 | 服务端控制 |
| 泄露应对 | 等 15min 自动失效 | 立即从 Redis 删除 | 紧急响应 |
⚠️ 警告: Refresh Token 绝对不能存储在 localStorage 或 sessionStorage 中——它必须放在 HttpOnly + Secure + SameSite=Strict 的 Cookie 中,确保 JavaScript 完全无法访问。
1.3 完整实现:Token 签发与验证
// auth/token.ts - Token 签发与验证核心模块
import jwt, { JwtPayload } from 'jsonwebtoken'
import crypto from 'crypto'
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
// 配置常量
const ACCESS_TOKEN_EXPIRY = '15m'
const REFRESH_TOKEN_EXPIRY_DAYS = 30
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
interface TokenPayload {
userId: string
role: string
sessionId: string
}
interface TokenPair {
accessToken: string
refreshToken: string
expiresIn: number // Access Token 过期秒数
}
// 签发 Token 对
export async function issueTokenPair(
user: { id: string; role: string },
deviceInfo: string
): Promise<TokenPair> {
const sessionId = crypto.randomUUID()
// Access Token: 短有效期,包含用户信息
const accessToken = jwt.sign(
{ userId: user.id, role: user.role, sessionId },
ACCESS_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
)
// Refresh Token: 长有效期,只包含 sessionId
const refreshToken = jwt.sign(
{ sessionId, type: 'refresh' },
REFRESH_SECRET,
{ expiresIn: `${REFRESH_TOKEN_EXPIRY_DAYS}d` }
)
// 将 Refresh Token 信息存入 Redis(支持服务端撤销)
await redis.setex(
`refresh:${sessionId}`,
REFRESH_TOKEN_EXPIRY_DAYS * 86400,
JSON.stringify({
userId: user.id,
device: deviceInfo,
createdAt: Date.now(),
revoked: false
})
)
return {
accessToken,
refreshToken,
expiresIn: 900 // 15 分钟 = 900 秒
}
}
// 验证 Access Token
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, ACCESS_SECRET) as TokenPayload
}
// 验证 Refresh Token(同时检查 Redis 中是否已被撤销)
export async function verifyRefreshToken(token: string): Promise<TokenPayload | null> {
try {
const payload = jwt.verify(token, REFRESH_SECRET) as JwtPayload & { sessionId: string }
// 检查 Redis 中该 session 是否已被撤销
const sessionData = await redis.get(`refresh:${payload.sessionId}`)
if (!sessionData) return null
const session = JSON.parse(sessionData)
if (session.revoked) return null
return payload as unknown as TokenPayload
} catch {
return null
}
}
💡 提示: Access Secret 和 Refresh Secret 必须使用不同的密钥。如果共用同一个密钥,攻击者拿到任意一个 Token 就能伪造另一种类型的 Token。
🔄 二、Refresh Token 轮转与会话管理
2.1 Token Rotation:每次刷新都换新 Token
如果 Refresh Token 永远不变,一旦泄露攻击者就能无限续期。Token Rotation(轮转)策略要求每次使用 Refresh Token 刷新时,同时签发新的 Access Token 和新的 Refresh Token,并将旧 Refresh Token 标记为失效。
// auth/refresh.ts - Token 刷新与轮转
import { verifyRefreshToken, issueTokenPair } from './token'
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
interface RefreshResult {
success: boolean
tokens?: { accessToken: string; refreshToken: string; expiresIn: number }
error?: string
}
export async function refreshTokens(
oldRefreshToken: string,
deviceInfo: string
): Promise<RefreshResult> {
// 1. 验证旧 Refresh Token
const payload = await verifyRefreshToken(oldRefreshToken)
if (!payload) {
return { success: false, error: 'Refresh Token 无效或已过期' }
}
// 2. 获取旧 session 信息
const sessionData = await redis.get(`refresh:${payload.sessionId}`)
if (!sessionData) {
return { success: false, error: '会话不存在' }
}
const session = JSON.parse(sessionData)
// 3. ⚠️ 关键安全检查:检测 Token 重用(可能意味着 Refresh Token 被盗)
// 如果一个已经被轮转过的旧 Token 被再次使用,立即撤销该用户的所有会话
if (session.rotated) {
// 这是重放攻击!撤销该用户的所有 Refresh Token
await revokeAllUserSessions(session.userId)
return { success: false, error: '检测到异常,所有会话已终止' }
}
// 4. 标记旧 session 为已轮转
await redis.setex(
`refresh:${payload.sessionId}`,
60, // 保留 60 秒的安全窗口(处理并发请求)
JSON.stringify({ ...session, rotated: true })
)
// 5. 签发全新的 Token 对
const newTokens = await issueTokenPair(
{ id: session.userId, role: payload.role || 'user' },
deviceInfo
)
return { success: true, tokens: newTokens }
}
// 撤销用户的所有会话(用于安全事件响应)
async function revokeAllUserSessions(userId: string): Promise<void> {
const keys = await redis.keys('refresh:*')
const pipeline = redis.pipeline()
for (const key of keys) {
const data = await redis.get(key)
if (data) {
const session = JSON.parse(data)
if (session.userId === userId) {
pipeline.del(key)
}
}
}
await pipeline.exec()
}
2.2 并发刷新的安全窗口
前端可能出现并发刷新的情况:两个请求同时发现 Access Token 过期,同时发起刷新请求。如果严格禁止旧 Token 重用,用户可能被意外登出。
解决方案是设置一个安全窗口(通常 30-60 秒):旧 Refresh Token 在轮转后的短时间内仍然有效,允许并发请求完成。超过窗口期后,旧 Token 彻底失效。
// auth/middleware.ts - Express 中间件:Access Token 验证与自动刷新
import { Request, Response, NextFunction } from 'express'
import { verifyAccessToken } from './token'
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
// 从 Authorization Header 获取 Access Token
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: '未提供认证令牌' })
}
const accessToken = authHeader.slice(7)
try {
const payload = verifyAccessToken(accessToken)
req.user = { id: payload.userId, role: payload.role, sessionId: payload.sessionId }
next()
} catch (err: any) {
if (err.name === 'TokenExpiredError') {
// Access Token 过期 → 返回 401 + 特殊标记,前端自动触发刷新
return res.status(401).json({
error: 'TOKEN_EXPIRED',
message: 'Access Token 已过期,请使用 Refresh Token 刷新'
})
}
return res.status(401).json({ error: '令牌无效' })
}
}
⚠️ 警告: 永远不要在 Access Token 中存放敏感信息(如密码哈希、身份证号)。Access Token 使用 Base64 编码,Payload 部分可以被任何人解码——它只做签名验证,不做加密。
🔑 三、TOTP 双因素验证:从原理到完整实现
3.1 TOTP 的工作原理
TOTP(Time-based One-Time Password,基于时间的一次性密码)是目前最广泛使用的双因素验证方案。Google Authenticator、Microsoft Authenticator、Authy 等应用都基于 TOTP 协议(RFC 6238)。
核心原理很简单:客户端和服务端共享一个密钥(Secret),双方用相同的算法(HMAC-SHA1)对当前时间戳计算哈希值,取 6 位数字作为验证码。因为时间是同步的,双方算出的结果一致。
TOTP = HMAC-SHA1(Secret, floor(CurrentTime / 30秒)) → 取后 6 位数字
| 参数 | 值 | 说明 |
|---|---|---|
| 算法 | HMAC-SHA1 | 业界标准,安全性足够 |
| 时间步长 | 30 秒 | 每 30 秒更新一次验证码 |
| 位数 | 6 位 | 用户输入体验最佳 |
| 容错窗口 | ±1 期 | 允许客户端时间有最多 30 秒偏差 |
3.2 完整 TOTP 实现:绑定、验证、恢复码
// auth/totp.ts - TOTP 双因素验证完整实现
import { authenticator } from 'otplib'
import crypto from 'crypto'
import QRCode from 'qrcode'
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
interface TotpSetup {
secret: string // Base32 编码的密钥
qrCodeUrl: string // Data URL 格式的二维码图片
backupCodes: string[] // 10 个恢复码
}
// 1. 为用户生成 TOTP 绑定信息
export async function setupTotp(userId: string, appName = 'jsjson.com'): Promise<TotpSetup> {
// 生成随机密钥
const secret = authenticator.generateSecret()
// 生成 otpauth URI(标准格式,所有 TOTP 应用都识别)
const otpauthUrl = authenticator.keyuri(userId, appName, secret)
// 生成二维码图片(Data URL,前端直接 <img src={qrCodeUrl} /> 即可展示)
const qrCodeUrl = await QRCode.toDataURL(otpauthUrl)
// 生成 10 个一次性恢复码(用于丢失手机时的应急登录)
const backupCodes = Array.from({ length: 10 }, () =>
crypto.randomBytes(4).toString('hex').toUpperCase() // 8 位十六进制,如 "A3F7B2C1"
)
// 临时存储(用户验证第一个 TOTP 码后才正式绑定)
await redis.setex(
`totp_setup:${userId}`,
600, // 10 分钟有效期
JSON.stringify({
secret,
backupCodes: backupCodes.map(code => hashBackupCode(code)),
verified: false
})
)
return { secret, qrCodeUrl, backupCodes }
}
// 2. 验证用户输入的 TOTP 码并完成绑定
export async function verifyAndBindTotp(userId: string, token: string): Promise<boolean> {
const setupData = await redis.get(`totp_setup:${userId}`)
if (!setupData) return false
const setup = JSON.parse(setupData)
// 验证 TOTP 码(允许 ±1 期的时间偏差)
const isValid = authenticator.verify({ token, secret: setup.secret })
if (!isValid) return false
// 验证通过 → 正式绑定到用户
await redis.setex(
`totp:${userId}`,
0, // 永不过期(手动删除即解绑)
JSON.stringify({
secret: setup.secret,
backupCodes: setup.backupCodes,
boundAt: Date.now()
})
)
// 清理临时数据
await redis.del(`totp_setup:${userId}`)
return true
}
// 3. 登录时验证 TOTP 码
export async function verifyTotp(userId: string, token: string): Promise<boolean> {
const totpData = await redis.get(`totp:${userId}`)
if (!totpData) return true // 用户未开启 2FA,直接通过
const { secret, backupCodes } = JSON.parse(totpData)
// 先尝试 TOTP 验证
if (authenticator.verify({ token, secret })) {
return true
}
// TOTP 失败 → 尝试恢复码(8 位字符串)
if (token.length === 8) {
const hashedInput = hashBackupCode(token)
const codeIndex = backupCodes.indexOf(hashedInput)
if (codeIndex !== -1) {
// 恢复码是一次性的,用过即删
backupCodes.splice(codeIndex, 1)
await redis.set(`totp:${userId}`, JSON.stringify({ secret, backupCodes }))
return true
}
}
return false
}
function hashBackupCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex')
}
3.3 登录流程编排:单因素 vs 双因素
当用户开启了 TOTP 双因素验证时,登录流程变为两步。前端需要一个状态机来管理这个流程:
// auth/login.ts - 完整登录流程(支持 2FA)
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { issueTokenPair } from './token'
import { verifyTotp } from './totp'
import { Redis } from 'ioredis'
const router = Router()
const redis = new Redis(process.env.REDIS_URL!)
// 第一步:验证用户名密码
router.post('/login', async (req, res) => {
const { email, password } = req.body
// 查找用户(伪代码,替换为实际数据库查询)
const user = await db.user.findByEmail(email)
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: '邮箱或密码错误' })
}
// 检查是否开启了 2FA
const has2FA = await redis.exists(`totp:${user.id}`)
if (!has2FA) {
// 未开启 2FA → 直接签发 Token
const tokens = await issueTokenPair(user, req.headers['user-agent'] || 'unknown')
// 设置 Refresh Token Cookie
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true, // JavaScript 无法读取
secure: true, // 仅 HTTPS
sameSite: 'strict', // 防 CSRF
maxAge: 30 * 24 * 60 * 60 * 1000 // 30 天
})
return res.json({ accessToken: tokens.accessToken, expiresIn: tokens.expiresIn })
}
// 开启了 2FA → 生成临时凭证,等待 TOTP 验证
const tempToken = crypto.randomUUID()
await redis.setex(`login_pending:${tempToken}`, 300, user.id) // 5 分钟有效
return res.json({
requiresTwoFactor: true,
tempToken, // 前端拿着这个 token 去提交 TOTP 码
message: '请输入双因素验证码'
})
})
// 第二步:验证 TOTP 码
router.post('/login/verify-2fa', async (req, res) => {
const { tempToken, totpCode } = req.body
// 验证临时凭证
const userId = await redis.get(`login_pending:${tempToken}`)
if (!userId) {
return res.status(401).json({ error: '临时凭证已过期,请重新登录' })
}
// 验证 TOTP 码
const isValid = await verifyTotp(userId, totpCode)
if (!isValid) {
return res.status(401).json({ error: '验证码错误,请重试' })
}
// 验证通过 → 签发 Token
const user = await db.user.findById(userId)
const tokens = await issueTokenPair(user, req.headers['user-agent'] || 'unknown')
// 清理临时凭证
await redis.del(`login_pending:${tempToken}`)
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000
})
return res.json({ accessToken: tokens.accessToken, expiresIn: tokens.expiresIn })
})
export default router
💡 提示: 临时凭证(tempToken)的存在是为了避免用户在 2FA 验证阶段重复输入密码。它只在 Redis 中存活 5 分钟,过期后用户需要重新输入密码。
⚠️ 四、安全加固与常见攻击防护
4.1 登录速率限制:防暴力破解
认证系统必须有速率限制。以下是基于 Redis 滑动窗口的实现:
// auth/rate-limit.ts - 登录速率限制
import { Redis } from 'ioredis'
const redis = new Redis(process.env.REDIS_URL!)
interface RateLimitResult {
allowed: boolean
remaining: number
retryAfter?: number // 秒
}
export async function checkLoginRateLimit(identifier: string): Promise<RateLimitResult> {
const key = `ratelimit:login:${identifier}`
const windowSeconds = 900 // 15 分钟窗口
const maxAttempts = 5 // 最多 5 次
const now = Date.now()
const windowStart = now - windowSeconds * 1000
// 滑动窗口:用 Sorted Set 存储每次尝试的时间戳
const pipeline = redis.pipeline()
pipeline.zremrangebyscore(key, 0, windowStart) // 移除窗口外的记录
pipeline.zadd(key, now, `${now}:${Math.random()}`) // 添加当前尝试
pipeline.zcard(key) // 统计窗口内尝试次数
pipeline.expire(key, windowSeconds) // 设置过期时间
const results = await pipeline.exec()
const attemptCount = results![2][1] as number
if (attemptCount > maxAttempts) {
// 获取最早的尝试时间,计算还需等待多久
const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES')
const retryAfter = oldest.length >= 2
? Math.ceil((parseInt(oldest[1]) + windowSeconds * 1000 - now) / 1000)
: windowSeconds
return { allowed: false, remaining: 0, retryAfter }
}
return { allowed: true, remaining: maxAttempts - attemptCount }
}
4.2 关键安全检查清单
构建认证系统时,以下每一项都不能遗漏:
- ✅ 密码存储:使用 bcrypt(cost=12)或 Argon2id,绝不用 MD5/SHA256
- ✅ Token 密钥隔离:Access Token 和 Refresh Token 使用不同的 JWT Secret
- ✅ Refresh Token 轮转:每次刷新都签发新 Token,旧 Token 立即失效
- ✅ HttpOnly Cookie:Refresh Token 必须放在 HttpOnly + Secure + SameSite Cookie 中
- ✅ 登录速率限制:同一 IP / 同一账户限制 5 次/15 分钟
- ✅ TOTP 恢复码:一次性使用,哈希存储,用过即删
- ✅ 会话管理:用户可以查看并终止所有活跃会话
- ✅ 异常检测:Refresh Token 被重用时自动撤销所有会话
- ✅ 敏感操作二次验证:修改密码、解绑 2FA 等操作需要重新验证身份
⚠️ 警告: 永远不要自己实现加密算法。JWT 签名用
jsonwebtoken或jose库,密码哈希用bcrypt,TOTP 用otplib——安全领域,轮子不要自己造。
💡 五、最佳实践与生产建议
5.1 Token 存储策略对比
| 存储方案 | 安全性 | XSS 风险 | CSRF 风险 | 推荐用途 |
|---|---|---|---|---|
| HttpOnly Cookie | ⭐⭐⭐⭐⭐ | 无 | 需要防护 | Refresh Token ✅ |
| localStorage | ⭐⭐ | 高 | 无 | 不推荐任何 Token |
| sessionStorage | ⭐⭐ | 高 | 无 | 不推荐任何 Token |
| 内存变量 | ⭐⭐⭐⭐ | 低 | 无 | Access Token ✅ |
⚡ 关键结论: Access Token 存 JavaScript 内存变量(页面刷新时通过 Refresh Token 静默恢复),Refresh Token 存 HttpOnly Cookie。这个组合在安全性和开发体验之间取得了最佳平衡。
5.2 多设备会话管理
用户可能在手机、平板、笔记本等多个设备上同时登录。你需要维护每个设备的独立会话:
// auth/session.ts - 多设备会话管理
interface DeviceSession {
sessionId: string
userId: string
device: string
ip: string
createdAt: number
lastActiveAt: number
}
// 获取用户的所有活跃会话
export async function getUserSessions(userId: string): Promise<DeviceSession[]> {
const keys = await redis.keys('refresh:*')
const sessions: DeviceSession[] = []
for (const key of keys) {
const data = await redis.get(key)
if (data) {
const session = JSON.parse(data)
if (session.userId === userId && !session.revoked) {
sessions.push({
sessionId: key.replace('refresh:', ''),
...session
})
}
}
}
return sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt)
}
// 终止指定会话(用户主动登出某设备)
export async function terminateSession(sessionId: string): Promise<void> {
await redis.del(`refresh:${sessionId}`)
}
🔧 六、相关工具与库推荐
| 工具 | 用途 | npm 周下载量 | 推荐度 |
|---|---|---|---|
jsonwebtoken |
JWT 签发/验证 | 2800 万 | ⭐⭐⭐⭐ |
jose |
现代 JWT 库(支持更多算法) | 800 万 | ⭐⭐⭐⭐⭐ |
otplib |
TOTP/HOTP 实现 | 45 万 | ⭐⭐⭐⭐⭐ |
qrcode |
生成二维码图片 | 200 万 | ⭐⭐⭐⭐ |
bcrypt |
密码哈希 | 800 万 | ⭐⭐⭐⭐⭐ |
ioredis |
Redis 客户端 | 500 万 | ⭐⭐⭐⭐⭐ |
💡 提示: 如果你的项目不依赖 Express,可以考虑使用
jose库替代jsonwebtoken。jose原生支持 Web Crypto API,在 Edge Runtime(Cloudflare Workers、Deno Deploy)中也能正常使用。
📝 总结
构建生产级认证系统不是一个「会用 JWT 就行」的事情。从双 Token 架构到 Refresh Token 轮转,从 TOTP 双因素验证到多设备会话管理,每个环节都需要精心设计。
核心要点回顾:
- 双 Token 架构是基础:Access Token 短命(15min)用于 API 认证,Refresh Token 长命(30d)用于静默刷新,两者使用不同的 Secret
- Token Rotation是关键:每次刷新都签发新 Token,旧 Refresh Token 立即失效,并检测重放攻击
- TOTP 2FA是标配:用
otplib实现标准 TOTP 协议,提供恢复码作为应急方案 - 安全加固不能少:登录速率限制、HttpOnly Cookie、密码 bcrypt 哈希、敏感操作二次验证
- 会话管理要完善:支持用户查看和终止所有活跃会话,支持多设备独立会话
认证系统是应用的「安全门面」——它出问题,所有数据都可能暴露。投入足够的时间和精力做好它,是对用户最基本的尊重。