diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 32ad91b7..d47eadd4 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -70,6 +70,7 @@ type Config struct { JWT JWTConfig `mapstructure:"jwt"` Totp TotpConfig `mapstructure:"totp"` LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` + WeChat WeChatConnectConfig `mapstructure:"wechat_connect"` OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` Default DefaultConfig `mapstructure:"default"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` @@ -190,6 +191,25 @@ type LinuxDoConnectConfig struct { UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` } +type WeChatConnectConfig struct { + Enabled bool `mapstructure:"enabled"` + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_secret"` + OpenAppID string `mapstructure:"open_app_id"` + OpenAppSecret string `mapstructure:"open_app_secret"` + MPAppID string `mapstructure:"mp_app_id"` + MPAppSecret string `mapstructure:"mp_app_secret"` + MobileAppID string `mapstructure:"mobile_app_id"` + MobileAppSecret string `mapstructure:"mobile_app_secret"` + OpenEnabled bool `mapstructure:"open_enabled"` + MPEnabled bool `mapstructure:"mp_enabled"` + MobileEnabled bool `mapstructure:"mobile_enabled"` + Mode string `mapstructure:"mode"` + Scopes string `mapstructure:"scopes"` + RedirectURL string `mapstructure:"redirect_url"` + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` +} + type OIDCConnectConfig struct { Enabled bool `mapstructure:"enabled"` ProviderName string `mapstructure:"provider_name"` // 显示名: "Keycloak" 等 @@ -218,6 +238,217 @@ type OIDCConnectConfig struct { UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` } +const ( + defaultWeChatConnectMode = "open" + defaultWeChatConnectScopes = "snsapi_login" + defaultWeChatConnectFrontendRedirect = "/auth/wechat/callback" +) + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func normalizeWeChatConnectMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "mp": + return "mp" + case "mobile": + return "mobile" + default: + return defaultWeChatConnectMode + } +} + +func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string { + mode = normalizeWeChatConnectMode(mode) + switch mode { + case "open": + if openEnabled { + return "open" + } + case "mp": + if mpEnabled { + return "mp" + } + case "mobile": + if mobileEnabled { + return "mobile" + } + } + switch { + case openEnabled: + return "open" + case mpEnabled: + return "mp" + case mobileEnabled: + return "mobile" + default: + return mode + } +} + +func defaultWeChatConnectScopesForMode(mode string) string { + switch normalizeWeChatConnectMode(mode) { + case "mp": + return "snsapi_userinfo" + case "mobile": + return "" + default: + return defaultWeChatConnectScopes + } +} + +func normalizeWeChatConnectScopes(raw, mode string) string { + switch normalizeWeChatConnectMode(mode) { + case "mp": + switch strings.TrimSpace(raw) { + case "snsapi_base": + return "snsapi_base" + case "snsapi_userinfo": + return "snsapi_userinfo" + default: + return defaultWeChatConnectScopesForMode(mode) + } + case "mobile": + return "" + default: + return defaultWeChatConnectScopes + } +} + +func shouldApplyLegacyWeChatEnv(configKey, envKey string) bool { + if viper.InConfig(configKey) { + return false + } + _, hasNewEnv := os.LookupEnv(envKey) + return !hasNewEnv +} + +func applyLegacyWeChatConnectEnvCompatibility(cfg *WeChatConnectConfig) { + if cfg == nil { + return + } + + legacyOpenAppID := "" + if shouldApplyLegacyWeChatEnv("wechat_connect.open_app_id", "WECHAT_CONNECT_OPEN_APP_ID") && + shouldApplyLegacyWeChatEnv("wechat_connect.app_id", "WECHAT_CONNECT_APP_ID") { + legacyOpenAppID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_ID")) + if legacyOpenAppID != "" { + cfg.OpenAppID = legacyOpenAppID + } + } + + legacyOpenAppSecret := "" + if shouldApplyLegacyWeChatEnv("wechat_connect.open_app_secret", "WECHAT_CONNECT_OPEN_APP_SECRET") && + shouldApplyLegacyWeChatEnv("wechat_connect.app_secret", "WECHAT_CONNECT_APP_SECRET") { + legacyOpenAppSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_OPEN_APP_SECRET")) + if legacyOpenAppSecret != "" { + cfg.OpenAppSecret = legacyOpenAppSecret + } + } + + legacyMPAppID := "" + if shouldApplyLegacyWeChatEnv("wechat_connect.mp_app_id", "WECHAT_CONNECT_MP_APP_ID") && + shouldApplyLegacyWeChatEnv("wechat_connect.app_id", "WECHAT_CONNECT_APP_ID") { + legacyMPAppID = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_ID")) + if legacyMPAppID != "" { + cfg.MPAppID = legacyMPAppID + } + } + + legacyMPAppSecret := "" + if shouldApplyLegacyWeChatEnv("wechat_connect.mp_app_secret", "WECHAT_CONNECT_MP_APP_SECRET") && + shouldApplyLegacyWeChatEnv("wechat_connect.app_secret", "WECHAT_CONNECT_APP_SECRET") { + legacyMPAppSecret = strings.TrimSpace(os.Getenv("WECHAT_OAUTH_MP_APP_SECRET")) + if legacyMPAppSecret != "" { + cfg.MPAppSecret = legacyMPAppSecret + } + } + + if shouldApplyLegacyWeChatEnv("wechat_connect.frontend_redirect_url", "WECHAT_CONNECT_FRONTEND_REDIRECT_URL") { + if legacyFrontend := strings.TrimSpace(os.Getenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL")); legacyFrontend != "" { + cfg.FrontendRedirectURL = legacyFrontend + } + } + + hasLegacyOpen := legacyOpenAppID != "" && legacyOpenAppSecret != "" + hasLegacyMP := legacyMPAppID != "" && legacyMPAppSecret != "" + + if shouldApplyLegacyWeChatEnv("wechat_connect.enabled", "WECHAT_CONNECT_ENABLED") && (hasLegacyOpen || hasLegacyMP) { + cfg.Enabled = true + } + if shouldApplyLegacyWeChatEnv("wechat_connect.open_enabled", "WECHAT_CONNECT_OPEN_ENABLED") && hasLegacyOpen { + cfg.OpenEnabled = true + } + if shouldApplyLegacyWeChatEnv("wechat_connect.mp_enabled", "WECHAT_CONNECT_MP_ENABLED") && hasLegacyMP { + cfg.MPEnabled = true + } + if shouldApplyLegacyWeChatEnv("wechat_connect.mode", "WECHAT_CONNECT_MODE") { + switch { + case hasLegacyMP && !hasLegacyOpen: + cfg.Mode = "mp" + case hasLegacyOpen: + cfg.Mode = "open" + } + } + if shouldApplyLegacyWeChatEnv("wechat_connect.scopes", "WECHAT_CONNECT_SCOPES") { + switch { + case hasLegacyMP && !hasLegacyOpen: + cfg.Scopes = defaultWeChatConnectScopesForMode("mp") + case hasLegacyOpen: + cfg.Scopes = defaultWeChatConnectScopesForMode("open") + } + } +} + +func normalizeWeChatConnectConfig(cfg *WeChatConnectConfig) { + if cfg == nil { + return + } + + cfg.AppID = strings.TrimSpace(cfg.AppID) + cfg.AppSecret = strings.TrimSpace(cfg.AppSecret) + cfg.OpenAppID = strings.TrimSpace(cfg.OpenAppID) + cfg.OpenAppSecret = strings.TrimSpace(cfg.OpenAppSecret) + cfg.MPAppID = strings.TrimSpace(cfg.MPAppID) + cfg.MPAppSecret = strings.TrimSpace(cfg.MPAppSecret) + cfg.MobileAppID = strings.TrimSpace(cfg.MobileAppID) + cfg.MobileAppSecret = strings.TrimSpace(cfg.MobileAppSecret) + cfg.Mode = normalizeWeChatConnectMode(cfg.Mode) + cfg.RedirectURL = strings.TrimSpace(cfg.RedirectURL) + cfg.FrontendRedirectURL = strings.TrimSpace(cfg.FrontendRedirectURL) + + cfg.AppID = firstNonEmptyString(cfg.AppID, cfg.OpenAppID, cfg.MPAppID, cfg.MobileAppID) + cfg.AppSecret = firstNonEmptyString(cfg.AppSecret, cfg.OpenAppSecret, cfg.MPAppSecret, cfg.MobileAppSecret) + cfg.OpenAppID = firstNonEmptyString(cfg.OpenAppID, cfg.AppID) + cfg.OpenAppSecret = firstNonEmptyString(cfg.OpenAppSecret, cfg.AppSecret) + cfg.MPAppID = firstNonEmptyString(cfg.MPAppID, cfg.AppID) + cfg.MPAppSecret = firstNonEmptyString(cfg.MPAppSecret, cfg.AppSecret) + cfg.MobileAppID = firstNonEmptyString(cfg.MobileAppID, cfg.AppID) + cfg.MobileAppSecret = firstNonEmptyString(cfg.MobileAppSecret, cfg.AppSecret) + + if !cfg.OpenEnabled && !cfg.MPEnabled && !cfg.MobileEnabled && cfg.Enabled { + switch cfg.Mode { + case "mp": + cfg.MPEnabled = true + case "mobile": + cfg.MobileEnabled = true + default: + cfg.OpenEnabled = true + } + } + cfg.Mode = normalizeWeChatConnectStoredMode(cfg.OpenEnabled, cfg.MPEnabled, cfg.MobileEnabled, cfg.Mode) + cfg.Scopes = normalizeWeChatConnectScopes(cfg.Scopes, cfg.Mode) + if cfg.FrontendRedirectURL == "" { + cfg.FrontendRedirectURL = defaultWeChatConnectFrontendRedirect + } +} + // TokenRefreshConfig OAuth token自动刷新配置 type TokenRefreshConfig struct { // 是否启用自动刷新 @@ -1012,6 +1243,8 @@ func load(allowMissingJWTSecret bool) (*Config, error) { cfg.LinuxDo.UserInfoEmailPath = strings.TrimSpace(cfg.LinuxDo.UserInfoEmailPath) cfg.LinuxDo.UserInfoIDPath = strings.TrimSpace(cfg.LinuxDo.UserInfoIDPath) cfg.LinuxDo.UserInfoUsernamePath = strings.TrimSpace(cfg.LinuxDo.UserInfoUsernamePath) + applyLegacyWeChatConnectEnvCompatibility(&cfg.WeChat) + normalizeWeChatConnectConfig(&cfg.WeChat) cfg.OIDC.ProviderName = strings.TrimSpace(cfg.OIDC.ProviderName) cfg.OIDC.ClientID = strings.TrimSpace(cfg.OIDC.ClientID) cfg.OIDC.ClientSecret = strings.TrimSpace(cfg.OIDC.ClientSecret) @@ -1207,6 +1440,24 @@ func setDefaults() { viper.SetDefault("linuxdo_connect.userinfo_id_path", "") viper.SetDefault("linuxdo_connect.userinfo_username_path", "") + // WeChat Connect OAuth 登录 + viper.SetDefault("wechat_connect.enabled", false) + viper.SetDefault("wechat_connect.app_id", "") + viper.SetDefault("wechat_connect.app_secret", "") + viper.SetDefault("wechat_connect.open_app_id", "") + viper.SetDefault("wechat_connect.open_app_secret", "") + viper.SetDefault("wechat_connect.mp_app_id", "") + viper.SetDefault("wechat_connect.mp_app_secret", "") + viper.SetDefault("wechat_connect.mobile_app_id", "") + viper.SetDefault("wechat_connect.mobile_app_secret", "") + viper.SetDefault("wechat_connect.open_enabled", false) + viper.SetDefault("wechat_connect.mp_enabled", false) + viper.SetDefault("wechat_connect.mobile_enabled", false) + viper.SetDefault("wechat_connect.mode", defaultWeChatConnectMode) + viper.SetDefault("wechat_connect.scopes", defaultWeChatConnectScopes) + viper.SetDefault("wechat_connect.redirect_url", "") + viper.SetDefault("wechat_connect.frontend_redirect_url", defaultWeChatConnectFrontendRedirect) + // Generic OIDC OAuth 登录 viper.SetDefault("oidc_connect.enabled", false) viper.SetDefault("oidc_connect.provider_name", "OIDC") @@ -1222,8 +1473,8 @@ func setDefaults() { viper.SetDefault("oidc_connect.redirect_url", "") viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback") viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post") - viper.SetDefault("oidc_connect.use_pkce", false) - viper.SetDefault("oidc_connect.validate_id_token", false) + viper.SetDefault("oidc_connect.use_pkce", true) + viper.SetDefault("oidc_connect.validate_id_token", true) viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256") viper.SetDefault("oidc_connect.clock_skew_seconds", 120) viper.SetDefault("oidc_connect.require_email_verified", false) @@ -1664,6 +1915,45 @@ func (c *Config) Validate() error { warnIfInsecureURL("linuxdo_connect.redirect_url", c.LinuxDo.RedirectURL) warnIfInsecureURL("linuxdo_connect.frontend_redirect_url", c.LinuxDo.FrontendRedirectURL) } + if c.WeChat.Enabled { + weChat := c.WeChat + normalizeWeChatConnectConfig(&weChat) + + if weChat.OpenEnabled { + if strings.TrimSpace(weChat.OpenAppID) == "" { + return fmt.Errorf("wechat_connect.open_app_id is required when wechat_connect.open_enabled=true") + } + if strings.TrimSpace(weChat.OpenAppSecret) == "" { + return fmt.Errorf("wechat_connect.open_app_secret is required when wechat_connect.open_enabled=true") + } + } + if weChat.MPEnabled { + if strings.TrimSpace(weChat.MPAppID) == "" { + return fmt.Errorf("wechat_connect.mp_app_id is required when wechat_connect.mp_enabled=true") + } + if strings.TrimSpace(weChat.MPAppSecret) == "" { + return fmt.Errorf("wechat_connect.mp_app_secret is required when wechat_connect.mp_enabled=true") + } + } + if weChat.MobileEnabled { + if strings.TrimSpace(weChat.MobileAppID) == "" { + return fmt.Errorf("wechat_connect.mobile_app_id is required when wechat_connect.mobile_enabled=true") + } + if strings.TrimSpace(weChat.MobileAppSecret) == "" { + return fmt.Errorf("wechat_connect.mobile_app_secret is required when wechat_connect.mobile_enabled=true") + } + } + if v := strings.TrimSpace(weChat.RedirectURL); v != "" { + if err := ValidateAbsoluteHTTPURL(v); err != nil { + return fmt.Errorf("wechat_connect.redirect_url invalid: %w", err) + } + warnIfInsecureURL("wechat_connect.redirect_url", v) + } + if err := ValidateFrontendRedirectURL(weChat.FrontendRedirectURL); err != nil { + return fmt.Errorf("wechat_connect.frontend_redirect_url invalid: %w", err) + } + warnIfInsecureURL("wechat_connect.frontend_redirect_url", weChat.FrontendRedirectURL) + } if c.OIDC.Enabled { if strings.TrimSpace(c.OIDC.ClientID) == "" { return fmt.Errorf("oidc_connect.client_id is required when oidc_connect.enabled=true") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f40a5f57..8b59ef5f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -225,6 +225,37 @@ func TestLoadSchedulingConfigFromEnv(t *testing.T) { } } +func TestLoadWeChatConnectConfigFromLegacyEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("WECHAT_OAUTH_OPEN_APP_ID", "wx-open-app") + t.Setenv("WECHAT_OAUTH_OPEN_APP_SECRET", "wx-open-secret") + t.Setenv("WECHAT_OAUTH_MP_APP_ID", "wx-mp-app") + t.Setenv("WECHAT_OAUTH_MP_APP_SECRET", "wx-mp-secret") + t.Setenv("WECHAT_OAUTH_FRONTEND_REDIRECT_URL", "/auth/wechat/legacy-callback") + + cfg, err := Load() + require.NoError(t, err) + require.True(t, cfg.WeChat.Enabled) + require.True(t, cfg.WeChat.OpenEnabled) + require.True(t, cfg.WeChat.MPEnabled) + require.False(t, cfg.WeChat.MobileEnabled) + require.Equal(t, "open", cfg.WeChat.Mode) + require.Equal(t, "wx-open-app", cfg.WeChat.OpenAppID) + require.Equal(t, "wx-open-secret", cfg.WeChat.OpenAppSecret) + require.Equal(t, "wx-mp-app", cfg.WeChat.MPAppID) + require.Equal(t, "wx-mp-secret", cfg.WeChat.MPAppSecret) + require.Equal(t, "/auth/wechat/legacy-callback", cfg.WeChat.FrontendRedirectURL) +} + +func TestLoadDefaultOIDCSecurityDefaults(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + require.NoError(t, err) + require.True(t, cfg.OIDC.UsePKCE) + require.True(t, cfg.OIDC.ValidateIDToken) +} + func TestLoadForcedCodexInstructionsTemplate(t *testing.T) { resetViperWithJWTSecret(t) @@ -424,7 +455,7 @@ func TestValidateOIDCAllowsIssuerOnlyEndpointsWithDiscoveryFallback(t *testing.T } } -func TestValidateOIDCAllowsDisablingPKCEAndIDTokenValidation(t *testing.T) { +func TestValidateOIDCAllowsExplicitCompatibilityOverridesForPKCEAndIDTokenValidation(t *testing.T) { resetViperWithJWTSecret(t) cfg, err := Load() diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index f85f199b..d340a8a6 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -565,6 +565,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.WeChatConnectScopes = strings.TrimSpace(req.WeChatConnectScopes) req.WeChatConnectRedirectURL = strings.TrimSpace(req.WeChatConnectRedirectURL) req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(req.WeChatConnectFrontendRedirectURL) + req.WeChatConnectAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectAppID, previousSettings.WeChatConnectAppID)) + req.WeChatConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectRedirectURL, previousSettings.WeChatConnectRedirectURL)) + req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectFrontendRedirectURL, previousSettings.WeChatConnectFrontendRedirectURL)) + if req.WeChatConnectMode == "" { + req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(previousSettings.WeChatConnectMode)) + } + if req.WeChatConnectScopes == "" { + req.WeChatConnectScopes = strings.TrimSpace(previousSettings.WeChatConnectScopes) + } if req.WeChatConnectMPEnabled && req.WeChatConnectMobileEnabled { response.BadRequest(c, "WeChat Official Account and Mobile App cannot be enabled at the same time") @@ -598,9 +607,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } - req.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppID, req.WeChatConnectAppID)) - req.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMPAppID, req.WeChatConnectAppID)) - req.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMobileAppID, req.WeChatConnectAppID)) + req.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectOpenAppID, previousSettings.WeChatConnectAppID)) + req.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMPAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMPAppID, previousSettings.WeChatConnectAppID)) + req.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMobileAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMobileAppID, previousSettings.WeChatConnectAppID)) if req.WeChatConnectOpenAppSecret == "" { req.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectOpenAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret)) @@ -691,10 +700,35 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath) req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath) req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath) - - if req.OIDCConnectProviderName == "" { - req.OIDCConnectProviderName = "OIDC" + req.OIDCConnectProviderName = strings.TrimSpace(firstNonEmpty(req.OIDCConnectProviderName, previousSettings.OIDCConnectProviderName, "OIDC")) + req.OIDCConnectClientID = strings.TrimSpace(firstNonEmpty(req.OIDCConnectClientID, previousSettings.OIDCConnectClientID)) + req.OIDCConnectIssuerURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectIssuerURL, previousSettings.OIDCConnectIssuerURL)) + req.OIDCConnectDiscoveryURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectDiscoveryURL, previousSettings.OIDCConnectDiscoveryURL)) + req.OIDCConnectAuthorizeURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAuthorizeURL, previousSettings.OIDCConnectAuthorizeURL)) + req.OIDCConnectTokenURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenURL, previousSettings.OIDCConnectTokenURL)) + req.OIDCConnectUserInfoURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoURL, previousSettings.OIDCConnectUserInfoURL)) + req.OIDCConnectJWKSURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectJWKSURL, previousSettings.OIDCConnectJWKSURL)) + req.OIDCConnectScopes = strings.TrimSpace(firstNonEmpty(req.OIDCConnectScopes, previousSettings.OIDCConnectScopes, "openid email profile")) + req.OIDCConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectRedirectURL, previousSettings.OIDCConnectRedirectURL)) + req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectFrontendRedirectURL, previousSettings.OIDCConnectFrontendRedirectURL, "/auth/oidc/callback")) + req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenAuthMethod, previousSettings.OIDCConnectTokenAuthMethod, "client_secret_post"))) + req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAllowedSigningAlgs, previousSettings.OIDCConnectAllowedSigningAlgs, "RS256,ES256,PS256")) + req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath)) + req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath)) + req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath)) + if !req.OIDCConnectUsePKCE { + req.OIDCConnectUsePKCE = previousSettings.OIDCConnectUsePKCE } + if !req.OIDCConnectValidateIDToken { + req.OIDCConnectValidateIDToken = previousSettings.OIDCConnectValidateIDToken + } + if req.OIDCConnectClockSkewSeconds == 0 { + req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds + if req.OIDCConnectClockSkewSeconds == 0 { + req.OIDCConnectClockSkewSeconds = 120 + } + } + if req.OIDCConnectClientID == "" { response.BadRequest(c, "OIDC Client ID is required when enabled") return diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index ed7764cf..3d933dbc 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -784,6 +784,198 @@ func TestAPIContracts(t *testing.T) { } }`, }, + { + name: "GET /api/v1/admin/settings falls back to config oauth defaults", + setup: func(t *testing.T, deps *contractDeps) { + t.Helper() + deps.cfg.OIDC = config.OIDCConnectConfig{ + Enabled: true, + ProviderName: "ConfigOIDC", + ClientID: "oidc-config-client", + ClientSecret: "oidc-config-secret", + IssuerURL: "https://issuer.example.com", + RedirectURL: "https://api.example.com/api/v1/auth/oauth/oidc/callback", + FrontendRedirectURL: "/auth/oidc/callback", + Scopes: "openid email profile", + TokenAuthMethod: "client_secret_post", + UsePKCE: true, + ValidateIDToken: true, + AllowedSigningAlgs: "RS256,ES256,PS256", + ClockSkewSeconds: 120, + } + deps.cfg.WeChat = config.WeChatConnectConfig{ + Enabled: true, + OpenEnabled: true, + OpenAppID: "wx-open-config", + OpenAppSecret: "wx-open-secret", + Mode: "open", + Scopes: "snsapi_login", + FrontendRedirectURL: "/auth/wechat/callback", + } + deps.settingRepo.SetAll(map[string]string{ + service.SettingKeyRegistrationEnabled: "true", + service.SettingKeyEmailVerifyEnabled: "false", + service.SettingKeyRegistrationEmailSuffixWhitelist: "[]", + }) + }, + method: http.MethodGet, + path: "/api/v1/admin/settings", + wantStatus: http.StatusOK, + wantJSON: `{ + "code": 0, + "message": "success", + "data": { + "registration_enabled": true, + "email_verify_enabled": false, + "registration_email_suffix_whitelist": [], + "promo_code_enabled": true, + "password_reset_enabled": false, + "frontend_url": "", + "invitation_code_enabled": false, + "totp_enabled": false, + "totp_encryption_key_configured": false, + "smtp_host": "", + "smtp_port": 587, + "smtp_username": "", + "smtp_password_configured": false, + "smtp_from_email": "", + "smtp_from_name": "", + "smtp_use_tls": false, + "turnstile_enabled": false, + "turnstile_site_key": "", + "turnstile_secret_key_configured": false, + "linuxdo_connect_enabled": false, + "linuxdo_connect_client_id": "", + "linuxdo_connect_client_secret_configured": false, + "linuxdo_connect_redirect_url": "", + "oidc_connect_enabled": true, + "oidc_connect_provider_name": "ConfigOIDC", + "oidc_connect_client_id": "oidc-config-client", + "oidc_connect_client_secret_configured": true, + "oidc_connect_issuer_url": "https://issuer.example.com", + "oidc_connect_discovery_url": "", + "oidc_connect_authorize_url": "", + "oidc_connect_token_url": "", + "oidc_connect_userinfo_url": "", + "oidc_connect_jwks_url": "", + "oidc_connect_scopes": "openid email profile", + "oidc_connect_redirect_url": "https://api.example.com/api/v1/auth/oauth/oidc/callback", + "oidc_connect_frontend_redirect_url": "/auth/oidc/callback", + "oidc_connect_token_auth_method": "client_secret_post", + "oidc_connect_use_pkce": true, + "oidc_connect_validate_id_token": true, + "oidc_connect_allowed_signing_algs": "RS256,ES256,PS256", + "oidc_connect_clock_skew_seconds": 120, + "oidc_connect_require_email_verified": false, + "oidc_connect_userinfo_email_path": "", + "oidc_connect_userinfo_id_path": "", + "oidc_connect_userinfo_username_path": "", + "site_name": "Sub2API", + "site_logo": "", + "site_subtitle": "Subscription to API Conversion Platform", + "api_base_url": "", + "contact_info": "", + "doc_url": "", + "home_content": "", + "hide_ccs_import_button": false, + "purchase_subscription_enabled": false, + "purchase_subscription_url": "", + "table_default_page_size": 20, + "table_page_size_options": [10, 20, 50], + "custom_menu_items": [], + "custom_endpoints": [], + "default_concurrency": 0, + "default_balance": 0, + "default_subscriptions": [], + "enable_model_fallback": false, + "fallback_model_anthropic": "claude-3-5-sonnet-20241022", + "fallback_model_openai": "gpt-4o", + "fallback_model_gemini": "gemini-2.5-pro", + "fallback_model_antigravity": "gemini-2.5-pro", + "enable_identity_patch": true, + "identity_patch_prompt": "", + "ops_monitoring_enabled": false, + "ops_realtime_monitoring_enabled": true, + "ops_query_mode_default": "auto", + "ops_metrics_interval_seconds": 60, + "min_claude_code_version": "", + "max_claude_code_version": "", + "allow_ungrouped_key_scheduling": false, + "backend_mode_enabled": false, + "enable_fingerprint_unification": true, + "enable_metadata_passthrough": false, + "enable_cch_signing": false, + "web_search_emulation_enabled": false, + "payment_visible_method_alipay_source": "", + "payment_visible_method_wxpay_source": "", + "payment_visible_method_alipay_enabled": false, + "payment_visible_method_wxpay_enabled": false, + "openai_advanced_scheduler_enabled": false, + "payment_enabled": false, + "payment_min_amount": 0, + "payment_max_amount": 0, + "payment_daily_limit": 0, + "payment_order_timeout_minutes": 0, + "payment_max_pending_orders": 0, + "payment_enabled_types": null, + "payment_balance_disabled": false, + "payment_balance_recharge_multiplier": 0, + "payment_recharge_fee_rate": 0, + "payment_load_balance_strategy": "", + "payment_product_name_prefix": "", + "payment_product_name_suffix": "", + "payment_help_image_url": "", + "payment_help_text": "", + "payment_cancel_rate_limit_enabled": false, + "payment_cancel_rate_limit_max": 0, + "payment_cancel_rate_limit_window": 0, + "payment_cancel_rate_limit_unit": "", + "payment_cancel_rate_limit_window_mode": "", + "balance_low_notify_enabled": false, + "account_quota_notify_enabled": false, + "balance_low_notify_threshold": 0, + "balance_low_notify_recharge_url": "", + "account_quota_notify_emails": [], + "wechat_connect_enabled": true, + "wechat_connect_app_id": "wx-open-config", + "wechat_connect_app_secret_configured": true, + "wechat_connect_mode": "open", + "wechat_connect_open_enabled": true, + "wechat_connect_open_app_id": "wx-open-config", + "wechat_connect_open_app_secret_configured": true, + "wechat_connect_mp_enabled": false, + "wechat_connect_mp_app_id": "wx-open-config", + "wechat_connect_mp_app_secret_configured": true, + "wechat_connect_mobile_enabled": false, + "wechat_connect_mobile_app_id": "wx-open-config", + "wechat_connect_mobile_app_secret_configured": true, + "wechat_connect_redirect_url": "", + "wechat_connect_frontend_redirect_url": "/auth/wechat/callback", + "wechat_connect_scopes": "snsapi_login", + "auth_source_default_email_balance": 0, + "auth_source_default_email_concurrency": 5, + "auth_source_default_email_subscriptions": [], + "auth_source_default_email_grant_on_signup": false, + "auth_source_default_email_grant_on_first_bind": false, + "auth_source_default_linuxdo_balance": 0, + "auth_source_default_linuxdo_concurrency": 5, + "auth_source_default_linuxdo_subscriptions": [], + "auth_source_default_linuxdo_grant_on_signup": false, + "auth_source_default_linuxdo_grant_on_first_bind": false, + "auth_source_default_oidc_balance": 0, + "auth_source_default_oidc_concurrency": 5, + "auth_source_default_oidc_subscriptions": [], + "auth_source_default_oidc_grant_on_signup": false, + "auth_source_default_oidc_grant_on_first_bind": false, + "auth_source_default_wechat_balance": 0, + "auth_source_default_wechat_concurrency": 5, + "auth_source_default_wechat_subscriptions": [], + "auth_source_default_wechat_grant_on_signup": false, + "auth_source_default_wechat_grant_on_first_bind": false, + "force_email_on_third_party_signup": false + } + }`, + }, { name: "POST /api/v1/admin/accounts/bulk-update", method: http.MethodPost, @@ -827,6 +1019,7 @@ func TestAPIContracts(t *testing.T) { type contractDeps struct { now time.Time router http.Handler + cfg *config.Config apiKeyRepo *stubApiKeyRepo groupRepo *stubGroupRepo userSubRepo *stubUserSubscriptionRepo @@ -947,6 +1140,7 @@ func newContractDeps(t *testing.T) *contractDeps { return &contractDeps{ now: now, router: r, + cfg: cfg, apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userSubRepo: userSubRepo, diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 059bbcd3..72569882 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -245,15 +245,107 @@ func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bo } func normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled bool, mode string) string { + mode = normalizeWeChatConnectModeSetting(mode) + switch mode { + case "open": + if openEnabled { + return "open" + } + case "mp": + if mpEnabled { + return "mp" + } + case "mobile": + if mobileEnabled { + return "mobile" + } + } switch { + case openEnabled: + return "open" case mpEnabled: return "mp" case mobileEnabled: return "mobile" - case openEnabled: - return "open" default: - return normalizeWeChatConnectModeSetting(mode) + return mode + } +} + +func mergeWeChatConnectCapabilitySettings(settings map[string]string, base config.WeChatConnectConfig, enabled bool, mode string) (bool, bool, bool) { + mode = normalizeWeChatConnectModeSetting(firstNonEmpty(mode, base.Mode)) + rawOpen, hasOpen := settings[SettingKeyWeChatConnectOpenEnabled] + rawMP, hasMP := settings[SettingKeyWeChatConnectMPEnabled] + rawMobile, hasMobile := settings[SettingKeyWeChatConnectMobileEnabled] + openConfigured := hasOpen && strings.TrimSpace(rawOpen) != "" + mpConfigured := hasMP && strings.TrimSpace(rawMP) != "" + mobileConfigured := hasMobile && strings.TrimSpace(rawMobile) != "" + + if openConfigured || mpConfigured || mobileConfigured { + return parseWeChatConnectCapabilitySettings(settings, enabled, mode) + } + if !enabled { + return false, false, false + } + if base.OpenEnabled || base.MPEnabled || base.MobileEnabled { + return base.OpenEnabled, base.MPEnabled, base.MobileEnabled + } + return parseWeChatConnectCapabilitySettings(settings, enabled, mode) +} + +func (s *SettingService) effectiveWeChatConnectOAuthConfig(settings map[string]string) WeChatConnectOAuthConfig { + base := config.WeChatConnectConfig{} + if s != nil && s.cfg != nil { + base = s.cfg.WeChat + } + + enabled := base.Enabled + if raw, ok := settings[SettingKeyWeChatConnectEnabled]; ok { + enabled = strings.TrimSpace(raw) == "true" + } + + legacyAppID := strings.TrimSpace(firstNonEmpty( + settings[SettingKeyWeChatConnectAppID], + base.AppID, + base.OpenAppID, + base.MPAppID, + base.MobileAppID, + )) + legacyAppSecret := strings.TrimSpace(firstNonEmpty( + settings[SettingKeyWeChatConnectAppSecret], + base.AppSecret, + base.OpenAppSecret, + base.MPAppSecret, + base.MobileAppSecret, + )) + openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], base.OpenAppID, legacyAppID)) + openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], base.OpenAppSecret, legacyAppSecret)) + mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], base.MPAppID, legacyAppID)) + mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], base.MPAppSecret, legacyAppSecret)) + mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], base.MobileAppID, legacyAppID)) + mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], base.MobileAppSecret, legacyAppSecret)) + + modeRaw := firstNonEmpty(settings[SettingKeyWeChatConnectMode], base.Mode) + openEnabled, mpEnabled, mobileEnabled := mergeWeChatConnectCapabilitySettings(settings, base, enabled, modeRaw) + mode := normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, modeRaw) + + return WeChatConnectOAuthConfig{ + Enabled: enabled, + LegacyAppID: legacyAppID, + LegacyAppSecret: legacyAppSecret, + OpenAppID: openAppID, + OpenAppSecret: openAppSecret, + MPAppID: mpAppID, + MPAppSecret: mpAppSecret, + MobileAppID: mobileAppID, + MobileAppSecret: mobileAppSecret, + OpenEnabled: openEnabled, + MPEnabled: mpEnabled, + MobileEnabled: mobileEnabled, + Mode: mode, + Scopes: normalizeWeChatConnectScopeSetting(firstNonEmpty(settings[SettingKeyWeChatConnectScopes], base.Scopes), mode), + RedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectRedirectURL], base.RedirectURL)), + FrontendRedirectURL: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectFrontendRedirectURL], base.FrontendRedirectURL, defaultWeChatConnectFrontend)), } } @@ -535,32 +627,7 @@ func DefaultWeChatConnectScopesForMode(mode string) string { } func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) { - enabled := settings[SettingKeyWeChatConnectEnabled] == "true" - mode := normalizeWeChatConnectModeSetting(settings[SettingKeyWeChatConnectMode]) - openEnabled, mpEnabled, mobileEnabled := parseWeChatConnectCapabilitySettings(settings, enabled, mode) - mode = normalizeWeChatConnectStoredMode(openEnabled, mpEnabled, mobileEnabled, mode) - - cfg := WeChatConnectOAuthConfig{ - Enabled: enabled, - LegacyAppID: strings.TrimSpace(settings[SettingKeyWeChatConnectAppID]), - LegacyAppSecret: strings.TrimSpace(settings[SettingKeyWeChatConnectAppSecret]), - OpenAppID: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], settings[SettingKeyWeChatConnectAppID])), - OpenAppSecret: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], settings[SettingKeyWeChatConnectAppSecret])), - MPAppID: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], settings[SettingKeyWeChatConnectAppID])), - MPAppSecret: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], settings[SettingKeyWeChatConnectAppSecret])), - MobileAppID: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], settings[SettingKeyWeChatConnectAppID])), - MobileAppSecret: strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], settings[SettingKeyWeChatConnectAppSecret])), - OpenEnabled: openEnabled, - MPEnabled: mpEnabled, - MobileEnabled: mobileEnabled, - Mode: mode, - Scopes: normalizeWeChatConnectScopeSetting(settings[SettingKeyWeChatConnectScopes], mode), - RedirectURL: strings.TrimSpace(settings[SettingKeyWeChatConnectRedirectURL]), - FrontendRedirectURL: strings.TrimSpace(settings[SettingKeyWeChatConnectFrontendRedirectURL]), - } - if cfg.FrontendRedirectURL == "" { - cfg.FrontendRedirectURL = defaultWeChatConnectFrontend - } + cfg := s.effectiveWeChatConnectOAuthConfig(settings) if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) { return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") @@ -589,14 +656,10 @@ func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]strin return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured") } } - if cfg.RedirectURL == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url not configured") - } - if cfg.FrontendRedirectURL == "" { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url not configured") - } - if err := config.ValidateAbsoluteHTTPURL(cfg.RedirectURL); err != nil { - return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid") + if v := strings.TrimSpace(cfg.RedirectURL); v != "" { + if err := config.ValidateAbsoluteHTTPURL(v); err != nil { + return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth redirect url invalid") + } } if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth frontend redirect url invalid") @@ -605,31 +668,14 @@ func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]strin } func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) { - if settings[SettingKeyWeChatConnectEnabled] != "true" { + cfg := s.effectiveWeChatConnectOAuthConfig(settings) + if !cfg.Enabled { return false, false, false, false } - mode := normalizeWeChatConnectModeSetting(settings[SettingKeyWeChatConnectMode]) - openEnabled, mpEnabled, mobileEnabled := parseWeChatConnectCapabilitySettings(settings, true, mode) - redirectURL := strings.TrimSpace(settings[SettingKeyWeChatConnectRedirectURL]) - frontendRedirectURL := strings.TrimSpace(settings[SettingKeyWeChatConnectFrontendRedirectURL]) - if frontendRedirectURL == "" { - frontendRedirectURL = defaultWeChatConnectFrontend - } - - legacyAppID := strings.TrimSpace(settings[SettingKeyWeChatConnectAppID]) - legacyAppSecret := strings.TrimSpace(settings[SettingKeyWeChatConnectAppSecret]) - openAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], legacyAppID)) - openAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], legacyAppSecret)) - mpAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], legacyAppID)) - mpAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], legacyAppSecret)) - mobileAppID := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], legacyAppID)) - mobileAppSecret := strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], legacyAppSecret)) - - webRedirectReady := redirectURL != "" && frontendRedirectURL != "" - openReady := openEnabled && webRedirectReady && openAppID != "" && openAppSecret != "" - mpReady := mpEnabled && webRedirectReady && mpAppID != "" && mpAppSecret != "" - mobileReady := mobileEnabled && mobileAppID != "" && mobileAppSecret != "" + openReady := cfg.OpenEnabled && cfg.AppIDForMode("open") != "" && cfg.AppSecretForMode("open") != "" + mpReady := cfg.MPEnabled && cfg.AppIDForMode("mp") != "" && cfg.AppSecretForMode("mp") != "" + mobileReady := cfg.MobileEnabled && cfg.AppIDForMode("mobile") != "" && cfg.AppSecretForMode("mobile") != "" return openReady || mpReady, openReady, mpReady, mobileReady } @@ -1436,6 +1482,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyCustomMenuItems: "[]", SettingKeyCustomEndpoints: "[]", SettingKeyWeChatConnectEnabled: "false", + SettingKeyWeChatConnectAppID: "", + SettingKeyWeChatConnectAppSecret: "", SettingKeyWeChatConnectOpenAppID: "", SettingKeyWeChatConnectOpenAppSecret: "", SettingKeyWeChatConnectMPAppID: "", @@ -1447,9 +1495,30 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyWeChatConnectMobileEnabled: "false", SettingKeyWeChatConnectMode: "open", SettingKeyWeChatConnectScopes: "snsapi_login", + SettingKeyWeChatConnectRedirectURL: "", SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, SettingKeyOIDCConnectEnabled: "false", SettingKeyOIDCConnectProviderName: "OIDC", + SettingKeyOIDCConnectClientID: "", + SettingKeyOIDCConnectClientSecret: "", + SettingKeyOIDCConnectIssuerURL: "", + SettingKeyOIDCConnectDiscoveryURL: "", + SettingKeyOIDCConnectAuthorizeURL: "", + SettingKeyOIDCConnectTokenURL: "", + SettingKeyOIDCConnectUserInfoURL: "", + SettingKeyOIDCConnectJWKSURL: "", + SettingKeyOIDCConnectScopes: "openid email profile", + SettingKeyOIDCConnectRedirectURL: "", + SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback", + SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post", + SettingKeyOIDCConnectUsePKCE: "true", + SettingKeyOIDCConnectValidateIDToken: "true", + SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256", + SettingKeyOIDCConnectClockSkewSeconds: "120", + SettingKeyOIDCConnectRequireEmailVerified: "false", + SettingKeyOIDCConnectUserInfoEmailPath: "", + SettingKeyOIDCConnectUserInfoIDPath: "", + SettingKeyOIDCConnectUserInfoUsernamePath: "", SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyDefaultSubscriptions: "[]", @@ -1737,37 +1806,30 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" - // WeChat Connect 设置:完全以 DB 系统设置为准。 - result.WeChatConnectEnabled = settings[SettingKeyWeChatConnectEnabled] == "true" - result.WeChatConnectAppID = strings.TrimSpace(settings[SettingKeyWeChatConnectAppID]) - result.WeChatConnectAppSecret = strings.TrimSpace(settings[SettingKeyWeChatConnectAppSecret]) - result.WeChatConnectAppSecretConfigured = result.WeChatConnectAppSecret != "" - result.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppID], result.WeChatConnectAppID)) - result.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectOpenAppSecret], result.WeChatConnectAppSecret)) - result.WeChatConnectOpenAppSecretConfigured = result.WeChatConnectOpenAppSecret != "" - result.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppID], result.WeChatConnectAppID)) - result.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMPAppSecret], result.WeChatConnectAppSecret)) - result.WeChatConnectMPAppSecretConfigured = result.WeChatConnectMPAppSecret != "" - result.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppID], result.WeChatConnectAppID)) - result.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings[SettingKeyWeChatConnectMobileAppSecret], result.WeChatConnectAppSecret)) - result.WeChatConnectMobileAppSecretConfigured = result.WeChatConnectMobileAppSecret != "" - result.WeChatConnectOpenEnabled, result.WeChatConnectMPEnabled, result.WeChatConnectMobileEnabled = parseWeChatConnectCapabilitySettings( - settings, - result.WeChatConnectEnabled, - settings[SettingKeyWeChatConnectMode], - ) - result.WeChatConnectMode = normalizeWeChatConnectStoredMode( - result.WeChatConnectOpenEnabled, - result.WeChatConnectMPEnabled, - result.WeChatConnectMobileEnabled, - settings[SettingKeyWeChatConnectMode], - ) - result.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings[SettingKeyWeChatConnectScopes], result.WeChatConnectMode) - result.WeChatConnectRedirectURL = strings.TrimSpace(settings[SettingKeyWeChatConnectRedirectURL]) - result.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings[SettingKeyWeChatConnectFrontendRedirectURL]) - if result.WeChatConnectFrontendRedirectURL == "" { - result.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend - } + // WeChat Connect 设置: + // - 优先读取 DB 系统设置 + // - 缺失时回退到 config/env,保持升级兼容 + weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings) + result.WeChatConnectEnabled = weChatEffective.Enabled + result.WeChatConnectAppID = weChatEffective.LegacyAppID + result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret + result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != "" + result.WeChatConnectOpenAppID = weChatEffective.OpenAppID + result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret + result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != "" + result.WeChatConnectMPAppID = weChatEffective.MPAppID + result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret + result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != "" + result.WeChatConnectMobileAppID = weChatEffective.MobileAppID + result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret + result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != "" + result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled + result.WeChatConnectMPEnabled = weChatEffective.MPEnabled + result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled + result.WeChatConnectMode = weChatEffective.Mode + result.WeChatConnectScopes = weChatEffective.Scopes + result.WeChatConnectRedirectURL = weChatEffective.RedirectURL + result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL // Model fallback settings result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true" diff --git a/backend/internal/service/setting_service_oidc_config_test.go b/backend/internal/service/setting_service_oidc_config_test.go index a5a3959a..eb312d2c 100644 --- a/backend/internal/service/setting_service_oidc_config_test.go +++ b/backend/internal/service/setting_service_oidc_config_test.go @@ -115,6 +115,22 @@ func TestSettingService_ParseSettings_PreservesOptionalOIDCCompatibilityFlags(t require.False(t, got.OIDCConnectValidateIDToken) } +func TestSettingService_ParseSettings_DefaultsOIDCSecurityFlagsToSafeConfigValues(t *testing.T) { + svc := NewSettingService(&settingOIDCRepoStub{values: map[string]string{}}, &config.Config{ + OIDC: config.OIDCConnectConfig{ + UsePKCE: true, + ValidateIDToken: true, + }, + }) + + got := svc.parseSettings(map[string]string{ + SettingKeyOIDCConnectEnabled: "true", + }) + + require.True(t, got.OIDCConnectUsePKCE) + require.True(t, got.OIDCConnectValidateIDToken) +} + func TestGetOIDCConnectOAuthConfig_AllowsCompatibilityFlagsToDisablePKCEAndIDTokenValidation(t *testing.T) { cfg := &config.Config{ OIDC: config.OIDCConnectConfig{ @@ -145,3 +161,37 @@ func TestGetOIDCConnectOAuthConfig_AllowsCompatibilityFlagsToDisablePKCEAndIDTok require.False(t, got.UsePKCE) require.False(t, got.ValidateIDToken) } + +func TestGetOIDCConnectOAuthConfig_DefaultsToSecureFlagsWhenSettingsMissing(t *testing.T) { + cfg := &config.Config{ + OIDC: config.OIDCConnectConfig{ + Enabled: true, + ProviderName: "OIDC", + ClientID: "oidc-client", + ClientSecret: "oidc-secret", + IssuerURL: "https://issuer.example.com", + AuthorizeURL: "https://issuer.example.com/auth", + TokenURL: "https://issuer.example.com/token", + UserInfoURL: "https://issuer.example.com/userinfo", + JWKSURL: "https://issuer.example.com/jwks", + RedirectURL: "https://example.com/api/v1/auth/oauth/oidc/callback", + FrontendRedirectURL: "/auth/oidc/callback", + Scopes: "openid email profile", + TokenAuthMethod: "client_secret_post", + UsePKCE: true, + ValidateIDToken: true, + AllowedSigningAlgs: "RS256", + ClockSkewSeconds: 120, + }, + } + + repo := &settingOIDCRepoStub{values: map[string]string{ + SettingKeyOIDCConnectEnabled: "true", + }} + svc := NewSettingService(repo, cfg) + + got, err := svc.GetOIDCConnectOAuthConfig(context.Background()) + require.NoError(t, err) + require.True(t, got.UsePKCE) + require.True(t, got.ValidateIDToken) +} diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 4c7ca14b..1ecd4e6f 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -132,3 +132,22 @@ func TestSettingService_GetPublicSettings_DoesNotExposeMobileOnlyWeChatAsWebOAut require.False(t, settings.WeChatOAuthMPEnabled) require.True(t, settings.WeChatOAuthMobileEnabled) } + +func TestSettingService_GetPublicSettings_FallsBackToConfigForWeChatOAuthCapabilities(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{ + WeChat: config.WeChatConnectConfig{ + Enabled: true, + OpenEnabled: true, + OpenAppID: "wx-open-config", + OpenAppSecret: "wx-open-secret", + FrontendRedirectURL: "/auth/wechat/config-callback", + }, + }) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.WeChatOAuthEnabled) + require.True(t, settings.WeChatOAuthOpenEnabled) + require.False(t, settings.WeChatOAuthMPEnabled) + require.False(t, settings.WeChatOAuthMobileEnabled) +} diff --git a/backend/internal/service/setting_service_wechat_config_test.go b/backend/internal/service/setting_service_wechat_config_test.go index 73d86e8f..08f67b7c 100644 --- a/backend/internal/service/setting_service_wechat_config_test.go +++ b/backend/internal/service/setting_service_wechat_config_test.go @@ -79,3 +79,54 @@ func TestSettingService_GetWeChatConnectOAuthConfig_UsesDatabaseOverrides(t *tes require.Equal(t, "https://api.example.com/api/v1/auth/oauth/wechat/callback", got.RedirectURL) require.Equal(t, "/auth/wechat/callback", got.FrontendRedirectURL) } + +func TestSettingService_GetWeChatConnectOAuthConfig_FallsBackToConfigWhenDatabaseEmpty(t *testing.T) { + repo := &settingWeChatRepoStub{values: map[string]string{}} + svc := NewSettingService(repo, &config.Config{ + WeChat: config.WeChatConnectConfig{ + Enabled: true, + OpenEnabled: true, + MPEnabled: true, + Mode: "open", + OpenAppID: "wx-open-config", + OpenAppSecret: "wx-open-secret", + MPAppID: "wx-mp-config", + MPAppSecret: "wx-mp-secret", + FrontendRedirectURL: "/auth/wechat/config-callback", + }, + }) + + got, err := svc.GetWeChatConnectOAuthConfig(context.Background()) + require.NoError(t, err) + require.True(t, got.Enabled) + require.True(t, got.OpenEnabled) + require.True(t, got.MPEnabled) + require.Equal(t, "wx-open-config", got.AppIDForMode("open")) + require.Equal(t, "wx-open-secret", got.AppSecretForMode("open")) + require.Equal(t, "wx-mp-config", got.AppIDForMode("mp")) + require.Equal(t, "wx-mp-secret", got.AppSecretForMode("mp")) + require.Equal(t, "/auth/wechat/config-callback", got.FrontendRedirectURL) + require.Empty(t, got.RedirectURL) +} + +func TestSettingService_ParseSettings_FallsBackToConfigForWeChatAdminView(t *testing.T) { + svc := NewSettingService(&settingWeChatRepoStub{values: map[string]string{}}, &config.Config{ + WeChat: config.WeChatConnectConfig{ + Enabled: true, + OpenEnabled: true, + Mode: "open", + OpenAppID: "wx-open-config", + OpenAppSecret: "wx-open-secret", + FrontendRedirectURL: "/auth/wechat/config-callback", + }, + }) + + got := svc.parseSettings(map[string]string{}) + require.True(t, got.WeChatConnectEnabled) + require.True(t, got.WeChatConnectOpenEnabled) + require.Equal(t, "wx-open-config", got.WeChatConnectOpenAppID) + require.True(t, got.WeChatConnectOpenAppSecretConfigured) + require.Equal(t, "/auth/wechat/config-callback", got.WeChatConnectFrontendRedirectURL) + require.Equal(t, "open", got.WeChatConnectMode) + require.Equal(t, "snsapi_login", got.WeChatConnectScopes) +}