Skip to content

Commit b19da9c

Browse files
DaydreamCodingclaude
authored andcommitted
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>
1 parent b006e36 commit b19da9c

54 files changed

Lines changed: 5545 additions & 365 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/cmd/server/wire_gen.go

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/ent/schema/auth_identity.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ import (
1515
)
1616

1717
var authProviderTypes = map[string]struct{}{
18-
"email": {},
19-
"github": {},
20-
"google": {},
21-
"linuxdo": {},
22-
"oidc": {},
23-
"wechat": {},
18+
"email": {},
19+
"github": {},
20+
"google": {},
21+
"linuxdo": {},
22+
"oidc": {},
23+
"wechat": {},
24+
"dingtalk": {},
2425
}
2526

2627
func validateAuthProviderType(value string) error {

backend/ent/schema/auth_identity_schema_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func TestAuthIdentityFoundationSchemas(t *testing.T) {
8383
require.Equal(t, 1, signupSource.Validators)
8484

8585
validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source")
86-
for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google"} {
86+
for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk"} {
8787
require.NoError(t, validator(value))
8888
}
8989
require.Error(t, validator("unknown"))

backend/ent/schema/user.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,10 @@ func (User) Fields() []ent.Field {
7777
field.String("signup_source").
7878
Validate(func(value string) error {
7979
switch value {
80-
case "email", "linuxdo", "wechat", "oidc", "github", "google":
80+
case "email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
8181
return nil
8282
default:
83-
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google")
83+
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google, dingtalk")
8484
}
8585
}).
8686
Default("email"),

backend/internal/config/config.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type Config struct {
7272
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
7373
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
7474
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
75+
DingTalk DingTalkConnectConfig `mapstructure:"dingtalk_connect"`
7576
GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"`
7677
GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"`
7778
Default DefaultConfig `mapstructure:"default"`
@@ -242,6 +243,47 @@ type OIDCConnectConfig struct {
242243
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
243244
}
244245

246+
type DingTalkConnectConfig struct {
247+
Enabled bool `mapstructure:"enabled"`
248+
ClientID string `mapstructure:"client_id"`
249+
ClientSecret string `mapstructure:"client_secret"`
250+
AuthorizeURL string `mapstructure:"authorize_url"`
251+
TokenURL string `mapstructure:"token_url"`
252+
UserInfoURL string `mapstructure:"userinfo_url"`
253+
Scopes string `mapstructure:"scopes"`
254+
RedirectURL string `mapstructure:"redirect_url"`
255+
FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
256+
257+
// 平台底座 + 业务行为
258+
DingTalkAppKind string `mapstructure:"dingtalk_app_kind"` // 仅 "internal_app"(V4 fail-closed)
259+
AppType string `mapstructure:"app_type"` // "public" (default) | "internal"
260+
261+
// Corp 限定(none | internal_only)
262+
CorpRestrictionPolicy string `mapstructure:"corp_restriction_policy"`
263+
InternalCorpID string `mapstructure:"internal_corp_id"`
264+
BypassRegistration bool `mapstructure:"bypass_registration"`
265+
SyncCorpEmail bool `mapstructure:"sync_corp_email"`
266+
SyncDisplayName bool `mapstructure:"sync_display_name"`
267+
SyncDept bool `mapstructure:"sync_dept"`
268+
SyncCorpEmailAttrKey string `mapstructure:"sync_corp_email_attr_key"`
269+
SyncDisplayNameAttrKey string `mapstructure:"sync_display_name_attr_key"`
270+
SyncDeptAttrKey string `mapstructure:"sync_dept_attr_key"`
271+
SyncCorpEmailAttrName string `mapstructure:"sync_corp_email_attr_name"`
272+
SyncDisplayNameAttrName string `mapstructure:"sync_display_name_attr_name"`
273+
SyncDeptAttrName string `mapstructure:"sync_dept_attr_name"`
274+
275+
// 邮箱 + Username
276+
RequireEmail bool `mapstructure:"require_email"`
277+
UsernameOverwritePolicy string `mapstructure:"username_overwrite_policy"`
278+
279+
// Attribute(私有版扩展点;开源版仅声明)
280+
UsernameAttributeKey string `mapstructure:"username_attribute_key"`
281+
EnableAttributeMatching bool `mapstructure:"enable_attribute_matching"`
282+
EnableAttributeSync bool `mapstructure:"enable_attribute_sync"`
283+
AttributeSyncFields []string `mapstructure:"attribute_sync_fields"`
284+
AttributeSyncOverwritePolicy string `mapstructure:"attribute_sync_overwrite_policy"`
285+
}
286+
245287
type EmailOAuthProviderConfig struct {
246288
Enabled bool `mapstructure:"enabled"`
247289
ClientID string `mapstructure:"client_id"`
@@ -1536,6 +1578,19 @@ func setDefaults() {
15361578
viper.SetDefault("oidc_connect.userinfo_id_path", "")
15371579
viper.SetDefault("oidc_connect.userinfo_username_path", "")
15381580

1581+
// DingTalk Connect OAuth 登录
1582+
viper.SetDefault("dingtalk_connect.enabled", false)
1583+
viper.SetDefault("dingtalk_connect.authorize_url", "https://login.dingtalk.com/oauth2/auth")
1584+
viper.SetDefault("dingtalk_connect.token_url", "https://api.dingtalk.com/v1.0/oauth2/userAccessToken")
1585+
viper.SetDefault("dingtalk_connect.userinfo_url", "https://api.dingtalk.com/v1.0/contact/users/me")
1586+
viper.SetDefault("dingtalk_connect.scopes", "openid")
1587+
viper.SetDefault("dingtalk_connect.frontend_redirect_url", "/auth/dingtalk/callback")
1588+
viper.SetDefault("dingtalk_connect.dingtalk_app_kind", "internal_app")
1589+
viper.SetDefault("dingtalk_connect.app_type", "public")
1590+
viper.SetDefault("dingtalk_connect.corp_restriction_policy", "none")
1591+
viper.SetDefault("dingtalk_connect.require_email", true)
1592+
viper.SetDefault("dingtalk_connect.username_overwrite_policy", "if_empty")
1593+
15391594
// Database
15401595
viper.SetDefault("database.host", "localhost")
15411596
viper.SetDefault("database.port", 5432)
@@ -2608,6 +2663,9 @@ func (c *Config) Validate() error {
26082663
if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 {
26092664
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
26102665
}
2666+
if err := ValidateDingTalkConfig(c.DingTalk); err != nil {
2667+
return fmt.Errorf("dingtalk_connect: %w", err)
2668+
}
26112669
return nil
26122670
}
26132671

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Package config 包含钉钉连接配置的校验逻辑。
2+
//
3+
// internal_only 模式安全模型(方案 A):
4+
// 不再要求 admin 填写 InternalCorpID 做二次 corpID 比对。
5+
// 安全边界由钉钉"企业内部应用"类型本身保证——只有应用所属企业的员工才能完成 OAuth,
6+
// 因此 ValidateDingTalkConfig 只要求 app_type=internal(V1),不再要求 InternalCorpID 非空(原 V3 已删除)。
7+
// InternalCorpID 字段保留,admin 可选填;若填写,checkDingTalkCorpAllowed 不会使用它做约束。
8+
package config
9+
10+
import "errors"
11+
12+
var (
13+
ErrDingTalkV1AppTypeMismatch = errors.New("dingtalk: internal_only requires app_type=internal")
14+
ErrDingTalkV4InvalidAppKind = errors.New("dingtalk: dingtalk_app_kind must be internal_app")
15+
)
16+
17+
func ValidateDingTalkConfig(cfg DingTalkConnectConfig) error {
18+
if !cfg.Enabled {
19+
return nil
20+
}
21+
if cfg.DingTalkAppKind != "internal_app" {
22+
return ErrDingTalkV4InvalidAppKind
23+
}
24+
if cfg.CorpRestrictionPolicy == "internal_only" {
25+
if cfg.AppType != "internal" {
26+
return ErrDingTalkV1AppTypeMismatch
27+
}
28+
}
29+
return nil
30+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestValidateDingTalkConfig_Disabled_Skip(t *testing.T) {
10+
require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{Enabled: false}))
11+
}
12+
13+
func TestValidateDingTalkConfig_V4_DingTalkAppKind(t *testing.T) {
14+
err := ValidateDingTalkConfig(DingTalkConnectConfig{
15+
Enabled: true,
16+
DingTalkAppKind: "third_party_enterprise_app",
17+
CorpRestrictionPolicy: "none",
18+
})
19+
require.ErrorIs(t, err, ErrDingTalkV4InvalidAppKind)
20+
}
21+
22+
func TestValidateDingTalkConfig_V1_InternalOnlyRequiresInternalAppType(t *testing.T) {
23+
err := ValidateDingTalkConfig(DingTalkConnectConfig{
24+
Enabled: true,
25+
DingTalkAppKind: "internal_app",
26+
AppType: "public",
27+
CorpRestrictionPolicy: "internal_only",
28+
InternalCorpID: "dingABC",
29+
})
30+
require.ErrorIs(t, err, ErrDingTalkV1AppTypeMismatch)
31+
}
32+
33+
// TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID 验证方案 A:
34+
// internal_only 策略下,InternalCorpID="" 应通过校验(企业隔离由钉钉 AppType=internal 保证)。
35+
func TestValidateDingTalkConfig_V3_InternalOnlyAllowsEmptyCorpID(t *testing.T) {
36+
err := ValidateDingTalkConfig(DingTalkConnectConfig{
37+
Enabled: true,
38+
DingTalkAppKind: "internal_app",
39+
AppType: "internal",
40+
CorpRestrictionPolicy: "internal_only",
41+
InternalCorpID: "",
42+
})
43+
require.NoError(t, err)
44+
}
45+
46+
func TestValidateDingTalkConfig_HappyPath_None(t *testing.T) {
47+
require.NoError(t, ValidateDingTalkConfig(DingTalkConnectConfig{
48+
Enabled: true,
49+
DingTalkAppKind: "internal_app",
50+
AppType: "public",
51+
CorpRestrictionPolicy: "none",
52+
}))
53+
}

0 commit comments

Comments
 (0)