feat: passwordHasher 可注入 — bcrypt 不再是 Shrimp 核心硬依赖
新增 IPasswordHasher 抽象 + bcryptHasher(默认,lazy import)+ plainHasher
(测试用)。Password 字段 reqFilter 和 UserModule.verifyPassword 都走
`Shrimp.passwordHasher`,消费方在启动期换实现就能彻底跳过 bcrypt。
import { Shrimp, plainHasher } from "shrimp-server"
Shrimp.passwordHasher = plainHasher
测试覆盖默认值、plain 端到端登录、自定义 hasher 被调用。
This commit is contained in:
@@ -266,6 +266,27 @@ ShrimpTableModule.registerTypeHandler('phone', {
|
|||||||
### 自定义 type 的前端渲染
|
### 自定义 type 的前端渲染
|
||||||
后端只声明 `type: 'fieldGoodsPicker'` 即可,前端在 `hand-admin/src/module/fields/` 放一个 `FieldGoodsPicker.vue`,会按命名约定自动注册(`Field*.vue` → `'field' + *`)。
|
后端只声明 `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 拿到当前登录用户
|
## 6. 用 operator 拿到当前登录用户
|
||||||
|
|||||||
@@ -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<string>
|
||||||
|
/** 校验明文密码是否匹配已存储的哈希 */
|
||||||
|
verify(plain: string, hashed: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认实现 — bcrypt
|
||||||
|
*
|
||||||
|
* 通过动态 import 加载,确保只有真正调用 hash/verify 时才 require('bcrypt')。
|
||||||
|
* 这样消费方:不创建 Password 字段 + 不登录的话,可以完全不装 bcrypt。
|
||||||
|
*/
|
||||||
|
export const bcryptHasher: IPasswordHasher = {
|
||||||
|
async hash(plain: string): Promise<string> {
|
||||||
|
const bcrypt = await import("bcrypt")
|
||||||
|
return bcrypt.hash(plain, 10)
|
||||||
|
},
|
||||||
|
async verify(plain: string, hashed: string): Promise<boolean> {
|
||||||
|
const bcrypt = await import("bcrypt")
|
||||||
|
return bcrypt.compare(plain, hashed)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 明文实现 — 仅用于测试 / 开发环境 / 自己已经在上层做了加密
|
||||||
|
*
|
||||||
|
* **生产环境严禁使用**。存储明文密码的后果你懂的。
|
||||||
|
*/
|
||||||
|
export const plainHasher: IPasswordHasher = {
|
||||||
|
async hash(plain: string): Promise<string> {
|
||||||
|
return plain
|
||||||
|
},
|
||||||
|
async verify(plain: string, hashed: string): Promise<boolean> {
|
||||||
|
return plain === hashed
|
||||||
|
},
|
||||||
|
}
|
||||||
+10
-11
@@ -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 { 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'
|
import { AsyncLocalStorage } from 'async_hooks'
|
||||||
|
import { IPasswordHasher, bcryptHasher } from './PasswordHasher'
|
||||||
// 懒加载 bcrypt: 只有走到 Password 字段 hash 或 verifyPassword 才 import,
|
|
||||||
// 让纯核心(MemoryShrimpCrud + 业务模块)的消费方可以不装 bcrypt
|
|
||||||
async function loadBcrypt(): Promise<typeof import("bcrypt")> {
|
|
||||||
return await import("bcrypt")
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Shrimp {
|
export class Shrimp {
|
||||||
root: Shrimp = this
|
root: Shrimp = this
|
||||||
modules: Map<string, ShrimpModule> = new Map()
|
modules: Map<string, ShrimpModule> = new Map()
|
||||||
/** 全局 CRUD 注册表,用于 MongoShrimpCrud 自动解析 relation */
|
/** 全局 CRUD 注册表,用于 MongoShrimpCrud 自动解析 relation */
|
||||||
crudRegistry: Map<string, ShrimpCrud<any>> = new Map()
|
crudRegistry: Map<string, ShrimpCrud<any>> = new Map()
|
||||||
|
/**
|
||||||
|
* 密码哈希器,默认 bcryptHasher(lazy import bcrypt)。
|
||||||
|
* 消费方可在启动时替换:`Shrimp.passwordHasher = plainHasher` 或自定义实现,
|
||||||
|
* 让 Password 字段 reqFilter / UserModule.verifyPassword 都走自定义逻辑。
|
||||||
|
*/
|
||||||
|
static passwordHasher: IPasswordHasher = bcryptHasher
|
||||||
/** 请求级操作者上下文,通过 AsyncLocalStorage 避免并发覆盖 */
|
/** 请求级操作者上下文,通过 AsyncLocalStorage 避免并发覆盖 */
|
||||||
static asyncLocalStorage = new AsyncLocalStorage<IShrimpOperator | null>()
|
static asyncLocalStorage = new AsyncLocalStorage<IShrimpOperator | null>()
|
||||||
get operator(): IShrimpOperator | null {
|
get operator(): IShrimpOperator | null {
|
||||||
@@ -258,8 +259,7 @@ export const defaultTypeHandlers: Record<string, IFieldTypeHandler> = {
|
|||||||
[ShrimpFieldTypes.Password]: {
|
[ShrimpFieldTypes.Password]: {
|
||||||
reqFilter: async (value) => {
|
reqFilter: async (value) => {
|
||||||
if (typeof value === 'string' && value.length > 0) {
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
const bcrypt = await loadBcrypt()
|
return Shrimp.passwordHasher.hash(value)
|
||||||
return bcrypt.hash(value, 10)
|
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
},
|
},
|
||||||
@@ -713,10 +713,9 @@ export class UserModule extends ShrimpTableModule<WithId<IUser>> {
|
|||||||
this.initCrud()
|
this.initCrud()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验密码,子类可覆盖 */
|
/** 校验密码,子类可覆盖。默认走 `Shrimp.passwordHasher` 上挂载的哈希器。 */
|
||||||
protected async verifyPassword(storedPassword: string, plainPassword: string): Promise<boolean> {
|
protected async verifyPassword(storedPassword: string, plainPassword: string): Promise<boolean> {
|
||||||
const bcrypt = await loadBcrypt()
|
return Shrimp.passwordHasher.verify(plainPassword, storedPassword)
|
||||||
return bcrypt.compare(plainPassword, storedPassword)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从用户对象提取角色 ID 列表,子类可覆盖 */
|
/** 从用户对象提取角色 ID 列表,子类可覆盖 */
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export type {
|
|||||||
ShrimpCrud,
|
ShrimpCrud,
|
||||||
} from "./Shrimp"
|
} from "./Shrimp"
|
||||||
|
|
||||||
|
// ===== 密码哈希(可注入,默认 bcrypt lazy import) =====
|
||||||
|
export { bcryptHasher, plainHasher } from "./PasswordHasher"
|
||||||
|
export type { IPasswordHasher } from "./PasswordHasher"
|
||||||
|
|
||||||
// ===== 数据源(纯内存,无外部依赖) =====
|
// ===== 数据源(纯内存,无外部依赖) =====
|
||||||
export { MemoryShrimpCrud } from "./MemoryShrimpCrud"
|
export { MemoryShrimpCrud } from "./MemoryShrimpCrud"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user