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 }