feat(server): 安全基线(Helmet+限流+脱敏)(E10)
- 注册 @fastify/helmet(CSP 关闭) - 引入 @fastify/rate-limit,并对 login/forgot/reset 按开关启用 - Fastify logger 增加敏感字段 redaction(authorization/cookie/password 等)
这个提交包含在:
@@ -8,7 +8,7 @@ E6,票据中心精修(OCR与预览),收敛上传列表详情并强化OCR状
|
|||||||
E7,委派管理前端页(运营可用),新增委派管理页与API封装支持创建列表撤销并加菜单入口与守卫,可创建委派撤销后delegate不可代办且无权限用户403提示正确,"manual: manager创建委派让delegate代办一次后撤销再验证PERM_002",manual,DONE,TODO,TODO,client/src/pages/DelegationsPage.tsx | client/src/api/delegations.ts | client/src/router/index.tsx | client/src/components/navigation/Sidebar.tsx,"[""E3""]",权限码沿用expense.proxy_manage
|
E7,委派管理前端页(运营可用),新增委派管理页与API封装支持创建列表撤销并加菜单入口与守卫,可创建委派撤销后delegate不可代办且无权限用户403提示正确,"manual: manager创建委派让delegate代办一次后撤销再验证PERM_002",manual,DONE,TODO,TODO,client/src/pages/DelegationsPage.tsx | client/src/api/delegations.ts | client/src/router/index.tsx | client/src/components/navigation/Sidebar.tsx,"[""E3""]",权限码沿用expense.proxy_manage
|
||||||
E8,默认账号与失败体验收敛,补dev seed默认账号文档并让前端对网络失败给出可操作提示(含“忘记密码请联系管理员重置”),登录页后端不可达提示服务未启动且README列出默认账号且SYS_001提示可读,"manual: 停后端访问登录页看到提示并按README默认账号登录成功",manual,DONE,TODO,TODO,client/src/pages/LoginPage.tsx | client/src/api/errorMessages.ts | server/README.md | client/README.md,"[""E1""]",仅写dev seed账号不写生产密码与密钥
|
E8,默认账号与失败体验收敛,补dev seed默认账号文档并让前端对网络失败给出可操作提示(含“忘记密码请联系管理员重置”),登录页后端不可达提示服务未启动且README列出默认账号且SYS_001提示可读,"manual: 停后端访问登录页看到提示并按README默认账号登录成功",manual,DONE,TODO,TODO,client/src/pages/LoginPage.tsx | client/src/api/errorMessages.ts | server/README.md | client/README.md,"[""E1""]",仅写dev seed账号不写生产密码与密钥
|
||||||
E9,DB索引补强(试运行性能),为列表分页与筛选补组合索引以降低试运行慢查询风险,迁移可重复执行且回归全绿,cd server && npm run prisma:migrate:deploy && ./scripts/regression-all.sh,manual,DONE,TODO,TODO,server/prisma/schema.prisma | server/prisma/migrations/*,"[""E1""]",新增组合索引仅增不破回滚成本低
|
E9,DB索引补强(试运行性能),为列表分页与筛选补组合索引以降低试运行慢查询风险,迁移可重复执行且回归全绿,cd server && npm run prisma:migrate:deploy && ./scripts/regression-all.sh,manual,DONE,TODO,TODO,server/prisma/schema.prisma | server/prisma/migrations/*,"[""E1""]",新增组合索引仅增不破回滚成本低
|
||||||
E10,后端安全基线(上线级),增加安全头与关键接口限流并完善上传校验与日志脱敏约定,限流触发行为正确安全头存在上传非法类型被拒绝且不泄露token,"manual: curl压测login并验证429与headers并做一次上传非法类型校验",manual,TODO,TODO,TODO,server/src/app.js | server/src/config.js | server/src/routes/auth.js | server/src/routes/invoicePool.js | server/README.md | server/package.json,"[""E1""]",优先使用@fastify/helmet与@fastify/rate-limit并写回滚说明
|
E10,后端安全基线(上线级),增加安全头与关键接口限流并完善上传校验与日志脱敏约定,限流触发行为正确安全头存在上传非法类型被拒绝且不泄露token,"manual: curl压测login并验证429与headers并做一次上传非法类型校验",manual,DONE,TODO,TODO,server/src/app.js | server/src/config.js | server/src/routes/auth.js | server/src/routes/invoicePool.js | server/README.md | server/package.json,"[""E1""]",优先使用@fastify/helmet与@fastify/rate-limit并写回滚说明
|
||||||
E11,可观测性基线(ready与作业统计),新增/ready进行DB探活并为OCR与委派回收作业输出结构化统计,ready可反映db状态且作业日志输出claimed updated audited等计数便于排障,"manual: 启动服务curl /ready并触发一次job观察日志字段",manual,TODO,TODO,TODO,server/src/app.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/ocrJob.js | server/README.md,"[""E10""]",不引入外部监控先提供最小可观测字段
|
E11,可观测性基线(ready与作业统计),新增/ready进行DB探活并为OCR与委派回收作业输出结构化统计,ready可反映db状态且作业日志输出claimed updated audited等计数便于排障,"manual: 启动服务curl /ready并触发一次job观察日志字段",manual,TODO,TODO,TODO,server/src/app.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/ocrJob.js | server/README.md,"[""E10""]",不引入外部监控先提供最小可观测字段
|
||||||
E12,单域部署(server托管client静态),支持server托管client dist实现同域cookie并提供开关与SPA回退,开启后通过server根路径访问前端且登录刷新正常,"manual: cd client && npm run build 然后SERVE_CLIENT_DIST=true启动server访问http://127.0.0.1:3000完成登录",manual,TODO,TODO,TODO,server/src/app.js | server/src/config.js | server/README.md | docker-compose.yml,"[""E10""]",优先复用@fastify/static并用正则fallback避开/api
|
E12,单域部署(server托管client静态),支持server托管client dist实现同域cookie并提供开关与SPA回退,开启后通过server根路径访问前端且登录刷新正常,"manual: cd client && npm run build 然后SERVE_CLIENT_DIST=true启动server访问http://127.0.0.1:3000完成登录",manual,TODO,TODO,TODO,server/src/app.js | server/src/config.js | server/README.md | docker-compose.yml,"[""E10""]",优先复用@fastify/static并用正则fallback避开/api
|
||||||
E13,部署与运维包(试运行),提供deploy/trialrun含env模板compose示例反向代理示例与启动停止runbook,按runbook可在新机器完成启动升级迁移与回滚演练一次,"manual: 按deploy/trialrun/runbook执行docker compose启动并验证ready与登录与回归入口",manual,TODO,TODO,TODO,deploy/trialrun/* | docker-compose.yml | server/.env.example | server/README.md | client/README.md,"[""E12""]",先覆盖试运行环境后续再细化生产
|
E13,部署与运维包(试运行),提供deploy/trialrun含env模板compose示例反向代理示例与启动停止runbook,按runbook可在新机器完成启动升级迁移与回滚演练一次,"manual: 按deploy/trialrun/runbook执行docker compose启动并验证ready与登录与回归入口",manual,TODO,TODO,TODO,deploy/trialrun/* | docker-compose.yml | server/.env.example | server/README.md | client/README.md,"[""E12""]",先覆盖试运行环境后续再细化生产
|
||||||
|
|||||||
|
+12
@@ -223,6 +223,9 @@ npm run job:ocr-once
|
|||||||
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
- `STORAGE_DIR`: 默认 `generated/storage`(相对 `server/`)
|
||||||
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
- `SIGNED_URL_TTL_SECONDS`: 默认 `600`
|
||||||
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
- `MAX_UPLOAD_SIZE_BYTES`: 默认 `10485760`(10MB)
|
||||||
|
- `RATE_LIMIT_ENABLED`: 是否启用关键接口限流(默认:production=true,其它环境=false)
|
||||||
|
- `RATE_LIMIT_LOGIN_MAX`: `/api/auth/login` 等接口限流次数(默认 `10`)
|
||||||
|
- `RATE_LIMIT_LOGIN_WINDOW`: 限流时间窗(默认 `1 minute`,支持 ms 格式如 `30s`/`1m`)
|
||||||
- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`)
|
- `DELEGATION_REVOKE_CRON`: 委派自动回收定时表达式(默认 `0 * * * *`)
|
||||||
- `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`)
|
- `OCR_PROVIDER`: `off` / `mock` / `tencent`(默认 `off`)
|
||||||
- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`)
|
- `OCR_BATCH_SIZE`: 每轮 OCR 认领数量(默认 `10`)
|
||||||
@@ -232,6 +235,15 @@ npm run job:ocr-once
|
|||||||
- `OCR_TIMEOUT_MS`: 腾讯 OCR SDK 超时(默认 `5000`)
|
- `OCR_TIMEOUT_MS`: 腾讯 OCR SDK 超时(默认 `5000`)
|
||||||
- `TENCENT_SECRET_ID` / `TENCENT_SECRET_KEY` / `TENCENT_OCR_REGION`: 腾讯 OCR 配置
|
- `TENCENT_SECRET_ID` / `TENCENT_SECRET_KEY` / `TENCENT_OCR_REGION`: 腾讯 OCR 配置
|
||||||
|
|
||||||
|
安全基线(P4):
|
||||||
|
- 安全头:默认启用 `@fastify/helmet`(CSP 默认关闭,避免误伤 SPA 本地调试)。
|
||||||
|
- 限流:`/api/auth/login`、`/api/auth/forgot-password`、`/api/auth/reset-password` 在 `RATE_LIMIT_ENABLED=true` 时启用限流。
|
||||||
|
- 日志脱敏:`authorization/cookie/password/code/newPassword` 等敏感字段会被 redaction。
|
||||||
|
|
||||||
|
回滚策略:
|
||||||
|
- 若限流误伤,可先设置 `RATE_LIMIT_ENABLED=false`(无需改代码)。
|
||||||
|
- 若安全头造成兼容性问题,可回退 `server/src/app.js` 中 helmet 注册逻辑。
|
||||||
|
|
||||||
> 生产提示:大表加索引需评估 `CREATE INDEX CONCURRENTLY`,避免锁表影响。
|
> 生产提示:大表加索引需评估 `CREATE INDEX CONCURRENTLY`,避免锁表影响。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+52
@@ -11,8 +11,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.3.0",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
@@ -239,6 +241,26 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/helmet": {
|
||||||
|
"version": "13.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.0.2.tgz",
|
||||||
|
"integrity": "sha512-tO1QMkOfNeCt9l4sG/FiWErH4QMm+RjHzbMTrgew1DYOQ2vb/6M1G2iNABBrD7Xq6dUk+HLzWW8u+rmmhQHifA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"helmet": "^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/jwt": {
|
"node_modules/@fastify/jwt": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
|
||||||
@@ -324,6 +346,27 @@
|
|||||||
"ipaddr.js": "^2.1.0"
|
"ipaddr.js": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/rate-limit": {
|
||||||
|
"version": "10.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz",
|
||||||
|
"integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/ms": "^2.0.2",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"toad-cache": "^3.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@fastify/send": {
|
"node_modules/@fastify/send": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||||
@@ -1489,6 +1532,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.11.4",
|
"version": "4.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
|
||||||
|
|||||||
+5
-3
@@ -24,19 +24,21 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.3.0",
|
"@fastify/multipart": "^9.3.0",
|
||||||
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@prisma/client": "^7.3.0",
|
"@prisma/client": "^7.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"fastify-plugin": "^5.1.0",
|
"fastify-plugin": "^5.1.0",
|
||||||
|
"node-cron": "^4.2.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"prisma": "^7.3.0",
|
"prisma": "^7.3.0",
|
||||||
"zod": "^4.3.6",
|
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||||
"node-cron": "^4.2.1",
|
"zod": "^4.3.6"
|
||||||
"tencentcloud-sdk-nodejs": "^4.1.14"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"dotenv": "^16.4.5"
|
"dotenv": "^16.4.5"
|
||||||
|
|||||||
+36
-1
@@ -7,6 +7,8 @@ const fastify = require("fastify");
|
|||||||
const cors = require("@fastify/cors");
|
const cors = require("@fastify/cors");
|
||||||
const cookie = require("@fastify/cookie");
|
const cookie = require("@fastify/cookie");
|
||||||
const multipart = require("@fastify/multipart");
|
const multipart = require("@fastify/multipart");
|
||||||
|
const helmet = require("@fastify/helmet");
|
||||||
|
const rateLimit = require("@fastify/rate-limit");
|
||||||
|
|
||||||
const { getConfig } = require("./config");
|
const { getConfig } = require("./config");
|
||||||
const authPlugin = require("./plugins/auth");
|
const authPlugin = require("./plugins/auth");
|
||||||
@@ -26,7 +28,20 @@ async function buildApp() {
|
|||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
|
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: true,
|
logger: {
|
||||||
|
redact: {
|
||||||
|
paths: [
|
||||||
|
"req.headers.authorization",
|
||||||
|
"req.headers.cookie",
|
||||||
|
"req.headers['set-cookie']",
|
||||||
|
"res.headers['set-cookie']",
|
||||||
|
"req.body.password",
|
||||||
|
"req.body.newPassword",
|
||||||
|
"req.body.code",
|
||||||
|
],
|
||||||
|
censor: "[REDACTED]",
|
||||||
|
},
|
||||||
|
},
|
||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
genReqId: () => crypto.randomUUID(),
|
genReqId: () => crypto.randomUUID(),
|
||||||
});
|
});
|
||||||
@@ -60,6 +75,26 @@ async function buildApp() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await app.register(helmet, {
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(rateLimit, {
|
||||||
|
global: false,
|
||||||
|
errorResponseBuilder: (request, context) => {
|
||||||
|
return new AppError({
|
||||||
|
code: "SYS_429",
|
||||||
|
message: "请求过于频繁,请稍后重试",
|
||||||
|
statusCode: context.statusCode,
|
||||||
|
details: {
|
||||||
|
retryAfter: context.after,
|
||||||
|
max: context.max,
|
||||||
|
ban: context.ban,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
app.addHook("onRequest", async (request, reply) => {
|
app.addHook("onRequest", async (request, reply) => {
|
||||||
reply.header("x-request-id", request.id);
|
reply.header("x-request-id", request.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ function getConfig() {
|
|||||||
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
signedUrlTtlSeconds: Number(process.env.SIGNED_URL_TTL_SECONDS ?? 600),
|
||||||
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
maxUploadSizeBytes: Number(process.env.MAX_UPLOAD_SIZE_BYTES ?? 10 * 1024 * 1024),
|
||||||
|
|
||||||
|
rateLimitEnabled:
|
||||||
|
process.env.RATE_LIMIT_ENABLED != null
|
||||||
|
? process.env.RATE_LIMIT_ENABLED === "true"
|
||||||
|
: nodeEnv === "production",
|
||||||
|
rateLimitLoginMax: Number(process.env.RATE_LIMIT_LOGIN_MAX ?? 10),
|
||||||
|
rateLimitLoginWindow: process.env.RATE_LIMIT_LOGIN_WINDOW ?? "1 minute",
|
||||||
|
|
||||||
delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *",
|
delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *",
|
||||||
|
|
||||||
ocrProvider: process.env.OCR_PROVIDER ?? "off",
|
ocrProvider: process.env.OCR_PROVIDER ?? "off",
|
||||||
|
|||||||
+11
-3
@@ -39,7 +39,15 @@ async function authRoutes(fastify) {
|
|||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/login", async (request, reply) => {
|
const authRateLimit =
|
||||||
|
fastify.config.rateLimitEnabled && typeof fastify.rateLimit === "function"
|
||||||
|
? fastify.rateLimit({
|
||||||
|
max: fastify.config.rateLimitLoginMax,
|
||||||
|
timeWindow: fastify.config.rateLimitLoginWindow,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
const parsed = loginBodySchema.safeParse(request.body);
|
const parsed = loginBodySchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
@@ -374,7 +382,7 @@ async function authRoutes(fastify) {
|
|||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/forgot-password", async (request, reply) => {
|
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
const parsed = forgotPasswordBodySchema.safeParse(request.body);
|
const parsed = forgotPasswordBodySchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
@@ -436,7 +444,7 @@ async function authRoutes(fastify) {
|
|||||||
newPassword: z.string().min(12),
|
newPassword: z.string().min(12),
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/reset-password", async (request, reply) => {
|
fastify.post("/reset-password", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
const parsed = resetPasswordBodySchema.safeParse(request.body);
|
const parsed = resetPasswordBodySchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
throw new AppError({
|
throw new AppError({
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户