使用 AES-256-GCM 保存认证中心令牌,并以随机 Cookie 哈希关联 PostgreSQL 会话。加入闲置与绝对期限、Refresh Token 轮换以及多实例刷新锁。
303 lines
11 KiB
Go
303 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"log/slog"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
DefaultLocalGeneratedStorageDir = "data/static/generated"
|
|
DefaultLocalUploadedStorageDir = "data/static/uploaded"
|
|
)
|
|
|
|
type Config struct {
|
|
AppEnv string
|
|
HTTPAddr string
|
|
DatabaseURL string
|
|
IdentityMode string
|
|
JWTSecret string
|
|
ServerMainBaseURL string
|
|
ServerMainInternalToken string
|
|
ServerMainInternalKey string
|
|
ServerMainInternalSecret string
|
|
OIDCEnabled bool
|
|
OIDCIssuer string
|
|
OIDCAudience string
|
|
OIDCTenantID string
|
|
OIDCRolePrefix string
|
|
OIDCRequiredScopes []string
|
|
OIDCJWKSCacheTTLSeconds int
|
|
OIDCAcceptLegacyHS256 bool
|
|
OIDCIntrospectionEnabled bool
|
|
OIDCIntrospectionClientID string
|
|
OIDCIntrospectionClientSecret string
|
|
OIDCJITProvisioningEnabled bool
|
|
OIDCGatewayTenantKey string
|
|
OIDCBrowserSessionEnabled bool
|
|
OIDCSessionCookieSecure bool
|
|
OIDCClientID string
|
|
OIDCRedirectURI string
|
|
OIDCPostLogoutRedirectURI string
|
|
OIDCSessionEncryptionKey string
|
|
OIDCSessionIdleTTLSeconds int
|
|
OIDCSessionAbsoluteTTLSeconds int
|
|
OIDCSessionRefreshBeforeSeconds int
|
|
PublicBaseURL string
|
|
WebBaseURL string
|
|
LocalGeneratedStorageDir string
|
|
LocalUploadedStorageDir string
|
|
LocalTempAssetTTLHours int
|
|
TaskProgressCallbackEnabled bool
|
|
TaskProgressCallbackURL string
|
|
TaskProgressCallbackTimeoutMS string
|
|
TaskProgressCallbackMaxAttempts string
|
|
CORSAllowedOrigin string
|
|
GlobalHTTPProxy string
|
|
GlobalHTTPProxySource string
|
|
LogLevel slog.Level
|
|
}
|
|
|
|
func Load() Config {
|
|
globalProxy := LoadGlobalHTTPProxyStatus()
|
|
appEnv := env("APP_ENV", "development")
|
|
return Config{
|
|
AppEnv: appEnv,
|
|
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
|
DatabaseURL: gatewayDatabaseURL(),
|
|
IdentityMode: env("IDENTITY_MODE", "hybrid"),
|
|
JWTSecret: env("CONFIG_JWT_SECRET", "this is a very secret secret"),
|
|
ServerMainBaseURL: strings.TrimRight(
|
|
env("SERVER_MAIN_BASE_URL", "http://localhost:3000"),
|
|
"/",
|
|
),
|
|
ServerMainInternalToken: env("SERVER_MAIN_INTERNAL_TOKEN", ""),
|
|
ServerMainInternalKey: env("SERVER_MAIN_INTERNAL_KEY", "gateway"),
|
|
ServerMainInternalSecret: env("SERVER_MAIN_INTERNAL_SECRET", env("SERVER_MAIN_INTERNAL_TOKEN", "")),
|
|
OIDCEnabled: env("OIDC_ENABLED", "false") == "true",
|
|
OIDCIssuer: strings.TrimRight(env("OIDC_ISSUER", ""), "/"),
|
|
OIDCAudience: env("OIDC_AUDIENCE", ""),
|
|
OIDCTenantID: env("OIDC_TENANT_ID", ""),
|
|
OIDCRolePrefix: env("OIDC_ROLE_PREFIX", "gateway."),
|
|
OIDCRequiredScopes: splitCSV(env("OIDC_REQUIRED_SCOPES", "gateway.access")),
|
|
OIDCJWKSCacheTTLSeconds: envInt("OIDC_JWKS_CACHE_TTL_SECONDS", 300),
|
|
OIDCAcceptLegacyHS256: env("OIDC_ACCEPT_LEGACY_HS256", "true") == "true",
|
|
OIDCIntrospectionEnabled: env("OIDC_INTROSPECTION_ENABLED", "false") == "true",
|
|
OIDCIntrospectionClientID: env("OIDC_INTROSPECTION_CLIENT_ID", ""),
|
|
OIDCIntrospectionClientSecret: env("OIDC_INTROSPECTION_CLIENT_SECRET", ""),
|
|
OIDCJITProvisioningEnabled: env("OIDC_JIT_PROVISIONING_ENABLED", "false") == "true",
|
|
OIDCGatewayTenantKey: env("OIDC_GATEWAY_TENANT_KEY", ""),
|
|
OIDCBrowserSessionEnabled: env("OIDC_BROWSER_SESSION_ENABLED", "true") == "true",
|
|
OIDCSessionCookieSecure: env("OIDC_SESSION_COOKIE_SECURE",
|
|
strconv.FormatBool(!isLocalEnvironment(appEnv)),
|
|
) == "true",
|
|
OIDCClientID: env("OIDC_CLIENT_ID", ""),
|
|
OIDCRedirectURI: env("OIDC_REDIRECT_URI", ""),
|
|
OIDCPostLogoutRedirectURI: env("OIDC_POST_LOGOUT_REDIRECT_URI", ""),
|
|
OIDCSessionEncryptionKey: env("OIDC_SESSION_ENCRYPTION_KEY", ""),
|
|
OIDCSessionIdleTTLSeconds: envInt("OIDC_SESSION_IDLE_TTL_SECONDS", 1800),
|
|
OIDCSessionAbsoluteTTLSeconds: envInt("OIDC_SESSION_ABSOLUTE_TTL_SECONDS", 28800),
|
|
OIDCSessionRefreshBeforeSeconds: envInt("OIDC_SESSION_REFRESH_BEFORE_SECONDS", 60),
|
|
PublicBaseURL: strings.TrimRight(env("AI_GATEWAY_PUBLIC_BASE_URL", env("PUBLIC_BASE_URL", "")), "/"),
|
|
WebBaseURL: strings.TrimRight(env("AI_GATEWAY_WEB_BASE_URL", env("GATEWAY_WEB_BASE_URL", env("PUBLIC_WEB_BASE_URL", ""))), "/"),
|
|
LocalGeneratedStorageDir: env("AI_GATEWAY_GENERATED_STORAGE_DIR", env("LOCAL_GENERATED_STORAGE_DIR", env("AI_GATEWAY_STATIC_STORAGE_DIR", DefaultLocalGeneratedStorageDir))),
|
|
LocalUploadedStorageDir: env("AI_GATEWAY_UPLOADED_STORAGE_DIR", env("LOCAL_UPLOADED_STORAGE_DIR", DefaultLocalUploadedStorageDir)),
|
|
LocalTempAssetTTLHours: envInt("AI_GATEWAY_LOCAL_TEMP_ASSET_TTL_HOURS", 24),
|
|
TaskProgressCallbackEnabled: env("TASK_PROGRESS_CALLBACK_ENABLED", "true") == "true",
|
|
TaskProgressCallbackURL: env("TASK_PROGRESS_CALLBACK_URL",
|
|
strings.TrimRight(env("SERVER_MAIN_BASE_URL", "http://localhost:3000"), "/")+"/internal/platform/task-progress-callbacks",
|
|
),
|
|
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
|
|
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
|
|
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"),
|
|
GlobalHTTPProxy: globalProxy.HTTPProxy,
|
|
GlobalHTTPProxySource: globalProxy.Source,
|
|
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
|
}
|
|
}
|
|
|
|
func (c Config) Validate() error {
|
|
if c.OIDCJITProvisioningEnabled && strings.TrimSpace(c.OIDCGatewayTenantKey) == "" {
|
|
return errors.New("OIDC_GATEWAY_TENANT_KEY is required when OIDC_JIT_PROVISIONING_ENABLED=true")
|
|
}
|
|
if c.OIDCEnabled && c.OIDCBrowserSessionEnabled {
|
|
for _, scope := range c.OIDCRequiredScopes {
|
|
if strings.EqualFold(strings.TrimSpace(scope), "offline_access") {
|
|
return errors.New("OIDC_REQUIRED_SCOPES cannot contain offline_access for browser sessions")
|
|
}
|
|
}
|
|
if strings.TrimSpace(c.OIDCClientID) == "" {
|
|
return errors.New("OIDC_CLIENT_ID is required when OIDC browser sessions are enabled")
|
|
}
|
|
if !validPublicRedirectURL(c.OIDCRedirectURI) {
|
|
return errors.New("OIDC_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
|
|
}
|
|
if !validPublicRedirectURL(c.OIDCPostLogoutRedirectURI) {
|
|
return errors.New("OIDC_POST_LOGOUT_REDIRECT_URI must be an exact HTTPS URL (localhost HTTP is allowed for development)")
|
|
}
|
|
if _, err := c.OIDCSessionEncryptionKeyBytes(); err != nil {
|
|
return err
|
|
}
|
|
if c.OIDCSessionIdleTTLSeconds <= 0 || c.OIDCSessionAbsoluteTTLSeconds <= c.OIDCSessionIdleTTLSeconds {
|
|
return errors.New("OIDC session idle TTL must be positive and shorter than the absolute TTL")
|
|
}
|
|
if c.OIDCSessionRefreshBeforeSeconds <= 0 || c.OIDCSessionRefreshBeforeSeconds >= c.OIDCSessionIdleTTLSeconds {
|
|
return errors.New("OIDC_SESSION_REFRESH_BEFORE_SECONDS must be positive and shorter than the idle TTL")
|
|
}
|
|
if !isLocalEnvironment(c.AppEnv) && !c.OIDCSessionCookieSecure {
|
|
return errors.New("OIDC_SESSION_COOKIE_SECURE must be true outside local development and tests")
|
|
}
|
|
for _, origin := range strings.Split(c.CORSAllowedOrigin, ",") {
|
|
if strings.TrimSpace(origin) == "*" {
|
|
return errors.New("CORS_ALLOWED_ORIGIN cannot contain * when OIDC browser sessions are enabled")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Config) OIDCSessionEncryptionKeyBytes() ([]byte, error) {
|
|
raw := strings.TrimSpace(c.OIDCSessionEncryptionKey)
|
|
for _, encoding := range []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} {
|
|
decoded, err := encoding.DecodeString(raw)
|
|
if err == nil && len(decoded) == 32 {
|
|
return decoded, nil
|
|
}
|
|
}
|
|
return nil, errors.New("OIDC_SESSION_ENCRYPTION_KEY must be a base64-encoded 32-byte random key")
|
|
}
|
|
|
|
func validPublicRedirectURL(raw string) bool {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return false
|
|
}
|
|
if parsed.Scheme == "https" {
|
|
return true
|
|
}
|
|
return parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
|
}
|
|
|
|
func isLocalEnvironment(value string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
|
case "development", "dev", "local", "test":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
type GlobalHTTPProxyStatus struct {
|
|
HTTPProxy string
|
|
Source string
|
|
}
|
|
|
|
func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus {
|
|
for _, key := range []string{
|
|
"AI_GATEWAY_GLOBAL_HTTP_PROXY",
|
|
"GLOBAL_HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"https_proxy",
|
|
"HTTP_PROXY",
|
|
"http_proxy",
|
|
"ALL_PROXY",
|
|
"all_proxy",
|
|
} {
|
|
if value := envValue(key); value != "" {
|
|
return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key}
|
|
}
|
|
}
|
|
return GlobalHTTPProxyStatus{}
|
|
}
|
|
|
|
func gatewayDatabaseURL() string {
|
|
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
|
|
return normalizePostgresURL(value)
|
|
}
|
|
if value := envValue("DATABASE_URL"); value != "" {
|
|
return normalizePostgresURL(value)
|
|
}
|
|
if memoryURL := envValue("MEMORY_DATABASE_URL"); memoryURL != "" {
|
|
return normalizePostgresURL(withDatabase(memoryURL, env("AI_GATEWAY_DATABASE_NAME", "easyai_ai_gateway")))
|
|
}
|
|
return normalizePostgresURL("postgresql://easyai:easyai2025@localhost:5432/easyai_ai_gateway?sslmode=disable")
|
|
}
|
|
|
|
func normalizePostgresURL(raw string) string {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
values := parsed.Query()
|
|
schema := values.Get("schema")
|
|
if schema == "" {
|
|
return raw
|
|
}
|
|
values.Del("schema")
|
|
if values.Get("search_path") == "" {
|
|
values.Set("search_path", schema)
|
|
}
|
|
parsed.RawQuery = values.Encode()
|
|
return parsed.String()
|
|
}
|
|
|
|
func splitCSV(value string) []string {
|
|
items := strings.Split(value, ",")
|
|
result := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func withDatabase(raw string, databaseName string) string {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || databaseName == "" {
|
|
return raw
|
|
}
|
|
parsed.Path = "/" + databaseName
|
|
return parsed.String()
|
|
}
|
|
|
|
func envValue(key string) string {
|
|
return strings.TrimSpace(os.Getenv(key))
|
|
}
|
|
|
|
func env(key string, fallback string) string {
|
|
if value := envValue(key); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func envInt(key string, fallback int) int {
|
|
value := envValue(key)
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func logLevel(value string) slog.Level {
|
|
switch strings.ToLower(value) {
|
|
case "debug":
|
|
return slog.LevelDebug
|
|
case "warn", "warning":
|
|
return slog.LevelWarn
|
|
case "error":
|
|
return slog.LevelError
|
|
default:
|
|
return slog.LevelInfo
|
|
}
|
|
}
|