feat(server): 敏感字段加密与脱敏(F25)
- 新增敏感字段加密服务(AES-256-GCM,FIELD_ENCRYPTION_KEY) - users 详情返回银行卡/身份证脱敏字段;新增 /users/:id/sensitive 特权明文接口并写审计 - 权限新增 system.users_sensitive(hr/admin) - 更新 env/README 与 v3 CSV 状态推进
这个提交包含在:
@@ -11,6 +11,9 @@ APP_ORIGIN="http://127.0.0.1:3000"
|
||||
|
||||
# ===== 服务端密钥(必须修改)=====
|
||||
JWT_SECRET="change_me_in_trialrun"
|
||||
# ===== 敏感字段加密(F25)=====
|
||||
# 生成方式示例:openssl rand -base64 32
|
||||
FIELD_ENCRYPTION_KEY=""
|
||||
|
||||
# ===== 数据库(Compose 内部连接)=====
|
||||
POSTGRES_DB="hyb_oa"
|
||||
|
||||
@@ -63,6 +63,7 @@ services:
|
||||
METRICS_ENABLED: ${METRICS_ENABLED}
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
@@ -118,6 +119,7 @@ services:
|
||||
METRICS_ENABLED: ${METRICS_ENABLED}
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
@@ -161,6 +163,7 @@ services:
|
||||
METRICS_ENABLED: ${METRICS_ENABLED}
|
||||
APP_ORIGIN: ${APP_ORIGIN}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
FIELD_ENCRYPTION_KEY: ${FIELD_ENCRYPTION_KEY}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
STORAGE_DIR: ${STORAGE_DIR}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
|
||||
@@ -26,7 +26,7 @@ F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容
|
||||
F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,DONE,DONE,DONE,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后"
|
||||
F23,SSO/OIDC(可选,P7+),"支持 OIDC 登录(企业 SSO),保留现有密码登录作为 fallback","可通过 OIDC 登录并绑定员工;权限模型不变;可配置开关","manual: 配置 OIDC provider 后走一次登录回调",manual,TODO,TODO,TODO,"server/src/routes/auth.js | server/src/services/oidc/* | server/README.md","[""F1""]","优先复用 openid-client/passport 策略"
|
||||
F24,工作流扩展到其他实体(P7+),"抽象 entity_type 扩展到请假/采购等(先骨架:definition+instance+task 复用)","新实体可创建 instance 并走同审批动作;不影响报销既有链路","manual: seed 一个 leave 定义并跑一条最小链路",manual,TODO,TODO,TODO,"server/src/routes/workflow.js | server/src/routes/leave.js | server/prisma/schema.prisma","[""F19""]","只做骨架与复用点,不做完整业务表单"
|
||||
F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,TODO,TODO,TODO,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略"
|
||||
F25,敏感字段加密与脱敏(P6+),"落实员工银行卡/身份证等敏感字段的可逆加密与脱敏输出,并对查看/变更写审计","对外响应仅脱敏;解密仅限特权权限并写 audit;不在日志/错误中泄露明文","manual: 创建员工并查看返回脱敏;用特权接口取明文并在 audit 检索记录",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/services/crypto/* | server/src/routes/audit.js","[""F1""]","优先用成熟库(libsodium/crypto),并明确密钥轮换策略"
|
||||
F26,部门API(departments),"补齐部门树/列表与管理接口:GET /api/departments(默认隐藏归档,可 query 开启);POST/PATCH;POST /:id/archive(无子部门/无在职员工约束);写 audit_logs","部门树可用且过滤正确;归档约束正确;system.users 才可写;审计可追踪","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments' | jq;curl -sS -X POST -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments' -d '{name:...}';curl -sS -X PATCH -H 'authorization: Bearer <TOKEN>' -H 'content-type: application/json' 'http://127.0.0.1:3000/api/departments/<ID>' -d '{name:...}';curl -sS -X POST -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/departments/<ID>/archive';可选 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/audit-logs?action=DEPARTMENT_*' | jq",manual,DONE,DONE,DONE,"server/src/routes/departments.js | server/src/app.js | server/README.md","[""F1""]","单机试运行优先;树构造按 parent_id 组装"
|
||||
F27,员工API对齐(新增别名 /api/employees),"对齐规格书:在保留 /api/users 的同时新增 /api/employees 别名路由;补齐 GET详情/PATCH更新/POST archive;并保证本人可读自己的详情","/api/employees 与 /api/users 行为一致;system.users 可增改归档;本人可 GET 自己;审计完整","manual: 启动 server;以 [email protected]/AdminPassword1234 登录拿 accessToken;curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees?page=1&pageSize=1' | jq;取 employeeId 后 curl -sS -H 'authorization: Bearer <TOKEN>' 'http://127.0.0.1:3000/api/employees/<ID>' | jq;再用普通 employee 登录拿 <EMP_TOKEN> 验证仅可 GET 自己且 GET 他人返回 403 PERM_001",manual,DONE,DONE,DONE,"server/src/routes/users.js | server/src/routes/employees.js | server/src/app.js | server/README.md","[""F26""]","优先复用既有 usersRoutes 实现,避免重复逻辑"
|
||||
F28,票据人工修正与手工归档(invoice-pool patch/archive),"补齐 PATCH /api/invoice-pool/:id(remarks/人工修正关键字段,允许 ocr_failed -> available);补齐 POST /api/invoice-pool/:id/archive(manual,未绑定才允许);写 audit_logs(INVOICE_POOL_EDITED/INVOICE_POOL_ARCHIVED)","服务端鉴权正确(owner/uploader/invoice.manage);人工修正后可参与 bind/判重;手工归档后不影响历史判重口径;审计可检索","cd server && ./scripts/regression-audit.sh",manual,DONE,DONE,DONE,"server/src/routes/invoicePool.js | server/scripts/regression-audit.sh | server/README.md","[""F1""]","extra.changes 仅记录必要字段的前后快照(避免敏感/大字段)"
|
||||
|
||||
|
@@ -1,5 +1,9 @@
|
||||
DATABASE_URL="postgresql://hyboa_user:hyboa_password@localhost:5432/hyb_oa?schema=public"
|
||||
JWT_SECRET="change_me"
|
||||
# ===== 敏感字段加密(F25)=====
|
||||
# 用于员工银行卡/身份证等字段的可逆加密(AES-256-GCM)。
|
||||
# 生成方式示例:openssl rand -base64 32
|
||||
# FIELD_ENCRYPTION_KEY=""
|
||||
# APP_ORIGIN="https://oa.hyinda.com"
|
||||
# COOKIE_SECURE="true"
|
||||
# DEV_SHOW_PASSWORD_RESET_CODE="true"
|
||||
|
||||
@@ -366,19 +366,24 @@ npm run job:ocr-once
|
||||
### 5.9 账号与角色管理(E17,试运行运营)
|
||||
- `GET /api/users`:员工列表(分页/搜索)
|
||||
- `POST /api/users`:创建员工(含初始密码)
|
||||
- `GET /api/users/:id`:员工详情(管理员可看全部;员工可看自己;敏感字段仅返回脱敏)
|
||||
- `POST /api/users/:id/disable`:禁用员工(tokenVersion+1 + 撤销 refresh)
|
||||
- `POST /api/users/:id/enable`:启用员工(tokenVersion+1 + 撤销 refresh)
|
||||
- `POST /api/users/:id/reset-password`:重置密码(返回一次性口令,tokenVersion+1)
|
||||
- `POST /api/users/:id/roles`:设置角色(tokenVersion+1 + 撤销 refresh)
|
||||
- `GET /api/users/:id/sensitive`:敏感字段明文查询(F25)
|
||||
- `PATCH /api/users/:id/sensitive`:敏感字段更新(F25)
|
||||
|
||||
权限:
|
||||
- `system.users`(hr/admin;admin 具备 `*`)
|
||||
- `system.users_sensitive`(仅特权账号;用于解密查看/更新银行卡、身份证等)
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键环境变量
|
||||
- `DATABASE_URL`: PostgreSQL 连接串
|
||||
- `JWT_SECRET`: JWT & signed-url token 签名密钥
|
||||
- `FIELD_ENCRYPTION_KEY`: 敏感字段加密密钥(AES-256-GCM;base64 32 bytes;用于银行卡/身份证等)
|
||||
- `APP_TIMEZONE`: 默认 `Asia/Shanghai`
|
||||
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
||||
- `SERVE_CLIENT_DIST`: 是否由 server 托管 `client/dist`(默认 `false`)
|
||||
|
||||
@@ -26,6 +26,7 @@ function getConfig() {
|
||||
|
||||
appOrigin: process.env.APP_ORIGIN ?? "",
|
||||
jwtSecret: requiredEnv("JWT_SECRET"),
|
||||
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "",
|
||||
|
||||
accessTokenTtl: "15m",
|
||||
refreshTtlSeconds: 7 * ONE_DAY_SECONDS,
|
||||
|
||||
@@ -42,7 +42,7 @@ const ROLE_PERMISSIONS = {
|
||||
"invoice.upload",
|
||||
"invoice.view_own",
|
||||
],
|
||||
hr: ["system.users", "expense.view_all"],
|
||||
hr: ["system.users", "system.users_sensitive", "expense.view_all"],
|
||||
admin: ["*"],
|
||||
};
|
||||
|
||||
|
||||
+263
-5
@@ -7,6 +7,13 @@ const { sendData } = require("../utils/reply");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { hashPassword } = require("../utils/crypto");
|
||||
const { validatePassword } = require("../utils/passwordPolicy");
|
||||
const {
|
||||
decryptStringOrThrow,
|
||||
decryptStringSafe,
|
||||
encryptStringOrThrow,
|
||||
maskBankAccount,
|
||||
maskIdNumber,
|
||||
} = require("../services/crypto");
|
||||
|
||||
const roleCodeSchema = z.enum([
|
||||
"employee",
|
||||
@@ -58,6 +65,14 @@ const resetPasswordBodySchema = z.object({
|
||||
newPassword: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const sensitiveBodySchema = z
|
||||
.object({
|
||||
bankName: z.string().trim().max(100).nullable().optional(),
|
||||
bankAccount: z.string().trim().max(100).nullable().optional(),
|
||||
idNumber: z.string().trim().max(64).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function parseUserId(params) {
|
||||
const parsed = z.string().uuid().safeParse(params?.id);
|
||||
if (!parsed.success) {
|
||||
@@ -156,12 +171,21 @@ function mapEmployeeSummary(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapEmployeeDetail(row) {
|
||||
function mapEmployeeDetail(row, options = {}) {
|
||||
const config = options.config ?? null;
|
||||
const logger = options.logger ?? null;
|
||||
|
||||
const bankAccountPlain = decryptStringSafe(config, row.bank_account_encrypted, logger);
|
||||
const idNumberPlain = decryptStringSafe(config, row.id_number_encrypted, logger);
|
||||
|
||||
return {
|
||||
...mapEmployeeSummary(row),
|
||||
directManagerId: row.direct_manager_id ?? null,
|
||||
hireDate: row.hire_date?.toISOString?.()?.slice(0, 10) ?? null,
|
||||
archivedAt: row.archived_at?.toISOString?.() ?? null,
|
||||
bankName: row.bank_name ? String(row.bank_name) : null,
|
||||
bankAccountMasked: bankAccountPlain ? maskBankAccount(bankAccountPlain) : null,
|
||||
idNumberMasked: idNumberPlain ? maskIdNumber(idNumberPlain) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +204,29 @@ function normalizeUniqueErrorDetails(error) {
|
||||
return Object.keys(details).length ? details : { unique: "duplicate" };
|
||||
}
|
||||
|
||||
function parseSensitiveBody(body) {
|
||||
const parsed = sensitiveBodySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!Object.keys(parsed.data).length) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 400,
|
||||
details: { body: "empty" },
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
async function usersRoutes(fastify, opts = {}) {
|
||||
const basePathRaw =
|
||||
typeof opts.basePath === "string" && opts.basePath.trim() ? opts.basePath.trim() : "/users";
|
||||
@@ -276,6 +323,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -291,7 +341,11 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeDetail(employee));
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(employee, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -368,8 +422,8 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
direct_manager_id: body.directManagerId ?? null,
|
||||
hire_date: hireDate,
|
||||
status: "active",
|
||||
bank_account_encrypted: "ENC_TBD",
|
||||
bank_name: "TBD",
|
||||
bank_account_encrypted: "",
|
||||
bank_name: "",
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
created_at: now,
|
||||
@@ -535,6 +589,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -605,6 +662,9 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
@@ -638,7 +698,205 @@ async function usersRoutes(fastify, opts = {}) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(reply, request, mapEmployeeDetail(updated));
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
`${routeBasePath}/:id/sensitive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users_sensitive"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
id_number_encrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
let bankAccount = null;
|
||||
let idNumber = null;
|
||||
try {
|
||||
bankAccount = decryptStringOrThrow(fastify.config, employee.bank_account_encrypted);
|
||||
idNumber = decryptStringOrThrow(fastify.config, employee.id_number_encrypted);
|
||||
} catch (error) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_SENSITIVE_VIEWED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: {},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
bankName: employee.bank_name ? String(employee.bank_name) : null,
|
||||
bankAccount,
|
||||
idNumber,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.patch(
|
||||
`${routeBasePath}/:id/sensitive`,
|
||||
{
|
||||
preHandler: fastify.requirePermission("system.users_sensitive"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = parseUserId(request.params);
|
||||
const body = parseSensitiveBody(request.body);
|
||||
const now = new Date();
|
||||
|
||||
let updated = null;
|
||||
try {
|
||||
updated = await fastify.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.employee.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
bank_name: true,
|
||||
bank_account_encrypted: true,
|
||||
id_number_encrypted: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new AppError({
|
||||
code: "SYS_404",
|
||||
message: "Not Found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const changes = {};
|
||||
const data = {
|
||||
updated_at: now,
|
||||
updated_by: request.employee.id,
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "bankName")) {
|
||||
const nextBankName =
|
||||
typeof body.bankName === "string" ? body.bankName.trim() : body.bankName;
|
||||
data.bank_name = nextBankName ? String(nextBankName) : "";
|
||||
changes.bankName = {
|
||||
before: current.bank_name ? String(current.bank_name) : null,
|
||||
after: data.bank_name || null,
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "bankAccount")) {
|
||||
const nextBankAccount =
|
||||
typeof body.bankAccount === "string" ? body.bankAccount.trim() : body.bankAccount;
|
||||
data.bank_account_encrypted = nextBankAccount
|
||||
? encryptStringOrThrow(fastify.config, nextBankAccount)
|
||||
: "";
|
||||
changes.bankAccountMasked = nextBankAccount ? maskBankAccount(nextBankAccount) : null;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(body, "idNumber")) {
|
||||
const nextIdNumber =
|
||||
typeof body.idNumber === "string" ? body.idNumber.trim() : body.idNumber;
|
||||
data.id_number_encrypted = nextIdNumber
|
||||
? encryptStringOrThrow(fastify.config, nextIdNumber)
|
||||
: null;
|
||||
changes.idNumberMasked = nextIdNumber ? maskIdNumber(nextIdNumber) : null;
|
||||
}
|
||||
|
||||
const employee = await tx.employee.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
title: true,
|
||||
direct_manager_id: true,
|
||||
hire_date: true,
|
||||
bank_account_encrypted: true,
|
||||
bank_name: true,
|
||||
id_number_encrypted: true,
|
||||
archived_at: true,
|
||||
created_at: true,
|
||||
updated_at: true,
|
||||
roles: { select: { role_code: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "SYSTEM_USER_SENSITIVE_UPDATED",
|
||||
targetType: "employee",
|
||||
targetId: userId,
|
||||
extra: { changes },
|
||||
}),
|
||||
});
|
||||
|
||||
return employee;
|
||||
});
|
||||
} catch (error) {
|
||||
if (String(error?.message ?? "").includes("FIELD_ENCRYPTION_KEY")) {
|
||||
fastify.log.error({ err: error, requestId: request.id }, "FIELD_ENCRYPTION_KEY misconfigured");
|
||||
throw new AppError({
|
||||
code: "SYS_001",
|
||||
message: "服务器内部错误",
|
||||
statusCode: 500,
|
||||
details: {},
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sendData(
|
||||
reply,
|
||||
request,
|
||||
mapEmployeeDetail(updated, { config: fastify.config, logger: request.log ?? fastify.log }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const ENCRYPTION_VERSION_PREFIX = "v1:";
|
||||
const AES_ALGO = "aes-256-gcm";
|
||||
const IV_LENGTH_BYTES = 12;
|
||||
const TAG_LENGTH_BYTES = 16;
|
||||
|
||||
function resolveFieldEncryptionKey(config) {
|
||||
const raw = config?.fieldEncryptionKey;
|
||||
if (!raw || typeof raw !== "string" || !raw.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let key;
|
||||
try {
|
||||
key = Buffer.from(raw.trim(), "base64");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.length !== 32) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function encryptString(plaintext, key) {
|
||||
const input = typeof plaintext === "string" ? plaintext : "";
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
if (!Buffer.isBuffer(key) || key.length !== 32) {
|
||||
throw new Error("Invalid encryption key");
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(IV_LENGTH_BYTES);
|
||||
const cipher = crypto.createCipheriv(AES_ALGO, key, iv);
|
||||
const cipherText = Buffer.concat([cipher.update(input, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const payload = `${ENCRYPTION_VERSION_PREFIX}${iv.toString("base64url")}:${tag.toString(
|
||||
"base64url",
|
||||
)}:${cipherText.toString("base64url")}`;
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function decryptString(value, key) {
|
||||
const input = typeof value === "string" ? value : "";
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
if (!input.startsWith(ENCRYPTION_VERSION_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
if (!Buffer.isBuffer(key) || key.length !== 32) {
|
||||
throw new Error("Invalid encryption key");
|
||||
}
|
||||
|
||||
const body = input.slice(ENCRYPTION_VERSION_PREFIX.length);
|
||||
const parts = body.split(":");
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ivB64, tagB64, cipherB64] = parts;
|
||||
const iv = Buffer.from(ivB64, "base64url");
|
||||
const tag = Buffer.from(tagB64, "base64url");
|
||||
const cipherText = Buffer.from(cipherB64, "base64url");
|
||||
|
||||
if (iv.length !== IV_LENGTH_BYTES || tag.length !== TAG_LENGTH_BYTES || !cipherText.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decipher = crypto.createDecipheriv(AES_ALGO, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const plain = Buffer.concat([decipher.update(cipherText), decipher.final()]).toString("utf8");
|
||||
return plain;
|
||||
}
|
||||
|
||||
function decryptStringSafe(config, encrypted, logger) {
|
||||
const key = resolveFieldEncryptionKey(config);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return decryptString(encrypted, key);
|
||||
} catch (error) {
|
||||
logger?.warn?.({ err: error }, "Sensitive field decrypt failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encryptStringOrThrow(config, plaintext) {
|
||||
const key = resolveFieldEncryptionKey(config);
|
||||
if (!key) {
|
||||
throw new Error("Missing FIELD_ENCRYPTION_KEY");
|
||||
}
|
||||
return encryptString(plaintext, key);
|
||||
}
|
||||
|
||||
function decryptStringOrThrow(config, encrypted) {
|
||||
const key = resolveFieldEncryptionKey(config);
|
||||
if (!key) {
|
||||
throw new Error("Missing FIELD_ENCRYPTION_KEY");
|
||||
}
|
||||
|
||||
return decryptString(encrypted, key);
|
||||
}
|
||||
|
||||
function maskKeepEnds(value, options = {}) {
|
||||
const input = typeof value === "string" ? value.trim() : "";
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keepStart = Math.max(0, Number(options.keepStart ?? 0));
|
||||
const keepEnd = Math.max(0, Number(options.keepEnd ?? 0));
|
||||
|
||||
if (input.length <= keepStart + keepEnd) {
|
||||
return "*".repeat(input.length);
|
||||
}
|
||||
|
||||
const start = keepStart ? input.slice(0, keepStart) : "";
|
||||
const end = keepEnd ? input.slice(-keepEnd) : "";
|
||||
return `${start}${"*".repeat(input.length - keepStart - keepEnd)}${end}`;
|
||||
}
|
||||
|
||||
function maskBankAccount(account) {
|
||||
return maskKeepEnds(account, { keepStart: 4, keepEnd: 4 });
|
||||
}
|
||||
|
||||
function maskIdNumber(idNumber) {
|
||||
return maskKeepEnds(idNumber, { keepStart: 6, keepEnd: 4 });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
decryptStringSafe,
|
||||
decryptStringOrThrow,
|
||||
encryptStringOrThrow,
|
||||
maskBankAccount,
|
||||
maskIdNumber,
|
||||
resolveFieldEncryptionKey,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户