feat(dingtalk): 钉钉 OAuth 登录接入与 internal_only 用户属性同步
⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(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 <noreply@anthropic.com>
This commit is contained in:
parent
b006e36af9
commit
b19da9c7fe
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
30
backend/internal/config/validate_dingtalk.go
Normal file
30
backend/internal/config/validate_dingtalk.go
Normal file
@ -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
|
||||
}
|
||||
53
backend/internal/config/validate_dingtalk_test.go
Normal file
53
backend/internal/config/validate_dingtalk_test.go
Normal file
@ -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",
|
||||
}))
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
319
backend/internal/handler/admin/setting_handler_dingtalk_test.go
Normal file
319
backend/internal/handler/admin/setting_handler_dingtalk_test.go
Normal file
@ -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])
|
||||
})
|
||||
}
|
||||
398
backend/internal/handler/auth_dingtalk_client.go
Normal file
398
backend/internal/handler/auth_dingtalk_client.go
Normal file
@ -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
|
||||
}
|
||||
143
backend/internal/handler/auth_dingtalk_client_test.go
Normal file
143
backend/internal/handler/auth_dingtalk_client_test.go
Normal file
@ -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)
|
||||
}
|
||||
1066
backend/internal/handler/auth_dingtalk_oauth.go
Normal file
1066
backend/internal/handler/auth_dingtalk_oauth.go
Normal file
File diff suppressed because it is too large
Load Diff
391
backend/internal/handler/auth_dingtalk_oauth_test.go
Normal file
391
backend/internal/handler/auth_dingtalk_oauth_test.go
Normal file
@ -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)
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
// 公开设置(无需认证)
|
||||
|
||||
@ -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 ""
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
13
backend/internal/service/auth_service_test.go
Normal file
13
backend/internal/service/auth_service_test.go
Normal file
@ -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"))
|
||||
}
|
||||
@ -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(用于外部系统集成)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
27
backend/migrations/136_add_dingtalk_provider_type.sql
Normal file
27
backend/migrations/136_add_dingtalk_provider_type.sql
Normal file
@ -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'));
|
||||
@ -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;
|
||||
|
||||
@ -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<PendingOAuthCreateAccountResponse> {
|
||||
return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode)
|
||||
}
|
||||
|
||||
export async function completePendingOAuthBindLogin(
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<PendingOAuthBindLoginResponse> {
|
||||
@ -683,7 +691,8 @@ export const authAPI = {
|
||||
exchangePendingOAuthCompletion,
|
||||
completeLinuxDoOAuthRegistration,
|
||||
completeOIDCOAuthRegistration,
|
||||
completeWeChatOAuthRegistration
|
||||
completeWeChatOAuthRegistration,
|
||||
createPendingDingTalkOAuthAccount
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
61
frontend/src/components/auth/DingTalkOAuthSection.vue
Normal file
61
frontend/src/components/auth/DingTalkOAuthSection.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<button type="button" :disabled="disabled" class="btn btn-secondary w-full" @click="startLogin">
|
||||
<svg
|
||||
class="icon mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
aria-hidden="true"
|
||||
style="flex-shrink: 0"
|
||||
>
|
||||
<circle cx="12" cy="12" r="12" fill="#1677FF" />
|
||||
<text
|
||||
x="12"
|
||||
y="17"
|
||||
font-family="sans-serif"
|
||||
font-size="13"
|
||||
font-weight="bold"
|
||||
fill="white"
|
||||
text-anchor="middle"
|
||||
>钉</text>
|
||||
</svg>
|
||||
{{ t('auth.dingtalk.signIn') }}
|
||||
</button>
|
||||
|
||||
<div v-if="showDivider" class="flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
<span class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthOrContinue') }}
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
affCode?: string
|
||||
showDivider?: boolean
|
||||
}>(), {
|
||||
showDivider: true
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
|
||||
function startLogin(): void {
|
||||
const redirectTo = (route.query.redirect as string) || '/dashboard'
|
||||
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const startURL = `${normalized}/auth/oauth/dingtalk/start?redirect=${encodeURIComponent(redirectTo)}`
|
||||
window.location.href = startURL
|
||||
}
|
||||
</script>
|
||||
@ -2,6 +2,7 @@
|
||||
<ProfileIdentityBindingsSection
|
||||
:user="user"
|
||||
:linuxdo-enabled="linuxdoEnabled"
|
||||
:dingtalk-enabled="dingtalkEnabled"
|
||||
:oidc-enabled="oidcEnabled"
|
||||
:oidc-provider-name="oidcProviderName"
|
||||
:wechat-enabled="wechatEnabled"
|
||||
@ -18,6 +19,7 @@ withDefaults(
|
||||
defineProps<{
|
||||
user: User | null
|
||||
linuxdoEnabled?: boolean
|
||||
dingtalkEnabled?: boolean
|
||||
oidcEnabled?: boolean
|
||||
oidcProviderName?: string
|
||||
wechatEnabled?: boolean
|
||||
@ -26,6 +28,7 @@ withDefaults(
|
||||
}>(),
|
||||
{
|
||||
linuxdoEnabled: false,
|
||||
dingtalkEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: 'OIDC',
|
||||
wechatEnabled: false,
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -139,6 +139,7 @@
|
||||
<ProfileIdentityBindingsSection
|
||||
:user="user"
|
||||
:linuxdo-enabled="linuxdoEnabled"
|
||||
:dingtalk-enabled="dingtalkEnabled"
|
||||
:oidc-enabled="oidcEnabled"
|
||||
:oidc-provider-name="oidcProviderName"
|
||||
:wechat-enabled="wechatEnabled"
|
||||
@ -190,6 +191,7 @@ import type { User, UserAuthBindingStatus, UserAuthProvider, UserProfileSourceCo
|
||||
const props = withDefaults(defineProps<{
|
||||
user: User | null
|
||||
linuxdoEnabled?: boolean
|
||||
dingtalkEnabled?: boolean
|
||||
oidcEnabled?: boolean
|
||||
oidcProviderName?: string
|
||||
wechatEnabled?: boolean
|
||||
@ -197,6 +199,7 @@ const props = withDefaults(defineProps<{
|
||||
wechatMpEnabled?: boolean
|
||||
}>(), {
|
||||
linuxdoEnabled: false,
|
||||
dingtalkEnabled: false,
|
||||
oidcEnabled: false,
|
||||
oidcProviderName: 'OIDC',
|
||||
wechatEnabled: false,
|
||||
@ -262,6 +265,7 @@ const memberSinceLabel = computed(() => {
|
||||
const providerLabels = computed<Record<UserAuthProvider, string>>(() => ({
|
||||
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',
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -2333,6 +2333,294 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DingTalk Connect OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div
|
||||
class="border-b border-gray-100 px-6 py-4 dark:border-dark-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.settings.dingtalk.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.dingtalk.enable")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.enableHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.dingtalk_connect_enabled" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="form.dingtalk_connect_enabled"
|
||||
class="border-t border-gray-100 pt-4 dark:border-dark-700"
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.dingtalk.clientId") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_client_id"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
t('admin.settings.dingtalk.clientIdPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.clientIdHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.dingtalk.clientSecret") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_client_secret"
|
||||
type="password"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
form.dingtalk_connect_client_secret_configured
|
||||
? t(
|
||||
'admin.settings.dingtalk.clientSecretConfiguredPlaceholder',
|
||||
)
|
||||
: t('admin.settings.dingtalk.clientSecretPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
form.dingtalk_connect_client_secret_configured
|
||||
? t(
|
||||
"admin.settings.dingtalk.clientSecretConfiguredHint",
|
||||
)
|
||||
: t("admin.settings.dingtalk.clientSecretHint")
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.settings.dingtalk.redirectUrl") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_redirect_url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="
|
||||
t('admin.settings.dingtalk.redirectUrlPlaceholder')
|
||||
"
|
||||
/>
|
||||
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.redirectUrlHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Corp Restriction Policy -->
|
||||
<div class="border-t border-gray-100 pt-4 dark:border-dark-700">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.dingtalk.corpPolicy.label") }}
|
||||
</label>
|
||||
<p class="mb-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.corpPolicy.hint") }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
v-model="form.dingtalk_connect_corp_restriction_policy"
|
||||
type="radio"
|
||||
value="none"
|
||||
class="h-4 w-4 text-primary-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.dingtalk.corpPolicy.none") }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
v-model="form.dingtalk_connect_corp_restriction_policy"
|
||||
type="radio"
|
||||
value="internal_only"
|
||||
class="h-4 w-4 text-primary-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t("admin.settings.dingtalk.corpPolicy.internalOnly") }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- bypass_registration toggle(仅 internal_only 模式下可见可用) -->
|
||||
<div
|
||||
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
|
||||
class="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-dark-700"
|
||||
>
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.dingtalk.bypassRegistration")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.bypassRegistrationHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.dingtalk_connect_bypass_registration" />
|
||||
</div>
|
||||
|
||||
<!-- 身份同步开关(仅 internal_only 模式下可见) -->
|
||||
<div
|
||||
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
|
||||
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.dingtalk.syncDisplayName")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.syncDisplayNameHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.dingtalk_connect_sync_display_name" />
|
||||
</div>
|
||||
<div v-if="form.dingtalk_connect_sync_display_name" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
|
||||
{{ t("admin.settings.dingtalk.syncDisplayNameTarget") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_display_name_attr_key"
|
||||
type="text"
|
||||
placeholder="dingtalk_name"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
|
||||
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_display_name_attr_name"
|
||||
type="text"
|
||||
placeholder="钉钉姓名"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="form.dingtalk_connect_sync_display_name" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.settings.dingtalk.syncDisplayNameTargetHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
|
||||
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.dingtalk.syncCorpEmail")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.syncCorpEmailHint") }}
|
||||
</p>
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{{ t("admin.settings.dingtalk.syncCorpEmailPermissionHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.dingtalk_connect_sync_corp_email" />
|
||||
</div>
|
||||
<div v-if="form.dingtalk_connect_sync_corp_email" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
|
||||
{{ t("admin.settings.dingtalk.syncCorpEmailTarget") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_corp_email_attr_key"
|
||||
type="text"
|
||||
placeholder="dingtalk_email"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
|
||||
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_corp_email_attr_name"
|
||||
type="text"
|
||||
placeholder="钉钉企业邮箱"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="form.dingtalk_connect_sync_corp_email" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.settings.dingtalk.syncCorpEmailTargetHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.dingtalk_connect_corp_restriction_policy === 'internal_only'"
|
||||
class="pt-4 border-t border-gray-100 dark:border-dark-700 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t("admin.settings.dingtalk.syncDept")
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.settings.dingtalk.syncDeptHint") }}
|
||||
</p>
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
{{ t("admin.settings.dingtalk.syncDeptPermissionHint") }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.dingtalk_connect_sync_dept" />
|
||||
</div>
|
||||
<div v-if="form.dingtalk_connect_sync_dept" class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
|
||||
{{ t("admin.settings.dingtalk.syncDeptTarget") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_dept_attr_key"
|
||||
type="text"
|
||||
placeholder="dingtalk_department"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap min-w-[5rem]">
|
||||
{{ t("admin.settings.dingtalk.syncAttrDisplayName") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.dingtalk_connect_sync_dept_attr_name"
|
||||
type="text"
|
||||
placeholder="钉钉部门"
|
||||
class="input text-sm flex-1 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="form.dingtalk_connect_sync_dept" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{{ t("admin.settings.dingtalk.syncDeptTargetHint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generic OIDC OAuth 登录 -->
|
||||
<div class="card">
|
||||
<div
|
||||
@ -6417,6 +6705,7 @@ type SettingsForm = Omit<
|
||||
smtp_password: string;
|
||||
turnstile_secret_key: string;
|
||||
linuxdo_connect_client_secret: string;
|
||||
dingtalk_connect_client_secret: string;
|
||||
wechat_connect_app_secret: string;
|
||||
wechat_connect_open_app_secret: string;
|
||||
wechat_connect_mp_app_secret: string;
|
||||
@ -6518,6 +6807,24 @@ const form = reactive<SettingsForm>({
|
||||
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
852
frontend/src/views/auth/DingTalkCallbackView.vue
Normal file
852
frontend/src/views/auth/DingTalkCallbackView.vue
Normal file
@ -0,0 +1,852 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.dingtalk.callbackTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ isProcessing ? t('auth.dingtalk.callbackProcessing') : t('auth.dingtalk.callbackHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="
|
||||
needsInvitation ||
|
||||
needsAdoptionConfirmation ||
|
||||
needsChooser ||
|
||||
needsCreateAccount ||
|
||||
needsBindLogin ||
|
||||
needsTotpChallenge
|
||||
"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div
|
||||
v-if="adoptionRequired && (suggestedDisplayName || suggestedAvatarUrl)"
|
||||
class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauthFlow.profileDetailsTitle', { providerName }) }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthFlow.profileDetailsDescription', { providerName }) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="suggestedDisplayName"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptDisplayName" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauthFlow.useDisplayName') }}
|
||||
</span>
|
||||
<span class="block text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedDisplayName }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="suggestedAvatarUrl"
|
||||
class="flex items-start gap-3 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-dark-600 dark:bg-dark-900/50"
|
||||
>
|
||||
<input v-model="adoptAvatar" type="checkbox" class="mt-1 h-4 w-4" />
|
||||
<img
|
||||
:src="suggestedAvatarUrl"
|
||||
:alt="t('auth.oauthFlow.avatarAlt', { providerName })"
|
||||
class="h-10 w-10 rounded-full border border-gray-200 object-cover dark:border-dark-600"
|
||||
/>
|
||||
<span class="space-y-1">
|
||||
<span class="block font-medium text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauthFlow.useAvatar') }}
|
||||
</span>
|
||||
<span class="block break-all text-gray-500 dark:text-dark-400">
|
||||
{{ suggestedAvatarUrl }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="needsInvitation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.dingtalk.invitationRequired') }}
|
||||
</p>
|
||||
<div>
|
||||
<input
|
||||
v-model="invitationCode"
|
||||
type="text"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.invitationCodePlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitInvitation"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !invitationCode.trim()"
|
||||
@click="handleSubmitInvitation"
|
||||
>
|
||||
{{ isSubmitting ? t('auth.dingtalk.completing') : t('auth.dingtalk.completeRegistration') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsAdoptionConfirmation">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oauthFlow.reviewProfileBeforeContinue', { providerName }) }}
|
||||
</p>
|
||||
<button class="btn btn-primary w-full" :disabled="isSubmitting" @click="handleContinueLogin">
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.continue') }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsChooser">
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-800/60">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('auth.oauthFlow.chooseHowToContinue') }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">
|
||||
{{
|
||||
pendingAccountEmail
|
||||
? t('auth.oauthFlow.suggestedEmail', { email: pendingAccountEmail })
|
||||
: t('auth.oauthFlow.chooseAccountActionHint')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToBindLoginMode()"
|
||||
>
|
||||
{{ t('auth.oauthFlow.bindExistingAccount') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
{{ t('auth.oauthFlow.createNewAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsCreateAccount">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oauthFlow.createAccountHint') }}
|
||||
</p>
|
||||
<PendingOAuthCreateAccountForm
|
||||
test-id-prefix="dingtalk"
|
||||
:initial-email="pendingAccountEmail"
|
||||
:is-submitting="isSubmitting"
|
||||
:error-message="accountActionError"
|
||||
@submit="handleCreateAccount"
|
||||
@switch-to-bind="switchToBindLoginMode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsBindLogin">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ t('auth.oauthFlow.bindLoginHint', { providerName }) }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model="bindLoginEmail"
|
||||
data-testid="dingtalk-bind-login-email"
|
||||
type="email"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
<input
|
||||
v-model="bindLoginPassword"
|
||||
data-testid="dingtalk-bind-login-password"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleBindLogin"
|
||||
/>
|
||||
<button
|
||||
data-testid="dingtalk-bind-login-submit"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || !bindLoginEmail.trim() || !bindLoginPassword"
|
||||
@click="handleBindLogin"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.logInAndBind') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canReturnToCreateAccount"
|
||||
class="btn btn-secondary w-full"
|
||||
:disabled="isSubmitting"
|
||||
@click="switchToCreateAccountMode"
|
||||
>
|
||||
{{ t('auth.oauthFlow.useDifferentEmail') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="needsTotpChallenge">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{
|
||||
t('auth.oauthFlow.totpHint', {
|
||||
providerName,
|
||||
account: totpUserEmailMasked || t('auth.oauthFlow.yourAccount')
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
v-model="totpCode"
|
||||
data-testid="dingtalk-bind-login-totp"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="input w-full"
|
||||
placeholder="123456"
|
||||
:disabled="isSubmitting"
|
||||
@keyup.enter="handleSubmitTotpChallenge"
|
||||
/>
|
||||
<button
|
||||
data-testid="dingtalk-bind-login-totp-submit"
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="isSubmitting || totpCode.trim().length !== 6"
|
||||
@click="handleSubmitTotpChallenge"
|
||||
>
|
||||
{{ isSubmitting ? t('common.processing') : t('auth.oauthFlow.verifyAndContinue') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import PendingOAuthCreateAccountForm, {
|
||||
type PendingOAuthCreateAccountPayload
|
||||
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
exchangePendingOAuthCompletion,
|
||||
getOAuthCompletionKind,
|
||||
isOAuthLoginCompletion,
|
||||
login2FA,
|
||||
persistOAuthTokenContext,
|
||||
type OAuthAdoptionDecision,
|
||||
type OAuthTokenResponse,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
import {
|
||||
clearAllAffiliateReferralCodes,
|
||||
loadOAuthAffiliateCode,
|
||||
oauthAffiliatePayload
|
||||
} from '@/utils/oauthAffiliate'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t, te } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isProcessing = ref(true)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// Invitation code flow state
|
||||
const needsInvitation = ref(false)
|
||||
const invitationCode = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const invitationError = ref('')
|
||||
const redirectTo = ref('/dashboard')
|
||||
const adoptionRequired = ref(false)
|
||||
const suggestedDisplayName = ref('')
|
||||
const suggestedAvatarUrl = ref('')
|
||||
const adoptDisplayName = ref(true)
|
||||
const adoptAvatar = ref(true)
|
||||
const needsAdoptionConfirmation = ref(false)
|
||||
const pendingAccountAction = ref<'none' | 'choose_account_action' | 'create_account' | 'bind_login'>('none')
|
||||
const pendingAccountEmail = ref('')
|
||||
const bindLoginEmail = ref('')
|
||||
const bindLoginPassword = ref('')
|
||||
const legacyPendingOAuthToken = ref('')
|
||||
const accountActionError = ref('')
|
||||
const canReturnToCreateAccount = ref(false)
|
||||
const bindSuccessMessage = t('profile.authBindings.bindSuccess')
|
||||
const needsTotpChallenge = ref(false)
|
||||
const totpTempToken = ref('')
|
||||
const totpCode = ref('')
|
||||
const totpError = ref('')
|
||||
const totpUserEmailMasked = ref('')
|
||||
const providerName = '钉钉'
|
||||
|
||||
const needsCreateAccount = computed(() => pendingAccountAction.value === 'create_account')
|
||||
const needsChooser = computed(() => pendingAccountAction.value === 'choose_account_action')
|
||||
const needsBindLogin = computed(() => pendingAccountAction.value === 'bind_login')
|
||||
|
||||
watch(invitationError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(accountActionError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(totpError, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(errorMessage, value => {
|
||||
if (value) {
|
||||
appStore.showError(value)
|
||||
}
|
||||
})
|
||||
|
||||
type DingTalkPendingActionResponse = PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
intent?: string
|
||||
email?: string
|
||||
resolved_email?: string
|
||||
pending_email?: string
|
||||
existing_account_email?: string
|
||||
suggested_email?: string
|
||||
}
|
||||
|
||||
function persistPendingAuthSession(redirect?: string) {
|
||||
authStore.setPendingAuthSession({
|
||||
token: '',
|
||||
token_field: 'pending_oauth_token',
|
||||
provider: 'dingtalk',
|
||||
redirect: sanitizeRedirectPath(redirect || redirectTo.value)
|
||||
})
|
||||
}
|
||||
|
||||
function clearPendingAuthSession() {
|
||||
authStore.clearPendingAuthSession()
|
||||
}
|
||||
|
||||
function parseFragmentParams(): URLSearchParams {
|
||||
const raw = typeof window !== 'undefined' ? window.location.hash : ''
|
||||
const hash = raw.startsWith('#') ? raw.slice(1) : raw
|
||||
return new URLSearchParams(hash)
|
||||
}
|
||||
|
||||
function readLegacyFragmentLogin(params: URLSearchParams): OAuthTokenResponse | null {
|
||||
const accessToken = params.get('access_token')?.trim() || ''
|
||||
if (!accessToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
const completion: OAuthTokenResponse = {
|
||||
access_token: accessToken
|
||||
}
|
||||
const refreshToken = params.get('refresh_token')?.trim() || ''
|
||||
if (refreshToken) {
|
||||
completion.refresh_token = refreshToken
|
||||
}
|
||||
const expiresIn = Number.parseInt(params.get('expires_in')?.trim() || '', 10)
|
||||
if (Number.isFinite(expiresIn) && expiresIn > 0) {
|
||||
completion.expires_in = expiresIn
|
||||
}
|
||||
const tokenType = params.get('token_type')?.trim() || ''
|
||||
if (tokenType) {
|
||||
completion.token_type = tokenType
|
||||
}
|
||||
return completion
|
||||
}
|
||||
|
||||
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
if (!path) return '/dashboard'
|
||||
if (!path.startsWith('/')) return '/dashboard'
|
||||
if (path.startsWith('//')) return '/dashboard'
|
||||
if (path.includes('://')) return '/dashboard'
|
||||
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
function currentAdoptionDecision(): OAuthAdoptionDecision {
|
||||
return {
|
||||
adoptDisplayName: adoptDisplayName.value,
|
||||
adoptAvatar: adoptAvatar.value
|
||||
}
|
||||
}
|
||||
|
||||
function serializeAdoptionDecision(decision: OAuthAdoptionDecision): Record<string, boolean> {
|
||||
const payload: Record<string, boolean> = {}
|
||||
if (typeof decision.adoptDisplayName === 'boolean') {
|
||||
payload.adopt_display_name = decision.adoptDisplayName
|
||||
}
|
||||
if (typeof decision.adoptAvatar === 'boolean') {
|
||||
payload.adopt_avatar = decision.adoptAvatar
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function applyAdoptionSuggestionState(completion: {
|
||||
adoption_required?: boolean
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}) {
|
||||
adoptionRequired.value = completion.adoption_required === true
|
||||
suggestedDisplayName.value = completion.suggested_display_name || ''
|
||||
suggestedAvatarUrl.value = completion.suggested_avatar_url || ''
|
||||
|
||||
if (!suggestedDisplayName.value) {
|
||||
adoptDisplayName.value = false
|
||||
}
|
||||
if (!suggestedAvatarUrl.value) {
|
||||
adoptAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function hasSuggestedProfile(completion: {
|
||||
suggested_display_name?: string
|
||||
suggested_avatar_url?: string
|
||||
}): boolean {
|
||||
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
|
||||
}
|
||||
|
||||
function normalizedPendingState(value: string | null | undefined): string {
|
||||
return value?.trim().toLowerCase() || ''
|
||||
}
|
||||
|
||||
function extractPendingAccountEmail(completion: DingTalkPendingActionResponse): string {
|
||||
return (
|
||||
completion.pending_email ||
|
||||
completion.existing_account_email ||
|
||||
completion.email ||
|
||||
completion.resolved_email ||
|
||||
completion.suggested_email ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolvePendingAccountAction(
|
||||
completion: DingTalkPendingActionResponse
|
||||
): 'none' | 'choose_account_action' | 'create_account' | 'bind_login' {
|
||||
const raw = normalizedPendingState(completion.step || completion.error || completion.intent)
|
||||
if (
|
||||
raw === 'choice' ||
|
||||
raw === 'choose_account_action_required' ||
|
||||
raw === 'choose_account_action' ||
|
||||
raw === 'choose_account' ||
|
||||
raw === 'choose'
|
||||
) {
|
||||
return 'choose_account_action'
|
||||
}
|
||||
if (raw === 'email_required' || raw === 'create_account_required' || raw === 'create_account') {
|
||||
return 'create_account'
|
||||
}
|
||||
if (
|
||||
raw === 'bind_login_required' ||
|
||||
raw === 'bind_login' ||
|
||||
raw === 'existing_account' ||
|
||||
raw === 'existing_account_required' ||
|
||||
raw === 'existing_account_binding_required' ||
|
||||
raw === 'adopt_existing_user_by_email'
|
||||
) {
|
||||
return 'bind_login'
|
||||
}
|
||||
return 'none'
|
||||
}
|
||||
|
||||
function applyPendingAccountAction(completion: DingTalkPendingActionResponse) {
|
||||
const action = resolvePendingAccountAction(completion)
|
||||
pendingAccountAction.value = action
|
||||
accountActionError.value = ''
|
||||
needsTotpChallenge.value = false
|
||||
totpTempToken.value = ''
|
||||
totpCode.value = ''
|
||||
totpError.value = ''
|
||||
totpUserEmailMasked.value = ''
|
||||
|
||||
const email = extractPendingAccountEmail(completion)
|
||||
if (action === 'choose_account_action') {
|
||||
pendingAccountEmail.value = email
|
||||
bindLoginEmail.value = email
|
||||
bindLoginPassword.value = ''
|
||||
canReturnToCreateAccount.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'create_account') {
|
||||
pendingAccountEmail.value = email
|
||||
canReturnToCreateAccount.value = true
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'bind_login') {
|
||||
bindLoginEmail.value = email
|
||||
bindLoginPassword.value = ''
|
||||
canReturnToCreateAccount.value = false
|
||||
return
|
||||
}
|
||||
|
||||
canReturnToCreateAccount.value = false
|
||||
}
|
||||
|
||||
function applyTotpChallenge(completion: DingTalkPendingActionResponse): boolean {
|
||||
if (completion.requires_2fa !== true || !completion.temp_token) {
|
||||
return false
|
||||
}
|
||||
|
||||
pendingAccountAction.value = 'none'
|
||||
needsInvitation.value = false
|
||||
needsAdoptionConfirmation.value = false
|
||||
needsTotpChallenge.value = true
|
||||
totpTempToken.value = completion.temp_token
|
||||
totpCode.value = ''
|
||||
totpError.value = ''
|
||||
totpUserEmailMasked.value = completion.user_email_masked || ''
|
||||
isProcessing.value = false
|
||||
return true
|
||||
}
|
||||
|
||||
function switchToBindLoginMode(nextEmail?: string) {
|
||||
pendingAccountAction.value = 'bind_login'
|
||||
bindLoginEmail.value = bindLoginEmail.value.trim() || nextEmail?.trim() || pendingAccountEmail.value.trim()
|
||||
bindLoginPassword.value = ''
|
||||
accountActionError.value = ''
|
||||
canReturnToCreateAccount.value = true
|
||||
}
|
||||
|
||||
function switchToCreateAccountMode() {
|
||||
pendingAccountAction.value = 'create_account'
|
||||
pendingAccountEmail.value = pendingAccountEmail.value.trim() || bindLoginEmail.value.trim()
|
||||
accountActionError.value = ''
|
||||
}
|
||||
|
||||
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||
}
|
||||
|
||||
function isCreateAccountRecoveryError(error: unknown): boolean {
|
||||
const data = (error as {
|
||||
response?: {
|
||||
data?: {
|
||||
reason?: string
|
||||
error?: string
|
||||
code?: string
|
||||
step?: string
|
||||
intent?: string
|
||||
}
|
||||
}
|
||||
}).response?.data
|
||||
const states = [data?.reason, data?.error, data?.code, data?.step, data?.intent]
|
||||
.map(value => value?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
|
||||
return states.includes('email_exists') ||
|
||||
states.includes('bind_login_required') ||
|
||||
states.includes('bind_login') ||
|
||||
states.includes('adopt_existing_user_by_email') ||
|
||||
states.includes('existing_account_required') ||
|
||||
states.includes('existing_account_binding_required')
|
||||
}
|
||||
|
||||
async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redirect: string) {
|
||||
if (getOAuthCompletionKind(completion) === 'bind') {
|
||||
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
|
||||
clearPendingAuthSession()
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(bindSuccessMessage)
|
||||
await router.replace(bindRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isOAuthLoginCompletion(completion)) {
|
||||
throw new Error(t('auth.dingtalk.callbackMissingToken'))
|
||||
}
|
||||
|
||||
persistOAuthTokenContext(completion)
|
||||
await authStore.setToken(completion.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
}
|
||||
|
||||
async function finalizePendingAccountResponse(completion: DingTalkPendingActionResponse) {
|
||||
applyAdoptionSuggestionState(completion)
|
||||
const redirect = sanitizeRedirectPath(completion.redirect || redirectTo.value)
|
||||
|
||||
// step=email_completion: 用户无邮箱,需要跳到补邮箱页面
|
||||
if (completion.step === 'email_completion' || (completion as Record<string, unknown>)['requires_email_completion'] === true) {
|
||||
await router.replace('/auth/dingtalk/email-completion?redirect=' + encodeURIComponent(redirect))
|
||||
return
|
||||
}
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
pendingAccountAction.value = 'none'
|
||||
needsInvitation.value = true
|
||||
needsAdoptionConfirmation.value = false
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (applyTotpChallenge(completion)) {
|
||||
persistPendingAuthSession(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
applyPendingAccountAction(completion)
|
||||
if (pendingAccountAction.value !== 'none') {
|
||||
needsInvitation.value = false
|
||||
needsAdoptionConfirmation.value = false
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (completion.auth_result === 'pending_session') {
|
||||
needsInvitation.value = false
|
||||
needsAdoptionConfirmation.value = false
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeCompletion(completion, redirect)
|
||||
}
|
||||
|
||||
async function handleSubmitInvitation() {
|
||||
invitationError.value = ''
|
||||
if (!invitationCode.value.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const affCode = loadOAuthAffiliateCode()
|
||||
const decision = currentAdoptionDecision()
|
||||
const { data: completion } = await apiClient.post<DingTalkPendingActionResponse>(
|
||||
'/auth/oauth/dingtalk/complete-registration',
|
||||
{
|
||||
pending_oauth_token: legacyPendingOAuthToken.value || undefined,
|
||||
invitation_code: invitationCode.value.trim(),
|
||||
...oauthAffiliatePayload(affCode),
|
||||
...serializeAdoptionDecision(decision)
|
||||
}
|
||||
)
|
||||
await finalizePendingAccountResponse(completion)
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; response?: { data?: { message?: string } } }
|
||||
invitationError.value =
|
||||
err.response?.data?.message || err.message || t('auth.dingtalk.completeRegistrationFailed')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContinueLogin() {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await exchangePendingOAuthCompletion(currentAdoptionDecision()) as DingTalkPendingActionResponse
|
||||
await finalizePendingAccountResponse(completion)
|
||||
} catch (e: unknown) {
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
needsAdoptionConfirmation.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
accountActionError.value = ''
|
||||
if (!payload.email || !payload.password) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<DingTalkPendingActionResponse>('/auth/oauth/pending/create-account', {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
invitation_code: payload.invitationCode || undefined,
|
||||
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
})
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
if (isCreateAccountRecoveryError(e)) {
|
||||
switchToBindLoginMode(payload.email.trim())
|
||||
return
|
||||
}
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBindLogin() {
|
||||
accountActionError.value = ''
|
||||
const email = bindLoginEmail.value.trim()
|
||||
const password = bindLoginPassword.value
|
||||
if (!email || !password) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<DingTalkPendingActionResponse>('/auth/oauth/pending/bind-login', {
|
||||
email,
|
||||
password,
|
||||
...serializeAdoptionDecision(currentAdoptionDecision())
|
||||
})
|
||||
await finalizePendingAccountResponse(data)
|
||||
} catch (e: unknown) {
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitTotpChallenge() {
|
||||
totpError.value = ''
|
||||
const code = totpCode.value.trim()
|
||||
if (!totpTempToken.value || code.length !== 6) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const completion = await login2FA({
|
||||
temp_token: totpTempToken.value,
|
||||
totp_code: code
|
||||
})
|
||||
await authStore.setToken(completion.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirectTo.value)
|
||||
} catch (e: unknown) {
|
||||
totpError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const params = parseFragmentParams()
|
||||
const legacyLogin = readLegacyFragmentLogin(params)
|
||||
const legacyPendingToken = params.get('pending_oauth_token')?.trim() || ''
|
||||
const error = params.get('error')
|
||||
const errorDesc = params.get('error_description') || params.get('error_message') || ''
|
||||
const redirect = sanitizeRedirectPath(
|
||||
params.get('redirect') || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
|
||||
try {
|
||||
if (legacyLogin) {
|
||||
persistOAuthTokenContext(legacyLogin)
|
||||
await authStore.setToken(legacyLogin.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (error === 'invitation_required' && legacyPendingToken) {
|
||||
legacyPendingOAuthToken.value = legacyPendingToken
|
||||
redirectTo.value = redirect
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const i18nKey = `auth.dingtalk.error.${error}`
|
||||
errorMessage.value = te(i18nKey) ? t(i18nKey) : (errorDesc || error)
|
||||
isProcessing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const completion = await exchangePendingOAuthCompletion()
|
||||
const completionRedirect = sanitizeRedirectPath(
|
||||
completion.redirect || (route.query.redirect as string | undefined) || '/dashboard'
|
||||
)
|
||||
applyAdoptionSuggestionState(completion)
|
||||
redirectTo.value = completionRedirect
|
||||
|
||||
const completionData = completion as DingTalkPendingActionResponse
|
||||
// 用户从补邮箱页"我已有账户"按钮跳回时携带 bind=1,跳过 email_completion 自动 redirect,
|
||||
// 直接进入 bind_login 输入密码绑定已有账户。
|
||||
const wantsBindExisting = (route.query.bind as string | undefined) === '1'
|
||||
const presetEmail = ((route.query.email as string | undefined) || '').trim()
|
||||
if (completionData.step === 'email_completion' || (completionData as unknown as Record<string, unknown>)['requires_email_completion'] === true) {
|
||||
if (wantsBindExisting) {
|
||||
pendingAccountAction.value = 'bind_login'
|
||||
bindLoginEmail.value = presetEmail
|
||||
bindLoginPassword.value = ''
|
||||
canReturnToCreateAccount.value = true
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(completionRedirect)
|
||||
return
|
||||
}
|
||||
await router.replace('/auth/dingtalk/email-completion?redirect=' + encodeURIComponent(completionRedirect))
|
||||
return
|
||||
}
|
||||
|
||||
if (completion.error === 'invitation_required') {
|
||||
needsInvitation.value = true
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(completionRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (applyTotpChallenge(completion as DingTalkPendingActionResponse)) {
|
||||
persistPendingAuthSession(completionRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
applyPendingAccountAction(completion as DingTalkPendingActionResponse)
|
||||
if (pendingAccountAction.value !== 'none') {
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(completionRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (adoptionRequired.value && hasSuggestedProfile(completion)) {
|
||||
needsAdoptionConfirmation.value = true
|
||||
isProcessing.value = false
|
||||
persistPendingAuthSession(completionRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
await finalizeCompletion(completion, completionRedirect)
|
||||
} catch (e: unknown) {
|
||||
clearPendingAuthSession()
|
||||
errorMessage.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
isProcessing.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
132
frontend/src/views/auth/DingTalkEmailCompletionView.vue
Normal file
132
frontend/src/views/auth/DingTalkEmailCompletionView.vue
Normal file
@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<AuthLayout>
|
||||
<div class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ t('auth.dingtalk.createAccountTitle') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('auth.oauthFlow.createAccountHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PendingOAuthCreateAccountForm
|
||||
test-id-prefix="dingtalk"
|
||||
:initial-email="initialEmail"
|
||||
:is-submitting="isSubmitting"
|
||||
:error-message="accountActionError"
|
||||
@submit="handleCreateAccount"
|
||||
@switch-to-bind="handleSwitchToBind"
|
||||
/>
|
||||
</div>
|
||||
</AuthLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import PendingOAuthCreateAccountForm, {
|
||||
type PendingOAuthCreateAccountPayload
|
||||
} from '@/components/auth/PendingOAuthCreateAccountForm.vue'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import {
|
||||
persistOAuthTokenContext,
|
||||
type PendingOAuthExchangeResponse
|
||||
} from '@/api/auth'
|
||||
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const accountActionError = ref('')
|
||||
|
||||
const initialEmail = (route.query.email as string | undefined) || ''
|
||||
|
||||
function sanitizeRedirectPath(path: string | null | undefined): string {
|
||||
if (!path) return '/dashboard'
|
||||
if (!path.startsWith('/')) return '/dashboard'
|
||||
if (path.startsWith('//')) return '/dashboard'
|
||||
if (path.includes('://')) return '/dashboard'
|
||||
if (path.includes('\n') || path.includes('\r')) return '/dashboard'
|
||||
return path
|
||||
}
|
||||
|
||||
function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
const err = error as { message?: string; response?: { data?: { detail?: string; message?: string } } }
|
||||
return err.response?.data?.detail || err.response?.data?.message || err.message || fallback
|
||||
}
|
||||
|
||||
async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
|
||||
accountActionError.value = ''
|
||||
if (!payload.email || !payload.password) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const { data } = await apiClient.post<
|
||||
PendingOAuthExchangeResponse & {
|
||||
step?: string
|
||||
redirect?: string
|
||||
existing_account_bindable?: boolean
|
||||
}
|
||||
>(
|
||||
'/auth/oauth/pending/create-account',
|
||||
{
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
verify_code: payload.verifyCode || undefined,
|
||||
invitation_code: payload.invitationCode || undefined
|
||||
}
|
||||
)
|
||||
|
||||
const redirect = sanitizeRedirectPath(data.redirect || (route.query.redirect as string | undefined))
|
||||
|
||||
if (data.access_token) {
|
||||
persistOAuthTokenContext(data)
|
||||
await authStore.setToken(data.access_token)
|
||||
clearAllAffiliateReferralCodes()
|
||||
appStore.showSuccess(t('auth.loginSuccess'))
|
||||
await router.replace(redirect)
|
||||
return
|
||||
}
|
||||
|
||||
// 后端把 pending session 转到 choice 状态(用户填的 email 已在系统内)→ 跳回 callback view 走绑定流程
|
||||
if (data.step === 'choose_account_action_required' || data.existing_account_bindable === true) {
|
||||
navigateToBindLogin(payload.email)
|
||||
return
|
||||
}
|
||||
|
||||
accountActionError.value = t('auth.loginFailed')
|
||||
} catch (e: unknown) {
|
||||
// 全局"开放注册"关闭且未开启钉钉企业模式豁免时,引导用户去绑定已有账户而非死路
|
||||
const err = e as { response?: { data?: { reason?: string } } }
|
||||
if (err.response?.data?.reason === 'REGISTRATION_DISABLED') {
|
||||
appStore.showInfo(t('auth.dingtalk.registrationDisabledRedirectToBind'))
|
||||
navigateToBindLogin(payload.email)
|
||||
return
|
||||
}
|
||||
accountActionError.value = getRequestErrorMessage(e, t('auth.loginFailed'))
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToBindLogin(email: string) {
|
||||
const query: Record<string, string> = { bind: '1' }
|
||||
if (email) query.email = email
|
||||
const redirect = route.query.redirect as string | undefined
|
||||
if (redirect) query.redirect = redirect
|
||||
router.replace({ path: '/auth/dingtalk/callback', query })
|
||||
}
|
||||
|
||||
function handleSwitchToBind(email: string) {
|
||||
navigateToBindLogin(email)
|
||||
}
|
||||
</script>
|
||||
@ -152,6 +152,11 @@
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<DingTalkOAuthSection
|
||||
v-if="dingtalkOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
:show-divider="false"
|
||||
/>
|
||||
<WechatOAuthSection
|
||||
v-if="wechatOAuthEnabled"
|
||||
:disabled="authActionDisabled"
|
||||
@ -198,6 +203,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AuthLayout } from '@/components/layout'
|
||||
import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue'
|
||||
import DingTalkOAuthSection from '@/components/auth/DingTalkOAuthSection.vue'
|
||||
import OidcOAuthSection from '@/components/auth/OidcOAuthSection.vue'
|
||||
import WechatOAuthSection from '@/components/auth/WechatOAuthSection.vue'
|
||||
import EmailOAuthButtons from '@/components/auth/EmailOAuthButtons.vue'
|
||||
@ -231,6 +237,7 @@ const publicSettingsLoaded = ref<boolean>(false)
|
||||
const turnstileEnabled = ref<boolean>(false)
|
||||
const turnstileSiteKey = ref<string>('')
|
||||
const linuxdoOAuthEnabled = ref<boolean>(false)
|
||||
const dingtalkOAuthEnabled = ref<boolean>(false)
|
||||
const wechatOAuthEnabled = ref<boolean>(false)
|
||||
const backendModeEnabled = ref<boolean>(false)
|
||||
const oidcOAuthEnabled = ref<boolean>(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
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
<ProfileInfoCard
|
||||
:user="user"
|
||||
:linuxdo-enabled="linuxdoOAuthEnabled"
|
||||
:dingtalk-enabled="dingtalkOAuthEnabled"
|
||||
:oidc-enabled="oidcOAuthEnabled"
|
||||
:oidc-provider-name="oidcOAuthProviderName"
|
||||
:wechat-enabled="wechatOAuthEnabled"
|
||||
@ -69,6 +70,7 @@ const contactInfo = ref('')
|
||||
const balanceLowNotifyEnabled = ref(false)
|
||||
const systemDefaultThreshold = ref(0)
|
||||
const linuxdoOAuthEnabled = ref(false)
|
||||
const dingtalkOAuthEnabled = ref(false)
|
||||
const wechatOAuthEnabled = ref(false)
|
||||
const wechatOAuthOpenEnabled = ref<boolean | undefined>(undefined)
|
||||
const wechatOAuthMPEnabled = ref<boolean | undefined>(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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user