构建企业级认证系统:JWT + Refresh Token 轮转 + TOTP 双因素验证完全实战

从零构建生产级认证系统,涵盖 JWT 双 Token 架构、Refresh Token 轮转、TOTP 双因素验证、Redis 会话管理与多设备控制,附 Node.js/TypeScript 完整代码与安全攻防分析。

安全与密码 2026-06-03 22 分钟

超过 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 签名用 jsonwebtokenjose 库,密码哈希用 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 库替代 jsonwebtokenjose 原生支持 Web Crypto API,在 Edge Runtime(Cloudflare Workers、Deno Deploy)中也能正常使用。

📝 总结

构建生产级认证系统不是一个「会用 JWT 就行」的事情。从双 Token 架构到 Refresh Token 轮转,从 TOTP 双因素验证到多设备会话管理,每个环节都需要精心设计。

核心要点回顾:

  1. 双 Token 架构是基础:Access Token 短命(15min)用于 API 认证,Refresh Token 长命(30d)用于静默刷新,两者使用不同的 Secret
  2. Token Rotation是关键:每次刷新都签发新 Token,旧 Refresh Token 立即失效,并检测重放攻击
  3. TOTP 2FA是标配:用 otplib 实现标准 TOTP 协议,提供恢复码作为应急方案
  4. 安全加固不能少:登录速率限制、HttpOnly Cookie、密码 bcrypt 哈希、敏感操作二次验证
  5. 会话管理要完善:支持用户查看和终止所有活跃会话,支持多设备独立会话

认证系统是应用的「安全门面」——它出问题,所有数据都可能暴露。投入足够的时间和精力做好它,是对用户最基本的尊重。

📚 相关文章