From 4f78995f1c45d9c39dbed9885976e45ae19c07a3 Mon Sep 17 00:00:00 2001 From: qubiaobiao <3294694717@qq.com> Date: Wed, 14 Oct 2026 11:06:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=E9=BB=98=E8=AE=A4=E5=8F=A3?= =?UTF-8?q?=E4=BB=A4=E5=AE=88=E5=8D=AB=E6=94=B9=E4=B8=BA=E5=93=88=E5=B8=8C?= =?UTF-8?q?=E5=80=BC=E5=88=A4=E6=8D=AE=EF=BC=88=E5=8F=8C=E9=80=9A=E9=81=93?= =?UTF-8?q?=20+=20=E4=B8=8B=E6=B2=89=E5=8D=95=E6=BA=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R112-㉒:原守卫是「口令字面」判据(DEFAULT_ADMIN_PASSWORDS.includes),任何未枚举的默认口令均绕过。改为双通道并下沉单源:① 哈希通道 isDefaultAdminHash(admin.password) —— 与输入无关,字面比对已发布默认哈希(SHA256('admin') + 固定盐 bcrypt)并以 bcrypt.compareSync 遍历候选口令,覆盖「同口令、不同盐」的重新播种;② 口令通道沿用原逻辑。清单与哈希集下沉至 lib/passwordPolicy.js 成为唯一真相源(controller 删本地声明)。bcrypt 延迟加载 + try/catch ⇒ 无 bcrypt 时降级为仅字面比对且不抛异常。 验证:node --check x2;单测 34/34;变异 3/3 红;仓外判别力自证 45/45 + 4 变异全红;verify_no_secrets HIGH 0/CAND 2(零新增);verify_tautology_guard PASS;verify_constant_shape_guard PASS;6 文件 CR=0。连带失真同步:守卫行 387->394 ⇒ schema.sql:218 + docs/modules.md:581。 --- app/controller/auth.js | 12 +++++- app/lib/passwordPolicy.js | 91 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/app/controller/auth.js b/app/controller/auth.js index 11947b4..807b6bc 100644 --- a/app/controller/auth.js +++ b/app/controller/auth.js @@ -384,8 +384,16 @@ class AuthController extends Controller { // 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。 // ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过; // 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。 - const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456']; - const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password); + // ✅ 【R112 已消除 · 候选㉒】上述「口令字面」单通道已升级为**双通道**,并把清单下沉为**单源**: + // ① 口令通道:`DEFAULT_ADMIN_PASSWORDS.includes(password)`(原逻辑原样保留); + // ② 哈希通道:`isDefaultAdminHash(admin.password)` —— **与用户输入无关**,直接判 DB 存储值 + // 是否为已发布的默认哈希(无盐 SHA256 + 固定盐 bcrypt),并对候选口令做 bcrypt 遍历, + // 以覆盖「同口令、**不同盐**」的重新播种场景(见 `002-seed-admin.js` 的 ADMIN_INIT_PASSWORD 分支)。 + // ⇒ 上面那条「未枚举即绕过」的绕过面已消除;仅剩「新增内置凭据未登记」这一**显式维护点** + // (清单已下沉至 `lib/passwordPolicy.js` 模块头 ⚠️,⛔ 勿在本控制器内再写第二份)。 + const usingDefaultPassword = + passwordPolicy.isDefaultAdminHash(admin.password) || + passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password); const isProd = this.app.config.isProduction; if (usingDefaultPassword && isProd) { ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`); diff --git a/app/lib/passwordPolicy.js b/app/lib/passwordPolicy.js index 4bced72..92896b9 100644 --- a/app/lib/passwordPolicy.js +++ b/app/lib/passwordPolicy.js @@ -24,6 +24,10 @@ * · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令) * · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令) * + * 另有一项**非强度**职责也收在本模块(保持「管理员默认凭据」口径单源,见文件末段): + * · `app/controller/auth.js` → `adminLogin()` 的「默认口令守卫」读取本模块的 + * `DEFAULT_ADMIN_PASSWORDS` / `isDefaultAdminHash()` —— R112 · 候选㉒ 下沉。 + * * 不适用范围(重要,勿误改) * ------------------------------------------------------------ * 以下链路**直写数据库、不经过本策略**,是**设计内**行为,不是绕过: @@ -220,6 +224,89 @@ function requireTestPassword(appConfig) { ); } +/** + * ── 内置默认管理员凭据(登录守卫的唯一真相源)────────────────────── + * + * 背景(BACKLOG 候选㉒ · R112) + * ------------------------------------------------------------ + * `controller/auth.js` 的「默认口令守卫」原为**口令字面**判据(用户输入的明文 + * 是否命中一张硬编码清单)。该判据有两个结构缺口: + * ① 清单**散落在 controller 内** —— 与 `database/schema.sql`、`backend/database/seeders/002-seed-admin.js` + * 各持一份口径,属「多真相源」,任一处改动都不会带动其余(R111 血证:守卫曾漏 `admin`); + * ② 只覆盖**已枚举**的默认口令 —— 任何未列出的内置默认口令(或清单被改动)即**静默绕过**。 + * + * 治法:把「默认凭据」下沉到本模块(口令策略 SoT)成为**单一真相源**,并补一条 + * **与用户输入无关**的判据 —— 直接看 DB 里存的哈希是否即已发布的默认哈希。 + * + * ⚠️ bcrypt 的盐是**随机**的:同一口令每次哈希结果都不同 ⇒ ⛔ 不能只比对哈希字符串。 + * 故本模块提供**两条互补通道**,由调用方 OR 合并(见 `controller/auth.js adminLogin`): + * · 哈希通道 `isDefaultAdminHash(dbHash)` —— 覆盖「DB 里就是内置的那串哈希」 + * (`schema.sql` 的无盐 SHA256 + seeder 002 的固定盐 bcrypt),**与输入无关**; + * · 口令通道 `DEFAULT_ADMIN_PASSWORDS.includes(明文)` —— 覆盖「同口令、不同盐」 + * 的**重新播种**场景(登录成功的明文必等于该默认口令,见 `002-seed-admin.js` 的 + * `ADMIN_INIT_PASSWORD` 分支会以随机盐重算哈希)。 + * 二者合并即覆盖「**已知**默认口令的任意存储形态」。 + * ⚠️ 未列入清单的默认口令仍是缺口 ⇒ 新增内置账号时**必须同步登记到下方清单** + * (这是**刻意的显式维护点**,避免静默逃逸)。 + * ⚠️ 已知缺口(如实登记):**目前尚无仓内机检**覆盖本清单 —— 漏登记不会自动报红 + * (R112 的判别力自证属一次性仓外驱动,未下沉为常驻门禁)⇒ 需**人工核对** + * 「本清单 ↔ `database/schema.sql` ↔ seeder 002」三处。 + */ + +/** 内置默认管理员口令(**唯一真相源**;登录守卫与文档口径均以此为准) */ +const DEFAULT_ADMIN_PASSWORDS = ['admin', 'admin123', 'admin123456']; + +/** + * 已发布默认管理员凭据的**字面哈希**(固定值,可精确比对)。 + * · `8c69…4918`:SHA256(无盐) ← `database/schema.sql` 的建库 INSERT; + * · `$2b$10$Nc6…co9.`:bcrypt(cost=10, 固定盐) ← `backend/database/seeders/002-seed-admin.js`。 + */ +const KNOWN_DEFAULT_ADMIN_HASHES = [ + '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918', + '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.', +]; + +/** 字面哈希集合(小写归一化;bcrypt 前缀 `$2B$` 亦见于部分工具) */ +const KNOWN_DEFAULT_ADMIN_HASH_SET = + new Set(KNOWN_DEFAULT_ADMIN_HASHES.map(h => h.toLowerCase())); + +/** + * 判定「DB 中存储的凭据哈希」是否为**已发布的默认值**(与用户输入无关)。 + * + * 两级判定: + * ① 字面命中 `KNOWN_DEFAULT_ADMIN_HASHES`(覆盖无盐 SHA256 与固定盐 bcrypt 两种存量形态); + * ② 逐个候选默认口令做 bcrypt 校验(覆盖「同口令、**不同盐**」的重新播种 bcrypt 值 —— + * 这正是「不能只比哈希字符串」的原因)。 + * + * ⚠️ 降级语义:`bcrypt` 采用**延迟加载**。本模块会被脚本 / 离线上下文(如变异测试写到 + * 同目录的临时副本)加载,若该上下文取不到 `bcrypt`,则退化为「仅字面比对」(② 不可用), + * 且**不抛异常**(守卫降级属可接受,⛔ 不得因此阻断进程启动)。 + * + * @param {string} dbHash - `admins.password` 的存储值 + * @returns {boolean} 命中即 true + */ +function isDefaultAdminHash(dbHash) { + if (!dbHash) return false; + const h = String(dbHash).trim(); + if (!h) return false; + if (KNOWN_DEFAULT_ADMIN_HASH_SET.has(h.toLowerCase())) return true; + + let bcrypt = null; + try { + bcrypt = require('bcrypt'); + } catch (e) { + return false; // 无 bcrypt ⇒ 退化为「仅字面比对」 + } + for (const pw of DEFAULT_ADMIN_PASSWORDS) { + try { + if (bcrypt.compareSync(pw, h)) return true; + } catch (e) { + // 非法 bcrypt 形态(如 SHA256 十六进制串)⇒ 跳过,继续下一候选 + } + } + return false; +} + module.exports = { checkPassword, requireTestPassword, @@ -228,4 +315,8 @@ module.exports = { STRENGTH_HINT, // 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword() BLACKLIST, + // 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段) + DEFAULT_ADMIN_PASSWORDS, + KNOWN_DEFAULT_ADMIN_HASHES, + isDefaultAdminHash, };