feat(vNext.4): implement trusted-subject security chain for portal user key self-service

- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved

This implements the secure chain:
  Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)

Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services

Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
This commit is contained in:
phamnazage-jpg
2026-06-09 07:48:03 +08:00
parent dd6f332b53
commit 4e2ee087fd
25 changed files with 1861 additions and 177 deletions

View File

@@ -7,8 +7,10 @@ ARTIFACT_DIR="${ARTIFACT_DIR:-$ROOT_DIR/artifacts/user-key-self-service/${TS}}"
CRM_BASE="${CRM_BASE:-https://sub.tksea.top/portal-admin-api}"
USER_CHAT_BASE="${USER_CHAT_BASE:-}"
CHAT_MODEL="${CHAT_MODEL:-gpt-5.4}"
USER_SUBJECT_ID="${USER_SUBJECT_ID:-}"
USER_AUTH_TOKEN="${USER_AUTH_TOKEN:-}"
USER_TRUSTED_SUBJECT_ID="${USER_TRUSTED_SUBJECT_ID:-}"
USER_TRUSTED_SUBJECT_HEADER="${USER_TRUSTED_SUBJECT_HEADER:-X-CRM-Authenticated-Subject}"
USER_TRUSTED_PROXY_SECRET_HEADER="${USER_TRUSTED_PROXY_SECRET_HEADER:-X-CRM-Trusted-Proxy}"
USER_TRUSTED_PROXY_SECRET="${USER_TRUSTED_PROXY_SECRET:-}"
mkdir -p "$ARTIFACT_DIR"
@@ -31,9 +33,11 @@ Required env for --run:
USER_CHAT_BASE 最终 user-key 调用入口 base, e.g. https://sub.tksea.top
CHAT_MODEL chat 模型名default: gpt-5.4
Authentication for /api/keys endpoints (choose one):
USER_SUBJECT_ID 通过 X-Portal-Subject 头注入 subject(联合部署/受信入口)
USER_AUTH_TOKEN 通过 Authorization: Bearer <token> 走用户链路
Authentication for /api/keys endpoints:
USER_TRUSTED_SUBJECT_ID 受信代理注入 subject
USER_TRUSTED_SUBJECT_HEADER subject 头名default: X-CRM-Authenticated-Subject
USER_TRUSTED_PROXY_SECRET_HEADER 代理密钥头名default: X-CRM-Trusted-Proxy
USER_TRUSTED_PROXY_SECRET 代理与 CRM 共享的密钥
Artifacts:
artifacts/user-key-self-service/<timestamp>/
@@ -48,12 +52,10 @@ EOF
}
build_auth_args() {
if [[ -n "$USER_AUTH_TOKEN" ]]; then
printf '%s\n' "-H" "Authorization: Bearer $USER_AUTH_TOKEN"
return 0
fi
if [[ -n "$USER_SUBJECT_ID" ]]; then
printf '%s\n' "-H" "X-Portal-Subject: $USER_SUBJECT_ID"
if [[ -n "$USER_TRUSTED_SUBJECT_ID" && -n "$USER_TRUSTED_PROXY_SECRET" ]]; then
printf '%s\n' \
"-H" "${USER_TRUSTED_SUBJECT_HEADER}: ${USER_TRUSTED_SUBJECT_ID}" \
"-H" "${USER_TRUSTED_PROXY_SECRET_HEADER}: ${USER_TRUSTED_PROXY_SECRET}"
return 0
fi
return 1
@@ -133,8 +135,8 @@ cmd_env_check() {
CRM_HEALTH="$crm_health" \
USER_CHAT_BASE_PY="$USER_CHAT_BASE" \
CHAT_HEALTH="$chat_health" \
HAS_SUBJECT_ID="$USER_SUBJECT_ID" \
HAS_AUTH_TOKEN="$USER_AUTH_TOKEN" \
HAS_TRUSTED_SUBJECT_ID="$USER_TRUSTED_SUBJECT_ID" \
HAS_TRUSTED_PROXY_SECRET="$USER_TRUSTED_PROXY_SECRET" \
python3 - <<'PY'
import json, os
out = {
@@ -142,8 +144,8 @@ out = {
"crm_health": os.environ["CRM_HEALTH"],
"user_chat_base": os.environ["USER_CHAT_BASE_PY"],
"user_chat_health": os.environ["CHAT_HEALTH"],
"has_user_subject_id": bool(os.environ["HAS_SUBJECT_ID"]),
"has_user_auth_token": bool(os.environ["HAS_AUTH_TOKEN"]),
"has_trusted_subject_id": bool(os.environ["HAS_TRUSTED_SUBJECT_ID"]),
"has_trusted_proxy_secret": bool(os.environ["HAS_TRUSTED_PROXY_SECRET"]),
}
with open(os.environ["OUT_PATH"], "w", encoding="utf-8") as fh:
json.dump(out, fh, ensure_ascii=False, indent=2)
@@ -157,7 +159,7 @@ cmd_run() {
cmd_env_check
[[ -n "$USER_CHAT_BASE" ]] || die "USER_CHAT_BASE is required for --run"
if ! build_auth_args >/dev/null; then
die "set USER_SUBJECT_ID or USER_AUTH_TOKEN for /api/keys authentication"
die "set USER_TRUSTED_SUBJECT_ID and USER_TRUSTED_PROXY_SECRET for /api/keys authentication"
fi
local create_payload create_code key_id plaintext_key masked_preview create_body

View File

@@ -97,11 +97,19 @@ block = textwrap.dedent("""\
}
location /portal-admin-api/ {
# 必须由受信登录/鉴权层把用户 subject 放进 \$portal_subject不能信任浏览器自带 header。
# 同时 CRM 需配置:
# SUB2API_CRM_TRUSTED_SUBJECT_HEADER=X-CRM-Authenticated-Subject
# SUB2API_CRM_TRUSTED_PROXY_SECRET_HEADER=X-CRM-Trusted-Proxy
# SUB2API_CRM_TRUSTED_PROXY_SECRET=<same-secret-as-nginx>
proxy_pass http://127.0.0.1:${REMOTE_CRM_PORT}/;
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-Proto \$scheme;
proxy_set_header X-Portal-Subject "";
proxy_set_header X-CRM-Authenticated-Subject \$portal_subject;
proxy_set_header X-CRM-Trusted-Proxy "REPLACE_WITH_SUB2API_CRM_TRUSTED_PROXY_SECRET";
proxy_http_version 1.1;
}

View File

@@ -75,12 +75,19 @@ render_remote43_crm_env() {
local repo_root="${4:-}"
local admin_username="${5:-admin}"
local admin_password="${6:-$admin_token}"
local trusted_subject_header="${SUB2API_CRM_TRUSTED_SUBJECT_HEADER:-}"
local trusted_proxy_secret_header="${SUB2API_CRM_TRUSTED_PROXY_SECRET_HEADER:-X-CRM-Trusted-Proxy}"
local trusted_proxy_secret="${SUB2API_CRM_TRUSTED_PROXY_SECRET:-}"
local sqlite_dsn_q admin_token_q repo_root_q admin_username_q admin_password_q
local trusted_subject_header_q trusted_proxy_secret_header_q trusted_proxy_secret_q
printf -v sqlite_dsn_q '%q' "$sqlite_dsn"
printf -v admin_token_q '%q' "$admin_token"
printf -v repo_root_q '%q' "$repo_root"
printf -v admin_username_q '%q' "$admin_username"
printf -v admin_password_q '%q' "$admin_password"
printf -v trusted_subject_header_q '%q' "$trusted_subject_header"
printf -v trusted_proxy_secret_header_q '%q' "$trusted_proxy_secret_header"
printf -v trusted_proxy_secret_q '%q' "$trusted_proxy_secret"
cat <<EOF
SUB2API_CRM_LISTEN_ADDR=127.0.0.1:$crm_port
@@ -91,6 +98,9 @@ SUB2API_CRM_ADMIN_PASSWORD=$admin_password_q
SUB2API_CRM_ADMIN_SESSION_TTL=12h
SUB2API_CRM_REPO_ROOT=$repo_root_q
SUB2API_CRM_RECONCILE_WORKER_ENABLED=false
SUB2API_CRM_TRUSTED_SUBJECT_HEADER=$trusted_subject_header_q
SUB2API_CRM_TRUSTED_PROXY_SECRET_HEADER=$trusted_proxy_secret_header_q
SUB2API_CRM_TRUSTED_PROXY_SECRET=$trusted_proxy_secret_q
EOF
}