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

@@ -9,26 +9,30 @@ import (
)
const (
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
EnvRouteRuntimeBackend = "SUB2API_CRM_ROUTE_RUNTIME_BACKEND"
EnvRedisAddr = "SUB2API_CRM_REDIS_ADDR"
EnvRedisPassword = "SUB2API_CRM_REDIS_PASSWORD"
EnvRedisDB = "SUB2API_CRM_REDIS_DB"
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
EnvAdminUsername = "SUB2API_CRM_ADMIN_USERNAME"
EnvAdminPassword = "SUB2API_CRM_ADMIN_PASSWORD"
EnvAdminSessionTTL = "SUB2API_CRM_ADMIN_SESSION_TTL"
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
EnvRouteRuntimeBackend = "SUB2API_CRM_ROUTE_RUNTIME_BACKEND"
EnvRedisAddr = "SUB2API_CRM_REDIS_ADDR"
EnvRedisPassword = "SUB2API_CRM_REDIS_PASSWORD"
EnvRedisDB = "SUB2API_CRM_REDIS_DB"
EnvTrustedSubjectHeader = "SUB2API_CRM_TRUSTED_SUBJECT_HEADER"
EnvTrustedProxySecretHeader = "SUB2API_CRM_TRUSTED_PROXY_SECRET_HEADER"
EnvTrustedProxySecret = "SUB2API_CRM_TRUSTED_PROXY_SECRET"
DefaultListenAddr = ":8080"
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
DefaultAdminUsername = "admin"
DefaultAdminSessionTTL = 12 * time.Hour
DefaultReconcilePollInterval = 10 * time.Minute
DefaultRouteRuntimeBackend = "memory"
DefaultListenAddr = ":8080"
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
DefaultAdminUsername = "admin"
DefaultAdminSessionTTL = 12 * time.Hour
DefaultReconcilePollInterval = 10 * time.Minute
DefaultRouteRuntimeBackend = "memory"
DefaultTrustedProxySecretHeader = "X-CRM-Trusted-Proxy"
)
type ServerConfig struct {
@@ -59,10 +63,17 @@ type RepositoryConfig struct {
RepoRoot string
}
type UserKeyAuthConfig struct {
TrustedSubjectHeader string
TrustedProxySecretHeader string
TrustedProxySecret string
}
type StartupConfig struct {
Server ServerConfig
Database DatabaseConfig
Repository RepositoryConfig
UserKeyAuth UserKeyAuthConfig
RouteRuntime RouteRuntimeConfig
Reconcile ReconcileConfig
}
@@ -96,6 +107,11 @@ func loadStartupFromLookupEnv(lookup func(string) (string, bool)) (StartupConfig
Repository: RepositoryConfig{
RepoRoot: readOptionalEnv(lookup, EnvRepoRoot, ""),
},
UserKeyAuth: UserKeyAuthConfig{
TrustedSubjectHeader: readOptionalEnv(lookup, EnvTrustedSubjectHeader, ""),
TrustedProxySecretHeader: readOptionalEnv(lookup, EnvTrustedProxySecretHeader, DefaultTrustedProxySecretHeader),
TrustedProxySecret: readOptionalEnv(lookup, EnvTrustedProxySecret, ""),
},
RouteRuntime: RouteRuntimeConfig{
Backend: readOptionalEnv(lookup, EnvRouteRuntimeBackend, DefaultRouteRuntimeBackend),
Redis: RedisRuntimeConfig{

View File

@@ -77,6 +77,12 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
return " redis-pass ", true
case EnvRedisDB:
return "5", true
case EnvTrustedSubjectHeader:
return "X-CRM-Authenticated-Subject", true
case EnvTrustedProxySecretHeader:
return "X-CRM-Trusted-Proxy", true
case EnvTrustedProxySecret:
return "proxy-secret", true
default:
return "", false
}
@@ -112,6 +118,15 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
if cfg.RouteRuntime.Redis.DB != 5 {
t.Fatalf("RouteRuntime.Redis.DB = %d, want 5", cfg.RouteRuntime.Redis.DB)
}
if cfg.UserKeyAuth.TrustedSubjectHeader != "X-CRM-Authenticated-Subject" {
t.Fatalf("UserKeyAuth.TrustedSubjectHeader = %q, want X-CRM-Authenticated-Subject", cfg.UserKeyAuth.TrustedSubjectHeader)
}
if cfg.UserKeyAuth.TrustedProxySecretHeader != "X-CRM-Trusted-Proxy" {
t.Fatalf("UserKeyAuth.TrustedProxySecretHeader = %q, want X-CRM-Trusted-Proxy", cfg.UserKeyAuth.TrustedProxySecretHeader)
}
if cfg.UserKeyAuth.TrustedProxySecret != "proxy-secret" {
t.Fatalf("UserKeyAuth.TrustedProxySecret = %q, want proxy-secret", cfg.UserKeyAuth.TrustedProxySecret)
}
})
t.Run("default values", func(t *testing.T) {
lookup := func(k string) (string, bool) {
@@ -142,6 +157,15 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
if cfg.RouteRuntime.Redis.Addr != "" || cfg.RouteRuntime.Redis.Password != "" || cfg.RouteRuntime.Redis.DB != 0 {
t.Fatalf("RouteRuntime.Redis = %+v, want zero value", cfg.RouteRuntime.Redis)
}
if cfg.UserKeyAuth.TrustedSubjectHeader != "" {
t.Fatalf("UserKeyAuth.TrustedSubjectHeader = %q, want empty by default", cfg.UserKeyAuth.TrustedSubjectHeader)
}
if cfg.UserKeyAuth.TrustedProxySecretHeader != DefaultTrustedProxySecretHeader {
t.Fatalf("UserKeyAuth.TrustedProxySecretHeader = %q, want %q", cfg.UserKeyAuth.TrustedProxySecretHeader, DefaultTrustedProxySecretHeader)
}
if cfg.UserKeyAuth.TrustedProxySecret != "" {
t.Fatalf("UserKeyAuth.TrustedProxySecret = %q, want empty by default", cfg.UserKeyAuth.TrustedProxySecret)
}
})
t.Run("invalid reconcile interval", func(t *testing.T) {
lookup := func(k string) (string, bool) {