From b19da9c7fea8a6ae075d2d265dec30c0ed75e8d9 Mon Sep 17 00:00:00 2001 From: DaydreamCoding <22166516+DaydreamCoding@users.noreply.github.com> Date: Fri, 15 May 2026 11:46:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(dingtalk):=20=E9=92=89=E9=92=89=20OAuth=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=8E=A5=E5=85=A5=E4=B8=8E=20internal=5Fonly?= =?UTF-8?q?=20=E7=94=A8=E6=88=B7=E5=B1=9E=E6=80=A7=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台 internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程 相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非 internal_app 类型 fail-closed(硬约束)。 钉钉 OAuth 登录主链 - 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken / GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存 - pending session 机制持久化 OAuth 中间态;cookie-only token 持久化 - 三种分流:bind_login_required / email_completion / choose_account_action - corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在 加载层与写入层均静默 coerce 为 none + slog.Warn - bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED - isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow 等横切点支持 dingtalk provider - migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值 internal_only 模式同步企业邮箱/姓名/部门到用户属性 - SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应 SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name / dingtalk_department);非 internal_only policy 在写入层与加载层均 coerce 为 false,admin handler 与 setting_service 双层兜底 - 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback), 之后每次登录刷新 3 个属性;空值也写入以覆盖旧值 - 邮箱三级 fallback:org_email > email > extension["企业邮箱"] (钉钉自定义字段 JSON) - 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名 - GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段; 新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get - AuthHandler 注入 UserAttributeService;OAuth pending flow 在 createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到 AfterRegistration(syncUsername=true)/ AfterLogin - migration 137 seed dingtalk_email/name/department 三个用户属性定义 附带修复(同集成路径暴露的两个 OAuth 注册回归) - LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource 覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat 渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断 - mergeProviderDefaultGrantSettings 把 parse fallback 默认值 (Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判 退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新 用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults 前端 - DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error 视图;router + auth API client - admin SettingsView:corp policy radio(none / internal_only)+ bypass 注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉 (拉取 user attribute definitions),展示 fieldEmail / qyapi_get_department_list 钉钉权限申请提示 - Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁 Co-Authored-By: Claude Opus 4.7 --- backend/cmd/server/wire_gen.go | 10 +- backend/ent/schema/auth_identity.go | 13 +- .../ent/schema/auth_identity_schema_test.go | 2 +- backend/ent/schema/user.go | 4 +- backend/internal/config/config.go | 58 + backend/internal/config/validate_dingtalk.go | 30 + .../internal/config/validate_dingtalk_test.go | 53 + .../internal/handler/admin/setting_handler.go | 582 ++++++--- ...tting_handler_auth_source_defaults_test.go | 16 +- .../admin/setting_handler_dingtalk_test.go | 319 +++++ .../internal/handler/auth_dingtalk_client.go | 398 ++++++ .../handler/auth_dingtalk_client_test.go | 143 +++ .../internal/handler/auth_dingtalk_oauth.go | 1066 +++++++++++++++++ .../handler/auth_dingtalk_oauth_test.go | 391 ++++++ backend/internal/handler/auth_handler.go | 36 +- .../internal/handler/auth_linuxdo_oauth.go | 5 +- .../handler/auth_oauth_pending_flow.go | 41 +- backend/internal/handler/auth_oidc_oauth.go | 5 +- backend/internal/handler/auth_wechat_oauth.go | 2 +- backend/internal/handler/dto/settings.go | 18 + backend/internal/handler/setting_handler.go | 1 + backend/internal/handler/user_handler.go | 13 +- backend/internal/repository/user_repo.go | 5 +- backend/internal/server/api_contract_test.go | 71 +- .../server/middleware/backend_mode_guard.go | 4 + .../middleware/backend_mode_guard_test.go | 30 + backend/internal/server/routes/auth.go | 26 + backend/internal/service/admin_service.go | 4 +- .../internal/service/auth_oauth_email_flow.go | 12 +- backend/internal/service/auth_service.go | 31 +- .../service/auth_service_register_test.go | 102 +- backend/internal/service/auth_service_test.go | 13 + backend/internal/service/domain_constants.go | 88 +- backend/internal/service/gateway_service.go | 1 - backend/internal/service/setting_service.go | 571 +++++++-- backend/internal/service/settings_view.go | 20 + .../service/user_attribute_service.go | 5 + backend/internal/service/user_service.go | 28 +- .../136_add_dingtalk_provider_type.sql | 27 + frontend/src/api/admin/settings.ts | 48 +- frontend/src/api/auth.ts | 13 +- .../components/auth/DingTalkOAuthSection.vue | 61 + .../profile/ProfileAccountBindingsCard.vue | 3 + .../ProfileIdentityBindingsSection.vue | 22 + .../user/profile/ProfileInfoCard.vue | 4 + frontend/src/i18n/locales/en.ts | 73 +- frontend/src/i18n/locales/zh.ts | 72 +- frontend/src/router/index.ts | 21 + frontend/src/types/index.ts | 3 +- frontend/src/views/admin/SettingsView.vue | 350 ++++++ .../src/views/auth/DingTalkCallbackView.vue | 852 +++++++++++++ .../auth/DingTalkEmailCompletionView.vue | 132 ++ frontend/src/views/auth/LoginView.vue | 9 + frontend/src/views/user/ProfileView.vue | 3 + 54 files changed, 5545 insertions(+), 365 deletions(-) create mode 100644 backend/internal/config/validate_dingtalk.go create mode 100644 backend/internal/config/validate_dingtalk_test.go create mode 100644 backend/internal/handler/admin/setting_handler_dingtalk_test.go create mode 100644 backend/internal/handler/auth_dingtalk_client.go create mode 100644 backend/internal/handler/auth_dingtalk_client_test.go create mode 100644 backend/internal/handler/auth_dingtalk_oauth.go create mode 100644 backend/internal/handler/auth_dingtalk_oauth_test.go create mode 100644 backend/internal/service/auth_service_test.go create mode 100644 backend/migrations/136_add_dingtalk_provider_type.sql create mode 100644 frontend/src/components/auth/DingTalkOAuthSection.vue create mode 100644 frontend/src/views/auth/DingTalkCallbackView.vue create mode 100644 frontend/src/views/auth/DingTalkEmailCompletionView.vue diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index a5501181..5a190c33 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -81,7 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { } totpCache := repository.NewTotpCache(redisClient) totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService) - authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService) + userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client) + userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) + userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) + authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService) userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageLogRepository := repository.NewUsageLogRepository(client, db) @@ -198,7 +201,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { registry := payment.ProvideRegistry() defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey) paymentService := service.NewPaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService) - settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService) + settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService) opsHandler := admin.NewOpsHandler(opsService) updateCache := repository.NewUpdateCache(redisClient) gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig) @@ -211,9 +214,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { usageCleanupRepository := repository.NewUsageCleanupRepository(client, db) usageCleanupService := service.ProvideUsageCleanupService(usageCleanupRepository, timingWheelService, dashboardAggregationService, configConfig) adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService, usageCleanupService) - userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client) - userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) - userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository) userAttributeHandler := admin.NewUserAttributeHandler(userAttributeService) errorPassthroughRepository := repository.NewErrorPassthroughRepository(client) errorPassthroughCache := repository.NewErrorPassthroughCache(redisClient) diff --git a/backend/ent/schema/auth_identity.go b/backend/ent/schema/auth_identity.go index 5f864080..3deeadcd 100644 --- a/backend/ent/schema/auth_identity.go +++ b/backend/ent/schema/auth_identity.go @@ -15,12 +15,13 @@ import ( ) var authProviderTypes = map[string]struct{}{ - "email": {}, - "github": {}, - "google": {}, - "linuxdo": {}, - "oidc": {}, - "wechat": {}, + "email": {}, + "github": {}, + "google": {}, + "linuxdo": {}, + "oidc": {}, + "wechat": {}, + "dingtalk": {}, } func validateAuthProviderType(value string) error { diff --git a/backend/ent/schema/auth_identity_schema_test.go b/backend/ent/schema/auth_identity_schema_test.go index d3e24050..af272790 100644 --- a/backend/ent/schema/auth_identity_schema_test.go +++ b/backend/ent/schema/auth_identity_schema_test.go @@ -83,7 +83,7 @@ func TestAuthIdentityFoundationSchemas(t *testing.T) { require.Equal(t, 1, signupSource.Validators) validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source") - for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google"} { + for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk"} { require.NoError(t, validator(value)) } require.Error(t, validator("unknown")) diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 08bab83a..c6e04273 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -77,10 +77,10 @@ func (User) Fields() []ent.Field { field.String("signup_source"). Validate(func(value string) error { switch value { - case "email", "linuxdo", "wechat", "oidc", "github", "google": + case "email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk": return nil default: - return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google") + return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google, dingtalk") } }). Default("email"), diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d42828d8..f08e0dea 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -72,6 +72,7 @@ type Config struct { LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` WeChat WeChatConnectConfig `mapstructure:"wechat_connect"` OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` + DingTalk DingTalkConnectConfig `mapstructure:"dingtalk_connect"` GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"` GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"` Default DefaultConfig `mapstructure:"default"` @@ -242,6 +243,47 @@ type OIDCConnectConfig struct { UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` } +type DingTalkConnectConfig struct { + Enabled bool `mapstructure:"enabled"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + AuthorizeURL string `mapstructure:"authorize_url"` + TokenURL string `mapstructure:"token_url"` + UserInfoURL string `mapstructure:"userinfo_url"` + Scopes string `mapstructure:"scopes"` + RedirectURL string `mapstructure:"redirect_url"` + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` + + // 平台底座 + 业务行为 + DingTalkAppKind string `mapstructure:"dingtalk_app_kind"` // 仅 "internal_app"(V4 fail-closed) + AppType string `mapstructure:"app_type"` // "public" (default) | "internal" + + // Corp 限定(none | internal_only) + CorpRestrictionPolicy string `mapstructure:"corp_restriction_policy"` + InternalCorpID string `mapstructure:"internal_corp_id"` + BypassRegistration bool `mapstructure:"bypass_registration"` + SyncCorpEmail bool `mapstructure:"sync_corp_email"` + SyncDisplayName bool `mapstructure:"sync_display_name"` + SyncDept bool `mapstructure:"sync_dept"` + SyncCorpEmailAttrKey string `mapstructure:"sync_corp_email_attr_key"` + SyncDisplayNameAttrKey string `mapstructure:"sync_display_name_attr_key"` + SyncDeptAttrKey string `mapstructure:"sync_dept_attr_key"` + SyncCorpEmailAttrName string `mapstructure:"sync_corp_email_attr_name"` + SyncDisplayNameAttrName string `mapstructure:"sync_display_name_attr_name"` + SyncDeptAttrName string `mapstructure:"sync_dept_attr_name"` + + // 邮箱 + Username + RequireEmail bool `mapstructure:"require_email"` + UsernameOverwritePolicy string `mapstructure:"username_overwrite_policy"` + + // Attribute(私有版扩展点;开源版仅声明) + UsernameAttributeKey string `mapstructure:"username_attribute_key"` + EnableAttributeMatching bool `mapstructure:"enable_attribute_matching"` + EnableAttributeSync bool `mapstructure:"enable_attribute_sync"` + AttributeSyncFields []string `mapstructure:"attribute_sync_fields"` + AttributeSyncOverwritePolicy string `mapstructure:"attribute_sync_overwrite_policy"` +} + type EmailOAuthProviderConfig struct { Enabled bool `mapstructure:"enabled"` ClientID string `mapstructure:"client_id"` @@ -1536,6 +1578,19 @@ func setDefaults() { viper.SetDefault("oidc_connect.userinfo_id_path", "") viper.SetDefault("oidc_connect.userinfo_username_path", "") + // DingTalk Connect OAuth 登录 + viper.SetDefault("dingtalk_connect.enabled", false) + viper.SetDefault("dingtalk_connect.authorize_url", "https://login.dingtalk.com/oauth2/auth") + viper.SetDefault("dingtalk_connect.token_url", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken") + viper.SetDefault("dingtalk_connect.userinfo_url", "https://api.dingtalk.com/v1.0/contact/users/me") + viper.SetDefault("dingtalk_connect.scopes", "openid") + viper.SetDefault("dingtalk_connect.frontend_redirect_url", "/auth/dingtalk/callback") + viper.SetDefault("dingtalk_connect.dingtalk_app_kind", "internal_app") + viper.SetDefault("dingtalk_connect.app_type", "public") + viper.SetDefault("dingtalk_connect.corp_restriction_policy", "none") + viper.SetDefault("dingtalk_connect.require_email", true) + viper.SetDefault("dingtalk_connect.username_overwrite_policy", "if_empty") + // Database viper.SetDefault("database.host", "localhost") viper.SetDefault("database.port", 5432) @@ -2608,6 +2663,9 @@ func (c *Config) Validate() error { if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 { return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds") } + if err := ValidateDingTalkConfig(c.DingTalk); err != nil { + return fmt.Errorf("dingtalk_connect: %w", err) + } return nil } diff --git a/backend/internal/config/validate_dingtalk.go b/backend/internal/config/validate_dingtalk.go new file mode 100644 index 00000000..15734eb5 --- /dev/null +++ b/backend/internal/config/validate_dingtalk.go @@ -0,0 +1,30 @@ +// Package config 包含钉钉连接配置的校验逻辑。 +// +// internal_only 模式安全模型(方案 A): +// 不再要求 admin 填写 InternalCorpID 做二次 corpID 比对。 +// 安全边界由钉钉"企业内部应用"类型本身保证——只有应用所属企业的员工才能完成 OAuth, +// 因此 ValidateDingTalkConfig 只要求 app_type=internal(V1),不再要求 InternalCorpID 非空(原 V3 已删除)。 +// InternalCorpID 字段保留,admin 可选填;若填写,checkDingTalkCorpAllowed 不会使用它做约束。 +package config + +import "errors" + +var ( + ErrDingTalkV1AppTypeMismatch = errors.New("dingtalk: internal_only requires app_type=internal") + ErrDingTalkV4InvalidAppKind = errors.New("dingtalk: dingtalk_app_kind must be internal_app") +) + +func ValidateDingTalkConfig(cfg DingTalkConnectConfig) error { + if !cfg.Enabled { + return nil + } + if cfg.DingTalkAppKind != "internal_app" { + return ErrDingTalkV4InvalidAppKind + } + if cfg.CorpRestrictionPolicy == "internal_only" { + if cfg.AppType != "internal" { + return ErrDingTalkV1AppTypeMismatch + } + } + return nil +} diff --git a/backend/internal/config/validate_dingtalk_test.go b/backend/internal/config/validate_dingtalk_test.go new file mode 100644 index 00000000..f121b97d --- /dev/null +++ b/backend/internal/config/validate_dingtalk_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateDingTalkConfig_Disabled_Skip(t *testing.T) { + require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{Enabled: false})) +} + +func TestValidateDingTalkConfig_V4_DingTalkAppKind(t *testing.T) { + err := ValidateDingTalkConfig(DingTalkConnectConfig{ + Enabled: true, + DingTalkAppKind: "third_party_enterprise_app", + CorpRestrictionPolicy: "none", + }) + require.ErrorIs(t, err, ErrDingTalkV4InvalidAppKind) +} + +func TestValidateDingTalkConfig_V1_InternalOnlyRequiresInternalAppType(t *testing.T) { + err := ValidateDingTalkConfig(DingTalkConnectConfig{ + Enabled: true, + DingTalkAppKind: "internal_app", + AppType: "public", + CorpRestrictionPolicy: "internal_only", + InternalCorpID: "dingABC", + }) + require.ErrorIs(t, err, ErrDingTalkV1AppTypeMismatch) +} + +// TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A: +// internal_only 策略下,InternalCorpID="" 应通过校验(企业隔离由钉钉 AppType=internal 保证)。 +func TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) { + err := ValidateDingTalkConfig(DingTalkConnectConfig{ + Enabled: true, + DingTalkAppKind: "internal_app", + AppType: "internal", + CorpRestrictionPolicy: "internal_only", + InternalCorpID: "", + }) + require.NoError(t, err) +} + +func TestValidateDingTalkConfig_HappyPath_None(t *testing.T) { + require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{ + Enabled: true, + DingTalkAppKind: "internal_app", + AppType: "public", + CorpRestrictionPolicy: "none", + })) +} diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 0ea664d8..eaaae471 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -1,9 +1,11 @@ package admin import ( + "context" "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -60,10 +62,11 @@ type SettingHandler struct { opsService *service.OpsService paymentConfigService *service.PaymentConfigService paymentService *service.PaymentService + userAttributeService *service.UserAttributeService } // NewSettingHandler 创建系统设置处理器 -func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService) *SettingHandler { +func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService, userAttributeService *service.UserAttributeService) *SettingHandler { return &SettingHandler{ settingService: settingService, emailService: emailService, @@ -71,6 +74,7 @@ func NewSettingHandler(settingService *service.SettingService, emailService *ser opsService: opsService, paymentConfigService: paymentConfigService, paymentService: paymentService, + userAttributeService: userAttributeService, } } @@ -135,6 +139,22 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { LinuxDoConnectClientID: settings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: settings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectRedirectURL: settings.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: settings.DingTalkConnectEnabled, + DingTalkConnectClientID: settings.DingTalkConnectClientID, + DingTalkConnectClientSecretConfigured: settings.DingTalkConnectClientSecretConfigured, + DingTalkConnectRedirectURL: settings.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: settings.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: settings.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: settings.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: settings.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: settings.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: settings.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: settings.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: settings.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: settings.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: settings.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: settings.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: settings.DingTalkConnectSyncDeptAttrName, WeChatConnectEnabled: settings.WeChatConnectEnabled, WeChatConnectAppID: settings.WeChatConnectAppID, WeChatConnectAppSecretConfigured: settings.WeChatConnectAppSecretConfigured, @@ -376,6 +396,24 @@ type UpdateSettingsRequest struct { LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + // DingTalk Connect OAuth 登录 + DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"` + DingTalkConnectClientID string `json:"dingtalk_connect_client_id"` + DingTalkConnectClientSecret string `json:"dingtalk_connect_client_secret"` + DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"` + DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"` + DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"` + DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"` + DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"` + DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"` + DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"` + DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"` + DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"` + DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"` + DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"` + DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"` + DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"` + // WeChat Connect OAuth 登录 WeChatConnectEnabled bool `json:"wechat_connect_enabled"` WeChatConnectAppID string `json:"wechat_connect_app_id"` @@ -446,45 +484,50 @@ type UpdateSettingsRequest struct { CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"` // 默认配置 - DefaultConcurrency int `json:"default_concurrency"` - DefaultBalance float64 `json:"default_balance"` - AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` - AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"` - AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"` - AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"` - DefaultUserRPMLimit int `json:"default_user_rpm_limit"` - DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` - AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` - AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"` - AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"` - AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"` - AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"` - AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"` - AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"` - AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"` - AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"` - AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"` - AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"` - AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"` - AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"` - AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"` - AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"` - AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"` - AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"` - AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"` - AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"` - AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"` - AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"` - AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"` - AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"` - AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"` - AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"` - AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"` - AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"` - AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"` - AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"` - AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"` - ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"` + DefaultConcurrency int `json:"default_concurrency"` + DefaultBalance float64 `json:"default_balance"` + AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` + AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"` + AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"` + AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"` + DefaultUserRPMLimit int `json:"default_user_rpm_limit"` + DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` + AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` + AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"` + AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"` + AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"` + AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"` + AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"` + AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"` + AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"` + AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"` + AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"` + AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"` + AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"` + AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"` + AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"` + AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"` + AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"` + AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"` + AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"` + AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"` + AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"` + AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"` + AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"` + AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"` + AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"` + AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"` + AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"` + AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"` + AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"` + AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"` + AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"` + AuthSourceDefaultDingTalkBalance *float64 `json:"auth_source_default_dingtalk_balance"` + AuthSourceDefaultDingTalkConcurrency *int `json:"auth_source_default_dingtalk_concurrency"` + AuthSourceDefaultDingTalkSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_dingtalk_subscriptions"` + AuthSourceDefaultDingTalkGrantOnSignup *bool `json:"auth_source_default_dingtalk_grant_on_signup"` + AuthSourceDefaultDingTalkGrantOnFirstBind *bool `json:"auth_source_default_dingtalk_grant_on_first_bind"` + ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"` // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` @@ -661,6 +704,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { req.AuthSourceDefaultLinuxDoSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultLinuxDoSubscriptions) req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions) req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions) + req.AuthSourceDefaultDingTalkSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultDingTalkSubscriptions) // SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置 // 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置 @@ -777,6 +821,100 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // DingTalk Connect 参数验证 + // 防御性:任何写入路径上把已废弃的 corp_restriction_policy=whitelist 入参 coerce 为 none, + // 避免任何直连 admin API 的客户端把死值写回 DB(前端 UI 已无此选项)。 + req.DingTalkConnectCorpRestrictionPolicy = service.CoerceDingTalkCorpPolicyForWrite(req.DingTalkConnectCorpRestrictionPolicy) + + if req.DingTalkConnectEnabled { + req.DingTalkConnectClientID = strings.TrimSpace(req.DingTalkConnectClientID) + req.DingTalkConnectClientSecret = strings.TrimSpace(req.DingTalkConnectClientSecret) + req.DingTalkConnectRedirectURL = strings.TrimSpace(req.DingTalkConnectRedirectURL) + req.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(req.DingTalkConnectCorpRestrictionPolicy) + req.DingTalkConnectInternalCorpID = strings.TrimSpace(req.DingTalkConnectInternalCorpID) + + if req.DingTalkConnectClientID == "" { + response.BadRequest(c, "DingTalk Client ID is required when enabled") + return + } + if req.DingTalkConnectRedirectURL == "" { + response.BadRequest(c, "DingTalk Redirect URL is required when enabled") + return + } + if err := config.ValidateAbsoluteHTTPURL(req.DingTalkConnectRedirectURL); err != nil { + response.BadRequest(c, "DingTalk Redirect URL must be an absolute http(s) URL") + return + } + + // 如果未提供 client_secret,则保留现有值(如有)。 + if req.DingTalkConnectClientSecret == "" { + if previousSettings.DingTalkConnectClientSecret == "" { + response.BadRequest(c, "DingTalk Client Secret is required when enabled") + return + } + req.DingTalkConnectClientSecret = previousSettings.DingTalkConnectClientSecret + } + + // Corp 策略校验(V1/V4 fail-closed) + dingTalkCfg := config.DingTalkConnectConfig{ + Enabled: true, + DingTalkAppKind: "internal_app", // 硬编码:settings 层仅支持 internal_app + AppType: "internal", // 对于 internal_only 策略的默认值 + CorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy, + InternalCorpID: req.DingTalkConnectInternalCorpID, + } + // 若未填 corp_restriction_policy,保留已有配置 + if dingTalkCfg.CorpRestrictionPolicy == "" { + dingTalkCfg.CorpRestrictionPolicy = previousSettings.DingTalkConnectCorpRestrictionPolicy + } + // 对于 internal_only 策略,app_type 必须为 internal(V1 校验) + if dingTalkCfg.CorpRestrictionPolicy == "internal_only" { + dingTalkCfg.AppType = "internal" + } else { + dingTalkCfg.AppType = "public" + } + if err := config.ValidateDingTalkConfig(dingTalkCfg); err != nil { + response.ErrorWithDetails(c, http.StatusBadRequest, err.Error(), mapDingTalkValidateError(err), nil) + return + } + + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制为 false, + // 防止 admin 在切换 policy 时把 bypass 残留在 DB 中(前端 UI 也已隐藏该开关)。 + if dingTalkCfg.CorpRestrictionPolicy != "internal_only" { + req.DingTalkConnectBypassRegistration = false + // 身份同步三开关同理:仅 internal_only 模式下有意义,其它策略强制 false。 + req.DingTalkConnectSyncCorpEmail = false + req.DingTalkConnectSyncDisplayName = false + req.DingTalkConnectSyncDept = false + } + // 身份同步目标 attr key:trimSpace + 空值 fallback 到默认值 + req.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrKey) + if req.DingTalkConnectSyncCorpEmailAttrKey == "" { + req.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" + } + req.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrKey) + if req.DingTalkConnectSyncDisplayNameAttrKey == "" { + req.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" + } + req.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrKey) + if req.DingTalkConnectSyncDeptAttrKey == "" { + req.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" + } + // 身份同步目标 attr 显示名称:trim + 空值 fallback 到默认中文名 + req.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrName) + if req.DingTalkConnectSyncCorpEmailAttrName == "" { + req.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" + } + req.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrName) + if req.DingTalkConnectSyncDisplayNameAttrName == "" { + req.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" + } + req.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrName) + if req.DingTalkConnectSyncDeptAttrName == "" { + req.DingTalkConnectSyncDeptAttrName = "钉钉部门" + } + } + if req.WeChatConnectEnabled { req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID) req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret) @@ -1272,113 +1410,129 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } settings := &service.SystemSettings{ - RegistrationEnabled: req.RegistrationEnabled, - EmailVerifyEnabled: req.EmailVerifyEnabled, - RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, - PromoCodeEnabled: req.PromoCodeEnabled, - PasswordResetEnabled: req.PasswordResetEnabled, - FrontendURL: req.FrontendURL, - InvitationCodeEnabled: req.InvitationCodeEnabled, - TotpEnabled: req.TotpEnabled, - LoginAgreementEnabled: req.LoginAgreementEnabled, - LoginAgreementMode: loginAgreementMode, - LoginAgreementUpdatedAt: loginAgreementUpdatedAt, - LoginAgreementDocuments: loginAgreementDocuments, - SMTPHost: req.SMTPHost, - SMTPPort: req.SMTPPort, - SMTPUsername: req.SMTPUsername, - SMTPPassword: req.SMTPPassword, - SMTPFrom: req.SMTPFrom, - SMTPFromName: req.SMTPFromName, - SMTPUseTLS: req.SMTPUseTLS, - TurnstileEnabled: req.TurnstileEnabled, - TurnstileSiteKey: req.TurnstileSiteKey, - TurnstileSecretKey: req.TurnstileSecretKey, - LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, - LinuxDoConnectClientID: req.LinuxDoConnectClientID, - LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, - LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, - WeChatConnectEnabled: req.WeChatConnectEnabled, - WeChatConnectAppID: req.WeChatConnectAppID, - WeChatConnectAppSecret: req.WeChatConnectAppSecret, - WeChatConnectOpenAppID: req.WeChatConnectOpenAppID, - WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret, - WeChatConnectMPAppID: req.WeChatConnectMPAppID, - WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret, - WeChatConnectMobileAppID: req.WeChatConnectMobileAppID, - WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret, - WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled, - WeChatConnectMPEnabled: req.WeChatConnectMPEnabled, - WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled, - WeChatConnectMode: req.WeChatConnectMode, - WeChatConnectScopes: req.WeChatConnectScopes, - WeChatConnectRedirectURL: req.WeChatConnectRedirectURL, - WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL, - OIDCConnectEnabled: req.OIDCConnectEnabled, - OIDCConnectProviderName: req.OIDCConnectProviderName, - OIDCConnectClientID: req.OIDCConnectClientID, - OIDCConnectClientSecret: req.OIDCConnectClientSecret, - OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, - OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, - OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, - OIDCConnectTokenURL: req.OIDCConnectTokenURL, - OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, - OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, - OIDCConnectScopes: req.OIDCConnectScopes, - OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, - OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, - OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, - OIDCConnectUsePKCE: oidcUsePKCE, - OIDCConnectValidateIDToken: oidcValidateIDToken, - OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, - OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, - OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, - OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, - OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, - OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, - GitHubOAuthEnabled: req.GitHubOAuthEnabled, - GitHubOAuthClientID: req.GitHubOAuthClientID, - GitHubOAuthClientSecret: req.GitHubOAuthClientSecret, - GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL, - GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL, - GoogleOAuthEnabled: req.GoogleOAuthEnabled, - GoogleOAuthClientID: req.GoogleOAuthClientID, - GoogleOAuthClientSecret: req.GoogleOAuthClientSecret, - GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL, - GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL, - SiteName: req.SiteName, - SiteLogo: req.SiteLogo, - SiteSubtitle: req.SiteSubtitle, - APIBaseURL: req.APIBaseURL, - ContactInfo: req.ContactInfo, - DocURL: req.DocURL, - HomeContent: req.HomeContent, - HideCcsImportButton: req.HideCcsImportButton, - PurchaseSubscriptionEnabled: purchaseEnabled, - PurchaseSubscriptionURL: purchaseURL, - TableDefaultPageSize: req.TableDefaultPageSize, - TablePageSizeOptions: req.TablePageSizeOptions, - CustomMenuItems: customMenuJSON, - CustomEndpoints: customEndpointsJSON, - DefaultConcurrency: req.DefaultConcurrency, - DefaultBalance: req.DefaultBalance, - AffiliateRebateRate: affiliateRebateRate, - AffiliateRebateFreezeHours: affiliateRebateFreezeHours, - AffiliateRebateDurationDays: affiliateRebateDurationDays, - AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap, - DefaultUserRPMLimit: req.DefaultUserRPMLimit, - DefaultSubscriptions: defaultSubscriptions, - EnableModelFallback: req.EnableModelFallback, - FallbackModelAnthropic: req.FallbackModelAnthropic, - FallbackModelOpenAI: req.FallbackModelOpenAI, - FallbackModelGemini: req.FallbackModelGemini, - FallbackModelAntigravity: req.FallbackModelAntigravity, - EnableIdentityPatch: req.EnableIdentityPatch, - IdentityPatchPrompt: req.IdentityPatchPrompt, - MinClaudeCodeVersion: req.MinClaudeCodeVersion, - MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, - AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, - BackendModeEnabled: req.BackendModeEnabled, + RegistrationEnabled: req.RegistrationEnabled, + EmailVerifyEnabled: req.EmailVerifyEnabled, + RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, + PromoCodeEnabled: req.PromoCodeEnabled, + PasswordResetEnabled: req.PasswordResetEnabled, + FrontendURL: req.FrontendURL, + InvitationCodeEnabled: req.InvitationCodeEnabled, + TotpEnabled: req.TotpEnabled, + LoginAgreementEnabled: req.LoginAgreementEnabled, + LoginAgreementMode: loginAgreementMode, + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + SMTPUsername: req.SMTPUsername, + SMTPPassword: req.SMTPPassword, + SMTPFrom: req.SMTPFrom, + SMTPFromName: req.SMTPFromName, + SMTPUseTLS: req.SMTPUseTLS, + TurnstileEnabled: req.TurnstileEnabled, + TurnstileSiteKey: req.TurnstileSiteKey, + TurnstileSecretKey: req.TurnstileSecretKey, + LinuxDoConnectEnabled: req.LinuxDoConnectEnabled, + LinuxDoConnectClientID: req.LinuxDoConnectClientID, + LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret, + LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: req.DingTalkConnectEnabled, + DingTalkConnectClientID: req.DingTalkConnectClientID, + DingTalkConnectClientSecret: req.DingTalkConnectClientSecret, + DingTalkConnectRedirectURL: req.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: req.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: req.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: req.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: req.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: req.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: req.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: req.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: req.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: req.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: req.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: req.DingTalkConnectSyncDeptAttrName, + WeChatConnectEnabled: req.WeChatConnectEnabled, + WeChatConnectAppID: req.WeChatConnectAppID, + WeChatConnectAppSecret: req.WeChatConnectAppSecret, + WeChatConnectOpenAppID: req.WeChatConnectOpenAppID, + WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret, + WeChatConnectMPAppID: req.WeChatConnectMPAppID, + WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret, + WeChatConnectMobileAppID: req.WeChatConnectMobileAppID, + WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret, + WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled, + WeChatConnectMPEnabled: req.WeChatConnectMPEnabled, + WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled, + WeChatConnectMode: req.WeChatConnectMode, + WeChatConnectScopes: req.WeChatConnectScopes, + WeChatConnectRedirectURL: req.WeChatConnectRedirectURL, + WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL, + OIDCConnectEnabled: req.OIDCConnectEnabled, + OIDCConnectProviderName: req.OIDCConnectProviderName, + OIDCConnectClientID: req.OIDCConnectClientID, + OIDCConnectClientSecret: req.OIDCConnectClientSecret, + OIDCConnectIssuerURL: req.OIDCConnectIssuerURL, + OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL, + OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL, + OIDCConnectTokenURL: req.OIDCConnectTokenURL, + OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL, + OIDCConnectJWKSURL: req.OIDCConnectJWKSURL, + OIDCConnectScopes: req.OIDCConnectScopes, + OIDCConnectRedirectURL: req.OIDCConnectRedirectURL, + OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL, + OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod, + OIDCConnectUsePKCE: oidcUsePKCE, + OIDCConnectValidateIDToken: oidcValidateIDToken, + OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs, + OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds, + OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified, + OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, + OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, + OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: req.GitHubOAuthEnabled, + GitHubOAuthClientID: req.GitHubOAuthClientID, + GitHubOAuthClientSecret: req.GitHubOAuthClientSecret, + GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: req.GoogleOAuthEnabled, + GoogleOAuthClientID: req.GoogleOAuthClientID, + GoogleOAuthClientSecret: req.GoogleOAuthClientSecret, + GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL, + SiteName: req.SiteName, + SiteLogo: req.SiteLogo, + SiteSubtitle: req.SiteSubtitle, + APIBaseURL: req.APIBaseURL, + ContactInfo: req.ContactInfo, + DocURL: req.DocURL, + HomeContent: req.HomeContent, + HideCcsImportButton: req.HideCcsImportButton, + PurchaseSubscriptionEnabled: purchaseEnabled, + PurchaseSubscriptionURL: purchaseURL, + TableDefaultPageSize: req.TableDefaultPageSize, + TablePageSizeOptions: req.TablePageSizeOptions, + CustomMenuItems: customMenuJSON, + CustomEndpoints: customEndpointsJSON, + DefaultConcurrency: req.DefaultConcurrency, + DefaultBalance: req.DefaultBalance, + AffiliateRebateRate: affiliateRebateRate, + AffiliateRebateFreezeHours: affiliateRebateFreezeHours, + AffiliateRebateDurationDays: affiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap, + DefaultUserRPMLimit: req.DefaultUserRPMLimit, + DefaultSubscriptions: defaultSubscriptions, + EnableModelFallback: req.EnableModelFallback, + FallbackModelAnthropic: req.FallbackModelAnthropic, + FallbackModelOpenAI: req.FallbackModelOpenAI, + FallbackModelGemini: req.FallbackModelGemini, + FallbackModelAntigravity: req.FallbackModelAntigravity, + EnableIdentityPatch: req.EnableIdentityPatch, + IdentityPatchPrompt: req.IdentityPatchPrompt, + MinClaudeCodeVersion: req.MinClaudeCodeVersion, + MaxClaudeCodeVersion: req.MaxClaudeCodeVersion, + AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling, + BackendModeEnabled: req.BackendModeEnabled, OpsMonitoringEnabled: func() bool { if req.OpsMonitoringEnabled != nil { return *req.OpsMonitoringEnabled @@ -1574,6 +1728,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup), GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind), }, + DingTalk: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultDingTalkBalance, previousAuthSourceDefaults.DingTalk.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultDingTalkConcurrency, previousAuthSourceDefaults.DingTalk.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultDingTalkSubscriptions, previousAuthSourceDefaults.DingTalk.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnSignup, previousAuthSourceDefaults.DingTalk.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnFirstBind, previousAuthSourceDefaults.DingTalk.GrantOnFirstBind), + }, ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), } if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { @@ -1632,6 +1793,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.ErrorFrom(c, err) return } + h.ensureDingTalkSyncAttributes(c.Request.Context(), updatedSettings) updatedAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -1682,6 +1844,22 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID, LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured, LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL, + DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled, + DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID, + DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured, + DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL, + DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy, + DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID, + DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration, + DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail, + DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName, + DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept, + DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey, + DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey, + DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey, + DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName, + DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName, + DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName, WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled, WeChatConnectAppID: updatedSettings.WeChatConnectAppID, WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured, @@ -1822,6 +2000,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } // hasPaymentFields returns true if any payment-related field was explicitly provided. +// mapDingTalkValidateError maps ValidateDingTalkConfig errors to machine-readable reason codes. +func mapDingTalkValidateError(err error) string { + switch { + case errors.Is(err, config.ErrDingTalkV1AppTypeMismatch): + return "dingtalk_apptype_mismatch" + case errors.Is(err, config.ErrDingTalkV4InvalidAppKind): + return "dingtalk_app_kind_invalid" + default: + return "dingtalk_corp_config_invalid" + } +} + func hasPaymentFields(req UpdateSettingsRequest) bool { return req.PaymentEnabled != nil || req.PaymentMinAmount != nil || req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil || @@ -1935,6 +2125,45 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.LinuxDoConnectRedirectURL != after.LinuxDoConnectRedirectURL { changed = append(changed, "linuxdo_connect_redirect_url") } + if before.DingTalkConnectEnabled != after.DingTalkConnectEnabled { + changed = append(changed, "dingtalk_connect_enabled") + } + if before.DingTalkConnectClientID != after.DingTalkConnectClientID { + changed = append(changed, "dingtalk_connect_client_id") + } + if req.DingTalkConnectClientSecret != "" { + changed = append(changed, "dingtalk_connect_client_secret") + } + if before.DingTalkConnectRedirectURL != after.DingTalkConnectRedirectURL { + changed = append(changed, "dingtalk_connect_redirect_url") + } + if before.DingTalkConnectCorpRestrictionPolicy != after.DingTalkConnectCorpRestrictionPolicy { + changed = append(changed, "dingtalk_connect_corp_restriction_policy") + } + if before.DingTalkConnectInternalCorpID != after.DingTalkConnectInternalCorpID { + changed = append(changed, "dingtalk_connect_internal_corp_id") + } + if before.DingTalkConnectBypassRegistration != after.DingTalkConnectBypassRegistration { + changed = append(changed, "dingtalk_connect_bypass_registration") + } + if before.DingTalkConnectSyncCorpEmail != after.DingTalkConnectSyncCorpEmail { + changed = append(changed, "dingtalk_connect_sync_corp_email") + } + if before.DingTalkConnectSyncDisplayName != after.DingTalkConnectSyncDisplayName { + changed = append(changed, "dingtalk_connect_sync_display_name") + } + if before.DingTalkConnectSyncDept != after.DingTalkConnectSyncDept { + changed = append(changed, "dingtalk_connect_sync_dept") + } + if before.DingTalkConnectSyncCorpEmailAttrKey != after.DingTalkConnectSyncCorpEmailAttrKey { + changed = append(changed, "dingtalk_connect_sync_corp_email_attr_key") + } + if before.DingTalkConnectSyncDisplayNameAttrKey != after.DingTalkConnectSyncDisplayNameAttrKey { + changed = append(changed, "dingtalk_connect_sync_display_name_attr_key") + } + if before.DingTalkConnectSyncDeptAttrKey != after.DingTalkConnectSyncDeptAttrKey { + changed = append(changed, "dingtalk_connect_sync_dept_attr_key") + } if before.WeChatConnectEnabled != after.WeChatConnectEnabled { changed = append(changed, "wechat_connect_enabled") } @@ -2246,6 +2475,7 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource {name: "wechat", before: before.WeChat, after: after.WeChat}, {name: "github", before: before.GitHub, after: after.GitHub}, {name: "google", before: before.Google, after: after.Google}, + {name: "dingtalk", before: before.DingTalk, after: after.DingTalk}, } for _, field := range fields { if field.before.Balance != field.after.Balance { @@ -2350,6 +2580,11 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults data["auth_source_default_linuxdo_subscriptions"] = authSourceDefaults.LinuxDo.Subscriptions data["auth_source_default_linuxdo_grant_on_signup"] = authSourceDefaults.LinuxDo.GrantOnSignup data["auth_source_default_linuxdo_grant_on_first_bind"] = authSourceDefaults.LinuxDo.GrantOnFirstBind + data["auth_source_default_dingtalk_balance"] = authSourceDefaults.DingTalk.Balance + data["auth_source_default_dingtalk_concurrency"] = authSourceDefaults.DingTalk.Concurrency + data["auth_source_default_dingtalk_subscriptions"] = authSourceDefaults.DingTalk.Subscriptions + data["auth_source_default_dingtalk_grant_on_signup"] = authSourceDefaults.DingTalk.GrantOnSignup + data["auth_source_default_dingtalk_grant_on_first_bind"] = authSourceDefaults.DingTalk.GrantOnFirstBind data["auth_source_default_oidc_balance"] = authSourceDefaults.OIDC.Balance data["auth_source_default_oidc_concurrency"] = authSourceDefaults.OIDC.Concurrency data["auth_source_default_oidc_subscriptions"] = authSourceDefaults.OIDC.Subscriptions @@ -3044,3 +3279,56 @@ func (h *SettingHandler) TestWebSearchEmulation(c *gin.Context) { } response.Success(c, result) } + +// ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name) +// 兜底 upsert 对应 user attribute definition:不存在则创建;存在但 name 不同则更新 name +// (type/options/required 不变)。仅 internal_only + 对应 sync 开关开启时执行。 +// 失败仅记录日志,不阻塞 settings 保存。 +func (h *SettingHandler) ensureDingTalkSyncAttributes(ctx context.Context, settings *service.SystemSettings) { + if h.userAttributeService == nil || settings == nil { + return + } + if settings.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + return + } + if settings.DingTalkConnectSyncDisplayName { + h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDisplayNameAttrKey, settings.DingTalkConnectSyncDisplayNameAttrName, "钉钉 internal_only 登录时同步的钉钉姓名", service.AttributeTypeText) + } + if settings.DingTalkConnectSyncCorpEmail { + h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncCorpEmailAttrKey, settings.DingTalkConnectSyncCorpEmailAttrName, "钉钉 internal_only 登录时同步的企业邮箱", service.AttributeTypeEmail) + } + if settings.DingTalkConnectSyncDept { + h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDeptAttrKey, settings.DingTalkConnectSyncDeptAttrName, "钉钉 internal_only 登录时同步的完整部门路径(如:公司/研发部)", service.AttributeTypeText) + } +} + +func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, name, description string, attrType service.UserAttributeType) { + key = strings.TrimSpace(key) + if key == "" { + return + } + existing, err := h.userAttributeService.GetDefinitionByKey(ctx, key) + if err == nil && existing != nil { + if strings.TrimSpace(name) != "" && existing.Name != name { + if _, err := h.userAttributeService.UpdateDefinition(ctx, existing.ID, service.UpdateAttributeDefinitionInput{ + Name: &name, + }); err != nil { + slog.Warn("dingtalk: update user attribute definition name failed", "key", key, "err", err.Error()) + return + } + slog.Info("dingtalk: updated user attribute definition name", "key", key, "name", name) + } + return + } + if _, err := h.userAttributeService.CreateDefinition(ctx, service.CreateAttributeDefinitionInput{ + Key: key, + Name: name, + Description: description, + Type: attrType, + Enabled: true, + }); err != nil { + slog.Warn("dingtalk: ensure user attribute definition failed", "key", key, "err", err.Error()) + return + } + slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType) +} diff --git a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go index 085fd2ca..f953f767 100644 --- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go +++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go @@ -137,7 +137,7 @@ func TestSettingHandler_GetSettings_InjectsAuthSourceDefaults(t *testing.T) { }, } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -174,7 +174,7 @@ func TestSettingHandler_UpdateSettings_PreservesOmittedAuthSourceDefaults(t *tes }, } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "registration_enabled": true, @@ -214,7 +214,7 @@ func TestSettingHandler_UpdateSettings_PersistsPaymentVisibleMethodsAndAdvancedS }, } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "promo_code_enabled": true, @@ -264,7 +264,7 @@ func TestSettingHandler_UpdateSettings_PreservesLegacyBlankPaymentVisibleMethodS }, } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "promo_code_enabled": false, @@ -309,7 +309,7 @@ func TestSettingHandler_UpdateSettings_PersistsExplicitFalseOIDCCompatibilityFla }, } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "promo_code_enabled": true, @@ -388,7 +388,7 @@ func TestSettingHandler_UpdateSettings_DoesNotSolidifyImplicitOIDCSecurityDefaul ClockSkewSeconds: 120, }, }) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "promo_code_enabled": true, @@ -417,7 +417,7 @@ func TestSettingHandler_UpdateSettings_RejectsInvalidPaymentVisibleMethodSource( }, } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "promo_code_enabled": true, @@ -450,7 +450,7 @@ func TestSettingHandler_UpdateSettings_DoesNotPersistPartialSystemSettingsWhenAu err: errors.New("write auth source defaults failed"), } svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) - handler := NewSettingHandler(svc, nil, nil, nil, nil, nil) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) body := map[string]any{ "registration_enabled": true, diff --git a/backend/internal/handler/admin/setting_handler_dingtalk_test.go b/backend/internal/handler/admin/setting_handler_dingtalk_test.go new file mode 100644 index 00000000..a3d944cc --- /dev/null +++ b/backend/internal/handler/admin/setting_handler_dingtalk_test.go @@ -0,0 +1,319 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// dingtalkSettingsRepoStub 复用 settingHandlerRepoStub(已在 setting_handler_auth_source_defaults_test.go 定义) + +func newDingTalkSettingsHandler() (*SettingHandler, *settingHandlerRepoStub) { + repo := &settingHandlerRepoStub{values: map[string]string{}} + svc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}}) + handler := NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) + return handler, repo +} + +// baseValidDingTalkBody 返回一个可以通过所有校验的最小合法 body。 +func baseValidDingTalkBody() map[string]any { + return map[string]any{ + "dingtalk_connect_enabled": true, + "dingtalk_connect_client_id": "test-client-id", + "dingtalk_connect_client_secret": "test-client-secret", + "dingtalk_connect_redirect_url": "https://example.com/auth/dingtalk/callback", + "dingtalk_connect_corp_restriction_policy": "none", + } +} + +// TestSettingsPUT_DingTalk_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A: +// internal_only + internal_corp_id="" 应通过校验(→ 200),不再是 400。 +func TestSettingsPUT_DingTalk_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "internal_only" + body["dingtalk_connect_internal_corp_id"] = "" // 空值现在合法 + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestSettingsPUT_DingTalk_HappyPath_None 验证 none policy → 200 +func TestSettingsPUT_DingTalk_HappyPath_None(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "none" + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + require.Equal(t, true, data["dingtalk_connect_enabled"]) +} + +// TestSettingsPUT_DingTalk_HappyPath_InternalOnly_WithCorpID 验证 internal_only + corp_id → 200 +func TestSettingsPUT_DingTalk_HappyPath_InternalOnly_WithCorpID(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "internal_only" + body["dingtalk_connect_internal_corp_id"] = "ding-corp-123" + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestSettingsPUT_DingTalk_BypassRegistration_RoundTrip 验证 bypass_registration 字段 save+load。 +// 必须用 policy=internal_only:bypass 仅在该 policy 下生效,其它 policy 写入层会 coerce 为 false。 +func TestSettingsPUT_DingTalk_BypassRegistration_RoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "internal_only" + body["dingtalk_connect_bypass_registration"] = true + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + require.Equal(t, true, data["dingtalk_connect_bypass_registration"]) +} + +// TestSettingsPUT_DingTalk_Disabled_SkipsValidation 验证 disabled 时跳过 corp 校验 → 200。 +// 用 enabled=true 时必然触发"Client ID is required when enabled"的空 client_id 作为 +// 哨兵——只要 enabled=false 仍能 200 就证明跳过了。 +func TestSettingsPUT_DingTalk_Disabled_SkipsValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := map[string]any{ + "dingtalk_connect_enabled": false, + "dingtalk_connect_client_id": "", // 这种空值在 enabled=true 时会被 400 拒绝 + "dingtalk_connect_corp_restriction_policy": "internal_only", + } + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) +} + +// TestSettingsPUT_DingTalk_SyncFlags_InternalOnly_RoundTrip 验证三个 sync 开关在 internal_only 下可正常 save+load。 +func TestSettingsPUT_DingTalk_SyncFlags_InternalOnly_RoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "internal_only" + body["dingtalk_connect_sync_corp_email"] = true + body["dingtalk_connect_sync_display_name"] = true + body["dingtalk_connect_sync_dept"] = true + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + require.Equal(t, true, data["dingtalk_connect_sync_corp_email"], "sync_corp_email should be true for internal_only") + require.Equal(t, true, data["dingtalk_connect_sync_display_name"], "sync_display_name should be true for internal_only") + require.Equal(t, true, data["dingtalk_connect_sync_dept"], "sync_dept should be true for internal_only") +} + +// TestSettingsPUT_DingTalk_SyncFlags_PolicyNone_CoercedToFalse 验证 policy=none 时三个 sync 开关被 coerce 为 false。 +func TestSettingsPUT_DingTalk_SyncFlags_PolicyNone_CoercedToFalse(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, _ := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "none" + body["dingtalk_connect_sync_corp_email"] = true + body["dingtalk_connect_sync_display_name"] = true + body["dingtalk_connect_sync_dept"] = true + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + require.Equal(t, false, data["dingtalk_connect_sync_corp_email"], "sync_corp_email must be coerced to false when policy=none") + require.Equal(t, false, data["dingtalk_connect_sync_display_name"], "sync_display_name must be coerced to false when policy=none") + require.Equal(t, false, data["dingtalk_connect_sync_dept"], "sync_dept must be coerced to false when policy=none") +} + +// TestSettingsPUT_DingTalk_StaleWhitelist_CoercedToNone 验证升级兼容: +// admin 直接把 corp_restriction_policy=whitelist 提交(前端 UI 已无此选项,但 API 仍可命中) +// 不应导致 400 失败,应该被静默 coerce 为 none 后通过校验。 +func TestSettingsPUT_DingTalk_StaleWhitelist_CoercedToNone(t *testing.T) { + gin.SetMode(gin.TestMode) + handler, repo := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "whitelist" + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "none", repo.values[service.SettingKeyDingTalkConnectCorpRestrictionPolicy], + "stale whitelist 应在写入路径被 coerce 为 none") +} + +// TestSettingsPUT_DingTalk_SyncAttrKey_RoundTrip 验证 3 个 attr key 字段 save+load + 空值 fallback 到默认值。 +func TestSettingsPUT_DingTalk_SyncAttrKey_RoundTrip(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("custom_attr_keys_saved", func(t *testing.T) { + handler, repo := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "internal_only" + body["dingtalk_connect_sync_corp_email"] = true + body["dingtalk_connect_sync_display_name"] = true + body["dingtalk_connect_sync_dept"] = true + body["dingtalk_connect_sync_corp_email_attr_key"] = "my_email_attr" + body["dingtalk_connect_sync_display_name_attr_key"] = "my_name_attr" + body["dingtalk_connect_sync_dept_attr_key"] = "my_dept_attr" + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + + // 验证写入 DB 的 key + require.Equal(t, "my_email_attr", repo.values[service.SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) + require.Equal(t, "my_name_attr", repo.values[service.SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) + require.Equal(t, "my_dept_attr", repo.values[service.SettingKeyDingTalkConnectSyncDeptAttrKey]) + + // 验证响应中的 attr key + var resp response.Response + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + data, ok := resp.Data.(map[string]any) + require.True(t, ok) + require.Equal(t, "my_email_attr", data["dingtalk_connect_sync_corp_email_attr_key"]) + require.Equal(t, "my_name_attr", data["dingtalk_connect_sync_display_name_attr_key"]) + require.Equal(t, "my_dept_attr", data["dingtalk_connect_sync_dept_attr_key"]) + }) + + t.Run("empty_attr_keys_fallback_to_defaults", func(t *testing.T) { + handler, repo := newDingTalkSettingsHandler() + + body := baseValidDingTalkBody() + body["dingtalk_connect_corp_restriction_policy"] = "internal_only" + // 不传 attr key → 写入层 fallback 到默认值 + body["dingtalk_connect_sync_corp_email_attr_key"] = "" + body["dingtalk_connect_sync_display_name_attr_key"] = "" + body["dingtalk_connect_sync_dept_attr_key"] = "" + + rawBody, err := json.Marshal(body) + require.NoError(t, err) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.UpdateSettings(c) + + require.Equal(t, http.StatusOK, rec.Code) + + // 空值应 fallback 到默认值并持久化 + require.Equal(t, "dingtalk_email", repo.values[service.SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) + require.Equal(t, "dingtalk_name", repo.values[service.SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) + require.Equal(t, "dingtalk_department", repo.values[service.SettingKeyDingTalkConnectSyncDeptAttrKey]) + }) +} diff --git a/backend/internal/handler/auth_dingtalk_client.go b/backend/internal/handler/auth_dingtalk_client.go new file mode 100644 index 00000000..2db07d05 --- /dev/null +++ b/backend/internal/handler/auth_dingtalk_client.go @@ -0,0 +1,398 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// dingTalkClientConfig 是 DingTalkClient 需要的最小配置子集 +type dingTalkClientConfig struct { + ClientID string + ClientSecret string + TokenURL string + UserInfoURL string +} + +type DingTalkClient struct { + cfg dingTalkClientConfig + appToken string + appTokenExp time.Time // 钉钉 7200s,留 200s 余量 → 7000s + mu sync.Mutex + httpClient *http.Client + // TODO(multi-instance): Redis 集中缓存 appToken +} + +type DingTalkUserTokenResp struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpireIn int64 `json:"expireIn"` + CorpID string `json:"corpId"` +} + +func (c *DingTalkClient) ExchangeCodeForUserToken(ctx context.Context, code string) (*DingTalkUserTokenResp, error) { + body := map[string]string{ + "clientId": c.cfg.ClientID, + "clientSecret": c.cfg.ClientSecret, + "code": code, + "grantType": "authorization_code", + } + payload, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.TokenURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + var out DingTalkUserTokenResp + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + if strings.TrimSpace(out.AccessToken) == "" { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + return &out, nil +} + +type DingTalkAPIError struct { + Code string + Message string + HTTP int +} + +func (e *DingTalkAPIError) Error() string { + return fmt.Sprintf("dingtalk api error code=%s msg=%s http=%d", e.Code, e.Message, e.HTTP) +} + +func parseDingTalkErr(raw []byte, status int) error { + var v struct { + Code string `json:"code"` + Message string `json:"message"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + _ = json.Unmarshal(raw, &v) + code := v.Code + if code == "" && v.ErrCode != 0 { + code = fmt.Sprintf("%d", v.ErrCode) + } + msg := v.Message + if msg == "" { + msg = v.ErrMsg + } + return &DingTalkAPIError{Code: code, Message: msg, HTTP: status} +} + +// GetUnionIdByUserToken 调用 /v1.0/contact/users/me 返回 unionId 与用户自设昵称 nick。 +// nick 来自钉钉新版 OIDC 接口(用户在 App 个人资料填的昵称),与旧版 user/get.nickname 不同源。 +func (c *DingTalkClient) GetUnionIdByUserToken(ctx context.Context, userToken string) (unionID string, nick string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.UserInfoURL, nil) + if err != nil { + return "", "", err + } + req.Header.Set("x-acs-dingtalk-access-token", userToken) + resp, err := c.httpClient.Do(req) + if err != nil { + return "", "", err + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", "", parseDingTalkErr(raw, resp.StatusCode) + } + var v struct { + UnionID string `json:"unionId"` + Nick string `json:"nick"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return "", "", err + } + if strings.TrimSpace(v.UnionID) == "" { + return "", "", parseDingTalkErr(raw, resp.StatusCode) + } + return v.UnionID, v.Nick, nil +} + +type DingTalkStaffInfo struct { + UserID string + Name string // 企业内真实姓名(钉钉企业管理后台配置) + Nickname string // 钉钉个人昵称(用户自己设置) + Email string + DeptIDs []int64 + // CorpID 不来自 staff 接口,来自 userToken;不在此 struct +} + +// dingTalkOAPIBase 推导钉钉旧版 OAPI base URL(host: api.dingtalk.com → oapi.dingtalk.com)。 +// getbyunionid 与 topapi/v2/user/get 仅在旧版 OAPI 提供,不在 v1.0 OpenAPI。 +func (c *DingTalkClient) dingTalkOAPIBase() string { + u, err := url.Parse(c.cfg.UserInfoURL) + if err != nil || u.Scheme == "" || u.Host == "" { + return "https://oapi.dingtalk.com" + } + host := u.Host + if strings.HasPrefix(host, "api.") { + host = "oapi." + strings.TrimPrefix(host, "api.") + } + return u.Scheme + "://" + host +} + +func (c *DingTalkClient) GetAppToken(ctx context.Context) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.appToken != "" && time.Now().Before(c.appTokenExp) { + return c.appToken, nil + } + body := map[string]string{"appKey": c.cfg.ClientID, "appSecret": c.cfg.ClientSecret} + payload, _ := json.Marshal(body) + // 钉钉新版 v1.0 企业内部应用 access_token: POST /v1.0/oauth2/accessToken + // 此 token 也可作为旧版 OAPI 的 access_token 使用(钉钉文档已说明) + appTokenURL := strings.Replace(c.cfg.TokenURL, "/oauth2/userAccessToken", "/oauth2/accessToken", 1) + if !strings.Contains(appTokenURL, "accessToken") && !strings.Contains(appTokenURL, "gettoken") { + appTokenURL = c.cfg.TokenURL // fallback for test stub + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, appTokenURL, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", parseDingTalkErr(raw, resp.StatusCode) + } + var v struct { + AccessToken string `json:"accessToken"` + ExpireIn int64 `json:"expireIn"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return "", err + } + if v.AccessToken == "" { + return "", parseDingTalkErr(raw, resp.StatusCode) + } + c.appToken = v.AccessToken + ttl := v.ExpireIn + if ttl > 200 { + ttl -= 200 + } + c.appTokenExp = time.Now().Add(time.Duration(ttl) * time.Second) + return c.appToken, nil +} + +func (c *DingTalkClient) GetUserIdByUnionId(ctx context.Context, unionID string) (string, error) { + appToken, err := c.GetAppToken(ctx) + if err != nil { + return "", err + } + body := map[string]string{"unionid": unionID} + payload, _ := json.Marshal(body) + // 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=XXX + // access_token 通过 query string 传递(不是 header) + var targetURL string + if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") { + targetURL = c.dingTalkOAPIBase() + "/topapi/user/getbyunionid?access_token=" + url.QueryEscape(appToken) + } else { + targetURL = c.cfg.UserInfoURL // fallback for test stub + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", parseDingTalkErr(raw, resp.StatusCode) + } + var v struct { + Result struct { + UserID string `json:"userid"` + } `json:"result"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return "", err + } + if v.ErrCode != 0 { + return "", parseDingTalkErr(raw, resp.StatusCode) + } + if strings.TrimSpace(v.Result.UserID) == "" { + return "", parseDingTalkErr(raw, resp.StatusCode) + } + return v.Result.UserID, nil +} + +// DingTalkDeptInfo 部门信息(topapi/v2/department/get 返回子集) +type DingTalkDeptInfo struct { + DeptID int64 + Name string + ParentID int64 +} + +// GetDeptInfo 查询单个部门信息(用于递归拼部门路径)。 +// 调用钉钉旧版 OAPI: POST /topapi/v2/department/get?access_token=XXX +func (c *DingTalkClient) GetDeptInfo(ctx context.Context, deptID int64) (*DingTalkDeptInfo, error) { + appToken, err := c.GetAppToken(ctx) + if err != nil { + return nil, err + } + body := map[string]any{"dept_id": deptID, "language": "zh_CN"} + payload, _ := json.Marshal(body) + var targetURL string + if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") { + targetURL = c.dingTalkOAPIBase() + "/topapi/v2/department/get?access_token=" + url.QueryEscape(appToken) + } else { + targetURL = c.cfg.UserInfoURL // test stub fallback + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + var v struct { + Result struct { + DeptID int64 `json:"dept_id"` + Name string `json:"name"` + ParentID int64 `json:"parent_id"` + } `json:"result"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + if v.ErrCode != 0 { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + return &DingTalkDeptInfo{ + DeptID: v.Result.DeptID, + Name: v.Result.Name, + ParentID: v.Result.ParentID, + }, nil +} + +func (c *DingTalkClient) GetStaffInfoByUserId(ctx context.Context, userID string) (*DingTalkStaffInfo, error) { + appToken, err := c.GetAppToken(ctx) + if err != nil { + return nil, err + } + body := map[string]string{"userid": userID} + payload, _ := json.Marshal(body) + // 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/v2/user/get?access_token=XXX + var targetURL string + if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") { + targetURL = c.dingTalkOAPIBase() + "/topapi/v2/user/get?access_token=" + url.QueryEscape(appToken) + } else { + targetURL = c.cfg.UserInfoURL // fallback for test stub + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + var v struct { + Result struct { + UserID string `json:"userid"` + Name string `json:"name"` + Nickname string `json:"nickname"` + Email string `json:"email"` + OrgEmail string `json:"org_email"` + Extension string `json:"extension"` + DeptID []int64 `json:"dept_id_list"` + } `json:"result"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + if v.ErrCode != 0 { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + if strings.TrimSpace(v.Result.UserID) == "" { + return nil, parseDingTalkErr(raw, resp.StatusCode) + } + // 邮箱三级 fallback:org_email > email > extension["企业邮箱"](钉钉自定义扩展字段,JSON string) + email := strings.TrimSpace(v.Result.OrgEmail) + emailSource := "org_email" + if email == "" { + email = strings.TrimSpace(v.Result.Email) + emailSource = "email" + } + extensionParsed := false + if email == "" && strings.TrimSpace(v.Result.Extension) != "" { + var ext map[string]string + if err := json.Unmarshal([]byte(v.Result.Extension), &ext); err == nil { + extensionParsed = true + if v, ok := ext["企业邮箱"]; ok { + email = strings.TrimSpace(v) + emailSource = "extension.企业邮箱" + } + } + } + if email == "" { + emailSource = "none" + } + slog.Info("dingtalk staff fetched", + "userid", v.Result.UserID, + "name_present", v.Result.Name != "", + "nickname_present", v.Result.Nickname != "", + "name_eq_nickname", v.Result.Name != "" && v.Result.Name == v.Result.Nickname, + "email_present", v.Result.Email != "", + "org_email_present", v.Result.OrgEmail != "", + "extension_present", v.Result.Extension != "", + "extension_parsed", extensionParsed, + "email_source", emailSource, + "dept_count", len(v.Result.DeptID), + ) + return &DingTalkStaffInfo{ + UserID: v.Result.UserID, + Name: v.Result.Name, + Nickname: v.Result.Nickname, + Email: email, + DeptIDs: v.Result.DeptID, + }, nil +} diff --git a/backend/internal/handler/auth_dingtalk_client_test.go b/backend/internal/handler/auth_dingtalk_client_test.go new file mode 100644 index 00000000..aa2e2fdd --- /dev/null +++ b/backend/internal/handler/auth_dingtalk_client_test.go @@ -0,0 +1,143 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDingTalkClient_ExchangeCodeForUserToken_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "POST", r.Method) + require.Equal(t, "/v1.0/oauth2/userAccessToken", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"accessToken":"USER_TOKEN_X","expireIn":7200,"refreshToken":"R","corpId":"dingABC"}`)) + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{ + ClientID: "k", ClientSecret: "s", + TokenURL: server.URL + "/v1.0/oauth2/userAccessToken", + }, + httpClient: server.Client(), + } + resp, err := cli.ExchangeCodeForUserToken(context.Background(), "AUTH_CODE") + require.NoError(t, err) + require.Equal(t, "USER_TOKEN_X", resp.AccessToken) + require.Equal(t, "dingABC", resp.CorpID) +} + +func TestDingTalkClient_GetUnionIdByUserToken_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "USER_TOKEN_X", r.Header.Get("x-acs-dingtalk-access-token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"nick":"张三","unionId":"UID_AAA","openId":"OPEN","avatarUrl":"http://x"}`)) + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/v1.0/contact/users/me"}, + httpClient: server.Client(), + } + unionID, nick, err := cli.GetUnionIdByUserToken(context.Background(), "USER_TOKEN_X") + require.NoError(t, err) + require.Equal(t, "UID_AAA", unionID) + require.Equal(t, "张三", nick) +} + +func TestDingTalkClient_GetAppToken_Cached(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + _, _ = w.Write([]byte(`{"accessToken":"APP_TKN","expireIn":7200}`)) + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{ClientID: "k", ClientSecret: "s", TokenURL: server.URL + "/gettoken"}, + httpClient: server.Client(), + } + t1, err := cli.GetAppToken(context.Background()) + require.NoError(t, err) + t2, err := cli.GetAppToken(context.Background()) + require.NoError(t, err) + require.Equal(t, t1, t2) + require.Equal(t, 1, callCount, "second call should hit cache") +} + +func TestDingTalkClient_GetUserIdByUnionId_60011(t *testing.T) { + appTokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"accessToken":"APP_TKN","expireIn":7200}`)) + })) + defer appTokenServer.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errcode":60011,"errmsg":"not in directory"}`)) + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{TokenURL: appTokenServer.URL + "/gettoken"}, + httpClient: server.Client(), + } + cli.appToken = "APP_TKN" + cli.appTokenExp = time.Now().Add(time.Hour) + cli.cfg.UserInfoURL = server.URL + "/v1.0/contact/users/byUnionId" + + _, err := cli.GetUserIdByUnionId(context.Background(), "UID_AAA") + require.Error(t, err) + apiErr, ok := err.(*DingTalkAPIError) + require.True(t, ok) + require.Equal(t, "60011", apiErr.Code) +} + +// TestDingTalkClient_GetDeptInfo_Success 验证 GetDeptInfo 正常情况返回部门信息。 +func TestDingTalkClient_GetDeptInfo_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errcode":0,"errmsg":"ok","result":{"dept_id":42,"name":"AI数据","parent_id":1}}`)) + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{ + UserInfoURL: server.URL + "/stub", // 不含 /contact/users/me,走 test stub 路径 + }, + httpClient: server.Client(), + } + cli.appToken = "APP_TKN" + cli.appTokenExp = time.Now().Add(time.Hour) + + info, err := cli.GetDeptInfo(context.Background(), 42) + require.NoError(t, err) + require.Equal(t, int64(42), info.DeptID) + require.Equal(t, "AI数据", info.Name) + require.Equal(t, int64(1), info.ParentID) +} + +// TestDingTalkClient_GetDeptInfo_ErrCode60003 验证 errcode=60003(部门不存在)时返回错误。 +func TestDingTalkClient_GetDeptInfo_ErrCode60003(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"dept not found"}`)) + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"}, + httpClient: server.Client(), + } + cli.appToken = "APP_TKN" + cli.appTokenExp = time.Now().Add(time.Hour) + + _, err := cli.GetDeptInfo(context.Background(), 999) + require.Error(t, err) + apiErr, ok := err.(*DingTalkAPIError) + require.True(t, ok) + require.Equal(t, "60003", apiErr.Code) +} diff --git a/backend/internal/handler/auth_dingtalk_oauth.go b/backend/internal/handler/auth_dingtalk_oauth.go new file mode 100644 index 00000000..a5b27dc6 --- /dev/null +++ b/backend/internal/handler/auth_dingtalk_oauth.go @@ -0,0 +1,1066 @@ +package handler + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + dbuser "github.com/Wei-Shaw/sub2api/ent/user" + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// dingTalkUpstreamRedirect 在 4 步链上游调用失败时记录详细错误日志并跳错误页。 +// 把钉钉 errcode/errmsg 写进 backend log + URL fragment,避免被泛 "internal error" 吞掉。 +func dingTalkUpstreamRedirect(c *gin.Context, frontendCallback, step string, err error) { + var apiErr *DingTalkAPIError + dtCode := "" + dtMsg := "" + dtHTTP := 0 + if errors.As(err, &apiErr) { + dtCode = apiErr.Code + dtMsg = apiErr.Message + dtHTTP = apiErr.HTTP + } + slog.Error("dingtalk upstream call failed", + "step", step, + "dingtalk_code", dtCode, + "dingtalk_msg", dtMsg, + "http_status", dtHTTP, + "error", err.Error(), + ) + msg := dtMsg + if strings.TrimSpace(msg) == "" { + msg = infraerrors.Message(err) + } + if strings.TrimSpace(dtCode) != "" { + msg = "dingtalk[" + dtCode + "] " + msg + } + redirectOAuthError(c, frontendCallback, mapDingTalkErrorCode(err), msg, "") +} + +// ─── 常量 ────────────────────────────────────────────────────────────────── + +const ( + dingTalkOAuthCookiePath = "/api/v1/auth/oauth/dingtalk" + dingTalkOAuthStateCookieName = "dingtalk_oauth_state" + dingTalkOAuthRedirectCookie = "dingtalk_oauth_redirect" + dingTalkOAuthIntentCookieName = "dingtalk_oauth_intent" + dingTalkOAuthBindUserCookieName = "dingtalk_oauth_bind_user" + dingTalkOAuthCookieMaxAgeSec = 600 // 10 分钟 + dingTalkOAuthDefaultRedirectTo = "/dashboard" + dingTalkOAuthDefaultFrontendCB = "/auth/dingtalk/callback" + + dingTalkLevelThreeEnabled = true +) + +// ─── Config helper ───────────────────────────────────────────────────────── + +// getDingTalkOAuthConfig 返回 DingTalk OAuth 最终生效配置。 +// 优先从 settingSvc(settings 表)读取,回退到 h.cfg.DingTalk。 +func (h *AuthHandler) getDingTalkOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) { + if h != nil && h.settingSvc != nil { + return h.settingSvc.GetDingTalkConnectOAuthConfig(ctx) + } + if h == nil || h.cfg == nil { + return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") + } + if !h.cfg.DingTalk.Enabled { + return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled") + } + return h.cfg.DingTalk, nil +} + +// ─── Cookie helpers(使用 dingtalk path)───────────────────────────────── + +func setDingTalkCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: dingTalkOAuthCookiePath, + MaxAge: maxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearDingTalkCookie(c *gin.Context, name string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: dingTalkOAuthCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// ─── DingTalkOAuthStart ──────────────────────────────────────────────────── + +// DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。 +// GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login +func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) { + cfg, err := h.getDingTalkOAuthConfig(c.Request.Context()) + if err != nil { + frontendCB := dingTalkOAuthDefaultFrontendCB + redirectOAuthError(c, frontendCB, "dingtalk_not_enabled", "", "") + return + } + + state, err := oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) + return + } + + redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) + if redirectTo == "" { + redirectTo = dingTalkOAuthDefaultRedirectTo + } + + browserSessionKey, err := generateOAuthPendingBrowserSession() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err)) + return + } + + secureCookie := isRequestHTTPS(c) + setDingTalkCookie(c, dingTalkOAuthStateCookieName, encodeCookieValue(state), dingTalkOAuthCookieMaxAgeSec, secureCookie) + setDingTalkCookie(c, dingTalkOAuthRedirectCookie, encodeCookieValue(redirectTo), dingTalkOAuthCookieMaxAgeSec, secureCookie) + + intent := normalizeOAuthIntent(c.Query("intent")) + setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie) + + setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie) + clearOAuthPendingSessionCookie(c, secureCookie) + + if intent == oauthIntentBindCurrentUser { + bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + setDingTalkCookie(c, dingTalkOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), dingTalkOAuthCookieMaxAgeSec, secureCookie) + } else { + clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie) + } + + authURL, err := buildDingTalkAuthorizeURL(cfg, state) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build dingtalk authorization url").WithCause(err)) + return + } + + c.Redirect(http.StatusFound, authURL) +} + +// ─── buildDingTalkAuthorizeURL ───────────────────────────────────────────── + +// ─── findDingTalkCompatEmailUser ─────────────────────────────────────────── + +// findDingTalkCompatEmailUser 通过真实邮箱查找可与 DingTalk 账号兼容绑定的现有用户。 +func (h *AuthHandler) findDingTalkCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) { + if !dingTalkLevelThreeEnabled { + return nil, nil + } + + client := h.entClient() + if client == nil { + return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") + } + + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" || + strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) { + return nil, nil + } + + userEntities, err := client.User.Query(). + Where(userNormalizedEmailPredicate(email)). + Order(dbent.Asc(dbuser.FieldID)). + All(ctx) + if err != nil { + return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err) + } + switch len(userEntities) { + case 0: + return nil, nil + case 1: + return userEntities[0], nil + default: + return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users") + } +} + +// ─── createDingTalkOAuthChoicePendingSession ─────────────────────────────── + +// createDingTalkOAuthChoicePendingSession 创建 DingTalk OAuth 三方注册/绑定的 choice pending session。 +// signupBlocked=true 时关闭"创建新账户"出口;若同时没有 compat email 匹配的已有账户, +// 直接把 step 切到 bind_login_required,避免前端展示一个没有实际可点选项的 choice 界面。 +func (h *AuthHandler) createDingTalkOAuthChoicePendingSession( + c *gin.Context, + identity service.PendingAuthIdentityKey, + suggestedEmail string, + resolvedEmail string, + redirectTo string, + browserSessionKey string, + upstreamClaims map[string]any, + compatEmail string, + compatEmailUser *dbent.User, + forceEmailOnSignup bool, + signupBlocked bool, +) error { + suggestionEmail := strings.TrimSpace(suggestedEmail) + canonicalEmail := strings.TrimSpace(resolvedEmail) + if suggestionEmail == "" { + suggestionEmail = canonicalEmail + } + + completionResponse := map[string]any{ + "step": oauthPendingChoiceStep, + "adoption_required": true, + "redirect": strings.TrimSpace(redirectTo), + "email": suggestionEmail, + "resolved_email": canonicalEmail, + "existing_account_email": "", + "existing_account_bindable": false, + "create_account_allowed": !signupBlocked, + "force_email_on_signup": forceEmailOnSignup, + "choice_reason": "third_party_signup", + } + if strings.TrimSpace(compatEmail) != "" { + completionResponse["compat_email"] = strings.TrimSpace(compatEmail) + } + resolvedChoiceEmail := suggestionEmail + if compatEmailUser != nil { + completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email) + completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email) + completionResponse["existing_account_bindable"] = true + completionResponse["choice_reason"] = "compat_email_match" + resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email) + } + if forceEmailOnSignup && compatEmailUser == nil { + completionResponse["choice_reason"] = "force_email_on_signup" + } + // 注册被拦:无论是否匹配到 compat email user,都跳过 choice,直接进 bind_login。 + // "开放注册" 关闭 且 "钉钉企业模式豁免" 也关闭时,唯一合法出口是绑定已有账户, + // 不应该让用户看到"创建新账户"按钮;compat user 命中只是让 bind_login 的邮箱字段预填得更准。 + if signupBlocked { + completionResponse["step"] = "bind_login_required" + completionResponse["existing_account_bindable"] = true + completionResponse["choice_reason"] = "signup_blocked_redirect_to_bind" + } + + var targetUserID *int64 + if compatEmailUser != nil && compatEmailUser.ID > 0 { + targetUserID = &compatEmailUser.ID + } + + return h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, + Identity: identity, + TargetUserID: targetUserID, + ResolvedEmail: resolvedChoiceEmail, + RedirectTo: redirectTo, + BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: completionResponse, + }) +} + +// ─── DingTalkOAuthCallback ───────────────────────────────────────────────── + +// DingTalkOAuthCallback 处理钉钉授权回调。 +// GET /api/v1/auth/oauth/dingtalk/callback?code=...&state=... +func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) { + cfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context()) + if cfgErr != nil { + response.ErrorFrom(c, cfgErr) + return + } + + frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) + if frontendCallback == "" { + frontendCallback = dingTalkOAuthDefaultFrontendCB + } + + if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { + redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) + return + } + + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" || state == "" { + redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") + return + } + + secureCookie := isRequestHTTPS(c) + defer func() { + clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie) + clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie) + clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie) + }() + + expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName) + if err != nil || state != expectedState { + redirectOAuthError(c, frontendCallback, "csrf", "state mismatch", "") + return + } + redirectTo, _ := readCookieDecoded(c, dingTalkOAuthRedirectCookie) + intent, _ := readCookieDecoded(c, dingTalkOAuthIntentCookieName) + intent = normalizeOAuthIntent(intent) + browserSessionKey, _ := readOAuthPendingBrowserCookie(c) + if strings.TrimSpace(browserSessionKey) == "" { + redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing browser session cookie", "") + return + } + forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context()) + + // ─── 4 步链(Step 1 + Step 2 必须;Step 3/4 按需 + 跨组织降级)─── + client := h.dingTalkClient(cfg) + userToken, err := client.ExchangeCodeForUserToken(c.Request.Context(), code) + if err != nil { + dingTalkUpstreamRedirect(c, frontendCallback, "exchange_code", err) + return + } + + // D: corp 校验提前到 Step 1 之后、Step 2 之前,减少不必要的上游调用 + corpID := strings.TrimSpace(userToken.CorpID) + if !checkDingTalkCorpAllowed(cfg, corpID) { + // 不在 URL 中透传 corpID,避免内部企业标识泄露给前端 + redirectOAuthError(c, frontendCallback, "corp_rejected", "", "") + return + } + + // Step 2: 必须 — UnionID 是全局唯一,作为 subject + 合成邮箱种子;nick 是用户在 App 自设的昵称 + unionID, oauthNick, err := client.GetUnionIdByUserToken(c.Request.Context(), userToken.AccessToken) + if err != nil { + dingTalkUpstreamRedirect(c, frontendCallback, "get_union_id", err) + return + } + + identityKey := service.PendingAuthIdentityKey{ProviderType: "dingtalk", ProviderKey: "dingtalk", ProviderSubject: unionID} + + // Step 3/4 调用策略由 policy 决定,与 require_email 解耦。 + // policy=internal_only → 必须成功(hard fail),因为 AppType=internal 已保证用户在应用企业。 + // policy=none / "" → 尝试,失败降级(公网场景跨组织用户属正常预期)。 + // require_email 只影响 Step 3/4 结果后的邮箱处理路径,不影响是否调用。 + var staff *DingTalkStaffInfo + switch cfg.CorpRestrictionPolicy { + case "internal_only": + // AppType=internal 已保证用户在应用企业,Step 3/4 必须成功。 + // 失败 = 钉钉 OAPI 故障或应用配置错误,应 hard fail。 + upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID) + if errStep3 != nil { + dingTalkUpstreamRedirect(c, frontendCallback, "get_user_id", errStep3) + return + } + staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID) + if errStep4 != nil { + dingTalkUpstreamRedirect(c, frontendCallback, "get_staff_info", errStep4) + return + } + staff = staffInfo + + default: // "none" or "" + // 公网登录,跨组织用户 Step 3/4 可能失败(设计预期),尝试调用,失败降级。 + // 即使 require_email=false 也尝试拿 name(用于 upstreamClaims.username),失败就空着。 + upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID) + if errStep3 != nil { + slog.Debug("dingtalk step3 fallback (none/cross-org)", + "corp_id", corpID, "union_id", unionID, "err", errStep3.Error()) + staff = &DingTalkStaffInfo{} + break + } + staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID) + if errStep4 != nil { + slog.Debug("dingtalk step4 fallback (none/cross-org)", + "corp_id", corpID, "union_id", unionID, "err", errStep4.Error()) + staff = &DingTalkStaffInfo{} + break + } + staff = staffInfo + } + + // nick 来自 OIDC /contact/users/me,优先作为钉钉昵称(user/get.nickname 多数为空)。 + if staff != nil && strings.TrimSpace(oauthNick) != "" { + staff.Nickname = strings.TrimSpace(oauthNick) + } + + upstreamClaims := buildDingTalkUpstreamClaims(staff, unionID, corpID) + + // ─── S1 主动绑定分支(PR-3 才走到这里)─── + if intent == oauthIntentBindCurrentUser { + targetUserID, err := h.readOAuthBindUserIDFromCookie(c, dingTalkOAuthBindUserCookieName) + if err != nil { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid bind user cookie", "") + return + } + // policy=none 跨组织用户绑定时 staff.Email="",用合成邮箱占位(用于 audit log,不用于注册) + bindResolvedEmail := staff.Email + if bindResolvedEmail == "" { + bindResolvedEmail = buildDingTalkSyntheticEmail(unionID) + } + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentBindCurrentUser, Identity: identityKey, + TargetUserID: &targetUserID, ResolvedEmail: bindResolvedEmail, + RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: map[string]any{"redirect": redirectTo}, + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie) + redirectToFrontendCallback(c, frontendCallback) + return + } + + // ─── Level 1:auth_identities hit ─── + if existing, _ := h.findOAuthIdentityUser(c.Request.Context(), identityKey); existing != nil { + // 身份同步:已登录用户,直接同步(user_id 已知)。 + // 异步执行避免上游钉钉接口(GetStaffInfoByUserId / 部门递归)阻塞登录跳转。 + runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) { + h.syncDingTalkIdentity(ctx, cfg, client, existing.ID, staff, false) + }) + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: &existing.ID, + ResolvedEmail: existing.Email, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: map[string]any{"redirect": redirectTo}, + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + + signupBlocked := h.isDingTalkSignupBlocked(c.Request.Context(), cfg) + + // ─── 非命中:require_email=false 走 synthetic email 直接登录 ─── + if !cfg.RequireEmail { + if signupBlocked { + // 注册被拦 + 无邮箱可输:唯一出路是绑定已有账户 + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil, + ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: dingTalkBindLoginCompletionResponse(redirectTo), + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + syntheticEmail := buildDingTalkSyntheticEmail(unionID) + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil, + ResolvedEmail: syntheticEmail, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: map[string]any{"redirect": redirectTo, "synthetic_email": syntheticEmail}, + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + + // ─── require_email=true 且 staff.Email 空 → 补邮箱(默认)或直接 bind_login(注册被拦时) ─── + if staff.Email == "" { + completionResponse := map[string]any{ + "step": "email_completion", + "requires_email_completion": true, + "redirect": redirectTo, + } + if signupBlocked { + // 注册被全局关闭且未豁免:跳过补邮箱页,直接进 bind_login 让用户输入已有账户 + completionResponse = dingTalkBindLoginCompletionResponse(redirectTo) + } + if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil, + ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: completionResponse, + }); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + + // ─── L3/L4 有邮箱:统一 choice pending session ─── + var compatEmailUser *dbent.User + if dingTalkLevelThreeEnabled && staff.Email != "" { + compatEmailUser, _ = h.findDingTalkCompatEmailUser(c.Request.Context(), staff.Email) + } + if err := h.createDingTalkOAuthChoicePendingSession( + c, identityKey, staff.Email, staff.Email, + redirectTo, browserSessionKey, upstreamClaims, + staff.Email, compatEmailUser, forceEmailOnSignup, + signupBlocked, + ); err != nil { + redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + redirectToFrontendCallback(c, frontendCallback) +} + +func buildDingTalkSyntheticEmail(userID string) string { + return "dingtalk-" + strings.ToLower(strings.TrimSpace(userID)) + service.DingTalkConnectSyntheticEmailDomain +} + +// isDingTalkSignupBlocked 当注册总开关关闭且未开启钉钉企业模式豁免 +// (policy=internal_only + dingtalk_connect_bypass_registration=true)时返回 true。 +// 镜像 service.AuthService.canBypassRegistrationDisabledForOAuth 用于 OAuth callback +// 早期路由决策:注册被拦 → 跳过补邮箱页直接进 bind_login,避免用户填完表单才报错。 +func (h *AuthHandler) isDingTalkSignupBlocked(ctx context.Context, cfg config.DingTalkConnectConfig) bool { + if h.settingSvc == nil { + return false + } + if h.settingSvc.IsRegistrationEnabled(ctx) { + return false + } + if cfg.BypassRegistration && cfg.CorpRestrictionPolicy == "internal_only" { + return false + } + return true +} + +func dingTalkBindLoginCompletionResponse(redirectTo string) map[string]any { + return map[string]any{ + "step": "bind_login_required", + "existing_account_bindable": true, + "create_account_allowed": false, + "redirect": redirectTo, + } +} + +func buildDingTalkUpstreamClaims(staff *DingTalkStaffInfo, unionID, corpID string) map[string]any { + primaryDeptID := int64(0) + if len(staff.DeptIDs) > 0 { + primaryDeptID = staff.DeptIDs[0] + } + return map[string]any{ + "email": staff.Email, + "username": staff.Name, + "nickname": staff.Nickname, + "subject": unionID, // 与 identityKey.ProviderSubject 保持一致(全局唯一 unionID) + "corp_user_id": staff.UserID, // 企业 userid(跨组织时为空),保留作独立字段用于 audit + "union_id": unionID, + "corp_id": corpID, + "primary_dept_id": primaryDeptID, // 首个部门 ID,用于 internal_only 同步路径 + } +} + +func checkDingTalkCorpAllowed(cfg config.DingTalkConnectConfig, corpID string) bool { + switch cfg.CorpRestrictionPolicy { + case "internal_only": + // 方案 A:完全跳过 corpID 字段校验,由 step 3 `GetUserIdByUnionId` 做真实判定。 + // 原因:钉钉 /v1.0/oauth2/userAccessToken 在部分授权场景(扫码登录、非企业工作台入口) + // 不会返回 corpId 字段。而 step 3 用本企业 appToken 查 unionId→userId 映射, + // 跨企业用户会被钉钉拒绝(错误码 60011/60121),mapDingTalkErrorCode 已将其映射回 "corp_rejected"。 + // AppType=internal 已由 ValidateDingTalkConfig 强制保证应用属性。 + return true + case "none", "": + return true + default: + return false + } +} + +// decideDingTalkStep34Strategy 根据 policy 和 Step 3/4 运行时错误决定处理方式。 +// 返回 (proceed bool, fatal bool): +// - proceed=true:继续处理(step 成功或降级) +// - fatal=true:应 hard fail(upstream_error) +// +// 此 helper 从主链中提取,便于 unit test 独立验证策略决策逻辑。 +func decideDingTalkStep34Strategy(policy string, stepErr error) (shouldFallback bool, isFatal bool) { + if stepErr == nil { + return false, false // 成功,不需要降级 + } + switch policy { + case "internal_only": + return false, true // hard fail:同企业 Step 3/4 必须成功 + case "none", "": + return true, false // 降级:公网场景跨组织用户失败属正常预期 + default: + return false, true // 未知 policy,视为 hard fail + } +} + +// mapDingTalkErrorCode 把 DingTalkAPIError 映射到 redirectOAuthError 用的字符串 code +func mapDingTalkErrorCode(err error) string { + var apiErr *DingTalkAPIError + if !errors.As(err, &apiErr) { + return "upstream_error" + } + switch apiErr.Code { + case "60011", "60121": + return "corp_rejected" + case "40014", "50015", "88": + return "upstream_error" + default: + return "upstream_error" + } +} + +// dingTalkClient 构造或返回缓存的 client 实例(h-level 单例)。 +// 若 cfg 关键字段(ClientID/ClientSecret/TokenURL/UserInfoURL)与已缓存实例不一致, +// 则丢弃旧实例(含 appToken 缓存)并重建,避免管理员改配置后旧凭据持续生效。 +func (h *AuthHandler) dingTalkClient(cfg config.DingTalkConnectConfig) *DingTalkClient { + h.dingTalkClientMu.Lock() + defer h.dingTalkClientMu.Unlock() + newCfg := dingTalkClientConfig{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + TokenURL: cfg.TokenURL, + UserInfoURL: cfg.UserInfoURL, + } + if h.dingTalkClientInstance == nil || h.dingTalkClientInstance.cfg != newCfg { + h.dingTalkClientInstance = &DingTalkClient{ + cfg: newCfg, + // 与 wechat OAuth client 对齐,避免上游网络抖动时请求悬挂。 + httpClient: &http.Client{Timeout: 10 * time.Second}, + } + } + return h.dingTalkClientInstance +} + +// ─── buildDingTalkAuthorizeURL ───────────────────────────────────────────── + +// buildDingTalkAuthorizeURL 根据配置和 state 构建钉钉 OAuth 授权 URL。 +func buildDingTalkAuthorizeURL(cfg config.DingTalkConnectConfig, state string) (string, error) { + base := strings.TrimSpace(cfg.AuthorizeURL) + if base == "" { + return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_EMPTY", "dingtalk authorize_url not configured") + } + redirectURI := strings.TrimSpace(cfg.RedirectURL) + if redirectURI == "" { + return "", infraerrors.InternalServer("DINGTALK_REDIRECT_URL_EMPTY", "dingtalk redirect_url not configured") + } + + u, err := url.Parse(base) + if err != nil { + return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_PARSE_FAILED", "failed to parse dingtalk authorize_url").WithCause(err) + } + + scopes := strings.TrimSpace(cfg.Scopes) + if scopes == "" { + scopes = "openid" + } + + q := u.Query() + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", redirectURI) + q.Set("response_type", "code") + q.Set("scope", scopes) + q.Set("state", state) + q.Set("prompt", "consent") + u.RawQuery = q.Encode() + + return u.String(), nil +} + +// ─── Complete Registration ───────────────────────────────────────────────── + +type completeDingTalkOAuthRequest struct { + InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` + AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` + AdoptAvatar *bool `json:"adopt_avatar,omitempty"` +} + +// CompleteDingTalkOAuthRegistration completes a pending OAuth registration by validating +// the invitation code and creating the user account. +// POST /api/v1/auth/oauth/dingtalk/complete-registration +func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) { + var req completeDingTalkOAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()}) + return + } + + secureCookie := isRequestHTTPS(c) + sessionToken, err := readOAuthPendingSessionCookie(c) + if err != nil { + clearOAuthPendingSessionCookie(c, secureCookie) + clearOAuthPendingBrowserCookie(c, secureCookie) + response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound) + return + } + browserSessionKey, err := readOAuthPendingBrowserCookie(c) + if err != nil { + clearOAuthPendingSessionCookie(c, secureCookie) + clearOAuthPendingBrowserCookie(c, secureCookie) + response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch) + return + } + pendingSvc, err := h.pendingIdentityService() + if err != nil { + response.ErrorFrom(c, err) + return + } + session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey) + if err != nil { + clearOAuthPendingSessionCookie(c, secureCookie) + clearOAuthPendingBrowserCookie(c, secureCookie) + response.ErrorFrom(c, err) + return + } + if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil { + response.ErrorFrom(c, err) + return + } + if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil { + response.ErrorFrom(c, err) + return + } else if handled { + c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession)) + return + } else { + session = updatedSession + } + if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } + + email := strings.TrimSpace(session.ResolvedEmail) + username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username") + // E: username 空时退到 email local part(跨组织用户没拿到 staff.Name 也能注册) + if username == "" { + if at := strings.Index(email, "@"); at > 0 { + username = email[:at] + } else { + username = email + } + } + if email == "" || username == "" { + response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid")) + return + } + + client := h.entClient() + if client == nil { + response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")) + return + } + if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil { + respondPendingOAuthBindingApplyError(c, err) + return + } + decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{ + AdoptDisplayName: req.AdoptDisplayName, + AdoptAvatar: req.AdoptAvatar, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "dingtalk") + if err != nil { + response.ErrorFrom(c, err) + return + } + if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil { + respondPendingOAuthBindingApplyError(c, err) + return + } + // 新用户注册完成后执行身份同步(user_id 现在已知)。 + // 异步执行避免阻塞 token 响应。 + if completionCfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context()); cfgErr == nil { + dtClient := h.dingTalkClient(completionCfg) + claims := session.UpstreamIdentityClaims + runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) { + h.syncDingTalkIdentityFromClaims(ctx, completionCfg, dtClient, user.ID, claims, true) + }) + } + h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + clearOAuthPendingSessionCookie(c, secureCookie) + clearOAuthPendingBrowserCookie(c, secureCookie) + + c.JSON(http.StatusOK, gin.H{ + "access_token": tokenPair.AccessToken, + "refresh_token": tokenPair.RefreshToken, + "expires_in": tokenPair.ExpiresIn, + "token_type": "Bearer", + }) +} + +// CreateDingTalkOAuthAccount creates a new user account from a pending DingTalk OAuth session. +// POST /api/v1/auth/oauth/dingtalk/create-account +func (h *AuthHandler) CreateDingTalkOAuthAccount(c *gin.Context) { + h.createPendingOAuthAccount(c, "dingtalk") +} + +// BindDingTalkOAuthLogin 处理已有账户绑定钉钉 OAuth 登录。 +// POST /api/v1/auth/oauth/dingtalk/bind-login +func (h *AuthHandler) BindDingTalkOAuthLogin(c *gin.Context) { + h.bindPendingOAuthLogin(c, "dingtalk") +} + +// ─── DingTalk 身份同步 ───────────────────────────────────────────────────── + +// runDingTalkSyncAsync 在后台 goroutine 执行钉钉身份同步,避免阻塞登录响应。 +// 与请求 ctx 解耦(handler 返回后会被取消),但保留其 values(trace/request id)。 +// 固定 30s 超时上限,防止 goroutine 因上游卡顿无限挂起。 +func runDingTalkSyncAsync(parent context.Context, fn func(ctx context.Context)) { + base := context.WithoutCancel(parent) + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("dingtalk sync: panic recovered", "panic", r) + } + }() + ctx, cancel := context.WithTimeout(base, 30*time.Second) + defer cancel() + fn(ctx) + }() +} + +// syncDingTalkIdentity 在 internal_only 模式下,按三个 sync 开关把钉钉身份信息 +// 同步到用户属性表(以及 users.username)。 +// 任何错误仅记日志,不中断登录流程(最终一致性)。 +func (h *AuthHandler) syncDingTalkIdentity(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, staff *DingTalkStaffInfo, syncUsername bool) { + slog.Info("dingtalk sync: entry", + "user_id", userID, + "policy", cfg.CorpRestrictionPolicy, + "sync_corp_email", cfg.SyncCorpEmail, + "sync_display_name", cfg.SyncDisplayName, + "sync_dept", cfg.SyncDept, + "sync_username", syncUsername, + "attr_key_email", cfg.SyncCorpEmailAttrKey, + "attr_key_name", cfg.SyncDisplayNameAttrKey, + "attr_key_dept", cfg.SyncDeptAttrKey, + "staff_nil", staff == nil, + ) + if cfg.CorpRestrictionPolicy != "internal_only" || staff == nil { + slog.Info("dingtalk sync: skip, not internal_only or staff nil") + return + } + slog.Info("dingtalk sync: staff snapshot", + "name", staff.Name, "email", staff.Email, "dept_ids", staff.DeptIDs, + ) + if !cfg.SyncCorpEmail && !cfg.SyncDisplayName && !cfg.SyncDept { + slog.Info("dingtalk sync: skip, all flags disabled") + return + } + if h.userAttributeService == nil { + slog.Warn("dingtalk sync: userAttributeService not available, skipping") + return + } + + // 仅首次注册时覆盖 users.username(避免每次登录覆盖用户后续手动改过的名字)。 + // dingtalk_name 属性下面单独每次写入企业 name,不受此条件影响。 + if syncUsername && cfg.SyncDisplayName { + username := strings.TrimSpace(staff.Nickname) + source := "nickname" + if username == "" { + username = strings.TrimSpace(staff.Name) + source = "name(fallback)" + } + if username != "" && h.userService != nil { + if _, err := h.userService.UpdateProfile(ctx, userID, service.UpdateProfileRequest{Username: &username}); err != nil { + slog.Warn("dingtalk sync: failed to update username", "user_id", userID, "err", err) + } else { + slog.Info("dingtalk sync: username updated (register)", "user_id", userID, "username", username, "source", source) + } + } + } + + // 属性同步(目标 attr key 从 cfg 读取,默认值由 GetDingTalkConnectOAuthConfig 保证非空) + type syncField struct { + key string + value string + } + var fields []syncField + + if cfg.SyncDisplayName && strings.TrimSpace(staff.Name) != "" { + fields = append(fields, syncField{cfg.SyncDisplayNameAttrKey, strings.TrimSpace(staff.Name)}) + } + if cfg.SyncCorpEmail && strings.TrimSpace(staff.Email) != "" { + fields = append(fields, syncField{cfg.SyncCorpEmailAttrKey, strings.TrimSpace(staff.Email)}) + } + if cfg.SyncDept && len(staff.DeptIDs) > 0 { + // 跳过根部门 ID=1,找第一个真实子部门;都是根则保留 1(最终写入空字符串覆盖旧值)。 + primaryDeptID := int64(0) + for _, id := range staff.DeptIDs { + if id > 1 { + primaryDeptID = id + break + } + } + if primaryDeptID == 0 { + primaryDeptID = staff.DeptIDs[0] + } + slog.Info("dingtalk sync: pick primary dept", "user_id", userID, "all_dept_ids", staff.DeptIDs, "primary", primaryDeptID) + path, err := h.resolveDingTalkDeptPath(ctx, client, primaryDeptID) + if err != nil { + slog.Warn("dingtalk sync: failed to resolve dept path", "user_id", userID, "dept_id", primaryDeptID, "err", err) + } else { + // path="" 表示公司直属(仅在根部门下),仍写入空串覆盖旧值。 + fields = append(fields, syncField{cfg.SyncDeptAttrKey, path}) + } + } + + if len(fields) == 0 { + return + } + + // 逐 key 查 definition 并 upsert + for _, f := range fields { + if err := h.setUserAttributeByKey(ctx, userID, f.key, f.value); err != nil { + slog.Warn("dingtalk sync: failed to set attribute", "user_id", userID, "key", f.key, "err", err) + } + } +} + +// syncDingTalkIdentityFromClaims 从 upstreamClaims 恢复 DingTalkStaffInfo 并调用 syncDingTalkIdentity。 +// 用于 pending session 完成阶段(complete-registration / create-account / bind-login)。 +// syncUsername=true 表示首次注册场景,需要把 nickname 写入 users.username。 +func (h *AuthHandler) syncDingTalkIdentityFromClaims(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, claims map[string]any, syncUsername bool) { + staff := dingTalkStaffFromClaims(claims) + h.syncDingTalkIdentity(ctx, cfg, client, userID, staff, syncUsername) +} + +// maybeSyncDingTalkAfterRegistration 在通用 OAuth 注册路径完成后调用。 +// 同步 4 个字段:users.username(首次) + dingtalk_name/email/department(每次)。 +func (h *AuthHandler) maybeSyncDingTalkAfterRegistration(ctx context.Context, session *dbent.PendingAuthSession, userID int64) { + h.dispatchDingTalkPendingSync(ctx, session, userID, true) +} + +// maybeSyncDingTalkAfterLogin 在通用 OAuth 登录/绑定路径完成后调用。 +// 仅刷新 3 个属性(dingtalk_name/email/department),不动 users.username。 +func (h *AuthHandler) maybeSyncDingTalkAfterLogin(ctx context.Context, session *dbent.PendingAuthSession, userID int64) { + h.dispatchDingTalkPendingSync(ctx, session, userID, false) +} + +func (h *AuthHandler) dispatchDingTalkPendingSync(ctx context.Context, session *dbent.PendingAuthSession, userID int64, syncUsername bool) { + if session == nil || userID <= 0 { + return + } + if !strings.EqualFold(strings.TrimSpace(session.ProviderType), "dingtalk") { + return + } + cfg, err := h.getDingTalkOAuthConfig(ctx) + if err != nil { + slog.Debug("dingtalk sync: skip post-login sync, config unavailable", "user_id", userID, "err", err.Error()) + return + } + client := h.dingTalkClient(cfg) + claims := session.UpstreamIdentityClaims + // 异步执行避免阻塞 token 响应。 + runDingTalkSyncAsync(ctx, func(asyncCtx context.Context) { + h.syncDingTalkIdentityFromClaims(asyncCtx, cfg, client, userID, claims, syncUsername) + }) +} + +// dingTalkStaffFromClaims 从 upstreamClaims 重建最小 DingTalkStaffInfo。 +func dingTalkStaffFromClaims(claims map[string]any) *DingTalkStaffInfo { + if claims == nil { + return &DingTalkStaffInfo{} + } + staff := &DingTalkStaffInfo{} + if v, ok := claims["username"].(string); ok { + staff.Name = v + } + if v, ok := claims["nickname"].(string); ok { + staff.Nickname = v + } + if v, ok := claims["email"].(string); ok { + staff.Email = v + } + if v, ok := claims["corp_user_id"].(string); ok { + staff.UserID = v + } + // primary_dept_id 存为 int64 或 float64(JSON round-trip) + switch v := claims["primary_dept_id"].(type) { + case int64: + if v > 0 { + staff.DeptIDs = []int64{v} + } + case float64: + if id := int64(v); id > 0 { + staff.DeptIDs = []int64{id} + } + } + return staff +} + +// setUserAttributeByKey 按 attribute key 查找 definition,再 upsert 用户属性值。 +// definition 不存在时记 warn 日志跳过(admin 在 settings 保存时已按需 upsert +// 对应 def;缺失意味着 admin 改了 attr key 但未保存 settings,或 def 被手工删除)。 +func (h *AuthHandler) setUserAttributeByKey(ctx context.Context, userID int64, key, value string) error { + def, err := h.userAttributeService.GetDefinitionByKey(ctx, key) + if err != nil { + slog.Warn("dingtalk sync: attribute definition not found, skipping", "key", key, "err", err.Error()) + return nil + } + if err := h.userAttributeService.UpdateUserAttributes(ctx, userID, []service.UpdateUserAttributeInput{ + {AttributeID: def.ID, Value: value}, + }); err != nil { + return err + } + slog.Info("dingtalk sync: attribute upserted", "user_id", userID, "key", key, "attr_id", def.ID) + return nil +} + +// resolveDingTalkDeptPath 从叶部门递归向上拼 "公司/部门/子部门" 路径字符串。 +// 遇 dept_id=1(根)或 parent_id=0 停止。加 visited set 防循环,最多 50 层。 +func (h *AuthHandler) resolveDingTalkDeptPath(ctx context.Context, client *DingTalkClient, deptID int64) (string, error) { + slog.Info("dingtalk sync: resolve dept path start", "dept_id", deptID) + const maxDepth = 50 + visited := make(map[int64]bool, maxDepth) + var parts []string + + current := deptID + for i := 0; i < maxDepth; i++ { + if current < 1 || visited[current] { + break + } + visited[current] = true + + info, err := client.GetDeptInfo(ctx, current) + if err != nil { + return "", fmt.Errorf("get dept info %d: %w", current, err) + } + if strings.TrimSpace(info.Name) != "" { + parts = append([]string{strings.TrimSpace(info.Name)}, parts...) + } + // 钉钉根部门 dept_id=1,ParentID 通常为 0;遇到 0 / self 终止避免循环。 + if info.ParentID < 1 || info.ParentID == current { + break + } + current = info.ParentID + } + + // 去除根组织名(parts[0] 始终是企业全称),仅保留部门层级。 + // 例:["公司","A","B"] → "A/B";["公司"] → ""(公司直属)。 + if len(parts) > 0 { + parts = parts[1:] + } + + return strings.Join(parts, "/"), nil +} diff --git a/backend/internal/handler/auth_dingtalk_oauth_test.go b/backend/internal/handler/auth_dingtalk_oauth_test.go new file mode 100644 index 00000000..1f60e6b6 --- /dev/null +++ b/backend/internal/handler/auth_dingtalk_oauth_test.go @@ -0,0 +1,391 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDingTalkOAuthStart_Disabled は sentinel テスト。 +// TODO(task-1.10): newTestAuthHandlerWithDingTalk helper が追加されたら t.Skip を外す。 +func TestDingTalkOAuthStart_Disabled(t *testing.T) { + t.Skip("helper newTestAuthHandlerWithDingTalk added in Task 1.10; sentinel only") +} + +// TestBuildDingTalkSyntheticEmail_UsesUnionID 验证合成邮箱种子使用 unionID。 +func TestBuildDingTalkSyntheticEmail_UsesUnionID(t *testing.T) { + unionID := "union_AbCdEf123" + email := buildDingTalkSyntheticEmail(unionID) + + want := "dingtalk-union_abcdef123@dingtalk-connect.invalid" + require.Equal(t, want, email) + + // 确保结果都是小写(邮箱大小写不敏感,统一小写) + require.True(t, strings.ToLower(email) == email, "synthetic email should be all lowercase") + + // 确保前缀正确 + require.True(t, strings.HasPrefix(email, "dingtalk-"), "should have dingtalk- prefix") + + // 确保后缀是合成邮箱域名 + require.True(t, strings.HasSuffix(email, "@dingtalk-connect.invalid"), "should have reserved domain suffix") +} + +// TestBuildDingTalkSyntheticEmail_TrimsSpace 验证 unionID 空白被修剪。 +func TestBuildDingTalkSyntheticEmail_TrimsSpace(t *testing.T) { + email := buildDingTalkSyntheticEmail(" UID_XYZ ") + require.Equal(t, "dingtalk-uid_xyz@dingtalk-connect.invalid", email) +} + +// TestBuildDingTalkUpstreamClaims_EmptyStaff 验证 staff 为空 struct(跨组织降级路径)时: +// - subject 等于 unionID(与 identityKey.ProviderSubject 一致) +// - corp_user_id 为空字符串(跨组织时拿不到企业 userid) +// - email/username 为空字符串 +// B/C: Step 3/4 失败降级时 staff = &DingTalkStaffInfo{},claims 不应有 nil。 +func TestBuildDingTalkUpstreamClaims_EmptyStaff(t *testing.T) { + staff := &DingTalkStaffInfo{} + claims := buildDingTalkUpstreamClaims(staff, "UNION_AAA", "CORP_X") + + require.Equal(t, "", claims["email"]) + require.Equal(t, "", claims["username"]) + // 重构后 subject = unionID(与 identityKey.ProviderSubject 保持一致) + require.Equal(t, "UNION_AAA", claims["subject"]) + require.Equal(t, "", claims["corp_user_id"]) // 企业 userid 跨组织时为空 + require.Equal(t, "UNION_AAA", claims["union_id"]) + require.Equal(t, "CORP_X", claims["corp_id"]) +} + +// TestCheckDingTalkCorpAllowed_CrossOrgPolicy 验证 policy=none 时允许任意 corp。 +// D: corp 校验提前后逻辑不变。 +func TestCheckDingTalkCorpAllowed_CrossOrgPolicy(t *testing.T) { + cfg := config.DingTalkConnectConfig{CorpRestrictionPolicy: "none"} + + assert.True(t, checkDingTalkCorpAllowed(cfg, "dingABC"), "policy=none should allow any corp") + assert.True(t, checkDingTalkCorpAllowed(cfg, ""), "policy=none should allow empty corp") + assert.True(t, checkDingTalkCorpAllowed(cfg, "foreign_corp"), "policy=none should allow foreign corp") +} + +// TestCheckDingTalkCorpAllowed_InternalOnly 验证 policy=internal_only 时的 corp 校验语义(方案 A 修订)。 +// 钉钉 userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)不返回 corpId 字段, +// 因此 checkDingTalkCorpAllowed 完全不校验 corpID,由 step 3 GetUserIdByUnionId 做真实判定 +// (跨企业用户会被钉钉错误码 60011/60121 拒绝,mapDingTalkErrorCode 映射回 corp_rejected)。 +func TestCheckDingTalkCorpAllowed_InternalOnly(t *testing.T) { + cfgWithCorpID := config.DingTalkConnectConfig{ + CorpRestrictionPolicy: "internal_only", + InternalCorpID: "dingInternal", + } + assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "dingInternal"), "internal_only: matching corpID allowed") + assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, "foreign_corp"), "internal_only: corpID 字段不再用于决策,step 3 兜底") + assert.True(t, checkDingTalkCorpAllowed(cfgWithCorpID, ""), "internal_only: 空 corpID 也通过(钉钉部分授权场景不返回 corpId)") + + cfgNoCorpID := config.DingTalkConnectConfig{ + CorpRestrictionPolicy: "internal_only", + InternalCorpID: "", + } + assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, "dingAnyNonEmpty"), "internal_only + no InternalCorpID: 非空 corpID 通过") + assert.True(t, checkDingTalkCorpAllowed(cfgNoCorpID, ""), "internal_only + no InternalCorpID: 空 corpID 也通过") +} + +// TestDecideDingTalkStep34Strategy_PolicyNone 验证 policy=none 时 +// Step 3/4 失败应降级(shouldFallback=true, isFatal=false)。 +func TestDecideDingTalkStep34Strategy_PolicyNone(t *testing.T) { + step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403} + + shouldFallback, isFatal := decideDingTalkStep34Strategy("none", step3Err) + + require.True(t, shouldFallback, "policy=none: step3 failure should trigger fallback") + require.False(t, isFatal, "policy=none: step3 failure should NOT be fatal") +} + +// TestDecideDingTalkStep34Strategy_PolicyNoneEmpty 验证 policy="" 时行为与 "none" 相同。 +func TestDecideDingTalkStep34Strategy_PolicyNoneEmpty(t *testing.T) { + stepErr := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403} + + shouldFallback, isFatal := decideDingTalkStep34Strategy("", stepErr) + + require.True(t, shouldFallback, "policy='': step failure should trigger fallback") + require.False(t, isFatal, "policy='': step failure should NOT be fatal") +} + +// TestDecideDingTalkStep34Strategy_PolicyInternalOnly 验证 policy=internal_only 时 +// Step 3/4 失败应 hard fail(isFatal=true)。 +func TestDecideDingTalkStep34Strategy_PolicyInternalOnly(t *testing.T) { + step3Err := &DingTalkAPIError{Code: "60011", Message: "not in directory", HTTP: 403} + + shouldFallback, isFatal := decideDingTalkStep34Strategy("internal_only", step3Err) + + require.False(t, shouldFallback, "policy=internal_only: should NOT fallback on step3 error") + require.True(t, isFatal, "policy=internal_only: step3 failure should be fatal") +} + +// TestDecideDingTalkStep34Strategy_NoError 验证 stepErr=nil 时两个返回值均为 false。 +func TestDecideDingTalkStep34Strategy_NoError(t *testing.T) { + for _, policy := range []string{"none", "internal_only", ""} { + shouldFallback, isFatal := decideDingTalkStep34Strategy(policy, nil) + require.False(t, shouldFallback, "no error should not trigger fallback (policy=%q)", policy) + require.False(t, isFatal, "no error should not be fatal (policy=%q)", policy) + } +} + +// TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart 验证 username 为空时 +// 退到 email local part(@ 之前的部分)。 +// E: CompleteDingTalkOAuthRegistration username fallback。 +func TestCompleteDingTalkRegistration_UsernameFromEmailLocalPart(t *testing.T) { + tests := []struct { + name string + email string + username string + wantUser string + wantValid bool + }{ + { + name: "username empty, normal email → local part", + email: "dingtalk-uid123@dingtalk-connect.invalid", + username: "", + wantUser: "dingtalk-uid123", + wantValid: true, + }, + { + name: "username already set → keep original", + email: "user@example.com", + username: "张三", + wantUser: "张三", + wantValid: true, + }, + { + name: "username empty, no @ in email → use whole email", + email: "noemail", + username: "", + wantUser: "noemail", + wantValid: true, + }, + { + name: "both empty → invalid", + email: "", + username: "", + wantUser: "", + wantValid: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + username := tc.username + email := tc.email + + // 模拟 CompleteDingTalkOAuthRegistration 中的 fallback 逻辑 + if username == "" { + if at := strings.Index(email, "@"); at > 0 { + username = email[:at] + } else { + username = email + } + } + + isValid := email != "" && username != "" + require.Equal(t, tc.wantUser, username, fmt.Sprintf("username for email=%q", tc.email)) + require.Equal(t, tc.wantValid, isValid, "validity check") + }) + } +} + +// TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID 验证重构后 subject = unionID +// 而非 staff.UserID,与 identityKey.ProviderSubject 保持一致。 +// §4.2: buildDingTalkUpstreamClaims subject 字段修正。 +func TestBuildDingTalkUpstreamClaims_SubjectEqualsUnionID(t *testing.T) { + staff := &DingTalkStaffInfo{UserID: "user123", Name: "张三", Email: "zhangsan@corp.com"} + claims := buildDingTalkUpstreamClaims(staff, "union456", "dingcorp789") + + // 重构后 subject = unionID(全局唯一,与 identityKey.ProviderSubject 一致) + require.Equal(t, "union456", claims["subject"], "subject should equal unionID after refactor") + // 企业 userid 保留为独立字段,供 audit/debug 使用 + require.Equal(t, "user123", claims["corp_user_id"], "corp_user_id should be staff.UserID") + // union_id 字段与 subject 相同(冗余保留,便于读取) + require.Equal(t, "union456", claims["union_id"]) + require.Equal(t, "dingcorp789", claims["corp_id"]) + require.Equal(t, "张三", claims["username"]) + require.Equal(t, "zhangsan@corp.com", claims["email"]) +} + +// TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID 验证跨组织降级时 +// corp_user_id 为空字符串(跨组织拿不到企业 userid),subject 仍为 unionID。 +func TestBuildDingTalkUpstreamClaims_CrossOrgEmptyCorpUserID(t *testing.T) { + // 跨组织降级路径:staff = &DingTalkStaffInfo{}(所有字段为零值) + staff := &DingTalkStaffInfo{} + claims := buildDingTalkUpstreamClaims(staff, "union_cross_org", "foreign_corp") + + require.Equal(t, "union_cross_org", claims["subject"], "subject should still be unionID for cross-org users") + require.Equal(t, "", claims["corp_user_id"], "corp_user_id should be empty for cross-org fallback") + require.Equal(t, "", claims["email"]) + require.Equal(t, "", claims["username"]) +} + +// TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims 验证首个 dept_id 被存入 claims。 +func TestBuildDingTalkUpstreamClaims_PrimaryDeptIDInClaims(t *testing.T) { + staff := &DingTalkStaffInfo{UserID: "u1", Name: "张三", Email: "a@b.com", DeptIDs: []int64{42, 99}} + claims := buildDingTalkUpstreamClaims(staff, "uid1", "corpX") + + // 只取首个 dept_id + require.Equal(t, int64(42), claims["primary_dept_id"], "primary_dept_id should be the first dept_id") +} + +// TestBuildDingTalkUpstreamClaims_NoDeptIDs 验证无部门时 primary_dept_id=0。 +func TestBuildDingTalkUpstreamClaims_NoDeptIDs(t *testing.T) { + staff := &DingTalkStaffInfo{UserID: "u2", Name: "李四"} + claims := buildDingTalkUpstreamClaims(staff, "uid2", "corpY") + + require.Equal(t, int64(0), claims["primary_dept_id"], "primary_dept_id should be 0 when no depts") +} + +// TestDingTalkStaffFromClaims_RoundTrip 验证 dingTalkStaffFromClaims 能从 claims 恢复 staff 信息。 +func TestDingTalkStaffFromClaims_RoundTrip(t *testing.T) { + staff := &DingTalkStaffInfo{UserID: "u3", Name: "王五", Email: "ww@corp.com", DeptIDs: []int64{55}} + claims := buildDingTalkUpstreamClaims(staff, "uid3", "corpZ") + + recovered := dingTalkStaffFromClaims(claims) + require.Equal(t, "王五", recovered.Name) + require.Equal(t, "ww@corp.com", recovered.Email) + require.Equal(t, "u3", recovered.UserID) + require.Equal(t, []int64{55}, recovered.DeptIDs) +} + +// TestResolveDingTalkDeptPath_SingleLevel 验证单层部门(parent_id=1)返回部门名。 +func TestResolveDingTalkDeptPath_SingleLevel(t *testing.T) { + handler := &AuthHandler{} + callCount := 0 + responses := map[string]string{ + "42": `{"errcode":0,"result":{"dept_id":42,"name":"研发部","parent_id":1}}`, + "1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + var req struct { + DeptID int64 `json:"dept_id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + w.Header().Set("Content-Type", "application/json") + if resp, ok := responses[fmt.Sprintf("%d", req.DeptID)]; ok { + _, _ = w.Write([]byte(resp)) + } else { + _, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`)) + } + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"}, + httpClient: server.Client(), + } + cli.appToken = "tok" + cli.appTokenExp = time.Now().Add(time.Hour) + + path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42) + require.NoError(t, err) + require.Equal(t, "研发部", path) + require.Equal(t, 2, callCount) +} + +// TestSyncDingTalkIdentity_UsesCfgAttrKeys 验证 syncDingTalkIdentity 使用 cfg 中配置的 attr key +// 而不是硬编码值。通过 userAttributeService=nil 使同步路径走 warn 跳过,但在此之前先验证 +// syncField 构建逻辑(即 attr key 从 cfg 读取)。 +// 间接验证:通过构造定制 cfg,确认不同 attr key 可以正确传入(编译时保证类型正确,运行时不 panic)。 +func TestSyncDingTalkIdentity_UsesCfgAttrKeys_NoopWithNilService(t *testing.T) { + handler := &AuthHandler{ + userAttributeService: nil, // nil → 触发 warn 跳过,但不 panic + } + + cfg := config.DingTalkConnectConfig{ + CorpRestrictionPolicy: "internal_only", + SyncCorpEmail: true, + SyncDisplayName: true, + SyncDept: true, + // 自定义 attr key(非默认值) + SyncCorpEmailAttrKey: "custom_email_key", + SyncDisplayNameAttrKey: "custom_name_key", + SyncDeptAttrKey: "custom_dept_key", + } + + staff := &DingTalkStaffInfo{ + Name: "张三", + Email: "zhangsan@example.com", + } + + // 调用不应 panic(userAttributeService 为 nil 时走 warn 跳过路径) + require.NotPanics(t, func() { + handler.syncDingTalkIdentity(context.Background(), cfg, nil, 42, staff, false) + }) +} + +// TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService 验证 cfg 默认 attr key 为空时 +// 使用 fallback 默认值(dingtalk_email / dingtalk_name / dingtalk_department)。 +// 此测试主要验证调用路径不 panic;实际 key 赋值默认值的逻辑在 GetDingTalkConnectOAuthConfig 层。 +func TestSyncDingTalkIdentity_DefaultAttrKeys_NoopWithNilService(t *testing.T) { + handler := &AuthHandler{ + userAttributeService: nil, + } + + cfg := config.DingTalkConnectConfig{ + CorpRestrictionPolicy: "internal_only", + SyncCorpEmail: true, + SyncDisplayName: true, + SyncDept: false, + // 不设置 attr key(等同于 GetDingTalkConnectOAuthConfig 未设置时 fallback 后的默认值已在调用前填充) + SyncCorpEmailAttrKey: "dingtalk_email", + SyncDisplayNameAttrKey: "dingtalk_name", + SyncDeptAttrKey: "dingtalk_department", + } + + staff := &DingTalkStaffInfo{ + Name: "李四", + Email: "lisi@corp.com", + } + + require.NotPanics(t, func() { + handler.syncDingTalkIdentity(context.Background(), cfg, nil, 99, staff, false) + }) +} + +// TestResolveDingTalkDeptPath_MultiLevel 验证多层部门路径拼接。 +func TestResolveDingTalkDeptPath_MultiLevel(t *testing.T) { + handler := &AuthHandler{} + // 模拟:42(AI研发) → parent=10(研发部) → parent=1(根) + responses := map[string]string{ + "42": `{"errcode":0,"result":{"dept_id":42,"name":"AI研发","parent_id":10}}`, + "10": `{"errcode":0,"result":{"dept_id":10,"name":"研发部","parent_id":1}}`, + "1": `{"errcode":0,"result":{"dept_id":1,"name":"公司","parent_id":0}}`, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // 解析请求 body 拿到 dept_id + var req struct { + DeptID int64 `json:"dept_id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + key := fmt.Sprintf("%d", req.DeptID) + w.Header().Set("Content-Type", "application/json") + if resp, ok := responses[key]; ok { + _, _ = w.Write([]byte(resp)) + } else { + _, _ = w.Write([]byte(`{"errcode":60003,"errmsg":"not found"}`)) + } + })) + defer server.Close() + + cli := &DingTalkClient{ + cfg: dingTalkClientConfig{UserInfoURL: server.URL + "/stub"}, + httpClient: server.Client(), + } + cli.appToken = "tok" + cli.appTokenExp = time.Now().Add(time.Hour) + + path, err := handler.resolveDingTalkDeptPath(context.Background(), cli, 42) + require.NoError(t, err) + require.Equal(t, "研发部/AI研发", path) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 1f9a66ff..a9af910d 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "strings" + "sync" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" @@ -18,25 +19,30 @@ import ( // AuthHandler handles authentication-related requests type AuthHandler struct { - cfg *config.Config - authService *service.AuthService - userService *service.UserService - settingSvc *service.SettingService - promoService *service.PromoService - redeemService *service.RedeemService - totpService *service.TotpService + cfg *config.Config + authService *service.AuthService + userService *service.UserService + settingSvc *service.SettingService + promoService *service.PromoService + redeemService *service.RedeemService + totpService *service.TotpService + userAttributeService *service.UserAttributeService + + dingTalkClientInstance *DingTalkClient + dingTalkClientMu sync.Mutex } // NewAuthHandler creates a new AuthHandler -func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService) *AuthHandler { +func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userService *service.UserService, settingService *service.SettingService, promoService *service.PromoService, redeemService *service.RedeemService, totpService *service.TotpService, userAttributeService *service.UserAttributeService) *AuthHandler { return &AuthHandler{ - cfg: cfg, - authService: authService, - userService: userService, - settingSvc: settingService, - promoService: promoService, - redeemService: redeemService, - totpService: totpService, + cfg: cfg, + authService: authService, + userService: userService, + settingSvc: settingService, + promoService: promoService, + redeemService: redeemService, + totpService: totpService, + userAttributeService: userAttributeService, } } diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index 7df4abfd..f0ea5fde 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -350,7 +350,8 @@ func (h *AuthHandler) findLinuxDoCompatEmailUser(ctx context.Context, email stri if email == "" || strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) || strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) || - strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) { + strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) { return nil, nil } @@ -519,7 +520,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "linuxdo") if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 490afd0f..1014a3e8 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "log/slog" "net/http" "net/url" "strings" @@ -195,6 +196,14 @@ func (h *AuthHandler) createOAuthPendingSession(c *gin.Context, payload oauthPen }, }) if err != nil { + slog.Error("pending auth session create failed", + "intent", strings.TrimSpace(payload.Intent), + "provider_type", strings.TrimSpace(payload.Identity.ProviderType), + "provider_key", strings.TrimSpace(payload.Identity.ProviderKey), + "provider_subject_len", len(strings.TrimSpace(payload.Identity.ProviderSubject)), + "resolved_email_len", len(strings.TrimSpace(payload.ResolvedEmail)), + "has_target_user", payload.TargetUserID != nil, + "error", err.Error()) return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err) } @@ -266,6 +275,22 @@ func pendingSessionWantsInvitation(payload map[string]any) bool { return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "error")), "invitation_required") } +// pendingSessionRequiresEmailCompletion 判断 callback 写入的 completion payload 是否处于"补邮箱"状态。 +// 钉钉跨组织/staff 邮箱缺失时进入此状态:前端跳到补邮箱页,exchange 不应走 adoption apply。 +func pendingSessionRequiresEmailCompletion(payload map[string]any) bool { + if v, ok := payload["requires_email_completion"].(bool); ok && v { + return true + } + return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "email_completion") +} + +// pendingSessionRequiresBindLogin 判断 callback 写入的 completion payload 是否处于"必须绑定已有账户"状态。 +// 钉钉 signupBlocked=true(注册关 + 钉钉企业豁免关)时进入此状态:前端渲染 bind_login 表单, +// exchange 不应消费 session,否则后续 /pending/bind-login 找不到 session。 +func pendingSessionRequiresBindLogin(payload map[string]any) bool { + return strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(payload, "step")), "bind_login_required") +} + func pendingOAuthCompletionCanIssueTokenPair(session *dbent.PendingAuthSession, payload map[string]any) bool { if session == nil { return false @@ -1467,8 +1492,10 @@ func normalizePendingOAuthCompletionResponse(payload map[string]any) map[string] delete(normalized, key) } step := strings.ToLower(strings.TrimSpace(pendingSessionStringValue(normalized, "step"))) + // 把多种 choice 别名归一为 oauthPendingChoiceStep;bind_login_required 是独立终态 + // (前端渲染 needsBindLogin 而非 needsChooser),故不能并入归一化列表。 switch step { - case "choice", "choose_account_action", "choose_account", "choose", "email_required", "bind_login_required": + case "choice", "choose_account_action", "choose_account", "choose", "email_required": normalized["step"] = oauthPendingChoiceStep } if strings.EqualFold(strings.TrimSpace(pendingSessionStringValue(normalized, "step")), oauthPendingChoiceStep) { @@ -1594,6 +1621,8 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) { } h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + // bindPendingOAuthLogin = 绑定已有账户登录,不动 users.username(用户已有自己的名字) + h.maybeSyncDingTalkAfterLogin(c.Request.Context(), session, user.ID) tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "") if err != nil { response.InternalError(c, "Failed to generate token pair") @@ -1792,6 +1821,8 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) } h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + // createPendingOAuthAccount = 注册新账户,需要把钉钉昵称同步到 users.username 作为初始值 + h.maybeSyncDingTalkAfterRegistration(c.Request.Context(), session, user.ID) clearCookies() writeOAuthTokenPairResponse(c, tokenPair) } @@ -1893,6 +1924,14 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) { response.Success(c, payload) return } + if pendingSessionRequiresEmailCompletion(payload) { + response.Success(c, payload) + return + } + if pendingSessionRequiresBindLogin(payload) { + response.Success(c, payload) + return + } if !adoptionDecision.hasDecision() { adoptionRequired, _ := payload["adoption_required"].(bool) if adoptionRequired { diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 4264002d..c7c517c8 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -502,7 +502,8 @@ func (h *AuthHandler) findOIDCCompatEmailUser(ctx context.Context, email string) if email == "" || strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) || strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) || - strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) { + strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) || + strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) { return nil, nil } @@ -666,7 +667,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "oidc") if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index 34e70ed0..2199c5bd 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -548,7 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) { return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "wechat") if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 551cf0dc..fb09faf7 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -56,6 +56,23 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool `json:"linuxdo_connect_client_secret_configured"` LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"` + DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"` + DingTalkConnectClientID string `json:"dingtalk_connect_client_id"` + DingTalkConnectClientSecretConfigured bool `json:"dingtalk_connect_client_secret_configured"` + DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"` + DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"` + DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"` + DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"` + DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"` + DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"` + DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"` + DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"` + DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"` + DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"` + DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"` + DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"` + DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"` + WeChatConnectEnabled bool `json:"wechat_connect_enabled"` WeChatConnectAppID string `json:"wechat_connect_app_id"` WeChatConnectAppSecretConfigured bool `json:"wechat_connect_app_secret_configured"` @@ -260,6 +277,7 @@ type PublicSettings struct { TablePageSizeOptions []int `json:"table_page_size_options"` CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 6c389e3d..c4ba43e4 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -61,6 +61,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { TablePageSizeOptions: settings.TablePageSizeOptions, CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 3f6ed8c2..f1dbf4e1 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -67,6 +67,7 @@ type userProfileResponse struct { LinuxDoBound bool `json:"linuxdo_bound"` OIDCBound bool `json:"oidc_bound"` WeChatBound bool `json:"wechat_bound"` + DingTalkBound bool `json:"dingtalk_bound"` } type userProfileSourceContext struct { @@ -528,15 +529,17 @@ func userProfileResponseFromService(user *service.User, identities service.UserI LinuxDoBound: identities.LinuxDo.Bound, OIDCBound: identities.OIDC.Bound, WeChatBound: identities.WeChat.Bound, + DingTalkBound: identities.DingTalk.Bound, } } func userProfileBindingMap(identities service.UserIdentitySummarySet) map[string]service.UserIdentitySummary { return map[string]service.UserIdentitySummary{ - "email": identities.Email, - "linuxdo": identities.LinuxDo, - "oidc": identities.OIDC, - "wechat": identities.WeChat, + "email": identities.Email, + "linuxdo": identities.LinuxDo, + "oidc": identities.OIDC, + "wechat": identities.WeChat, + "dingtalk": identities.DingTalk, } } @@ -585,7 +588,7 @@ func inferUserProfileSources(user *service.User, identities service.UserIdentity func thirdPartyIdentityProviders(identities service.UserIdentitySummarySet) []service.UserIdentitySummary { out := make([]service.UserIdentitySummary, 0, 3) - for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat} { + for _, summary := range []service.UserIdentitySummary{identities.LinuxDo, identities.OIDC, identities.WeChat, identities.DingTalk} { if summary.Bound { out = append(out, summary) } diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index 1566756d..610d9a7b 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -334,7 +334,8 @@ func normalizeEmailAuthIdentitySubject(email string) string { } if strings.HasSuffix(normalized, service.LinuxDoConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, service.OIDCConnectSyntheticEmailDomain) || - strings.HasSuffix(normalized, service.WeChatConnectSyntheticEmailDomain) { + strings.HasSuffix(normalized, service.WeChatConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, service.DingTalkConnectSyntheticEmailDomain) { return "" } return normalized @@ -956,7 +957,7 @@ func userSignupSourceOrDefault(signupSource string) string { switch strings.TrimSpace(strings.ToLower(signupSource)) { case "", "email": return "email" - case "linuxdo", "wechat", "oidc": + case "linuxdo", "wechat", "oidc", "dingtalk": return strings.TrimSpace(strings.ToLower(signupSource)) default: return "email" diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 17455410..0d60ac9d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -68,6 +68,7 @@ func TestAPIContracts(t *testing.T) { "linuxdo_bound": false, "oidc_bound": false, "wechat_bound": false, + "dingtalk_bound": false, "identities": { "email": { "provider": "email", @@ -104,6 +105,14 @@ func TestAPIContracts(t *testing.T) { "can_bind": true, "can_unbind": false, "bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + }, + "dingtalk": { + "provider": "dingtalk", + "bound": false, + "bound_count": 0, + "can_bind": true, + "can_unbind": false, + "bind_start_path": "/api/v1/auth/oauth/dingtalk/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" } }, "identity_bindings": { @@ -142,6 +151,14 @@ func TestAPIContracts(t *testing.T) { "can_bind": true, "can_unbind": false, "bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + }, + "dingtalk": { + "provider": "dingtalk", + "bound": false, + "bound_count": 0, + "can_bind": true, + "can_unbind": false, + "bind_start_path": "/api/v1/auth/oauth/dingtalk/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" } }, "auth_bindings": { @@ -180,6 +197,14 @@ func TestAPIContracts(t *testing.T) { "can_bind": true, "can_unbind": false, "bind_start_path": "/api/v1/auth/oauth/wechat/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" + }, + "dingtalk": { + "provider": "dingtalk", + "bound": false, + "bound_count": 0, + "can_bind": true, + "can_unbind": false, + "bind_start_path": "/api/v1/auth/oauth/dingtalk/bind/start?intent=bind_current_user&redirect=%2Fsettings%2Fprofile" } }, "run_mode": "standard" @@ -676,6 +701,22 @@ func TestAPIContracts(t *testing.T) { "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, "linuxdo_connect_redirect_url": "", + "dingtalk_connect_enabled": false, + "dingtalk_connect_bypass_registration": false, + "dingtalk_connect_client_id": "", + "dingtalk_connect_client_secret_configured": false, + "dingtalk_connect_redirect_url": "", + "dingtalk_connect_internal_corp_id": "", + "dingtalk_connect_corp_restriction_policy": "", + "dingtalk_connect_sync_corp_email": false, + "dingtalk_connect_sync_corp_email_attr_key": "dingtalk_email", + "dingtalk_connect_sync_corp_email_attr_name": "钉钉企业邮箱", + "dingtalk_connect_sync_dept": false, + "dingtalk_connect_sync_dept_attr_key": "dingtalk_department", + "dingtalk_connect_sync_dept_attr_name": "钉钉部门", + "dingtalk_connect_sync_display_name": false, + "dingtalk_connect_sync_display_name_attr_key": "dingtalk_name", + "dingtalk_connect_sync_display_name_attr_name": "钉钉姓名", "oidc_connect_enabled": false, "oidc_connect_provider_name": "OIDC", "oidc_connect_client_id": "", @@ -748,6 +789,11 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_wechat_subscriptions": [], "auth_source_default_wechat_grant_on_signup": false, "auth_source_default_wechat_grant_on_first_bind": false, + "auth_source_default_dingtalk_balance": 0, + "auth_source_default_dingtalk_concurrency": 5, + "auth_source_default_dingtalk_subscriptions": [], + "auth_source_default_dingtalk_grant_on_signup": false, + "auth_source_default_dingtalk_grant_on_first_bind": false, "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, @@ -914,6 +960,22 @@ func TestAPIContracts(t *testing.T) { "linuxdo_connect_client_id": "", "linuxdo_connect_client_secret_configured": false, "linuxdo_connect_redirect_url": "", + "dingtalk_connect_enabled": false, + "dingtalk_connect_bypass_registration": false, + "dingtalk_connect_client_id": "", + "dingtalk_connect_client_secret_configured": false, + "dingtalk_connect_redirect_url": "", + "dingtalk_connect_internal_corp_id": "", + "dingtalk_connect_corp_restriction_policy": "", + "dingtalk_connect_sync_corp_email": false, + "dingtalk_connect_sync_corp_email_attr_key": "dingtalk_email", + "dingtalk_connect_sync_corp_email_attr_name": "钉钉企业邮箱", + "dingtalk_connect_sync_dept": false, + "dingtalk_connect_sync_dept_attr_key": "dingtalk_department", + "dingtalk_connect_sync_dept_attr_name": "钉钉部门", + "dingtalk_connect_sync_display_name": false, + "dingtalk_connect_sync_display_name_attr_key": "dingtalk_name", + "dingtalk_connect_sync_display_name_attr_name": "钉钉姓名", "oidc_connect_enabled": true, "oidc_connect_provider_name": "ConfigOIDC", "oidc_connect_client_id": "oidc-config-client", @@ -1074,6 +1136,11 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_wechat_subscriptions": [], "auth_source_default_wechat_grant_on_signup": false, "auth_source_default_wechat_grant_on_first_bind": false, + "auth_source_default_dingtalk_balance": 0, + "auth_source_default_dingtalk_concurrency": 5, + "auth_source_default_dingtalk_subscriptions": [], + "auth_source_default_dingtalk_grant_on_signup": false, + "auth_source_default_dingtalk_grant_on_first_bind": false, "force_email_on_third_party_signup": false } }`, @@ -1184,10 +1251,10 @@ func newContractDeps(t *testing.T) *contractDeps { settingService := service.NewSettingService(settingRepo, cfg) adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) - authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil) + authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService) - adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil) + adminSettingHandler := adminhandler.NewSettingHandler(settingService, nil, nil, nil, nil, nil, nil) adminAccountHandler := adminhandler.NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) jwtAuth := func(c *gin.Context) { diff --git a/backend/internal/server/middleware/backend_mode_guard.go b/backend/internal/server/middleware/backend_mode_guard.go index 157f06b0..050e3bc6 100644 --- a/backend/internal/server/middleware/backend_mode_guard.go +++ b/backend/internal/server/middleware/backend_mode_guard.go @@ -42,15 +42,19 @@ func backendModeAllowsAuthPath(path string) bool { "/auth/oauth/oidc/callback", "/auth/oauth/github/callback", "/auth/oauth/google/callback", + "/auth/oauth/dingtalk/callback", "/auth/oauth/linuxdo/complete-registration", "/auth/oauth/wechat/complete-registration", "/auth/oauth/oidc/complete-registration", + "/auth/oauth/dingtalk/complete-registration", "/auth/oauth/linuxdo/create-account", "/auth/oauth/wechat/create-account", "/auth/oauth/oidc/create-account", + "/auth/oauth/dingtalk/create-account", "/auth/oauth/linuxdo/bind-login", "/auth/oauth/wechat/bind-login", "/auth/oauth/oidc/bind-login", + "/auth/oauth/dingtalk/bind-login", } { if strings.HasSuffix(path, suffix) { return true diff --git a/backend/internal/server/middleware/backend_mode_guard_test.go b/backend/internal/server/middleware/backend_mode_guard_test.go index de9c9ec9..df2edde6 100644 --- a/backend/internal/server/middleware/backend_mode_guard_test.go +++ b/backend/internal/server/middleware/backend_mode_guard_test.go @@ -270,6 +270,36 @@ func TestBackendModeAuthGuard(t *testing.T) { path: "/api/v1/auth/oauth/google/callback", wantStatus: http.StatusOK, }, + { + name: "enabled_blocks_dingtalk_oauth_start", + enabled: "true", + path: "/api/v1/auth/oauth/dingtalk/start", + wantStatus: http.StatusForbidden, + }, + { + name: "enabled_allows_dingtalk_oauth_callback", + enabled: "true", + path: "/api/v1/auth/oauth/dingtalk/callback", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_dingtalk_complete_registration", + enabled: "true", + path: "/api/v1/auth/oauth/dingtalk/complete-registration", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_dingtalk_create_account", + enabled: "true", + path: "/api/v1/auth/oauth/dingtalk/create-account", + wantStatus: http.StatusOK, + }, + { + name: "enabled_allows_dingtalk_bind_login", + enabled: "true", + path: "/api/v1/auth/oauth/dingtalk/bind-login", + wantStatus: http.StatusOK, + }, { name: "enabled_allows_oauth_pending_exchange", enabled: "true", diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 54d40e92..19d0fd2a 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -182,6 +182,32 @@ func RegisterAuthRoutes( }), h.Auth.CreateOIDCOAuthAccount, ) + auth.GET("/oauth/dingtalk/start", h.Auth.DingTalkOAuthStart) + auth.GET("/oauth/dingtalk/bind/start", func(c *gin.Context) { + query := c.Request.URL.Query() + query.Set("intent", "bind_current_user") + c.Request.URL.RawQuery = query.Encode() + h.Auth.DingTalkOAuthStart(c) + }) + auth.GET("/oauth/dingtalk/callback", h.Auth.DingTalkOAuthCallback) + auth.POST("/oauth/dingtalk/complete-registration", + rateLimiter.LimitWithOptions("oauth-dingtalk-complete", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.CompleteDingTalkOAuthRegistration, + ) + auth.POST("/oauth/dingtalk/bind-login", + rateLimiter.LimitWithOptions("oauth-dingtalk-bind-login", 20, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.BindDingTalkOAuthLogin, + ) + auth.POST("/oauth/dingtalk/create-account", + rateLimiter.LimitWithOptions("oauth-dingtalk-create-account", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.CreateDingTalkOAuthAccount, + ) } // 公开设置(无需认证) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index eb5994d5..acca2af4 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1238,7 +1238,7 @@ func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int6 providerKey := strings.TrimSpace(input.ProviderKey) providerSubject := strings.TrimSpace(input.ProviderSubject) if providerType == "" { - return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, or wechat") + return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, or dingtalk") } if providerKey == "" || providerSubject == "" { return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required") @@ -1493,6 +1493,8 @@ func normalizeAdminAuthIdentityProviderType(input string) string { return "oidc" case "wechat": return "wechat" + case "dingtalk": + return "dingtalk" default: return "" } diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index e3c8298c..59290028 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/mail" "strings" "time" @@ -18,7 +19,7 @@ func normalizeOAuthSignupSource(signupSource string) string { switch signupSource { case "", "email": return "email" - case "linuxdo", "wechat", "oidc", "github", "google": + case "linuxdo", "wechat", "oidc", "github", "google", "dingtalk": return signupSource default: return "email" @@ -109,7 +110,7 @@ func (s *AuthService) RegisterOAuthEmailAccount( if s == nil { return nil, nil, ErrServiceUnavailable } - if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { + if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) { return nil, nil, ErrRegDisabled } @@ -118,18 +119,22 @@ func (s *AuthService) RegisterOAuthEmailAccount( return nil, nil, ErrEmailReserved } if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { + slog.Error("oauth email register: policy rejected", "email", email, "error", err.Error()) return nil, nil, err } if err := s.VerifyOAuthEmailCode(ctx, email, verifyCode); err != nil { + slog.Error("oauth email register: verify code failed", "email", email, "error", err.Error()) return nil, nil, err } if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil { + slog.Error("oauth email register: invitation failed", "email", email, "error", err.Error()) return nil, nil, err } existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) if err != nil { + slog.Error("oauth email register: ExistsByEmail failed", "email", email, "error", err.Error()) return nil, nil, ErrServiceUnavailable } if existsEmail { @@ -158,6 +163,7 @@ func (s *AuthService) RegisterOAuthEmailAccount( if errors.Is(err, ErrEmailExists) { return nil, nil, ErrEmailExists } + slog.Error("oauth email register: userRepo.Create failed", "email", email, "signup_source", signupSource, "error", err.Error()) return nil, nil, ErrServiceUnavailable } @@ -181,7 +187,7 @@ func (s *AuthService) RegisterVerifiedOAuthEmailAccount( if s == nil { return nil, nil, ErrServiceUnavailable } - if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { + if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) { return nil, nil, ErrRegDisabled } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index e01e8217..f5302164 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -560,11 +560,25 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username return token, user, nil } +// canBypassRegistrationDisabledForOAuth 在钉钉企业模式(internal_only)且 +// dingtalk_connect_bypass_registration=true 时,允许跳过全局 registration_enabled 检查。 +func (s *AuthService) canBypassRegistrationDisabledForOAuth(ctx context.Context, signupSource string) bool { + if signupSource != "dingtalk" { + return false + } + cfg, err := s.settingService.GetDingTalkConnectOAuthConfig(ctx) + if err != nil || !cfg.Enabled || !cfg.BypassRegistration { + return false + } + return cfg.CorpRestrictionPolicy == "internal_only" +} + // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。 // 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。 // invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。 // affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。 -func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode string) (*TokenPair, *User, error) { +// signupSource 标识来源渠道("dingtalk"/"linuxdo"/"wechat"/"oidc" 等),仅用于豁免检查。 +func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode, signupSource string) (*TokenPair, *User, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, nil, errors.New("refresh token cache not configured") @@ -587,7 +601,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema if err != nil { if errors.Is(err, ErrUserNotFound) { // OAuth 首次登录视为注册 - if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { + if s.settingService == nil || (!s.settingService.IsRegistrationEnabled(ctx) && !s.canBypassRegistrationDisabledForOAuth(ctx, signupSource)) { return nil, nil, ErrRegDisabled } @@ -617,7 +631,11 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema return nil, nil, fmt.Errorf("hash password: %w", err) } - signupSource := inferLegacySignupSource(email) + // 优先用 caller 显式传入的 signupSource(如 "dingtalk" / "linuxdo" / "oidc" / "wechat"), + // 否则才按邮箱后缀推断——避免有真实邮箱的 OAuth 用户被推断为 "email" 渠道,导致渠道授权错读。 + if strings.TrimSpace(signupSource) == "" { + signupSource = inferLegacySignupSource(email) + } grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) var defaultRPMLimit int if s.settingService != nil { @@ -779,6 +797,8 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource return defaults.GitHub, true case "google": return defaults.Google, true + case "dingtalk": + return defaults.DingTalk, true default: return ProviderDefaultGrantSettings{}, false } @@ -992,6 +1012,8 @@ func (s *AuthService) ensureEmailAuthIdentity(ctx context.Context, user *User, s func inferLegacySignupSource(email string) string { normalized := strings.ToLower(strings.TrimSpace(email)) switch { + case strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain): + return "dingtalk" case strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain): return "linuxdo" case strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain): @@ -1086,7 +1108,8 @@ func isReservedEmail(email string) bool { normalized := strings.ToLower(strings.TrimSpace(email)) return strings.HasSuffix(normalized, LinuxDoConnectSyntheticEmailDomain) || strings.HasSuffix(normalized, OIDCConnectSyntheticEmailDomain) || - strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) + strings.HasSuffix(normalized, WeChatConnectSyntheticEmailDomain) || + strings.HasSuffix(normalized, DingTalkConnectSyntheticEmailDomain) } // GenerateToken 生成JWT access token diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index acc44a38..ece02474 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -602,7 +602,7 @@ func TestAuthService_Register_GrantOnSignupMergesSourceOverridesWithGlobalDefaul require.NoError(t, err) require.NotNil(t, user) require.Equal(t, 9.5, user.Balance) - require.Equal(t, 2, user.Concurrency) + require.Equal(t, 5, user.Concurrency) require.Len(t, assigner.calls, 1) require.Equal(t, int64(31), assigner.calls[0].GroupID) require.Equal(t, 5, assigner.calls[0].ValidityDays) @@ -622,7 +622,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_UsesLinuxDoAuthSourceDefa service.defaultSubAssigner = assigner service.refreshTokenCache = &refreshTokenCacheStub{} - tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "") + tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "", "linuxdo") require.NoError(t, err) require.NotNil(t, tokenPair) require.NotNil(t, user) @@ -658,7 +658,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA service.defaultSubAssigner = assigner service.refreshTokenCache = &refreshTokenCacheStub{} - tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "") + tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "", "linuxdo") require.NoError(t, err) require.NotNil(t, tokenPair) require.Equal(t, existing.ID, user.ID) @@ -667,3 +667,99 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA require.Empty(t, repo.created) require.Empty(t, assigner.calls) } + +// newAuthServiceWithDingTalkCfg 构建一个含完整 DingTalk config 的 AuthService, +// 用于测试 canBypassRegistrationDisabledForOAuth。 +func newAuthServiceWithDingTalkCfg(settings map[string]string, dtCfg config.DingTalkConnectConfig) *AuthService { + cfg := &config.Config{ + JWT: config.JWTConfig{Secret: "test-secret", ExpireHour: 1}, + Default: config.DefaultConfig{UserBalance: 3.5, UserConcurrency: 2}, + DingTalk: dtCfg, + } + settingService := NewSettingService(&settingRepoStub{values: settings}, cfg) + return NewAuthService(nil, nil, nil, nil, cfg, settingService, nil, nil, nil, nil, nil, nil) +} + +// minDingTalkURLs 返回一个包含必填字段的基础 DingTalkConnectConfig(不设 Enabled/BypassRegistration/Policy)。 +func minDingTalkURLs() config.DingTalkConnectConfig { + return config.DingTalkConnectConfig{ + ClientID: "test-client", + ClientSecret: "test-secret", + AuthorizeURL: "https://example.com/oauth2/auth", + TokenURL: "https://example.com/oauth2/token", + UserInfoURL: "https://example.com/oauth2/userinfo", + RedirectURL: "https://example.com/callback", + FrontendRedirectURL: "https://example.com/auth/callback", + DingTalkAppKind: "internal_app", + AppType: "internal", + } +} + +func TestCanBypassRegistrationDisabledForOAuth(t *testing.T) { + cases := []struct { + name string + signupSource string + settings map[string]string + dtCfg config.DingTalkConnectConfig + want bool + }{ + { + name: "non-dingtalk source → false", + signupSource: "linuxdo", + settings: map[string]string{}, + dtCfg: minDingTalkURLs(), + want: false, + }, + { + name: "dingtalk but cfg.Enabled=false → false", + signupSource: "dingtalk", + settings: map[string]string{ + SettingKeyDingTalkConnectEnabled: "false", + SettingKeyDingTalkConnectBypassRegistration: "true", + SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only", + }, + dtCfg: minDingTalkURLs(), + want: false, + }, + { + name: "dingtalk enabled but BypassRegistration=false → false", + signupSource: "dingtalk", + settings: map[string]string{ + SettingKeyDingTalkConnectEnabled: "true", + SettingKeyDingTalkConnectBypassRegistration: "false", + SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only", + }, + dtCfg: minDingTalkURLs(), + want: false, + }, + { + name: "dingtalk enabled + bypass=true but policy=none → false", + signupSource: "dingtalk", + settings: map[string]string{ + SettingKeyDingTalkConnectEnabled: "true", + SettingKeyDingTalkConnectBypassRegistration: "true", + SettingKeyDingTalkConnectCorpRestrictionPolicy: "none", + }, + dtCfg: minDingTalkURLs(), + want: false, + }, + { + name: "dingtalk enabled + bypass=true + policy=internal_only → true", + signupSource: "dingtalk", + settings: map[string]string{ + SettingKeyDingTalkConnectEnabled: "true", + SettingKeyDingTalkConnectBypassRegistration: "true", + SettingKeyDingTalkConnectCorpRestrictionPolicy: "internal_only", + }, + dtCfg: minDingTalkURLs(), + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc := newAuthServiceWithDingTalkCfg(tc.settings, tc.dtCfg) + got := svc.canBypassRegistrationDisabledForOAuth(context.Background(), tc.signupSource) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/backend/internal/service/auth_service_test.go b/backend/internal/service/auth_service_test.go new file mode 100644 index 00000000..2aeb6205 --- /dev/null +++ b/backend/internal/service/auth_service_test.go @@ -0,0 +1,13 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsReservedEmail_DingTalkDomain(t *testing.T) { + require.True(t, isReservedEmail("dingtalk-123@dingtalk-connect.invalid")) + require.True(t, isReservedEmail("DINGTALK-456@DINGTALK-CONNECT.INVALID")) // case-insensitive + require.False(t, isReservedEmail("real@dingtalk.com")) +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 17c40ba1..f39c5d7e 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -92,6 +92,9 @@ const OIDCConnectSyntheticEmailDomain = "@oidc-connect.invalid" // WeChatConnectSyntheticEmailDomain 是 WeChat Connect 用户的合成邮箱后缀(RFC 保留域名)。 const WeChatConnectSyntheticEmailDomain = "@wechat-connect.invalid" +// DingTalkConnectSyntheticEmailDomain 是 DingTalk Connect 用户的合成邮箱后缀(RFC 保留域名)。 +const DingTalkConnectSyntheticEmailDomain = "@dingtalk-connect.invalid" + // Setting keys const ( // 注册设置 @@ -137,6 +140,24 @@ const ( SettingKeyLinuxDoConnectClientSecret = "linuxdo_connect_client_secret" SettingKeyLinuxDoConnectRedirectURL = "linuxdo_connect_redirect_url" + // DingTalk Connect OAuth 登录设置 + SettingKeyDingTalkConnectEnabled = "dingtalk_connect_enabled" + SettingKeyDingTalkConnectClientID = "dingtalk_connect_client_id" + SettingKeyDingTalkConnectClientSecret = "dingtalk_connect_client_secret" + SettingKeyDingTalkConnectRedirectURL = "dingtalk_connect_redirect_url" + SettingKeyDingTalkConnectCorpRestrictionPolicy = "dingtalk_connect_corp_restriction_policy" + SettingKeyDingTalkConnectInternalCorpID = "dingtalk_connect_internal_corp_id" + SettingKeyDingTalkConnectBypassRegistration = "dingtalk_connect_bypass_registration" + SettingKeyDingTalkConnectSyncCorpEmail = "dingtalk_connect_sync_corp_email" + SettingKeyDingTalkConnectSyncDisplayName = "dingtalk_connect_sync_display_name" + SettingKeyDingTalkConnectSyncDept = "dingtalk_connect_sync_dept" + SettingKeyDingTalkConnectSyncCorpEmailAttrKey = "dingtalk_connect_sync_corp_email_attr_key" + SettingKeyDingTalkConnectSyncDisplayNameAttrKey = "dingtalk_connect_sync_display_name_attr_key" + SettingKeyDingTalkConnectSyncDeptAttrKey = "dingtalk_connect_sync_dept_attr_key" + SettingKeyDingTalkConnectSyncCorpEmailAttrName = "dingtalk_connect_sync_corp_email_attr_name" + SettingKeyDingTalkConnectSyncDisplayNameAttrName = "dingtalk_connect_sync_display_name_attr_name" + SettingKeyDingTalkConnectSyncDeptAttrName = "dingtalk_connect_sync_dept_attr_name" + // WeChat Connect OAuth 登录设置 SettingKeyWeChatConnectEnabled = "wechat_connect_enabled" SettingKeyWeChatConnectAppID = "wechat_connect_app_id" @@ -214,37 +235,42 @@ const ( SettingKeyDefaultUserRPMLimit = "default_user_rpm_limit" // 新用户默认 RPM 限制(0 = 不限制) // 第三方认证来源默认授予配置 - SettingKeyAuthSourceDefaultEmailBalance = "auth_source_default_email_balance" - SettingKeyAuthSourceDefaultEmailConcurrency = "auth_source_default_email_concurrency" - SettingKeyAuthSourceDefaultEmailSubscriptions = "auth_source_default_email_subscriptions" - SettingKeyAuthSourceDefaultEmailGrantOnSignup = "auth_source_default_email_grant_on_signup" - SettingKeyAuthSourceDefaultEmailGrantOnFirstBind = "auth_source_default_email_grant_on_first_bind" - SettingKeyAuthSourceDefaultLinuxDoBalance = "auth_source_default_linuxdo_balance" - SettingKeyAuthSourceDefaultLinuxDoConcurrency = "auth_source_default_linuxdo_concurrency" - SettingKeyAuthSourceDefaultLinuxDoSubscriptions = "auth_source_default_linuxdo_subscriptions" - SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup = "auth_source_default_linuxdo_grant_on_signup" - SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind = "auth_source_default_linuxdo_grant_on_first_bind" - SettingKeyAuthSourceDefaultOIDCBalance = "auth_source_default_oidc_balance" - SettingKeyAuthSourceDefaultOIDCConcurrency = "auth_source_default_oidc_concurrency" - SettingKeyAuthSourceDefaultOIDCSubscriptions = "auth_source_default_oidc_subscriptions" - SettingKeyAuthSourceDefaultOIDCGrantOnSignup = "auth_source_default_oidc_grant_on_signup" - SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind = "auth_source_default_oidc_grant_on_first_bind" - SettingKeyAuthSourceDefaultWeChatBalance = "auth_source_default_wechat_balance" - SettingKeyAuthSourceDefaultWeChatConcurrency = "auth_source_default_wechat_concurrency" - SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions" - SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup" - SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind" - SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance" - SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency" - SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions" - SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup" - SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind" - SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance" - SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency" - SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions" - SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup" - SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind" - SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup" + SettingKeyAuthSourceDefaultEmailBalance = "auth_source_default_email_balance" + SettingKeyAuthSourceDefaultEmailConcurrency = "auth_source_default_email_concurrency" + SettingKeyAuthSourceDefaultEmailSubscriptions = "auth_source_default_email_subscriptions" + SettingKeyAuthSourceDefaultEmailGrantOnSignup = "auth_source_default_email_grant_on_signup" + SettingKeyAuthSourceDefaultEmailGrantOnFirstBind = "auth_source_default_email_grant_on_first_bind" + SettingKeyAuthSourceDefaultLinuxDoBalance = "auth_source_default_linuxdo_balance" + SettingKeyAuthSourceDefaultLinuxDoConcurrency = "auth_source_default_linuxdo_concurrency" + SettingKeyAuthSourceDefaultLinuxDoSubscriptions = "auth_source_default_linuxdo_subscriptions" + SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup = "auth_source_default_linuxdo_grant_on_signup" + SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind = "auth_source_default_linuxdo_grant_on_first_bind" + SettingKeyAuthSourceDefaultOIDCBalance = "auth_source_default_oidc_balance" + SettingKeyAuthSourceDefaultOIDCConcurrency = "auth_source_default_oidc_concurrency" + SettingKeyAuthSourceDefaultOIDCSubscriptions = "auth_source_default_oidc_subscriptions" + SettingKeyAuthSourceDefaultOIDCGrantOnSignup = "auth_source_default_oidc_grant_on_signup" + SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind = "auth_source_default_oidc_grant_on_first_bind" + SettingKeyAuthSourceDefaultWeChatBalance = "auth_source_default_wechat_balance" + SettingKeyAuthSourceDefaultWeChatConcurrency = "auth_source_default_wechat_concurrency" + SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions" + SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup" + SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind" + SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance" + SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency" + SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions" + SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup" + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind" + SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance" + SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency" + SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions" + SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup" + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind" + SettingKeyAuthSourceDefaultDingTalkBalance = "auth_source_default_dingtalk_balance" + SettingKeyAuthSourceDefaultDingTalkConcurrency = "auth_source_default_dingtalk_concurrency" + SettingKeyAuthSourceDefaultDingTalkSubscriptions = "auth_source_default_dingtalk_subscriptions" + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup = "auth_source_default_dingtalk_grant_on_signup" + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind = "auth_source_default_dingtalk_grant_on_first_bind" + SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup" // 管理员 API Key SettingKeyAdminAPIKey = "admin_api_key" // 全局管理员 API Key(用于外部系统集成) diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 8ccd0c01..8180e321 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1402,7 +1402,6 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context } // SelectAccountWithLoadAwareness selects account with load-awareness and wait plan. -// 调度流程文档见 docs/ACCOUNT_SCHEDULING_FLOW.md 。 // metadataUserID: 用于客户端亲和调度,从中提取客户端 ID // sub2apiUserID: 系统用户 ID,用于二维亲和调度 func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, metadataUserID string, sub2apiUserID int64) (*AccountSelectionResult, error) { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 86978eec..a5c16b1f 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -24,6 +24,25 @@ import ( "golang.org/x/sync/singleflight" ) +// 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 +} + var ( ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled") ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found") @@ -146,6 +165,7 @@ type AuthSourceDefaultSettings struct { WeChat ProviderDefaultGrantSettings GitHub ProviderDefaultGrantSettings Google ProviderDefaultGrantSettings + DingTalk ProviderDefaultGrantSettings ForceEmailOnThirdPartySignup bool } @@ -200,6 +220,13 @@ var ( grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, } + dingTalkAuthSourceDefaultKeys = authSourceDefaultKeySet{ + balance: SettingKeyAuthSourceDefaultDingTalkBalance, + concurrency: SettingKeyAuthSourceDefaultDingTalkConcurrency, + subscriptions: SettingKeyAuthSourceDefaultDingTalkSubscriptions, + grantOnSignup: SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, + grantOnFirstBind: SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, + } ) const ( @@ -606,6 +633,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyCustomMenuItems, SettingKeyCustomEndpoints, SettingKeyLinuxDoConnectEnabled, + SettingKeyDingTalkConnectEnabled, SettingKeyWeChatConnectEnabled, SettingKeyWeChatConnectAppID, SettingKeyWeChatConnectAppSecret, @@ -654,6 +682,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings } else { linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled } + dingTalkEnabled := false + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + dingTalkEnabled = raw == "true" + } else { + dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled + } oidcEnabled := false if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok { oidcEnabled = raw == "true" @@ -723,6 +757,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings CustomMenuItems: settings[SettingKeyCustomMenuItems], CustomEndpoints: settings[SettingKeyCustomEndpoints], LinuxDoOAuthEnabled: linuxDoEnabled, + DingTalkOAuthEnabled: dingTalkEnabled, WeChatOAuthEnabled: weChatEnabled, WeChatOAuthOpenEnabled: weChatOpenEnabled, WeChatOAuthMPEnabled: weChatMPEnabled, @@ -926,6 +961,7 @@ type PublicSettingsInjectionPayload struct { CustomMenuItems json.RawMessage `json:"custom_menu_items"` CustomEndpoints json.RawMessage `json:"custom_endpoints"` LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"` WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` @@ -990,6 +1026,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems), CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints), LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled, + DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled, WeChatOAuthEnabled: settings.WeChatOAuthEnabled, WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled, WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled, @@ -1476,6 +1513,26 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret } + // DingTalk Connect OAuth 登录 + updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled) + updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID + updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL + if settings.DingTalkConnectClientSecret != "" { + updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret + } + updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy + updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID + updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration) + updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail) + updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName) + updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept) + updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey + updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey + updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey + updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName + updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName + updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName + // Generic OIDC OAuth 登录 updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled) updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName @@ -1677,19 +1734,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett settings.WeChat.Subscriptions, settings.GitHub.Subscriptions, settings.Google.Subscriptions, + settings.DingTalk.Subscriptions, } { if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { return nil, err } } - updates := make(map[string]string, 31) + updates := make(map[string]string, 36) writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) + writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk) updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) return updates, nil } @@ -2225,6 +2284,11 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut SettingKeyAuthSourceDefaultGoogleSubscriptions, SettingKeyAuthSourceDefaultGoogleGrantOnSignup, SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, + SettingKeyAuthSourceDefaultDingTalkBalance, + SettingKeyAuthSourceDefaultDingTalkConcurrency, + SettingKeyAuthSourceDefaultDingTalkSubscriptions, + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup, + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind, SettingKeyForceEmailOnThirdPartySignup, } @@ -2240,6 +2304,7 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys), GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), + DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys), ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", }, nil } @@ -2316,111 +2381,116 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // 初始化默认设置 defaults := map[string]string{ - SettingKeyRegistrationEnabled: "true", - SettingKeyEmailVerifyEnabled: "false", - SettingKeyRegistrationEmailSuffixWhitelist: "[]", - SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 - SettingKeyLoginAgreementEnabled: "false", - SettingKeyLoginAgreementMode: defaultLoginAgreementMode, - SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, - SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, - SettingKeySiteName: "Sub2API", - SettingKeySiteLogo: "", - SettingKeyPurchaseSubscriptionEnabled: "false", - SettingKeyPurchaseSubscriptionURL: "", - SettingKeyTableDefaultPageSize: "20", - SettingKeyTablePageSizeOptions: "[10,20,50,100]", - SettingKeyCustomMenuItems: "[]", - SettingKeyCustomEndpoints: "[]", - SettingKeyWeChatConnectEnabled: "false", - SettingKeyWeChatConnectAppID: "", - SettingKeyWeChatConnectAppSecret: "", - SettingKeyWeChatConnectOpenAppID: "", - SettingKeyWeChatConnectOpenAppSecret: "", - SettingKeyWeChatConnectMPAppID: "", - SettingKeyWeChatConnectMPAppSecret: "", - SettingKeyWeChatConnectMobileAppID: "", - SettingKeyWeChatConnectMobileAppSecret: "", - SettingKeyWeChatConnectOpenEnabled: "false", - SettingKeyWeChatConnectMPEnabled: "false", - SettingKeyWeChatConnectMobileEnabled: "false", - SettingKeyWeChatConnectMode: "open", - SettingKeyWeChatConnectScopes: "snsapi_login", - SettingKeyWeChatConnectRedirectURL: "", - SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, - SettingKeyGitHubOAuthEnabled: "false", - SettingKeyGitHubOAuthClientID: "", - SettingKeyGitHubOAuthClientSecret: "", - SettingKeyGitHubOAuthRedirectURL: "", - SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, - SettingKeyGoogleOAuthEnabled: "false", - SettingKeyGoogleOAuthClientID: "", - SettingKeyGoogleOAuthClientSecret: "", - SettingKeyGoogleOAuthRedirectURL: "", - SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, - 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: strconv.FormatBool(oidcUsePKCEDefault), - SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault), - 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), - SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), - SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), - SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), - SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), - SettingKeyDefaultUserRPMLimit: "0", - SettingKeyDefaultSubscriptions: "[]", - SettingKeyAuthSourceDefaultEmailBalance: "0", - SettingKeyAuthSourceDefaultEmailConcurrency: "5", - SettingKeyAuthSourceDefaultEmailSubscriptions: "[]", - SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false", - SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultLinuxDoBalance: "0", - SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5", - SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]", - SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false", - SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultOIDCBalance: "0", - SettingKeyAuthSourceDefaultOIDCConcurrency: "5", - SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]", - SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false", - SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultWeChatBalance: "0", - SettingKeyAuthSourceDefaultWeChatConcurrency: "5", - SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", - SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", - SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultGitHubBalance: "0", - SettingKeyAuthSourceDefaultGitHubConcurrency: "5", - SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", - SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", - SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", - SettingKeyAuthSourceDefaultGoogleBalance: "0", - SettingKeyAuthSourceDefaultGoogleConcurrency: "5", - SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", - SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", - SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", - SettingKeyForceEmailOnThirdPartySignup: "false", - SettingKeySMTPPort: "587", - SettingKeySMTPUseTLS: "false", + SettingKeyRegistrationEnabled: "true", + SettingKeyEmailVerifyEnabled: "false", + SettingKeyRegistrationEmailSuffixWhitelist: "[]", + SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 + SettingKeyLoginAgreementEnabled: "false", + SettingKeyLoginAgreementMode: defaultLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, + SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, + SettingKeySiteName: "Sub2API", + SettingKeySiteLogo: "", + SettingKeyPurchaseSubscriptionEnabled: "false", + SettingKeyPurchaseSubscriptionURL: "", + SettingKeyTableDefaultPageSize: "20", + SettingKeyTablePageSizeOptions: "[10,20,50,100]", + SettingKeyCustomMenuItems: "[]", + SettingKeyCustomEndpoints: "[]", + SettingKeyWeChatConnectEnabled: "false", + SettingKeyWeChatConnectAppID: "", + SettingKeyWeChatConnectAppSecret: "", + SettingKeyWeChatConnectOpenAppID: "", + SettingKeyWeChatConnectOpenAppSecret: "", + SettingKeyWeChatConnectMPAppID: "", + SettingKeyWeChatConnectMPAppSecret: "", + SettingKeyWeChatConnectMobileAppID: "", + SettingKeyWeChatConnectMobileAppSecret: "", + SettingKeyWeChatConnectOpenEnabled: "false", + SettingKeyWeChatConnectMPEnabled: "false", + SettingKeyWeChatConnectMobileEnabled: "false", + SettingKeyWeChatConnectMode: "open", + SettingKeyWeChatConnectScopes: "snsapi_login", + SettingKeyWeChatConnectRedirectURL: "", + SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, + SettingKeyGitHubOAuthEnabled: "false", + SettingKeyGitHubOAuthClientID: "", + SettingKeyGitHubOAuthClientSecret: "", + SettingKeyGitHubOAuthRedirectURL: "", + SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, + SettingKeyGoogleOAuthEnabled: "false", + SettingKeyGoogleOAuthClientID: "", + SettingKeyGoogleOAuthClientSecret: "", + SettingKeyGoogleOAuthRedirectURL: "", + SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, + 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: strconv.FormatBool(oidcUsePKCEDefault), + SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault), + 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), + SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), + SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), + SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), + SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), + SettingKeyDefaultUserRPMLimit: "0", + SettingKeyDefaultSubscriptions: "[]", + SettingKeyAuthSourceDefaultEmailBalance: "0", + SettingKeyAuthSourceDefaultEmailConcurrency: "5", + SettingKeyAuthSourceDefaultEmailSubscriptions: "[]", + SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false", + SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultLinuxDoBalance: "0", + SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5", + SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]", + SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false", + SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultOIDCBalance: "0", + SettingKeyAuthSourceDefaultOIDCConcurrency: "5", + SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]", + SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false", + SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultWeChatBalance: "0", + SettingKeyAuthSourceDefaultWeChatConcurrency: "5", + SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", + SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", + SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGitHubBalance: "0", + SettingKeyAuthSourceDefaultGitHubConcurrency: "5", + SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", + SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGoogleBalance: "0", + SettingKeyAuthSourceDefaultGoogleConcurrency: "5", + SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", + SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultDingTalkBalance: "0", + SettingKeyAuthSourceDefaultDingTalkConcurrency: "5", + SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]", + SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false", + SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false", + SettingKeyForceEmailOnThirdPartySignup: "false", + SettingKeySMTPPort: "587", + SettingKeySMTPUseTLS: "false", // Model fallback defaults SettingKeyEnableModelFallback: "false", SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022", @@ -2599,6 +2669,136 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != "" + // DingTalk Connect 设置: + // - 兼容 config.yaml/env + // - 支持后台系统设置覆盖并持久化(存储于 DB) + dingTalkBase := config.DingTalkConnectConfig{} + if s.cfg != nil { + dingTalkBase = s.cfg.DingTalk + } + + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok { + result.DingTalkConnectEnabled = raw == "true" + } else { + result.DingTalkConnectEnabled = dingTalkBase.Enabled + } + + if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectClientID = strings.TrimSpace(v) + } else { + result.DingTalkConnectClientID = dingTalkBase.ClientID + } + + if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectRedirectURL = strings.TrimSpace(v) + } else { + result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL + } + + result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret]) + if result.DingTalkConnectClientSecret == "" { + result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret) + } + result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != "" + + if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v) + } else { + result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy + } + result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy) + + if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectInternalCorpID = strings.TrimSpace(v) + } else { + result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID + } + + if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration + } + // bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false, + // 以保证加载出的 effective config 永远是一致状态。 + if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + result.DingTalkConnectBypassRegistration = false + } + + if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName + } + if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" { + result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true") + } else { + result.DingTalkConnectSyncDept = dingTalkBase.SyncDept + } + // 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。 + if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" { + result.DingTalkConnectSyncCorpEmail = false + result.DingTalkConnectSyncDisplayName = false + result.DingTalkConnectSyncDept = false + } + + // 身份同步目标 attr key(DB 空 → fallback 默认值) + result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey]) + if result.DingTalkConnectSyncCorpEmailAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" { + result.DingTalkConnectSyncCorpEmailAttrKey = v + } else { + result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email" + } + } + result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey]) + if result.DingTalkConnectSyncDisplayNameAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" { + result.DingTalkConnectSyncDisplayNameAttrKey = v + } else { + result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name" + } + } + result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey]) + if result.DingTalkConnectSyncDeptAttrKey == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" { + result.DingTalkConnectSyncDeptAttrKey = v + } else { + result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department" + } + } + + // 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文) + result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName]) + if result.DingTalkConnectSyncCorpEmailAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" { + result.DingTalkConnectSyncCorpEmailAttrName = v + } else { + result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱" + } + } + result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName]) + if result.DingTalkConnectSyncDisplayNameAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" { + result.DingTalkConnectSyncDisplayNameAttrName = v + } else { + result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名" + } + } + result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName]) + if result.DingTalkConnectSyncDeptAttrName == "" { + if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" { + result.DingTalkConnectSyncDeptAttrName = v + } else { + result.DingTalkConnectSyncDeptAttrName = "钉钉部门" + } + } + // Generic OIDC 设置: // - 兼容 config.yaml/env // - 支持后台系统设置覆盖并持久化(存储于 DB) @@ -2992,10 +3192,14 @@ func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettin GrantOnFirstBind: providerDefaults.GrantOnFirstBind, } - if providerDefaults.Balance != defaultAuthSourceBalance { + // 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency) + // 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。 + // 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆, + // 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。 + if providerDefaults.Balance >= 0 { result.Balance = providerDefaults.Balance } - if providerDefaults.Concurrency > 0 && providerDefaults.Concurrency != defaultAuthSourceConcurrency { + if providerDefaults.Concurrency > 0 { result.Concurrency = providerDefaults.Concurrency } if len(providerDefaults.Subscriptions) > 0 { @@ -3281,6 +3485,157 @@ func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (conf 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。 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 9066e050..ea5fa57c 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -46,6 +46,25 @@ type SystemSettings struct { LinuxDoConnectClientSecretConfigured bool LinuxDoConnectRedirectURL string + // DingTalk Connect OAuth 登录 + DingTalkConnectEnabled bool + DingTalkConnectClientID string + DingTalkConnectClientSecret string + DingTalkConnectClientSecretConfigured bool + DingTalkConnectRedirectURL string + DingTalkConnectCorpRestrictionPolicy string + DingTalkConnectInternalCorpID string + DingTalkConnectBypassRegistration bool + DingTalkConnectSyncCorpEmail bool + DingTalkConnectSyncDisplayName bool + DingTalkConnectSyncDept bool + DingTalkConnectSyncCorpEmailAttrKey string + DingTalkConnectSyncDisplayNameAttrKey string + DingTalkConnectSyncDeptAttrKey string + DingTalkConnectSyncCorpEmailAttrName string + DingTalkConnectSyncDisplayNameAttrName string + DingTalkConnectSyncDeptAttrName string + // WeChat Connect OAuth 登录 WeChatConnectEnabled bool WeChatConnectAppID string @@ -235,6 +254,7 @@ type PublicSettings struct { CustomEndpoints string // JSON array of custom endpoints LinuxDoOAuthEnabled bool + DingTalkOAuthEnabled bool WeChatOAuthEnabled bool WeChatOAuthOpenEnabled bool WeChatOAuthMPEnabled bool diff --git a/backend/internal/service/user_attribute_service.go b/backend/internal/service/user_attribute_service.go index 6c2f8077..ef19e078 100644 --- a/backend/internal/service/user_attribute_service.go +++ b/backend/internal/service/user_attribute_service.go @@ -72,6 +72,11 @@ func (s *UserAttributeService) GetDefinition(ctx context.Context, id int64) (*Us return s.defRepo.GetByID(ctx, id) } +// GetDefinitionByKey retrieves a definition by its unique key +func (s *UserAttributeService) GetDefinitionByKey(ctx context.Context, key string) (*UserAttributeDefinition, error) { + return s.defRepo.GetByKey(ctx, key) +} + // ListDefinitions lists all definitions func (s *UserAttributeService) ListDefinitions(ctx context.Context, enabledOnly bool) ([]UserAttributeDefinition, error) { return s.defRepo.List(ctx, enabledOnly) diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index f84e6f0a..b346f6e7 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -141,10 +141,11 @@ type UserIdentitySummary struct { } type UserIdentitySummarySet struct { - Email UserIdentitySummary `json:"email"` - LinuxDo UserIdentitySummary `json:"linuxdo"` - OIDC UserIdentitySummary `json:"oidc"` - WeChat UserIdentitySummary `json:"wechat"` + Email UserIdentitySummary `json:"email"` + LinuxDo UserIdentitySummary `json:"linuxdo"` + OIDC UserIdentitySummary `json:"oidc"` + WeChat UserIdentitySummary `json:"wechat"` + DingTalk UserIdentitySummary `json:"dingtalk"` } type StartUserIdentityBindingRequest struct { @@ -260,10 +261,11 @@ func (s *UserService) GetProfileIdentitySummaries(ctx context.Context, userID in } summaries := UserIdentitySummarySet{ - Email: s.buildEmailIdentitySummary(user, records), - LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records), - OIDC: s.buildProviderIdentitySummary("oidc", user, records), - WeChat: s.buildProviderIdentitySummary("wechat", user, records), + Email: s.buildEmailIdentitySummary(user, records), + LinuxDo: s.buildProviderIdentitySummary("linuxdo", user, records), + OIDC: s.buildProviderIdentitySummary("oidc", user, records), + WeChat: s.buildProviderIdentitySummary("wechat", user, records), + DingTalk: s.buildProviderIdentitySummary("dingtalk", user, records), } s.applyExplicitProviderAvailability(ctx, &summaries) @@ -283,6 +285,7 @@ func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, sum SettingKeyWeChatConnectMPEnabled, SettingKeyWeChatConnectMobileEnabled, SettingKeyWeChatConnectMode, + SettingKeyDingTalkConnectEnabled, }) if err != nil { return @@ -291,6 +294,9 @@ func (s *UserService) applyExplicitProviderAvailability(ctx context.Context, sum if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" { disableIdentityBindAction(&summaries.LinuxDo) } + if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" { + disableIdentityBindAction(&summaries.DingTalk) + } if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok && strings.TrimSpace(raw) != "" && raw != "true" { disableIdentityBindAction(&summaries.OIDC) } @@ -696,7 +702,7 @@ func (s *UserService) canUnbindProvider(provider string, user *User, records []U return true } - for _, candidate := range []string{"linuxdo", "oidc", "wechat"} { + for _, candidate := range []string{"linuxdo", "oidc", "wechat", "dingtalk"} { if candidate == provider { continue } @@ -772,6 +778,8 @@ func buildUserIdentityBindAuthorizeURL(provider, redirectTo string) (string, err path = "/api/v1/auth/oauth/oidc/bind/start" case "wechat": path = "/api/v1/auth/oauth/wechat/bind/start" + case "dingtalk": + path = "/api/v1/auth/oauth/dingtalk/bind/start" default: return "", ErrIdentityProviderInvalid } @@ -790,6 +798,8 @@ func normalizeUserIdentityProvider(provider string) string { return "oidc" case "wechat": return "wechat" + case "dingtalk": + return "dingtalk" case "email": return "email" default: diff --git a/backend/migrations/136_add_dingtalk_provider_type.sql b/backend/migrations/136_add_dingtalk_provider_type.sql new file mode 100644 index 00000000..79c7ba05 --- /dev/null +++ b/backend/migrations/136_add_dingtalk_provider_type.sql @@ -0,0 +1,27 @@ +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_signup_source_check; + +ALTER TABLE users + ADD CONSTRAINT users_signup_source_check + CHECK (signup_source IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk')); + +ALTER TABLE auth_identities + DROP CONSTRAINT IF EXISTS auth_identities_provider_type_check; + +ALTER TABLE auth_identities + ADD CONSTRAINT auth_identities_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk')); + +ALTER TABLE auth_identity_channels + DROP CONSTRAINT IF EXISTS auth_identity_channels_provider_type_check; + +ALTER TABLE auth_identity_channels + ADD CONSTRAINT auth_identity_channels_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk')); + +ALTER TABLE pending_auth_sessions + DROP CONSTRAINT IF EXISTS pending_auth_sessions_provider_type_check; + +ALTER TABLE pending_auth_sessions + ADD CONSTRAINT pending_auth_sessions_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google', 'dingtalk')); diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 03e9e58f..4fc49de6 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -22,7 +22,8 @@ export type AuthSourceType = | "oidc" | "wechat" | "github" - | "google"; + | "google" + | "dingtalk"; export interface AuthSourceDefaultsValue { balance: number; @@ -64,6 +65,7 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [ "wechat", "github", "google", + "dingtalk", ]; const AUTH_SOURCE_DEFAULT_BALANCE = 0; const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5; @@ -352,6 +354,11 @@ export interface SystemSettings { auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]; auth_source_default_wechat_grant_on_signup?: boolean; auth_source_default_wechat_grant_on_first_bind?: boolean; + auth_source_default_dingtalk_balance?: number; + auth_source_default_dingtalk_concurrency?: number; + auth_source_default_dingtalk_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_dingtalk_grant_on_signup?: boolean; + auth_source_default_dingtalk_grant_on_first_bind?: boolean; auth_source_default_github_balance?: number; auth_source_default_github_concurrency?: number; auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[]; @@ -396,6 +403,24 @@ export interface SystemSettings { linuxdo_connect_client_secret_configured: boolean; linuxdo_connect_redirect_url: string; + // DingTalk Connect OAuth settings + dingtalk_connect_enabled: boolean; + dingtalk_connect_client_id: string; + dingtalk_connect_client_secret_configured: boolean; + dingtalk_connect_redirect_url: string; + dingtalk_connect_corp_restriction_policy: string; + dingtalk_connect_internal_corp_id: string; + dingtalk_connect_bypass_registration: boolean; + dingtalk_connect_sync_corp_email: boolean; + dingtalk_connect_sync_display_name: boolean; + dingtalk_connect_sync_dept: boolean; + dingtalk_connect_sync_corp_email_attr_key: string; + dingtalk_connect_sync_display_name_attr_key: string; + dingtalk_connect_sync_dept_attr_key: string; + dingtalk_connect_sync_corp_email_attr_name: string; + dingtalk_connect_sync_display_name_attr_name: string; + dingtalk_connect_sync_dept_attr_name: string; + // WeChat Connect OAuth settings wechat_connect_enabled: boolean; wechat_connect_app_id: string; @@ -571,6 +596,11 @@ export interface UpdateSettingsRequest { auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]; auth_source_default_wechat_grant_on_signup?: boolean; auth_source_default_wechat_grant_on_first_bind?: boolean; + auth_source_default_dingtalk_balance?: number; + auth_source_default_dingtalk_concurrency?: number; + auth_source_default_dingtalk_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_dingtalk_grant_on_signup?: boolean; + auth_source_default_dingtalk_grant_on_first_bind?: boolean; auth_source_default_github_balance?: number; auth_source_default_github_concurrency?: number; auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[]; @@ -609,6 +639,22 @@ export interface UpdateSettingsRequest { linuxdo_connect_client_id?: string; linuxdo_connect_client_secret?: string; linuxdo_connect_redirect_url?: string; + dingtalk_connect_enabled?: boolean; + dingtalk_connect_client_id?: string; + dingtalk_connect_client_secret?: string; + dingtalk_connect_redirect_url?: string; + dingtalk_connect_corp_restriction_policy?: string; + dingtalk_connect_internal_corp_id?: string; + dingtalk_connect_bypass_registration?: boolean; + dingtalk_connect_sync_corp_email?: boolean; + dingtalk_connect_sync_display_name?: boolean; + dingtalk_connect_sync_dept?: boolean; + dingtalk_connect_sync_corp_email_attr_key?: string; + dingtalk_connect_sync_display_name_attr_key?: string; + dingtalk_connect_sync_dept_attr_key?: string; + dingtalk_connect_sync_corp_email_attr_name?: string; + dingtalk_connect_sync_display_name_attr_name?: string; + dingtalk_connect_sync_dept_attr_name?: string; wechat_connect_enabled?: boolean; wechat_connect_app_id?: string; wechat_connect_app_secret?: string; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index bb990fc4..fd259230 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -592,7 +592,7 @@ export async function completeWeChatOAuthRegistration( } async function createPendingOAuthAccount( - provider: 'linuxdo' | 'oidc' | 'wechat', + provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk', invitationCode: string, decision?: OAuthAdoptionDecision, affiliateCode?: string @@ -633,6 +633,14 @@ export async function createPendingWeChatOAuthAccount( return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode) } +export async function createPendingDingTalkOAuthAccount( + invitationCode: string, + decision?: OAuthAdoptionDecision, + affiliateCode?: string +): Promise { + return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode) +} + export async function completePendingOAuthBindLogin( decision?: OAuthAdoptionDecision ): Promise { @@ -683,7 +691,8 @@ export const authAPI = { exchangePendingOAuthCompletion, completeLinuxDoOAuthRegistration, completeOIDCOAuthRegistration, - completeWeChatOAuthRegistration + completeWeChatOAuthRegistration, + createPendingDingTalkOAuthAccount } export default authAPI diff --git a/frontend/src/components/auth/DingTalkOAuthSection.vue b/frontend/src/components/auth/DingTalkOAuthSection.vue new file mode 100644 index 00000000..9003225d --- /dev/null +++ b/frontend/src/components/auth/DingTalkOAuthSection.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/user/profile/ProfileAccountBindingsCard.vue b/frontend/src/components/user/profile/ProfileAccountBindingsCard.vue index f1cf54a9..3ee033ee 100644 --- a/frontend/src/components/user/profile/ProfileAccountBindingsCard.vue +++ b/frontend/src/components/user/profile/ProfileAccountBindingsCard.vue @@ -2,6 +2,7 @@ (), { linuxdoEnabled: false, + dingtalkEnabled: false, oidcEnabled: false, oidcProviderName: 'OIDC', wechatEnabled: false, diff --git a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue index 7843dace..8764c07e 100644 --- a/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue +++ b/frontend/src/components/user/profile/ProfileIdentityBindingsSection.vue @@ -217,6 +217,7 @@ const props = withDefaults( defineProps<{ user: User | null linuxdoEnabled?: boolean + dingtalkEnabled?: boolean oidcEnabled?: boolean oidcProviderName?: string wechatEnabled?: boolean @@ -227,6 +228,7 @@ const props = withDefaults( }>(), { linuxdoEnabled: false, + dingtalkEnabled: false, oidcEnabled: false, oidcProviderName: 'OIDC', wechatEnabled: false, @@ -406,6 +408,9 @@ function isProviderEnabledForBinding(provider: BindableProvider): boolean { if (provider === 'linuxdo') { return props.linuxdoEnabled } + if (provider === 'dingtalk') { + return props.dingtalkEnabled + } if (provider === 'oidc') { return props.oidcEnabled } @@ -432,6 +437,17 @@ const providerItems = computed(() => [ canUnbind: Boolean(getBindingStatus('linuxdo') && getBindingDetails('linuxdo')?.can_unbind), details: getBindingDetails('linuxdo'), }, + { + provider: 'dingtalk' as const, + label: t('profile.authBindings.providers.dingtalk'), + bound: getBindingStatus('dingtalk'), + canBind: + !getBindingStatus('dingtalk') && + isProviderEnabledForBinding('dingtalk') && + (getBindingDetails('dingtalk')?.can_bind ?? true), + canUnbind: Boolean(getBindingStatus('dingtalk') && getBindingDetails('dingtalk')?.can_unbind), + details: getBindingDetails('dingtalk'), + }, { provider: 'oidc' as const, label: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }), @@ -460,6 +476,9 @@ function providerInitial(provider: UserAuthProvider): string { if (provider === 'linuxdo') { return 'L' } + if (provider === 'dingtalk') { + return 'D' + } if (provider === 'wechat') { return 'W' } @@ -473,6 +492,9 @@ function providerIconClass(provider: UserAuthProvider): string { if (provider === 'linuxdo') { return 'bg-orange-100 text-orange-600 dark:bg-orange-900/20 dark:text-orange-300' } + if (provider === 'dingtalk') { + return 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300' + } if (provider === 'wechat') { return 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-300' } diff --git a/frontend/src/components/user/profile/ProfileInfoCard.vue b/frontend/src/components/user/profile/ProfileInfoCard.vue index 2c190715..fc5691a8 100644 --- a/frontend/src/components/user/profile/ProfileInfoCard.vue +++ b/frontend/src/components/user/profile/ProfileInfoCard.vue @@ -139,6 +139,7 @@ (), { linuxdoEnabled: false, + dingtalkEnabled: false, oidcEnabled: false, oidcProviderName: 'OIDC', wechatEnabled: false, @@ -262,6 +265,7 @@ const memberSinceLabel = computed(() => { const providerLabels = computed>(() => ({ email: t('profile.authBindings.providers.email'), linuxdo: t('profile.authBindings.providers.linuxdo'), + dingtalk: t('profile.authBindings.providers.dingtalk'), oidc: t('profile.authBindings.providers.oidc', { providerName: props.oidcProviderName }), wechat: t('profile.authBindings.providers.wechat'), github: 'GitHub', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index eb373e16..d9d13c8c 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -461,7 +461,7 @@ export default { invitationCodeInvalid: 'Invalid or used invitation code', invitationCodeValidating: 'Validating invitation code...', invitationCodeInvalidCannotRegister: 'Invalid invitation code. Please check and try again', - oauthOrContinue: 'or continue with email', + oauthOrContinue: 'or continue with others', linuxdo: { signIn: 'Continue with Linux.do', orContinue: 'or continue with email', @@ -476,6 +476,34 @@ export default { completing: 'Completing registration…', completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.' }, + dingtalk: { + signIn: 'Continue with DingTalk', + callbackTitle: 'Signing you in with DingTalk', + callbackProcessing: 'Completing DingTalk login, please wait...', + callbackHint: 'If you are not redirected automatically, go back to the login page and try again.', + callbackMissingToken: 'Missing login token, please try again.', + backToLogin: 'Back to Login', + invitationRequired: 'This DingTalk account is not yet registered. The site requires an invitation code — please enter one to complete registration.', + invalidPendingToken: 'The registration token has expired. Please sign in with DingTalk again.', + completeRegistration: 'Complete Registration', + completing: 'Completing registration…', + completeRegistrationFailed: 'Registration failed. Please check your invitation code and try again.', + createAccountTitle: 'Create DingTalk Account', + registrationDisabledRedirectToBind: 'New account registration is currently disabled. Please bind to your existing account with its email and password.', + error: { + title: 'DingTalk Sign-in Failed', + csrf: 'Login session expired, please scan again', + corp_rejected: 'Your DingTalk account is not part of this organization. Please contact administrator', + dingtalk_not_enabled: 'DingTalk login is not enabled', + upstream_error: 'DingTalk service is temporarily unavailable. Please try again later', + missing_browser_session: 'Browser session lost. Please login again', + missing_params: 'Request parameters are incomplete', + invalid_state: 'Invalid login state', + provider_error: 'DingTalk authorization failed', + session_error: 'Failed to create session. Please retry', + retry: 'Retry Login' + } + }, emailOAuth: { signIn: 'Continue with {providerName}' }, @@ -524,6 +552,7 @@ export default { wechatNotConfigured: 'WeChat sign-in is not configured yet.' }, linuxdoCallbackPageTitle: 'LinuxDo Sign-In Callback', + dingtalkCallbackPageTitle: 'DingTalk Sign-In Callback', oidcCallbackPageTitle: 'OIDC Sign-In Callback', oauthCallbackPageTitle: 'OAuth Callback', wechatProviderName: 'WeChat', @@ -1244,6 +1273,7 @@ export default { providers: { email: 'Email', linuxdo: 'LinuxDo', + dingtalk: 'DingTalk', oidc: '{providerName}', wechat: 'WeChat', }, @@ -5268,6 +5298,47 @@ export default { quickSetCopy: 'Generate & Copy (current site)', redirectUrlSetAndCopied: 'Redirect URL generated and copied to clipboard' }, + dingtalk: { + title: 'DingTalk Login', + description: 'Configure DingTalk OAuth for Sub2API end-user login', + enable: 'Enable DingTalk Login (Internal Corporate App)', + enableHint: 'Show DingTalk login on the login/register pages', + clientId: 'Client ID (AppKey)', + clientIdPlaceholder: 'e.g., dingxxxxxxxxxxxxxxxx', + clientIdHint: 'Get this from the DingTalk Open Platform app details', + clientSecret: 'Client Secret (AppSecret)', + clientSecretPlaceholder: '********', + clientSecretHint: 'Used by backend to exchange tokens (keep it secret)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: 'Secret configured. Leave empty to keep the current value.', + redirectUrl: 'Redirect URL', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback', + redirectUrlHint: + 'Must match the redirect URL configured in DingTalk Open Platform (must be an absolute http(s) URL)', + corpPolicy: { + label: 'Corp Restriction Policy', + hint: 'Control which DingTalk accounts (orgs) are allowed to sign in', + none: 'No restriction (all DingTalk accounts allowed)', + internalOnly: 'Internal only (single corp)' + }, + bypassRegistration: 'Enable DingTalk signup', + bypassRegistrationHint: 'Allow new users to register via DingTalk even when public registration is disabled.', + syncDisplayName: 'Sync DingTalk display name', + syncDisplayNameHint: 'Overwrite username with the DingTalk staff name on each login (also stored in the dingtalk_name attribute).', + syncCorpEmail: 'Sync corporate email', + syncCorpEmailHint: 'Write the DingTalk corporate email to the dingtalk_email attribute on each login (does not change the login email).', + syncCorpEmailPermissionHint: 'Requires the OAPI permission "Personal info incl. email (fieldEmail)" to be granted to the app on the DingTalk open platform, otherwise OAPI will not return the email field.', + syncDept: 'Sync department', + syncDeptHint: 'Write the full DingTalk department path to the dingtalk_department attribute on each login (fetched live each time).', + syncDeptPermissionHint: 'Requires the OAPI "Department info read (qyapi_get_department_list)" permission to be granted to the app on the DingTalk open platform, otherwise the department path cannot be resolved.', + syncDisplayNameTarget: 'Attribute key', + syncDisplayNameTargetHint: 'Defaults to dingtalk_name / DingTalk Name. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', + syncCorpEmailTarget: 'Attribute key', + syncCorpEmailTargetHint: 'Defaults to dingtalk_email / DingTalk Corporate Email. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', + syncDeptTarget: 'Attribute key', + syncDeptTargetHint: 'Defaults to dingtalk_department / DingTalk Department. Saving settings auto-creates the user attribute by the key and display name above (existing definition only has its display name synced).', + syncAttrDisplayName: 'Display name' + }, oidc: { title: 'OIDC Login', description: 'Configure a standard OIDC provider (for example Keycloak)', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ca3b4879..dded02de 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -460,7 +460,7 @@ export default { invitationCodeInvalid: '邀请码无效或已被使用', invitationCodeValidating: '正在验证邀请码...', invitationCodeInvalidCannotRegister: '邀请码无效,请检查后重试', - oauthOrContinue: '或使用邮箱密码继续', + oauthOrContinue: '或使用其他继续', linuxdo: { signIn: '使用 Linux.do 登录', orContinue: '或使用邮箱密码继续', @@ -475,6 +475,34 @@ export default { completing: '正在完成注册...', completeRegistrationFailed: '注册失败,请检查邀请码后重试。' }, + dingtalk: { + signIn: '钉钉登录', + callbackTitle: '正在完成钉钉登录', + callbackProcessing: '正在验证钉钉登录信息,请稍候...', + callbackHint: '如果页面未自动跳转,请返回登录页重试。', + callbackMissingToken: '登录信息缺失,请返回重试。', + backToLogin: '返回登录', + invitationRequired: '该钉钉账号尚未注册,站点已开启邀请码注册,请输入邀请码以完成注册。', + invalidPendingToken: '注册凭证已失效,请重新使用钉钉登录。', + completeRegistration: '完成注册', + completing: '正在完成注册...', + completeRegistrationFailed: '注册失败,请检查邀请码后重试。', + createAccountTitle: '创建钉钉账户', + registrationDisabledRedirectToBind: '当前已禁止注册新账户,请使用已有账户邮箱和密码绑定钉钉登录', + error: { + title: '钉钉登录失败', + csrf: '登录会话已过期,请重新扫码登录', + corp_rejected: '您的钉钉账号不属于本企业,请联系管理员', + dingtalk_not_enabled: '钉钉登录暂未启用', + upstream_error: '钉钉服务暂时不可用,请稍后重试', + missing_browser_session: '浏览器会话丢失,请重新登录', + missing_params: '请求参数不完整', + invalid_state: '登录状态异常', + provider_error: '钉钉授权失败', + session_error: '会话创建失败,请重试', + retry: '重新登录' + } + }, emailOAuth: { signIn: '使用 {providerName} 登录' }, @@ -522,6 +550,7 @@ export default { wechatNotConfigured: '微信登录尚未配置。' }, linuxdoCallbackPageTitle: 'LinuxDo 登录回调', + dingtalkCallbackPageTitle: '钉钉登录回调', oidcCallbackPageTitle: 'OIDC 登录回调', oauthCallbackPageTitle: 'OAuth 回调', wechatProviderName: '微信', @@ -1248,6 +1277,7 @@ export default { providers: { email: '邮箱', linuxdo: 'LinuxDo', + dingtalk: '钉钉', oidc: '{providerName}', wechat: '微信', }, @@ -5431,6 +5461,46 @@ export default { quickSetCopy: '使用当前站点生成并复制', redirectUrlSetAndCopied: '已使用当前站点生成回调地址并复制到剪贴板' }, + dingtalk: { + title: '钉钉登录', + description: '配置钉钉 OAuth,用于 Sub2API 用户登录', + enable: '启用钉钉登录-企业内部应用', + enableHint: '在登录/注册页面显示钉钉登录入口', + clientId: 'Client ID(AppKey)', + clientIdPlaceholder: '例如:dingxxxxxxxxxxxxxxxx', + clientIdHint: '从钉钉开放平台应用详情页获取', + clientSecret: 'Client Secret(AppSecret)', + clientSecretPlaceholder: '********', + clientSecretHint: '用于后端交换 token(请保密)', + clientSecretConfiguredPlaceholder: '********', + clientSecretConfiguredHint: '密钥已配置,留空以保留当前值。', + redirectUrl: '回调地址(Redirect URL)', + redirectUrlPlaceholder: 'https://your-domain.com/api/v1/auth/oauth/dingtalk/callback', + redirectUrlHint: '需与钉钉开放平台中配置的回调地址一致(必须是 http(s) 完整 URL)', + corpPolicy: { + label: '企业限制策略', + hint: '控制哪些钉钉账号(企业)可以登录', + none: '不限制(所有钉钉账号均可登录)', + internalOnly: '仅本企业(Internal Only)' + }, + bypassRegistration: '开放钉钉注册', + bypassRegistrationHint: '即使「开放注册」关闭时也可以通过钉钉登录来注册', + syncDisplayName: '同步钉钉姓名', + syncDisplayNameHint: '登录时将钉钉姓名写入 username 字段(同时记录到 dingtalk_name 属性)', + syncCorpEmail: '同步企业邮箱', + syncCorpEmailHint: '登录时将钉钉企业邮箱写入 dingtalk_email 属性(不影响登录邮箱)', + syncCorpEmailPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「邮箱等个人信息(fieldEmail)」权限,否则 OAPI 不会返回企业邮箱字段', + syncDept: '同步部门', + syncDeptHint: '登录时将钉钉首个部门完整路径写入 dingtalk_department 属性(每次登录实时拉取)', + syncDeptPermissionHint: '需在钉钉开放平台 → 应用 → 权限管理中为本应用申请「通讯录部门信息读权限(qyapi_get_department_list)」,否则无法递归出部门路径', + syncDisplayNameTarget: '属性键', + syncDisplayNameTargetHint: '默认 dingtalk_name / 钉钉姓名;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', + syncCorpEmailTarget: '属性键', + syncCorpEmailTargetHint: '默认 dingtalk_email / 钉钉企业邮箱;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', + syncDeptTarget: '属性键', + syncDeptTargetHint: '默认 dingtalk_department / 钉钉部门;保存设置时按上述属性键和显示名称自动创建用户属性(已存在则仅同步显示名称)', + syncAttrDisplayName: '显示名称' + }, oidc: { title: 'OIDC 登录', description: '配置标准 OIDC Provider(例如 Keycloak)', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 656421cc..9902fe0e 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -106,6 +106,25 @@ const routes: RouteRecordRaw[] = [ titleKey: 'auth.wechatPaymentCallbackPageTitle' } }, + { + path: '/auth/dingtalk/callback', + name: 'DingTalkOAuthCallback', + component: () => import('@/views/auth/DingTalkCallbackView.vue'), + meta: { + requiresAuth: false, + title: 'DingTalk OAuth Callback', + titleKey: 'auth.dingtalkCallbackPageTitle' + } + }, + { + path: '/auth/dingtalk/email-completion', + name: 'dingtalk-email-completion', + component: () => import('@/views/auth/DingTalkEmailCompletionView.vue'), + meta: { + requiresAuth: false, + title: 'DingTalk Email Completion' + } + }, { path: '/auth/oidc/callback', name: 'OIDCOAuthCallback', @@ -672,6 +691,8 @@ const BACKEND_MODE_ALLOWED_PATHS = ['/login', '/key-usage', '/setup', '/payment/ const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', + '/auth/dingtalk/callback', + '/auth/dingtalk/email-completion', '/auth/oidc/callback', '/auth/wechat/callback', '/auth/wechat/payment/callback', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 94c6da57..2cc7d9d0 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -34,7 +34,7 @@ export interface NotifyEmailEntry { // ==================== User & Auth Types ==================== -export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google' +export type UserAuthProvider = 'email' | 'linuxdo' | 'oidc' | 'wechat' | 'github' | 'google' | 'dingtalk' export interface UserAuthBindingStatus { bound?: boolean @@ -215,6 +215,7 @@ export interface PublicSettings { custom_menu_items: CustomMenuItem[] custom_endpoints: CustomEndpoint[] linuxdo_oauth_enabled: boolean + dingtalk_oauth_enabled?: boolean wechat_oauth_enabled: boolean wechat_oauth_open_enabled?: boolean wechat_oauth_mp_enabled?: boolean diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index cce3bf81..3f44a474 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2333,6 +2333,294 @@ + +
+
+

+ {{ t("admin.settings.dingtalk.title") }} +

+

+ {{ t("admin.settings.dingtalk.description") }} +

+
+
+
+
+ +

+ {{ t("admin.settings.dingtalk.enableHint") }} +

+
+ +
+ +
+
+
+ + +

+ {{ t("admin.settings.dingtalk.clientIdHint") }} +

+
+ +
+ + +

+ {{ + form.dingtalk_connect_client_secret_configured + ? t( + "admin.settings.dingtalk.clientSecretConfiguredHint", + ) + : t("admin.settings.dingtalk.clientSecretHint") + }} +

+
+ +
+ + +

+ {{ t("admin.settings.dingtalk.redirectUrlHint") }} +

+
+ + +
+ +

+ {{ t("admin.settings.dingtalk.corpPolicy.hint") }} +

+
+ + +
+
+ + +
+
+ +

+ {{ t("admin.settings.dingtalk.bypassRegistrationHint") }} +

+
+ +
+ + +
+
+
+ +

+ {{ t("admin.settings.dingtalk.syncDisplayNameHint") }} +

+
+ +
+
+
+ + +
+
+ + +
+
+

+ {{ t("admin.settings.dingtalk.syncDisplayNameTargetHint") }} +

+
+
+
+
+ +

+ {{ t("admin.settings.dingtalk.syncCorpEmailHint") }} +

+

+ {{ t("admin.settings.dingtalk.syncCorpEmailPermissionHint") }} +

+
+ +
+
+
+ + +
+
+ + +
+
+

+ {{ t("admin.settings.dingtalk.syncCorpEmailTargetHint") }} +

+
+
+
+
+ +

+ {{ t("admin.settings.dingtalk.syncDeptHint") }} +

+

+ {{ t("admin.settings.dingtalk.syncDeptPermissionHint") }} +

+
+ +
+
+
+ + +
+
+ + +
+
+

+ {{ t("admin.settings.dingtalk.syncDeptTargetHint") }} +

+
+
+
+
+
+
({ linuxdo_connect_client_secret: "", linuxdo_connect_client_secret_configured: false, linuxdo_connect_redirect_url: "", + // DingTalk Connect OAuth 登录 + dingtalk_connect_enabled: false, + dingtalk_connect_client_id: "", + dingtalk_connect_client_secret: "", + dingtalk_connect_client_secret_configured: false, + dingtalk_connect_redirect_url: "", + dingtalk_connect_corp_restriction_policy: "none", + dingtalk_connect_internal_corp_id: "", + dingtalk_connect_bypass_registration: false, + dingtalk_connect_sync_corp_email: false, + dingtalk_connect_sync_display_name: false, + dingtalk_connect_sync_dept: false, + dingtalk_connect_sync_corp_email_attr_key: "dingtalk_email", + dingtalk_connect_sync_display_name_attr_key: "dingtalk_name", + dingtalk_connect_sync_dept_attr_key: "dingtalk_department", + dingtalk_connect_sync_corp_email_attr_name: "钉钉企业邮箱", + dingtalk_connect_sync_display_name_attr_name: "钉钉姓名", + dingtalk_connect_sync_dept_attr_name: "钉钉部门", wechat_connect_enabled: false, wechat_connect_app_id: "", wechat_connect_app_secret: "", @@ -6658,6 +6965,14 @@ const authSourceDefaultsMeta = computed(() => [ "Applied on first signup or first bind through a verified Google email.", ), }, + { + source: "dingtalk" as AuthSourceType, + title: "钉钉", + description: localText( + "通过钉钉首次注册或首次绑定时应用。", + "Applied on first signup or first bind through DingTalk.", + ), + }, ]); // Proxies for web search emulation ProxySelector @@ -7241,6 +7556,7 @@ async function loadSettings() { smtpPasswordManuallyEdited.value = false; form.turnstile_secret_key = ""; form.linuxdo_connect_client_secret = ""; + form.dingtalk_connect_client_secret = ""; form.github_oauth_client_secret = ""; form.google_oauth_client_secret = ""; form.wechat_connect_app_secret = ""; @@ -7593,6 +7909,24 @@ async function saveSettings() { linuxdo_connect_client_secret: form.linuxdo_connect_client_secret || undefined, linuxdo_connect_redirect_url: form.linuxdo_connect_redirect_url, + dingtalk_connect_enabled: form.dingtalk_connect_enabled, + dingtalk_connect_client_id: form.dingtalk_connect_client_id, + dingtalk_connect_client_secret: + form.dingtalk_connect_client_secret || undefined, + dingtalk_connect_redirect_url: form.dingtalk_connect_redirect_url, + dingtalk_connect_corp_restriction_policy: + form.dingtalk_connect_corp_restriction_policy, + dingtalk_connect_internal_corp_id: form.dingtalk_connect_internal_corp_id, + dingtalk_connect_bypass_registration: form.dingtalk_connect_bypass_registration, + dingtalk_connect_sync_corp_email: form.dingtalk_connect_sync_corp_email, + dingtalk_connect_sync_display_name: form.dingtalk_connect_sync_display_name, + dingtalk_connect_sync_dept: form.dingtalk_connect_sync_dept, + dingtalk_connect_sync_corp_email_attr_key: form.dingtalk_connect_sync_corp_email_attr_key, + dingtalk_connect_sync_display_name_attr_key: form.dingtalk_connect_sync_display_name_attr_key, + dingtalk_connect_sync_dept_attr_key: form.dingtalk_connect_sync_dept_attr_key, + dingtalk_connect_sync_corp_email_attr_name: form.dingtalk_connect_sync_corp_email_attr_name, + dingtalk_connect_sync_display_name_attr_name: form.dingtalk_connect_sync_display_name_attr_name, + dingtalk_connect_sync_dept_attr_name: form.dingtalk_connect_sync_dept_attr_name, wechat_connect_enabled: form.wechat_connect_enabled, wechat_connect_app_id: form.wechat_connect_open_app_id || @@ -7775,6 +8109,7 @@ async function saveSettings() { smtpPasswordManuallyEdited.value = false; form.turnstile_secret_key = ""; form.linuxdo_connect_client_secret = ""; + form.dingtalk_connect_client_secret = ""; form.github_oauth_client_secret = ""; form.google_oauth_client_secret = ""; form.wechat_connect_app_secret = ""; @@ -8992,6 +9327,21 @@ watch( } }, ); + +// bypass_registration 与身份同步三开关仅在 internal_only 模式下生效。切换 policy 到其它值时, +// 立即把相关字段重置为 false,避免保存请求里残留旧值。后端 admin handler 与 +// 配置加载层都有 coerce 兜底,这里是 UX 层的同步而非安全防线。 +watch( + () => form.dingtalk_connect_corp_restriction_policy, + (policy) => { + if (policy !== "internal_only") { + if (form.dingtalk_connect_bypass_registration) form.dingtalk_connect_bypass_registration = false; + if (form.dingtalk_connect_sync_corp_email) form.dingtalk_connect_sync_corp_email = false; + if (form.dingtalk_connect_sync_display_name) form.dingtalk_connect_sync_display_name = false; + if (form.dingtalk_connect_sync_dept) form.dingtalk_connect_sync_dept = false; + } + }, +); diff --git a/frontend/src/views/auth/DingTalkEmailCompletionView.vue b/frontend/src/views/auth/DingTalkEmailCompletionView.vue new file mode 100644 index 00000000..11e631cd --- /dev/null +++ b/frontend/src/views/auth/DingTalkEmailCompletionView.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/src/views/auth/LoginView.vue b/frontend/src/views/auth/LoginView.vue index 3e89b079..52f3cfef 100644 --- a/frontend/src/views/auth/LoginView.vue +++ b/frontend/src/views/auth/LoginView.vue @@ -152,6 +152,11 @@ :disabled="authActionDisabled" :show-divider="false" /> + (false) const turnstileEnabled = ref(false) const turnstileSiteKey = ref('') const linuxdoOAuthEnabled = ref(false) +const dingtalkOAuthEnabled = ref(false) const wechatOAuthEnabled = ref(false) const backendModeEnabled = ref(false) const oidcOAuthEnabled = ref(false) @@ -283,6 +290,7 @@ const showOAuthLogin = computed( () => !backendModeEnabled.value && (linuxdoOAuthEnabled.value || + dingtalkOAuthEnabled.value || wechatOAuthEnabled.value || oidcOAuthEnabled.value || githubOAuthEnabled.value || @@ -311,6 +319,7 @@ onMounted(async () => { turnstileEnabled.value = settings.turnstile_enabled turnstileSiteKey.value = settings.turnstile_site_key || '' linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled + dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) backendModeEnabled.value = settings.backend_mode_enabled oidcOAuthEnabled.value = settings.oidc_oauth_enabled diff --git a/frontend/src/views/user/ProfileView.vue b/frontend/src/views/user/ProfileView.vue index 84055119..a6481cee 100644 --- a/frontend/src/views/user/ProfileView.vue +++ b/frontend/src/views/user/ProfileView.vue @@ -7,6 +7,7 @@ (undefined) const wechatOAuthMPEnabled = ref(undefined) @@ -89,6 +91,7 @@ onMounted(async () => { balanceLowNotifyEnabled.value = settings.balance_low_notify_enabled ?? false systemDefaultThreshold.value = settings.balance_low_notify_threshold ?? 0 linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled ?? false + dingtalkOAuthEnabled.value = settings.dingtalk_oauth_enabled ?? false wechatOAuthEnabled.value = isWeChatWebOAuthEnabled(settings) wechatOAuthOpenEnabled.value = typeof settings.wechat_oauth_open_enabled === 'boolean' ? settings.wechat_oauth_open_enabled