⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(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>
1067 lines
42 KiB
Go
1067 lines
42 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// dingTalkUpstreamRedirect 在 4 步链上游调用失败时记录详细错误日志并跳错误页。
|
||
// 把钉钉 errcode/errmsg 写进 backend log + URL fragment,避免被泛 "internal error" 吞掉。
|
||
func dingTalkUpstreamRedirect(c *gin.Context, frontendCallback, step string, err error) {
|
||
var apiErr *DingTalkAPIError
|
||
dtCode := ""
|
||
dtMsg := ""
|
||
dtHTTP := 0
|
||
if errors.As(err, &apiErr) {
|
||
dtCode = apiErr.Code
|
||
dtMsg = apiErr.Message
|
||
dtHTTP = apiErr.HTTP
|
||
}
|
||
slog.Error("dingtalk upstream call failed",
|
||
"step", step,
|
||
"dingtalk_code", dtCode,
|
||
"dingtalk_msg", dtMsg,
|
||
"http_status", dtHTTP,
|
||
"error", err.Error(),
|
||
)
|
||
msg := dtMsg
|
||
if strings.TrimSpace(msg) == "" {
|
||
msg = infraerrors.Message(err)
|
||
}
|
||
if strings.TrimSpace(dtCode) != "" {
|
||
msg = "dingtalk[" + dtCode + "] " + msg
|
||
}
|
||
redirectOAuthError(c, frontendCallback, mapDingTalkErrorCode(err), msg, "")
|
||
}
|
||
|
||
// ─── 常量 ──────────────────────────────────────────────────────────────────
|
||
|
||
const (
|
||
dingTalkOAuthCookiePath = "/api/v1/auth/oauth/dingtalk"
|
||
dingTalkOAuthStateCookieName = "dingtalk_oauth_state"
|
||
dingTalkOAuthRedirectCookie = "dingtalk_oauth_redirect"
|
||
dingTalkOAuthIntentCookieName = "dingtalk_oauth_intent"
|
||
dingTalkOAuthBindUserCookieName = "dingtalk_oauth_bind_user"
|
||
dingTalkOAuthCookieMaxAgeSec = 600 // 10 分钟
|
||
dingTalkOAuthDefaultRedirectTo = "/dashboard"
|
||
dingTalkOAuthDefaultFrontendCB = "/auth/dingtalk/callback"
|
||
|
||
dingTalkLevelThreeEnabled = true
|
||
)
|
||
|
||
// ─── Config helper ─────────────────────────────────────────────────────────
|
||
|
||
// getDingTalkOAuthConfig 返回 DingTalk OAuth 最终生效配置。
|
||
// 优先从 settingSvc(settings 表)读取,回退到 h.cfg.DingTalk。
|
||
func (h *AuthHandler) getDingTalkOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
|
||
if h != nil && h.settingSvc != nil {
|
||
return h.settingSvc.GetDingTalkConnectOAuthConfig(ctx)
|
||
}
|
||
if h == nil || h.cfg == nil {
|
||
return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
|
||
}
|
||
if !h.cfg.DingTalk.Enabled {
|
||
return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
|
||
}
|
||
return h.cfg.DingTalk, nil
|
||
}
|
||
|
||
// ─── Cookie helpers(使用 dingtalk path)─────────────────────────────────
|
||
|
||
func setDingTalkCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: name,
|
||
Value: value,
|
||
Path: dingTalkOAuthCookiePath,
|
||
MaxAge: maxAgeSec,
|
||
HttpOnly: true,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
}
|
||
|
||
func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: name,
|
||
Value: "",
|
||
Path: dingTalkOAuthCookiePath,
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
Secure: secure,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
}
|
||
|
||
// ─── DingTalkOAuthStart ────────────────────────────────────────────────────
|
||
|
||
// DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
|
||
// GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
|
||
func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
|
||
cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
|
||
if err != nil {
|
||
frontendCB := dingTalkOAuthDefaultFrontendCB
|
||
redirectOAuthError(c, frontendCB, "dingtalk_not_enabled", "", "")
|
||
return
|
||
}
|
||
|
||
state, err := oauth.GenerateState()
|
||
if err != nil {
|
||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
|
||
return
|
||
}
|
||
|
||
redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
|
||
if redirectTo == "" {
|
||
redirectTo = dingTalkOAuthDefaultRedirectTo
|
||
}
|
||
|
||
browserSessionKey, err := generateOAuthPendingBrowserSession()
|
||
if err != nil {
|
||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
|
||
return
|
||
}
|
||
|
||
secureCookie := isRequestHTTPS(c)
|
||
setDingTalkCookie(c, dingTalkOAuthStateCookieName, encodeCookieValue(state), dingTalkOAuthCookieMaxAgeSec, secureCookie)
|
||
setDingTalkCookie(c, dingTalkOAuthRedirectCookie, encodeCookieValue(redirectTo), dingTalkOAuthCookieMaxAgeSec, secureCookie)
|
||
|
||
intent := normalizeOAuthIntent(c.Query("intent"))
|
||
setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie)
|
||
|
||
setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
|
||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||
|
||
if intent == oauthIntentBindCurrentUser {
|
||
bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
setDingTalkCookie(c, dingTalkOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), dingTalkOAuthCookieMaxAgeSec, secureCookie)
|
||
} else {
|
||
clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie)
|
||
}
|
||
|
||
authURL, err := buildDingTalkAuthorizeURL(cfg, state)
|
||
if err != nil {
|
||
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build dingtalk authorization url").WithCause(err))
|
||
return
|
||
}
|
||
|
||
c.Redirect(http.StatusFound, authURL)
|
||
}
|
||
|
||
// ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
|
||
|
||
// ─── findDingTalkCompatEmailUser ───────────────────────────────────────────
|
||
|
||
// findDingTalkCompatEmailUser 通过真实邮箱查找可与 DingTalk 账号兼容绑定的现有用户。
|
||
func (h *AuthHandler) findDingTalkCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
|
||
if !dingTalkLevelThreeEnabled {
|
||
return nil, nil
|
||
}
|
||
|
||
client := h.entClient()
|
||
if client == nil {
|
||
return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
|
||
}
|
||
|
||
email = strings.TrimSpace(strings.ToLower(email))
|
||
if email == "" ||
|
||
strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) ||
|
||
strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
|
||
strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
|
||
strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
|
||
return nil, nil
|
||
}
|
||
|
||
userEntities, err := client.User.Query().
|
||
Where(userNormalizedEmailPredicate(email)).
|
||
Order(dbent.Asc(dbuser.FieldID)).
|
||
All(ctx)
|
||
if err != nil {
|
||
return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
|
||
}
|
||
switch len(userEntities) {
|
||
case 0:
|
||
return nil, nil
|
||
case 1:
|
||
return userEntities[0], nil
|
||
default:
|
||
return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
|
||
}
|
||
}
|
||
|
||
// ─── createDingTalkOAuthChoicePendingSession ───────────────────────────────
|
||
|
||
// createDingTalkOAuthChoicePendingSession 创建 DingTalk OAuth 三方注册/绑定的 choice pending session。
|
||
// signupBlocked=true 时关闭"创建新账户"出口;若同时没有 compat email 匹配的已有账户,
|
||
// 直接把 step 切到 bind_login_required,避免前端展示一个没有实际可点选项的 choice 界面。
|
||
func (h *AuthHandler) createDingTalkOAuthChoicePendingSession(
|
||
c *gin.Context,
|
||
identity service.PendingAuthIdentityKey,
|
||
suggestedEmail string,
|
||
resolvedEmail string,
|
||
redirectTo string,
|
||
browserSessionKey string,
|
||
upstreamClaims map[string]any,
|
||
compatEmail string,
|
||
compatEmailUser *dbent.User,
|
||
forceEmailOnSignup bool,
|
||
signupBlocked bool,
|
||
) error {
|
||
suggestionEmail := strings.TrimSpace(suggestedEmail)
|
||
canonicalEmail := strings.TrimSpace(resolvedEmail)
|
||
if suggestionEmail == "" {
|
||
suggestionEmail = canonicalEmail
|
||
}
|
||
|
||
completionResponse := map[string]any{
|
||
"step": oauthPendingChoiceStep,
|
||
"adoption_required": true,
|
||
"redirect": strings.TrimSpace(redirectTo),
|
||
"email": suggestionEmail,
|
||
"resolved_email": canonicalEmail,
|
||
"existing_account_email": "",
|
||
"existing_account_bindable": false,
|
||
"create_account_allowed": !signupBlocked,
|
||
"force_email_on_signup": forceEmailOnSignup,
|
||
"choice_reason": "third_party_signup",
|
||
}
|
||
if strings.TrimSpace(compatEmail) != "" {
|
||
completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
|
||
}
|
||
resolvedChoiceEmail := suggestionEmail
|
||
if compatEmailUser != nil {
|
||
completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
|
||
completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
|
||
completionResponse["existing_account_bindable"] = true
|
||
completionResponse["choice_reason"] = "compat_email_match"
|
||
resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
|
||
}
|
||
if forceEmailOnSignup && compatEmailUser == nil {
|
||
completionResponse["choice_reason"] = "force_email_on_signup"
|
||
}
|
||
// 注册被拦:无论是否匹配到 compat email user,都跳过 choice,直接进 bind_login。
|
||
// "开放注册" 关闭 且 "钉钉企业模式豁免" 也关闭时,唯一合法出口是绑定已有账户,
|
||
// 不应该让用户看到"创建新账户"按钮;compat user 命中只是让 bind_login 的邮箱字段预填得更准。
|
||
if signupBlocked {
|
||
completionResponse["step"] = "bind_login_required"
|
||
completionResponse["existing_account_bindable"] = true
|
||
completionResponse["choice_reason"] = "signup_blocked_redirect_to_bind"
|
||
}
|
||
|
||
var targetUserID *int64
|
||
if compatEmailUser != nil && compatEmailUser.ID > 0 {
|
||
targetUserID = &compatEmailUser.ID
|
||
}
|
||
|
||
return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||
Intent: oauthIntentLogin,
|
||
Identity: identity,
|
||
TargetUserID: targetUserID,
|
||
ResolvedEmail: resolvedChoiceEmail,
|
||
RedirectTo: redirectTo,
|
||
BrowserSessionKey: browserSessionKey,
|
||
UpstreamIdentityClaims: upstreamClaims,
|
||
CompletionResponse: completionResponse,
|
||
})
|
||
}
|
||
|
||
// ─── DingTalkOAuthCallback ─────────────────────────────────────────────────
|
||
|
||
// DingTalkOAuthCallback 处理钉钉授权回调。
|
||
// GET /api/v1/auth/oauth/dingtalk/callback?code=...&state=...
|
||
func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) {
|
||
cfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context())
|
||
if cfgErr != nil {
|
||
response.ErrorFrom(c, cfgErr)
|
||
return
|
||
}
|
||
|
||
frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
|
||
if frontendCallback == "" {
|
||
frontendCallback = dingTalkOAuthDefaultFrontendCB
|
||
}
|
||
|
||
if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
|
||
redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
|
||
return
|
||
}
|
||
|
||
code := strings.TrimSpace(c.Query("code"))
|
||
state := strings.TrimSpace(c.Query("state"))
|
||
if code == "" || state == "" {
|
||
redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
|
||
return
|
||
}
|
||
|
||
secureCookie := isRequestHTTPS(c)
|
||
defer func() {
|
||
clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie)
|
||
clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie)
|
||
clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie)
|
||
}()
|
||
|
||
expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName)
|
||
if err != nil || state != expectedState {
|
||
redirectOAuthError(c, frontendCallback, "csrf", "state mismatch", "")
|
||
return
|
||
}
|
||
redirectTo, _ := readCookieDecoded(c, dingTalkOAuthRedirectCookie)
|
||
intent, _ := readCookieDecoded(c, dingTalkOAuthIntentCookieName)
|
||
intent = normalizeOAuthIntent(intent)
|
||
browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
|
||
if strings.TrimSpace(browserSessionKey) == "" {
|
||
redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing browser session cookie", "")
|
||
return
|
||
}
|
||
forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
|
||
|
||
// ─── 4 步链(Step 1 + Step 2 必须;Step 3/4 按需 + 跨组织降级)───
|
||
client := h.dingTalkClient(cfg)
|
||
userToken, err := client.ExchangeCodeForUserToken(c.Request.Context(), code)
|
||
if err != nil {
|
||
dingTalkUpstreamRedirect(c, frontendCallback, "exchange_code", err)
|
||
return
|
||
}
|
||
|
||
// D: corp 校验提前到 Step 1 之后、Step 2 之前,减少不必要的上游调用
|
||
corpID := strings.TrimSpace(userToken.CorpID)
|
||
if !checkDingTalkCorpAllowed(cfg, corpID) {
|
||
// 不在 URL 中透传 corpID,避免内部企业标识泄露给前端
|
||
redirectOAuthError(c, frontendCallback, "corp_rejected", "", "")
|
||
return
|
||
}
|
||
|
||
// Step 2: 必须 — UnionID 是全局唯一,作为 subject + 合成邮箱种子;nick 是用户在 App 自设的昵称
|
||
unionID, oauthNick, err := client.GetUnionIdByUserToken(c.Request.Context(), userToken.AccessToken)
|
||
if err != nil {
|
||
dingTalkUpstreamRedirect(c, frontendCallback, "get_union_id", err)
|
||
return
|
||
}
|
||
|
||
identityKey := service.PendingAuthIdentityKey{ProviderType: "dingtalk", ProviderKey: "dingtalk", ProviderSubject: unionID}
|
||
|
||
// Step 3/4 调用策略由 policy 决定,与 require_email 解耦。
|
||
// policy=internal_only → 必须成功(hard fail),因为 AppType=internal 已保证用户在应用企业。
|
||
// policy=none / "" → 尝试,失败降级(公网场景跨组织用户属正常预期)。
|
||
// require_email 只影响 Step 3/4 结果后的邮箱处理路径,不影响是否调用。
|
||
var staff *DingTalkStaffInfo
|
||
switch cfg.CorpRestrictionPolicy {
|
||
case "internal_only":
|
||
// AppType=internal 已保证用户在应用企业,Step 3/4 必须成功。
|
||
// 失败 = 钉钉 OAPI 故障或应用配置错误,应 hard fail。
|
||
upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID)
|
||
if errStep3 != nil {
|
||
dingTalkUpstreamRedirect(c, frontendCallback, "get_user_id", errStep3)
|
||
return
|
||
}
|
||
staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID)
|
||
if errStep4 != nil {
|
||
dingTalkUpstreamRedirect(c, frontendCallback, "get_staff_info", errStep4)
|
||
return
|
||
}
|
||
staff = staffInfo
|
||
|
||
default: // "none" or ""
|
||
// 公网登录,跨组织用户 Step 3/4 可能失败(设计预期),尝试调用,失败降级。
|
||
// 即使 require_email=false 也尝试拿 name(用于 upstreamClaims.username),失败就空着。
|
||
upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID)
|
||
if errStep3 != nil {
|
||
slog.Debug("dingtalk step3 fallback (none/cross-org)",
|
||
"corp_id", corpID, "union_id", unionID, "err", errStep3.Error())
|
||
staff = &DingTalkStaffInfo{}
|
||
break
|
||
}
|
||
staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID)
|
||
if errStep4 != nil {
|
||
slog.Debug("dingtalk step4 fallback (none/cross-org)",
|
||
"corp_id", corpID, "union_id", unionID, "err", errStep4.Error())
|
||
staff = &DingTalkStaffInfo{}
|
||
break
|
||
}
|
||
staff = staffInfo
|
||
}
|
||
|
||
// nick 来自 OIDC /contact/users/me,优先作为钉钉昵称(user/get.nickname 多数为空)。
|
||
if staff != nil && strings.TrimSpace(oauthNick) != "" {
|
||
staff.Nickname = strings.TrimSpace(oauthNick)
|
||
}
|
||
|
||
upstreamClaims := buildDingTalkUpstreamClaims(staff, unionID, corpID)
|
||
|
||
// ─── S1 主动绑定分支(PR-3 才走到这里)───
|
||
if intent == oauthIntentBindCurrentUser {
|
||
targetUserID, err := h.readOAuthBindUserIDFromCookie(c, dingTalkOAuthBindUserCookieName)
|
||
if err != nil {
|
||
redirectOAuthError(c, frontendCallback, "invalid_state", "invalid bind user cookie", "")
|
||
return
|
||
}
|
||
// policy=none 跨组织用户绑定时 staff.Email="",用合成邮箱占位(用于 audit log,不用于注册)
|
||
bindResolvedEmail := staff.Email
|
||
if bindResolvedEmail == "" {
|
||
bindResolvedEmail = buildDingTalkSyntheticEmail(unionID)
|
||
}
|
||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||
Intent: oauthIntentBindCurrentUser, Identity: identityKey,
|
||
TargetUserID: &targetUserID, ResolvedEmail: bindResolvedEmail,
|
||
RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
|
||
UpstreamIdentityClaims: upstreamClaims,
|
||
CompletionResponse: map[string]any{"redirect": redirectTo},
|
||
}); err != nil {
|
||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||
return
|
||
}
|
||
clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie)
|
||
redirectToFrontendCallback(c, frontendCallback)
|
||
return
|
||
}
|
||
|
||
// ─── Level 1:auth_identities hit ───
|
||
if existing, _ := h.findOAuthIdentityUser(c.Request.Context(), identityKey); existing != nil {
|
||
// 身份同步:已登录用户,直接同步(user_id 已知)。
|
||
// 异步执行避免上游钉钉接口(GetStaffInfoByUserId / 部门递归)阻塞登录跳转。
|
||
runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
|
||
h.syncDingTalkIdentity(ctx, cfg, client, existing.ID, staff, false)
|
||
})
|
||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: &existing.ID,
|
||
ResolvedEmail: existing.Email, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
|
||
UpstreamIdentityClaims: upstreamClaims,
|
||
CompletionResponse: map[string]any{"redirect": redirectTo},
|
||
}); err != nil {
|
||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||
return
|
||
}
|
||
redirectToFrontendCallback(c, frontendCallback)
|
||
return
|
||
}
|
||
|
||
signupBlocked := h.isDingTalkSignupBlocked(c.Request.Context(), cfg)
|
||
|
||
// ─── 非命中:require_email=false 走 synthetic email 直接登录 ───
|
||
if !cfg.RequireEmail {
|
||
if signupBlocked {
|
||
// 注册被拦 + 无邮箱可输:唯一出路是绑定已有账户
|
||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
|
||
ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
|
||
UpstreamIdentityClaims: upstreamClaims,
|
||
CompletionResponse: dingTalkBindLoginCompletionResponse(redirectTo),
|
||
}); err != nil {
|
||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||
return
|
||
}
|
||
redirectToFrontendCallback(c, frontendCallback)
|
||
return
|
||
}
|
||
syntheticEmail := buildDingTalkSyntheticEmail(unionID)
|
||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
|
||
ResolvedEmail: syntheticEmail, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
|
||
UpstreamIdentityClaims: upstreamClaims,
|
||
CompletionResponse: map[string]any{"redirect": redirectTo, "synthetic_email": syntheticEmail},
|
||
}); err != nil {
|
||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||
return
|
||
}
|
||
redirectToFrontendCallback(c, frontendCallback)
|
||
return
|
||
}
|
||
|
||
// ─── require_email=true 且 staff.Email 空 → 补邮箱(默认)或直接 bind_login(注册被拦时) ───
|
||
if staff.Email == "" {
|
||
completionResponse := map[string]any{
|
||
"step": "email_completion",
|
||
"requires_email_completion": true,
|
||
"redirect": redirectTo,
|
||
}
|
||
if signupBlocked {
|
||
// 注册被全局关闭且未豁免:跳过补邮箱页,直接进 bind_login 让用户输入已有账户
|
||
completionResponse = dingTalkBindLoginCompletionResponse(redirectTo)
|
||
}
|
||
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
|
||
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
|
||
ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
|
||
UpstreamIdentityClaims: upstreamClaims,
|
||
CompletionResponse: completionResponse,
|
||
}); err != nil {
|
||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||
return
|
||
}
|
||
redirectToFrontendCallback(c, frontendCallback)
|
||
return
|
||
}
|
||
|
||
// ─── L3/L4 有邮箱:统一 choice pending session ───
|
||
var compatEmailUser *dbent.User
|
||
if dingTalkLevelThreeEnabled && staff.Email != "" {
|
||
compatEmailUser, _ = h.findDingTalkCompatEmailUser(c.Request.Context(), staff.Email)
|
||
}
|
||
if err := h.createDingTalkOAuthChoicePendingSession(
|
||
c, identityKey, staff.Email, staff.Email,
|
||
redirectTo, browserSessionKey, upstreamClaims,
|
||
staff.Email, compatEmailUser, forceEmailOnSignup,
|
||
signupBlocked,
|
||
); err != nil {
|
||
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
|
||
return
|
||
}
|
||
redirectToFrontendCallback(c, frontendCallback)
|
||
}
|
||
|
||
func buildDingTalkSyntheticEmail(userID string) string {
|
||
return "dingtalk-" + strings.ToLower(strings.TrimSpace(userID)) + service.DingTalkConnectSyntheticEmailDomain
|
||
}
|
||
|
||
// isDingTalkSignupBlocked 当注册总开关关闭且未开启钉钉企业模式豁免
|
||
// (policy=internal_only + dingtalk_connect_bypass_registration=true)时返回 true。
|
||
// 镜像 service.AuthService.canBypassRegistrationDisabledForOAuth 用于 OAuth callback
|
||
// 早期路由决策:注册被拦 → 跳过补邮箱页直接进 bind_login,避免用户填完表单才报错。
|
||
func (h *AuthHandler) isDingTalkSignupBlocked(ctx context.Context, cfg config.DingTalkConnectConfig) bool {
|
||
if h.settingSvc == nil {
|
||
return false
|
||
}
|
||
if h.settingSvc.IsRegistrationEnabled(ctx) {
|
||
return false
|
||
}
|
||
if cfg.BypassRegistration && cfg.CorpRestrictionPolicy == "internal_only" {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func dingTalkBindLoginCompletionResponse(redirectTo string) map[string]any {
|
||
return map[string]any{
|
||
"step": "bind_login_required",
|
||
"existing_account_bindable": true,
|
||
"create_account_allowed": false,
|
||
"redirect": redirectTo,
|
||
}
|
||
}
|
||
|
||
func buildDingTalkUpstreamClaims(staff *DingTalkStaffInfo, unionID, corpID string) map[string]any {
|
||
primaryDeptID := int64(0)
|
||
if len(staff.DeptIDs) > 0 {
|
||
primaryDeptID = staff.DeptIDs[0]
|
||
}
|
||
return map[string]any{
|
||
"email": staff.Email,
|
||
"username": staff.Name,
|
||
"nickname": staff.Nickname,
|
||
"subject": unionID, // 与 identityKey.ProviderSubject 保持一致(全局唯一 unionID)
|
||
"corp_user_id": staff.UserID, // 企业 userid(跨组织时为空),保留作独立字段用于 audit
|
||
"union_id": unionID,
|
||
"corp_id": corpID,
|
||
"primary_dept_id": primaryDeptID, // 首个部门 ID,用于 internal_only 同步路径
|
||
}
|
||
}
|
||
|
||
func checkDingTalkCorpAllowed(cfg config.DingTalkConnectConfig, corpID string) bool {
|
||
switch cfg.CorpRestrictionPolicy {
|
||
case "internal_only":
|
||
// 方案 A:完全跳过 corpID 字段校验,由 step 3 `GetUserIdByUnionId` 做真实判定。
|
||
// 原因:钉钉 /v1.0/oauth2/userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)
|
||
// 不会返回 corpId 字段。而 step 3 用本企业 appToken 查 unionId→userId 映射,
|
||
// 跨企业用户会被钉钉拒绝(错误码 60011/60121),mapDingTalkErrorCode 已将其映射回 "corp_rejected"。
|
||
// AppType=internal 已由 ValidateDingTalkConfig 强制保证应用属性。
|
||
return true
|
||
case "none", "":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// decideDingTalkStep34Strategy 根据 policy 和 Step 3/4 运行时错误决定处理方式。
|
||
// 返回 (proceed bool, fatal bool):
|
||
// - proceed=true:继续处理(step 成功或降级)
|
||
// - fatal=true:应 hard fail(upstream_error)
|
||
//
|
||
// 此 helper 从主链中提取,便于 unit test 独立验证策略决策逻辑。
|
||
func decideDingTalkStep34Strategy(policy string, stepErr error) (shouldFallback bool, isFatal bool) {
|
||
if stepErr == nil {
|
||
return false, false // 成功,不需要降级
|
||
}
|
||
switch policy {
|
||
case "internal_only":
|
||
return false, true // hard fail:同企业 Step 3/4 必须成功
|
||
case "none", "":
|
||
return true, false // 降级:公网场景跨组织用户失败属正常预期
|
||
default:
|
||
return false, true // 未知 policy,视为 hard fail
|
||
}
|
||
}
|
||
|
||
// mapDingTalkErrorCode 把 DingTalkAPIError 映射到 redirectOAuthError 用的字符串 code
|
||
func mapDingTalkErrorCode(err error) string {
|
||
var apiErr *DingTalkAPIError
|
||
if !errors.As(err, &apiErr) {
|
||
return "upstream_error"
|
||
}
|
||
switch apiErr.Code {
|
||
case "60011", "60121":
|
||
return "corp_rejected"
|
||
case "40014", "50015", "88":
|
||
return "upstream_error"
|
||
default:
|
||
return "upstream_error"
|
||
}
|
||
}
|
||
|
||
// dingTalkClient 构造或返回缓存的 client 实例(h-level 单例)。
|
||
// 若 cfg 关键字段(ClientID/ClientSecret/TokenURL/UserInfoURL)与已缓存实例不一致,
|
||
// 则丢弃旧实例(含 appToken 缓存)并重建,避免管理员改配置后旧凭据持续生效。
|
||
func (h *AuthHandler) dingTalkClient(cfg config.DingTalkConnectConfig) *DingTalkClient {
|
||
h.dingTalkClientMu.Lock()
|
||
defer h.dingTalkClientMu.Unlock()
|
||
newCfg := dingTalkClientConfig{
|
||
ClientID: cfg.ClientID,
|
||
ClientSecret: cfg.ClientSecret,
|
||
TokenURL: cfg.TokenURL,
|
||
UserInfoURL: cfg.UserInfoURL,
|
||
}
|
||
if h.dingTalkClientInstance == nil || h.dingTalkClientInstance.cfg != newCfg {
|
||
h.dingTalkClientInstance = &DingTalkClient{
|
||
cfg: newCfg,
|
||
// 与 wechat OAuth client 对齐,避免上游网络抖动时请求悬挂。
|
||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||
}
|
||
}
|
||
return h.dingTalkClientInstance
|
||
}
|
||
|
||
// ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
|
||
|
||
// buildDingTalkAuthorizeURL 根据配置和 state 构建钉钉 OAuth 授权 URL。
|
||
func buildDingTalkAuthorizeURL(cfg config.DingTalkConnectConfig, state string) (string, error) {
|
||
base := strings.TrimSpace(cfg.AuthorizeURL)
|
||
if base == "" {
|
||
return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_EMPTY", "dingtalk authorize_url not configured")
|
||
}
|
||
redirectURI := strings.TrimSpace(cfg.RedirectURL)
|
||
if redirectURI == "" {
|
||
return "", infraerrors.InternalServer("DINGTALK_REDIRECT_URL_EMPTY", "dingtalk redirect_url not configured")
|
||
}
|
||
|
||
u, err := url.Parse(base)
|
||
if err != nil {
|
||
return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_PARSE_FAILED", "failed to parse dingtalk authorize_url").WithCause(err)
|
||
}
|
||
|
||
scopes := strings.TrimSpace(cfg.Scopes)
|
||
if scopes == "" {
|
||
scopes = "openid"
|
||
}
|
||
|
||
q := u.Query()
|
||
q.Set("client_id", cfg.ClientID)
|
||
q.Set("redirect_uri", redirectURI)
|
||
q.Set("response_type", "code")
|
||
q.Set("scope", scopes)
|
||
q.Set("state", state)
|
||
q.Set("prompt", "consent")
|
||
u.RawQuery = q.Encode()
|
||
|
||
return u.String(), nil
|
||
}
|
||
|
||
// ─── Complete Registration ─────────────────────────────────────────────────
|
||
|
||
type completeDingTalkOAuthRequest struct {
|
||
InvitationCode string `json:"invitation_code" binding:"required"`
|
||
AffCode string `json:"aff_code,omitempty"`
|
||
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
|
||
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
|
||
}
|
||
|
||
// CompleteDingTalkOAuthRegistration completes a pending OAuth registration by validating
|
||
// the invitation code and creating the user account.
|
||
// POST /api/v1/auth/oauth/dingtalk/complete-registration
|
||
func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
|
||
var req completeDingTalkOAuthRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
|
||
return
|
||
}
|
||
|
||
secureCookie := isRequestHTTPS(c)
|
||
sessionToken, err := readOAuthPendingSessionCookie(c)
|
||
if err != nil {
|
||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||
response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
|
||
return
|
||
}
|
||
browserSessionKey, err := readOAuthPendingBrowserCookie(c)
|
||
if err != nil {
|
||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||
response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
|
||
return
|
||
}
|
||
pendingSvc, err := h.pendingIdentityService()
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
|
||
if err != nil {
|
||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
} else if handled {
|
||
c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
|
||
return
|
||
} else {
|
||
session = updatedSession
|
||
}
|
||
if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
email := strings.TrimSpace(session.ResolvedEmail)
|
||
username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
|
||
// E: username 空时退到 email local part(跨组织用户没拿到 staff.Name 也能注册)
|
||
if username == "" {
|
||
if at := strings.Index(email, "@"); at > 0 {
|
||
username = email[:at]
|
||
} else {
|
||
username = email
|
||
}
|
||
}
|
||
if email == "" || username == "" {
|
||
response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
|
||
return
|
||
}
|
||
|
||
client := h.entClient()
|
||
if client == nil {
|
||
response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
|
||
return
|
||
}
|
||
if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
|
||
respondPendingOAuthBindingApplyError(c, err)
|
||
return
|
||
}
|
||
decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
|
||
AdoptDisplayName: req.AdoptDisplayName,
|
||
AdoptAvatar: req.AdoptAvatar,
|
||
})
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode, "dingtalk")
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
|
||
respondPendingOAuthBindingApplyError(c, err)
|
||
return
|
||
}
|
||
// 新用户注册完成后执行身份同步(user_id 现在已知)。
|
||
// 异步执行避免阻塞 token 响应。
|
||
if completionCfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context()); cfgErr == nil {
|
||
dtClient := h.dingTalkClient(completionCfg)
|
||
claims := session.UpstreamIdentityClaims
|
||
runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
|
||
h.syncDingTalkIdentityFromClaims(ctx, completionCfg, dtClient, user.ID, claims, true)
|
||
})
|
||
}
|
||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||
clearOAuthPendingSessionCookie(c, secureCookie)
|
||
clearOAuthPendingBrowserCookie(c, secureCookie)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"access_token": tokenPair.AccessToken,
|
||
"refresh_token": tokenPair.RefreshToken,
|
||
"expires_in": tokenPair.ExpiresIn,
|
||
"token_type": "Bearer",
|
||
})
|
||
}
|
||
|
||
// CreateDingTalkOAuthAccount creates a new user account from a pending DingTalk OAuth session.
|
||
// POST /api/v1/auth/oauth/dingtalk/create-account
|
||
func (h *AuthHandler) CreateDingTalkOAuthAccount(c *gin.Context) {
|
||
h.createPendingOAuthAccount(c, "dingtalk")
|
||
}
|
||
|
||
// BindDingTalkOAuthLogin 处理已有账户绑定钉钉 OAuth 登录。
|
||
// POST /api/v1/auth/oauth/dingtalk/bind-login
|
||
func (h *AuthHandler) BindDingTalkOAuthLogin(c *gin.Context) {
|
||
h.bindPendingOAuthLogin(c, "dingtalk")
|
||
}
|
||
|
||
// ─── DingTalk 身份同步 ─────────────────────────────────────────────────────
|
||
|
||
// runDingTalkSyncAsync 在后台 goroutine 执行钉钉身份同步,避免阻塞登录响应。
|
||
// 与请求 ctx 解耦(handler 返回后会被取消),但保留其 values(trace/request id)。
|
||
// 固定 30s 超时上限,防止 goroutine 因上游卡顿无限挂起。
|
||
func runDingTalkSyncAsync(parent context.Context, fn func(ctx context.Context)) {
|
||
base := context.WithoutCancel(parent)
|
||
go func() {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
slog.Error("dingtalk sync: panic recovered", "panic", r)
|
||
}
|
||
}()
|
||
ctx, cancel := context.WithTimeout(base, 30*time.Second)
|
||
defer cancel()
|
||
fn(ctx)
|
||
}()
|
||
}
|
||
|
||
// syncDingTalkIdentity 在 internal_only 模式下,按三个 sync 开关把钉钉身份信息
|
||
// 同步到用户属性表(以及 users.username)。
|
||
// 任何错误仅记日志,不中断登录流程(最终一致性)。
|
||
func (h *AuthHandler) syncDingTalkIdentity(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, staff *DingTalkStaffInfo, syncUsername bool) {
|
||
slog.Info("dingtalk sync: entry",
|
||
"user_id", userID,
|
||
"policy", cfg.CorpRestrictionPolicy,
|
||
"sync_corp_email", cfg.SyncCorpEmail,
|
||
"sync_display_name", cfg.SyncDisplayName,
|
||
"sync_dept", cfg.SyncDept,
|
||
"sync_username", syncUsername,
|
||
"attr_key_email", cfg.SyncCorpEmailAttrKey,
|
||
"attr_key_name", cfg.SyncDisplayNameAttrKey,
|
||
"attr_key_dept", cfg.SyncDeptAttrKey,
|
||
"staff_nil", staff == nil,
|
||
)
|
||
if cfg.CorpRestrictionPolicy != "internal_only" || staff == nil {
|
||
slog.Info("dingtalk sync: skip, not internal_only or staff nil")
|
||
return
|
||
}
|
||
slog.Info("dingtalk sync: staff snapshot",
|
||
"name", staff.Name, "email", staff.Email, "dept_ids", staff.DeptIDs,
|
||
)
|
||
if !cfg.SyncCorpEmail && !cfg.SyncDisplayName && !cfg.SyncDept {
|
||
slog.Info("dingtalk sync: skip, all flags disabled")
|
||
return
|
||
}
|
||
if h.userAttributeService == nil {
|
||
slog.Warn("dingtalk sync: userAttributeService not available, skipping")
|
||
return
|
||
}
|
||
|
||
// 仅首次注册时覆盖 users.username(避免每次登录覆盖用户后续手动改过的名字)。
|
||
// dingtalk_name 属性下面单独每次写入企业 name,不受此条件影响。
|
||
if syncUsername && cfg.SyncDisplayName {
|
||
username := strings.TrimSpace(staff.Nickname)
|
||
source := "nickname"
|
||
if username == "" {
|
||
username = strings.TrimSpace(staff.Name)
|
||
source = "name(fallback)"
|
||
}
|
||
if username != "" && h.userService != nil {
|
||
if _, err := h.userService.UpdateProfile(ctx, userID, service.UpdateProfileRequest{Username: &username}); err != nil {
|
||
slog.Warn("dingtalk sync: failed to update username", "user_id", userID, "err", err)
|
||
} else {
|
||
slog.Info("dingtalk sync: username updated (register)", "user_id", userID, "username", username, "source", source)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 属性同步(目标 attr key 从 cfg 读取,默认值由 GetDingTalkConnectOAuthConfig 保证非空)
|
||
type syncField struct {
|
||
key string
|
||
value string
|
||
}
|
||
var fields []syncField
|
||
|
||
if cfg.SyncDisplayName && strings.TrimSpace(staff.Name) != "" {
|
||
fields = append(fields, syncField{cfg.SyncDisplayNameAttrKey, strings.TrimSpace(staff.Name)})
|
||
}
|
||
if cfg.SyncCorpEmail && strings.TrimSpace(staff.Email) != "" {
|
||
fields = append(fields, syncField{cfg.SyncCorpEmailAttrKey, strings.TrimSpace(staff.Email)})
|
||
}
|
||
if cfg.SyncDept && len(staff.DeptIDs) > 0 {
|
||
// 跳过根部门 ID=1,找第一个真实子部门;都是根则保留 1(最终写入空字符串覆盖旧值)。
|
||
primaryDeptID := int64(0)
|
||
for _, id := range staff.DeptIDs {
|
||
if id > 1 {
|
||
primaryDeptID = id
|
||
break
|
||
}
|
||
}
|
||
if primaryDeptID == 0 {
|
||
primaryDeptID = staff.DeptIDs[0]
|
||
}
|
||
slog.Info("dingtalk sync: pick primary dept", "user_id", userID, "all_dept_ids", staff.DeptIDs, "primary", primaryDeptID)
|
||
path, err := h.resolveDingTalkDeptPath(ctx, client, primaryDeptID)
|
||
if err != nil {
|
||
slog.Warn("dingtalk sync: failed to resolve dept path", "user_id", userID, "dept_id", primaryDeptID, "err", err)
|
||
} else {
|
||
// path="" 表示公司直属(仅在根部门下),仍写入空串覆盖旧值。
|
||
fields = append(fields, syncField{cfg.SyncDeptAttrKey, path})
|
||
}
|
||
}
|
||
|
||
if len(fields) == 0 {
|
||
return
|
||
}
|
||
|
||
// 逐 key 查 definition 并 upsert
|
||
for _, f := range fields {
|
||
if err := h.setUserAttributeByKey(ctx, userID, f.key, f.value); err != nil {
|
||
slog.Warn("dingtalk sync: failed to set attribute", "user_id", userID, "key", f.key, "err", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// syncDingTalkIdentityFromClaims 从 upstreamClaims 恢复 DingTalkStaffInfo 并调用 syncDingTalkIdentity。
|
||
// 用于 pending session 完成阶段(complete-registration / create-account / bind-login)。
|
||
// syncUsername=true 表示首次注册场景,需要把 nickname 写入 users.username。
|
||
func (h *AuthHandler) syncDingTalkIdentityFromClaims(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, claims map[string]any, syncUsername bool) {
|
||
staff := dingTalkStaffFromClaims(claims)
|
||
h.syncDingTalkIdentity(ctx, cfg, client, userID, staff, syncUsername)
|
||
}
|
||
|
||
// maybeSyncDingTalkAfterRegistration 在通用 OAuth 注册路径完成后调用。
|
||
// 同步 4 个字段:users.username(首次) + dingtalk_name/email/department(每次)。
|
||
func (h *AuthHandler) maybeSyncDingTalkAfterRegistration(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
|
||
h.dispatchDingTalkPendingSync(ctx, session, userID, true)
|
||
}
|
||
|
||
// maybeSyncDingTalkAfterLogin 在通用 OAuth 登录/绑定路径完成后调用。
|
||
// 仅刷新 3 个属性(dingtalk_name/email/department),不动 users.username。
|
||
func (h *AuthHandler) maybeSyncDingTalkAfterLogin(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
|
||
h.dispatchDingTalkPendingSync(ctx, session, userID, false)
|
||
}
|
||
|
||
func (h *AuthHandler) dispatchDingTalkPendingSync(ctx context.Context, session *dbent.PendingAuthSession, userID int64, syncUsername bool) {
|
||
if session == nil || userID <= 0 {
|
||
return
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(session.ProviderType), "dingtalk") {
|
||
return
|
||
}
|
||
cfg, err := h.getDingTalkOAuthConfig(ctx)
|
||
if err != nil {
|
||
slog.Debug("dingtalk sync: skip post-login sync, config unavailable", "user_id", userID, "err", err.Error())
|
||
return
|
||
}
|
||
client := h.dingTalkClient(cfg)
|
||
claims := session.UpstreamIdentityClaims
|
||
// 异步执行避免阻塞 token 响应。
|
||
runDingTalkSyncAsync(ctx, func(asyncCtx context.Context) {
|
||
h.syncDingTalkIdentityFromClaims(asyncCtx, cfg, client, userID, claims, syncUsername)
|
||
})
|
||
}
|
||
|
||
// dingTalkStaffFromClaims 从 upstreamClaims 重建最小 DingTalkStaffInfo。
|
||
func dingTalkStaffFromClaims(claims map[string]any) *DingTalkStaffInfo {
|
||
if claims == nil {
|
||
return &DingTalkStaffInfo{}
|
||
}
|
||
staff := &DingTalkStaffInfo{}
|
||
if v, ok := claims["username"].(string); ok {
|
||
staff.Name = v
|
||
}
|
||
if v, ok := claims["nickname"].(string); ok {
|
||
staff.Nickname = v
|
||
}
|
||
if v, ok := claims["email"].(string); ok {
|
||
staff.Email = v
|
||
}
|
||
if v, ok := claims["corp_user_id"].(string); ok {
|
||
staff.UserID = v
|
||
}
|
||
// primary_dept_id 存为 int64 或 float64(JSON round-trip)
|
||
switch v := claims["primary_dept_id"].(type) {
|
||
case int64:
|
||
if v > 0 {
|
||
staff.DeptIDs = []int64{v}
|
||
}
|
||
case float64:
|
||
if id := int64(v); id > 0 {
|
||
staff.DeptIDs = []int64{id}
|
||
}
|
||
}
|
||
return staff
|
||
}
|
||
|
||
// setUserAttributeByKey 按 attribute key 查找 definition,再 upsert 用户属性值。
|
||
// definition 不存在时记 warn 日志跳过(admin 在 settings 保存时已按需 upsert
|
||
// 对应 def;缺失意味着 admin 改了 attr key 但未保存 settings,或 def 被手工删除)。
|
||
func (h *AuthHandler) setUserAttributeByKey(ctx context.Context, userID int64, key, value string) error {
|
||
def, err := h.userAttributeService.GetDefinitionByKey(ctx, key)
|
||
if err != nil {
|
||
slog.Warn("dingtalk sync: attribute definition not found, skipping", "key", key, "err", err.Error())
|
||
return nil
|
||
}
|
||
if err := h.userAttributeService.UpdateUserAttributes(ctx, userID, []service.UpdateUserAttributeInput{
|
||
{AttributeID: def.ID, Value: value},
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
slog.Info("dingtalk sync: attribute upserted", "user_id", userID, "key", key, "attr_id", def.ID)
|
||
return nil
|
||
}
|
||
|
||
// resolveDingTalkDeptPath 从叶部门递归向上拼 "公司/部门/子部门" 路径字符串。
|
||
// 遇 dept_id=1(根)或 parent_id=0 停止。加 visited set 防循环,最多 50 层。
|
||
func (h *AuthHandler) resolveDingTalkDeptPath(ctx context.Context, client *DingTalkClient, deptID int64) (string, error) {
|
||
slog.Info("dingtalk sync: resolve dept path start", "dept_id", deptID)
|
||
const maxDepth = 50
|
||
visited := make(map[int64]bool, maxDepth)
|
||
var parts []string
|
||
|
||
current := deptID
|
||
for i := 0; i < maxDepth; i++ {
|
||
if current < 1 || visited[current] {
|
||
break
|
||
}
|
||
visited[current] = true
|
||
|
||
info, err := client.GetDeptInfo(ctx, current)
|
||
if err != nil {
|
||
return "", fmt.Errorf("get dept info %d: %w", current, err)
|
||
}
|
||
if strings.TrimSpace(info.Name) != "" {
|
||
parts = append([]string{strings.TrimSpace(info.Name)}, parts...)
|
||
}
|
||
// 钉钉根部门 dept_id=1,ParentID 通常为 0;遇到 0 / self 终止避免循环。
|
||
if info.ParentID < 1 || info.ParentID == current {
|
||
break
|
||
}
|
||
current = info.ParentID
|
||
}
|
||
|
||
// 去除根组织名(parts[0] 始终是企业全称),仅保留部门层级。
|
||
// 例:["公司","A","B"] → "A/B";["公司"] → ""(公司直属)。
|
||
if len(parts) > 0 {
|
||
parts = parts[1:]
|
||
}
|
||
|
||
return strings.Join(parts, "/"), nil
|
||
}
|