fix(release): secure dogfood provisioning transaction
This commit is contained in:
parent
20dd705fac
commit
9fb669c4fa
@ -104,6 +104,9 @@ migration_started=0
|
||||
database_restore_required=0
|
||||
database_restore_rc=not-run
|
||||
bootstrap_token_validated=0
|
||||
account_provision_run_id="${run_id}-dogfood"
|
||||
account_provision_cleanup_manifest="$evidence/account-provision/provision-cleanup.tsv"
|
||||
runtime_cleanup_rc=not-run
|
||||
|
||||
umask 077
|
||||
if [ "$activation_mode" = activate ]; then
|
||||
@ -130,6 +133,15 @@ delete_bootstrap_token() {
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_dogfood_provision_runtime() {
|
||||
[ -f "$account_provision_cleanup_manifest" ] || return 0
|
||||
log_step "定向清理本次 dogfood 注册/登录限流桶与 OAuth token 缓存"
|
||||
REDIS_PASSWORD="${REDIS_PASSWORD:-}" \
|
||||
"$release/deploy/provision-dogfood-users.sh" --cleanup \
|
||||
"$account_provision_cleanup_manifest" "$account_provision_run_id" \
|
||||
>"$evidence/account-provision-runtime-cleanup.log" 2>&1
|
||||
}
|
||||
|
||||
unit_name() {
|
||||
printf '%s-%s' "$1" "$2"
|
||||
}
|
||||
@ -513,6 +525,9 @@ failure_handler() {
|
||||
capture_journals "$candidate_prefix" candidate-failed
|
||||
capture_journals "$stable_prefix" stable-failed
|
||||
stop_stack "$candidate_prefix"
|
||||
cleanup_dogfood_provision_runtime
|
||||
runtime_cleanup_rc=$?
|
||||
printf 'runtime_cleanup_rc=%s\n' "$runtime_cleanup_rc" >"$evidence/account-provision-runtime-cleanup.status"
|
||||
if [ "$database_restore_required" -eq 1 ]; then
|
||||
log_step "迁移后失败,停止新栈并恢复迁移前数据库快照"
|
||||
restore_database_snapshot
|
||||
@ -541,6 +556,10 @@ failure_handler() {
|
||||
if [ "$bootstrap_token_validated" -eq 1 ]; then
|
||||
log_step "激活失败,保留 0600 bootstrap admin token 供补偿后重试或人工吊销"
|
||||
fi
|
||||
if [ "$runtime_cleanup_rc" != not-run ] && [ "$runtime_cleanup_rc" -ne 0 ]; then
|
||||
log_step "dogfood 运行态补偿失败,禁止直接重试激活"
|
||||
rc=72
|
||||
fi
|
||||
printf 'failed commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$evidence" \
|
||||
>"$release/RC_STATUS"
|
||||
chmod -R go-rwx "$evidence" "$release/RC_STATUS"
|
||||
@ -674,6 +693,7 @@ log_step "使用 bootstrap admin token 执行 40 人账号预置事务"
|
||||
account_provision_evidence="$evidence/account-provision"
|
||||
ADMIN_TOKEN_FILE="$bootstrap_admin_token_file" \
|
||||
DOGFOOD_PROVISION_ACK='PROVISION_DOGFOOD_WITH_NACOS_TRANSACTION' \
|
||||
RUN_ID="$account_provision_run_id" \
|
||||
BASE=http://127.0.0.1:48080 MYSQL_SSH_HOST='' \
|
||||
DOGFOOD_ACCOUNTS_FILE="$evidence/dogfood-accounts.tsv" \
|
||||
EVIDENCE_DIR="$account_provision_evidence" \
|
||||
@ -752,8 +772,8 @@ for username in ("dogfood_reviewer","dogfood_readonly"):
|
||||
status,body=request_json(Request(
|
||||
"http://127.0.0.1:48080/admin-api/system/auth/get-permission-info",
|
||||
method="GET",headers={"Authorization":f"Bearer {bootstrap_token}","tenant-id":"1"}))
|
||||
if status == 200 and body.get("code") == 0:
|
||||
raise RuntimeError("bootstrap admin 旧 token 仍然有效")
|
||||
if status != 401 or body.get("code") != 401:
|
||||
raise RuntimeError(f"bootstrap admin 旧 token 失效证据不严格 status={status} code={body.get('code')}")
|
||||
|
||||
flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)
|
||||
fd=os.open(output_path,flags,0o600)
|
||||
@ -780,6 +800,7 @@ printf 'active commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:
|
||||
>"$release/RC_STATUS"
|
||||
|
||||
stop_stack "$candidate_prefix"
|
||||
rm -f -- "$account_provision_cleanup_manifest"
|
||||
chmod -R go-rwx "$evidence" "$release/RC_STATUS"
|
||||
delete_bootstrap_token
|
||||
trap - ERR
|
||||
|
||||
@ -138,8 +138,12 @@ missing=[name for name in expected if name not in rows]
|
||||
if missing:
|
||||
raise SystemExit("安全测试报告缺失: "+",".join(missing))
|
||||
for name,(tests,failures,errors,skipped) in rows.items():
|
||||
if tests < 1 or failures or errors:
|
||||
raise SystemExit(f"安全测试未通过: {name} tests={tests} failures={failures} errors={errors}")
|
||||
executed=tests-skipped
|
||||
if tests < 1 or executed < 1 or failures or errors:
|
||||
raise SystemExit(
|
||||
f"安全测试未通过: {name} tests={tests} skipped={skipped} "
|
||||
f"executed={executed} failures={failures} errors={errors}"
|
||||
)
|
||||
with output.open("w",encoding="utf-8") as handle:
|
||||
handle.write("test_class\ttests\tfailures\terrors\tskipped\n")
|
||||
for name in expected:
|
||||
@ -156,15 +160,24 @@ printf '[4/7] 构建 game-admin stage\n'
|
||||
corepack prepare pnpm@9.15.4 --activate
|
||||
(cd game-admin && pnpm install --frozen-lockfile && pnpm build:stage)
|
||||
|
||||
# 前端制品可被所有内测用户读取,任何默认口令、模拟令牌或预置密钥标识都必须拒绝签发。
|
||||
# 前端制品可被所有内测用户读取,任何默认口令、测试口令、模拟令牌或预置密钥标识都必须拒绝签发。
|
||||
frontend_forbidden_patterns='admin123|test1|mock-token|mock-studio-token|bootstrap-admin\.token|DOGFOOD_PASSWORD_SEED'
|
||||
frontend_forbidden_patterns_sha256="$(printf '%s' "$frontend_forbidden_patterns" | sha256sum | awk '{print $1}')"
|
||||
{
|
||||
printf 'schema=frontend-secret-scan/1\n'
|
||||
printf 'rc_commit=%s\n' "$commit"
|
||||
printf 'build_dirs=game-studio/dist,game-admin/dist-stage\n'
|
||||
printf 'patterns=%s\n' "$frontend_forbidden_patterns"
|
||||
printf 'patterns_sha256=%s\n' "$frontend_forbidden_patterns_sha256"
|
||||
} >"$evidence/frontend-secret-scan.log"
|
||||
for build_dir in game-studio/dist game-admin/dist-stage; do
|
||||
if rg -n -i 'admin123|mock-token|bootstrap-admin\.token|DOGFOOD_PASSWORD_SEED' "$build_dir" \
|
||||
>"$evidence/frontend-secret-scan.log"; then
|
||||
if rg -n -i "$frontend_forbidden_patterns" "$build_dir" >>"$evidence/frontend-secret-scan.log"; then
|
||||
printf 'status=FAIL\n' >>"$evidence/frontend-secret-scan.log"
|
||||
printf '前端制品包含禁止发布的凭据或模拟令牌:%s\n' "$build_dir" >&2
|
||||
exit 5
|
||||
fi
|
||||
done
|
||||
printf 'FRONTEND_SECRET_SCAN_PASS\n' >"$evidence/frontend-secret-scan.log"
|
||||
printf 'status=PASS\nFRONTEND_SECRET_SCAN_PASS\n' >>"$evidence/frontend-secret-scan.log"
|
||||
chmod 600 "$evidence/frontend-secret-scan.log"
|
||||
|
||||
printf '[5/7] 准备 commit 内独立 Python 虚拟环境\n'
|
||||
@ -229,6 +242,7 @@ printf '[7/7] 记录制品与启动坐标\n'
|
||||
printf 'studio_build=PASS\n'
|
||||
printf 'admin_build=PASS\n'
|
||||
printf 'frontend_secret_scan=PASS\n'
|
||||
printf 'frontend_secret_scan_patterns_sha256=%s\n' "$frontend_forbidden_patterns_sha256"
|
||||
printf 'frontend_secret_scan_sha256=%s\n' "$(sha256sum "$evidence/frontend-secret-scan.log" | awk '{print $1}')"
|
||||
} >"$evidence/verification-summary.txt"
|
||||
|
||||
|
||||
@ -9,6 +9,16 @@ readonly CODE_REGISTER_DISABLED='1002090007'
|
||||
readonly CODE_AD_DOGFOOD_DISABLED='1111002006'
|
||||
readonly CODE_TRADE_DOGFOOD_DISABLED='1106008000'
|
||||
|
||||
MODE='provision'
|
||||
if [ "${1:-}" = '--cleanup' ]; then
|
||||
MODE='cleanup'
|
||||
CLEANUP_MANIFEST="${2:-}"
|
||||
CLEANUP_RUN_ID="${3:-}"
|
||||
elif [ -n "${1:-}" ]; then
|
||||
printf 'PROVISION_FAIL 未知参数:%s\n' "$1" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
BASE="${BASE:-http://100.64.0.7:48080}"
|
||||
TENANT="${TENANT:-1}"
|
||||
DOGFOOD_ACCOUNTS_FILE="${DOGFOOD_ACCOUNTS_FILE:-/root/game-staging/dogfood-accounts.tsv}"
|
||||
@ -23,6 +33,14 @@ VERIFY_TIMEOUT_SEC="${VERIFY_TIMEOUT_SEC:-60}"
|
||||
ADMIN_TOKEN_FILE="${ADMIN_TOKEN_FILE:-}"
|
||||
ADMIN_USERNAME="${ADMIN_USERNAME:-}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||
DOGFOOD_PROVISION_RUN_ID="${DOGFOOD_PROVISION_RUN_ID:-}"
|
||||
DOGFOOD_PROVISION_CAPABILITY="${DOGFOOD_PROVISION_CAPABILITY:-}"
|
||||
DOGFOOD_PROVISION_CLIENT_IP="${DOGFOOD_PROVISION_CLIENT_IP:-127.0.0.1}"
|
||||
DOGFOOD_CLEANUP_MANIFEST_FILE="${DOGFOOD_CLEANUP_MANIFEST_FILE:-${DOGFOOD_ACCOUNTS_FILE}.runtime-cleanup.tsv}"
|
||||
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
|
||||
REDIS_PORT="${REDIS_PORT:-16379}"
|
||||
REDIS_DATABASE="${REDIS_DATABASE:-0}"
|
||||
REDIS_CLI_BIN="${REDIS_CLI_BIN:-redis-cli}"
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
die() { printf 'PROVISION_FAIL %s\n' "$*" >&2; exit 1; }
|
||||
@ -34,8 +52,74 @@ require_env() {
|
||||
done
|
||||
}
|
||||
|
||||
require_env PROVISION_ACK DOGFOOD_PASSWORD_SEED
|
||||
cleanup_runtime_manifest() {
|
||||
local manifest="$1" expected_run_id="$2" keys_file key cleanup_trap deleted=0
|
||||
[ -f "$manifest" ] && [ ! -L "$manifest" ] || die '运行态清理清单必须是非 symlink 普通文件'
|
||||
[ "$(stat -c '%a' "$manifest" 2>/dev/null || stat -f '%Lp' "$manifest")" = 600 ] || \
|
||||
die '运行态清理清单权限必须为 0600'
|
||||
[[ "$expected_run_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die '清理 run-id 非法'
|
||||
[[ "$REDIS_PORT" =~ ^[0-9]+$ && "$REDIS_DATABASE" =~ ^[0-9]+$ ]] || die 'Redis 端口或 database 非法'
|
||||
require_env REDIS_PASSWORD
|
||||
command -v "$REDIS_CLI_BIN" >/dev/null || die "缺少 Redis CLI:$REDIS_CLI_BIN"
|
||||
keys_file="$(mktemp)"
|
||||
printf -v cleanup_trap 'rm -f -- %q' "$keys_file"
|
||||
trap "$cleanup_trap" EXIT
|
||||
python3 - "$manifest" "$expected_run_id" >"$keys_file" <<'PY'
|
||||
import os,re,stat,sys
|
||||
path,expected_run_id=sys.argv[1:]
|
||||
metadata=os.stat(path,follow_symlinks=False)
|
||||
if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600:
|
||||
raise SystemExit("运行态清理清单必须是 0600 普通文件")
|
||||
schema=None
|
||||
run_id=None
|
||||
keys=[]
|
||||
with open(path,encoding="utf-8") as handle:
|
||||
for raw in handle:
|
||||
raw=raw.rstrip("\n")
|
||||
if not raw: continue
|
||||
kind,separator,value=raw.partition(" ")
|
||||
if not separator: raise SystemExit("运行态清理清单行格式非法")
|
||||
if kind == "schema": schema=value
|
||||
elif kind == "run_id": run_id=value
|
||||
elif kind == "key": keys.append(value)
|
||||
else: raise SystemExit("运行态清理清单字段非法")
|
||||
if schema != "dogfood-provision-cleanup/1" or run_id != expected_run_id:
|
||||
raise SystemExit("运行态清理清单 schema 或 run-id 不匹配")
|
||||
rate=re.compile(
|
||||
r"passport_ip:(?:register|login):(?:hour:[0-9A-Fa-f:.]+:[0-9]{10}|day:[0-9A-Fa-f:.]+:[0-9]{8})"
|
||||
)
|
||||
token=re.compile(r"oauth2_access_token:[A-Za-z0-9_-]{16,256}")
|
||||
unique=[]
|
||||
for key in keys:
|
||||
if not (rate.fullmatch(key) or token.fullmatch(key)):
|
||||
raise SystemExit("运行态清理 key 越界")
|
||||
if key not in unique: unique.append(key)
|
||||
print(*unique,sep="\n")
|
||||
PY
|
||||
while IFS= read -r key; do
|
||||
[ -n "$key" ] || continue
|
||||
# key 经 stdin 传入,避免 OAuth token 出现在进程参数或日志。
|
||||
printf '%s' "$key" | REDISCLI_AUTH="$REDIS_PASSWORD" "$REDIS_CLI_BIN" \
|
||||
-h "$REDIS_HOST" -p "$REDIS_PORT" -n "$REDIS_DATABASE" -x DEL >/dev/null || \
|
||||
die '定向 Redis 清理失败'
|
||||
deleted=$((deleted + 1))
|
||||
done <"$keys_file"
|
||||
rm -f -- "$manifest" "$keys_file"
|
||||
trap - EXIT
|
||||
printf 'DOGFOOD_RUNTIME_CLEANUP_PASS run_id=%s keys=%s\n' "$expected_run_id" "$deleted"
|
||||
}
|
||||
|
||||
if [ "$MODE" = cleanup ]; then
|
||||
umask 077
|
||||
cleanup_runtime_manifest "$CLEANUP_MANIFEST" "$CLEANUP_RUN_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
require_env PROVISION_ACK DOGFOOD_PASSWORD_SEED DOGFOOD_PROVISION_RUN_ID DOGFOOD_PROVISION_CAPABILITY
|
||||
[ "$PROVISION_ACK" = "$EXPECTED_ACK" ] || die "PROVISION_ACK 不匹配;确认临时配置后必须精确设为 $EXPECTED_ACK"
|
||||
[[ "$DOGFOOD_PROVISION_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die 'DOGFOOD_PROVISION_RUN_ID 非法'
|
||||
[[ "$DOGFOOD_PROVISION_CAPABILITY" =~ ^[0-9a-f]{64}$ ]] || die 'DOGFOOD_PROVISION_CAPABILITY 必须是 256 bit 小写十六进制'
|
||||
[[ "$DOGFOOD_PROVISION_CLIENT_IP" =~ ^[0-9A-Fa-f:.]+$ ]] || die 'DOGFOOD_PROVISION_CLIENT_IP 非法'
|
||||
if [ -n "$ADMIN_TOKEN_FILE" ]; then
|
||||
[ -z "$ADMIN_USERNAME" ] && [ -z "$ADMIN_PASSWORD" ] || \
|
||||
die "ADMIN_TOKEN_FILE 不能与 ADMIN_USERNAME/ADMIN_PASSWORD 混用"
|
||||
@ -93,7 +177,13 @@ PY
|
||||
umask 077
|
||||
BODY_FILE="$(mktemp)"
|
||||
TMP_ACCOUNTS="$(mktemp)"
|
||||
cleanup() { rm -f "$BODY_FILE" "$TMP_ACCOUNTS"; }
|
||||
PROVISION_HEADER_FILE="$(mktemp)"
|
||||
{
|
||||
printf 'header = "X-Dogfood-Provisioning-Run-Id: %s"\n' "$DOGFOOD_PROVISION_RUN_ID"
|
||||
printf 'header = "X-Dogfood-Provisioning-Capability: %s"\n' "$DOGFOOD_PROVISION_CAPABILITY"
|
||||
} >"$PROVISION_HEADER_FILE"
|
||||
chmod 600 "$PROVISION_HEADER_FILE"
|
||||
cleanup() { rm -f "$BODY_FILE" "$TMP_ACCOUNTS" "$PROVISION_HEADER_FILE"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
LAST_HTTP=''
|
||||
@ -103,11 +193,38 @@ request() {
|
||||
local -a args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}'
|
||||
-X "$method" -H "tenant-id: $TENANT")
|
||||
[ -z "$token" ] || args+=(-H "Authorization: Bearer $token")
|
||||
[ "$path" != '/app-api/passport/password-register' ] || args+=(--config "$PROVISION_HEADER_FILE")
|
||||
[ -z "$data" ] || args+=(-H 'Content-Type: application/json' --data "$data")
|
||||
LAST_HTTP="$(curl "${args[@]}" "$BASE$path" 2>/dev/null || printf '000')"
|
||||
LAST_BODY="$(<"$BODY_FILE")"
|
||||
}
|
||||
|
||||
mkdir -p "$(dirname "$DOGFOOD_CLEANUP_MANIFEST_FILE")"
|
||||
{
|
||||
printf 'schema dogfood-provision-cleanup/1\n'
|
||||
printf 'run_id %s\n' "$DOGFOOD_PROVISION_RUN_ID"
|
||||
} >"$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
chmod 600 "$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
|
||||
record_rate_keys() {
|
||||
local scene="$1"
|
||||
printf 'key passport_ip:%s:hour:%s:%s\n' "$scene" "$DOGFOOD_PROVISION_CLIENT_IP" "$(date +%Y%m%d%H)" \
|
||||
>>"$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
printf 'key passport_ip:%s:day:%s:%s\n' "$scene" "$DOGFOOD_PROVISION_CLIENT_IP" "$(date +%Y%m%d)" \
|
||||
>>"$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
}
|
||||
|
||||
record_oauth_tokens() {
|
||||
local access_token="$1" refresh_token="$2" token
|
||||
for token in "$access_token" "$refresh_token"; do
|
||||
[ -n "$token" ] || continue
|
||||
[ "${#token}" -ge 16 ] && [ "${#token}" -le 256 ] \
|
||||
&& [[ "$token" =~ ^[A-Za-z0-9_-]+$ ]] || \
|
||||
die '后端返回的 OAuth token 格式非法,拒绝写入清理清单'
|
||||
printf 'key oauth2_access_token:%s\n' "$token" >>"$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
done
|
||||
}
|
||||
|
||||
json_value() {
|
||||
local path="$1"
|
||||
printf '%s' "$LAST_BODY" | python3 -c '
|
||||
@ -256,7 +373,7 @@ read_missing_claim_user_ids() {
|
||||
done <<<"$raw"
|
||||
}
|
||||
|
||||
log "预置目标:10 creator + 30 player;BASE host=$BASE_HOST"
|
||||
log "预置目标:10 creator + 30 player;BASE host=$BASE_HOST runId=$DOGFOOD_PROVISION_RUN_ID"
|
||||
log "运行前置:dogfood=true,临时 password-register/provisioning=true,注册与登录 IP 时/日上限均为 60;结束后恢复配置。"
|
||||
|
||||
# 临时开窗只能放行密码注册;广告和资金入口必须继续由 dogfood 后端守卫拒绝。
|
||||
@ -296,6 +413,7 @@ for index in "${!TARGET_NAMES[@]}"; do
|
||||
password="$(derive_password "$username")"
|
||||
body="$(json_body "$username" "$password" "$username")"
|
||||
|
||||
record_rate_keys register
|
||||
request '' POST /app-api/passport/password-register "$body"
|
||||
code="$(json_value code)"
|
||||
if [ "$LAST_HTTP" = '429' ] || [ "$code" = '429' ]; then
|
||||
@ -307,8 +425,10 @@ for index in "${!TARGET_NAMES[@]}"; do
|
||||
elif [ "$code" != '0' ] && [ "$code" != "$CODE_USERNAME_EXISTS" ]; then
|
||||
die "账号 $username 注册失败(code=${code:-none});若为 429,请确认临时注册 IP 时/日上限均为 60"
|
||||
fi
|
||||
[ "$code" != '0' ] || record_oauth_tokens "$(json_value data.accessToken)" "$(json_value data.refreshToken)"
|
||||
|
||||
# 新建与已存在统一使用派生密码登录;已存在但密码不匹配必须失败,不能冒充幂等。
|
||||
record_rate_keys login
|
||||
request '' POST /app-api/passport/password-login "$body"
|
||||
code="$(json_value code)"
|
||||
if [ "$LAST_HTTP" = '429' ] || [ "$code" = '429' ]; then
|
||||
@ -317,6 +437,7 @@ for index in "${!TARGET_NAMES[@]}"; do
|
||||
die "账号 $username 派生密码登录验证失败(http=$LAST_HTTP code=${code:-none});若为 429,请确认临时登录 IP 时/日上限均为 60"
|
||||
fi
|
||||
user_id="$(json_value data.userId)"
|
||||
record_oauth_tokens "$(json_value data.accessToken)" "$(json_value data.refreshToken)"
|
||||
[[ "$user_id" =~ ^[0-9]+$ ]] || die "账号 $username 登录响应缺合法 userId"
|
||||
is_target_user_id "$user_id" && die "多个目标账号返回同一 userId=$user_id,拒绝继续"
|
||||
TARGET_USER_IDS+=("$user_id")
|
||||
@ -372,5 +493,5 @@ while :; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0 FREE=$FREE_COUNT"
|
||||
log "PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0 FREE=$FREE_COUNT run_id=$DOGFOOD_PROVISION_RUN_ID"
|
||||
log "账号凭据已写入 ${DOGFOOD_ACCOUNTS_FILE}(mode=600);密码、token、new-api key 未输出。"
|
||||
|
||||
@ -37,7 +37,7 @@ SYSTEMD_RUN_BIN="${SYSTEMD_RUN_BIN:-systemd-run}"
|
||||
FLOCK_BIN="${FLOCK_BIN:-flock}"
|
||||
SLEEP_BIN="${SLEEP_BIN:-sleep}"
|
||||
RESTART_TIMEOUT_SEC="${RESTART_TIMEOUT_SEC:-180}"
|
||||
PROVISION_TTL_SEC="${PROVISION_TTL_SEC:-900}"
|
||||
PROVISION_TTL_SEC="${PROVISION_TTL_SEC:-600}"
|
||||
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
EVIDENCE_DIR="${EVIDENCE_DIR:-/root/game-staging/evidence/dogfood-provision-$RUN_ID}"
|
||||
LOCK_FILE="${LOCK_FILE:-/root/game-staging/locks/dogfood-provision.lock}"
|
||||
@ -55,6 +55,8 @@ die() { printf 'DOGFOOD_NACOS_TX_FAIL %s\n' "$*" >&2; exit 1; }
|
||||
die 'RESTART_TIMEOUT_SEC 必须是至少 30 秒的整数'
|
||||
[[ "$PROVISION_TTL_SEC" =~ ^[0-9]+$ ]] && [ "$PROVISION_TTL_SEC" -ge 60 ] || \
|
||||
die 'PROVISION_TTL_SEC 必须是至少 60 秒的整数'
|
||||
[ "$PROVISION_TTL_SEC" -le 600 ] || die 'PROVISION_TTL_SEC 不得超过后端硬上限 600 秒'
|
||||
[[ "$RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die 'RUN_ID 非法'
|
||||
[ "$MODE" = watchdog ] || [ "$DOGFOOD_PROVISION_ACK" = "$EXPECTED_ACK" ] || \
|
||||
die "DOGFOOD_PROVISION_ACK 不匹配;必须精确确认 $EXPECTED_ACK"
|
||||
[ "$MODE" = watchdog ] || [ -x "$PROVISION_BIN" ] || die "预置脚本不可执行:$PROVISION_BIN"
|
||||
@ -107,6 +109,7 @@ ORIGINAL_CONFIG="$EVIDENCE_DIR/nacos-original.yaml"
|
||||
TEMP_CONFIG="$EVIDENCE_DIR/nacos-temporary.yaml"
|
||||
ORIGINAL_STATUS_FILE="$EVIDENCE_DIR/nacos-original.status"
|
||||
SUMMARY_FILE="$EVIDENCE_DIR/transaction.status"
|
||||
PROVISION_CLEANUP_MANIFEST="$EVIDENCE_DIR/provision-cleanup.tsv"
|
||||
RECOVERY_DIR="$EVIDENCE_DIR/watchdog"
|
||||
WATCHDOG_UNIT="dogfood-provision-watchdog-${RUN_ID//[^A-Za-z0-9_-]/-}"
|
||||
restore_required=0
|
||||
@ -115,6 +118,8 @@ watchdog_armed=0
|
||||
NACOS_TOKEN=''
|
||||
LAST_HTTP=''
|
||||
LAST_BODY=''
|
||||
PROVISION_CAPABILITY=''
|
||||
PROVISION_CAPABILITY_SHA256=''
|
||||
|
||||
cleanup_body() { rm -f "$BODY_FILE" "$NACOS_PASSWORD_FILE" "$NACOS_TOKEN_FILE"; }
|
||||
|
||||
@ -248,9 +253,17 @@ restore_original() {
|
||||
nacos_login || return 1
|
||||
nacos_get "$current" || return 1
|
||||
[ "$LAST_HTTP" = 200 ] || return 1
|
||||
cmp -s "$TEMP_CONFIG" "$current" || return 1
|
||||
temporary_md5="$(nacos_md5 "$TEMP_CONFIG")"
|
||||
nacos_publish "$ORIGINAL_CONFIG" "$temporary_md5" || return 1
|
||||
if cmp -s "$TEMP_CONFIG" "$current"; then
|
||||
temporary_md5="$(nacos_md5 "$TEMP_CONFIG")"
|
||||
nacos_publish "$ORIGINAL_CONFIG" "$temporary_md5" || return 1
|
||||
log '检测到 current==TEMP,已 CAS 恢复 ORIGINAL'
|
||||
elif cmp -s "$ORIGINAL_CONFIG" "$current"; then
|
||||
# 上次恢复可能已发布原文但在重启或运行态验证时中断;重试必须继续后半程。
|
||||
log '检测到 current==ORIGINAL,跳过重复发布并继续重启与运行态验证'
|
||||
else
|
||||
log '当前 Nacos 配置既不是 TEMP 也不是 ORIGINAL,拒绝覆盖外部变更'
|
||||
return 1
|
||||
fi
|
||||
verify_nacos_exact "$ORIGINAL_CONFIG" || return 1
|
||||
restart_backend || return 1
|
||||
verify_restored_runtime || return 1
|
||||
@ -264,6 +277,7 @@ arm_watchdog() {
|
||||
printf 'schema=dogfood-provision-watchdog/1\n'
|
||||
printf 'run_id=%s\n' "$RUN_ID"
|
||||
printf 'ttl_sec=%s\n' "$PROVISION_TTL_SEC"
|
||||
printf 'capability_sha256=%s\n' "$PROVISION_CAPABILITY_SHA256"
|
||||
printf 'temporary_md5=%s\n' "$(nacos_md5 "$TEMP_CONFIG")"
|
||||
printf 'original_md5=%s\n' "$(nacos_md5 "$ORIGINAL_CONFIG")"
|
||||
} >"$RECOVERY_DIR/metadata"
|
||||
@ -313,7 +327,7 @@ if [ "$MODE" = watchdog ]; then
|
||||
fi
|
||||
|
||||
finalize() {
|
||||
local main_rc=$? restore_rc=0
|
||||
local main_rc=$? restore_rc=0 cleanup_rc=0
|
||||
[ "$finalizing" -eq 0 ] || return
|
||||
finalizing=1
|
||||
trap - EXIT INT TERM
|
||||
@ -322,6 +336,11 @@ finalize() {
|
||||
restore_original
|
||||
restore_rc=$?
|
||||
fi
|
||||
if [ "$main_rc" -ne 0 ] && [ -f "$PROVISION_CLEANUP_MANIFEST" ]; then
|
||||
DOGFOOD_PROVISION_RUN_ID="$RUN_ID" \
|
||||
"$PROVISION_BIN" --cleanup "$PROVISION_CLEANUP_MANIFEST" "$RUN_ID"
|
||||
cleanup_rc=$?
|
||||
fi
|
||||
cleanup_body
|
||||
if [ "$restore_rc" -ne 0 ]; then
|
||||
printf 'restore_failed main_rc=%s restore_rc=%s evidence=%s\n' "$main_rc" "$restore_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE"
|
||||
@ -329,6 +348,13 @@ finalize() {
|
||||
printf 'DOGFOOD_NACOS_TX_RESTORE_FAIL evidence=%s\n' "$EVIDENCE_DIR" >&2
|
||||
exit 70
|
||||
fi
|
||||
if [ "$cleanup_rc" -ne 0 ]; then
|
||||
printf 'compensation_failed main_rc=%s cleanup_rc=%s evidence=%s\n' \
|
||||
"$main_rc" "$cleanup_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE"
|
||||
chmod 600 "$SUMMARY_FILE"
|
||||
printf 'DOGFOOD_NACOS_TX_COMPENSATION_FAIL evidence=%s\n' "$EVIDENCE_DIR" >&2
|
||||
exit 71
|
||||
fi
|
||||
disarm_watchdog
|
||||
if [ "$main_rc" -ne 0 ]; then
|
||||
printf 'provision_failed rc=%s restored=true evidence=%s\n' "$main_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE"
|
||||
@ -351,8 +377,20 @@ printf '%s\n' "$LAST_HTTP" >"$ORIGINAL_STATUS_FILE"
|
||||
chmod 600 "$ORIGINAL_STATUS_FILE"
|
||||
[ "$LAST_HTTP" = 200 ] || die 'Nacos dataId 不存在,无法执行内容 CAS;拒绝创建临时配置'
|
||||
|
||||
# 每个事务生成独立 256-bit capability;原文只驻留当前进程与子进程环境,Nacos/证据只保存 SHA-256。
|
||||
read -r PROVISION_CAPABILITY PROVISION_CAPABILITY_SHA256 < <(python3 - <<'PY'
|
||||
import hashlib,secrets
|
||||
capability=secrets.token_hex(32)
|
||||
print(capability,hashlib.sha256(capability.encode()).hexdigest())
|
||||
PY
|
||||
)
|
||||
[[ "$PROVISION_CAPABILITY" =~ ^[0-9a-f]{64}$ && "$PROVISION_CAPABILITY_SHA256" =~ ^[0-9a-f]{64}$ ]] || \
|
||||
die '无法生成高熵 provisioning capability'
|
||||
|
||||
# 在原 YAML 上只覆盖预置窗口所需键;原文始终单独留证并在 EXIT trap 逐字恢复。
|
||||
PROVISION_TTL_SEC="$PROVISION_TTL_SEC" python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY'
|
||||
RUN_ID="$RUN_ID" PROVISION_TTL_SEC="$PROVISION_TTL_SEC" \
|
||||
PROVISION_CAPABILITY_SHA256="$PROVISION_CAPABILITY_SHA256" \
|
||||
python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY'
|
||||
import datetime,os,sys,yaml
|
||||
source,target=sys.argv[1:]
|
||||
with open(source,encoding="utf-8") as f:
|
||||
@ -363,9 +401,16 @@ if not isinstance(doc,dict):
|
||||
for key in (
|
||||
"huijing.dogfood.enabled",
|
||||
"huijing.dogfood.provisioning-enabled",
|
||||
"huijing.dogfood.provisioning-issued-at",
|
||||
"huijing.dogfood.provisioning-expires-at",
|
||||
"huijing.dogfood.provisioning-run-id",
|
||||
"huijing.dogfood.provisioning-capability-sha256",
|
||||
"wanxiang.passport.password-register-enabled",
|
||||
"wanxiang.passport.password-login-enabled",
|
||||
"wanxiang.passport.register-ip-limit-per-hour",
|
||||
"wanxiang.passport.register-ip-limit-per-day",
|
||||
"wanxiang.passport.login-ip-limit-per-hour",
|
||||
"wanxiang.passport.login-ip-limit-per-day",
|
||||
):
|
||||
doc.pop(key,None)
|
||||
huijing=doc.setdefault("huijing",{})
|
||||
@ -375,13 +420,21 @@ if not isinstance(dogfood,dict): raise SystemExit("huijing.dogfood 必须是 obj
|
||||
dogfood["enabled"]=True
|
||||
dogfood["provisioning-enabled"]=True
|
||||
dogfood["provisioning-run-id"]=os.environ["RUN_ID"]
|
||||
expires_at=datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(seconds=int(os.environ["PROVISION_TTL_SEC"]))
|
||||
dogfood["provisioning-capability-sha256"]=os.environ["PROVISION_CAPABILITY_SHA256"]
|
||||
issued_at=datetime.datetime.now(datetime.timezone.utc)
|
||||
expires_at=issued_at+datetime.timedelta(seconds=int(os.environ["PROVISION_TTL_SEC"]))
|
||||
dogfood["provisioning-issued-at"]=issued_at.isoformat().replace("+00:00","Z")
|
||||
dogfood["provisioning-expires-at"]=expires_at.isoformat().replace("+00:00","Z")
|
||||
wanxiang=doc.setdefault("wanxiang",{})
|
||||
if not isinstance(wanxiang,dict): raise SystemExit("wanxiang 必须是 object")
|
||||
passport=wanxiang.setdefault("passport",{})
|
||||
if not isinstance(passport,dict): raise SystemExit("wanxiang.passport 必须是 object")
|
||||
passport["password-register-enabled"]=True
|
||||
passport["password-login-enabled"]=True
|
||||
passport["register-ip-limit-per-hour"]=60
|
||||
passport["register-ip-limit-per-day"]=60
|
||||
passport["login-ip-limit-per-hour"]=60
|
||||
passport["login-ip-limit-per-day"]=60
|
||||
with open(target,"w",encoding="utf-8") as f:
|
||||
yaml.safe_dump(doc,f,allow_unicode=True,sort_keys=False)
|
||||
PY
|
||||
@ -398,6 +451,9 @@ verify_temporary_runtime || die '窄 provisioning 开关未生效或安全守卫
|
||||
log '临时配置已生效,执行既有账号预置脚本'
|
||||
env -u ADMIN_USERNAME -u ADMIN_PASSWORD \
|
||||
PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PROVISIONING_MODE=narrow \
|
||||
RUN_ID="$RUN_ID" \
|
||||
DOGFOOD_PROVISION_RUN_ID="$RUN_ID" DOGFOOD_PROVISION_CAPABILITY="$PROVISION_CAPABILITY" \
|
||||
DOGFOOD_PROVISION_CLIENT_IP='127.0.0.1' DOGFOOD_CLEANUP_MANIFEST_FILE="$PROVISION_CLEANUP_MANIFEST" \
|
||||
ADMIN_TOKEN_FILE="$ADMIN_TOKEN_FILE" "$PROVISION_BIN" >"$EVIDENCE_DIR/provision.log" 2>&1
|
||||
chmod 600 "$EVIDENCE_DIR/provision.log"
|
||||
log '账号预置脚本完成,退出时恢复原配置'
|
||||
|
||||
@ -117,25 +117,61 @@ SH
|
||||
cat >"$TMP_DIR/fake-provision" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ "${1:-}" = '--cleanup' ]; then
|
||||
[ -f "${2:-}" ]
|
||||
[ "${3:-}" = "$(sed -n 's/^run_id[[:space:]]//p' "$2")" ]
|
||||
printf 'register=0 login=0 tokens=0\n' >"$MOCK_RUNTIME_STATE"
|
||||
rm -f -- "$2"
|
||||
printf 'DOGFOOD_RUNTIME_CLEANUP_PASS run_id=%s keys=6\n' "$3"
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<'PY'
|
||||
import os,yaml
|
||||
import datetime,hashlib,os,re,yaml
|
||||
p=os.environ["MOCK_NACOS_CONFIG"]
|
||||
d=yaml.safe_load(open(p,encoding="utf-8"))
|
||||
assert d["huijing"]["dogfood"]["enabled"] is True
|
||||
assert d["huijing"]["dogfood"]["provisioning-enabled"] is True
|
||||
assert d["huijing"]["dogfood"]["provisioning-expires-at"]
|
||||
assert d["huijing"]["dogfood"]["provisioning-run-id"] == os.environ["RUN_ID"]
|
||||
dogfood=d["huijing"]["dogfood"]
|
||||
assert dogfood["enabled"] is True
|
||||
assert dogfood["provisioning-enabled"] is True
|
||||
issued=datetime.datetime.fromisoformat(dogfood["provisioning-issued-at"].replace("Z","+00:00"))
|
||||
expires=datetime.datetime.fromisoformat(dogfood["provisioning-expires-at"].replace("Z","+00:00"))
|
||||
assert 60 <= (expires-issued).total_seconds() <= 600
|
||||
assert dogfood["provisioning-run-id"] == os.environ["RUN_ID"]
|
||||
capability=os.environ["DOGFOOD_PROVISION_CAPABILITY"]
|
||||
assert re.fullmatch(r"[0-9a-f]{64}",capability)
|
||||
assert os.environ["DOGFOOD_PROVISION_RUN_ID"] == os.environ["RUN_ID"]
|
||||
assert dogfood["provisioning-capability-sha256"] == hashlib.sha256(capability.encode()).hexdigest()
|
||||
assert capability not in open(p,encoding="utf-8").read()
|
||||
ppt=d["wanxiang"]["passport"]
|
||||
assert ppt["password-register-enabled"] is True
|
||||
assert ppt["password-login-enabled"] is True
|
||||
assert ppt["register-ip-limit-per-hour"] == 60
|
||||
assert ppt["register-ip-limit-per-day"] == 60
|
||||
assert ppt["login-ip-limit-per-hour"] == 60
|
||||
assert ppt["login-ip-limit-per-day"] == 60
|
||||
PY
|
||||
[ "${PROVISION_ACK:-}" = 'PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' ]
|
||||
[ "${DOGFOOD_PROVISIONING_MODE:-}" = 'narrow' ]
|
||||
[ -f "${ADMIN_TOKEN_FILE:-}" ] && [ ! -L "$ADMIN_TOKEN_FILE" ]
|
||||
[ "$(stat -c '%a' "$ADMIN_TOKEN_FILE" 2>/dev/null || stat -f '%Lp' "$ADMIN_TOKEN_FILE")" = 600 ]
|
||||
[ -z "${ADMIN_PASSWORD:-}" ]
|
||||
[ "${MOCK_REQUIRE_CLEAN_STATE:-0}" != 1 ] || \
|
||||
grep -q '^register=0 login=0 tokens=0$' "$MOCK_RUNTIME_STATE"
|
||||
printf 'MOCK_PROVISION_CALLED\n'
|
||||
[ "${MOCK_PROVISION_FAIL:-0}" != 1 ] || exit 23
|
||||
if [ "${MOCK_PROVISION_FAIL:-0}" = 1 ]; then
|
||||
{
|
||||
printf 'schema dogfood-provision-cleanup/1\n'
|
||||
printf 'run_id %s\n' "$DOGFOOD_PROVISION_RUN_ID"
|
||||
printf 'key passport_ip:register:hour:127.0.0.1:2026072312\n'
|
||||
printf 'key passport_ip:register:day:127.0.0.1:20260723\n'
|
||||
printf 'key passport_ip:login:hour:127.0.0.1:2026072312\n'
|
||||
printf 'key passport_ip:login:day:127.0.0.1:20260723\n'
|
||||
printf 'key oauth2_access_token:access-token-123456\n'
|
||||
printf 'key oauth2_access_token:refresh-token-123456\n'
|
||||
} >"$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
chmod 600 "$DOGFOOD_CLEANUP_MANIFEST_FILE"
|
||||
printf 'register=25 login=25 tokens=2\n' >"$MOCK_RUNTIME_STATE"
|
||||
exit 23
|
||||
fi
|
||||
SH
|
||||
chmod +x "$TMP_DIR/fake-curl" "$TMP_DIR/fake-systemctl" "$TMP_DIR/fake-systemd-run" \
|
||||
"$TMP_DIR/fake-flock" "$TMP_DIR/fake-provision"
|
||||
@ -150,10 +186,24 @@ run_tx() {
|
||||
PROVISION_BIN="$TMP_DIR/fake-provision" MOCK_NACOS_CONFIG="$TMP_DIR/nacos.yaml" \
|
||||
MOCK_PUBLISH_COUNT="$TMP_DIR/publish-count" MOCK_SYSTEMCTL_LOG="$TMP_DIR/systemctl.log" \
|
||||
MOCK_SYSTEMD_RUN_LOG="$TMP_DIR/systemd-run.log" LOCK_FILE="$TMP_DIR/provision.lock" \
|
||||
MOCK_RUNTIME_STATE="$TMP_DIR/runtime-state" \
|
||||
EVIDENCE_DIR="$evidence" RUN_ID="$(basename "$evidence")" RESTART_TIMEOUT_SEC=30 \
|
||||
bash "$SCRIPT"
|
||||
}
|
||||
|
||||
run_tx_without_run_id() {
|
||||
local evidence="$1"
|
||||
DOGFOOD_PROVISION_ACK='PROVISION_DOGFOOD_WITH_NACOS_TRANSACTION' \
|
||||
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" NACOS_PASSWORD='test-secret-must-not-leak' \
|
||||
CURL_BIN="$TMP_DIR/fake-curl" SYSTEMCTL_BIN="$TMP_DIR/fake-systemctl" \
|
||||
SYSTEMD_RUN_BIN="$TMP_DIR/fake-systemd-run" FLOCK_BIN="$TMP_DIR/fake-flock" \
|
||||
PROVISION_BIN="$TMP_DIR/fake-provision" MOCK_NACOS_CONFIG="$TMP_DIR/nacos.yaml" \
|
||||
MOCK_PUBLISH_COUNT="$TMP_DIR/publish-count" MOCK_SYSTEMCTL_LOG="$TMP_DIR/systemctl.log" \
|
||||
MOCK_SYSTEMD_RUN_LOG="$TMP_DIR/systemd-run.log" MOCK_RUNTIME_STATE="$TMP_DIR/runtime-state" \
|
||||
LOCK_FILE="$TMP_DIR/provision.lock" EVIDENCE_DIR="$evidence" RESTART_TIMEOUT_SEC=30 \
|
||||
env -u RUN_ID bash "$SCRIPT"
|
||||
}
|
||||
|
||||
run_watchdog() {
|
||||
local recovery="$1"
|
||||
NACOS_PASSWORD='test-secret-must-not-leak' CURL_BIN="$TMP_DIR/fake-curl" \
|
||||
@ -166,13 +216,21 @@ run_watchdog() {
|
||||
bash -n "$SCRIPT"
|
||||
printf 'bootstrap-token-from-file\n' >"$TMP_DIR/bootstrap-admin.token"
|
||||
chmod 600 "$TMP_DIR/bootstrap-admin.token"
|
||||
printf 'register=0 login=0 tokens=0\n' >"$TMP_DIR/runtime-state"
|
||||
rg -q 'flock' "$SCRIPT" || fail "缺单实例锁"
|
||||
rg -q 'casMd5' "$SCRIPT" || fail "Nacos 发布/恢复缺内容 CAS"
|
||||
rg -q 'provisioning-expires-at' "$SCRIPT" || fail "窄 provisioning 开关缺 TTL"
|
||||
rg -q 'provisioning-run-id' "$SCRIPT" || fail "窄 provisioning 开关缺运行版本令牌"
|
||||
rg -q 'provisioning-capability-sha256' "$SCRIPT" || fail "Nacos 临时配置缺 capability SHA-256"
|
||||
rg -q 'X-Dogfood-Provisioning-Capability' "$ROOT_DIR/deploy/provision-dogfood-users.sh" || \
|
||||
fail "预置客户端未使用固定 capability header"
|
||||
rg -q -- '--watchdog' "$SCRIPT" || fail "缺 SIGKILL 独立 watchdog"
|
||||
! rg -q 'dogfood.*enabled.*False' "$SCRIPT" || fail "事务仍会关闭 dogfood 总守卫"
|
||||
rg -q 'password-register-enabled.*True' "$SCRIPT" || fail "事务未临时打开密码注册"
|
||||
for limit_key in register-ip-limit-per-hour register-ip-limit-per-day \
|
||||
login-ip-limit-per-hour login-ip-limit-per-day; do
|
||||
rg -q "$limit_key" "$SCRIPT" || fail "临时配置未覆盖限额:$limit_key"
|
||||
done
|
||||
! rg -q 'provisioning charge still disabled\|provisioning withdraw still disabled' "$SCRIPT" || \
|
||||
fail "Nacos 临时窗口仍使用无效 mock token 探测受保护资金端点"
|
||||
|
||||
@ -212,6 +270,42 @@ rg -q '^pass restored=true ' "$TMP_DIR/evidence-existing/transaction.status" ||
|
||||
[ "$(mode_of "$TMP_DIR/evidence-existing/nacos-temporary.yaml")" = 600 ] || fail "临时配置证据权限不是 600"
|
||||
rg -q -- '--watchdog' "$TMP_DIR/systemd-run.log" || fail "未注册独立 watchdog"
|
||||
rg -q 'Restart=on-failure' "$TMP_DIR/systemd-run.log" || fail "watchdog 抢锁失败后不会自动重试"
|
||||
rg -q '^run_id=evidence-existing$' "$TMP_DIR/evidence-existing/watchdog/metadata" || \
|
||||
fail "恢复证据未绑定 run-id"
|
||||
|
||||
# 恢复已发布 ORIGINAL、但重启或运行态验证尚未完成时,watchdog 重试必须继续完成后半程。
|
||||
reentrant_recovery="$TMP_DIR/evidence-existing/watchdog"
|
||||
cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml"
|
||||
: >"$reentrant_recovery/armed"
|
||||
chmod 600 "$reentrant_recovery/armed"
|
||||
rm -f "$TMP_DIR/publish-count"
|
||||
run_watchdog "$reentrant_recovery" >"$TMP_DIR/watchdog-original-retry.log"
|
||||
cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "恢复重入改变了 ORIGINAL 配置"
|
||||
rg -q '^WATCHDOG_RESTORE=PASS ' "$reentrant_recovery/watchdog.status" || \
|
||||
fail "current==ORIGINAL 的恢复重入未收口"
|
||||
[ ! -e "$reentrant_recovery/armed" ] || fail "恢复重入成功后仍保留 armed"
|
||||
|
||||
# 调用方不传 RUN_ID 时也必须把脚本生成值显式传入 Python、Nacos 与 watchdog 证据。
|
||||
cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml"
|
||||
rm -f "$TMP_DIR/publish-count"
|
||||
run_tx_without_run_id "$TMP_DIR/evidence-default-run-id" >"$TMP_DIR/default-run-id.log"
|
||||
generated_run_id="$(sed -n 's/^run_id=//p' "$TMP_DIR/evidence-default-run-id/watchdog/metadata")"
|
||||
[[ "$generated_run_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || \
|
||||
fail "生产默认路径未生成合法 run-id"
|
||||
python3 - "$TMP_DIR/evidence-default-run-id/nacos-temporary.yaml" "$generated_run_id" <<'PY'
|
||||
import sys,yaml
|
||||
doc=yaml.safe_load(open(sys.argv[1],encoding="utf-8"))
|
||||
assert doc["huijing"]["dogfood"]["provisioning-run-id"] == sys.argv[2]
|
||||
PY
|
||||
|
||||
# 超过后端硬上限的 TTL 必须在登录 Nacos 前 fail-closed,不能依赖后端稍后拒绝。
|
||||
rm -f "$TMP_DIR/publish-count"
|
||||
set +e
|
||||
PROVISION_TTL_SEC=601 run_tx "$TMP_DIR/evidence-overlong-ttl" >"$TMP_DIR/overlong-ttl.log" 2>&1
|
||||
rc=$?
|
||||
set -e
|
||||
[ "$rc" -ne 0 ] || fail "超过十分钟的 TTL 被放行"
|
||||
[ ! -e "$TMP_DIR/publish-count" ] || fail "超长 TTL 拒绝前已修改 Nacos"
|
||||
|
||||
# watchdog 注册失败必须发生在 Nacos 写入前,且不能留下假的 armed 状态。
|
||||
cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml"
|
||||
@ -240,6 +334,17 @@ set -e
|
||||
cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "provision 失败后未恢复原配置"
|
||||
rg -q '^provision_failed rc=23 restored=true ' "$TMP_DIR/evidence-provision-fail/transaction.status" || \
|
||||
fail "provision 失败证据状态错误"
|
||||
grep -q '^register=0 login=0 tokens=0$' "$TMP_DIR/runtime-state" || \
|
||||
fail "中途失败后未清理本次限流桶与 OAuth token 缓存"
|
||||
[ ! -e "$TMP_DIR/evidence-provision-fail/provision-cleanup.tsv" ] || \
|
||||
fail "运行态清理成功后仍保留敏感 token 清单"
|
||||
|
||||
# 清理后用同一来源 IP 的同一时/日桶立即重试,不能被上次半程计数或 token 缓存阻断。
|
||||
cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml"
|
||||
rm -f "$TMP_DIR/publish-count"
|
||||
MOCK_REQUIRE_CLEAN_STATE=1 run_tx "$TMP_DIR/evidence-retry-same-bucket" >"$TMP_DIR/retry-same-bucket.log"
|
||||
rg -q '^pass restored=true ' "$TMP_DIR/evidence-retry-same-bucket/transaction.status" || \
|
||||
fail "恢复后同桶重试未收口"
|
||||
|
||||
# 场景四:恢复 DELETE 失败必须硬失败 70,不能把 provision 成功当事务成功。
|
||||
cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml"
|
||||
|
||||
@ -34,6 +34,8 @@ from socketserver import TCPServer
|
||||
state_file = os.environ["DOGFOOD_FAKE_STATE"]
|
||||
port_file = os.environ["DOGFOOD_FAKE_PORT_FILE"]
|
||||
request_log = os.environ["DOGFOOD_FAKE_REQUEST_LOG"]
|
||||
provisioning_run_id = os.environ["DOGFOOD_FAKE_PROVISIONING_RUN_ID"]
|
||||
provisioning_capability = os.environ["DOGFOOD_FAKE_PROVISIONING_CAPABILITY"]
|
||||
|
||||
|
||||
def load_state():
|
||||
@ -121,6 +123,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.response({"code": 0, "data": True})
|
||||
return
|
||||
if self.path == "/app-api/passport/password-register":
|
||||
if (
|
||||
self.headers.get("X-Dogfood-Provisioning-Run-Id") != provisioning_run_id
|
||||
or self.headers.get("X-Dogfood-Provisioning-Capability") != provisioning_capability
|
||||
):
|
||||
self.response({"code": 1002090007, "msg": "预置凭据错误"})
|
||||
return
|
||||
username = body.get("username", "")
|
||||
if username in state["accounts"]:
|
||||
self.response({"code": 1002090004, "msg": "用户名已存在"})
|
||||
@ -251,6 +259,8 @@ chmod +x "$TMP_DIR/ssh"
|
||||
DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" \
|
||||
DOGFOOD_FAKE_PORT_FILE="$TMP_DIR/port" \
|
||||
DOGFOOD_FAKE_REQUEST_LOG="$TMP_DIR/requests.log" \
|
||||
DOGFOOD_FAKE_PROVISIONING_RUN_ID='dogfood-test-run-001' \
|
||||
DOGFOOD_FAKE_PROVISIONING_CAPABILITY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
|
||||
python3 "$TMP_DIR/fake-server.py" >"$TMP_DIR/server.log" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
for _ in $(seq 1 300); do
|
||||
@ -266,6 +276,8 @@ run_provision() {
|
||||
local expected="${3:-pass}"
|
||||
if ! PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \
|
||||
DOGFOOD_PASSWORD_SEED='test-seed-must-not-appear' \
|
||||
DOGFOOD_PROVISION_RUN_ID='dogfood-test-run-001' \
|
||||
DOGFOOD_PROVISION_CAPABILITY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
|
||||
ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" \
|
||||
BASE="http://127.0.0.1:$PORT" \
|
||||
MYSQL_BIN="$TMP_DIR/mysql" \
|
||||
@ -359,6 +371,8 @@ mv "$TMP_DIR/requests.log" "$TMP_DIR/token-requests.log"
|
||||
: >"$TMP_DIR/requests.log"
|
||||
PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \
|
||||
DOGFOOD_PASSWORD_SEED='test-seed-must-not-appear' ADMIN_USERNAME='admin' \
|
||||
DOGFOOD_PROVISION_RUN_ID='dogfood-test-run-001' \
|
||||
DOGFOOD_PROVISION_CAPABILITY='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' \
|
||||
ADMIN_PASSWORD='admin-password-must-not-appear' BASE="http://127.0.0.1:$PORT" \
|
||||
MYSQL_BIN="$TMP_DIR/mysql" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \
|
||||
DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" DOGFOOD_ACCOUNTS_FILE="$TMP_DIR/manual-accounts.tsv" \
|
||||
@ -404,4 +418,52 @@ if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_
|
||||
fail "非法 BASE host 未被拒绝"
|
||||
fi
|
||||
|
||||
printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n'
|
||||
# 失败补偿只按本轮 0600 清单删除四个精确限流桶和本轮 OAuth access/refresh 缓存 key。
|
||||
cat >"$TMP_DIR/redis-cli" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
[ "${REDISCLI_AUTH:-}" = 'fake-redis-password' ]
|
||||
[ "$*" = '-h 127.0.0.1 -p 16379 -n 0 -x DEL' ]
|
||||
key="$(cat)"
|
||||
printf '%s\n' "$key" >>"$DOGFOOD_FAKE_REDIS_DELETES"
|
||||
printf '1\n'
|
||||
SH
|
||||
chmod +x "$TMP_DIR/redis-cli"
|
||||
cat >"$TMP_DIR/runtime-cleanup.tsv" <<'EOF'
|
||||
schema dogfood-provision-cleanup/1
|
||||
run_id cleanup-run-001
|
||||
key passport_ip:register:hour:127.0.0.1:2026072312
|
||||
key passport_ip:register:day:127.0.0.1:20260723
|
||||
key passport_ip:login:hour:127.0.0.1:2026072312
|
||||
key passport_ip:login:day:127.0.0.1:20260723
|
||||
key oauth2_access_token:accessToken0123456789abcdef
|
||||
key oauth2_access_token:refreshToken0123456789abcdef
|
||||
EOF
|
||||
chmod 600 "$TMP_DIR/runtime-cleanup.tsv"
|
||||
REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \
|
||||
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/redis-deletes.log" \
|
||||
"$SCRIPT" --cleanup "$TMP_DIR/runtime-cleanup.tsv" cleanup-run-001 \
|
||||
>"$TMP_DIR/runtime-cleanup.log"
|
||||
[ "$(sort -u "$TMP_DIR/redis-deletes.log" | wc -l | tr -d ' ')" = 6 ] || \
|
||||
fail "运行态补偿未精确删除 4 个限流桶与 2 个 token key"
|
||||
[ ! -e "$TMP_DIR/runtime-cleanup.tsv" ] || fail "补偿成功后未删除敏感清单"
|
||||
rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-001 keys=6$' \
|
||||
"$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿缺可审计结果"
|
||||
! rg -q 'accessToken0123456789abcdef|refreshToken0123456789abcdef' \
|
||||
"$TMP_DIR/runtime-cleanup.log" || fail "运行态补偿日志泄漏 token"
|
||||
|
||||
# 预检阶段尚无 Redis 副作用时,只有 schema/run-id 的清单应幂等收口为 keys=0。
|
||||
cat >"$TMP_DIR/empty-runtime-cleanup.tsv" <<'EOF'
|
||||
schema dogfood-provision-cleanup/1
|
||||
run_id cleanup-run-empty
|
||||
EOF
|
||||
chmod 600 "$TMP_DIR/empty-runtime-cleanup.tsv"
|
||||
REDIS_PASSWORD='fake-redis-password' REDIS_CLI_BIN="$TMP_DIR/redis-cli" \
|
||||
DOGFOOD_FAKE_REDIS_DELETES="$TMP_DIR/redis-deletes.log" \
|
||||
"$SCRIPT" --cleanup "$TMP_DIR/empty-runtime-cleanup.tsv" cleanup-run-empty \
|
||||
>"$TMP_DIR/empty-runtime-cleanup.log"
|
||||
[ ! -e "$TMP_DIR/empty-runtime-cleanup.tsv" ] || fail "空运行态清理清单未幂等删除"
|
||||
rg -q '^DOGFOOD_RUNTIME_CLEANUP_PASS run_id=cleanup-run-empty keys=0$' \
|
||||
"$TMP_DIR/empty-runtime-cleanup.log" || fail "空运行态清理清单未幂等收口"
|
||||
|
||||
printf 'TEST_PASS provision-dogfood-users 缺失 claim 补偿、运行态清理、40/40 单 claim、越权拒绝、幂等与脱敏校验通过\n'
|
||||
|
||||
@ -26,6 +26,48 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 执行 prepare 内嵌的真实 Surefire XML 校验器,覆盖全跳过拒绝与部分跳过放行边界。
|
||||
test_surefire_report_guard() {
|
||||
local surefire_verifier="$TMP_DIR/verify-security-java-reports.py"
|
||||
local surefire_fixture="$TMP_DIR/surefire-fixture"
|
||||
local surefire_report_dir="$surefire_fixture/game-cloud/security/target/surefire-reports"
|
||||
|
||||
awk '
|
||||
/^import pathlib,sys,xml.etree.ElementTree as ET$/ { capture=1 }
|
||||
capture && /^PY$/ { exit }
|
||||
capture { print }
|
||||
' "$PREPARE" >"$surefire_verifier"
|
||||
rg -q 'surefire-reports/TEST-\*\.xml' "$surefire_verifier" || fail "无法提取 prepare 的 Surefire XML 校验器"
|
||||
|
||||
mkdir -p "$surefire_report_dir" "$surefire_fixture/evidence"
|
||||
cat >"$surefire_report_dir/TEST-com.example.AllSkippedSecurityTest.xml" <<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuite name="com.example.AllSkippedSecurityTest" tests="1" failures="0" errors="0" skipped="1"/>
|
||||
XML
|
||||
if (cd "$surefire_fixture" && python3 "$surefire_verifier" \
|
||||
"$surefire_fixture/evidence/security-java-tests-summary.tsv" AllSkippedSecurityTest) \
|
||||
>"$TMP_DIR/surefire-all-skipped.log" 2>&1; then
|
||||
fail "Surefire 校验器错误放行 tests=1、skipped=1 的全跳过测试类"
|
||||
fi
|
||||
rg -q 'executed=0' "$TMP_DIR/surefire-all-skipped.log" || fail "全跳过拒绝原因未记录实际执行数"
|
||||
|
||||
# 边界正例:同类存在一个 skipped,但仍至少执行一个测试时必须允许并产出摘要。
|
||||
cat >"$surefire_report_dir/TEST-com.example.AllSkippedSecurityTest.xml" <<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuite name="com.example.AllSkippedSecurityTest" tests="2" failures="0" errors="0" skipped="1"/>
|
||||
XML
|
||||
(cd "$surefire_fixture" && python3 "$surefire_verifier" \
|
||||
"$surefire_fixture/evidence/security-java-tests-summary.tsv" AllSkippedSecurityTest)
|
||||
rg -q $'^AllSkippedSecurityTest\\t2\\t0\\t0\\t1$' \
|
||||
"$surefire_fixture/evidence/security-java-tests-summary.tsv" || fail "部分跳过但有执行的测试类未生成正确摘要"
|
||||
}
|
||||
|
||||
if [ "${STAGING_RC_TEST_CASE:-}" = "surefire-report-guard" ]; then
|
||||
test_surefire_report_guard
|
||||
printf 'TEST_PASS Surefire XML 全跳过拒绝与部分跳过边界通过\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bash -n "$ACTIVATE" "$PREPARE" "$SMOKE" "$STORAGE_AUDIT" "$RESTORE_VERIFY"
|
||||
rg -q 'docker exec -i .*MYSQL_CONTAINER' "$STORAGE_AUDIT" || fail "存储审计未把 SQL stdin 接入 MySQL 容器"
|
||||
|
||||
@ -78,6 +120,8 @@ rg -q '请先运行受控 new-api import' "$TMP_DIR/remote-script.sh" || \
|
||||
fail "额度池不足没有给出受控 new-api import 指引"
|
||||
rg -q -- '--ack ROTATE_DOGFOOD_REVIEWERS' "$TMP_DIR/remote-script.sh" || fail "审核账号轮换 ACK 不精确"
|
||||
rg -q 'reviewer-runtime-verification\.json' "$TMP_DIR/remote-script.sh" || fail "缺 reviewer/readonly 登录与旧 token 失效运行态证据"
|
||||
rg -Fq 'if status != 401 or body.get("code") != 401:' "$TMP_DIR/remote-script.sh" || \
|
||||
fail "bootstrap token 失效验收未严格要求 HTTP 401 且业务 code=401,非 401 可能被误判为失效"
|
||||
rg -q 'rm -f.*bootstrap_admin_token_file' "$TMP_DIR/remote-script.sh" || fail "轮换成功后未删除 bootstrap token 文件"
|
||||
! rg -q 'admin123' "$ACTIVATE" || fail "激活脚本写入了默认公开密码"
|
||||
for required_marker in actor_prompt_eval judge_prompt_eval game_content_gold live_prompt full_gate fresh_25 historical_11 production_shadow_20 runtime_eligible; do
|
||||
@ -110,6 +154,22 @@ token_delete_line="$(rg -n '^delete_bootstrap_token$' "$TMP_DIR/remote-script.sh
|
||||
failure_handler_body="$(sed -n '/^failure_handler()/,/^}/p' "$TMP_DIR/remote-script.sh")"
|
||||
! printf '%s\n' "$failure_handler_body" | rg -q '^ delete_bootstrap_token' || \
|
||||
fail "激活失败路径删除了补偿后重试所需的 bootstrap token"
|
||||
printf '%s\n' "$failure_handler_body" | rg -q 'cleanup_dogfood_provision_runtime' || \
|
||||
fail "激活失败补偿未定向清理本次限流桶与 OAuth token 缓存"
|
||||
! printf '%s\n' "$failure_handler_body" | rg -q 'exit 72' || \
|
||||
fail "运行态 cleanup 失败仍直接 exit 72,跳过最终证据落盘"
|
||||
cleanup_rc_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'rc=72' | cut -d: -f1)"
|
||||
failure_status_line="$(printf '%s\n' "$failure_handler_body" | rg -n "printf 'failed commit=" | cut -d: -f1)"
|
||||
failure_chmod_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'chmod -R go-rwx' | cut -d: -f1)"
|
||||
failure_exit_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'exit "\$rc"' | cut -d: -f1)"
|
||||
[ "$cleanup_rc_line" -lt "$failure_status_line" ] \
|
||||
&& [ "$failure_status_line" -lt "$failure_chmod_line" ] \
|
||||
&& [ "$failure_chmod_line" -lt "$failure_exit_line" ] || \
|
||||
fail "cleanup rc=72 后未按 RC_STATUS→chmod→统一 exit 顺序完整落证"
|
||||
cleanup_function_body="$(sed -n '/^cleanup_dogfood_provision_runtime()/,/^}/p' "$TMP_DIR/remote-script.sh")"
|
||||
printf '%s\n' "$cleanup_function_body" | rg -q -- '--cleanup' || fail "激活补偿未进入定向清理模式"
|
||||
printf '%s\n' "$cleanup_function_body" | rg -q 'account_provision_cleanup_manifest.*account_provision_run_id' || \
|
||||
fail "激活补偿未用本次 run-id 约束清理清单"
|
||||
if rg -q '/root/games-development-ai' "$TMP_DIR/remote-script.sh"; then
|
||||
fail "激活脚本引用了远端脏旧仓"
|
||||
fi
|
||||
@ -174,8 +234,24 @@ rg -q 'security_java_tests_sha256=' "$PREPARE" || fail "prepare 摘要未绑定
|
||||
rg -q 'verify_security_java_reports' "$PREPARE" || fail "prepare 未校验安全测试报告实际包含全部目标类"
|
||||
rg -q 'security-java-tests-summary\.tsv' "$PREPARE" || fail "prepare 未保存可审计的 Java 测试计数摘要"
|
||||
rg -q 'security_java_tests_summary_sha256=' "$PREPARE" || fail "prepare 摘要未绑定 Java 测试计数证据"
|
||||
|
||||
test_surefire_report_guard
|
||||
|
||||
rg -q 'FRONTEND_SECRET_SCAN_PASS' "$PREPARE" || fail "prepare 未扫描前端制品中的默认凭据"
|
||||
rg -q 'frontend_secret_scan_sha256=' "$PREPARE" || fail "prepare 摘要未绑定前端凭据扫描证据"
|
||||
rg -Fq "frontend_forbidden_patterns='admin123|test1|mock-token|mock-studio-token|bootstrap-admin\\.token|DOGFOOD_PASSWORD_SEED'" \
|
||||
"$PREPARE" || fail "prepare 前端扫描模式未完整覆盖 test1、mock-studio-token 与既有模式"
|
||||
rg -q "printf 'patterns=%s" "$PREPARE" || fail "prepare 扫描证据未记录实际模式集"
|
||||
rg -q 'frontend_secret_scan_patterns_sha256=' "$PREPARE" || fail "prepare 摘要未绑定扫描模式集证据"
|
||||
frontend_forbidden_patterns="$(sed -n "s/^frontend_forbidden_patterns='\\(.*\\)'$/\\1/p" "$PREPARE")"
|
||||
[ -n "$frontend_forbidden_patterns" ] || fail "无法从 prepare 解析前端禁止模式集"
|
||||
for forbidden_sample in admin123 test1 mock-token mock-studio-token bootstrap-admin.token DOGFOOD_PASSWORD_SEED; do
|
||||
printf '%s\n' "$forbidden_sample" | rg -q -i "$frontend_forbidden_patterns" || \
|
||||
fail "前端扫描正则未命中禁止样本:$forbidden_sample"
|
||||
done
|
||||
if printf '%s\n' 'production-studio-token' | rg -q -i "$frontend_forbidden_patterns"; then
|
||||
fail "前端扫描正则误伤安全样本"
|
||||
fi
|
||||
! rg -q 'backend_tests=NOT_RUN' "$PREPARE" || fail "prepare 仍宣称后端测试未运行"
|
||||
venv_line="$(rg -n '准备 commit 内独立 Python 虚拟环境' "$PREPARE" | cut -d: -f1)"
|
||||
tool_test_line="$(rg -n '运行发布工具回归测试' "$PREPARE" | cut -d: -f1)"
|
||||
|
||||
@ -3,9 +3,14 @@ package com.wanxiang.huijing.framework.env.config;
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 内部狗粮环境的后端安全开关。
|
||||
@ -19,6 +24,19 @@ import java.time.format.DateTimeParseException;
|
||||
@ConfigurationProperties(prefix = "huijing.dogfood")
|
||||
public class DogfoodProperties {
|
||||
|
||||
/** 预置租约的后端硬上限;Nacos 不能放宽该可信边界。 */
|
||||
public static final Duration MAX_PROVISIONING_TTL = Duration.ofMinutes(10);
|
||||
|
||||
/** 事务 run-id 只接受可安全进入日志、证据目录和 systemd unit 的有限字符集。 */
|
||||
private static final Pattern RUN_ID_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
|
||||
|
||||
/** capability 原文固定为 256 bit 小写十六进制,避免低熵或歧义编码。 */
|
||||
private static final Pattern CAPABILITY_PATTERN = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
/** 预置用户名精确覆盖 dogfoodc01..10 与 dogfoodp01..30,不接受相似前缀。 */
|
||||
private static final Pattern PROVISIONING_USERNAME_PATTERN = Pattern.compile(
|
||||
"(?:dogfoodc(?:0[1-9]|10)|dogfoodp(?:0[1-9]|[12][0-9]|30))");
|
||||
|
||||
/** 是否启用内部狗粮安全边界。 */
|
||||
private boolean enabled = false;
|
||||
|
||||
@ -30,11 +48,20 @@ public class DogfoodProperties {
|
||||
/** 是否开启仅供账号预置使用的密码注册窄租约。 */
|
||||
private boolean provisioningEnabled = false;
|
||||
|
||||
/** 账号预置租约的 UTC 签发时间;与到期时间共同约束最大 TTL。 */
|
||||
private String provisioningIssuedAt;
|
||||
|
||||
/**
|
||||
* 账号预置租约的 UTC 到期时间,格式为 ISO-8601 Instant,例如 {@code 2026-07-22T12:05:00Z}。
|
||||
*/
|
||||
private String provisioningExpiresAt;
|
||||
|
||||
/** 本次账号预置事务的 run-id,必须与请求头逐字一致。 */
|
||||
private String provisioningRunId;
|
||||
|
||||
/** 本次高熵 capability 原文的 SHA-256;配置中心不得保存原文。 */
|
||||
private String provisioningCapabilitySha256;
|
||||
|
||||
/**
|
||||
* 使用调用方提供的时钟判定预置租约,确保启动门、运行时边界和测试共享同一时间语义。
|
||||
*
|
||||
@ -48,16 +75,42 @@ public class DogfoodProperties {
|
||||
if (!provisioningEnabled) {
|
||||
return ProvisioningLeaseStatus.PROVISIONING_DISABLED;
|
||||
}
|
||||
if (provisioningIssuedAt == null || provisioningIssuedAt.trim().isEmpty()) {
|
||||
return ProvisioningLeaseStatus.ISSUED_AT_MISSING;
|
||||
}
|
||||
if (provisioningExpiresAt == null || provisioningExpiresAt.trim().isEmpty()) {
|
||||
return ProvisioningLeaseStatus.EXPIRES_AT_MISSING;
|
||||
}
|
||||
final Instant issuedAt;
|
||||
final Instant expiresAt;
|
||||
try {
|
||||
issuedAt = Instant.parse(provisioningIssuedAt.trim());
|
||||
} catch (DateTimeParseException ex) {
|
||||
return ProvisioningLeaseStatus.ISSUED_AT_INVALID;
|
||||
}
|
||||
try {
|
||||
expiresAt = Instant.parse(provisioningExpiresAt.trim());
|
||||
} catch (DateTimeParseException ex) {
|
||||
return ProvisioningLeaseStatus.EXPIRES_AT_INVALID;
|
||||
}
|
||||
return clock.instant().isBefore(expiresAt)
|
||||
if (!expiresAt.isAfter(issuedAt)) {
|
||||
return ProvisioningLeaseStatus.INTERVAL_INVALID;
|
||||
}
|
||||
if (Duration.between(issuedAt, expiresAt).compareTo(MAX_PROVISIONING_TTL) > 0) {
|
||||
return ProvisioningLeaseStatus.TTL_EXCEEDED;
|
||||
}
|
||||
if (provisioningRunId == null || !RUN_ID_PATTERN.matcher(provisioningRunId).matches()) {
|
||||
return ProvisioningLeaseStatus.RUN_ID_INVALID;
|
||||
}
|
||||
if (provisioningCapabilitySha256 == null
|
||||
|| !CAPABILITY_PATTERN.matcher(provisioningCapabilitySha256).matches()) {
|
||||
return ProvisioningLeaseStatus.CAPABILITY_SHA256_INVALID;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (now.isBefore(issuedAt)) {
|
||||
return ProvisioningLeaseStatus.NOT_STARTED;
|
||||
}
|
||||
return now.isBefore(expiresAt)
|
||||
? ProvisioningLeaseStatus.ACTIVE
|
||||
: ProvisioningLeaseStatus.EXPIRED;
|
||||
}
|
||||
@ -67,13 +120,64 @@ public class DogfoodProperties {
|
||||
return getProvisioningLeaseStatus(clock) == ProvisioningLeaseStatus.ACTIVE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验狗粮账号预置请求的完整授权条件。
|
||||
*
|
||||
* <p>capability 原文先做 SHA-256,再用 {@link MessageDigest#isEqual(byte[], byte[])}
|
||||
* 常量时间比较摘要;任何失败均返回同一布尔结果,调用方不得向外暴露细分原因。</p>
|
||||
*
|
||||
* @param clock UTC 判定时钟
|
||||
* @param username 待注册用户名
|
||||
* @param runId 固定请求头中的事务 run-id
|
||||
* @param capability 固定请求头中的 capability 原文
|
||||
* @return 租约、用户名、run-id 与 capability 全部有效时返回 true
|
||||
*/
|
||||
public boolean isProvisioningRequestAuthorized(Clock clock, String username,
|
||||
String runId, String capability) {
|
||||
if (getProvisioningLeaseStatus(clock) != ProvisioningLeaseStatus.ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
boolean usernameAllowed = username != null && PROVISIONING_USERNAME_PATTERN.matcher(username).matches();
|
||||
boolean runIdMatches = provisioningRunId.equals(runId);
|
||||
boolean capabilityFormatValid = capability != null && CAPABILITY_PATTERN.matcher(capability).matches();
|
||||
byte[] expectedDigest = decodeHex(provisioningCapabilitySha256);
|
||||
byte[] actualDigest = sha256(capability == null ? "" : capability);
|
||||
boolean capabilityMatches = MessageDigest.isEqual(expectedDigest, actualDigest);
|
||||
return usernameAllowed && runIdMatches && capabilityFormatValid && capabilityMatches;
|
||||
}
|
||||
|
||||
/** 计算 UTF-8 文本的 SHA-256;标准 JDK 缺少该算法时 fail-closed。 */
|
||||
private static byte[] sha256(String value) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("JDK 未提供 SHA-256,拒绝狗粮账号预置", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** 将已通过格式校验的 SHA-256 十六进制文本解码为固定 32 字节。 */
|
||||
private static byte[] decodeHex(String value) {
|
||||
byte[] result = new byte[value.length() / 2];
|
||||
for (int index = 0; index < value.length(); index += 2) {
|
||||
result[index / 2] = (byte) Integer.parseInt(value.substring(index, index + 2), 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 预置租约状态;日志只记录状态,不记录租约原值。 */
|
||||
public enum ProvisioningLeaseStatus {
|
||||
ACTIVE,
|
||||
DOGFOOD_DISABLED,
|
||||
PROVISIONING_DISABLED,
|
||||
ISSUED_AT_MISSING,
|
||||
ISSUED_AT_INVALID,
|
||||
EXPIRES_AT_MISSING,
|
||||
EXPIRES_AT_INVALID,
|
||||
INTERVAL_INVALID,
|
||||
TTL_EXCEEDED,
|
||||
RUN_ID_INVALID,
|
||||
CAPABILITY_SHA256_INVALID,
|
||||
NOT_STARTED,
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
|
||||
@ -41,6 +41,12 @@ import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
@Validated
|
||||
public class AppPassportController {
|
||||
|
||||
/** 狗粮账号预置事务 run-id 的固定请求头。 */
|
||||
public static final String DOGFOOD_PROVISIONING_RUN_ID_HEADER = "X-Dogfood-Provisioning-Run-Id";
|
||||
|
||||
/** 狗粮账号预置 capability 原文的固定请求头。 */
|
||||
public static final String DOGFOOD_PROVISIONING_CAPABILITY_HEADER = "X-Dogfood-Provisioning-Capability";
|
||||
|
||||
/**
|
||||
* 发码端点 IP 限频阈值:10 次/小时/IP(R6 §7.2,推断初值;@RateLimiter 注解需编译期常量,
|
||||
* 故此处与 Service 层 IP 计数上限 wanxiang.passport.sms-ip-limit-per-hour 并行,双闸兜底)。
|
||||
@ -90,9 +96,14 @@ public class AppPassportController {
|
||||
@ApiAccessLog(sanitizeKeys = {"password"})
|
||||
@Operation(summary = "用户名密码注册", description = "内测种子用户;register_channel=password,返回体与短信路 token 一字不差")
|
||||
public CommonResult<LoginRespVO> passwordRegister(@Valid @RequestBody PasswordRegisterReqVO reqVO,
|
||||
HttpServletRequest request) {
|
||||
HttpServletRequest request,
|
||||
@RequestHeader(value = DOGFOOD_PROVISIONING_RUN_ID_HEADER,
|
||||
required = false) String provisioningRunId,
|
||||
@RequestHeader(value = DOGFOOD_PROVISIONING_CAPABILITY_HEADER,
|
||||
required = false) String provisioningCapability) {
|
||||
// WU1 §3.3 命门:IP 一律采信连接层 getRemoteAddr(),不读客户端可伪造的 XFF(不走 ServletUtils.getClientIP)
|
||||
return success(passportService.passwordRegister(reqVO, request.getRemoteAddr()));
|
||||
return success(passportService.passwordRegister(reqVO, request.getRemoteAddr(),
|
||||
provisioningRunId, provisioningCapability));
|
||||
}
|
||||
|
||||
@PostMapping("/password-login")
|
||||
|
||||
@ -61,11 +61,14 @@ public interface PassportService {
|
||||
* 用户名撞 uk_username 报 PLAYER_USERNAME_ALREADY_REGISTERED(注册态明确报占用,不静默转登录);
|
||||
* 注册事务内与建号原子写一行 outbox(注册成功事件生产者,三条注册路共用出口)。
|
||||
*
|
||||
* @param reqVO username/password/nickname?/anonId?
|
||||
* @param clientIp 请求 IP(取连接层 request.getRemoteAddr(),不采信可伪造 XFF;§3.3)
|
||||
* @param reqVO username/password/nickname?/anonId?
|
||||
* @param clientIp 请求 IP(取连接层 request.getRemoteAddr(),不采信可伪造 XFF;§3.3)
|
||||
* @param provisioningRunId 狗粮预置事务 run-id;非 dogfood 环境忽略
|
||||
* @param provisioningCapability 狗粮预置 capability 原文;非 dogfood 环境忽略
|
||||
* @return 登录响应(真 OAuth2 token + 身份摘要,与短信路一字不差)
|
||||
*/
|
||||
LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp);
|
||||
LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp,
|
||||
String provisioningRunId, String provisioningCapability);
|
||||
|
||||
/**
|
||||
* 用户名密码登录(内测种子用户,2026-07-07 WU1 §3.2)
|
||||
|
||||
@ -265,19 +265,22 @@ public class PassportServiceImpl implements PassportService {
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class) // 建号 + outbox 写入需同一本地事务(注册成功事件与建号原子落库,§3.6)
|
||||
public LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp) {
|
||||
public LoginRespVO passwordRegister(PasswordRegisterReqVO reqVO, String clientIp,
|
||||
String provisioningRunId, String provisioningCapability) {
|
||||
// 1) 总开关(§3.4 信任边界=后端):关闭时拒服务,不靠前端隐藏 Tab
|
||||
if (!passwordRegisterEnabled) {
|
||||
log.warn("[passwordRegister] 用户名密码注册通道已关闭,拒绝新建账号 clientIp={}", clientIp);
|
||||
throw exception(PASSWORD_REGISTER_DISABLED);
|
||||
}
|
||||
// dogfood 只允许受控预置租约临时开放密码注册;非 dogfood 保留原有注册语义。
|
||||
// dogfood 必须同时满足短租约、精确用户名白名单、run-id 与 capability;非 dogfood 完全保留原注册语义。
|
||||
if (dogfoodProperties.isEnabled()
|
||||
&& !dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)) {
|
||||
&& !dogfoodProperties.isProvisioningRequestAuthorized(dogfoodProvisioningClock,
|
||||
reqVO.getUsername(), provisioningRunId, provisioningCapability)) {
|
||||
DogfoodProperties.ProvisioningLeaseStatus leaseStatus =
|
||||
dogfoodProperties.getProvisioningLeaseStatus(dogfoodProvisioningClock);
|
||||
log.warn("[passwordRegister] 狗粮态预置租约无效,拒绝新建账号 leaseStatus={} clientIp={}",
|
||||
leaseStatus, clientIp);
|
||||
// capability 绝不落日志;用户名与配置 run-id 用于定位错批次、错目标,外部统一返回注册关闭。
|
||||
log.warn("[passwordRegister] 狗粮态预置授权无效,拒绝新建账号 leaseStatus={} username={} runId={} clientIp={}",
|
||||
leaseStatus, reqVO.getUsername(), dogfoodProperties.getProvisioningRunId(), clientIp);
|
||||
throw exception(PASSWORD_REGISTER_DISABLED);
|
||||
}
|
||||
// 2) 应用层纯 IP 计数防刷(scene=register,先增后判,越限 429;IP 取连接层 remoteAddr 由 controller 传入,不采信 XFF)
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package com.wanxiang.huijing.framework.env.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link DogfoodProperties} 预置租约与 capability 边界测试。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
class DogfoodPropertiesTest {
|
||||
|
||||
private static final Instant NOW = Instant.parse("2026-07-23T12:00:00Z");
|
||||
private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);
|
||||
private static final String CAPABILITY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
@Test
|
||||
void leaseLongerThanTenMinutesNeverBecomesActiveNearExpiry() {
|
||||
DogfoodProperties properties = validProperties();
|
||||
properties.setProvisioningIssuedAt(NOW.minusSeconds(51 * 60L).toString());
|
||||
properties.setProvisioningExpiresAt(NOW.plusSeconds(9 * 60L).toString());
|
||||
|
||||
assertEquals(DogfoodProperties.ProvisioningLeaseStatus.TTL_EXCEEDED,
|
||||
properties.getProvisioningLeaseStatus(CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestRequiresExactRunIdCapabilityAndFortyAccountAllowlist() {
|
||||
DogfoodProperties properties = validProperties();
|
||||
|
||||
assertTrue(properties.isProvisioningRequestAuthorized(
|
||||
CLOCK, "dogfoodc01", "run-001", CAPABILITY));
|
||||
assertTrue(properties.isProvisioningRequestAuthorized(
|
||||
CLOCK, "dogfoodc10", "run-001", CAPABILITY));
|
||||
assertTrue(properties.isProvisioningRequestAuthorized(
|
||||
CLOCK, "dogfoodp30", "run-001", CAPABILITY));
|
||||
assertFalse(properties.isProvisioningRequestAuthorized(
|
||||
CLOCK, "dogfoodp31", "run-001", CAPABILITY));
|
||||
assertFalse(properties.isProvisioningRequestAuthorized(
|
||||
CLOCK, "dogfoodp01", "run-002", CAPABILITY));
|
||||
assertFalse(properties.isProvisioningRequestAuthorized(
|
||||
CLOCK, "dogfoodp01", "run-001",
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"));
|
||||
}
|
||||
|
||||
/** 构造完整、有效的十分钟租约。 */
|
||||
private static DogfoodProperties validProperties() {
|
||||
DogfoodProperties properties = new DogfoodProperties();
|
||||
properties.setEnabled(true);
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningIssuedAt(NOW.toString());
|
||||
properties.setProvisioningExpiresAt(NOW.plusSeconds(600).toString());
|
||||
properties.setProvisioningRunId("run-001");
|
||||
properties.setProvisioningCapabilitySha256(sha256Hex(CAPABILITY));
|
||||
return properties;
|
||||
}
|
||||
|
||||
/** 计算 capability 摘要,测试配置与生产脚本使用同一算法。 */
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||
for (byte item : digest) {
|
||||
result.append(String.format("%02x", item & 0xff));
|
||||
}
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("JDK 必须提供 SHA-256", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.wanxiang.huijing.module.system.controller.app.passport;
|
||||
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.LoginRespVO;
|
||||
import com.wanxiang.huijing.module.system.controller.app.passport.vo.PasswordRegisterReqVO;
|
||||
import com.wanxiang.huijing.module.system.service.passport.PassportService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link AppPassportController} 狗粮预置安全头透传测试。
|
||||
*
|
||||
* @author 绘境AI
|
||||
*/
|
||||
class AppPassportControllerTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private AppPassportController controller;
|
||||
|
||||
@Mock
|
||||
private PassportService passportService;
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
@Test
|
||||
void passwordRegisterForwardsFixedProvisioningHeaders() {
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("dogfoodc01");
|
||||
reqVO.setPassword("secret6");
|
||||
LoginRespVO expected = new LoginRespVO();
|
||||
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||
when(passportService.passwordRegister(reqVO, "127.0.0.1", "run-001", "capability-raw"))
|
||||
.thenReturn(expected);
|
||||
|
||||
LoginRespVO actual = controller.passwordRegister(reqVO, request, "run-001", "capability-raw").getData();
|
||||
|
||||
assertSame(expected, actual);
|
||||
verify(passportService).passwordRegister(reqVO, "127.0.0.1", "run-001", "capability-raw");
|
||||
}
|
||||
|
||||
}
|
||||
@ -31,7 +31,13 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeConstants.TOO_MANY_REQUESTS;
|
||||
import static com.wanxiang.huijing.module.system.dal.redis.passport.PassportSmsIpCounterRedisDAO.SCENE_LOGIN;
|
||||
@ -55,6 +61,11 @@ import static org.mockito.Mockito.*;
|
||||
*/
|
||||
class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
private static final Instant PROVISIONING_NOW = Instant.parse("2026-07-23T12:00:00Z");
|
||||
private static final String PROVISIONING_RUN_ID = "dogfood-test-run-001";
|
||||
private static final String PROVISIONING_CAPABILITY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
@InjectMocks
|
||||
private PassportServiceImpl passportService;
|
||||
|
||||
@ -76,13 +87,15 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
private PassportRegisterOutboxMapper registerOutboxMapper;
|
||||
@Mock
|
||||
private DogfoodAccessGuard dogfoodAccessGuard;
|
||||
@Mock
|
||||
private DogfoodProperties dogfoodProperties;
|
||||
@Mock
|
||||
private Clock dogfoodProvisioningClock;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
dogfoodProperties = new DogfoodProperties();
|
||||
dogfoodProvisioningClock = Clock.fixed(PROVISIONING_NOW, ZoneOffset.UTC);
|
||||
ReflectionTestUtils.setField(passportService, "dogfoodProperties", dogfoodProperties);
|
||||
ReflectionTestUtils.setField(passportService, "dogfoodProvisioningClock", dogfoodProvisioningClock);
|
||||
// @Value 注入字段在纯 Mockito 下不生效,手动反射注入(与生产默认值一致)
|
||||
ReflectionTestUtils.setField(passportService, "smsIpLimitPerHour", 10);
|
||||
ReflectionTestUtils.setField(passportService, "smsIpLimitPerDay", 30);
|
||||
@ -470,7 +483,7 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
}).when(playerMapper).insert(any(PlayerDO.class));
|
||||
stubToken(2048L);
|
||||
|
||||
LoginRespVO resp = passportService.passwordRegister(reqVO, "9.9.9.9");
|
||||
LoginRespVO resp = passportService.passwordRegister(reqVO, "9.9.9.9", null, null);
|
||||
|
||||
// 建号:channel=password、username 落列、password 落 BCrypt 密文、mobile=null(用户名路)、默认昵称取用户名
|
||||
ArgumentCaptor<PlayerDO> pc = ArgumentCaptor.forClass(PlayerDO.class);
|
||||
@ -506,7 +519,7 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
.when(playerMapper).insert(any(PlayerDO.class));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9", null, null));
|
||||
assertEquals(PLAYER_USERNAME_ALREADY_REGISTERED.getCode(), ex.getCode());
|
||||
// 注册态撞名不静默转登录:不回查手机号、不发 token、不写 outbox
|
||||
verify(playerMapper, never()).selectByMobile(any());
|
||||
@ -524,7 +537,7 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
reqVO.setPassword("secret6");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9", null, null));
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verify(playerMapper, never()).insert(any(PlayerDO.class));
|
||||
verify(smsIpCounterRedisDAO, never()).incrHourAndGet(any(), any());
|
||||
@ -533,14 +546,14 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:租约无效时在 IP 计数、加密和建号前拒绝")
|
||||
void testPasswordRegister_dogfoodProvisioningLeaseRequired() {
|
||||
when(dogfoodProperties.isEnabled()).thenReturn(true);
|
||||
when(dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)).thenReturn(false);
|
||||
dogfoodProperties.setEnabled(true);
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setUsername("dogfoodc01");
|
||||
reqVO.setPassword("secret6");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9",
|
||||
PROVISIONING_RUN_ID, PROVISIONING_CAPABILITY));
|
||||
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
|
||||
@ -549,10 +562,9 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:有效租约允许进入既有注册流程")
|
||||
void testPasswordRegister_dogfoodActiveProvisioningLeaseAllowed() {
|
||||
when(dogfoodProperties.isEnabled()).thenReturn(true);
|
||||
when(dogfoodProperties.isProvisioningLeaseActive(dogfoodProvisioningClock)).thenReturn(true);
|
||||
enableDogfoodProvisioningLease(Duration.ofMinutes(10));
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername("seedalice");
|
||||
reqVO.setUsername("dogfoodc01");
|
||||
reqVO.setPassword("secret6");
|
||||
when(passwordEncoder.encode("secret6")).thenReturn("$2a$10$encodedhash");
|
||||
doAnswer(invocation -> {
|
||||
@ -562,12 +574,70 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
}).when(playerMapper).insert(any(PlayerDO.class));
|
||||
stubToken(2048L);
|
||||
|
||||
LoginRespVO response = passportService.passwordRegister(reqVO, "9.9.9.9");
|
||||
LoginRespVO response = passportService.passwordRegister(reqVO, "9.9.9.9",
|
||||
PROVISIONING_RUN_ID, PROVISIONING_CAPABILITY);
|
||||
|
||||
assertEquals(2048L, response.getUserId());
|
||||
verify(playerMapper).insert(any(PlayerDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:错误 capability 在任何副作用前拒绝")
|
||||
void testPasswordRegister_dogfoodWrongCapabilityRejected() {
|
||||
enableDogfoodProvisioningLease(Duration.ofMinutes(10));
|
||||
PasswordRegisterReqVO reqVO = passwordRegisterReq("dogfoodp01");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9",
|
||||
PROVISIONING_RUN_ID,
|
||||
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"));
|
||||
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:run-id 不匹配在任何副作用前拒绝")
|
||||
void testPasswordRegister_dogfoodWrongRunIdRejected() {
|
||||
enableDogfoodProvisioningLease(Duration.ofMinutes(10));
|
||||
PasswordRegisterReqVO reqVO = passwordRegisterReq("dogfoodp01");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9",
|
||||
"another-run", PROVISIONING_CAPABILITY));
|
||||
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:40 人严格白名单之外的用户名在任何副作用前拒绝")
|
||||
void testPasswordRegister_dogfoodOutOfScopeUsernameRejected() {
|
||||
enableDogfoodProvisioningLease(Duration.ofMinutes(10));
|
||||
PasswordRegisterReqVO reqVO = passwordRegisterReq("dogfoodp31");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9",
|
||||
PROVISIONING_RUN_ID, PROVISIONING_CAPABILITY));
|
||||
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("狗粮用户名注册:签发跨度超过十分钟的租约始终拒绝")
|
||||
void testPasswordRegister_dogfoodLeaseExceedsMaximumTtlRejected() {
|
||||
enableDogfoodProvisioningLease(Duration.ofMinutes(10).plusSeconds(1));
|
||||
PasswordRegisterReqVO reqVO = passwordRegisterReq("dogfoodp30");
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9",
|
||||
PROVISIONING_RUN_ID, PROVISIONING_CAPABILITY));
|
||||
|
||||
assertEquals(PASSWORD_REGISTER_DISABLED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(smsIpCounterRedisDAO, passwordEncoder, playerMapper, registerOutboxMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("用户名注册:同 IP 越注册档小时阈值 → 抛 429 且不建号")
|
||||
void testPasswordRegister_ipLimitExceeded() {
|
||||
@ -578,7 +648,7 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
when(smsIpCounterRedisDAO.incrDayAndGet(SCENE_REGISTER, "9.9.9.9")).thenReturn(21L);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9"));
|
||||
() -> passportService.passwordRegister(reqVO, "9.9.9.9", null, null));
|
||||
assertEquals(TOO_MANY_REQUESTS.getCode(), ex.getCode());
|
||||
verify(playerMapper, never()).insert(any(PlayerDO.class));
|
||||
verify(registerOutboxMapper, never()).insert(any(PassportRegisterOutboxDO.class));
|
||||
@ -705,6 +775,39 @@ class PassportServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/** 配置一份与固定测试时钟对齐的狗粮预置租约。 */
|
||||
private void enableDogfoodProvisioningLease(Duration ttl) {
|
||||
dogfoodProperties.setEnabled(true);
|
||||
dogfoodProperties.setProvisioningEnabled(true);
|
||||
dogfoodProperties.setProvisioningIssuedAt(PROVISIONING_NOW.toString());
|
||||
dogfoodProperties.setProvisioningExpiresAt(PROVISIONING_NOW.plus(ttl).toString());
|
||||
dogfoodProperties.setProvisioningRunId(PROVISIONING_RUN_ID);
|
||||
dogfoodProperties.setProvisioningCapabilitySha256(sha256Hex(PROVISIONING_CAPABILITY));
|
||||
}
|
||||
|
||||
/** 构造最小用户名密码注册请求。 */
|
||||
private static PasswordRegisterReqVO passwordRegisterReq(String username) {
|
||||
PasswordRegisterReqVO reqVO = new PasswordRegisterReqVO();
|
||||
reqVO.setUsername(username);
|
||||
reqVO.setPassword("secret6");
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
/** 计算测试 capability 的 SHA-256 十六进制摘要。 */
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||
for (byte item : digest) {
|
||||
result.append(String.format("%02x", item & 0xff));
|
||||
}
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("JDK 必须提供 SHA-256", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/** stub 发 token 返回固定 access/refresh/过期时间 */
|
||||
private void stubToken(Long userId) {
|
||||
OAuth2AccessTokenDO tokenDO = new OAuth2AccessTokenDO();
|
||||
|
||||
@ -96,7 +96,10 @@ class DogfoodStartupValidatorTest {
|
||||
void shouldAllowPasswordRegistrationWithActiveProvisioningLease() {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningIssuedAt("2026-07-22T12:00:00Z");
|
||||
properties.setProvisioningExpiresAt("2026-07-22T12:05:00Z");
|
||||
properties.setProvisioningRunId("test-run");
|
||||
properties.setProvisioningCapabilitySha256("0".repeat(64));
|
||||
MockEnvironment environment = safeEnvironment()
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
@ -107,7 +110,10 @@ class DogfoodStartupValidatorTest {
|
||||
void shouldRejectExpiredProvisioningLease() {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningIssuedAt("2026-07-22T11:55:00Z");
|
||||
properties.setProvisioningExpiresAt("2026-07-22T12:00:00Z");
|
||||
properties.setProvisioningRunId("test-run");
|
||||
properties.setProvisioningCapabilitySha256("0".repeat(64));
|
||||
MockEnvironment environment = safeEnvironment()
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
@ -119,7 +125,10 @@ class DogfoodStartupValidatorTest {
|
||||
void shouldRejectInvalidProvisioningLease() {
|
||||
DogfoodProperties properties = enabledDogfood();
|
||||
properties.setProvisioningEnabled(true);
|
||||
properties.setProvisioningIssuedAt("2026-07-22T12:00:00Z");
|
||||
properties.setProvisioningExpiresAt("not-an-instant");
|
||||
properties.setProvisioningRunId("test-run");
|
||||
properties.setProvisioningCapabilitySha256("0".repeat(64));
|
||||
MockEnvironment environment = safeEnvironment()
|
||||
.withProperty("wanxiang.passport.password-register-enabled", "true");
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user