/** * 验证码 —— 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 || '*'}` }