bruteforce.js 1.96 KB
/**
 * 防爆破 —— 窗口计数 + 阈值判定的纯逻辑(计数存储走注入的 repo,不用进程内 Map)。
 *
 * 判定逻辑(`evaluateAttempts`)是纯函数:给定窗口内已记录的失败次数、阈值、窗口长度,
 * 返回是否锁定与 `retryAfterMs`。落库/读库由 repo 完成,本文件只决策。
 *
 * 默认阈值/窗口可由调用方覆写(开闭原则:改规则只动这里的常量或注入 opts)。
 */

export const DEFAULT_MAX_ATTEMPTS = 5
export const DEFAULT_WINDOW_MS = 15 * 60 * 1000 // 15 分钟

/**
 * 把 email + ip 归一成防爆破计数 key(同标识合并计数)。
 * @param {string} email
 * @param {string} [ip]
 * @returns {string}
 */
export function attemptKey(email, ip) {
  const e = String(email ?? '').trim().toLowerCase()
  return ip ? `${e}|${ip}` : e
}

/**
 * 纯判定:当前窗口失败次数是否已达阈值;若是,给出还需等待多久。
 *
 * `retryAfterMs` = 窗口长度(保守估计:最早一条失败记录在窗口边界时需等待整个窗口;
 * 若提供 `oldestAt` 与 `now` 则给出精确剩余时间)。
 *
 * @param {Object} args
 * @param {number} args.recentCount  窗口内已记录的失败次数
 * @param {number} [args.maxAttempts]
 * @param {number} [args.windowMs]
 * @param {number} [args.oldestAt]   窗口内最早一条失败记录的时间戳(epoch ms),可选精确化
 * @param {number} [args.now]
 * @returns {{ locked: boolean, retryAfterMs: number }}
 */
export function evaluateAttempts({
  recentCount,
  maxAttempts = DEFAULT_MAX_ATTEMPTS,
  windowMs = DEFAULT_WINDOW_MS,
  oldestAt,
  now = Date.now(),
}) {
  const locked = recentCount >= maxAttempts
  if (!locked) return { locked: false, retryAfterMs: 0 }
  let retryAfterMs = windowMs
  if (typeof oldestAt === 'number') {
    // 最早一条失败记录滚出窗口后即可重试。
    retryAfterMs = Math.max(0, oldestAt + windowMs - now)
  }
  return { locked: true, retryAfterMs }
}