code.js 5.18 KB
/**
 * 验证码 —— sendCode / verifyCode 编排 + 验证码生命周期纯逻辑。
 *
 * 纯逻辑(`generateCode` / `evaluateCode`)可直接单测,无 IO;落库(saveCode/consumeCode)、
 * 投递(email Port)、哈希(hasher)由编排函数注入。验证码本身经 hasher 哈希落库,
 * 库内 grep 不到明文验证码持久化。
 */

import { AuthError } from './errors.js'

export const DEFAULT_CODE_TTL_MS = 10 * 60 * 1000 // 10 分钟
export const DEFAULT_MAX_CODE_ATTEMPTS = 5
export const DEFAULT_CODE_SEND_WINDOW_MS = 60 * 1000
export const DEFAULT_MAX_CODE_SENDS = 3
export const VALID_PURPOSES = new Set(['register', 'login', 'reset'])

/**
 * 生成 6 位数字验证码(密码学随机)。投递走 email Port,哈希后落库。
 * @param {() => number} [rand]  注入随机源(测试用,缺省 crypto 安全随机)。
 * @returns {string}
 */
export function generateCode(rand) {
  if (typeof rand === 'function') {
    const n = Math.floor(rand() * 1_000_000)
    return String(((n % 1_000_000) + 1_000_000) % 1_000_000).padStart(6, '0')
  }
  // 无注入时用 Node 内置 CSPRNG(不自研随机)。
  const buf = globalThis.crypto.getRandomValues(new Uint32Array(1))
  return String(buf[0] % 1_000_000).padStart(6, '0')
}

/**
 * 纯判定:给定已落库的验证码记录与提交的明码校验结果,决定通过 / 抛哪种结构化错误。
 *
 * 分支(各对应一个错误码):
 *   - 记录不存在 / purpose 不匹配 → CODE_INVALID
 *   - 已过期 → CODE_EXPIRED
 *   - 尝试次数超限 → CODE_INVALID(视为失效,调用方应要求重发)
 *   - 哈希不匹配 → CODE_INVALID
 *
 * @param {Object} args
 * @param {import('./index.js').CodeRecord|null} args.record
 * @param {string} args.purpose
 * @param {boolean} args.matches    hasher.verify(submitted, record.code_hash) 的结果
 * @param {number} [args.maxAttempts]
 * @param {number} [args.now]
 * @returns {{ ok: true }}
 * @throws {AuthError}
 */
export function evaluateCode({
  record,
  purpose,
  matches,
  maxAttempts = DEFAULT_MAX_CODE_ATTEMPTS,
  now = Date.now(),
}) {
  if (!record || record.purpose !== purpose) {
    throw new AuthError({ code: 'CODE_INVALID' })
  }
  if (now > record.expires_at.getTime()) {
    throw new AuthError({ code: 'CODE_EXPIRED' })
  }
  if (typeof record.attempts === 'number' && record.attempts >= maxAttempts) {
    throw new AuthError({ code: 'CODE_INVALID' })
  }
  if (!matches) {
    throw new AuthError({ code: 'CODE_INVALID' })
  }
  return { ok: true }
}

/**
 * sendCode 编排:生成码 → 哈希落库 → 后台经 email Port 投递。
 * @param {Object} deps
 * @param {import('./index.js').UserRepo} deps.userRepo
 * @param {import('./index.js').EmailPort} deps.email
 * @param {import('./index.js').Hasher} deps.hasher
 * @param {number} [deps.ttlMs]
 * @param {number} [deps.sendWindowMs]
 * @param {number} [deps.maxSends]
 * @param {() => number} [deps.rand]
 * @returns {(args: { email: string, purpose: string, locale?: string, ip?: string }) => Promise<{ ok: true }>}
 */
export function makeSendCode({
  userRepo,
  email,
  hasher,
  ttlMs = DEFAULT_CODE_TTL_MS,
  sendWindowMs = DEFAULT_CODE_SEND_WINDOW_MS,
  maxSends = DEFAULT_MAX_CODE_SENDS,
  rand,
}) {
  return async function sendCode({ email: to, purpose, locale, ip }) {
    if (!VALID_PURPOSES.has(purpose)) {
      throw new AuthError({ code: 'CODE_INVALID', message: `unsupported purpose: ${purpose}` })
    }
    const normalizedEmail = normEmail(to)
    await guardCodeSend({ userRepo, email: normalizedEmail, purpose, ip, sendWindowMs, maxSends })
    const code = generateCode(rand)
    const code_hash = await hasher.hash(code)
    const expires_at = new Date(Date.now() + ttlMs)
    await userRepo.saveCode({ email: normalizedEmail, purpose, code_hash, expires_at })
    email.sendCode({ to, code, purpose, locale })
    return { ok: true }
  }
}

/**
 * verifyCode 编排:取记录 → hasher 校验 → evaluateCode 判定。
 * @param {Object} deps
 * @param {import('./index.js').UserRepo} deps.userRepo
 * @param {import('./index.js').Hasher} deps.hasher
 * @param {number} [deps.maxAttempts]
 * @returns {(args: { email: string, code: string, purpose: string }) => Promise<{ ok: true }>}
 */
export function makeVerifyCode({ userRepo, hasher, maxAttempts = DEFAULT_MAX_CODE_ATTEMPTS }) {
  return async function verifyCode({ email: to, code, purpose }) {
    const record = await userRepo.consumeCode({ email: normEmail(to), purpose })
    const matches = record ? await hasher.verify(code, record.code_hash) : false
    return evaluateCode({ record, purpose, matches, maxAttempts })
  }
}

/** @param {string} e */
function normEmail(e) {
  return String(e ?? '').trim().toLowerCase()
}

async function guardCodeSend({ userRepo, email, purpose, ip, sendWindowMs, maxSends }) {
  const key = codeSendAttemptKey(email, purpose, ip)
  const count = await userRepo.countRecentAttempts({ key, windowMs: sendWindowMs })
  if (count >= maxSends) {
    throw new AuthError({ code: 'TOO_MANY_ATTEMPTS', retryAfterMs: sendWindowMs })
  }
  await userRepo.recordAttempt({ key, ip, at: new Date() })
}

function codeSendAttemptKey(email, purpose, ip) {
  return `code:${purpose}:${email}:${ip || '*'}`
}