Compare commits
2 Commits
6d3fbd62b4
...
4f78995f1c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f78995f1c | |||
| ae436c01ca |
@@ -372,11 +372,28 @@ class AuthController extends Controller {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// P2-3 默认口令守卫:schema.sql 内置默认管理员(admin/admin123),
|
// P2-3 默认口令守卫:内置默认管理员(见 database/schema.sql 与 database/seeders/002-seed-admin.js),
|
||||||
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
// 生产环境禁止使用默认口令登录,防止默认账号直接上线被接管;
|
||||||
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
// 非生产环境放行但记录告警,并在响应中提示尽快改密。
|
||||||
const DEFAULT_ADMIN_PASSWORDS = ['admin123', 'admin123456'];
|
//
|
||||||
const usingDefaultPassword = DEFAULT_ADMIN_PASSWORDS.includes(password);
|
// ⚠️ 【R111 更正 · 实测】原注释写「schema.sql 内置默认管理员(admin/admin123)」,两处失实:
|
||||||
|
// ① 归属错 —— `admin123` 是 **seeder 002** 的 bcrypt 值;`schema.sql:214` 的字面哈希实测 = SHA256(`admin`)。
|
||||||
|
// ② 列表漏项 —— 原列表 `['admin123','admin123456']` **不含 `admin`**,而 `schema.sql` 建库路径的初始
|
||||||
|
// 口令恰为 `admin` ⇒ 生产若未改密,用 `admin/admin` 登录**不会**触发 4005(`verifyPassword` 兼容
|
||||||
|
// SHA256 ⇒ 可直接登录成功)⇒ 恰是本守卫要防的场景却不设防。已补入 `'admin'`。
|
||||||
|
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑱/㉑。
|
||||||
|
// ⚠️ 结构性缺口**未消除**:本守卫是「口令字面」判据 ⇒ 任何未枚举的默认口令均绕过;
|
||||||
|
// 彻底治法应改为「哈希值判据」(DB 值 ∈ 已知默认哈希集)。见候选㉑。
|
||||||
|
// ✅ 【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;
|
const isProd = this.app.config.isProduction;
|
||||||
if (usingDefaultPassword && isProd) {
|
if (usingDefaultPassword && isProd) {
|
||||||
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
|
ctx.logger.error(`[安全阻断] 管理员 ${admin.username} 在生产环境尝试使用默认密码登录,已拒绝`);
|
||||||
|
|||||||
@@ -24,6 +24,10 @@
|
|||||||
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
|
* · `app/controller/auth.js` → `activate()` (短信激活,设置初始口令)
|
||||||
* · `app/controller/auth.js` → `resetPassword()` (忘记密码,重设口令)
|
* · `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 = {
|
module.exports = {
|
||||||
checkPassword,
|
checkPassword,
|
||||||
requireTestPassword,
|
requireTestPassword,
|
||||||
@@ -228,4 +315,8 @@ module.exports = {
|
|||||||
STRENGTH_HINT,
|
STRENGTH_HINT,
|
||||||
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
|
// 仅供门禁 / 运维脚本断言使用;业务代码请走 checkPassword()
|
||||||
BLACKLIST,
|
BLACKLIST,
|
||||||
|
// 默认管理员凭据判据(登录守卫用;见上文「内置默认管理员凭据」段)
|
||||||
|
DEFAULT_ADMIN_PASSWORDS,
|
||||||
|
KNOWN_DEFAULT_ADMIN_HASHES,
|
||||||
|
isDefaultAdminHash,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,43 +4,38 @@
|
|||||||
// 口令:下方内置 bcrypt 哈希仅为**开发/演示**默认值(对应公开弱口令,仅供本机初始化)。
|
// 口令:下方内置 bcrypt 哈希仅为**开发/演示**默认值(对应公开弱口令,仅供本机初始化)。
|
||||||
// 正式初始化(生产等)时应通过环境变量 ADMIN_INIT_PASSWORD 注入真实口令 ——
|
// 正式初始化(生产等)时应通过环境变量 ADMIN_INIT_PASSWORD 注入真实口令 ——
|
||||||
// 本文件⛔不落盘任何明文口令(铁律 25)。
|
// 本文件⛔不落盘任何明文口令(铁律 25)。
|
||||||
|
//
|
||||||
|
// ⚠️ 【R111 更正 · 实测】本 seeder 原先 `bulkInsert` 到 **`accounts`** 表(row 含
|
||||||
|
// role/name_cn/salt/id_number 等列);但登录链路读的是 **`admins`** 表
|
||||||
|
// (`service/auth.js:101 findAdmin()` → `model/admin.js:49 tableName='admins'`)
|
||||||
|
// ⇒ 原写法属**死写入**(种下的那行永远不会被登录读取)。
|
||||||
|
// 已改为写入 `admins`,并按该表真实列契约(`id/username/password/created_at/updated_at`,
|
||||||
|
// 见 `database/migrations/002-create-admins.js:17-46` 与 `database/schema.sql:20-29`)重写 row。
|
||||||
|
// 详见 `.workbuddy/memory/BACKLOG.md` 候选⑳。
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async (queryInterface, Sequelize) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// `admins` 表列契约:id(自增主键,勿传)/ username / password / created_at / updated_at。
|
||||||
|
// ⛔ 该表**无** salt / role / name_cn / id_number / activated / score 等列。
|
||||||
const row = {
|
const row = {
|
||||||
role: 'admin',
|
username: 'admin',
|
||||||
name_cn: '系统管理员',
|
// bcrypt(cost=10) 哈希 = 开发默认弱口令(公开值,仅供本机初始化;生产须经 ADMIN_INIT_PASSWORD 覆盖)。
|
||||||
name_en: 'Administrator',
|
// 60 字符 < `admins.password VARCHAR(100)` ⇒ 列宽容得下。
|
||||||
gender: null,
|
|
||||||
phone: null,
|
|
||||||
email: 'admin@competition.com',
|
|
||||||
id_number: '000000000000000000',
|
|
||||||
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
password: '$2b$10$Nc6PCJItIwuOzpM22dTiAezB.jZMI8mzkCbg3oRZkAWx.QaI4co9.',
|
||||||
salt: '$2b$10$Nc6PCJItIwuOzpM22dTiAe',
|
|
||||||
position: '系统管理员',
|
|
||||||
major: null,
|
|
||||||
country: '中国',
|
|
||||||
region: null,
|
|
||||||
organization_cn: '赛事组委会',
|
|
||||||
organization_en: 'Competition Committee',
|
|
||||||
activated: 1,
|
|
||||||
score: null,
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(salt 取哈希前 29 字符的 bcrypt 盐前缀)。
|
// 若提供 ADMIN_INIT_PASSWORD,则以之为准重算 bcrypt 哈希(明文只经过程环境,⛔ 不落盘)。
|
||||||
const initPwd = process.env.ADMIN_INIT_PASSWORD;
|
const initPwd = process.env.ADMIN_INIT_PASSWORD;
|
||||||
if (initPwd) {
|
if (initPwd) {
|
||||||
const hash = require('bcrypt').hashSync(String(initPwd), 10);
|
row.password = require('bcrypt').hashSync(String(initPwd), 10);
|
||||||
row.password = hash;
|
|
||||||
row.salt = hash.slice(0, 29);
|
|
||||||
}
|
}
|
||||||
await queryInterface.bulkInsert('accounts', [row], {});
|
await queryInterface.bulkInsert('admins', [row], {});
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
down: async (queryInterface, Sequelize) => {
|
||||||
await queryInterface.bulkDelete('accounts', {
|
await queryInterface.bulkDelete('admins', {
|
||||||
id_number: '000000000000000000',
|
username: 'admin',
|
||||||
}, {});
|
}, {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user