Files
dsh_ai1net_server/test/register-guard.test.mjs
T

489 lines
22 KiB
JavaScript
Raw Normal View History

/**
* 注册页「邮箱验证码 + 爆破防护」回归测试(档案 134)。
*
* 为什么这几条必须钉在测试里(而不是靠"上线上点一遍"):
* · **冷却与配额是时间相关的** —— 手点是验不出边界的(第 5 次到底拦没拦、retryAfter 是不是
* 正好等于剩余秒数),只有把 `now` 当入参才钉得住;
* · **`sent` / `throttled` / `failed` 三种事件直接决定配额与可校验性** —— 记错一种就变成
* "发信失败也允许校验"或"被限流不计入配额(于是可以无限重试)";
* · **单次使用 + 试错作废** —— 猜码防护的全部价值就在这里。
*
* 全程用 **`log` 驱动 + 内存 SQLite**:不打网络、不发真邮件,但仍走完整的编排与落库路径
* (唯一的例外是 `http` 驱动那两条 —— 它们打到本测试起的本地 HTTP 服务上,验证头/模板)。
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import { randomUUID } from 'node:crypto'
import { SqliteAdapter } from '../lib/db/sqlite.js'
import {
DEFAULT_GUARD_POLICY,
HOUR_MS,
evaluateSendGuard,
evaluateVerifyGuard,
formatRetryAfter,
} from '../lib/web/register-guard.js'
import {
codeMatches,
consumeRegisterCode,
generateCode,
hashCode,
isValidEmail,
isValidUsername,
normalizeEmail,
requestRegisterCode,
} from '../lib/web/email-code.js'
import { renderVerificationMail, sendVerificationCodeMail } from '../lib/web/mail.js'
import { turnstileEnabled, verifyTurnstile } from '../lib/web/turnstile.js'
import { normalizeHostnames, resolveTurnstileHostnames } from '../lib/config.js'
const PEPPER = 'test-pepper'
/** 一份最小可用的配置(只填本模块真的会读的字段)。 */
function makeConfig(overrides = {}) {
return {
encryptionSecret: PEPPER,
emailCodeTtlMs: DEFAULT_GUARD_POLICY.codeTtlMs,
emailCodeMaxAttempts: DEFAULT_GUARD_POLICY.maxAttemptsPerCode,
emailCodeGuard: {
emailPerHour: DEFAULT_GUARD_POLICY.emailPerHour,
emailSentPerDay: DEFAULT_GUARD_POLICY.emailSentPerDay,
ipPerHour: DEFAULT_GUARD_POLICY.ipPerHour,
globalPerHour: DEFAULT_GUARD_POLICY.globalPerHour,
cooldownLadderMs: [...DEFAULT_GUARD_POLICY.cooldownLadderMs],
},
mailDriver: 'log',
mailApiUrl: '',
mailApiKey: '',
mailAuthHeader: '',
mailFrom: '',
mailFromName: '',
mailBodyTemplate: '',
mailTimeoutMs: 3000,
registerRequireEmailCode: true,
registerRequireCaptcha: false,
turnstileSiteKey: '',
turnstileSecret: '',
...overrides,
}
}
/** 计数快照构造器:把 5 个数字包成 `evaluateSendGuard` 需要的形状。 */
function counts(over = {}) {
const zero = { emailTotal: 0, emailSent: 0, emailLastAt: 0, ipTotal: 0, globalTotal: 0 }
return { hour: { ...zero, ...over }, day: { ...zero, ...over } }
}
test('冷却阶梯:首次放行,紧接着再发被拦且 retryAfter = 剩余秒数', () => {
const now = 1_700_000_000_000
assert.deepEqual(evaluateSendGuard(counts(), now), { allowed: true, cooldownMs: 60_000 })
// 30 秒前发过一次 ⇒ 还差 30 秒
const recent = evaluateSendGuard(counts({ emailTotal: 1, emailLastAt: now - 30_000 }), now)
assert.equal(recent.allowed, false)
assert.equal(recent.reason, 'email_cooldown')
assert.equal(recent.retryAfterSeconds, 30)
// 冷却已过 ⇒ 放行
assert.equal(evaluateSendGuard(counts({ emailTotal: 1, emailLastAt: now - 61_000 }), now).allowed, true)
})
test('冷却阶梯随"一小时内已发起次数"递增(脚本化重试收益递减)', () => {
const now = 1_700_000_000_000
const ladder = DEFAULT_GUARD_POLICY.cooldownLadderMs
// 索引 0…5 恰好覆盖全部 6 级(`emailPerHour` = 6 就是为了让最后一级可达)
for (const n of [0, 1, 2, 3, 4, 5]) {
const verdict = evaluateSendGuard(counts({ emailTotal: n, emailLastAt: 0 }), now)
assert.equal(verdict.allowed, true, `hourCount=${n} 应放行`)
assert.equal(verdict.cooldownMs, ladder[n], `hourCount=${n}`)
}
assert.equal(ladder.length, DEFAULT_GUARD_POLICY.emailPerHour, '每一级都必须可达(否则白写一级)')
})
test('配额:每小时 6 次 / 每天 8 封 / IP 20 次 / 全局 200 次各自独挡一面', () => {
const now = 1_700_000_000_000
const hourQuota = evaluateSendGuard(counts({ emailTotal: DEFAULT_GUARD_POLICY.emailPerHour }), now)
assert.equal(hourQuota.allowed, false)
assert.equal(hourQuota.reason, 'email_hourly_quota')
const dayQuota = evaluateSendGuard(
{ hour: { emailTotal: 0, emailSent: 0, emailLastAt: 0, ipTotal: 0, globalTotal: 0 },
day: { emailTotal: 8, emailSent: 8, emailLastAt: now, ipTotal: 0, globalTotal: 0 } },
now,
)
assert.equal(dayQuota.allowed, false)
assert.equal(dayQuota.reason, 'email_daily_quota')
assert.equal(evaluateSendGuard(counts({ ipTotal: 20 }), now).reason, 'ip_hourly_quota')
assert.equal(evaluateSendGuard(counts({ globalTotal: 200 }), now).reason, 'global_hourly_quota')
})
test('校验前置判定:过期 / 已用 / 试错超限 三种不可用状态分得开', () => {
const now = 1_700_000_000_000
const row = (over) => ({
id: 'x', email: '[email protected]', purpose: 'register', code_hash: 'h', status: 'sent', attempts: 0,
ip: null, username: null, reason: null, created_at: now - 1000, expires_at: now + 1000, consumed_at: null,
...over,
})
assert.equal(evaluateVerifyGuard(row({}), now), 'ok')
assert.equal(evaluateVerifyGuard(row({ expires_at: now - 1 }), now), 'expired')
assert.equal(evaluateVerifyGuard(row({ consumed_at: now - 1, attempts: 1 }), now), 'used')
assert.equal(
evaluateVerifyGuard(row({ consumed_at: now - 1, attempts: DEFAULT_GUARD_POLICY.maxAttemptsPerCode }), now),
'too_many_attempts',
)
assert.equal(evaluateVerifyGuard(undefined, now), 'missing')
})
test('验证码/邮箱/用户名 的取值口径', () => {
const seen = new Set(Array.from({ length: 200 }, () => generateCode()))
for (const code of seen) assert.match(code, /^\d{6}$/)
assert.ok(seen.size > 150, '随机性抽样:200 次不应大量重复')
assert.equal(normalizeEmail(' [email protected] '), '[email protected]')
assert.equal(isValidEmail('[email protected]'), true)
assert.equal(isValidEmail('a@b'), false)
assert.equal(isValidEmail('nope'), false)
assert.equal(isValidUsername('ab'), false)
assert.equal(isValidUsername('a_b-1'), true)
const hash = hashCode('[email protected]', 'register', '123456', PEPPER)
assert.equal(hash.length, 64)
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123456', PEPPER)), true)
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123457', PEPPER)), false)
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123456', 'other-pepper')), false)
})
test('retryAfter 人话格式化', () => {
assert.equal(formatRetryAfter(45), '45 秒')
assert.equal(formatRetryAfter(120), '2 分钟')
assert.equal(formatRetryAfter(7200), '2 小时')
})
test('mail: 正文含验证码与有效期,且明确告知"非本人操作请忽略"', () => {
const { subject, text } = renderVerificationMail({ to: '[email protected]', code: '123456', ttlMinutes: 10, brand: 'EXAMPLE' })
assert.ok(subject.includes('123456'))
assert.ok(subject.includes('EXAMPLE'))
assert.ok(text.includes('123456'))
assert.ok(text.includes('10'))
assert.ok(text.includes('ignore this e-mail'))
// 站点名缺失时**不得**回落到平台内部名(邮件是给终端用户看的)
const plain = renderVerificationMail({ to: '[email protected]', code: '654321', ttlMinutes: 10 })
assert.equal(plain.subject, '654321 is your verification code')
assert.ok(!/DSH|dshs/i.test(plain.subject + plain.text), '不得出现平台内部名')
assert.ok(plain.text.includes('654321'))
})
test('mail: log 驱动不发信但算成功;未配置的通道直接判失败(不静默吞掉)', async () => {
const settings = (over) => ({
driver: 'log', apiUrl: '', apiKey: '', authHeader: '', from: '', fromName: '', bodyTemplate: '', timeoutMs: 1000,
...over,
})
assert.equal((await sendVerificationCodeMail(settings({}), { to: '[email protected]', code: '1', ttlMinutes: 10 })).ok, true)
const unconfigured = await sendVerificationCodeMail(
settings({ driver: 'brevo', from: '' }),
{ to: '[email protected]', code: '1', ttlMinutes: 10 },
)
assert.equal(unconfigured.ok, false)
assert.equal(unconfigured.error, 'mail_not_configured')
})
test('mail: http 驱动把 URL / 鉴权头 / body 模板交给配置(换供应商不用改代码)', async () => {
const seen = []
const server = createServer((req, res) => {
let body = ''
req.on('data', (chunk) => { body += chunk })
req.on('end', () => {
seen.push({ url: req.url, headers: req.headers, body })
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"ok":true}')
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
try {
const result = await sendVerificationCodeMail(
{
driver: 'http',
apiUrl: `http://127.0.0.1:${port}/send`,
apiKey: 'sekret',
authHeader: 'x-api-key',
from: '[email protected]',
fromName: 'EXAMPLE',
bodyTemplate: '{"to":"{{to}}","subject":"{{subject}}","payload":{"code":"{{code}}"}}',
timeoutMs: 3000,
},
{ to: '[email protected]', code: '987654', ttlMinutes: 10 },
)
assert.equal(result.ok, true)
assert.equal(seen.length, 1)
assert.equal(seen[0].headers['x-api-key'], 'sekret')
const payload = JSON.parse(seen[0].body)
assert.equal(payload.to, '[email protected]')
assert.equal(payload.payload.code, '987654')
assert.ok(payload.subject.includes('987654'))
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
test('mail: 上游 5xx ⇒ 明确失败(不重试、不假装成功)', async () => {
const server = createServer((req, res) => { res.writeHead(502); res.end('bad gateway') })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
try {
const result = await sendVerificationCodeMail(
{
driver: 'http', apiUrl: `http://127.0.0.1:${port}/send`, apiKey: '', authHeader: '',
from: '[email protected]', fromName: '', bodyTemplate: '', timeoutMs: 3000,
},
{ to: '[email protected]', code: '1', ttlMinutes: 10 },
)
assert.equal(result.ok, false)
assert.equal(result.status, 502)
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
test('turnstile: 只填一把钥匙视为未配置(半配置不许当"已启用")', () => {
const base = { siteKey: 'k', secret: 's', action: 'signup', hostnames: ['example.net'], timeoutMs: 1000 }
assert.equal(turnstileEnabled(base), true)
assert.equal(turnstileEnabled({ ...base, secret: '' }), false)
assert.equal(turnstileEnabled({ ...base, siteKey: '' }), false)
// 🔴 主机名白名单为空 ⇒ **视为未配置完成**(否则 sitekey 泄露后可在任意站点伪造挑战)
assert.equal(turnstileEnabled({ ...base, hostnames: [] }), false)
})
test('turnstile: success 之后仍校 action 与 hostname(官方 canonical 三项)', async () => {
// 本地假 siteverify:按 path 决定回什么,从而逐组合断言判定逻辑
const cases = {
'/ok': { success: true, action: 'signup', hostname: 'example.net' },
'/badaction': { success: true, action: 'login', hostname: 'example.net' },
'/badhost': { success: true, action: 'signup', hostname: 'evil.example' },
'/nohost': { success: true, action: 'signup' },
'/fail': { success: false, 'error-codes': ['invalid-input-response'] },
}
let hits = 0
const server = createServer((req, res) => {
hits += 1
const body = cases[req.url] ?? { success: false, 'error-codes': ['bad-request'] }
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
const settings = (path) => ({
siteKey: 'k', secret: 's', timeoutMs: 3000, action: 'signup',
hostnames: ['example.net', 'www.example.net'],
verifyUrl: `http://127.0.0.1:${port}${path}`,
})
try {
assert.equal((await verifyTurnstile(settings('/ok'), 'tok')).ok, true, '三项齐备 ⇒ 放行')
assert.equal((await verifyTurnstile(settings('/badaction'), 'tok')).error, 'action_mismatch: login')
assert.equal((await verifyTurnstile(settings('/badhost'), 'tok')).error, 'hostname_mismatch: evil.example')
assert.equal((await verifyTurnstile(settings('/nohost'), 'tok')).error, 'hostname_mismatch: (none)')
assert.equal((await verifyTurnstile(settings('/fail'), 'tok')).error, 'invalid-input-response')
// 空 token / 超长 token:**本地就拒**,不浪费一次上游往返
const before = hits
assert.equal((await verifyTurnstile(settings('/ok'), '')).error, 'missing-input-response')
assert.equal((await verifyTurnstile(settings('/ok'), 'x'.repeat(2049))).error, 'invalid-input-response')
assert.equal(hits, before, '本地拒绝不应打到 siteverify')
// 缺 secret ⇒ not_configured(不请求上游)
assert.equal((await verifyTurnstile({ ...settings('/ok'), secret: '' }, 'tok')).error, 'not_configured')
} finally {
await new Promise((resolve) => server.close(resolve))
}
})
test('config: 期望主机名归一 + 派生 + ⛔ 绝不自动加 localhost', () => {
assert.deepEqual(normalizeHostnames(['https://EXAMPLE.net/', 'www.example.net:443', ' example.net ']), [
'example.net', 'www.example.net',
])
// 未配(undefined)⇒ 从 baseDomain 派生
assert.deepEqual(resolveTurnstileHostnames(undefined, 'example.net'), ['example.net', 'www.example.net'])
// 显式配 ⇒ 只用配的(可加旧域,旧域门户仍在线)
assert.deepEqual(
resolveTurnstileHostnames(['example.net', 'example.org'], 'example.net'),
['example.net', 'example.org'],
)
// 显式空 ⇒ 关闭(不派生)
assert.deepEqual(resolveTurnstileHostnames([], 'example.net'), [])
// baseDomain 空 ⇒ 空(= 未配置完成 ⇒ 人机验证停用,不会静默放开)
assert.deepEqual(resolveTurnstileHostnames(undefined, ''), [])
assert.equal(normalizeHostnames(['localhost']).includes('example.net'), false)
})
/* ── 编排层:真库 + log 驱动 ─────────────────────────────────────────────── */
/** 直接插一条"可校验"的事件行(发码走真实路径时验证码只在邮件里,测试需要已知码)。 */
async function seedCode(db, config, { email, username, code, createdAt = Date.now(), expiresAt }) {
await db.recordEmailCode({
id: randomUUID(),
email,
purpose: 'register',
codeHash: hashCode(email, 'register', code, config.encryptionSecret),
status: 'sent',
ip: '203.0.113.9',
username,
reason: null,
createdAt,
expiresAt: expiresAt ?? createdAt + config.emailCodeTtlMs,
})
}
test('编排:发码成功落 sent,冷却期内再发落 throttled 并回 429', async () => {
const db = new SqliteAdapter(':memory:', 100000)
const config = makeConfig()
const deps = { db, config }
try {
const first = await requestRegisterCode(deps, { email: '[email protected]', username: 'newbie', ip: '198.51.100.7' })
assert.equal(first.ok, true)
assert.equal(first.retryAfterSeconds, 60)
const latest = await db.latestSentEmailCode('[email protected]', 'register')
assert.ok(latest, '应留下一条可校验的 sent 行')
assert.equal(latest.status, 'sent')
assert.equal(latest.username, 'newbie')
assert.equal(latest.code_hash?.length, 64, '只存哈希、不存明文')
const second = await requestRegisterCode(deps, { email: '[email protected]', username: 'newbie', ip: '198.51.100.7' })
assert.equal(second.ok, false)
assert.equal(second.status, 429)
assert.equal(second.error, 'email_cooldown')
assert.ok(second.retryAfterSeconds > 0 && second.retryAfterSeconds <= 60)
const hour = await db.emailCodeCounts('[email protected]', '198.51.100.7', Date.now() - HOUR_MS)
assert.equal(hour.emailTotal, 2, '被拒的那次也必须计入配额')
} finally {
await db.close()
}
})
test('编排:用户名/邮箱占用与格式错误在发码阶段就被挡(不浪费配额)', async () => {
const db = new SqliteAdapter(':memory:', 100000)
const config = makeConfig()
const deps = { db, config }
try {
await db.createUser({ id: 'u1', username: 'taken', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' })
const takenName = await requestRegisterCode(deps, { email: '[email protected]', username: 'taken', ip: null })
assert.equal(takenName.error, 'username_taken')
assert.equal(takenName.status, 409)
const takenMail = await requestRegisterCode(deps, { email: '[email protected]', username: 'fresh', ip: null })
assert.equal(takenMail.error, 'email_taken')
assert.equal(takenMail.status, 409)
assert.equal((await requestRegisterCode(deps, { email: 'bad', username: 'fresh', ip: null })).error, 'invalid_email')
assert.equal((await requestRegisterCode(deps, { email: '[email protected]', username: 'x', ip: null })).error, 'invalid_username')
const countsAfter = await db.emailCodeCounts('[email protected]', null, Date.now() - HOUR_MS)
assert.equal(countsAfter.emailTotal, 0, '被前置挡掉的请求不应占用配额')
} finally {
await db.close()
}
})
test('编排:试错递增、达上限立即作废;正确码单次使用', async () => {
const db = new SqliteAdapter(':memory:', 100000)
const config = makeConfig()
const deps = { db, config }
try {
await seedCode(db, config, { email: '[email protected]', username: 'carl', code: '111111' })
const first = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
assert.equal(first.error, 'code_invalid')
assert.equal(first.attemptsLeft, config.emailCodeMaxAttempts - 1)
for (let i = 1; i < config.emailCodeMaxAttempts; i += 1) {
await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
}
// 第 5 次错 ⇒ 直接作废
const exhausted = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
assert.equal(exhausted.error, 'code_attempts_exceeded')
// 此时**连正确码也不认**(必须重新获取)
const afterExhaust = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '111111', ip: null })
assert.equal(afterExhaust.error, 'code_attempts_exceeded')
// 新码:一次成功、二次被拒(单次使用)
await seedCode(db, config, { email: '[email protected]', username: 'carl', code: '222222' })
const ok = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '222222', ip: null })
assert.equal(ok.ok, true)
const reuse = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '222222', ip: null })
assert.equal(reuse.error, 'code_used')
} finally {
await db.close()
}
})
test('编排:过期 / 用户名不匹配 / 大小写无关', async () => {
const db = new SqliteAdapter(':memory:', 100000)
const config = makeConfig()
const deps = { db, config }
try {
await seedCode(db, config, {
email: '[email protected]', username: 'dora', code: '333333',
createdAt: Date.now() - 20 * 60 * 1000, expiresAt: Date.now() - 10 * 60 * 1000,
})
assert.equal(
(await consumeRegisterCode(deps, { email: '[email protected]', username: 'dora', code: '333333', ip: null })).error,
'code_expired',
)
await seedCode(db, config, { email: '[email protected]', username: 'erin', code: '444444' })
assert.equal(
(await consumeRegisterCode(deps, { email: '[email protected]', username: 'someone', code: '444444', ip: null })).error,
'code_username_mismatch',
)
// 大小写不敏感 + 邮箱归一化
const ok = await consumeRegisterCode(deps, { email: '[email protected]', username: 'ERIN', code: '444444', ip: null })
assert.equal(ok.ok, true)
} finally {
await db.close()
}
})
test('v10 迁移:users.email 大小写不敏感唯一 + findUserByEmail', async () => {
const db = new SqliteAdapter(':memory:', 100000)
try {
await db.createUser({ id: 'a', username: 'alice', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' })
const found = await db.findUserByEmail('[email protected]')
assert.equal(found?.id, 'a')
assert.equal(found?.email, '[email protected]')
assert.equal(await db.findUserByEmail('[email protected]'), undefined)
await assert.rejects(() =>
db.createUser({ id: 'b', username: 'bob', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' }),
)
// email 可选(老路径 / k8s 路径不受影响)
await db.createUser({ id: 'c', username: 'carol', passHash: 'x', role: 'active', homeDir: '/h' })
assert.equal((await db.findUserById('c'))?.email, null)
} finally {
await db.close()
}
})
test('v10:事件表自维护(purge 只清旧行)', async () => {
const db = new SqliteAdapter(':memory:', 100000)
const now = Date.now()
try {
await db.recordEmailCode({
id: 'old', email: '[email protected]', purpose: 'register', codeHash: null, status: 'throttled',
createdAt: now - 40 * 24 * 60 * 60 * 1000,
})
await db.recordEmailCode({
id: 'new', email: '[email protected]', purpose: 'register', codeHash: null, status: 'throttled', createdAt: now,
})
const removed = await db.purgeEmailCodes(now - 30 * 24 * 60 * 60 * 1000)
assert.equal(removed, 1)
const left = await db.emailCodeCounts('[email protected]', null, 0)
assert.equal(left.emailTotal, 1)
} finally {
await db.close()
}
})