auth_dingtalk_oauth.go 42 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079
  1. package handler
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "net/http"
  8. "net/url"
  9. "strings"
  10. "time"
  11. dbent "github.com/Wei-Shaw/sub2api/ent"
  12. dbuser "github.com/Wei-Shaw/sub2api/ent/user"
  13. "github.com/Wei-Shaw/sub2api/internal/config"
  14. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/response"
  17. "github.com/Wei-Shaw/sub2api/internal/service"
  18. "github.com/gin-gonic/gin"
  19. )
  20. // dingTalkUpstreamRedirect 在 4 步链上游调用失败时记录详细错误日志并跳错误页。
  21. // 把钉钉 errcode/errmsg 写进 backend log + URL fragment,避免被泛 "internal error" 吞掉。
  22. func dingTalkUpstreamRedirect(c *gin.Context, frontendCallback, step string, err error) {
  23. var apiErr *DingTalkAPIError
  24. dtCode := ""
  25. dtMsg := ""
  26. dtHTTP := 0
  27. if errors.As(err, &apiErr) {
  28. dtCode = apiErr.Code
  29. dtMsg = apiErr.Message
  30. dtHTTP = apiErr.HTTP
  31. }
  32. slog.Error("dingtalk upstream call failed",
  33. "step", step,
  34. "dingtalk_code", dtCode,
  35. "dingtalk_msg", dtMsg,
  36. "http_status", dtHTTP,
  37. "error", err.Error(),
  38. )
  39. msg := dtMsg
  40. if strings.TrimSpace(msg) == "" {
  41. msg = infraerrors.Message(err)
  42. }
  43. if strings.TrimSpace(dtCode) != "" {
  44. msg = "dingtalk[" + dtCode + "] " + msg
  45. }
  46. redirectOAuthError(c, frontendCallback, mapDingTalkErrorCode(err), msg, "")
  47. }
  48. // ─── 常量 ──────────────────────────────────────────────────────────────────
  49. const (
  50. dingTalkOAuthCookiePath = "/api/v1/auth/oauth/dingtalk"
  51. dingTalkOAuthStateCookieName = "dingtalk_oauth_state"
  52. dingTalkOAuthRedirectCookie = "dingtalk_oauth_redirect"
  53. dingTalkOAuthIntentCookieName = "dingtalk_oauth_intent"
  54. dingTalkOAuthBindUserCookieName = "dingtalk_oauth_bind_user"
  55. dingTalkOAuthCookieMaxAgeSec = 600 // 10 分钟
  56. dingTalkOAuthDefaultRedirectTo = "/dashboard"
  57. dingTalkOAuthDefaultFrontendCB = "/auth/dingtalk/callback"
  58. dingTalkLevelThreeEnabled = true
  59. )
  60. // ─── Config helper ─────────────────────────────────────────────────────────
  61. // getDingTalkOAuthConfig 返回 DingTalk OAuth 最终生效配置。
  62. // 优先从 settingSvc(settings 表)读取,回退到 h.cfg.DingTalk。
  63. func (h *AuthHandler) getDingTalkOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
  64. if h != nil && h.settingSvc != nil {
  65. return h.settingSvc.GetDingTalkConnectOAuthConfig(ctx)
  66. }
  67. if h == nil || h.cfg == nil {
  68. return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
  69. }
  70. if !h.cfg.DingTalk.Enabled {
  71. return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
  72. }
  73. return h.cfg.DingTalk, nil
  74. }
  75. // ─── Cookie helpers(使用 dingtalk path)─────────────────────────────────
  76. func setDingTalkCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
  77. http.SetCookie(c.Writer, &http.Cookie{
  78. Name: name,
  79. Value: value,
  80. Path: dingTalkOAuthCookiePath,
  81. MaxAge: maxAgeSec,
  82. HttpOnly: true,
  83. Secure: secure,
  84. SameSite: http.SameSiteLaxMode,
  85. })
  86. }
  87. func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
  88. http.SetCookie(c.Writer, &http.Cookie{
  89. Name: name,
  90. Value: "",
  91. Path: dingTalkOAuthCookiePath,
  92. MaxAge: -1,
  93. HttpOnly: true,
  94. Secure: secure,
  95. SameSite: http.SameSiteLaxMode,
  96. })
  97. }
  98. // ─── DingTalkOAuthStart ────────────────────────────────────────────────────
  99. // DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
  100. // GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
  101. func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
  102. if !h.requireActionCaptchaForOAuthLoginStart(c) {
  103. return
  104. }
  105. cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
  106. if err != nil {
  107. frontendCB := dingTalkOAuthDefaultFrontendCB
  108. redirectOAuthError(c, frontendCB, "dingtalk_not_enabled", "", "")
  109. return
  110. }
  111. state, err := oauth.GenerateState()
  112. if err != nil {
  113. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
  114. return
  115. }
  116. redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
  117. if redirectTo == "" {
  118. redirectTo = dingTalkOAuthDefaultRedirectTo
  119. }
  120. browserSessionKey, err := generateOAuthPendingBrowserSession()
  121. if err != nil {
  122. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BROWSER_SESSION_GEN_FAILED", "failed to generate oauth browser session").WithCause(err))
  123. return
  124. }
  125. secureCookie := isRequestHTTPS(c)
  126. setDingTalkCookie(c, dingTalkOAuthStateCookieName, encodeCookieValue(state), dingTalkOAuthCookieMaxAgeSec, secureCookie)
  127. setDingTalkCookie(c, dingTalkOAuthRedirectCookie, encodeCookieValue(redirectTo), dingTalkOAuthCookieMaxAgeSec, secureCookie)
  128. intent := normalizeOAuthIntent(c.Query("intent"))
  129. setDingTalkCookie(c, dingTalkOAuthIntentCookieName, encodeCookieValue(intent), dingTalkOAuthCookieMaxAgeSec, secureCookie)
  130. captureOAuthPromoCode(c, secureCookie)
  131. setOAuthPendingBrowserCookie(c, browserSessionKey, secureCookie)
  132. clearOAuthPendingSessionCookie(c, secureCookie)
  133. if intent == oauthIntentBindCurrentUser {
  134. bindCookieValue, err := h.buildOAuthBindUserCookieFromContext(c)
  135. if err != nil {
  136. response.ErrorFrom(c, err)
  137. return
  138. }
  139. setDingTalkCookie(c, dingTalkOAuthBindUserCookieName, encodeCookieValue(bindCookieValue), dingTalkOAuthCookieMaxAgeSec, secureCookie)
  140. } else {
  141. clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie)
  142. }
  143. authURL, err := buildDingTalkAuthorizeURL(cfg, state)
  144. if err != nil {
  145. response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build dingtalk authorization url").WithCause(err))
  146. return
  147. }
  148. respondOAuthStart(c, authURL)
  149. }
  150. // ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
  151. // ─── findDingTalkCompatEmailUser ───────────────────────────────────────────
  152. // findDingTalkCompatEmailUser 通过真实邮箱查找可与 DingTalk 账号兼容绑定的现有用户。
  153. func (h *AuthHandler) findDingTalkCompatEmailUser(ctx context.Context, email string) (*dbent.User, error) {
  154. if !dingTalkLevelThreeEnabled {
  155. return nil, nil
  156. }
  157. client := h.entClient()
  158. if client == nil {
  159. return nil, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
  160. }
  161. email = strings.TrimSpace(strings.ToLower(email))
  162. if email == "" ||
  163. strings.HasSuffix(email, service.DingTalkConnectSyntheticEmailDomain) ||
  164. strings.HasSuffix(email, service.LinuxDoConnectSyntheticEmailDomain) ||
  165. strings.HasSuffix(email, service.OIDCConnectSyntheticEmailDomain) ||
  166. strings.HasSuffix(email, service.WeChatConnectSyntheticEmailDomain) {
  167. return nil, nil
  168. }
  169. userEntities, err := client.User.Query().
  170. Where(userNormalizedEmailPredicate(email)).
  171. Order(dbent.Asc(dbuser.FieldID)).
  172. All(ctx)
  173. if err != nil {
  174. return nil, infraerrors.InternalServer("COMPAT_EMAIL_LOOKUP_FAILED", "failed to look up compat email user").WithCause(err)
  175. }
  176. switch len(userEntities) {
  177. case 0:
  178. return nil, nil
  179. case 1:
  180. return userEntities[0], nil
  181. default:
  182. return nil, infraerrors.Conflict("USER_EMAIL_CONFLICT", "normalized email matched multiple users")
  183. }
  184. }
  185. // ─── createDingTalkOAuthChoicePendingSession ───────────────────────────────
  186. // createDingTalkOAuthChoicePendingSession 创建 DingTalk OAuth 三方注册/绑定的 choice pending session。
  187. // signupBlocked=true 时关闭"创建新账户"出口;若同时没有 compat email 匹配的已有账户,
  188. // 直接把 step 切到 bind_login_required,避免前端展示一个没有实际可点选项的 choice 界面。
  189. func (h *AuthHandler) createDingTalkOAuthChoicePendingSession(
  190. c *gin.Context,
  191. identity service.PendingAuthIdentityKey,
  192. suggestedEmail string,
  193. resolvedEmail string,
  194. redirectTo string,
  195. browserSessionKey string,
  196. upstreamClaims map[string]any,
  197. compatEmail string,
  198. compatEmailUser *dbent.User,
  199. forceEmailOnSignup bool,
  200. signupBlocked bool,
  201. ) error {
  202. suggestionEmail := strings.TrimSpace(suggestedEmail)
  203. canonicalEmail := strings.TrimSpace(resolvedEmail)
  204. if suggestionEmail == "" {
  205. suggestionEmail = canonicalEmail
  206. }
  207. completionResponse := map[string]any{
  208. "step": oauthPendingChoiceStep,
  209. "adoption_required": true,
  210. "redirect": strings.TrimSpace(redirectTo),
  211. "email": suggestionEmail,
  212. "resolved_email": canonicalEmail,
  213. "existing_account_email": "",
  214. "existing_account_bindable": false,
  215. "create_account_allowed": !signupBlocked,
  216. "force_email_on_signup": forceEmailOnSignup,
  217. "choice_reason": "third_party_signup",
  218. }
  219. if strings.TrimSpace(compatEmail) != "" {
  220. completionResponse["compat_email"] = strings.TrimSpace(compatEmail)
  221. }
  222. resolvedChoiceEmail := suggestionEmail
  223. if compatEmailUser != nil {
  224. completionResponse["email"] = strings.TrimSpace(compatEmailUser.Email)
  225. completionResponse["existing_account_email"] = strings.TrimSpace(compatEmailUser.Email)
  226. completionResponse["existing_account_bindable"] = true
  227. completionResponse["choice_reason"] = "compat_email_match"
  228. resolvedChoiceEmail = strings.TrimSpace(compatEmailUser.Email)
  229. }
  230. if forceEmailOnSignup && compatEmailUser == nil {
  231. completionResponse["choice_reason"] = "force_email_on_signup"
  232. }
  233. // 注册被拦:无论是否匹配到 compat email user,都跳过 choice,直接进 bind_login。
  234. // "开放注册" 关闭 且 "钉钉企业模式豁免" 也关闭时,唯一合法出口是绑定已有账户,
  235. // 不应该让用户看到"创建新账户"按钮;compat user 命中只是让 bind_login 的邮箱字段预填得更准。
  236. if signupBlocked {
  237. completionResponse["step"] = "bind_login_required"
  238. completionResponse["existing_account_bindable"] = true
  239. completionResponse["choice_reason"] = "signup_blocked_redirect_to_bind"
  240. }
  241. var targetUserID *int64
  242. if compatEmailUser != nil && compatEmailUser.ID > 0 {
  243. targetUserID = &compatEmailUser.ID
  244. }
  245. return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  246. Intent: oauthIntentLogin,
  247. Identity: identity,
  248. TargetUserID: targetUserID,
  249. ResolvedEmail: resolvedChoiceEmail,
  250. RedirectTo: redirectTo,
  251. BrowserSessionKey: browserSessionKey,
  252. UpstreamIdentityClaims: upstreamClaims,
  253. CompletionResponse: completionResponse,
  254. })
  255. }
  256. // ─── DingTalkOAuthCallback ─────────────────────────────────────────────────
  257. // DingTalkOAuthCallback 处理钉钉授权回调。
  258. // GET /api/v1/auth/oauth/dingtalk/callback?code=...&state=...
  259. func (h *AuthHandler) DingTalkOAuthCallback(c *gin.Context) {
  260. cfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context())
  261. if cfgErr != nil {
  262. response.ErrorFrom(c, cfgErr)
  263. return
  264. }
  265. frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
  266. if frontendCallback == "" {
  267. frontendCallback = dingTalkOAuthDefaultFrontendCB
  268. }
  269. if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
  270. redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
  271. return
  272. }
  273. code := strings.TrimSpace(c.Query("code"))
  274. state := strings.TrimSpace(c.Query("state"))
  275. if code == "" || state == "" {
  276. redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
  277. return
  278. }
  279. secureCookie := isRequestHTTPS(c)
  280. defer func() {
  281. clearDingTalkCookie(c, dingTalkOAuthStateCookieName, secureCookie)
  282. clearDingTalkCookie(c, dingTalkOAuthRedirectCookie, secureCookie)
  283. clearDingTalkCookie(c, dingTalkOAuthIntentCookieName, secureCookie)
  284. clearOAuthPromoCodeCookie(c, secureCookie)
  285. }()
  286. expectedState, err := readCookieDecoded(c, dingTalkOAuthStateCookieName)
  287. if err != nil || state != expectedState {
  288. redirectOAuthError(c, frontendCallback, "csrf", "state mismatch", "")
  289. return
  290. }
  291. redirectTo, _ := readCookieDecoded(c, dingTalkOAuthRedirectCookie)
  292. intent, _ := readCookieDecoded(c, dingTalkOAuthIntentCookieName)
  293. intent = normalizeOAuthIntent(intent)
  294. browserSessionKey, _ := readOAuthPendingBrowserCookie(c)
  295. if strings.TrimSpace(browserSessionKey) == "" {
  296. redirectOAuthError(c, frontendCallback, "missing_browser_session", "missing browser session cookie", "")
  297. return
  298. }
  299. forceEmailOnSignup := h.isForceEmailOnThirdPartySignup(c.Request.Context())
  300. // ─── 4 步链(Step 1 + Step 2 必须;Step 3/4 按需 + 跨组织降级)───
  301. client := h.dingTalkClient(cfg)
  302. userToken, err := client.ExchangeCodeForUserToken(c.Request.Context(), code)
  303. if err != nil {
  304. dingTalkUpstreamRedirect(c, frontendCallback, "exchange_code", err)
  305. return
  306. }
  307. // D: corp 校验提前到 Step 1 之后、Step 2 之前,减少不必要的上游调用
  308. corpID := strings.TrimSpace(userToken.CorpID)
  309. if !checkDingTalkCorpAllowed(cfg, corpID) {
  310. // 不在 URL 中透传 corpID,避免内部企业标识泄露给前端
  311. redirectOAuthError(c, frontendCallback, "corp_rejected", "", "")
  312. return
  313. }
  314. // Step 2: 必须 — UnionID 是全局唯一,作为 subject + 合成邮箱种子;nick 是用户在 App 自设的昵称
  315. unionID, oauthNick, err := client.GetUnionIdByUserToken(c.Request.Context(), userToken.AccessToken)
  316. if err != nil {
  317. dingTalkUpstreamRedirect(c, frontendCallback, "get_union_id", err)
  318. return
  319. }
  320. identityKey := service.PendingAuthIdentityKey{ProviderType: "dingtalk", ProviderKey: "dingtalk", ProviderSubject: unionID}
  321. // Step 3/4 调用策略由 policy 决定,与 require_email 解耦。
  322. // policy=internal_only → 必须成功(hard fail),因为 AppType=internal 已保证用户在应用企业。
  323. // policy=none / "" → 尝试,失败降级(公网场景跨组织用户属正常预期)。
  324. // require_email 只影响 Step 3/4 结果后的邮箱处理路径,不影响是否调用。
  325. var staff *DingTalkStaffInfo
  326. switch cfg.CorpRestrictionPolicy {
  327. case "internal_only":
  328. // AppType=internal 已保证用户在应用企业,Step 3/4 必须成功。
  329. // 失败 = 钉钉 OAPI 故障或应用配置错误,应 hard fail。
  330. upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID)
  331. if errStep3 != nil {
  332. dingTalkUpstreamRedirect(c, frontendCallback, "get_user_id", errStep3)
  333. return
  334. }
  335. staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID)
  336. if errStep4 != nil {
  337. dingTalkUpstreamRedirect(c, frontendCallback, "get_staff_info", errStep4)
  338. return
  339. }
  340. staff = staffInfo
  341. default: // "none" or ""
  342. // 公网登录,跨组织用户 Step 3/4 可能失败(设计预期),尝试调用,失败降级。
  343. // 即使 require_email=false 也尝试拿 name(用于 upstreamClaims.username),失败就空着。
  344. upstreamUserID, errStep3 := client.GetUserIdByUnionId(c.Request.Context(), unionID)
  345. if errStep3 != nil {
  346. slog.Debug("dingtalk step3 fallback (none/cross-org)",
  347. "corp_id", corpID, "union_id", unionID, "err", errStep3.Error())
  348. staff = &DingTalkStaffInfo{}
  349. break
  350. }
  351. staffInfo, errStep4 := client.GetStaffInfoByUserId(c.Request.Context(), upstreamUserID)
  352. if errStep4 != nil {
  353. slog.Debug("dingtalk step4 fallback (none/cross-org)",
  354. "corp_id", corpID, "union_id", unionID, "err", errStep4.Error())
  355. staff = &DingTalkStaffInfo{}
  356. break
  357. }
  358. staff = staffInfo
  359. }
  360. // nick 来自 OIDC /contact/users/me,优先作为钉钉昵称(user/get.nickname 多数为空)。
  361. if staff != nil && strings.TrimSpace(oauthNick) != "" {
  362. staff.Nickname = strings.TrimSpace(oauthNick)
  363. }
  364. upstreamClaims := buildDingTalkUpstreamClaims(staff, unionID, corpID)
  365. // ─── S1 主动绑定分支(PR-3 才走到这里)───
  366. if intent == oauthIntentBindCurrentUser {
  367. targetUserID, err := h.readOAuthBindUserIDFromCookie(c, dingTalkOAuthBindUserCookieName)
  368. if err != nil {
  369. redirectOAuthError(c, frontendCallback, "invalid_state", "invalid bind user cookie", "")
  370. return
  371. }
  372. // policy=none 跨组织用户绑定时 staff.Email="",用合成邮箱占位(用于 audit log,不用于注册)
  373. bindResolvedEmail := staff.Email
  374. if bindResolvedEmail == "" {
  375. bindResolvedEmail = buildDingTalkSyntheticEmail(unionID)
  376. }
  377. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  378. Intent: oauthIntentBindCurrentUser, Identity: identityKey,
  379. TargetUserID: &targetUserID, ResolvedEmail: bindResolvedEmail,
  380. RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
  381. UpstreamIdentityClaims: upstreamClaims,
  382. CompletionResponse: map[string]any{"redirect": redirectTo},
  383. }); err != nil {
  384. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  385. return
  386. }
  387. clearDingTalkCookie(c, dingTalkOAuthBindUserCookieName, secureCookie)
  388. redirectToFrontendCallback(c, frontendCallback)
  389. return
  390. }
  391. // ─── Level 1:auth_identities hit ───
  392. if existing, _ := h.findOAuthIdentityUser(c.Request.Context(), identityKey); existing != nil {
  393. // 身份同步:已登录用户,直接同步(user_id 已知)。
  394. // 异步执行避免上游钉钉接口(GetStaffInfoByUserId / 部门递归)阻塞登录跳转。
  395. runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
  396. h.syncDingTalkIdentity(ctx, cfg, client, existing.ID, staff, false)
  397. })
  398. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  399. Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: &existing.ID,
  400. ResolvedEmail: existing.Email, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
  401. UpstreamIdentityClaims: upstreamClaims,
  402. CompletionResponse: map[string]any{"redirect": redirectTo},
  403. }); err != nil {
  404. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  405. return
  406. }
  407. redirectToFrontendCallback(c, frontendCallback)
  408. return
  409. }
  410. signupBlocked := h.isDingTalkSignupBlocked(c.Request.Context(), cfg)
  411. // ─── 非命中:require_email=false 走 synthetic email 直接登录 ───
  412. if !cfg.RequireEmail {
  413. if signupBlocked {
  414. // 注册被拦 + 无邮箱可输:唯一出路是绑定已有账户
  415. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  416. Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
  417. ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
  418. UpstreamIdentityClaims: upstreamClaims,
  419. CompletionResponse: dingTalkBindLoginCompletionResponse(redirectTo),
  420. }); err != nil {
  421. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  422. return
  423. }
  424. redirectToFrontendCallback(c, frontendCallback)
  425. return
  426. }
  427. syntheticEmail := buildDingTalkSyntheticEmail(unionID)
  428. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  429. Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
  430. ResolvedEmail: syntheticEmail, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
  431. UpstreamIdentityClaims: upstreamClaims,
  432. CompletionResponse: map[string]any{"redirect": redirectTo, "synthetic_email": syntheticEmail},
  433. }); err != nil {
  434. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  435. return
  436. }
  437. redirectToFrontendCallback(c, frontendCallback)
  438. return
  439. }
  440. // ─── require_email=true 且 staff.Email 空 → 补邮箱(默认)或直接 bind_login(注册被拦时) ───
  441. if staff.Email == "" {
  442. completionResponse := map[string]any{
  443. "step": "email_completion",
  444. "requires_email_completion": true,
  445. "redirect": redirectTo,
  446. }
  447. if signupBlocked {
  448. // 注册被全局关闭且未豁免:跳过补邮箱页,直接进 bind_login 让用户输入已有账户
  449. completionResponse = dingTalkBindLoginCompletionResponse(redirectTo)
  450. }
  451. if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
  452. Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
  453. ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
  454. UpstreamIdentityClaims: upstreamClaims,
  455. CompletionResponse: completionResponse,
  456. }); err != nil {
  457. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  458. return
  459. }
  460. redirectToFrontendCallback(c, frontendCallback)
  461. return
  462. }
  463. // ─── L3/L4 有邮箱:统一 choice pending session ───
  464. var compatEmailUser *dbent.User
  465. if dingTalkLevelThreeEnabled && staff.Email != "" {
  466. compatEmailUser, _ = h.findDingTalkCompatEmailUser(c.Request.Context(), staff.Email)
  467. }
  468. if err := h.createDingTalkOAuthChoicePendingSession(
  469. c, identityKey, staff.Email, staff.Email,
  470. redirectTo, browserSessionKey, upstreamClaims,
  471. staff.Email, compatEmailUser, forceEmailOnSignup,
  472. signupBlocked,
  473. ); err != nil {
  474. redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
  475. return
  476. }
  477. redirectToFrontendCallback(c, frontendCallback)
  478. }
  479. func buildDingTalkSyntheticEmail(userID string) string {
  480. return "dingtalk-" + strings.ToLower(strings.TrimSpace(userID)) + service.DingTalkConnectSyntheticEmailDomain
  481. }
  482. // isDingTalkSignupBlocked 当注册总开关关闭且未开启钉钉企业模式豁免
  483. // (policy=internal_only + dingtalk_connect_bypass_registration=true)时返回 true。
  484. // 镜像 service.AuthService.canBypassRegistrationDisabledForOAuth 用于 OAuth callback
  485. // 早期路由决策:注册被拦 → 跳过补邮箱页直接进 bind_login,避免用户填完表单才报错。
  486. func (h *AuthHandler) isDingTalkSignupBlocked(ctx context.Context, cfg config.DingTalkConnectConfig) bool {
  487. if h.settingSvc == nil {
  488. return false
  489. }
  490. if h.settingSvc.IsRegistrationEnabled(ctx) {
  491. return false
  492. }
  493. if cfg.BypassRegistration && cfg.CorpRestrictionPolicy == "internal_only" {
  494. return false
  495. }
  496. return true
  497. }
  498. func dingTalkBindLoginCompletionResponse(redirectTo string) map[string]any {
  499. return map[string]any{
  500. "step": "bind_login_required",
  501. "existing_account_bindable": true,
  502. "create_account_allowed": false,
  503. "redirect": redirectTo,
  504. }
  505. }
  506. func buildDingTalkUpstreamClaims(staff *DingTalkStaffInfo, unionID, corpID string) map[string]any {
  507. primaryDeptID := int64(0)
  508. if len(staff.DeptIDs) > 0 {
  509. primaryDeptID = staff.DeptIDs[0]
  510. }
  511. return map[string]any{
  512. "email": staff.Email,
  513. "username": staff.Name,
  514. "nickname": staff.Nickname,
  515. "subject": unionID, // 与 identityKey.ProviderSubject 保持一致(全局唯一 unionID)
  516. "corp_user_id": staff.UserID, // 企业 userid(跨组织时为空),保留作独立字段用于 audit
  517. "union_id": unionID,
  518. "corp_id": corpID,
  519. "primary_dept_id": primaryDeptID, // 首个部门 ID,用于 internal_only 同步路径
  520. }
  521. }
  522. func checkDingTalkCorpAllowed(cfg config.DingTalkConnectConfig, corpID string) bool {
  523. switch cfg.CorpRestrictionPolicy {
  524. case "internal_only":
  525. // 方案 A:完全跳过 corpID 字段校验,由 step 3 `GetUserIdByUnionId` 做真实判定。
  526. // 原因:钉钉 /v1.0/oauth2/userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)
  527. // 不会返回 corpId 字段。而 step 3 用本企业 appToken 查 unionId→userId 映射,
  528. // 跨企业用户会被钉钉拒绝(错误码 60011/60121),mapDingTalkErrorCode 已将其映射回 "corp_rejected"。
  529. // AppType=internal 已由 ValidateDingTalkConfig 强制保证应用属性。
  530. return true
  531. case "none", "":
  532. return true
  533. default:
  534. return false
  535. }
  536. }
  537. // decideDingTalkStep34Strategy 根据 policy 和 Step 3/4 运行时错误决定处理方式。
  538. // 返回 (proceed bool, fatal bool):
  539. // - proceed=true:继续处理(step 成功或降级)
  540. // - fatal=true:应 hard fail(upstream_error)
  541. //
  542. // 此 helper 从主链中提取,便于 unit test 独立验证策略决策逻辑。
  543. func decideDingTalkStep34Strategy(policy string, stepErr error) (shouldFallback bool, isFatal bool) {
  544. if stepErr == nil {
  545. return false, false // 成功,不需要降级
  546. }
  547. switch policy {
  548. case "internal_only":
  549. return false, true // hard fail:同企业 Step 3/4 必须成功
  550. case "none", "":
  551. return true, false // 降级:公网场景跨组织用户失败属正常预期
  552. default:
  553. return false, true // 未知 policy,视为 hard fail
  554. }
  555. }
  556. // mapDingTalkErrorCode 把 DingTalkAPIError 映射到 redirectOAuthError 用的字符串 code
  557. func mapDingTalkErrorCode(err error) string {
  558. var apiErr *DingTalkAPIError
  559. if !errors.As(err, &apiErr) {
  560. return "upstream_error"
  561. }
  562. switch apiErr.Code {
  563. case "60011", "60121":
  564. return "corp_rejected"
  565. case "40014", "50015", "88":
  566. return "upstream_error"
  567. default:
  568. return "upstream_error"
  569. }
  570. }
  571. // dingTalkClient 构造或返回缓存的 client 实例(h-level 单例)。
  572. // 若 cfg 关键字段(ClientID/ClientSecret/TokenURL/UserInfoURL)与已缓存实例不一致,
  573. // 则丢弃旧实例(含 appToken 缓存)并重建,避免管理员改配置后旧凭据持续生效。
  574. func (h *AuthHandler) dingTalkClient(cfg config.DingTalkConnectConfig) *DingTalkClient {
  575. h.dingTalkClientMu.Lock()
  576. defer h.dingTalkClientMu.Unlock()
  577. newCfg := dingTalkClientConfig{
  578. ClientID: cfg.ClientID,
  579. ClientSecret: cfg.ClientSecret,
  580. TokenURL: cfg.TokenURL,
  581. UserInfoURL: cfg.UserInfoURL,
  582. }
  583. if h.dingTalkClientInstance == nil || h.dingTalkClientInstance.cfg != newCfg {
  584. h.dingTalkClientInstance = &DingTalkClient{
  585. cfg: newCfg,
  586. // 与 wechat OAuth client 对齐,避免上游网络抖动时请求悬挂。
  587. httpClient: &http.Client{Timeout: 10 * time.Second},
  588. }
  589. }
  590. return h.dingTalkClientInstance
  591. }
  592. // ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
  593. // buildDingTalkAuthorizeURL 根据配置和 state 构建钉钉 OAuth 授权 URL。
  594. func buildDingTalkAuthorizeURL(cfg config.DingTalkConnectConfig, state string) (string, error) {
  595. base := strings.TrimSpace(cfg.AuthorizeURL)
  596. if base == "" {
  597. return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_EMPTY", "dingtalk authorize_url not configured")
  598. }
  599. redirectURI := strings.TrimSpace(cfg.RedirectURL)
  600. if redirectURI == "" {
  601. return "", infraerrors.InternalServer("DINGTALK_REDIRECT_URL_EMPTY", "dingtalk redirect_url not configured")
  602. }
  603. u, err := url.Parse(base)
  604. if err != nil {
  605. return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_PARSE_FAILED", "failed to parse dingtalk authorize_url").WithCause(err)
  606. }
  607. scopes := strings.TrimSpace(cfg.Scopes)
  608. if scopes == "" {
  609. scopes = "openid"
  610. }
  611. q := u.Query()
  612. q.Set("client_id", cfg.ClientID)
  613. q.Set("redirect_uri", redirectURI)
  614. q.Set("response_type", "code")
  615. q.Set("scope", scopes)
  616. q.Set("state", state)
  617. q.Set("prompt", "consent")
  618. u.RawQuery = q.Encode()
  619. return u.String(), nil
  620. }
  621. // ─── Complete Registration ─────────────────────────────────────────────────
  622. type completeDingTalkOAuthRequest struct {
  623. InvitationCode string `json:"invitation_code" binding:"required"`
  624. AffCode string `json:"aff_code,omitempty"`
  625. AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
  626. AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
  627. }
  628. // CompleteDingTalkOAuthRegistration completes a pending OAuth registration by validating
  629. // the invitation code and creating the user account.
  630. // POST /api/v1/auth/oauth/dingtalk/complete-registration
  631. func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
  632. var req completeDingTalkOAuthRequest
  633. if err := c.ShouldBindJSON(&req); err != nil {
  634. c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
  635. return
  636. }
  637. secureCookie := isRequestHTTPS(c)
  638. sessionToken, err := readOAuthPendingSessionCookie(c)
  639. if err != nil {
  640. clearOAuthPendingSessionCookie(c, secureCookie)
  641. clearOAuthPendingBrowserCookie(c, secureCookie)
  642. response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
  643. return
  644. }
  645. browserSessionKey, err := readOAuthPendingBrowserCookie(c)
  646. if err != nil {
  647. clearOAuthPendingSessionCookie(c, secureCookie)
  648. clearOAuthPendingBrowserCookie(c, secureCookie)
  649. response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
  650. return
  651. }
  652. pendingSvc, err := h.pendingIdentityService()
  653. if err != nil {
  654. response.ErrorFrom(c, err)
  655. return
  656. }
  657. session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
  658. if err != nil {
  659. clearOAuthPendingSessionCookie(c, secureCookie)
  660. clearOAuthPendingBrowserCookie(c, secureCookie)
  661. response.ErrorFrom(c, err)
  662. return
  663. }
  664. if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
  665. response.ErrorFrom(c, err)
  666. return
  667. }
  668. if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
  669. response.ErrorFrom(c, err)
  670. return
  671. } else if handled {
  672. c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
  673. return
  674. } else {
  675. session = updatedSession
  676. }
  677. if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
  678. response.ErrorFrom(c, err)
  679. return
  680. }
  681. email := strings.TrimSpace(session.ResolvedEmail)
  682. username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
  683. // E: username 空时退到 email local part(跨组织用户没拿到 staff.Name 也能注册)
  684. if username == "" {
  685. if at := strings.Index(email, "@"); at > 0 {
  686. username = email[:at]
  687. } else {
  688. username = email
  689. }
  690. }
  691. if email == "" || username == "" {
  692. response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
  693. return
  694. }
  695. client := h.entClient()
  696. if client == nil {
  697. response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
  698. return
  699. }
  700. if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
  701. respondPendingOAuthBindingApplyError(c, err)
  702. return
  703. }
  704. decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
  705. AdoptDisplayName: req.AdoptDisplayName,
  706. AdoptAvatar: req.AdoptAvatar,
  707. })
  708. if err != nil {
  709. response.ErrorFrom(c, err)
  710. return
  711. }
  712. tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
  713. c.Request.Context(),
  714. email,
  715. username,
  716. req.InvitationCode,
  717. req.AffCode,
  718. pendingOAuthPromoCode(session),
  719. "dingtalk",
  720. )
  721. if err != nil {
  722. response.ErrorFrom(c, err)
  723. return
  724. }
  725. if err := applyPendingOAuthAdoptionAndConsumeSession(c.Request.Context(), client, h.authService, h.userService, session, decision, user.ID); err != nil {
  726. respondPendingOAuthBindingApplyError(c, err)
  727. return
  728. }
  729. // 新用户注册完成后执行身份同步(user_id 现在已知)。
  730. // 异步执行避免阻塞 token 响应。
  731. if completionCfg, cfgErr := h.getDingTalkOAuthConfig(c.Request.Context()); cfgErr == nil {
  732. dtClient := h.dingTalkClient(completionCfg)
  733. claims := session.UpstreamIdentityClaims
  734. runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
  735. h.syncDingTalkIdentityFromClaims(ctx, completionCfg, dtClient, user.ID, claims, true)
  736. })
  737. }
  738. h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
  739. clearOAuthPendingSessionCookie(c, secureCookie)
  740. clearOAuthPendingBrowserCookie(c, secureCookie)
  741. c.JSON(http.StatusOK, gin.H{
  742. "access_token": tokenPair.AccessToken,
  743. "refresh_token": tokenPair.RefreshToken,
  744. "expires_in": tokenPair.ExpiresIn,
  745. "token_type": "Bearer",
  746. })
  747. }
  748. // CreateDingTalkOAuthAccount creates a new user account from a pending DingTalk OAuth session.
  749. // POST /api/v1/auth/oauth/dingtalk/create-account
  750. func (h *AuthHandler) CreateDingTalkOAuthAccount(c *gin.Context) {
  751. h.createPendingOAuthAccount(c, "dingtalk")
  752. }
  753. // BindDingTalkOAuthLogin 处理已有账户绑定钉钉 OAuth 登录。
  754. // POST /api/v1/auth/oauth/dingtalk/bind-login
  755. func (h *AuthHandler) BindDingTalkOAuthLogin(c *gin.Context) {
  756. h.bindPendingOAuthLogin(c, "dingtalk")
  757. }
  758. // ─── DingTalk 身份同步 ─────────────────────────────────────────────────────
  759. // runDingTalkSyncAsync 在后台 goroutine 执行钉钉身份同步,避免阻塞登录响应。
  760. // 与请求 ctx 解耦(handler 返回后会被取消),但保留其 values(trace/request id)。
  761. // 固定 30s 超时上限,防止 goroutine 因上游卡顿无限挂起。
  762. func runDingTalkSyncAsync(parent context.Context, fn func(ctx context.Context)) {
  763. base := context.WithoutCancel(parent)
  764. go func() {
  765. defer func() {
  766. if r := recover(); r != nil {
  767. slog.Error("dingtalk sync: panic recovered", "panic", r)
  768. }
  769. }()
  770. ctx, cancel := context.WithTimeout(base, 30*time.Second)
  771. defer cancel()
  772. fn(ctx)
  773. }()
  774. }
  775. // syncDingTalkIdentity 在 internal_only 模式下,按三个 sync 开关把钉钉身份信息
  776. // 同步到用户属性表(以及 users.username)。
  777. // 任何错误仅记日志,不中断登录流程(最终一致性)。
  778. func (h *AuthHandler) syncDingTalkIdentity(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, staff *DingTalkStaffInfo, syncUsername bool) {
  779. slog.Info("dingtalk sync: entry",
  780. "user_id", userID,
  781. "policy", cfg.CorpRestrictionPolicy,
  782. "sync_corp_email", cfg.SyncCorpEmail,
  783. "sync_display_name", cfg.SyncDisplayName,
  784. "sync_dept", cfg.SyncDept,
  785. "sync_username", syncUsername,
  786. "attr_key_email", cfg.SyncCorpEmailAttrKey,
  787. "attr_key_name", cfg.SyncDisplayNameAttrKey,
  788. "attr_key_dept", cfg.SyncDeptAttrKey,
  789. "staff_nil", staff == nil,
  790. )
  791. if cfg.CorpRestrictionPolicy != "internal_only" || staff == nil {
  792. slog.Info("dingtalk sync: skip, not internal_only or staff nil")
  793. return
  794. }
  795. slog.Info("dingtalk sync: staff snapshot",
  796. "name", staff.Name, "email", staff.Email, "dept_ids", staff.DeptIDs,
  797. )
  798. if !cfg.SyncCorpEmail && !cfg.SyncDisplayName && !cfg.SyncDept {
  799. slog.Info("dingtalk sync: skip, all flags disabled")
  800. return
  801. }
  802. if h.userAttributeService == nil {
  803. slog.Warn("dingtalk sync: userAttributeService not available, skipping")
  804. return
  805. }
  806. // 仅首次注册时覆盖 users.username(避免每次登录覆盖用户后续手动改过的名字)。
  807. // dingtalk_name 属性下面单独每次写入企业 name,不受此条件影响。
  808. if syncUsername && cfg.SyncDisplayName {
  809. username := strings.TrimSpace(staff.Nickname)
  810. source := "nickname"
  811. if username == "" {
  812. username = strings.TrimSpace(staff.Name)
  813. source = "name(fallback)"
  814. }
  815. if username != "" && h.userService != nil {
  816. if _, err := h.userService.UpdateProfile(ctx, userID, service.UpdateProfileRequest{Username: &username}); err != nil {
  817. slog.Warn("dingtalk sync: failed to update username", "user_id", userID, "err", err)
  818. } else {
  819. slog.Info("dingtalk sync: username updated (register)", "user_id", userID, "username", username, "source", source)
  820. }
  821. }
  822. }
  823. // 属性同步(目标 attr key 从 cfg 读取,默认值由 GetDingTalkConnectOAuthConfig 保证非空)
  824. type syncField struct {
  825. key string
  826. value string
  827. }
  828. var fields []syncField
  829. if cfg.SyncDisplayName && strings.TrimSpace(staff.Name) != "" {
  830. fields = append(fields, syncField{cfg.SyncDisplayNameAttrKey, strings.TrimSpace(staff.Name)})
  831. }
  832. if cfg.SyncCorpEmail && strings.TrimSpace(staff.Email) != "" {
  833. fields = append(fields, syncField{cfg.SyncCorpEmailAttrKey, strings.TrimSpace(staff.Email)})
  834. }
  835. if cfg.SyncDept && len(staff.DeptIDs) > 0 {
  836. // 跳过根部门 ID=1,找第一个真实子部门;都是根则保留 1(最终写入空字符串覆盖旧值)。
  837. primaryDeptID := int64(0)
  838. for _, id := range staff.DeptIDs {
  839. if id > 1 {
  840. primaryDeptID = id
  841. break
  842. }
  843. }
  844. if primaryDeptID == 0 {
  845. primaryDeptID = staff.DeptIDs[0]
  846. }
  847. slog.Info("dingtalk sync: pick primary dept", "user_id", userID, "all_dept_ids", staff.DeptIDs, "primary", primaryDeptID)
  848. path, err := h.resolveDingTalkDeptPath(ctx, client, primaryDeptID)
  849. if err != nil {
  850. slog.Warn("dingtalk sync: failed to resolve dept path", "user_id", userID, "dept_id", primaryDeptID, "err", err)
  851. } else {
  852. // path="" 表示公司直属(仅在根部门下),仍写入空串覆盖旧值。
  853. fields = append(fields, syncField{cfg.SyncDeptAttrKey, path})
  854. }
  855. }
  856. if len(fields) == 0 {
  857. return
  858. }
  859. // 逐 key 查 definition 并 upsert
  860. for _, f := range fields {
  861. if err := h.setUserAttributeByKey(ctx, userID, f.key, f.value); err != nil {
  862. slog.Warn("dingtalk sync: failed to set attribute", "user_id", userID, "key", f.key, "err", err)
  863. }
  864. }
  865. }
  866. // syncDingTalkIdentityFromClaims 从 upstreamClaims 恢复 DingTalkStaffInfo 并调用 syncDingTalkIdentity。
  867. // 用于 pending session 完成阶段(complete-registration / create-account / bind-login)。
  868. // syncUsername=true 表示首次注册场景,需要把 nickname 写入 users.username。
  869. func (h *AuthHandler) syncDingTalkIdentityFromClaims(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, claims map[string]any, syncUsername bool) {
  870. staff := dingTalkStaffFromClaims(claims)
  871. h.syncDingTalkIdentity(ctx, cfg, client, userID, staff, syncUsername)
  872. }
  873. // maybeSyncDingTalkAfterRegistration 在通用 OAuth 注册路径完成后调用。
  874. // 同步 4 个字段:users.username(首次) + dingtalk_name/email/department(每次)。
  875. func (h *AuthHandler) maybeSyncDingTalkAfterRegistration(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
  876. h.dispatchDingTalkPendingSync(ctx, session, userID, true)
  877. }
  878. // maybeSyncDingTalkAfterLogin 在通用 OAuth 登录/绑定路径完成后调用。
  879. // 仅刷新 3 个属性(dingtalk_name/email/department),不动 users.username。
  880. func (h *AuthHandler) maybeSyncDingTalkAfterLogin(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
  881. h.dispatchDingTalkPendingSync(ctx, session, userID, false)
  882. }
  883. func (h *AuthHandler) dispatchDingTalkPendingSync(ctx context.Context, session *dbent.PendingAuthSession, userID int64, syncUsername bool) {
  884. if session == nil || userID <= 0 {
  885. return
  886. }
  887. if !strings.EqualFold(strings.TrimSpace(session.ProviderType), "dingtalk") {
  888. return
  889. }
  890. cfg, err := h.getDingTalkOAuthConfig(ctx)
  891. if err != nil {
  892. slog.Debug("dingtalk sync: skip post-login sync, config unavailable", "user_id", userID, "err", err.Error())
  893. return
  894. }
  895. client := h.dingTalkClient(cfg)
  896. claims := session.UpstreamIdentityClaims
  897. // 异步执行避免阻塞 token 响应。
  898. runDingTalkSyncAsync(ctx, func(asyncCtx context.Context) {
  899. h.syncDingTalkIdentityFromClaims(asyncCtx, cfg, client, userID, claims, syncUsername)
  900. })
  901. }
  902. // dingTalkStaffFromClaims 从 upstreamClaims 重建最小 DingTalkStaffInfo。
  903. func dingTalkStaffFromClaims(claims map[string]any) *DingTalkStaffInfo {
  904. if claims == nil {
  905. return &DingTalkStaffInfo{}
  906. }
  907. staff := &DingTalkStaffInfo{}
  908. if v, ok := claims["username"].(string); ok {
  909. staff.Name = v
  910. }
  911. if v, ok := claims["nickname"].(string); ok {
  912. staff.Nickname = v
  913. }
  914. if v, ok := claims["email"].(string); ok {
  915. staff.Email = v
  916. }
  917. if v, ok := claims["corp_user_id"].(string); ok {
  918. staff.UserID = v
  919. }
  920. // primary_dept_id 存为 int64 或 float64(JSON round-trip)
  921. switch v := claims["primary_dept_id"].(type) {
  922. case int64:
  923. if v > 0 {
  924. staff.DeptIDs = []int64{v}
  925. }
  926. case float64:
  927. if id := int64(v); id > 0 {
  928. staff.DeptIDs = []int64{id}
  929. }
  930. }
  931. return staff
  932. }
  933. // setUserAttributeByKey 按 attribute key 查找 definition,再 upsert 用户属性值。
  934. // definition 不存在时记 warn 日志跳过(admin 在 settings 保存时已按需 upsert
  935. // 对应 def;缺失意味着 admin 改了 attr key 但未保存 settings,或 def 被手工删除)。
  936. func (h *AuthHandler) setUserAttributeByKey(ctx context.Context, userID int64, key, value string) error {
  937. def, err := h.userAttributeService.GetDefinitionByKey(ctx, key)
  938. if err != nil {
  939. slog.Warn("dingtalk sync: attribute definition not found, skipping", "key", key, "err", err.Error())
  940. return nil
  941. }
  942. if err := h.userAttributeService.UpdateUserAttributes(ctx, userID, []service.UpdateUserAttributeInput{
  943. {AttributeID: def.ID, Value: value},
  944. }); err != nil {
  945. return err
  946. }
  947. slog.Info("dingtalk sync: attribute upserted", "user_id", userID, "key", key, "attr_id", def.ID)
  948. return nil
  949. }
  950. // resolveDingTalkDeptPath 从叶部门递归向上拼 "公司/部门/子部门" 路径字符串。
  951. // 遇 dept_id=1(根)或 parent_id=0 停止。加 visited set 防循环,最多 50 层。
  952. func (h *AuthHandler) resolveDingTalkDeptPath(ctx context.Context, client *DingTalkClient, deptID int64) (string, error) {
  953. slog.Info("dingtalk sync: resolve dept path start", "dept_id", deptID)
  954. const maxDepth = 50
  955. visited := make(map[int64]bool, maxDepth)
  956. var parts []string
  957. current := deptID
  958. for i := 0; i < maxDepth; i++ {
  959. if current < 1 || visited[current] {
  960. break
  961. }
  962. visited[current] = true
  963. info, err := client.GetDeptInfo(ctx, current)
  964. if err != nil {
  965. return "", fmt.Errorf("get dept info %d: %w", current, err)
  966. }
  967. if strings.TrimSpace(info.Name) != "" {
  968. parts = append([]string{strings.TrimSpace(info.Name)}, parts...)
  969. }
  970. // 钉钉根部门 dept_id=1,ParentID 通常为 0;遇到 0 / self 终止避免循环。
  971. if info.ParentID < 1 || info.ParentID == current {
  972. break
  973. }
  974. current = info.ParentID
  975. }
  976. // 去除根组织名(parts[0] 始终是企业全称),仅保留部门层级。
  977. // 例:["公司","A","B"] → "A/B";["公司"] → ""(公司直属)。
  978. if len(parts) > 0 {
  979. parts = parts[1:]
  980. }
  981. return strings.Join(parts, "/"), nil
  982. }