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:
DaydreamCoding
2026-05-19 15:27:47 +08:00
committed by QTom
co-authored by Claude Opus 4.7
parent b006e36af9
commit b19da9c7fe
54 changed files with 5545 additions and 365 deletions
+350
View File
@@ -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>