Files
sub2api/backend/internal/handler/auth_dingtalk_oauth.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1080 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}