修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)
这个提交包含在:
@@ -23,6 +23,7 @@ APP_ORIGIN=https://oa.example.com
|
||||
APP_TIMEZONE=Asia/Shanghai
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
TRUST_PROXY_HOPS=1
|
||||
COOKIE_SECURE=true
|
||||
OPENAPI_DOCS_ENABLED=false
|
||||
SERVE_CLIENT_DIST=true
|
||||
@@ -32,13 +33,22 @@ MAX_UPLOAD_SIZE_BYTES=10485760
|
||||
|
||||
# ===== 服务端密钥(必须修改) =====
|
||||
JWT_SECRET=<managed-jwt-secret>
|
||||
AUTH_CODE_PEPPER=<managed-auth-code-pepper>
|
||||
FIELD_ENCRYPTION_KEY=
|
||||
|
||||
# ===== 密码 / 登录安全 =====
|
||||
PASSWORD_RESET_CODE_TTL_SECONDS=600
|
||||
PASSWORD_RESET_FAIL_WINDOW_SECONDS=1800
|
||||
PASSWORD_RESET_MAX_ATTEMPTS=5
|
||||
PASSWORD_RESET_LOCK_SECONDS=1800
|
||||
PASSWORD_RESET_REQUEST_WINDOW_SECONDS=3600
|
||||
PASSWORD_RESET_REQUEST_MAX=3
|
||||
AUTH_CODE_REQUEST_COOLDOWN_SECONDS=60
|
||||
DEV_SHOW_PASSWORD_RESET_CODE=false
|
||||
EMAIL_LOGIN_CODE_TTL_SECONDS=600
|
||||
EMAIL_LOGIN_CODE_MAX_ATTEMPTS=5
|
||||
EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS=1800
|
||||
EMAIL_LOGIN_CODE_LOCK_SECONDS=1800
|
||||
AUTH_LOGIN_FAIL_WINDOW_SECONDS=900
|
||||
AUTH_LOGIN_FAIL_MAX_ATTEMPTS=5
|
||||
AUTH_LOGIN_LOCK_SECONDS=1800
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
- [ ] 已在服务器创建 OA 目录(默认 `/opt/hyb-oa-trialrun`)
|
||||
- [ ] 已确认常规 SSH 用户为 `deploy`,系统配置用户为 `root`;当前不依赖 `shunli` 登录
|
||||
- [ ] 已准备 `deploy/trialrun/.env`,并将 `APP_ORIGIN` 改为 OA 公网域名
|
||||
- [ ] 已配置独立、至少 32 字节的 `AUTH_CODE_PEPPER`,且不是仓库示例占位符
|
||||
- [ ] 已将 `TRUST_PROXY_HOPS` 固定为 `1`
|
||||
- [ ] 已将 `COOKIE_SECURE` 设为 `true`
|
||||
- [ ] 已确认 `deploy/trialrun/.env` 作为部署事实源,新增环境变量优先补到该文件
|
||||
- [ ] 已尽量使用无引号 env 值;历史带引号值当前兼容,但建议逐步清理
|
||||
- [ ] 已将 OA `Caddyfile` 站点块配置到 `/etc/caddy/Caddyfile`
|
||||
- [ ] 已确认 `oa.hyinda.com { reverse_proxy 127.0.0.1:3000 }` 生效
|
||||
- [ ] 已确认 Caddy 反代到 `127.0.0.1:3000`,并用 `header_up X-Forwarded-For {remote_host}` 覆盖客户端 XFF
|
||||
- [ ] 已执行 `systemctl reload caddy`
|
||||
- [ ] 已显式提供 `DEPLOY_SERVER`、`REMOTE_DIR`,并从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh`
|
||||
- [ ] 已通过 `https://<oa-domain>/ready` 或首页登录完成最小探活
|
||||
@@ -36,6 +38,7 @@
|
||||
|
||||
## B. 发布(Deploy)
|
||||
- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致
|
||||
- [ ] **EXTERNAL_GATED**:操作人已在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;部署脚本不会自动修改边缘配置,未人工确认四端点关闭不得发布,配置预检、迁移、新应用和 smoke 全部通过后才人工恢复
|
||||
- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识
|
||||
- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
|
||||
- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
|
||||
@@ -66,6 +69,7 @@
|
||||
## D. 回滚(Rollback)
|
||||
> 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。
|
||||
|
||||
- [ ] **EXTERNAL_GATED**:若回滚目标仍包含旧认证挑战逻辑,人工确认边缘继续关闭 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;脚本不会自动改边缘,未确认不得回滚开放,直到安全版本恢复
|
||||
- [ ] 停止 server:`docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env stop server`
|
||||
- [ ] 恢复 DB:`./scripts/restore-db.sh <db.dump>`
|
||||
- [ ] 恢复存储:`./scripts/restore-storage.sh <storage.tar.gz> deploy/trialrun/data/storage`
|
||||
|
||||
+13
-11
@@ -172,25 +172,27 @@ echo ">>> 4.2 启动基础服务"
|
||||
if [[ "$ENABLE_MINIO" == "1" ]]; then
|
||||
"${compose_cmd[@]}" "${profile_args[@]}" up -d minio minio_init
|
||||
fi
|
||||
|
||||
echo ">>> 4.3 构建最新 init 镜像"
|
||||
"${compose_cmd[@]}" build init
|
||||
|
||||
echo ">>> 4.4 预检新配置并执行 init"
|
||||
"${compose_cmd[@]}" run --rm -T --entrypoint sh init -lc 'cd /app/server && node -e '\''require("./src/config").getConfig()'\'' && npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun' </dev/null
|
||||
|
||||
echo ">>> 4.5 构建并切换应用服务"
|
||||
"${compose_cmd[@]}" "${profile_args[@]}" up -d --build server
|
||||
if [[ "$ENABLE_WORKER" == "1" ]]; then
|
||||
"${compose_cmd[@]}" "${profile_args[@]}" up -d worker
|
||||
fi
|
||||
|
||||
echo ">>> 4.3 构建最新 init 镜像"
|
||||
"${compose_cmd[@]}" build init
|
||||
|
||||
echo ">>> 4.4 执行 init"
|
||||
"${compose_cmd[@]}" run --rm -T --entrypoint sh init -lc 'cd /app/server && npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun' </dev/null
|
||||
|
||||
echo ">>> 4.5 校验 Prisma migration status"
|
||||
echo ">>> 4.6 校验 Prisma migration status"
|
||||
if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' </dev/null; then
|
||||
echo "[ERROR] Prisma migration status 未通过,停止部署" >&2
|
||||
"${compose_cmd[@]}" logs --tail=200 server || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 4.6 等待 /ready 并核对构建标识"
|
||||
echo ">>> 4.7 等待 /ready 并核对构建标识"
|
||||
ready_ok=0
|
||||
for i in $(seq 1 60); do
|
||||
if ready_payload="$(curl -fsS http://127.0.0.1:3000/ready 2>/dev/null)"; then
|
||||
@@ -211,21 +213,21 @@ if [[ "$ready_ok" != "1" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 4.7 执行 external deps smoke"
|
||||
echo ">>> 4.8 执行 external deps smoke"
|
||||
if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-external-deps.js </dev/null; then
|
||||
echo "[ERROR] external deps smoke 失败,输出最近 server 日志" >&2
|
||||
"${compose_cmd[@]}" logs --tail=200 server || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 4.8 执行 runtime smoke"
|
||||
echo ">>> 4.9 执行 runtime smoke"
|
||||
if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-trialrun-runtime.js </dev/null; then
|
||||
echo "[ERROR] runtime smoke 失败,输出最近 server 日志" >&2
|
||||
"${compose_cmd[@]}" logs --tail=200 server || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ">>> 4.9 当前服务状态"
|
||||
echo ">>> 4.10 当前服务状态"
|
||||
"${compose_cmd[@]}" ps
|
||||
REMOTE
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
NODE_ENV: production
|
||||
BUILD_SHA: ${BUILD_SHA:?set BUILD_SHA to the approved full commit}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:3000:3000"
|
||||
volumes:
|
||||
- ./data/storage:${STORAGE_DIR}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
@@ -53,7 +53,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
|
||||
@@ -12,7 +12,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- OA 新增独立二级域名,例如 `oa.hyinda.com`。
|
||||
- OA 部署目录由受控环境中的 `REMOTE_DIR` 指定,不在仓库固化机器路径。
|
||||
- OA 推荐访问方式:`Caddy(443) -> 127.0.0.1:3000 -> docker-compose(server)`。
|
||||
- Caddy 必须覆盖客户端传入的 `X-Forwarded-For`。OA 站点的 `reverse_proxy` 中应设置 `header_up X-Forwarded-For {remote_host}`,不得保留不可信客户端链。
|
||||
- 首次初始化时:
|
||||
1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh`
|
||||
2. 手工创建 `deploy/trialrun/.env`
|
||||
@@ -36,6 +37,8 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
|
||||
|
||||
必须修改:
|
||||
- `JWT_SECRET`
|
||||
- `AUTH_CODE_PEPPER`(独立随机秘密,至少 32 字节,不得沿用示例占位符或 JWT secret)
|
||||
- `TRUST_PROXY_HOPS=1`(当前单跳 Caddy/Nginx 部署固定为 1)
|
||||
- `APP_ORIGIN`(建议与对外域名一致,例如 `https://oa.example.com`)
|
||||
|
||||
环境变量约束:
|
||||
@@ -44,6 +47,7 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
|
||||
- 推荐使用无引号值,例如 `COOKIE_SECURE=true`、`PORT=3000`。
|
||||
- 历史上已经写成带引号的 `.env` 当前仍兼容,但建议后续逐步改成无引号。
|
||||
- 正式部署前可先执行 `node deploy/trialrun/validate-env-contract.js` 做静态契约检查。
|
||||
- **EXTERNAL_GATED**:涉及认证安全迁移时,发布或回滚前必须由操作人先在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify` 四个 challenge 端点,再执行“新镜像配置预检 -> additive migration -> 新应用切换”。部署脚本不会自动修改边缘配置;未人工确认四端点已关闭不得发布或回滚开放。仅在安全版本及 smoke 全部通过后人工恢复;若回滚到旧认证代码,四端点必须继续关闭。
|
||||
|
||||
作业系统建议:
|
||||
- 试运行默认 `JOB_DRIVER=pgboss`(基于 Postgres 的队列调度,单机/多进程语义更稳)
|
||||
@@ -61,6 +65,8 @@ node deploy/trialrun/validate-env-contract.js
|
||||
export BUILD_SHA="<approved-full-commit>"
|
||||
export VITE_BUILD_SHA="<matching-12-char-commit>"
|
||||
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d db
|
||||
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env build init
|
||||
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env run --rm init
|
||||
docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env up -d --build server
|
||||
```
|
||||
|
||||
@@ -88,6 +94,7 @@ docker-compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
|
||||
|
||||
说明:
|
||||
- `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。
|
||||
- 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。
|
||||
- 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。
|
||||
- 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。
|
||||
- 如确需导入,仅导入最小必要主数据:部门、员工、角色、在用资产、活跃项目、合同元数据。
|
||||
|
||||
@@ -6,6 +6,12 @@ const rootDir = path.resolve(__dirname, '..', '..');
|
||||
const configPath = path.join(rootDir, 'server/src/config.js');
|
||||
const envExamplePath = path.join(rootDir, 'deploy/trialrun/.env.example');
|
||||
const composePath = path.join(rootDir, 'deploy/trialrun/docker-compose.yml');
|
||||
const runbookPath = path.join(rootDir, 'deploy/trialrun/runbook.md');
|
||||
const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md');
|
||||
const nginxPaths = [
|
||||
path.join(rootDir, 'deploy/trialrun/nginx-hyb-oa.conf'),
|
||||
path.join(rootDir, 'deploy/trialrun/nginx.conf.example'),
|
||||
];
|
||||
|
||||
const requiredServices = ['server', 'worker', 'init'];
|
||||
const deploymentInjectedEnvKeys = new Set(['BUILD_SHA']);
|
||||
@@ -122,6 +128,12 @@ function fail(message, errors) {
|
||||
const configContent = readFile(configPath);
|
||||
const envExampleContent = readFile(envExamplePath);
|
||||
const composeContent = readFile(composePath);
|
||||
const runbookContent = readFile(runbookPath);
|
||||
const checklistContent = readFile(checklistPath);
|
||||
const nginxContents = nginxPaths.map((filePath) => ({
|
||||
filePath,
|
||||
content: readFile(filePath),
|
||||
}));
|
||||
|
||||
const configKeys = extractConfigEnvKeys(configContent);
|
||||
const envExampleKeys = extractEnvExampleKeys(envExampleContent);
|
||||
@@ -172,6 +184,51 @@ for (const serviceName of requiredServices) {
|
||||
}
|
||||
}
|
||||
|
||||
const serverBlock = extractServiceBlock(composeContent, 'server');
|
||||
if (!serverBlock || !/^\s{6}-\s*["']127\.0\.0\.1:3000:3000["']\s*$/m.test(serverBlock)) {
|
||||
errors.push('服务 server 的 3000 端口必须只绑定宿主回环地址');
|
||||
}
|
||||
|
||||
for (const { filePath, content } of nginxContents) {
|
||||
const relativePath = path.relative(rootDir, filePath);
|
||||
if (content.includes('$proxy_add_x_forwarded_for')) {
|
||||
errors.push(`${relativePath} 不得追加客户端提供的 X-Forwarded-For`);
|
||||
}
|
||||
|
||||
const forwardedForDirectives = [
|
||||
...content.matchAll(/proxy_set_header\s+X-Forwarded-For\s+([^;]+);/g),
|
||||
];
|
||||
if (
|
||||
forwardedForDirectives.length === 0 ||
|
||||
forwardedForDirectives.some((match) => match[1].trim() !== '$remote_addr')
|
||||
) {
|
||||
errors.push(`${relativePath} 必须用 $remote_addr 覆盖 X-Forwarded-For`);
|
||||
}
|
||||
}
|
||||
|
||||
const authChallengeEndpoints = [
|
||||
'/api/auth/forgot-password',
|
||||
'/api/auth/reset-password',
|
||||
'/api/auth/email-login/code',
|
||||
'/api/auth/email-login/verify',
|
||||
];
|
||||
for (const [name, content] of [
|
||||
['runbook.md', runbookContent],
|
||||
['checklist.md', checklistContent],
|
||||
]) {
|
||||
if (!content.includes('EXTERNAL_GATED')) {
|
||||
errors.push(`${name} 必须标记认证 challenge 边缘操作为 EXTERNAL_GATED`);
|
||||
}
|
||||
for (const endpoint of authChallengeEndpoints) {
|
||||
if (!content.includes(endpoint)) {
|
||||
errors.push(`${name} 缺少认证 challenge 边缘关闭端点 ${endpoint}`);
|
||||
}
|
||||
}
|
||||
if (!/脚本不会自动(?:修改|改)边缘/.test(content)) {
|
||||
errors.push(`${name} 必须声明部署脚本不会自动修改边缘配置`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
fail('部署环境契约校验失败', errors);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户