diff --git a/README.md b/README.md index 749ff6f..03881dc 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,27 @@ ShrimpTableModule.registerTypeHandler('phone', { ### 自定义 type 的前端渲染 后端只声明 `type: 'fieldGoodsPicker'` 即可,前端在 `hand-admin/src/module/fields/` 放一个 `FieldGoodsPicker.vue`,会按命名约定自动注册(`Field*.vue` → `'field' + *`)。 +### 替换密码哈希(passwordHasher) + +默认 Password 字段写入时走 bcrypt,登录时 `UserModule.verifyPassword` 也走 bcrypt。bcrypt 是 lazy import 的——不真正调用 hash/verify 就不会 require,所以**不需要**密码功能的项目可以根本不装 bcrypt。 + +需要替换(测试用明文 / 改 argon2 / 接入老系统的 SHA256)时,在启动期改一行: + +```ts +import { Shrimp, plainHasher } from "shrimp-server" + +// 测试 / 开发 +Shrimp.passwordHasher = plainHasher + +// 或自定义 +Shrimp.passwordHasher = { + async hash(plain) { /* ... */ return hashed }, + async verify(plain, hashed) { /* ... */ return ok }, +} +``` + +`Password` 字段 reqFilter 和 `UserModule.verifyPassword` 都会走新 hasher。**plainHasher 仅用于测试/开发**,不要在生产环境用。 + --- ## 6. 用 operator 拿到当前登录用户 diff --git a/src/PasswordHasher.ts b/src/PasswordHasher.ts new file mode 100644 index 0000000..627ebba --- /dev/null +++ b/src/PasswordHasher.ts @@ -0,0 +1,51 @@ +/** + * 密码哈希抽象 — 把 bcrypt 从 Shrimp 核心彻底剥离 + * + * 默认仍用 bcrypt(行业标准,Shrimp 大多数场景都需要),但通过抽象接口 + * 让消费方可以: + * - 完全不装 bcrypt(走 plainHasher / 自己写一个 argon2 / scrypt 等) + * - 在测试里跳过哈希计算开销 + * - 接入已有的密码系统(SHA256 + salt 之类) + * + * 配置方式: + * import { Shrimp, plainHasher } from "shrimp-server" + * Shrimp.passwordHasher = plainHasher // 或自己实现的 IPasswordHasher + */ + +export interface IPasswordHasher { + /** 把明文密码加密成存储字符串 */ + hash(plain: string): Promise + /** 校验明文密码是否匹配已存储的哈希 */ + verify(plain: string, hashed: string): Promise +} + +/** + * 默认实现 — bcrypt + * + * 通过动态 import 加载,确保只有真正调用 hash/verify 时才 require('bcrypt')。 + * 这样消费方:不创建 Password 字段 + 不登录的话,可以完全不装 bcrypt。 + */ +export const bcryptHasher: IPasswordHasher = { + async hash(plain: string): Promise { + const bcrypt = await import("bcrypt") + return bcrypt.hash(plain, 10) + }, + async verify(plain: string, hashed: string): Promise { + const bcrypt = await import("bcrypt") + return bcrypt.compare(plain, hashed) + }, +} + +/** + * 明文实现 — 仅用于测试 / 开发环境 / 自己已经在上层做了加密 + * + * **生产环境严禁使用**。存储明文密码的后果你懂的。 + */ +export const plainHasher: IPasswordHasher = { + async hash(plain: string): Promise { + return plain + }, + async verify(plain: string, hashed: string): Promise { + return plain === hashed + }, +} diff --git a/src/Shrimp.ts b/src/Shrimp.ts index eaf1158..c0c6339 100644 --- a/src/Shrimp.ts +++ b/src/Shrimp.ts @@ -6,18 +6,19 @@ import { ShrimpCode, IShrimpResponse, ITableCrudProto, IModuleInfo, IUser, IRole, WithId, IRoleProto, IPageQuery, IPageResult, IFieldConfig, IFieldRule, IFieldTypeHandler, IShrimpQuery, IShrimpCondition, IShrimpPopulate, ShrimpConfig, IShrimpOperator, IMenuItem, ShrimpError, ShrimpFieldTypes, ILabel } from "./IShrimpType" import { AsyncLocalStorage } from 'async_hooks' - -// 懒加载 bcrypt: 只有走到 Password 字段 hash 或 verifyPassword 才 import, -// 让纯核心(MemoryShrimpCrud + 业务模块)的消费方可以不装 bcrypt -async function loadBcrypt(): Promise { - return await import("bcrypt") -} +import { IPasswordHasher, bcryptHasher } from './PasswordHasher' export class Shrimp { root: Shrimp = this modules: Map = new Map() /** 全局 CRUD 注册表,用于 MongoShrimpCrud 自动解析 relation */ crudRegistry: Map> = new Map() + /** + * 密码哈希器,默认 bcryptHasher(lazy import bcrypt)。 + * 消费方可在启动时替换:`Shrimp.passwordHasher = plainHasher` 或自定义实现, + * 让 Password 字段 reqFilter / UserModule.verifyPassword 都走自定义逻辑。 + */ + static passwordHasher: IPasswordHasher = bcryptHasher /** 请求级操作者上下文,通过 AsyncLocalStorage 避免并发覆盖 */ static asyncLocalStorage = new AsyncLocalStorage() get operator(): IShrimpOperator | null { @@ -258,8 +259,7 @@ export const defaultTypeHandlers: Record = { [ShrimpFieldTypes.Password]: { reqFilter: async (value) => { if (typeof value === 'string' && value.length > 0) { - const bcrypt = await loadBcrypt() - return bcrypt.hash(value, 10) + return Shrimp.passwordHasher.hash(value) } return value }, @@ -713,10 +713,9 @@ export class UserModule extends ShrimpTableModule> { this.initCrud() } - /** 校验密码,子类可覆盖 */ + /** 校验密码,子类可覆盖。默认走 `Shrimp.passwordHasher` 上挂载的哈希器。 */ protected async verifyPassword(storedPassword: string, plainPassword: string): Promise { - const bcrypt = await loadBcrypt() - return bcrypt.compare(plainPassword, storedPassword) + return Shrimp.passwordHasher.verify(plainPassword, storedPassword) } /** 从用户对象提取角色 ID 列表,子类可覆盖 */ diff --git a/src/core.ts b/src/core.ts index 78e34bf..7fd2e8c 100644 --- a/src/core.ts +++ b/src/core.ts @@ -71,6 +71,10 @@ export type { ShrimpCrud, } from "./Shrimp" +// ===== 密码哈希(可注入,默认 bcrypt lazy import) ===== +export { bcryptHasher, plainHasher } from "./PasswordHasher" +export type { IPasswordHasher } from "./PasswordHasher" + // ===== 数据源(纯内存,无外部依赖) ===== export { MemoryShrimpCrud } from "./MemoryShrimpCrud" diff --git a/test/PasswordHasher.test.ts b/test/PasswordHasher.test.ts new file mode 100644 index 0000000..94a3d5f --- /dev/null +++ b/test/PasswordHasher.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import { Shrimp, plainHasher, bcryptHasher, UserModule, MemoryShrimpCrud, ShrimpFieldTypes } from "../src" +import type { IPasswordHasher } from "../src" + +describe("PasswordHasher 抽象", () => { + const original = Shrimp.passwordHasher + afterEach(() => { + Shrimp.passwordHasher = original + }) + + it("默认是 bcryptHasher", () => { + expect(Shrimp.passwordHasher).toBe(bcryptHasher) + }) + + it("plainHasher: hash 原样返回,verify 字符串等值", async () => { + expect(await plainHasher.hash("secret")).toBe("secret") + expect(await plainHasher.verify("secret", "secret")).toBe(true) + expect(await plainHasher.verify("secret", "other")).toBe(false) + }) + + it("通过替换 Shrimp.passwordHasher 让 Password 字段走自定义哈希", async () => { + const calls: string[] = [] + const custom: IPasswordHasher = { + async hash(plain) { calls.push("hash:" + plain); return "X:" + plain }, + async verify(plain, hashed) { calls.push("verify:" + plain + ":" + hashed); return hashed === "X:" + plain }, + } + Shrimp.passwordHasher = custom + + const shrimp = new Shrimp() + const userCrud = new MemoryShrimpCrud<{ _id: string; username: string; password: string; active: boolean; roleId?: string[] }>() + const userModule = new UserModule(shrimp, userCrud as any, [ + { key: "username", label: "用户名", type: ShrimpFieldTypes.String, required: true, editable: "addOnly" }, + { key: "password", label: "密码", type: ShrimpFieldTypes.Password, required: true, tableHide: true }, + { key: "active", label: "启用", type: ShrimpFieldTypes.Boolean, defaultValue: true }, + ]) + + await userModule.add({ username: "alice", password: "pw123", active: true, roleId: [] } as any) + const stored = (await userCrud.list({})).list[0] + expect(stored.password).toBe("X:pw123") + expect(calls).toContain("hash:pw123") + + const ok = await userModule.getUser("alice", "pw123") + expect(ok.username).toBe("alice") + expect(calls.some(c => c.startsWith("verify:pw123:X:pw123"))).toBe(true) + }) + + it("plainHasher 端到端: 在不装 bcrypt 的场景下完成登录校验", async () => { + Shrimp.passwordHasher = plainHasher + + const shrimp = new Shrimp() + const userCrud = new MemoryShrimpCrud<{ _id: string; username: string; password: string; active: boolean; roleId?: string[] }>() + const userModule = new UserModule(shrimp, userCrud as any, [ + { key: "username", label: "用户名", type: ShrimpFieldTypes.String, required: true, editable: "addOnly" }, + { key: "password", label: "密码", type: ShrimpFieldTypes.Password, required: true, tableHide: true }, + { key: "active", label: "启用", type: ShrimpFieldTypes.Boolean, defaultValue: true }, + ]) + + await userModule.add({ username: "bob", password: "open", active: true, roleId: [] } as any) + const user = await userModule.getUser("bob", "open") + expect(user.username).toBe("bob") + + await expect(userModule.getUser("bob", "wrong")).rejects.toThrow() + }) +})