code.js
5.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
/**
* 验证码 —— 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 || '*'}`
}