package service import ( "context" "encoding/json" "fmt" "log/slog" "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/imroc/req/v3" ) // CoerceDingTalkCorpPolicyForWrite 是 coerceDeprecatedDingTalkCorpPolicy 的导出版本, // 用于 admin handler 在写入路径上对客户端直传的入参做防御性 coerce(前端 UI 虽已无 whitelist 选项, // 但 API 可被直接调用)。 func CoerceDingTalkCorpPolicyForWrite(policy string) string { return coerceDeprecatedDingTalkCorpPolicy(policy) } // coerceDeprecatedDingTalkCorpPolicy 把已废弃的 corp_restriction_policy 值替换成安全的等价值。 // 升级前残留在 DB 中的 "whitelist" 会导致 callback 链路在 default case 静默 fail-closed // (所有钉钉登录被拒)。这里统一退化为 "none" 让服务保持可用,并 warn 日志提醒 admin 重新保存设置。 func coerceDeprecatedDingTalkCorpPolicy(policy string) string { if policy == "whitelist" { slog.Warn("dingtalk: corp_restriction_policy=whitelist is deprecated and unsupported, coercing to none", "hint", "re-save DingTalk settings in admin UI to clear this warning") return "none" } return policy } func normalizeWeChatConnectModeSetting(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "mp": return "mp" case "mobile": return "mobile" default: return "open" } } func defaultWeChatConnectScopeForMode(mode string) string { switch normalizeWeChatConnectModeSetting(mode) { case "mp": return "snsapi_userinfo" case "mobile": return "" } return defaultWeChatConnectScopes } func normalizeWeChatConnectScopeSetting(raw, mode string) string { switch normalizeWeChatConnectModeSetting(mode) { case "mp": switch strings.TrimSpace(raw) { case "snsapi_base": return "snsapi_base" case "snsapi_userinfo": return "snsapi_userinfo" default: return defaultWeChatConnectScopeForMode(mode) } case "mobile": return "" default: return defaultWeChatConnectScopes } } func parseWeChatConnectCapabilitySettings(settings map[string]string, enabled bool, mode string) (bool, bool, bool) { mode = normalizeWeChatConnectModeSetting(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 { openEnabled := strings.TrimSpace(rawOpen) == "true" mpEnabled := strings.TrimSpace(rawMP) == "true" mobileEnabled := strings.TrimSpace(rawMobile) == "true" return openEnabled, mpEnabled, mobileEnabled } if !enabled { return false, false, false } if mode == "mp" { return false, true, false } if mode == "mobile" { return false, false, true } return true, false, false } 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" default: 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 { openEnabled := strings.TrimSpace(rawOpen) == "true" mpEnabled := strings.TrimSpace(rawMP) == "true" mobileEnabled := strings.TrimSpace(rawMobile) == "true" _, enabledConfigured := settings[SettingKeyWeChatConnectEnabled] if !enabledConfigured && enabled && !openEnabled && !mpEnabled && !mobileEnabled && (base.OpenEnabled || base.MPEnabled || base.MobileEnabled) { return base.OpenEnabled, base.MPEnabled, base.MobileEnabled } return openEnabled, mpEnabled, mobileEnabled } 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)), } } func DefaultWeChatConnectScopesForMode(mode string) string { return defaultWeChatConnectScopeForMode(mode) } func (s *SettingService) parseWeChatConnectOAuthConfig(settings map[string]string) (WeChatConnectOAuthConfig, error) { cfg := s.effectiveWeChatConnectOAuthConfig(settings) if !cfg.Enabled || (!cfg.OpenEnabled && !cfg.MPEnabled) { return WeChatConnectOAuthConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "wechat oauth is disabled") } if cfg.OpenEnabled { if cfg.AppIDForMode("open") == "" { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app id not configured") } if cfg.AppSecretForMode("open") == "" { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth pc app secret not configured") } } if cfg.MPEnabled { if cfg.AppIDForMode("mp") == "" { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app id not configured") } if cfg.AppSecretForMode("mp") == "" { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth official account app secret not configured") } } if cfg.MobileEnabled { if cfg.AppIDForMode("mobile") == "" { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app id not configured") } if cfg.AppSecretForMode("mobile") == "" { return WeChatConnectOAuthConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "wechat oauth mobile app secret not configured") } } 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") } return cfg, nil } func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string]string) (bool, bool, bool, bool) { cfg := s.effectiveWeChatConnectOAuthConfig(settings) if !cfg.Enabled { return false, false, false, false } 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 } func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig { switch strings.ToLower(strings.TrimSpace(provider)) { case "github": cfg := config.EmailOAuthProviderConfig{ AuthorizeURL: defaultGitHubOAuthAuthorize, TokenURL: defaultGitHubOAuthToken, UserInfoURL: defaultGitHubOAuthUserInfo, EmailsURL: defaultGitHubOAuthEmails, Scopes: defaultGitHubOAuthScopes, FrontendRedirectURL: defaultGitHubOAuthFrontend, } if s != nil && s.cfg != nil { cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth) } return cfg case "google": cfg := config.EmailOAuthProviderConfig{ AuthorizeURL: defaultGoogleOAuthAuthorize, TokenURL: defaultGoogleOAuthToken, UserInfoURL: defaultGoogleOAuthUserInfo, Scopes: defaultGoogleOAuthScopes, FrontendRedirectURL: defaultGoogleOAuthFrontend, } if s != nil && s.cfg != nil { cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth) } return cfg default: return config.EmailOAuthProviderConfig{} } } func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig { base.Enabled = override.Enabled if strings.TrimSpace(override.ClientID) != "" { base.ClientID = strings.TrimSpace(override.ClientID) } if strings.TrimSpace(override.ClientSecret) != "" { base.ClientSecret = strings.TrimSpace(override.ClientSecret) } if strings.TrimSpace(override.AuthorizeURL) != "" { base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL) } if strings.TrimSpace(override.TokenURL) != "" { base.TokenURL = strings.TrimSpace(override.TokenURL) } if strings.TrimSpace(override.UserInfoURL) != "" { base.UserInfoURL = strings.TrimSpace(override.UserInfoURL) } if strings.TrimSpace(override.EmailsURL) != "" { base.EmailsURL = strings.TrimSpace(override.EmailsURL) } if strings.TrimSpace(override.Scopes) != "" { base.Scopes = strings.TrimSpace(override.Scopes) } if strings.TrimSpace(override.RedirectURL) != "" { base.RedirectURL = strings.TrimSpace(override.RedirectURL) } if strings.TrimSpace(override.FrontendRedirectURL) != "" { base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL) } return base } func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool { cfg := s.effectiveEmailOAuthConfig(settings, provider) return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" } func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig { cfg := s.emailOAuthBaseConfig(provider) switch strings.ToLower(strings.TrimSpace(provider)) { case "github": if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok { cfg.Enabled = raw == "true" } cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID) cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret) cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL) cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend) case "google": if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok { cfg.Enabled = raw == "true" } cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID) cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret) cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL) cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend) } return cfg } func oidcUsePKCECompatibilityDefault(base config.OIDCConnectConfig) bool { if base.UsePKCEExplicit { return base.UsePKCE } return true } func oidcValidateIDTokenCompatibilityDefault(base config.OIDCConnectConfig) bool { if base.ValidateIDTokenExplicit { return base.ValidateIDToken } return true } func oidcCompatibilityWriteDefault(base config.OIDCConnectConfig, configured bool, raw string, explicit bool, explicitValue bool) bool { if configured { return strings.TrimSpace(raw) == "true" } if explicit { return explicitValue } return false } func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, bool, error) { rawSettings, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyOIDCConnectUsePKCE, SettingKeyOIDCConnectValidateIDToken, }) if err != nil { return false, false, fmt.Errorf("get oidc security write defaults: %w", err) } base := config.OIDCConnectConfig{} if s != nil && s.cfg != nil { base = s.cfg.OIDC } rawUsePKCE, hasUsePKCE := rawSettings[SettingKeyOIDCConnectUsePKCE] rawValidateIDToken, hasValidateIDToken := rawSettings[SettingKeyOIDCConnectValidateIDToken] return oidcCompatibilityWriteDefault(base, hasUsePKCE, rawUsePKCE, base.UsePKCEExplicit, base.UsePKCE), oidcCompatibilityWriteDefault(base, hasValidateIDToken, rawValidateIDToken, base.ValidateIDTokenExplicit, base.ValidateIDToken), nil } func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { provider = strings.ToLower(strings.TrimSpace(provider)) if provider != "github" && provider != "google" { return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found") } keys := []string{ SettingKeyGitHubOAuthEnabled, SettingKeyGitHubOAuthClientID, SettingKeyGitHubOAuthClientSecret, SettingKeyGitHubOAuthRedirectURL, SettingKeyGitHubOAuthFrontendRedirectURL, SettingKeyGoogleOAuthEnabled, SettingKeyGoogleOAuthClientID, SettingKeyGoogleOAuthClientSecret, SettingKeyGoogleOAuthRedirectURL, SettingKeyGoogleOAuthFrontendRedirectURL, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err) } cfg := s.effectiveEmailOAuthConfig(settings, provider) if !cfg.Enabled { return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") } if strings.TrimSpace(cfg.ClientID) == "" { return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") } if strings.TrimSpace(cfg.ClientSecret) == "" { return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") } for label, rawURL := range map[string]string{ "authorize": cfg.AuthorizeURL, "token": cfg.TokenURL, "userinfo": cfg.UserInfoURL, "redirect": cfg.RedirectURL, } { if strings.TrimSpace(rawURL) == "" { return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured") } if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil { return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid") } } if strings.TrimSpace(cfg.EmailsURL) != "" { if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil { return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid") } } if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") } return cfg, nil } // GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。 // // 优先级: // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 // - 否则回退到 config.yaml/env 的值 func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) { if s == nil || s.cfg == nil { return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") } effective := s.cfg.LinuxDo keys := []string{ SettingKeyLinuxDoConnectEnabled, SettingKeyLinuxDoConnectClientID, SettingKeyLinuxDoConnectClientSecret, SettingKeyLinuxDoConnectRedirectURL, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err) } if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok { effective.Enabled = raw == "true" } if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" { effective.ClientID = strings.TrimSpace(v) } if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" { effective.ClientSecret = strings.TrimSpace(v) } if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { effective.RedirectURL = strings.TrimSpace(v) } if !effective.Enabled { return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") } // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 if strings.TrimSpace(effective.ClientID) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") } if strings.TrimSpace(effective.AuthorizeURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") } if strings.TrimSpace(effective.TokenURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") } if strings.TrimSpace(effective.UserInfoURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured") } if strings.TrimSpace(effective.RedirectURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") } if strings.TrimSpace(effective.FrontendRedirectURL) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") } if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") } if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") } method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) switch method { case "", "client_secret_post", "client_secret_basic": if strings.TrimSpace(effective.ClientSecret) == "" { return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") } case "none": default: return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") } return effective, nil } // GetDingTalkConnectOAuthConfig 返回用于登录的"最终生效" DingTalk Connect 配置。 // // 优先级: // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 // - 否则回退到 config.yaml/env 的值 func (s *SettingService) GetDingTalkConnectOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) { if s == nil || s.cfg == nil { return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") } effective := s.cfg.DingTalk keys := []string{ SettingKeyDingTalkConnectEnabled, SettingKeyDingTalkConnectClientID, SettingKeyDingTalkConnectClientSecret, SettingKeyDingTalkConnectRedirectURL, SettingKeyDingTalkConnectCorpRestrictionPolicy, SettingKeyDingTalkConnectInternalCorpID, SettingKeyDingTalkConnectBypassRegistration, SettingKeyDingTalkConnectSyncCorpEmail, SettingKeyDingTalkConnectSyncDisplayName, SettingKeyDingTalkConnectSyncDept, SettingKeyDingTalkConnectSyncCorpEmailAttrKey, SettingKeyDingTalkConnectSyncDisplayNameAttrKey, SettingKeyDingTalkConnectSyncDeptAttrKey, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return config.DingTalkConnectConfig{}, fmt.Errorf("get dingtalk connect settings: %w", err) } if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { effective.Enabled = raw == "true" } if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { effective.ClientID = strings.TrimSpace(v) } if v, ok := settings[SettingKeyDingTalkConnectClientSecret]; ok && strings.TrimSpace(v) != "" { effective.ClientSecret = strings.TrimSpace(v) } if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { effective.RedirectURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { effective.CorpRestrictionPolicy = strings.TrimSpace(v) } effective.CorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(effective.CorpRestrictionPolicy) if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { effective.InternalCorpID = strings.TrimSpace(v) } if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { effective.BypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") } // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, // 以保证 OAuth callback 看到的 effective config 永远是一致状态。 if effective.CorpRestrictionPolicy != "internal_only" { effective.BypassRegistration = false } if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { effective.SyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") } if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { effective.SyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") } if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { effective.SyncDept = strings.EqualFold(strings.TrimSpace(v), "true") } // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 if effective.CorpRestrictionPolicy != "internal_only" { effective.SyncCorpEmail = false effective.SyncDisplayName = false effective.SyncDept = false } // 身份同步目标 attr key(DB 空 → fallback 默认值) if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]); v != "" { effective.SyncCorpEmailAttrKey = v } if effective.SyncCorpEmailAttrKey == "" { effective.SyncCorpEmailAttrKey = "dingtalk_email" } if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]); v != "" { effective.SyncDisplayNameAttrKey = v } if effective.SyncDisplayNameAttrKey == "" { effective.SyncDisplayNameAttrKey = "dingtalk_name" } if v := strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]); v != "" { effective.SyncDeptAttrKey = v } if effective.SyncDeptAttrKey == "" { effective.SyncDeptAttrKey = "dingtalk_department" } if !effective.Enabled { return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled") } // 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。 if strings.TrimSpace(effective.ClientID) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client id not configured") } if strings.TrimSpace(effective.AuthorizeURL) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url not configured") } if strings.TrimSpace(effective.TokenURL) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url not configured") } if strings.TrimSpace(effective.UserInfoURL) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url not configured") } if strings.TrimSpace(effective.RedirectURL) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url not configured") } if strings.TrimSpace(effective.FrontendRedirectURL) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url not configured") } if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth authorize url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth token url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth userinfo url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth redirect url invalid") } if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth frontend redirect url invalid") } if strings.TrimSpace(effective.ClientSecret) == "" { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "dingtalk oauth client secret not configured") } // 镜像 admin handler 行为:internal_only policy 隐式要求 AppType=internal if effective.CorpRestrictionPolicy == "internal_only" { effective.AppType = "internal" } if err := config.ValidateDingTalkConfig(effective); err != nil { return config.DingTalkConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", err.Error()) } return effective, nil } // GetWeChatConnectOAuthConfig 返回用于登录的最终生效 WeChat Connect 配置。 // // WeChat Connect 已回归 DB 系统设置模型,不再回退到 config/env。 func (s *SettingService) GetWeChatConnectOAuthConfig(ctx context.Context) (WeChatConnectOAuthConfig, error) { keys := []string{ SettingKeyWeChatConnectEnabled, SettingKeyWeChatConnectAppID, SettingKeyWeChatConnectAppSecret, SettingKeyWeChatConnectOpenAppID, SettingKeyWeChatConnectOpenAppSecret, SettingKeyWeChatConnectMPAppID, SettingKeyWeChatConnectMPAppSecret, SettingKeyWeChatConnectMobileAppID, SettingKeyWeChatConnectMobileAppSecret, SettingKeyWeChatConnectOpenEnabled, SettingKeyWeChatConnectMPEnabled, SettingKeyWeChatConnectMobileEnabled, SettingKeyWeChatConnectMode, SettingKeyWeChatConnectScopes, SettingKeyWeChatConnectRedirectURL, SettingKeyWeChatConnectFrontendRedirectURL, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return WeChatConnectOAuthConfig{}, fmt.Errorf("get wechat connect settings: %w", err) } return s.parseWeChatConnectOAuthConfig(settings) } // GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 // // 优先级: // - 若对应系统设置键存在,则覆盖 config.yaml/env 的值 // - 否则回退到 config.yaml/env 的值 func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) { if s == nil || s.cfg == nil { return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") } effective := s.cfg.OIDC keys := []string{ SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectProviderName, SettingKeyOIDCConnectClientID, SettingKeyOIDCConnectClientSecret, SettingKeyOIDCConnectIssuerURL, SettingKeyOIDCConnectDiscoveryURL, SettingKeyOIDCConnectAuthorizeURL, SettingKeyOIDCConnectTokenURL, SettingKeyOIDCConnectUserInfoURL, SettingKeyOIDCConnectJWKSURL, SettingKeyOIDCConnectScopes, SettingKeyOIDCConnectRedirectURL, SettingKeyOIDCConnectFrontendRedirectURL, SettingKeyOIDCConnectTokenAuthMethod, SettingKeyOIDCConnectUsePKCE, SettingKeyOIDCConnectValidateIDToken, SettingKeyOIDCConnectAllowedSigningAlgs, SettingKeyOIDCConnectClockSkewSeconds, SettingKeyOIDCConnectRequireEmailVerified, SettingKeyOIDCConnectUserInfoEmailPath, SettingKeyOIDCConnectUserInfoIDPath, SettingKeyOIDCConnectUserInfoUsernamePath, } settings, err := s.settingRepo.GetMultiple(ctx, keys) if err != nil { return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err) } if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { effective.Enabled = raw == "true" } if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" { effective.ProviderName = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" { effective.ClientID = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" { effective.ClientSecret = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" { effective.IssuerURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" { effective.DiscoveryURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" { effective.AuthorizeURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" { effective.TokenURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" { effective.UserInfoURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" { effective.JWKSURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" { effective.Scopes = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { effective.RedirectURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" { effective.FrontendRedirectURL = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" { effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v)) } if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok { effective.UsePKCE = raw == "true" } else { effective.UsePKCE = oidcUsePKCECompatibilityDefault(effective) } if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok { effective.ValidateIDToken = raw == "true" } else { effective.ValidateIDToken = oidcValidateIDTokenCompatibilityDefault(effective) } if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" { effective.AllowedSigningAlgs = strings.TrimSpace(v) } if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" { if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil { effective.ClockSkewSeconds = parsed } } if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok { effective.RequireEmailVerified = raw == "true" } if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok { effective.UserInfoEmailPath = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok { effective.UserInfoIDPath = strings.TrimSpace(v) } if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok { effective.UserInfoUsernamePath = strings.TrimSpace(v) } if !effective.Enabled { return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") } if strings.TrimSpace(effective.ProviderName) == "" { effective.ProviderName = "OIDC" } if strings.TrimSpace(effective.ClientID) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") } if strings.TrimSpace(effective.IssuerURL) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured") } if strings.TrimSpace(effective.RedirectURL) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured") } if strings.TrimSpace(effective.FrontendRedirectURL) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured") } if !scopesContainOpenID(effective.Scopes) { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid") } if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600") } if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid") } discoveryURL := strings.TrimSpace(effective.DiscoveryURL) if discoveryURL == "" { discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL) effective.DiscoveryURL = discoveryURL } if discoveryURL != "" { if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid") } } needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" || strings.TrimSpace(effective.TokenURL) == "" || (effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "") if needsDiscovery && discoveryURL != "" { metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL) if resolveErr != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr) } if strings.TrimSpace(effective.AuthorizeURL) == "" { effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint) } if strings.TrimSpace(effective.TokenURL) == "" { effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint) } if strings.TrimSpace(effective.UserInfoURL) == "" { effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint) } if strings.TrimSpace(effective.JWKSURL) == "" { effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI) } } if strings.TrimSpace(effective.AuthorizeURL) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured") } if strings.TrimSpace(effective.TokenURL) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured") } if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid") } if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid") } if v := strings.TrimSpace(effective.UserInfoURL); v != "" { if err := config.ValidateAbsoluteHTTPURL(v); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid") } } if effective.ValidateIDToken { if strings.TrimSpace(effective.JWKSURL) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured") } if strings.TrimSpace(effective.AllowedSigningAlgs) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured") } } if v := strings.TrimSpace(effective.JWKSURL); v != "" { if err := config.ValidateAbsoluteHTTPURL(v); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid") } } if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid") } if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") } method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod)) switch method { case "", "client_secret_post", "client_secret_basic": if strings.TrimSpace(effective.ClientSecret) == "" { return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") } case "none": default: return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid") } return effective, nil } func scopesContainOpenID(scopes string) bool { for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) { if scope == "openid" { return true } } return false } type oidcProviderMetadata struct { AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserInfoEndpoint string `json:"userinfo_endpoint"` JWKSURI string `json:"jwks_uri"` } func oidcDefaultDiscoveryURL(issuerURL string) string { issuerURL = strings.TrimSpace(issuerURL) if issuerURL == "" { return "" } return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration" } func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) { discoveryURL = strings.TrimSpace(discoveryURL) if discoveryURL == "" { return nil, fmt.Errorf("discovery url is empty") } resp, err := req.C(). SetTimeout(15*time.Second). R(). SetContext(ctx). SetHeader("Accept", "application/json"). Get(discoveryURL) if err != nil { return nil, fmt.Errorf("request discovery document: %w", err) } if !resp.IsSuccessState() { return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode) } metadata := &oidcProviderMetadata{} if err := json.Unmarshal(resp.Bytes(), metadata); err != nil { return nil, fmt.Errorf("parse discovery document: %w", err) } return metadata, nil }