Files

1080 lines
42 KiB
Go
Raw Permalink Normal View History

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 最终生效配置。
// 优先从 settingSvcsettings 表)读取,回退到 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) {
if !h.requireActionCaptchaForOAuthLoginStart(c) {
return
}
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)
captureOAuthPromoCode(c, 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
}
respondOAuthStart(c, 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)
clearOAuthPromoCodeCookie(c, 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 1auth_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 failupstream_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.LoginOrRegisterOAuthWithTokenPairAndPromoCode(
c.Request.Context(),
email,
username,
req.InvitationCode,
req.AffCode,
pendingOAuthPromoCode(session),
"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 返回后会被取消),但保留其 valuestrace/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 或 float64JSON 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=1ParentID 通常为 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
}