feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。 ⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。 【档案 134 · 注册页人机验证 + 邮箱验证码】 - DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引) - 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts - routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code; 注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为) - 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF) - 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯 (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定 - Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的, 只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的 - 新增 test/register-guard.test.mjs(19 用例) 【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】 - login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形 → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name) - portal 顶栏换图标(页面名「管理门户」保留) - 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它 - 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果) - scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG 解析失败、图标静默不显示 —— 实际踩到过) - 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束) 【档案 135/136 · 域名迁移线(另一会话产出)】 - 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置 - src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新; src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs - 档案 136 = 控制面按两台中继取并集(**已立项、未落地**) 验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped| verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、 发码 delivered、四页 deepseek 命中 0、favicon 200。
This commit is contained in:
1 parent
d2ef362a98
commit
971ccc3703
56 files changed
+3498
-193
No files matched your search
+209
-3
@@ -17,6 +17,9 @@ export type IsolationMode = 'soft' | 'account'
|
||||
* `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */
|
||||
export type DeployMode = 'local' | 'k8s' | 'cluster'
|
||||
|
||||
/** 外发邮件驱动(注册验证码)。见 `web/mail.ts`。 */
|
||||
export type MailDriver = 'brevo' | 'http' | 'log'
|
||||
|
||||
/** Resolved, immutable runtime configuration. */
|
||||
export interface ServerConfig {
|
||||
/** Bind host for the orchestrator HTTP server. */
|
||||
@@ -119,7 +122,7 @@ export interface ServerConfig {
|
||||
*/
|
||||
clusterRendezvousUrl: string
|
||||
/**
|
||||
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。
|
||||
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://ai1net.com/dshs-relay`。
|
||||
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
|
||||
*/
|
||||
relayUrl: string
|
||||
@@ -195,6 +198,57 @@ export interface ServerConfig {
|
||||
overlayNodeKeyFile: string
|
||||
/** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */
|
||||
overlayNodeGrantFile: string
|
||||
// ── 注册页人机验证 + 邮箱验证码(档案 134;`web/turnstile.ts` / `web/mail.ts`)──────
|
||||
/**
|
||||
* Cloudflare Turnstile 站点公钥(**会下发到注册页**,不是秘密)。
|
||||
* ⚠️ 与 `turnstileSecret` **必须成对**:只填一把 = 视为未配置(人机验证整体不启用)。
|
||||
*/
|
||||
turnstileSiteKey: string
|
||||
/** Turnstile 服务端密钥。空 ⇒ 人机验证停用(注册页不渲染 widget、后端不校验)。 */
|
||||
turnstileSecret: string
|
||||
/**
|
||||
* 期望的 `action`(渲染 widget 时声明、siteverify 时回显)。默认 `signup`。
|
||||
* 不校它 ⇒ 同 sitekey 的各个入口共享 token,人机验证退化成"过一处即可用到处"。
|
||||
*/
|
||||
turnstileAction: string
|
||||
/**
|
||||
* 🔴 期望的**前端主机名**白名单(`result.hostname` 必须在此列)。
|
||||
*
|
||||
* sitekey 是公开的(就在页面 HTML 里)⇒ 攻击者可在**自己站点**嵌入我们的 sitekey、
|
||||
* 为真人访客拿到合法 token,再拿去打我们的注册接口;只校 `success` 的话这条路完全通畅。
|
||||
* `hostname` 由 **Cloudflare 服务端**判定并回显,访客篡改不了 ⇒ 只有校它才能把 token
|
||||
* 真正绑到"从我们站点发出的挑战"上。
|
||||
* 默认从 `baseDomain` 派生(`<domain>` + `www.<domain>`);**空数组 = 不安全 ⇒ 视为未配置完成**。
|
||||
*/
|
||||
turnstileHostnames: string[]
|
||||
/** 邮件驱动:`brevo` / `http` / `log`。`log` 只在开发排障时用(验证码会进 journald)。 */
|
||||
mailDriver: MailDriver
|
||||
mailApiUrl: string
|
||||
mailApiKey: string
|
||||
mailAuthHeader: string
|
||||
mailFrom: string
|
||||
mailFromName: string
|
||||
mailBodyTemplate: string
|
||||
mailTimeoutMs: number
|
||||
/**
|
||||
* 注册是否**强制**邮箱验证码。默认 `true`,但**仅当邮件通道已配置**时才真正生效
|
||||
* (见 `mailEnabled`)—— 这样"没配邮件"的环境不会因为缺配置而注册不了。
|
||||
*/
|
||||
registerRequireEmailCode: boolean
|
||||
/** 注册是否强制人机验证。同理:只在 `turnstileSecret` 有值时才生效。 */
|
||||
registerRequireCaptcha: boolean
|
||||
/** 验证码有效期(毫秒)。 */
|
||||
emailCodeTtlMs: number
|
||||
/** 单个验证码允许的最大试错次数(达上限即作废)。 */
|
||||
emailCodeMaxAttempts: number
|
||||
/** 反爆破策略:限额与冷却阶梯(见 `web/register-guard.ts`)。 */
|
||||
emailCodeGuard: {
|
||||
emailPerHour: number
|
||||
emailSentPerDay: number
|
||||
ipPerHour: number
|
||||
globalPerHour: number
|
||||
cooldownLadderMs: number[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Untyped overrides collected from argv / env. */
|
||||
@@ -258,6 +312,22 @@ export interface ConfigOverrides {
|
||||
overlayDirTrustedKeys?: string[]
|
||||
overlayDirKeyFile?: string
|
||||
overlayDirectoryCacheFile?: string
|
||||
turnstileSiteKey?: string
|
||||
turnstileSecret?: string
|
||||
turnstileAction?: string
|
||||
turnstileHostnames?: string[]
|
||||
mailDriver?: MailDriver | string
|
||||
mailApiUrl?: string
|
||||
mailApiKey?: string
|
||||
mailAuthHeader?: string
|
||||
mailFrom?: string
|
||||
mailFromName?: string
|
||||
mailBodyTemplate?: string
|
||||
mailTimeoutMs?: number | string
|
||||
registerRequireEmailCode?: boolean
|
||||
registerRequireCaptcha?: boolean
|
||||
emailCodeTtlMs?: number | string
|
||||
emailCodeMaxAttempts?: number | string
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = '127.0.0.1'
|
||||
@@ -304,12 +374,30 @@ const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
|
||||
const DEFAULT_K8S_NAMESPACE = 'dsh'
|
||||
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
|
||||
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
|
||||
// 注册页人机验证 + 邮箱验证码(档案 134)
|
||||
const DEFAULT_REGISTER_REQUIRE_EMAIL_CODE = true
|
||||
const DEFAULT_REGISTER_REQUIRE_CAPTCHA = true
|
||||
const DEFAULT_MAIL_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_TURNSTILE_TIMEOUT_MS = 8_000
|
||||
/** Turnstile `action` 默认值:与注册页渲染时声明的一致(见 `web/register.html`)。 */
|
||||
const DEFAULT_TURNSTILE_ACTION = 'signup'
|
||||
const DEFAULT_EMAIL_CODE_TTL_MS = 10 * 60 * 1000
|
||||
const DEFAULT_EMAIL_CODE_MAX_ATTEMPTS = 5
|
||||
const DEFAULT_EMAIL_GUARD = {
|
||||
// 6 = 冷却阶梯每一级都可达(索引 0…5);见 `web/register-guard.ts` 的说明。
|
||||
emailPerHour: 6,
|
||||
emailSentPerDay: 8,
|
||||
ipPerHour: 20,
|
||||
globalPerHour: 200,
|
||||
// 索引 = 该邮箱最近一小时内已发起的次数(超出取最后一项)⇒ 冷却随重试次数递增。
|
||||
cooldownLadderMs: [60_000, 60_000, 180_000, 300_000, 900_000, 1_800_000],
|
||||
}
|
||||
/**
|
||||
* 覆盖网络 P0-2:**内置种子**(引导链的常量位)。
|
||||
* 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。
|
||||
* ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。
|
||||
*/
|
||||
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://alotbuy.com/dshs-relay']
|
||||
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://ai1net.com/dshs-relay']
|
||||
|
||||
/** Load the encryption secret from env, or persist a generated one at
|
||||
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
|
||||
@@ -350,6 +438,77 @@ function parseCidrs(value: string | undefined): string[] {
|
||||
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件驱动解析(档案 134)。**不认识的值回落 `brevo` 而不是报错** ——
|
||||
* 写错一个字母就打不开注册页,代价远大于"驱动没换成"。
|
||||
*/
|
||||
function toMailDriver(value: string | undefined): MailDriver {
|
||||
const v = (value ?? '').trim().toLowerCase()
|
||||
return v === 'http' || v === 'log' || v === 'brevo' ? v : 'brevo'
|
||||
}
|
||||
|
||||
/**
|
||||
* Turnstile `action` 归一:CF 规定 1–32 字符、仅 `[A-Za-z0-9_-]`。
|
||||
* 非法值 ⇒ 回落默认(而不是抛错):`action` 只是"这枚 token 属于哪个业务"的标签,
|
||||
* 打错字不该让注册页起不来。
|
||||
*/
|
||||
function normalizeAction(value: string | undefined): string {
|
||||
const v = (value ?? '').trim()
|
||||
return /^[A-Za-z0-9_-]{1,32}$/.test(v) ? v : DEFAULT_TURNSTILE_ACTION
|
||||
}
|
||||
|
||||
/**
|
||||
* 主机名归一:去掉协议 / 路径 / 端口 / 首尾点,转小写并去重。
|
||||
* 为什么要容错:运维很容易把 env 写成 `https://ai1net.com/`(照抄 URL 的习惯),
|
||||
* 而 CF 回显的是**裸主机名** ⇒ 不归一就是"配了却永远不匹配"的静默失效。
|
||||
*/
|
||||
export function normalizeHostnames(values: readonly string[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const raw of values) {
|
||||
const host = raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^[a-z]+:\/\//, '')
|
||||
.split('/')[0]
|
||||
.split(':')[0]
|
||||
.replace(/^\.+|\.+$/g, '')
|
||||
if (host !== '' && !out.includes(host)) out.push(host)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 期望主机名:**显式配了就用配的**(空 ⇒ 视为未配置完成,交由 `turnstileEnabled` 判停用),
|
||||
* 否则从 `baseDomain` 派生 `<domain>` + `www.<domain>`。
|
||||
* ⚠️ **绝不自动加 `localhost` / `127.0.0.1`** —— 生产后端的白名单里放它们 = 放开本地伪造。
|
||||
*/
|
||||
export function resolveTurnstileHostnames(configured: readonly string[] | undefined, baseDomain: string): string[] {
|
||||
if (configured !== undefined && configured.length > 0) return normalizeHostnames(configured)
|
||||
if (configured !== undefined) return [] // 显式空 = 关闭(不派生)
|
||||
const domain = baseDomain.trim().toLowerCase()
|
||||
return domain === '' ? [] : normalizeHostnames([domain, `www.${domain}`])
|
||||
}
|
||||
|
||||
/** 正整数解析(0 / 负数 / 非数字 / 空 ⇒ `fallback`)。用于各种毫秒数与次数上限。 */
|
||||
function toPositiveInt(value: string | number | undefined, fallback: number): number {
|
||||
if (value === undefined || value === '') return fallback
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 冷却阶梯解析:env 给的是**秒**的逗号分隔列表(`60,60,180,300,900,1800`),
|
||||
* 内部一律用毫秒。空 / 全非法 ⇒ 回落默认阶梯。
|
||||
*/
|
||||
function parseSecondsLadder(value: string | undefined, fallback: number[]): number[] {
|
||||
if (value === undefined || value === '') return [...fallback]
|
||||
const seconds = value
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
return seconds.length === 0 ? [...fallback] : seconds.map((s) => Math.floor(s) * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回
|
||||
* `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。
|
||||
@@ -395,6 +554,8 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
|
||||
toDeployMode(overrides.deployMode) ??
|
||||
toDeployMode(process.env.DSHS_DEPLOY_MODE) ??
|
||||
DEFAULT_DEPLOY_MODE
|
||||
// `baseDomain` 提前算出:Turnstile 的**期望主机名白名单**要从它派生(见下)。
|
||||
const baseDomainResolved = overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN
|
||||
return {
|
||||
host: overrides.host ?? DEFAULT_HOST,
|
||||
port: typeof port === 'number' ? port : Number(port),
|
||||
@@ -447,7 +608,7 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
|
||||
isolationMode,
|
||||
spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND,
|
||||
baseUid: Number(overrides.baseUid ?? process.env.DSHS_BASE_UID ?? DEFAULT_BASE_UID),
|
||||
baseDomain: overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN,
|
||||
baseDomain: baseDomainResolved,
|
||||
cookieDomain: overrides.cookieDomain ?? process.env.DSHS_COOKIE_DOMAIN ?? DEFAULT_COOKIE_DOMAIN,
|
||||
enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH),
|
||||
portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false),
|
||||
@@ -529,5 +690,50 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
|
||||
// 表现成"一切正常",等 relay 一开强制就整台失联)。
|
||||
overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(),
|
||||
overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '').trim(),
|
||||
// ── 注册页人机验证 + 邮箱验证码(档案 134)────────────────────────────────
|
||||
// 主键一律**默认空** ⇒ 「不配任何 env = 与今天行为完全一致」;配齐了才启用。
|
||||
// 这一条是刻意的:注册是平台唯一的入口,不能因为漏配一个 env 就把注册锁死。
|
||||
turnstileSiteKey: (overrides.turnstileSiteKey ?? process.env.DSHS_TURNSTILE_SITE_KEY ?? '').trim(),
|
||||
turnstileSecret: (overrides.turnstileSecret ?? process.env.DSHS_TURNSTILE_SECRET ?? '').trim(),
|
||||
turnstileAction: normalizeAction(overrides.turnstileAction ?? process.env.DSHS_TURNSTILE_ACTION),
|
||||
turnstileHostnames: resolveTurnstileHostnames(
|
||||
overrides.turnstileHostnames ?? splitList(process.env.DSHS_TURNSTILE_HOSTNAMES),
|
||||
baseDomainResolved,
|
||||
),
|
||||
mailDriver: toMailDriver(overrides.mailDriver ?? process.env.DSHS_MAIL_DRIVER),
|
||||
mailApiUrl: (overrides.mailApiUrl ?? process.env.DSHS_MAIL_API_URL ?? '').trim(),
|
||||
mailApiKey: (overrides.mailApiKey ?? process.env.DSHS_MAIL_API_KEY ?? '').trim(),
|
||||
mailAuthHeader: (overrides.mailAuthHeader ?? process.env.DSHS_MAIL_AUTH_HEADER ?? '').trim(),
|
||||
mailFrom: (overrides.mailFrom ?? process.env.DSHS_MAIL_FROM ?? '').trim(),
|
||||
mailFromName: (overrides.mailFromName ?? process.env.DSHS_MAIL_FROM_NAME ?? '').trim(),
|
||||
mailBodyTemplate: (overrides.mailBodyTemplate ?? process.env.DSHS_MAIL_BODY_TEMPLATE ?? '').trim(),
|
||||
mailTimeoutMs: toPositiveInt(
|
||||
overrides.mailTimeoutMs ?? process.env.DSHS_MAIL_TIMEOUT_MS,
|
||||
DEFAULT_MAIL_TIMEOUT_MS,
|
||||
),
|
||||
registerRequireEmailCode:
|
||||
overrides.registerRequireEmailCode ??
|
||||
toBool(process.env.DSHS_REGISTER_REQUIRE_EMAIL_CODE, DEFAULT_REGISTER_REQUIRE_EMAIL_CODE),
|
||||
registerRequireCaptcha:
|
||||
overrides.registerRequireCaptcha ??
|
||||
toBool(process.env.DSHS_REGISTER_REQUIRE_CAPTCHA, DEFAULT_REGISTER_REQUIRE_CAPTCHA),
|
||||
emailCodeTtlMs: toPositiveInt(
|
||||
overrides.emailCodeTtlMs ?? process.env.DSHS_EMAIL_CODE_TTL_MS,
|
||||
DEFAULT_EMAIL_CODE_TTL_MS,
|
||||
),
|
||||
emailCodeMaxAttempts: toPositiveInt(
|
||||
overrides.emailCodeMaxAttempts ?? process.env.DSHS_EMAIL_CODE_MAX_ATTEMPTS,
|
||||
DEFAULT_EMAIL_CODE_MAX_ATTEMPTS,
|
||||
),
|
||||
emailCodeGuard: {
|
||||
emailPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_EMAIL_HOUR, DEFAULT_EMAIL_GUARD.emailPerHour),
|
||||
emailSentPerDay: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_EMAIL_DAY, DEFAULT_EMAIL_GUARD.emailSentPerDay),
|
||||
ipPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_IP_HOUR, DEFAULT_EMAIL_GUARD.ipPerHour),
|
||||
globalPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_GLOBAL_HOUR, DEFAULT_EMAIL_GUARD.globalPerHour),
|
||||
cooldownLadderMs: parseSecondsLadder(
|
||||
process.env.DSHS_EMAIL_COOLDOWN_LADDER_SEC,
|
||||
DEFAULT_EMAIL_GUARD.cooldownLadderMs,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@ import type {
|
||||
DshInstance,
|
||||
DshInstanceRole,
|
||||
DshInstanceStatus,
|
||||
EmailCodeCounts,
|
||||
EmailCodeRow,
|
||||
PublicUser,
|
||||
RecordEmailCodeInput,
|
||||
SessionRow,
|
||||
SessionUser,
|
||||
UpsertBusinessPluginInput,
|
||||
@@ -57,6 +60,24 @@ export interface DbAdapter {
|
||||
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
|
||||
/** Whether the user has any session that has not yet expired (idle reap). */
|
||||
hasActiveSession(userId: string): Promise<boolean>
|
||||
/** Registration e-mail lookup (v10, case-insensitive). */
|
||||
findUserByEmail(email: string): Promise<User | undefined>
|
||||
// e-mail verification codes (v10) — 注册页「邮箱验证码」的存储 + 防爆破计数来源
|
||||
/** Append one `email_codes` row (sent / throttled / failed). */
|
||||
recordEmailCode(input: RecordEmailCodeInput): Promise<void>
|
||||
/** Rolling counters for one (email, ip) pair — the brute-force guard's only input. */
|
||||
emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts>
|
||||
/** The newest **deliverable** code for (email, purpose), or undefined. */
|
||||
latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined>
|
||||
/**
|
||||
* 记一次校验失败:`attempts + 1`;`consume` 为真时同时打上 `consumed_at`(= 该码作废)。
|
||||
* 返回 false 表示该行已不存在(例如被并发守卫先行作废)。
|
||||
*/
|
||||
bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean>
|
||||
/** 校验通过:打上 `consumed_at`(**单次使用**)。返回 false = 在写入前已被别人用掉。 */
|
||||
consumeEmailCode(id: string, now: number): Promise<boolean>
|
||||
/** 删除早于 `before` 的 `email_codes` 行(自维护,避免表无限增长)。 */
|
||||
purgeEmailCodes(before: number): Promise<number>
|
||||
// audit
|
||||
audit(actor: string | null, action: string, detail?: string | null): Promise<void>
|
||||
// workspaces / plugins
|
||||
|
||||
+96
-4
@@ -16,6 +16,7 @@ import {
|
||||
toDomain,
|
||||
toDshHost,
|
||||
toDshInstance,
|
||||
toEmailCode,
|
||||
toPublicUser,
|
||||
toSession,
|
||||
toUser,
|
||||
@@ -33,7 +34,10 @@ import {
|
||||
type DshInstance,
|
||||
type DshInstanceRole,
|
||||
type DshInstanceStatus,
|
||||
type EmailCodeCounts,
|
||||
type EmailCodeRow,
|
||||
type PublicUser,
|
||||
type RecordEmailCodeInput,
|
||||
type SessionRow,
|
||||
type SessionUser,
|
||||
type UpsertBusinessPluginInput,
|
||||
@@ -50,7 +54,9 @@ import {
|
||||
// both backends. This is process-global and idempotent.
|
||||
types.setTypeParser(20, (value: string) => Number(value))
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
|
||||
const EMAIL_CODE_COLS =
|
||||
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
|
||||
const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat'
|
||||
@@ -114,8 +120,9 @@ export class PgAdapter implements DbAdapter {
|
||||
try {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING row_id',
|
||||
[input.id, input.username, input.passHash, input.role, input.homeDir, createdAt],
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) '
|
||||
+ 'VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING row_id',
|
||||
[input.id, input.username, input.passHash, input.role, input.homeDir, input.email ?? null, createdAt],
|
||||
)
|
||||
const uid = this.baseUid + Number((rows[0] as { row_id: number }).row_id)
|
||||
await client.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, input.id])
|
||||
@@ -129,6 +136,7 @@ export class PgAdapter implements DbAdapter {
|
||||
created_at: createdAt,
|
||||
approved_by: null,
|
||||
uid,
|
||||
email: input.email ?? null,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -140,7 +148,6 @@ export class PgAdapter implements DbAdapter {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async findUserBySlug(slug: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [
|
||||
slug.toLowerCase(),
|
||||
@@ -153,6 +160,91 @@ export class PgAdapter implements DbAdapter {
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/** v10:注册邮箱查重(大小写不敏感)。 */
|
||||
async findUserByEmail(email: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(email) = $1`, [
|
||||
email.toLowerCase(),
|
||||
])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
// ── v10 邮箱验证码(注册页)—— 与 SQLite 版同款语义,见 repo.ts 注释 ───────────
|
||||
|
||||
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
|
||||
await this.pool.query(
|
||||
'INSERT INTO email_codes '
|
||||
+ '(id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at) '
|
||||
+ 'VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8, $9, $10, NULL)',
|
||||
[
|
||||
input.id,
|
||||
input.email,
|
||||
input.purpose,
|
||||
input.codeHash,
|
||||
input.status,
|
||||
input.ip ?? null,
|
||||
input.username ?? null,
|
||||
input.reason ?? null,
|
||||
input.createdAt,
|
||||
input.expiresAt ?? null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = $1 AND created_at >= $2) AS email_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = $1 AND created_at >= $2 AND status = 'sent') AS email_sent,
|
||||
(SELECT COALESCE(MAX(created_at), 0) FROM email_codes WHERE email = $1) AS email_last_at,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE ip IS NOT NULL AND ip = $3 AND created_at >= $2) AS ip_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE created_at >= $2) AS global_total`,
|
||||
[email, since, ip],
|
||||
)
|
||||
const row = rows[0] as Record<string, unknown>
|
||||
return {
|
||||
emailTotal: Number(row.email_total ?? 0),
|
||||
emailSent: Number(row.email_sent ?? 0),
|
||||
emailLastAt: Number(row.email_last_at ?? 0),
|
||||
ipTotal: Number(row.ip_total ?? 0),
|
||||
globalTotal: Number(row.global_total ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT ${EMAIL_CODE_COLS} FROM email_codes WHERE email = $1 AND purpose = $2 AND status = 'sent' `
|
||||
+ 'ORDER BY created_at DESC LIMIT 1',
|
||||
[email, purpose],
|
||||
)
|
||||
return rows.length > 0 ? toEmailCode(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
|
||||
const { rowCount } = consume
|
||||
? await this.pool.query(
|
||||
'UPDATE email_codes SET attempts = attempts + 1, consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL',
|
||||
[now, id],
|
||||
)
|
||||
: await this.pool.query(
|
||||
'UPDATE email_codes SET attempts = attempts + 1 WHERE id = $1 AND consumed_at IS NULL',
|
||||
[id],
|
||||
)
|
||||
return (rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async consumeEmailCode(id: string, now: number): Promise<boolean> {
|
||||
const { rowCount } = await this.pool.query(
|
||||
'UPDATE email_codes SET consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL',
|
||||
[now, id],
|
||||
)
|
||||
return (rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async purgeEmailCodes(before: number): Promise<number> {
|
||||
const { rowCount } = await this.pool.query('DELETE FROM email_codes WHERE created_at < $1', [before])
|
||||
return rowCount ?? 0
|
||||
}
|
||||
|
||||
async listPublicUsers(): Promise<PublicUser[]> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`)
|
||||
return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>)))
|
||||
|
||||
+80
-3
@@ -17,6 +17,7 @@ import {
|
||||
toDomain,
|
||||
toDshHost,
|
||||
toDshInstance,
|
||||
toEmailCode,
|
||||
toPublicUser,
|
||||
toSession,
|
||||
toUser,
|
||||
@@ -34,7 +35,10 @@ import {
|
||||
type DshInstance,
|
||||
type DshInstanceRole,
|
||||
type DshInstanceStatus,
|
||||
type EmailCodeCounts,
|
||||
type EmailCodeRow,
|
||||
type PublicUser,
|
||||
type RecordEmailCodeInput,
|
||||
type SessionRow,
|
||||
type SessionUser,
|
||||
type UpsertBusinessPluginInput,
|
||||
@@ -45,7 +49,9 @@ import {
|
||||
type Workspace,
|
||||
} from './types.js'
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
|
||||
const EMAIL_CODE_COLS =
|
||||
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
|
||||
const INSTANCE_COLS =
|
||||
@@ -56,8 +62,8 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
|
||||
const createdAt = Date.now()
|
||||
return db.transaction((): User => {
|
||||
const info = prepare(db,
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(input.id, input.username, input.passHash, input.role, input.homeDir, createdAt)
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(input.id, input.username, input.passHash, input.role, input.homeDir, input.email ?? null, createdAt)
|
||||
// SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
|
||||
const uid = baseUid + Number(info.lastInsertRowid)
|
||||
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id)
|
||||
@@ -71,6 +77,7 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
|
||||
created_at: createdAt,
|
||||
approved_by: null,
|
||||
uid,
|
||||
email: input.email ?? null,
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -86,6 +93,76 @@ export function findUserBySlug(db: Database, slug: string): User | undefined {
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/** v10:注册邮箱查重(大小写不敏感)。 */
|
||||
export function findUserByEmail(db: Database, email: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE LOWER(email) = ?`).get(email.toLowerCase())
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
// ── v10 邮箱验证码 ────────────────────────────────────────────────────────────
|
||||
// 全部计数都**现算**(不维护冗余计数器):省掉"计数器与事实不一致"的整类缺陷,
|
||||
// 代价只是一次带索引的 COUNT;量级(每邮箱每小时个位数行)完全够用。
|
||||
|
||||
export function recordEmailCode(db: Database, input: RecordEmailCodeInput): void {
|
||||
prepare(db,
|
||||
`INSERT INTO email_codes (${EMAIL_CODE_COLS}) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL)`,
|
||||
).run(
|
||||
input.id,
|
||||
input.email,
|
||||
input.purpose,
|
||||
input.codeHash,
|
||||
input.status,
|
||||
input.ip ?? null,
|
||||
input.username ?? null,
|
||||
input.reason ?? null,
|
||||
input.createdAt,
|
||||
input.expiresAt ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export function emailCodeCounts(db: Database, email: string, ip: string | null, since: number): EmailCodeCounts {
|
||||
const row = prepare(db, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = ? AND created_at >= ?) AS email_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = ? AND created_at >= ? AND status = 'sent') AS email_sent,
|
||||
(SELECT COALESCE(MAX(created_at), 0) FROM email_codes WHERE email = ?) AS email_last_at,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE ip IS NOT NULL AND ip = ? AND created_at >= ?) AS ip_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE created_at >= ?) AS global_total
|
||||
`).get(email, since, email, since, email, ip, since, since) as Record<string, unknown>
|
||||
return {
|
||||
emailTotal: Number(row.email_total ?? 0),
|
||||
emailSent: Number(row.email_sent ?? 0),
|
||||
emailLastAt: Number(row.email_last_at ?? 0),
|
||||
ipTotal: Number(row.ip_total ?? 0),
|
||||
globalTotal: Number(row.global_total ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function latestSentEmailCode(db: Database, email: string, purpose: string): EmailCodeRow | undefined {
|
||||
const row = prepare(db,
|
||||
`SELECT ${EMAIL_CODE_COLS} FROM email_codes WHERE email = ? AND purpose = ? AND status = 'sent' `
|
||||
+ 'ORDER BY created_at DESC LIMIT 1',
|
||||
).get(email, purpose)
|
||||
return row ? toEmailCode(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function bumpEmailCodeAttempts(db: Database, id: string, consume: boolean, now: number): boolean {
|
||||
const info = consume
|
||||
? prepare(db, 'UPDATE email_codes SET attempts = attempts + 1, consumed_at = ? WHERE id = ? AND consumed_at IS NULL')
|
||||
.run(now, id)
|
||||
: prepare(db, 'UPDATE email_codes SET attempts = attempts + 1 WHERE id = ? AND consumed_at IS NULL').run(id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function consumeEmailCode(db: Database, id: string, now: number): boolean {
|
||||
const info = prepare(db, 'UPDATE email_codes SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL').run(now, id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function purgeEmailCodes(db: Database, before: number): number {
|
||||
return prepare(db, 'DELETE FROM email_codes WHERE created_at < ?').run(before).changes
|
||||
}
|
||||
|
||||
export function findUserById(db: Database, id: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id)
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
|
||||
@@ -382,6 +382,66 @@ const PG_V9 = `
|
||||
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
|
||||
`
|
||||
|
||||
// v10(注册页人机验证 + 邮箱验证码):`users.email` + `email_codes` 事件表。
|
||||
//
|
||||
// 为什么放 DB 而不是进程内 Map:① 本表既是**验证码存储**,也是**防爆破计数器的唯一来源**
|
||||
// (冷却 / 每时每刻配额 / 试错次数全靠 `COUNT(*)` 现算)—— 进程内的计数器**一次 restart 即清零**,
|
||||
// 而 deployment 恰好天天重启,等于把限流关掉;② 发码是被外部触发的花钱动作(邮件配额),
|
||||
// 必须可审计(`audit_log` 只记"发生过",记不了"每分钟多少次");③ PG/SQLite 双后端可查。
|
||||
//
|
||||
// 表设计取舍:**只建一张事件表**,把"发送请求"与"校验失败"都记成行(`status` 区分)——
|
||||
// 比"验证码表 + 计数器表 + 黑名单表"三张表少两次 JOIN、且天然是取证时间线。
|
||||
// · `status`:`sent`(已发出,可校验)/ `throttled`(被限流,占位计入配额)/ `failed`(驱动发信失败)
|
||||
// · 只有 `sent` 且 `consumed_at IS NULL` 且未过期的那一行可被校验通过。
|
||||
// · `code_hash` **不存明文**:sha256(email:purpose:code:pepper),pepper = 平台 `encryptionSecret`。
|
||||
// · `attempts` 记该码**已被试错几次**,达上限即 `consumed_at` 打上(作废,必须重新获取)。
|
||||
// ⚠️ 时间戳仍是 **epoch 毫秒 BIGINT**(与全库一致,勿用 timestamptz)。
|
||||
// ⚠️ `users.email` 唯一索引:存量行全为 NULL ⇒ 两方言都允许多个 NULL ⇒ 迁移不会失败;
|
||||
// 用 `LOWER(email)` 保证大小写不敏感唯一(注册时不区分大小写)。
|
||||
const SQLITE_V10 = `
|
||||
ALTER TABLE users ADD COLUMN email TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
|
||||
CREATE TABLE IF NOT EXISTS email_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
code_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'sent'
|
||||
CHECK (status IN ('sent','throttled','failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
ip TEXT,
|
||||
username TEXT,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
consumed_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
|
||||
`
|
||||
|
||||
const PG_V10 = `
|
||||
ALTER TABLE users ADD COLUMN email TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
|
||||
CREATE TABLE IF NOT EXISTS email_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
code_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'sent'
|
||||
CHECK (status IN ('sent','throttled','failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
ip TEXT,
|
||||
username TEXT,
|
||||
reason TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT,
|
||||
consumed_at BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
|
||||
`
|
||||
|
||||
interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
@@ -399,6 +459,7 @@ const MIGRATIONS: readonly Migration[] = [
|
||||
{ version: 7, name: 'cluster host registry + instance lease', sqlite: SQLITE_V7, pg: PG_V7 },
|
||||
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
|
||||
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
|
||||
{ version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 },
|
||||
]
|
||||
|
||||
/** Apply unapplied SQLite migrations inside a single transaction. */
|
||||
|
||||
@@ -14,6 +14,8 @@ import { openDatabase, type Database } from './connection.js'
|
||||
import { mapSqliteError } from './errors.js'
|
||||
import {
|
||||
audit as auditSync,
|
||||
bumpEmailCodeAttempts as bumpEmailCodeAttemptsSync,
|
||||
consumeEmailCode as consumeEmailCodeSync,
|
||||
countAdmins as countAdminsSync,
|
||||
createSession as createSessionSync,
|
||||
createUser as createUserSync,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
deleteUser as deleteUserSync,
|
||||
deleteUserInstances as deleteUserInstancesSync,
|
||||
deleteUserSessions as deleteUserSessionsSync,
|
||||
emailCodeCounts as emailCodeCountsSync,
|
||||
findBusinessPlugin as findBusinessPluginSync,
|
||||
findDomainById as findDomainByIdSync,
|
||||
findDomainByUser as findDomainByUserSync,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
findSession as findSessionSync,
|
||||
findSessionWithUser as findSessionWithUserSync,
|
||||
hasActiveSession as hasActiveSessionSync,
|
||||
findUserByEmail as findUserByEmailSync,
|
||||
findUserById as findUserByIdSync,
|
||||
findUserBySlug as findUserBySlugSync,
|
||||
findUserByUsername as findUserByUsernameSync,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
getEnabledPluginIds as getEnabledPluginIdsSync,
|
||||
getOrCreateWorkspace as getOrCreateWorkspaceSync,
|
||||
getSharedModelEnabled as getSharedModelEnabledSync,
|
||||
latestSentEmailCode as latestSentEmailCodeSync,
|
||||
listBusinessPlugins as listBusinessPluginsSync,
|
||||
listCredentialKeys as listCredentialKeysSync,
|
||||
listCredentialLandingRows as listCredentialLandingRowsSync,
|
||||
@@ -48,6 +53,8 @@ import {
|
||||
listInstancesByRole as listInstancesByRoleSync,
|
||||
listPublicUsers as listPublicUsersSync,
|
||||
listUsersWithoutUid as listUsersWithoutUidSync,
|
||||
purgeEmailCodes as purgeEmailCodesSync,
|
||||
recordEmailCode as recordEmailCodeSync,
|
||||
selectCredentialKey as selectCredentialKeySync,
|
||||
setCredentialKey as setCredentialKeySync,
|
||||
setDomainVerified as setDomainVerifiedSync,
|
||||
@@ -86,7 +93,10 @@ import type {
|
||||
DshInstance,
|
||||
DshInstanceRole,
|
||||
DshInstanceStatus,
|
||||
EmailCodeCounts,
|
||||
EmailCodeRow,
|
||||
PublicUser,
|
||||
RecordEmailCodeInput,
|
||||
SessionRow,
|
||||
SessionUser,
|
||||
UpsertBusinessPluginInput,
|
||||
@@ -176,6 +186,35 @@ export class SqliteAdapter implements DbAdapter {
|
||||
auditSync(this.db, actor, action, detail)
|
||||
}
|
||||
|
||||
// ── v10 邮箱验证码(注册页)────────────────────────────────────────────────
|
||||
async findUserByEmail(email: string): Promise<User | undefined> {
|
||||
return findUserByEmailSync(this.db, email)
|
||||
}
|
||||
|
||||
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
|
||||
recordEmailCodeSync(this.db, input)
|
||||
}
|
||||
|
||||
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
|
||||
return emailCodeCountsSync(this.db, email, ip, since)
|
||||
}
|
||||
|
||||
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
|
||||
return latestSentEmailCodeSync(this.db, email, purpose)
|
||||
}
|
||||
|
||||
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
|
||||
return bumpEmailCodeAttemptsSync(this.db, id, consume, now)
|
||||
}
|
||||
|
||||
async consumeEmailCode(id: string, now: number): Promise<boolean> {
|
||||
return consumeEmailCodeSync(this.db, id, now)
|
||||
}
|
||||
|
||||
async purgeEmailCodes(before: number): Promise<number> {
|
||||
return purgeEmailCodesSync(this.db, before)
|
||||
}
|
||||
|
||||
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
|
||||
return findWorkspaceByPathSync(this.db, userId, relPath)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,29 @@ export interface User {
|
||||
approved_by: string | null
|
||||
/** Assigned Linux uid; null until backfilled/assigned (legacy rows). */
|
||||
uid: number | null
|
||||
/** Registration e-mail (v10); null for the legacy rows created before v10. */
|
||||
email: string | null
|
||||
}
|
||||
|
||||
/** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */
|
||||
export interface EmailCodeRow {
|
||||
id: string
|
||||
email: string
|
||||
purpose: string
|
||||
code_hash: string | null
|
||||
status: EmailCodeStatus
|
||||
attempts: number
|
||||
ip: string | null
|
||||
username: string | null
|
||||
reason: string | null
|
||||
created_at: number
|
||||
expires_at: number | null
|
||||
consumed_at: number | null
|
||||
}
|
||||
|
||||
/** `sent` = a deliverable code; the other two only feed the brute-force counters. */
|
||||
export type EmailCodeStatus = 'sent' | 'throttled' | 'failed'
|
||||
|
||||
/** The user shape safe to return over the wire. */
|
||||
export interface PublicUser {
|
||||
id: string
|
||||
@@ -194,6 +215,36 @@ export interface CreateUserInput {
|
||||
passHash: string
|
||||
role: UserRole
|
||||
homeDir: string
|
||||
/** Registration e-mail (v10, optional so the legacy/k8s paths keep working). */
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Insert one `email_codes` row (send attempt, throttled attempt or failed send). */
|
||||
export interface RecordEmailCodeInput {
|
||||
id: string
|
||||
email: string
|
||||
purpose: string
|
||||
codeHash: string | null
|
||||
status: EmailCodeStatus
|
||||
ip?: string | null
|
||||
username?: string | null
|
||||
reason?: string | null
|
||||
createdAt: number
|
||||
expiresAt?: number | null
|
||||
}
|
||||
|
||||
/** Rolling counters used by the brute-force guard (all computed from `email_codes`). */
|
||||
export interface EmailCodeCounts {
|
||||
/** Rows for this e-mail since `since`. */
|
||||
emailTotal: number
|
||||
/** Rows for this e-mail since `since` that were actually deliverable (`sent`). */
|
||||
emailSent: number
|
||||
/** Most recent row timestamp for this e-mail (any status), or 0 when none. */
|
||||
emailLastAt: number
|
||||
/** Rows from this ip since `since` (empty ip ⇒ 0). */
|
||||
ipTotal: number
|
||||
/** Global row count since `since` (protects the provider's daily quota). */
|
||||
globalTotal: number
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
@@ -236,6 +287,24 @@ export function toUser(row: Record<string, unknown>): User {
|
||||
created_at: row.created_at as number,
|
||||
approved_by: (row.approved_by as string | null) ?? null,
|
||||
uid: (row.uid as number | null) ?? null,
|
||||
email: (row.email as string | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toEmailCode(row: Record<string, unknown>): EmailCodeRow {
|
||||
return {
|
||||
id: row.id as string,
|
||||
email: row.email as string,
|
||||
purpose: row.purpose as string,
|
||||
code_hash: (row.code_hash as string | null) ?? null,
|
||||
status: row.status as EmailCodeStatus,
|
||||
attempts: Number(row.attempts ?? 0),
|
||||
ip: (row.ip as string | null) ?? null,
|
||||
username: (row.username as string | null) ?? null,
|
||||
reason: (row.reason as string | null) ?? null,
|
||||
created_at: Number(row.created_at),
|
||||
expires_at: row.expires_at === null || row.expires_at === undefined ? null : Number(row.expires_at),
|
||||
consumed_at: row.consumed_at === null || row.consumed_at === undefined ? null : Number(row.consumed_at),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
|
||||
*
|
||||
* ## 它解决的唯一问题
|
||||
* 兜底入口 `relay-direct.alotbuy.com` 与主入口 `alotbuy.com` **同属 `*.alotbuy.com`**,
|
||||
* 兜底入口 `relay-direct.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**,
|
||||
* 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于
|
||||
* "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果
|
||||
* **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5)
|
||||
* ```
|
||||
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89
|
||||
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.ai1net.com=47.77.182.89
|
||||
* ```
|
||||
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。
|
||||
*
|
||||
|
||||
@@ -78,7 +78,7 @@ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||
* ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量,
|
||||
* **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。
|
||||
*/
|
||||
export const DEFAULT_OVERLAY_SEED = 'https://alotbuy.com/dshs-relay'
|
||||
export const DEFAULT_OVERLAY_SEED = 'https://ai1net.com/dshs-relay'
|
||||
|
||||
/** 从环境变量取种子;**没配** ⇒ 用内置常量位。 */
|
||||
export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
@@ -321,7 +321,7 @@ export function buildDirectoryDocument(input: {
|
||||
/**
|
||||
* 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。
|
||||
*
|
||||
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://alotbuy.com/dshs-relay`,
|
||||
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://ai1net.com/dshs-relay`,
|
||||
* 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。
|
||||
* 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { AddressLookup, Rendezvous } from '../rendezvous.js'
|
||||
import { parseLogicalName } from './network.js'
|
||||
|
||||
export interface RelayRendezvousOptions {
|
||||
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.alotbuy.com/dshs-relay`。 */
|
||||
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.ai1net.com/dshs-relay`。 */
|
||||
dialTargetUrl: string
|
||||
/**
|
||||
* **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。
|
||||
|
||||
@@ -329,7 +329,7 @@ export class RelayFailoverSupervisor {
|
||||
*
|
||||
* 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与
|
||||
* **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。
|
||||
* 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前
|
||||
* 首轮真机实测(11:43:26):`wss://106… -> wss://ai1net.com…` —— 而 `ai1net.com` 十几分钟前
|
||||
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
|
||||
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
|
||||
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。
|
||||
@@ -353,7 +353,7 @@ export class RelayFailoverSupervisor {
|
||||
/**
|
||||
* 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖):
|
||||
*
|
||||
* 生产目录的 `relays[]` = `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`
|
||||
* 生产目录的 `relays[]` = `[ai1net.com(47), relay-direct.ai1net.com(47), 106]`
|
||||
* ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
|
||||
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
|
||||
* 链虽然"不再退化成单点",却依然**换不过去**。
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 注册邮箱验证码:发码 / 校验两条链路 + 防爆破。路由层只做参数校验与响应组装。
|
||||
*
|
||||
* **为什么单独成模块**:这一段同时牵着四样东西 —— DB 事件表(配额与验证码的唯一来源)、
|
||||
* 外发邮件、人机验证、以及限流策略。塞进 `routes/auth.ts` 会让"注册"这个路由变成
|
||||
* 300 行的混合体,且**没法单测**(策略本身要用真接口才验得到边界)。拆出来后:
|
||||
* · 策略 = `register-guard.ts`(纯函数,可逐边界断言)
|
||||
* · 通道 = `mail.ts` / `turnstile.ts`(只负责一次网络往返)
|
||||
* · 编排 = 本文件(把上面三者按"先判配额 → 再发信 → 落事件"的顺序串起来)
|
||||
*
|
||||
* 三条不可让步的顺序:
|
||||
* ① **先人机验证再花配额** —— 否则机器人可以靠"打满配额"把真用户挡在门外;
|
||||
* ② **被拒绝也要落库** —— 事件表既是配额来源,也是"有人在撞"的唯一证据;
|
||||
* ③ **发信成功才记 `sent`** —— 记早了会让"上游全挂"看起来像"发出去过"。
|
||||
* @module dshs/web/email-code
|
||||
*/
|
||||
|
||||
import { createHash, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import type { DbAdapter } from '../db/adapter.js'
|
||||
import type { ServerConfig } from '../config.js'
|
||||
import { sendVerificationCodeMail, type MailSettings } from './mail.js'
|
||||
import { turnstileEnabled, type TurnstileSettings } from './turnstile.js'
|
||||
import {
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
evaluateSendGuard,
|
||||
evaluateVerifyGuard,
|
||||
type GuardPolicy,
|
||||
} from './register-guard.js'
|
||||
|
||||
export const PURPOSE_REGISTER = 'register'
|
||||
|
||||
/** 事件表自维护:每小时最多清一次 30 天前的行。 */
|
||||
const PURGE_OLDER_THAN_MS = 30 * DAY_MS
|
||||
const PURGE_INTERVAL_MS = HOUR_MS
|
||||
|
||||
/** 进程内串行化:同一邮箱同时只允许一个发信在飞(单进程控制面,够用)。 */
|
||||
const inflight = new Map<string, Promise<unknown>>()
|
||||
|
||||
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,32}$/
|
||||
|
||||
export function normalizeEmail(raw: string): string {
|
||||
return raw.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return email.length >= 6 && email.length <= 254 && EMAIL_RE.test(email)
|
||||
}
|
||||
|
||||
export function isValidUsername(username: string): boolean {
|
||||
return USERNAME_RE.test(username)
|
||||
}
|
||||
|
||||
/** 邮件里的站点名:取主域名标签大写(`ai1net.com` → `AI1NET`)。空 ⇒ 不写站点名。 */
|
||||
export function mailBrandFromConfig(config: ServerConfig): string {
|
||||
const domain = (config.baseDomain ?? '').trim()
|
||||
if (domain === '') return ''
|
||||
const label = domain.split('.')[0] ?? ''
|
||||
return label === '' ? '' : label.toUpperCase()
|
||||
}
|
||||
|
||||
/** 6 位数字(含前导 0)。用 `randomInt` 而非 `Math.random` —— 后者可预测。 */
|
||||
export function generateCode(): string {
|
||||
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
||||
}
|
||||
|
||||
/** 验证码**不存明文**:sha256(email | purpose | code | pepper),pepper = 平台密钥。 */
|
||||
export function hashCode(email: string, purpose: string, code: string, pepper: string): string {
|
||||
return createHash('sha256').update(`${email}|${purpose}|${code}|${pepper}`).digest('hex')
|
||||
}
|
||||
|
||||
/** 定长比较(两边都是 hex ⇒ 等长),避免按字节短路泄露前缀。 */
|
||||
export function codeMatches(expected: string, candidate: string): boolean {
|
||||
const a = Buffer.from(expected, 'utf8')
|
||||
const b = Buffer.from(candidate, 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
/** 把配置翻译成策略。**策略与配置分家**:策略是纯函数需要的形状,配置是 env 的形状。 */
|
||||
export function guardPolicyFromConfig(config: ServerConfig): GuardPolicy {
|
||||
return {
|
||||
codeTtlMs: config.emailCodeTtlMs,
|
||||
maxAttemptsPerCode: config.emailCodeMaxAttempts,
|
||||
emailPerHour: config.emailCodeGuard.emailPerHour,
|
||||
emailSentPerDay: config.emailCodeGuard.emailSentPerDay,
|
||||
ipPerHour: config.emailCodeGuard.ipPerHour,
|
||||
globalPerHour: config.emailCodeGuard.globalPerHour,
|
||||
cooldownLadderMs: config.emailCodeGuard.cooldownLadderMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function mailSettingsFromConfig(config: ServerConfig): MailSettings {
|
||||
return {
|
||||
driver: config.mailDriver,
|
||||
apiUrl: config.mailApiUrl,
|
||||
apiKey: config.mailApiKey,
|
||||
authHeader: config.mailAuthHeader,
|
||||
from: config.mailFrom,
|
||||
fromName: config.mailFromName,
|
||||
bodyTemplate: config.mailBodyTemplate,
|
||||
timeoutMs: config.mailTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function turnstileSettingsFromConfig(config: ServerConfig): TurnstileSettings {
|
||||
return {
|
||||
siteKey: config.turnstileSiteKey,
|
||||
secret: config.turnstileSecret,
|
||||
timeoutMs: 8000,
|
||||
action: config.turnstileAction,
|
||||
hostnames: config.turnstileHostnames,
|
||||
}
|
||||
}
|
||||
|
||||
/** 人机验证是否**实际**启用(要密钥成对 + 期望主机名非空 + 策略要求)。 */
|
||||
export function captchaActive(config: ServerConfig): boolean {
|
||||
return config.registerRequireCaptcha && turnstileEnabled(turnstileSettingsFromConfig(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* 「配了一半」的检测:**密钥给了但从没给期望主机名**。
|
||||
*
|
||||
* 为什么单独判这个:`hostnames` 为空 ⇒ `turnstileEnabled=false` ⇒ 人机验证**静默不生效**
|
||||
* (注册页不渲染控件)。这是"看起来配了、实际没防住"的典型形态,必须能在日志里被看见,
|
||||
* 否则只有抓包才能发现。
|
||||
*/
|
||||
export function captchaPartiallyConfigured(config: ServerConfig): boolean {
|
||||
const keysGiven = config.turnstileSiteKey !== '' && config.turnstileSecret !== ''
|
||||
return keysGiven && config.turnstileHostnames.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱验证码是否**实际**强制。注意是"策略要求 **且** 邮件通道已配置"——
|
||||
* 只要求策略、不检查通道,会让一个配错 env 的部署**彻底注册不进来**。
|
||||
*/
|
||||
export function emailCodeActive(config: ServerConfig): boolean {
|
||||
if (!config.registerRequireEmailCode) return false
|
||||
const settings = mailSettingsFromConfig(config)
|
||||
if (settings.driver === 'log') return true
|
||||
return settings.apiKey !== '' && settings.from !== ''
|
||||
}
|
||||
|
||||
export interface RequestCodeInput {
|
||||
email: string
|
||||
username: string
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export interface RequestCodeOutcome {
|
||||
ok: boolean
|
||||
/** 直接作为 HTTP 状态码使用。 */
|
||||
status: number
|
||||
error: string | null
|
||||
retryAfterSeconds: number
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
export interface ConsumeCodeInput {
|
||||
email: string
|
||||
username: string
|
||||
code: string
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export interface ConsumeCodeOutcome {
|
||||
ok: boolean
|
||||
status: number
|
||||
error: string | null
|
||||
/** 还剩几次机会(失败时给界面用;不泄露码本身)。 */
|
||||
attemptsLeft?: number
|
||||
}
|
||||
|
||||
/** 同邮箱串行化:并发点两次"获取验证码"只会有一次真的发信。 */
|
||||
function withEmailLock<T>(email: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = inflight.get(email) ?? Promise.resolve()
|
||||
const run = previous.then(fn, fn)
|
||||
// 表里只留"不会 reject 的链尾",避免未处理的 rejection 与 Map 无界增长。
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
inflight.set(email, tail)
|
||||
return run.finally(() => {
|
||||
if (inflight.get(email) === tail) inflight.delete(email)
|
||||
})
|
||||
}
|
||||
|
||||
let lastPurgeAt = 0
|
||||
|
||||
/** 机会性清理:调用方不用管,量级极小(每邮箱每小时个位行)。 */
|
||||
async function maybePurge(db: DbAdapter, now: number): Promise<void> {
|
||||
if (now - lastPurgeAt < PURGE_INTERVAL_MS) return
|
||||
lastPurgeAt = now
|
||||
try {
|
||||
await db.purgeEmailCodes(now - PURGE_OLDER_THAN_MS)
|
||||
} catch {
|
||||
// 清理失败不影响发码;下次再试。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发码。**调用方必须先完成人机验证**(本函数不碰 Turnstile,避免两处都校一遍)。
|
||||
*/
|
||||
export async function requestRegisterCode(
|
||||
deps: { db: DbAdapter; config: ServerConfig; onWarn?: (message: string) => void },
|
||||
input: RequestCodeInput,
|
||||
): Promise<RequestCodeOutcome> {
|
||||
const { db, config } = deps
|
||||
const policy = guardPolicyFromConfig(config)
|
||||
const now = Date.now()
|
||||
const email = normalizeEmail(input.email)
|
||||
const miss = (status: number, error: string, retryAfterSeconds = 0): RequestCodeOutcome => ({
|
||||
ok: false,
|
||||
status,
|
||||
error,
|
||||
retryAfterSeconds,
|
||||
expiresInSeconds: Math.ceil(policy.codeTtlMs / 1000),
|
||||
})
|
||||
|
||||
if (!isValidEmail(email)) return miss(400, 'invalid_email')
|
||||
if (!isValidUsername(input.username)) return miss(400, 'invalid_username')
|
||||
|
||||
// 提前挡住注定失败的注册:既不浪费邮件配额,也让用户在填表阶段就得到反馈。
|
||||
if ((await db.findUserByUsername(input.username)) !== undefined) return miss(409, 'username_taken')
|
||||
if ((await db.findUserByEmail(email)) !== undefined) return miss(409, 'email_taken')
|
||||
|
||||
await maybePurge(db, now)
|
||||
|
||||
const [hour, day] = await Promise.all([
|
||||
db.emailCodeCounts(email, input.ip, now - HOUR_MS),
|
||||
db.emailCodeCounts(email, input.ip, now - DAY_MS),
|
||||
])
|
||||
const verdict = evaluateSendGuard({ hour, day }, now, policy)
|
||||
if (!verdict.allowed) {
|
||||
// ③ 被拒也落库:它是配额的一部分,也是"有人在猛撞"的证据。
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: PURPOSE_REGISTER,
|
||||
codeHash: null,
|
||||
status: 'throttled',
|
||||
ip: input.ip,
|
||||
username: input.username,
|
||||
reason: verdict.reason,
|
||||
createdAt: now,
|
||||
})
|
||||
return miss(429, verdict.reason, verdict.retryAfterSeconds)
|
||||
}
|
||||
|
||||
return withEmailLock(email, async () => {
|
||||
const code = generateCode()
|
||||
const settings = mailSettingsFromConfig(config)
|
||||
const result = await sendVerificationCodeMail(settings, {
|
||||
to: email,
|
||||
code,
|
||||
ttlMinutes: Math.round(policy.codeTtlMs / 60_000),
|
||||
brand: mailBrandFromConfig(config),
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
// ② 发信失败**也**占配额(否则上游一挂,请求会无限重试把它压得更死)。
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: PURPOSE_REGISTER,
|
||||
codeHash: null,
|
||||
status: 'failed',
|
||||
ip: input.ip,
|
||||
username: input.username,
|
||||
reason: result.error,
|
||||
createdAt: now,
|
||||
})
|
||||
deps.onWarn?.(`[register-code] 发送失败 to=${maskEmail(email)} driver=${settings.driver} err=${result.error ?? ''}`)
|
||||
return miss(502, 'mail_send_failed')
|
||||
}
|
||||
|
||||
// ③ 只有真发出去才记 `sent`(它才是可校验的那一行)。
|
||||
if (settings.driver === 'log') {
|
||||
// 显式选择了 `log` 驱动才把验证码写进服务日志(开发/断网排障用)。
|
||||
deps.onWarn?.(`[register-code][log-driver] ${maskEmail(email)} 验证码=${code}(${Math.round(policy.codeTtlMs / 60_000)} 分钟内有效)`)
|
||||
}
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: PURPOSE_REGISTER,
|
||||
codeHash: hashCode(email, PURPOSE_REGISTER, code, config.encryptionSecret),
|
||||
status: 'sent',
|
||||
ip: input.ip,
|
||||
username: input.username,
|
||||
reason: null,
|
||||
createdAt: now,
|
||||
expiresAt: now + policy.codeTtlMs,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
retryAfterSeconds: Math.ceil(verdict.cooldownMs / 1000),
|
||||
expiresInSeconds: Math.ceil(policy.codeTtlMs / 1000),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并**消费**验证码。返回 `ok:true` 时该码已被标记用掉(单次使用),
|
||||
* 调用方可以放心建账号 —— 重复提交同一码会在第二次拿到 `code_used`。
|
||||
*/
|
||||
export async function consumeRegisterCode(
|
||||
deps: { db: DbAdapter; config: ServerConfig },
|
||||
input: ConsumeCodeInput,
|
||||
): Promise<ConsumeCodeOutcome> {
|
||||
const { db, config } = deps
|
||||
const policy = guardPolicyFromConfig(config)
|
||||
const now = Date.now()
|
||||
const email = normalizeEmail(input.email)
|
||||
const fail = (status: number, error: string, attemptsLeft?: number): ConsumeCodeOutcome => ({
|
||||
ok: false,
|
||||
status,
|
||||
error,
|
||||
attemptsLeft,
|
||||
})
|
||||
|
||||
if (!isValidEmail(email)) return fail(400, 'invalid_email')
|
||||
if (!/^\d{4,8}$/.test(input.code)) return fail(400, 'code_invalid')
|
||||
|
||||
const row = await db.latestSentEmailCode(email, PURPOSE_REGISTER)
|
||||
const pre = evaluateVerifyGuard(row, now, policy)
|
||||
if (pre === 'missing') return fail(400, 'code_missing')
|
||||
if (pre === 'used') return fail(400, 'code_used')
|
||||
if (pre === 'too_many_attempts') return fail(400, 'code_attempts_exceeded')
|
||||
if (pre === 'expired') return fail(400, 'code_expired')
|
||||
if (row === undefined) return fail(400, 'code_missing')
|
||||
|
||||
// 验证码与"申请时填的用户名"绑定:换用户名重放同一封邮件里拿到的码不作数。
|
||||
if (row.username !== null && row.username.toLowerCase() !== input.username.toLowerCase()) {
|
||||
return fail(400, 'code_username_mismatch')
|
||||
}
|
||||
|
||||
const expected = hashCode(email, PURPOSE_REGISTER, input.code, config.encryptionSecret)
|
||||
if (!codeMatches(row.code_hash ?? '', expected)) {
|
||||
const nextAttempts = row.attempts + 1
|
||||
const consume = nextAttempts >= policy.maxAttemptsPerCode
|
||||
await db.bumpEmailCodeAttempts(row.id, consume, now)
|
||||
return consume
|
||||
? fail(400, 'code_attempts_exceeded', 0)
|
||||
: fail(400, 'code_invalid', Math.max(0, policy.maxAttemptsPerCode - nextAttempts))
|
||||
}
|
||||
|
||||
// 单次使用:CAS 失败 = 已被并发请求用掉。
|
||||
if (!(await db.consumeEmailCode(row.id, now))) return fail(409, 'code_used')
|
||||
return { ok: true, status: 200, error: null }
|
||||
}
|
||||
|
||||
/** 日志里的邮箱脱敏(只留域名,够定位是哪家邮箱出问题)。 */
|
||||
export function maskEmail(email: string): string {
|
||||
const at = email.indexOf('@')
|
||||
return at <= 0 ? '***' : `***${email.slice(at)}`
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 注册验证码的外发邮件层。
|
||||
*
|
||||
* 为什么做成"驱动 + 通用 HTTP 兜底"而不是直接写死某家 SDK:
|
||||
* ① 平台目前**没有**任何邮件基础设施(全库 grep `smtp|nodemailer|mail` = 0 命中),
|
||||
* 而注册是**唯一**必须先发信才能完成的功能 ⇒ 它是外部依赖最重的一环,必须能换;
|
||||
* ② 换供应商时**只改 env、不动代码**(`http` 驱动连 body 结构都能由配置给出),
|
||||
* 这对"以后可能要接别的邮件服务"是硬需求 —— 不必为了换家再走一次发布;
|
||||
* ③ 不引第三方依赖:`fetch` + JSON 就够,少一个供应链面。
|
||||
*
|
||||
* 驱动:
|
||||
* · `brevo` —— Brevo(原 Sendinblue)事务邮件 API:`POST /v3/smtp/email`,头 `api-key`。
|
||||
* 本机已有可用凭据(见档案),因此作为默认驱动。
|
||||
* · `http` —— **任意** JSON HTTP 接口:URL / 鉴权头 / body 模板全部由 env 给,
|
||||
* body 里可用 `{{to}} {{code}} {{subject}} {{text}} {{from}} {{fromName}}` 占位。
|
||||
* · `log` —— 不真发信,只把验证码交给调用方(由路由写 journald)。**仅供开发/断网排障**,
|
||||
* 显式选它才会生效(不选=不发日志,避免验证码进日志)。
|
||||
*
|
||||
* 纪律:**失败即失败**(返回 `{ok:false}`),不重试 —— 重试会造成"用户点一次收两封",
|
||||
* 且与 `email_codes` 的事件计数(= 防爆破的判据)对不上。
|
||||
* @module dshs/web/mail
|
||||
*/
|
||||
|
||||
/** 邮件通道的运行时配置(由 `config.ts` 从 env 组装后传入,本模块不读 env)。 */
|
||||
export interface MailSettings {
|
||||
driver: MailDriver
|
||||
/** 服务端点(`brevo` 驱动留空则用官方默认)。 */
|
||||
apiUrl: string
|
||||
apiKey: string
|
||||
/** `http` 驱动的鉴权头名(留空 + 有 apiKey ⇒ 用 `Authorization: Bearer`)。 */
|
||||
authHeader: string
|
||||
/** 发件地址(**必须**是该服务里已验证过的发件人,否则上游直接拒收)。 */
|
||||
from: string
|
||||
fromName: string
|
||||
/** `http` 驱动的 JSON body 模板(支持占位符)。 */
|
||||
bodyTemplate: string
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
export type MailDriver = 'brevo' | 'http' | 'log'
|
||||
|
||||
export interface VerificationMail {
|
||||
to: string
|
||||
code: string
|
||||
ttlMinutes: number
|
||||
/** 展示给收件人的站点名(如 `AI1NET`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
|
||||
brand?: string
|
||||
}
|
||||
|
||||
export interface MailResult {
|
||||
ok: boolean
|
||||
/** 失败原因(**不含**验证码本身),供审计与界面提示。 */
|
||||
error: string | null
|
||||
/** 上游返回的状态码(有则记),便于区分"配错了"与"上游抽风"。 */
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** 该驱动是否具备发信条件(缺关键项 ⇒ 视为未配置,路由据此回退/报错)。 */
|
||||
export function mailConfigured(settings: MailSettings): boolean {
|
||||
if (settings.driver === 'log') return true
|
||||
if (settings.from === '') return false
|
||||
if (settings.driver === 'brevo') return settings.apiKey !== '' || settings.apiUrl !== ''
|
||||
return settings.apiUrl !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染主题与正文(中英双语:平台默认语言是英语,运营方是中文,两者都照顾到)。
|
||||
* ⚠️ `brand` 为空时**不能**回落到任何内部名(邮件是给终端用户的,平台内部名不该出现在里面)——
|
||||
* 此时整句退化为"你的验证码是"。
|
||||
*/
|
||||
export function renderVerificationMail(mail: VerificationMail): { subject: string; text: string } {
|
||||
const brand = (mail.brand ?? '').trim()
|
||||
const name = brand === '' ? '' : ` ${brand}`
|
||||
const subject = brand === '' ? `${mail.code} is your verification code` : `${mail.code} is your ${brand} verification code`
|
||||
const text = [
|
||||
`Your${name} verification code is: ${mail.code}`,
|
||||
`It expires in ${mail.ttlMinutes} minutes. If you did not request this, just ignore this e-mail.`,
|
||||
'',
|
||||
`你的${name}验证码是:${mail.code}`,
|
||||
`有效期 ${mail.ttlMinutes} 分钟。若非本人操作,请忽略本邮件。`,
|
||||
...(brand === '' ? [] : ['', `— ${brand}`]),
|
||||
].join('\n')
|
||||
return { subject, text }
|
||||
}
|
||||
|
||||
/** 占位符替换:值按 JSON 字符串转义后**只保留内容**,以便安全地嵌进 body 模板的引号内。 */
|
||||
function applyTemplate(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (match, key: string) => {
|
||||
const value = values[key]
|
||||
if (value === undefined) return match
|
||||
// slice(1,-1) 去掉 JSON.stringify 加的两端引号:调用方的模板里自己带引号。
|
||||
return JSON.stringify(value).slice(1, -1)
|
||||
})
|
||||
}
|
||||
|
||||
/** 发一封验证码邮件。永不抛异常 —— 失败以 `{ok:false}` 返回,由调用方决定如何记事件。 */
|
||||
export async function sendVerificationCodeMail(
|
||||
settings: MailSettings,
|
||||
mail: VerificationMail,
|
||||
): Promise<MailResult> {
|
||||
const { subject, text } = renderVerificationMail(mail)
|
||||
|
||||
if (settings.driver === 'log') return { ok: true, error: null }
|
||||
|
||||
if (!mailConfigured(settings)) {
|
||||
return { ok: false, error: 'mail_not_configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (settings.driver === 'brevo') {
|
||||
return await sendViaBrevo(settings, { ...mail, subject, text })
|
||||
}
|
||||
return await sendViaHttp(settings, { ...mail, subject, text })
|
||||
} catch (e) {
|
||||
// AbortSignal.timeout 抛的是 TimeoutError;其它是网络/解析错误。
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: message.slice(0, 200) }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaBrevo(
|
||||
settings: MailSettings,
|
||||
mail: VerificationMail & { subject: string; text: string },
|
||||
): Promise<MailResult> {
|
||||
const url = settings.apiUrl === '' ? 'https://api.brevo.com/v3/smtp/email' : settings.apiUrl
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json' }
|
||||
if (settings.apiKey !== '') headers['api-key'] = settings.apiKey
|
||||
const body = {
|
||||
sender: { email: settings.from, name: settings.fromName === '' ? undefined : settings.fromName },
|
||||
to: [{ email: mail.to }],
|
||||
subject: mail.subject,
|
||||
textContent: mail.text,
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(settings.timeoutMs),
|
||||
})
|
||||
if (!res.ok) {
|
||||
// 上游会回一段 JSON({code,message})—— 它不含验证码,可以安全地截断留证。
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 200)
|
||||
return { ok: false, error: `brevo_http_${res.status}${detail === '' ? '' : `: ${detail}`}`, status: res.status }
|
||||
}
|
||||
return { ok: true, error: null, status: res.status }
|
||||
}
|
||||
|
||||
async function sendViaHttp(
|
||||
settings: MailSettings,
|
||||
mail: VerificationMail & { subject: string; text: string },
|
||||
): Promise<MailResult> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
if (settings.apiKey !== '') {
|
||||
if (settings.authHeader !== '') headers[settings.authHeader] = settings.apiKey
|
||||
else headers.authorization = `Bearer ${settings.apiKey}`
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
to: mail.to,
|
||||
code: mail.code,
|
||||
subject: mail.subject,
|
||||
text: mail.text,
|
||||
from: settings.from,
|
||||
fromName: settings.fromName,
|
||||
}
|
||||
const body =
|
||||
settings.bodyTemplate === ''
|
||||
? JSON.stringify({ from: settings.from, to: mail.to, subject: mail.subject, text: mail.text })
|
||||
: applyTemplate(settings.bodyTemplate, values)
|
||||
|
||||
const res = await fetch(settings.apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(settings.timeoutMs),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 200)
|
||||
return { ok: false, error: `mail_http_${res.status}${detail === '' ? '' : `: ${detail}`}`, status: res.status }
|
||||
}
|
||||
return { ok: true, error: null, status: res.status }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 注册验证码的**防爆破策略**(纯函数 + 纯数据,便于单测与复用)。
|
||||
*
|
||||
* 为什么单独成模块:限流一旦和路由耦合,就只能靠"真打一遍接口"验证 —— 而限流恰恰是
|
||||
* **最需要精确边界**的东西(差 1 秒就漏一次发送)。这里所有判定都不碰 IO:
|
||||
* 输入 = 计数快照 + 当前时间,输出 = 允许/拒绝 + 还需等多久,因此可以逐边界断言。
|
||||
*
|
||||
* 设计口径(用户 2026-09-19 需求:「增加重复获取验证码的爆破设计」):
|
||||
* ① **发送侧**是花钱 + 打信誉的动作 ⇒ 收紧(阶梯冷却 + 每小时/每天配额 + IP 维度 + 全局维度);
|
||||
* ② **校验侧**是猜码 ⇒ 每个码最多试 N 次、超过即作废、成功即作废(单次使用);
|
||||
* ③ 被拒绝的请求**也要落库计入配额** —— 否则"被拒 → 立刻重试"就成了无限循环,
|
||||
* 而且拿不到"有人在猛撞"的证据;
|
||||
* ④ 冷却时间随次数**递增**(阶梯),让脚本化重试的收益递减,而真人重发一次仍只等 60 秒。
|
||||
*/
|
||||
|
||||
import type { EmailCodeCounts, EmailCodeRow } from '../db/types.js'
|
||||
|
||||
/** 生效中的策略值(可由配置覆盖;默认值即生产口径)。 */
|
||||
export interface GuardPolicy {
|
||||
/** 验证码有效期。 */
|
||||
codeTtlMs: number
|
||||
/** 同一个码最多允许试错几次,达上限**立即作废**(必须重新获取)。 */
|
||||
maxAttemptsPerCode: number
|
||||
/** 同一邮箱每小时最多**发起**几次(含被拒的)。 */
|
||||
emailPerHour: number
|
||||
/** 同一邮箱每 24 小时最多**真正发出**几封。 */
|
||||
emailSentPerDay: number
|
||||
/** 同一 IP 每小时最多发起几次(IP 可能 NAT,给得比邮箱宽)。 */
|
||||
ipPerHour: number
|
||||
/** 保护邮件服务商日配额的全局闸门(每小时)。 */
|
||||
globalPerHour: number
|
||||
/**
|
||||
* 冷却阶梯:索引 = 该邮箱**最近一小时内已发起的次数**(超出则用最后一项)。
|
||||
* 于是"第 1、2 次等 60 秒;第 3 次 3 分钟;第 4 次 5 分钟;第 5 次起 15~30 分钟"。
|
||||
*/
|
||||
cooldownLadderMs: readonly number[]
|
||||
}
|
||||
|
||||
export const DEFAULT_GUARD_POLICY: GuardPolicy = {
|
||||
codeTtlMs: 10 * 60 * 1000,
|
||||
maxAttemptsPerCode: 5,
|
||||
/**
|
||||
* 6 = **刚好让冷却阶梯的每一级都可达**(阶梯索引 = 一小时内已发起次数,0…5)。
|
||||
* 若设成 5,最后一级(30 分钟)永远走不到 —— 那就等于白写一级。
|
||||
* 真正拦人的是阶梯(累计 60+60+180+300+900+1800 ≈ 55 分钟),小时配额只是兜底。
|
||||
*/
|
||||
emailPerHour: 6,
|
||||
emailSentPerDay: 8,
|
||||
ipPerHour: 20,
|
||||
globalPerHour: 200,
|
||||
cooldownLadderMs: [60_000, 60_000, 180_000, 300_000, 900_000, 1_800_000],
|
||||
}
|
||||
|
||||
export const HOUR_MS = 60 * 60 * 1000
|
||||
export const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
/** 计数快照:两个窗口各查一次(`hour` 供冷却与小时配额,`day` 供日配额)。 */
|
||||
export interface GuardCounts {
|
||||
hour: EmailCodeCounts
|
||||
day: EmailCodeCounts
|
||||
}
|
||||
|
||||
export type SendVerdict =
|
||||
| { allowed: true; cooldownMs: number }
|
||||
| { allowed: false; reason: string; retryAfterSeconds: number }
|
||||
|
||||
/**
|
||||
* 发送前判定。**顺序有意义**:先判配额(终局性拒绝,无"等一会就好"的错觉),
|
||||
* 再判冷却(可以等到具体时刻)。
|
||||
*/
|
||||
export function evaluateSendGuard(counts: GuardCounts, now: number, policy: GuardPolicy = DEFAULT_GUARD_POLICY): SendVerdict {
|
||||
const { hour, day } = counts
|
||||
|
||||
if (day.emailSent >= policy.emailSentPerDay) {
|
||||
// 按"本邮箱当天第一封的时间 + 24h"给出最早可再试的时刻,避免用户面对一个空泛的拒绝。
|
||||
return { allowed: false, reason: 'email_daily_quota', retryAfterSeconds: 3600 }
|
||||
}
|
||||
if (hour.emailTotal >= policy.emailPerHour) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'email_hourly_quota',
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((HOUR_MS - (now - hour.emailLastAt)) / 1000)),
|
||||
}
|
||||
}
|
||||
if (counts.hour.ipTotal >= policy.ipPerHour) {
|
||||
return { allowed: false, reason: 'ip_hourly_quota', retryAfterSeconds: 600 }
|
||||
}
|
||||
if (hour.globalTotal >= policy.globalPerHour) {
|
||||
return { allowed: false, reason: 'global_hourly_quota', retryAfterSeconds: 300 }
|
||||
}
|
||||
|
||||
const ladder = policy.cooldownLadderMs
|
||||
const cooldown = ladder[Math.min(hour.emailTotal, ladder.length - 1)] ?? 60_000
|
||||
const last = hour.emailLastAt
|
||||
if (last > 0 && now - last < cooldown) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'email_cooldown',
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((cooldown - (now - last)) / 1000)),
|
||||
}
|
||||
}
|
||||
return { allowed: true, cooldownMs: cooldown }
|
||||
}
|
||||
|
||||
/** 校验前的判定(尚未比较哈希):码存在性 / 有效期 / 试错余量。 */
|
||||
export type VerifyPreVerdict = 'ok' | 'missing' | 'expired' | 'too_many_attempts' | 'used'
|
||||
|
||||
export function evaluateVerifyGuard(
|
||||
row: EmailCodeRow | undefined,
|
||||
now: number,
|
||||
policy: GuardPolicy = DEFAULT_GUARD_POLICY,
|
||||
): VerifyPreVerdict {
|
||||
if (row === undefined || row.code_hash === null) return 'missing'
|
||||
// `consumed_at` 非空 = 已用掉或被判作废(试错超限)。
|
||||
if (row.consumed_at !== null) return row.attempts >= policy.maxAttemptsPerCode ? 'too_many_attempts' : 'used'
|
||||
if (row.expires_at !== null && now > row.expires_at) return 'expired'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** 把剩余等待秒数切成给用户看的粒度(前端只用它做倒计时)。 */
|
||||
export function formatRetryAfter(seconds: number): string {
|
||||
if (seconds <= 90) return `${seconds} 秒`
|
||||
const minutes = Math.ceil(seconds / 60)
|
||||
return minutes < 60 ? `${minutes} 分钟` : `${Math.ceil(minutes / 60)} 小时`
|
||||
}
|
||||
+196
-3
@@ -14,6 +14,18 @@ import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProvide
|
||||
import { PROTOCOLS } from '../model-landing.js'
|
||||
import { readTextOrEmpty, writeHomeFile } from '../home-files.js'
|
||||
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
|
||||
import {
|
||||
captchaActive,
|
||||
captchaPartiallyConfigured,
|
||||
consumeRegisterCode,
|
||||
emailCodeActive,
|
||||
guardPolicyFromConfig,
|
||||
isValidEmail,
|
||||
normalizeEmail,
|
||||
requestRegisterCode,
|
||||
turnstileSettingsFromConfig,
|
||||
} from '../email-code.js'
|
||||
import { verifyTurnstile } from '../turnstile.js'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
clearSessionCookie,
|
||||
@@ -42,10 +54,87 @@ const registerSchema = {
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
|
||||
password: { type: 'string', minLength: 8, maxLength: 128 },
|
||||
// 档案 134:邮箱 + 验证码 + 人机验证 token 一律**可选**声明 ——
|
||||
// 「必填」由配置决定(`emailCodeActive` / `captchaActive`),schema 只保证形状与长度。
|
||||
// 这样同一份代码在"没配邮件的环境"里仍是老行为,不用改 schema 再发一次版。
|
||||
email: { type: 'string', maxLength: 254 },
|
||||
code: { type: 'string', maxLength: 16 },
|
||||
captchaToken: { type: 'string', maxLength: 4096 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const emailCodeSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['username', 'email'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
|
||||
email: { type: 'string', minLength: 6, maxLength: 254 },
|
||||
captchaToken: { type: 'string', maxLength: 4096 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
interface RegisterBody {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
code?: string
|
||||
captchaToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实客户端 IP(防爆破的**次要**维度)。
|
||||
*
|
||||
* 站点经 Cloudflare → 宝塔 nginx → `127.0.0.1:3080`,`trustProxy` 已开 ⇒ `request.ip` 取 XFF 首项
|
||||
* (由 CF 覆写,不可由访客伪造)。这里优先用 `CF-Connecting-IP`(CF 的权威口径,且不受链路上
|
||||
* 其它代理拼接 XFF 的影响)。
|
||||
* ⚠️ **它是次要维度**:源站若可被直连,头仍可伪造 —— 因此真正的承重维度是
|
||||
* **邮箱维度**(攻击者无法伪造目标地址)与**全局闸门**(保护邮件服务商配额)。IP 维度只用来
|
||||
* 让"同一台机器猛撞"更快被拦住。
|
||||
*/
|
||||
function clientIp(request: { headers: Record<string, unknown>; ip: string }): string | null {
|
||||
const header = request.headers['cf-connecting-ip']
|
||||
const value = Array.isArray(header) ? header[0] : header
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
||||
return request.ip === '' ? null : request.ip
|
||||
}
|
||||
|
||||
/** 人机验证:配置齐了才校;未配置时**放行**(老行为),并由调用方必要时告警。 */
|
||||
async function checkCaptcha(
|
||||
app: { config: unknown; log: { warn: (message: string) => void } },
|
||||
token: string | undefined,
|
||||
ip: string | null,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const config = app.config as Parameters<typeof captchaActive>[0]
|
||||
if (!captchaActive(config)) {
|
||||
// 「密钥给了但没给期望主机名」⇒ 人机验证**静默不生效**。这是最危险的形态
|
||||
// (看起来配了、实际没防住),所以每次走到这里都留一条告警,让它在日志里可见。
|
||||
// 去重只做在同一进程内,不影响可观测性(重启后第一条仍会打出来)。
|
||||
if (captchaPartiallyConfigured(config)) captchaWarnOnce(app)
|
||||
return { ok: true }
|
||||
}
|
||||
const verdict = await verifyTurnstile(turnstileSettingsFromConfig(config), token ?? '', ip ?? undefined)
|
||||
if (verdict.ok) return { ok: true }
|
||||
// 上游故障与"token 不对"必须可分:前者是运维问题,后者是访客问题。
|
||||
const upstream = verdict.error !== null && verdict.error.startsWith('network_error')
|
||||
app.log.warn(`[register] Turnstile 校验失败 ip=${ip ?? '-'} err=${verdict.error ?? ''}`)
|
||||
return { ok: false, error: upstream ? 'captcha_unavailable' : 'captcha_failed' }
|
||||
}
|
||||
|
||||
let captchaPartialWarned = false
|
||||
/** 只在进程内第一条上告警,避免被机器人刷日志。 */
|
||||
function captchaWarnOnce(app: { log: { warn: (message: string) => void } }): void {
|
||||
if (captchaPartialWarned) return
|
||||
captchaPartialWarned = true
|
||||
app.log.warn(
|
||||
'[register] Turnstile 已配 siteKey/secret,但期望主机名列表为空 ⇒ 人机验证**未启用**'
|
||||
+ '(请设 DSHS_TURNSTILE_HOSTNAMES=<域名[,www.域名]>)',
|
||||
)
|
||||
}
|
||||
|
||||
const loginSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
@@ -64,23 +153,127 @@ interface Credentials {
|
||||
}
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
/**
|
||||
* 注册页需要知道的**公开**配置(档案 134)。
|
||||
*
|
||||
* 为什么要一个端点而不是把 sitekey 写进 HTML:
|
||||
* · sitekey 随环境变(本地/测试/线上各一个 Turnstile widget),写死会让"换环境忘记换 key"
|
||||
* 变成一次静默失效 —— 人机验证看起来在,其实一直在报 `invalid-input-secret`;
|
||||
* · 前端据此**决定要不要渲染**控件,而不是渲染一个永远失败的控件。
|
||||
* ⚠️ 只回**公钥**。服务端密钥永不出现在任何响应里。
|
||||
*/
|
||||
app.get('/api/auth/register/config', async (_request, reply) => {
|
||||
reply.header('cache-control', 'no-store')
|
||||
const captchaOn = captchaActive(app.config)
|
||||
return {
|
||||
captcha: {
|
||||
enabled: captchaOn,
|
||||
// 未启用时给空串 ⇒ 前端不会去加载 CF 脚本(少一个第三方请求)。
|
||||
siteKey: captchaOn ? app.config.turnstileSiteKey : '',
|
||||
// ⚠️ `action` 必须由**服务端**下发且与 siteverify 校验的一致:两边各写一份常量,
|
||||
// 一旦漂掉就是"过不了验证且看不出原因"(CF 只回 action 不匹配,不说是谁配错)。
|
||||
action: app.config.turnstileAction,
|
||||
},
|
||||
emailCode: {
|
||||
enabled: emailCodeActive(app.config),
|
||||
ttlSeconds: Math.round(app.config.emailCodeTtlMs / 1000),
|
||||
maxAttempts: app.config.emailCodeMaxAttempts,
|
||||
},
|
||||
// 前端做即时校验用(避免"提交了才说格式错"),与后端同一份口径。
|
||||
usernamePattern: '^[a-zA-Z0-9_-]{3,32}$',
|
||||
minPasswordLength: 8,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 请求邮箱验证码(档案 134)。
|
||||
*
|
||||
* 顺序不可交换:**人机验证 → 配额/冷却 → 发信 → 落事件**。
|
||||
* 反过来的话,机器人只需猛点就能把真用户的配额吃光(用自己的拒绝服务挡住别人)。
|
||||
*/
|
||||
app.post(
|
||||
'/api/auth/register/email-code',
|
||||
{ schema: emailCodeSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const body = request.body as { username: string; email: string; captchaToken?: string }
|
||||
const ip = clientIp(request as unknown as { headers: Record<string, unknown>; ip: string })
|
||||
|
||||
if (!emailCodeActive(app.config)) return reply.code(503).send({ error: 'email_code_disabled' })
|
||||
|
||||
const captcha = await checkCaptcha(app, body.captchaToken, ip)
|
||||
if (!captcha.ok) return reply.code(403).send({ error: captcha.error })
|
||||
|
||||
const outcome = await requestRegisterCode(
|
||||
{ db: app.db, config: app.config, onWarn: (message) => app.log.warn(message) },
|
||||
{ email: body.email, username: body.username, ip },
|
||||
)
|
||||
if (!outcome.ok) {
|
||||
await app.db.audit(null, 'register_code_rejected', JSON.stringify({ reason: outcome.error, ip }))
|
||||
if (outcome.retryAfterSeconds > 0) reply.header('retry-after', String(outcome.retryAfterSeconds))
|
||||
return reply.code(outcome.status).send({
|
||||
error: outcome.error,
|
||||
retryAfterSeconds: outcome.retryAfterSeconds,
|
||||
})
|
||||
}
|
||||
await app.db.audit(null, 'register_code_sent', JSON.stringify({ ip }))
|
||||
return reply.header('retry-after', String(outcome.retryAfterSeconds)).send({
|
||||
ok: true,
|
||||
retryAfterSeconds: outcome.retryAfterSeconds,
|
||||
expiresInSeconds: outcome.expiresInSeconds,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/auth/register',
|
||||
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const { username, password } = request.body as Credentials
|
||||
const { username, password } = request.body as RegisterBody
|
||||
const body = request.body as RegisterBody
|
||||
const ip = clientIp(request as unknown as { headers: Record<string, unknown>; ip: string })
|
||||
const email = normalizeEmail(body.email ?? '')
|
||||
|
||||
// ① 先做**无副作用**的占用检查:命中就不必浪费一次人机验证与一个验证码。
|
||||
if ((await app.db.findUserByUsername(username)) !== undefined) {
|
||||
return reply.code(409).send({ error: 'username_taken' })
|
||||
}
|
||||
if (email !== '' && (await app.db.findUserByEmail(email)) !== undefined) {
|
||||
return reply.code(409).send({ error: 'email_taken' })
|
||||
}
|
||||
|
||||
// ② 人机验证(配置齐了才校)。
|
||||
const captcha = await checkCaptcha(app, body.captchaToken, ip)
|
||||
if (!captcha.ok) return reply.code(403).send({ error: captcha.error })
|
||||
|
||||
// ③ 邮箱验证码(配置齐了才强制)。**校验通过即消费**,同一码不能建两个账号。
|
||||
if (emailCodeActive(app.config)) {
|
||||
if (email === '' || !isValidEmail(email)) return reply.code(400).send({ error: 'email_required' })
|
||||
const verdict = await consumeRegisterCode(
|
||||
{ db: app.db, config: app.config },
|
||||
{ email, username, code: (body.code ?? '').trim(), ip },
|
||||
)
|
||||
if (!verdict.ok) {
|
||||
await app.db.audit(null, 'register_code_invalid', JSON.stringify({ reason: verdict.error, ip }))
|
||||
return reply.code(verdict.status).send({ error: verdict.error, attemptsLeft: verdict.attemptsLeft })
|
||||
}
|
||||
}
|
||||
|
||||
const id = randomUUID()
|
||||
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
|
||||
const passHash = await hashPassword(password)
|
||||
// Create the user first so `initUserRoot` resolves the DB-assigned uid
|
||||
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
|
||||
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
|
||||
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
|
||||
const user = await app.db.createUser({
|
||||
id,
|
||||
username,
|
||||
passHash,
|
||||
role: 'pending',
|
||||
homeDir,
|
||||
email: email === '' ? null : email,
|
||||
})
|
||||
await app.userFs.initUserRoot(id, user.uid ?? undefined)
|
||||
await app.db.audit(id, 'register', JSON.stringify({ username }))
|
||||
await app.db.audit(id, 'register', JSON.stringify({ username, email: email === '' ? null : email }))
|
||||
return reply.code(201).send({ user: { id, username, role: 'pending' } })
|
||||
},
|
||||
)
|
||||
|
||||
+3
-3
@@ -1127,9 +1127,9 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
await registerDshProxy(app)
|
||||
|
||||
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
|
||||
// runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on
|
||||
// `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
|
||||
// Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow
|
||||
// runs in the browser on `<user>.dsh.ai1net.com` and calls portal APIs on
|
||||
// `dsh.ai1net.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
|
||||
// Domain=.dsh.ai1net.com, so credentials ride along; we only need to allow
|
||||
// the Origin. Restricted to the platform base domain and its subdomains.
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const origin = request.headers.origin
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Cloudflare Turnstile 服务端校验(注册页人机验证)。
|
||||
*
|
||||
* 为什么人机验证放在**服务端**而不是只靠前端 widget:
|
||||
* 前端 widget 只负责"取一个 token",token 本身**谁都能伪造**—— 只有拿它去 CF 的
|
||||
* `siteverify` 换回 `success:true` 才算数。因此后端必须独立再校一遍,且**发码与注册两处都校**
|
||||
* (发码是花钱动作、注册是建账号动作,任一被绕过都不算防住)。
|
||||
*
|
||||
* 两处刻意的选择:
|
||||
* ① **失败关闭(fail-closed)**:CF 不可达 / 校验不通过 ⇒ 直接拒绝注册。
|
||||
* 理由:Turnstile 是反自动化闸门,若"网络一抖就放行",攻击者只要让校验超时即可绕过。
|
||||
* 代价是 CF 侧长时间不可用时注册会受影响 —— 因此失败原因**分类返回**(见 `error`),
|
||||
* 运维能一眼分清"配错了密钥"和"上游抽风"。
|
||||
* ② token **单次有效**(CF 语义):一个 token 校验过一次后再用会拿到 `timeout-or-duplicate`。
|
||||
* 所以发码成功与注册成功之后,前端都必须 `reset()` 重新取 —— 见 `web/register.html`。
|
||||
* @module dshs/web/turnstile
|
||||
*/
|
||||
|
||||
/** Turnstile 的运行时配置(由 `config.ts` 从 env 组装)。 */
|
||||
export interface TurnstileSettings {
|
||||
/** 站点公钥 —— 会下发到注册页,**不是秘密**。 */
|
||||
siteKey: string
|
||||
/** 服务端密钥 —— 只留在服务端,绝不下发。 */
|
||||
secret: string
|
||||
timeoutMs: number
|
||||
/**
|
||||
* 期望的 `action`(渲染 widget 时声明、siteverify 时回显)。
|
||||
* 为什么必须校:`action` 是 CF 给"这一枚 token 是给哪个业务用的"打的标签。
|
||||
* 不校它 ⇒ 我们**所有**用同一 sitekey 的入口共享 token,人机验证退化成"过了任意一处即可用到处"。
|
||||
* 取值约束(CF 规定):1–32 字符,仅字母 / 数字 / `_` / `-`。
|
||||
*/
|
||||
action: string
|
||||
/**
|
||||
* 期望的**前端主机名**白名单(`result.hostname` 必须在此列)。
|
||||
*
|
||||
* 🔴 为什么这是**最关键**的一项:sitekey 是公开的(会出现在页面 HTML 里)⇒
|
||||
* 攻击者可以在**自己的站点**上嵌入我们的 sitekey、为真人访客拿到合法 token,再拿去打我们的接口
|
||||
* —— 只校 `success` 的话,这条路完全通畅。`hostname` 是**服务端**(CF)判定并回显的,
|
||||
* 访客篡改不了 ⇒ 只有校它才能真正把 token 绑到"从我们站点发出的挑战"上。
|
||||
*
|
||||
* ⚠️ 空数组 = **不安全**,`captchaActive()` 据此判"未配置完成"(宁可功能不启用,也不放开)。
|
||||
*/
|
||||
hostnames: string[]
|
||||
/**
|
||||
* siteverify 端点(可选,默认 Cloudflare 官方)。
|
||||
* 做成可覆盖的**唯一动机是可测性**:`action` / `hostname` 这两条判定是"放过还是拦下"的
|
||||
* 全部依据,必须能逐组合断言 —— 而硬编码 URL 就只能靠"真拿 CF token 打线上"才验得到。
|
||||
*/
|
||||
verifyUrl?: string
|
||||
}
|
||||
|
||||
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
|
||||
export interface TurnstileResult {
|
||||
ok: boolean
|
||||
/** CF 的错误码或本地判定的拒绝原因;网络失败时为 `network_error`。 */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** 是否启用:**公钥、私钥、期望主机名三者齐了才算配置完成**(缺一视为未启用并告警)。 */
|
||||
export function turnstileEnabled(settings: TurnstileSettings): boolean {
|
||||
return settings.siteKey !== '' && settings.secret !== '' && settings.hostnames.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端校验一个 Turnstile token。永不抛异常。
|
||||
* 判定**三项齐备**才放行(与 Cloudflare 官方 canonical siteverify 同口径):
|
||||
* `success === true` ∧ `action` 匹配 ∧ `hostname` 在白名单内。
|
||||
*/
|
||||
export async function verifyTurnstile(
|
||||
settings: TurnstileSettings,
|
||||
token: string,
|
||||
remoteIp?: string,
|
||||
): Promise<TurnstileResult> {
|
||||
if (!turnstileEnabled(settings)) return { ok: false, error: 'not_configured' }
|
||||
if (token === '') return { ok: false, error: 'missing-input-response' }
|
||||
// 官方上限 2048:超长一律视为伪造(不必浪费一次上游往返)。
|
||||
if (token.length > 2048) return { ok: false, error: 'invalid-input-response' }
|
||||
|
||||
const form = new URLSearchParams()
|
||||
form.set('secret', settings.secret)
|
||||
form.set('response', token)
|
||||
if (remoteIp !== undefined && remoteIp !== '') form.set('remoteip', remoteIp)
|
||||
|
||||
try {
|
||||
const res = await fetch(settings.verifyUrl ?? SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
signal: AbortSignal.timeout(settings.timeoutMs),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `siteverify_http_${res.status}` }
|
||||
const body = (await res.json()) as {
|
||||
success?: boolean
|
||||
action?: string
|
||||
hostname?: string
|
||||
'error-codes'?: string[]
|
||||
}
|
||||
if (body.success !== true) {
|
||||
const codes = body['error-codes'] ?? []
|
||||
return { ok: false, error: codes.length === 0 ? 'verification_failed' : codes.join(',') }
|
||||
}
|
||||
// ⚠️ `success:true` 之后**仍要**校这两项 —— 它们才是"这枚 token 是为我们站点、我们这个动作签发的"证据。
|
||||
if (settings.action !== '' && body.action !== settings.action) {
|
||||
return { ok: false, error: `action_mismatch: ${body.action ?? '(none)'}` }
|
||||
}
|
||||
if (body.hostname === undefined || !settings.hostnames.includes(body.hostname)) {
|
||||
return { ok: false, error: `hostname_mismatch: ${body.hostname ?? '(none)'}` }
|
||||
}
|
||||
return { ok: true, error: null }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: `network_error: ${message.slice(0, 120)}` }
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/s
|
||||
import type { WorkerTunnel } from './tunnel.js'
|
||||
|
||||
export interface RelayTunnelOptions {
|
||||
/** relay 的 WebSocket 地址:`wss://alotbuy.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
|
||||
/** relay 的 WebSocket 地址:`wss://ai1net.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
|
||||
url: string
|
||||
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
|
||||
hostId: string
|
||||
@@ -93,7 +93,7 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea
|
||||
* - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`);
|
||||
* - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**:
|
||||
* 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选
|
||||
* `wss://alotbuy.com/dshs-relay` 与 `wss://relay-direct.alotbuy.com/dshs-relay` **摘名不同、
|
||||
* `wss://ai1net.com/dshs-relay` 与 `wss://relay-direct.ai1net.com/dshs-relay` **摘名不同、
|
||||
* 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
|
||||
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
|
||||
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
|
||||
@@ -119,7 +119,7 @@ export function candidateObsMs(env: Record<string, string | undefined> = process
|
||||
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
|
||||
*
|
||||
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
|
||||
* 本数**不是独立物理路径数**(真机实证:`alotbuy.com` 与 `relay-direct.alotbuy.com` 都在 47,
|
||||
* 本数**不是独立物理路径数**(真机实证:`ai1net.com` 与 `relay-direct.ai1net.com` 都在 47,
|
||||
* 但 `count=3` 时 `hosts` 也报 3)。
|
||||
*/
|
||||
function candHostsOf(urls: readonly string[]): number {
|
||||
|
||||
Reference in new issue
Block a user