Files
sub2api/backend/internal/handler/auth_dingtalk_oauth.go
T
feeeei 26e0a89323 人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立
开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时
CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片:
总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并
关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。

阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、
region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由
「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com,
非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey
取自持有该实例的账号,无需在配置中区分站点。

- AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK
  VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile
  网络错误行为对称;保存设置时真实探测 AK/SK 有效性
- 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为
  VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云
  启用时同样拦截;Turnstile 维持既有覆盖不扩大
- 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露
  verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接
  提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发,
  并轮询弹窗可见性识别用户关闭
- captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发
  aliyun_captcha_enabled / scene_id / prefix / region
- CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
2026-08-04 20:57:15 +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 最终生效配置。
// 优先从 settingSvc(settings 表)读取,回退到 h.cfg.DingTalk。
func (h *AuthHandler) getDingTalkOAuthConfig(ctx context.Context) (config.DingTalkConnectConfig, error) {
if h != nil && h.settingSvc != nil {
return h.settingSvc.GetDingTalkConnectOAuthConfig(ctx)
}
if h == nil || h.cfg == nil {
return config.DingTalkConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
}
if !h.cfg.DingTalk.Enabled {
return config.DingTalkConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "dingtalk oauth login is disabled")
}
return h.cfg.DingTalk, nil
}
// ─── Cookie helpers(使用 dingtalk path)─────────────────────────────────
func setDingTalkCookie(c *gin.Context, name string, value string, maxAgeSec int, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: value,
Path: dingTalkOAuthCookiePath,
MaxAge: maxAgeSec,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
http.SetCookie(c.Writer, &http.Cookie{
Name: name,
Value: "",
Path: dingTalkOAuthCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
})
}
// ─── DingTalkOAuthStart ────────────────────────────────────────────────────
// DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
// GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
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 1:auth_identities hit ───
if existing, _ := h.findOAuthIdentityUser(c.Request.Context(), identityKey); existing != nil {
// 身份同步:已登录用户,直接同步(user_id 已知)。
// 异步执行避免上游钉钉接口(GetStaffInfoByUserId / 部门递归)阻塞登录跳转。
runDingTalkSyncAsync(c.Request.Context(), func(ctx context.Context) {
h.syncDingTalkIdentity(ctx, cfg, client, existing.ID, staff, false)
})
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: &existing.ID,
ResolvedEmail: existing.Email, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{"redirect": redirectTo},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
signupBlocked := h.isDingTalkSignupBlocked(c.Request.Context(), cfg)
// ─── 非命中:require_email=false 走 synthetic email 直接登录 ───
if !cfg.RequireEmail {
if signupBlocked {
// 注册被拦 + 无邮箱可输:唯一出路是绑定已有账户
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: dingTalkBindLoginCompletionResponse(redirectTo),
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
syntheticEmail := buildDingTalkSyntheticEmail(unionID)
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
ResolvedEmail: syntheticEmail, RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: map[string]any{"redirect": redirectTo, "synthetic_email": syntheticEmail},
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
// ─── require_email=true 且 staff.Email 空 → 补邮箱(默认)或直接 bind_login(注册被拦时) ───
if staff.Email == "" {
completionResponse := map[string]any{
"step": "email_completion",
"requires_email_completion": true,
"redirect": redirectTo,
}
if signupBlocked {
// 注册被全局关闭且未豁免:跳过补邮箱页,直接进 bind_login 让用户输入已有账户
completionResponse = dingTalkBindLoginCompletionResponse(redirectTo)
}
if err := h.createOAuthPendingSession(c, oauthPendingSessionPayload{
Intent: oauthIntentLogin, Identity: identityKey, TargetUserID: nil,
ResolvedEmail: "", RedirectTo: redirectTo, BrowserSessionKey: browserSessionKey,
UpstreamIdentityClaims: upstreamClaims,
CompletionResponse: completionResponse,
}); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
redirectToFrontendCallback(c, frontendCallback)
return
}
// ─── L3/L4 有邮箱:统一 choice pending session ───
var compatEmailUser *dbent.User
if dingTalkLevelThreeEnabled && staff.Email != "" {
compatEmailUser, _ = h.findDingTalkCompatEmailUser(c.Request.Context(), staff.Email)
}
if err := h.createDingTalkOAuthChoicePendingSession(
c, identityKey, staff.Email, staff.Email,
redirectTo, browserSessionKey, upstreamClaims,
staff.Email, compatEmailUser, forceEmailOnSignup,
signupBlocked,
); err != nil {
redirectOAuthError(c, frontendCallback, "session_error", infraerrors.Reason(err), infraerrors.Message(err))
return
}
redirectToFrontendCallback(c, frontendCallback)
}
func buildDingTalkSyntheticEmail(userID string) string {
return "dingtalk-" + strings.ToLower(strings.TrimSpace(userID)) + service.DingTalkConnectSyntheticEmailDomain
}
// isDingTalkSignupBlocked 当注册总开关关闭且未开启钉钉企业模式豁免
// (policy=internal_only + dingtalk_connect_bypass_registration=true)时返回 true。
// 镜像 service.AuthService.canBypassRegistrationDisabledForOAuth 用于 OAuth callback
// 早期路由决策:注册被拦 → 跳过补邮箱页直接进 bind_login,避免用户填完表单才报错。
func (h *AuthHandler) isDingTalkSignupBlocked(ctx context.Context, cfg config.DingTalkConnectConfig) bool {
if h.settingSvc == nil {
return false
}
if h.settingSvc.IsRegistrationEnabled(ctx) {
return false
}
if cfg.BypassRegistration && cfg.CorpRestrictionPolicy == "internal_only" {
return false
}
return true
}
func dingTalkBindLoginCompletionResponse(redirectTo string) map[string]any {
return map[string]any{
"step": "bind_login_required",
"existing_account_bindable": true,
"create_account_allowed": false,
"redirect": redirectTo,
}
}
func buildDingTalkUpstreamClaims(staff *DingTalkStaffInfo, unionID, corpID string) map[string]any {
primaryDeptID := int64(0)
if len(staff.DeptIDs) > 0 {
primaryDeptID = staff.DeptIDs[0]
}
return map[string]any{
"email": staff.Email,
"username": staff.Name,
"nickname": staff.Nickname,
"subject": unionID, // 与 identityKey.ProviderSubject 保持一致(全局唯一 unionID)
"corp_user_id": staff.UserID, // 企业 userid(跨组织时为空),保留作独立字段用于 audit
"union_id": unionID,
"corp_id": corpID,
"primary_dept_id": primaryDeptID, // 首个部门 ID,用于 internal_only 同步路径
}
}
func checkDingTalkCorpAllowed(cfg config.DingTalkConnectConfig, corpID string) bool {
switch cfg.CorpRestrictionPolicy {
case "internal_only":
// 方案 A:完全跳过 corpID 字段校验,由 step 3 `GetUserIdByUnionId` 做真实判定。
// 原因:钉钉 /v1.0/oauth2/userAccessToken 在部分授权场景(扫码登录、非企业工作台入口)
// 不会返回 corpId 字段。而 step 3 用本企业 appToken 查 unionId→userId 映射,
// 跨企业用户会被钉钉拒绝(错误码 60011/60121),mapDingTalkErrorCode 已将其映射回 "corp_rejected"。
// AppType=internal 已由 ValidateDingTalkConfig 强制保证应用属性。
return true
case "none", "":
return true
default:
return false
}
}
// decideDingTalkStep34Strategy 根据 policy 和 Step 3/4 运行时错误决定处理方式。
// 返回 (proceed bool, fatal bool):
// - proceed=true:继续处理(step 成功或降级)
// - fatal=true:应 hard fail(upstream_error)
//
// 此 helper 从主链中提取,便于 unit test 独立验证策略决策逻辑。
func decideDingTalkStep34Strategy(policy string, stepErr error) (shouldFallback bool, isFatal bool) {
if stepErr == nil {
return false, false // 成功,不需要降级
}
switch policy {
case "internal_only":
return false, true // hard fail:同企业 Step 3/4 必须成功
case "none", "":
return true, false // 降级:公网场景跨组织用户失败属正常预期
default:
return false, true // 未知 policy,视为 hard fail
}
}
// mapDingTalkErrorCode 把 DingTalkAPIError 映射到 redirectOAuthError 用的字符串 code
func mapDingTalkErrorCode(err error) string {
var apiErr *DingTalkAPIError
if !errors.As(err, &apiErr) {
return "upstream_error"
}
switch apiErr.Code {
case "60011", "60121":
return "corp_rejected"
case "40014", "50015", "88":
return "upstream_error"
default:
return "upstream_error"
}
}
// dingTalkClient 构造或返回缓存的 client 实例(h-level 单例)。
// 若 cfg 关键字段(ClientID/ClientSecret/TokenURL/UserInfoURL)与已缓存实例不一致,
// 则丢弃旧实例(含 appToken 缓存)并重建,避免管理员改配置后旧凭据持续生效。
func (h *AuthHandler) dingTalkClient(cfg config.DingTalkConnectConfig) *DingTalkClient {
h.dingTalkClientMu.Lock()
defer h.dingTalkClientMu.Unlock()
newCfg := dingTalkClientConfig{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
TokenURL: cfg.TokenURL,
UserInfoURL: cfg.UserInfoURL,
}
if h.dingTalkClientInstance == nil || h.dingTalkClientInstance.cfg != newCfg {
h.dingTalkClientInstance = &DingTalkClient{
cfg: newCfg,
// 与 wechat OAuth client 对齐,避免上游网络抖动时请求悬挂。
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
return h.dingTalkClientInstance
}
// ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
// buildDingTalkAuthorizeURL 根据配置和 state 构建钉钉 OAuth 授权 URL。
func buildDingTalkAuthorizeURL(cfg config.DingTalkConnectConfig, state string) (string, error) {
base := strings.TrimSpace(cfg.AuthorizeURL)
if base == "" {
return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_EMPTY", "dingtalk authorize_url not configured")
}
redirectURI := strings.TrimSpace(cfg.RedirectURL)
if redirectURI == "" {
return "", infraerrors.InternalServer("DINGTALK_REDIRECT_URL_EMPTY", "dingtalk redirect_url not configured")
}
u, err := url.Parse(base)
if err != nil {
return "", infraerrors.InternalServer("DINGTALK_AUTHORIZE_URL_PARSE_FAILED", "failed to parse dingtalk authorize_url").WithCause(err)
}
scopes := strings.TrimSpace(cfg.Scopes)
if scopes == "" {
scopes = "openid"
}
q := u.Query()
q.Set("client_id", cfg.ClientID)
q.Set("redirect_uri", redirectURI)
q.Set("response_type", "code")
q.Set("scope", scopes)
q.Set("state", state)
q.Set("prompt", "consent")
u.RawQuery = q.Encode()
return u.String(), nil
}
// ─── Complete Registration ─────────────────────────────────────────────────
type completeDingTalkOAuthRequest struct {
InvitationCode string `json:"invitation_code" binding:"required"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
// CompleteDingTalkOAuthRegistration completes a pending OAuth registration by validating
// the invitation code and creating the user account.
// POST /api/v1/auth/oauth/dingtalk/complete-registration
func (h *AuthHandler) CompleteDingTalkOAuthRegistration(c *gin.Context) {
var req completeDingTalkOAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "INVALID_REQUEST", "message": err.Error()})
return
}
secureCookie := isRequestHTTPS(c)
sessionToken, err := readOAuthPendingSessionCookie(c)
if err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, service.ErrPendingAuthSessionNotFound)
return
}
browserSessionKey, err := readOAuthPendingBrowserCookie(c)
if err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, service.ErrPendingAuthBrowserMismatch)
return
}
pendingSvc, err := h.pendingIdentityService()
if err != nil {
response.ErrorFrom(c, err)
return
}
session, err := pendingSvc.GetBrowserSession(c.Request.Context(), sessionToken, browserSessionKey)
if err != nil {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
response.ErrorFrom(c, err)
return
}
if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
response.ErrorFrom(c, err)
return
}
if updatedSession, handled, err := h.legacyCompleteRegistrationSessionStatus(c, session); err != nil {
response.ErrorFrom(c, err)
return
} else if handled {
c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(updatedSession))
return
} else {
session = updatedSession
}
if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
response.ErrorFrom(c, err)
return
}
email := strings.TrimSpace(session.ResolvedEmail)
username := pendingSessionStringValue(session.UpstreamIdentityClaims, "username")
// E: username 空时退到 email local part(跨组织用户没拿到 staff.Name 也能注册)
if username == "" {
if at := strings.Index(email, "@"); at > 0 {
username = email[:at]
} else {
username = email
}
}
if email == "" || username == "" {
response.ErrorFrom(c, infraerrors.BadRequest("PENDING_AUTH_SESSION_INVALID", "pending auth registration context is invalid"))
return
}
client := h.entClient()
if client == nil {
response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
return
}
if err := ensurePendingOAuthRegistrationIdentityAvailable(c.Request.Context(), client, session); err != nil {
respondPendingOAuthBindingApplyError(c, err)
return
}
decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{
AdoptDisplayName: req.AdoptDisplayName,
AdoptAvatar: req.AdoptAvatar,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.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 返回后会被取消),但保留其 values(trace/request id)。
// 固定 30s 超时上限,防止 goroutine 因上游卡顿无限挂起。
func runDingTalkSyncAsync(parent context.Context, fn func(ctx context.Context)) {
base := context.WithoutCancel(parent)
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("dingtalk sync: panic recovered", "panic", r)
}
}()
ctx, cancel := context.WithTimeout(base, 30*time.Second)
defer cancel()
fn(ctx)
}()
}
// syncDingTalkIdentity 在 internal_only 模式下,按三个 sync 开关把钉钉身份信息
// 同步到用户属性表(以及 users.username)。
// 任何错误仅记日志,不中断登录流程(最终一致性)。
func (h *AuthHandler) syncDingTalkIdentity(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, staff *DingTalkStaffInfo, syncUsername bool) {
slog.Info("dingtalk sync: entry",
"user_id", userID,
"policy", cfg.CorpRestrictionPolicy,
"sync_corp_email", cfg.SyncCorpEmail,
"sync_display_name", cfg.SyncDisplayName,
"sync_dept", cfg.SyncDept,
"sync_username", syncUsername,
"attr_key_email", cfg.SyncCorpEmailAttrKey,
"attr_key_name", cfg.SyncDisplayNameAttrKey,
"attr_key_dept", cfg.SyncDeptAttrKey,
"staff_nil", staff == nil,
)
if cfg.CorpRestrictionPolicy != "internal_only" || staff == nil {
slog.Info("dingtalk sync: skip, not internal_only or staff nil")
return
}
slog.Info("dingtalk sync: staff snapshot",
"name", staff.Name, "email", staff.Email, "dept_ids", staff.DeptIDs,
)
if !cfg.SyncCorpEmail && !cfg.SyncDisplayName && !cfg.SyncDept {
slog.Info("dingtalk sync: skip, all flags disabled")
return
}
if h.userAttributeService == nil {
slog.Warn("dingtalk sync: userAttributeService not available, skipping")
return
}
// 仅首次注册时覆盖 users.username(避免每次登录覆盖用户后续手动改过的名字)。
// dingtalk_name 属性下面单独每次写入企业 name,不受此条件影响。
if syncUsername && cfg.SyncDisplayName {
username := strings.TrimSpace(staff.Nickname)
source := "nickname"
if username == "" {
username = strings.TrimSpace(staff.Name)
source = "name(fallback)"
}
if username != "" && h.userService != nil {
if _, err := h.userService.UpdateProfile(ctx, userID, service.UpdateProfileRequest{Username: &username}); err != nil {
slog.Warn("dingtalk sync: failed to update username", "user_id", userID, "err", err)
} else {
slog.Info("dingtalk sync: username updated (register)", "user_id", userID, "username", username, "source", source)
}
}
}
// 属性同步(目标 attr key 从 cfg 读取,默认值由 GetDingTalkConnectOAuthConfig 保证非空)
type syncField struct {
key string
value string
}
var fields []syncField
if cfg.SyncDisplayName && strings.TrimSpace(staff.Name) != "" {
fields = append(fields, syncField{cfg.SyncDisplayNameAttrKey, strings.TrimSpace(staff.Name)})
}
if cfg.SyncCorpEmail && strings.TrimSpace(staff.Email) != "" {
fields = append(fields, syncField{cfg.SyncCorpEmailAttrKey, strings.TrimSpace(staff.Email)})
}
if cfg.SyncDept && len(staff.DeptIDs) > 0 {
// 跳过根部门 ID=1,找第一个真实子部门;都是根则保留 1(最终写入空字符串覆盖旧值)。
primaryDeptID := int64(0)
for _, id := range staff.DeptIDs {
if id > 1 {
primaryDeptID = id
break
}
}
if primaryDeptID == 0 {
primaryDeptID = staff.DeptIDs[0]
}
slog.Info("dingtalk sync: pick primary dept", "user_id", userID, "all_dept_ids", staff.DeptIDs, "primary", primaryDeptID)
path, err := h.resolveDingTalkDeptPath(ctx, client, primaryDeptID)
if err != nil {
slog.Warn("dingtalk sync: failed to resolve dept path", "user_id", userID, "dept_id", primaryDeptID, "err", err)
} else {
// path="" 表示公司直属(仅在根部门下),仍写入空串覆盖旧值。
fields = append(fields, syncField{cfg.SyncDeptAttrKey, path})
}
}
if len(fields) == 0 {
return
}
// 逐 key 查 definition 并 upsert
for _, f := range fields {
if err := h.setUserAttributeByKey(ctx, userID, f.key, f.value); err != nil {
slog.Warn("dingtalk sync: failed to set attribute", "user_id", userID, "key", f.key, "err", err)
}
}
}
// syncDingTalkIdentityFromClaims 从 upstreamClaims 恢复 DingTalkStaffInfo 并调用 syncDingTalkIdentity。
// 用于 pending session 完成阶段(complete-registration / create-account / bind-login)。
// syncUsername=true 表示首次注册场景,需要把 nickname 写入 users.username。
func (h *AuthHandler) syncDingTalkIdentityFromClaims(ctx context.Context, cfg config.DingTalkConnectConfig, client *DingTalkClient, userID int64, claims map[string]any, syncUsername bool) {
staff := dingTalkStaffFromClaims(claims)
h.syncDingTalkIdentity(ctx, cfg, client, userID, staff, syncUsername)
}
// maybeSyncDingTalkAfterRegistration 在通用 OAuth 注册路径完成后调用。
// 同步 4 个字段:users.username(首次) + dingtalk_name/email/department(每次)。
func (h *AuthHandler) maybeSyncDingTalkAfterRegistration(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
h.dispatchDingTalkPendingSync(ctx, session, userID, true)
}
// maybeSyncDingTalkAfterLogin 在通用 OAuth 登录/绑定路径完成后调用。
// 仅刷新 3 个属性(dingtalk_name/email/department),不动 users.username。
func (h *AuthHandler) maybeSyncDingTalkAfterLogin(ctx context.Context, session *dbent.PendingAuthSession, userID int64) {
h.dispatchDingTalkPendingSync(ctx, session, userID, false)
}
func (h *AuthHandler) dispatchDingTalkPendingSync(ctx context.Context, session *dbent.PendingAuthSession, userID int64, syncUsername bool) {
if session == nil || userID <= 0 {
return
}
if !strings.EqualFold(strings.TrimSpace(session.ProviderType), "dingtalk") {
return
}
cfg, err := h.getDingTalkOAuthConfig(ctx)
if err != nil {
slog.Debug("dingtalk sync: skip post-login sync, config unavailable", "user_id", userID, "err", err.Error())
return
}
client := h.dingTalkClient(cfg)
claims := session.UpstreamIdentityClaims
// 异步执行避免阻塞 token 响应。
runDingTalkSyncAsync(ctx, func(asyncCtx context.Context) {
h.syncDingTalkIdentityFromClaims(asyncCtx, cfg, client, userID, claims, syncUsername)
})
}
// dingTalkStaffFromClaims 从 upstreamClaims 重建最小 DingTalkStaffInfo。
func dingTalkStaffFromClaims(claims map[string]any) *DingTalkStaffInfo {
if claims == nil {
return &DingTalkStaffInfo{}
}
staff := &DingTalkStaffInfo{}
if v, ok := claims["username"].(string); ok {
staff.Name = v
}
if v, ok := claims["nickname"].(string); ok {
staff.Nickname = v
}
if v, ok := claims["email"].(string); ok {
staff.Email = v
}
if v, ok := claims["corp_user_id"].(string); ok {
staff.UserID = v
}
// primary_dept_id 存为 int64 或 float64(JSON round-trip)
switch v := claims["primary_dept_id"].(type) {
case int64:
if v > 0 {
staff.DeptIDs = []int64{v}
}
case float64:
if id := int64(v); id > 0 {
staff.DeptIDs = []int64{id}
}
}
return staff
}
// setUserAttributeByKey 按 attribute key 查找 definition,再 upsert 用户属性值。
// definition 不存在时记 warn 日志跳过(admin 在 settings 保存时已按需 upsert
// 对应 def;缺失意味着 admin 改了 attr key 但未保存 settings,或 def 被手工删除)。
func (h *AuthHandler) setUserAttributeByKey(ctx context.Context, userID int64, key, value string) error {
def, err := h.userAttributeService.GetDefinitionByKey(ctx, key)
if err != nil {
slog.Warn("dingtalk sync: attribute definition not found, skipping", "key", key, "err", err.Error())
return nil
}
if err := h.userAttributeService.UpdateUserAttributes(ctx, userID, []service.UpdateUserAttributeInput{
{AttributeID: def.ID, Value: value},
}); err != nil {
return err
}
slog.Info("dingtalk sync: attribute upserted", "user_id", userID, "key", key, "attr_id", def.ID)
return nil
}
// resolveDingTalkDeptPath 从叶部门递归向上拼 "公司/部门/子部门" 路径字符串。
// 遇 dept_id=1(根)或 parent_id=0 停止。加 visited set 防循环,最多 50 层。
func (h *AuthHandler) resolveDingTalkDeptPath(ctx context.Context, client *DingTalkClient, deptID int64) (string, error) {
slog.Info("dingtalk sync: resolve dept path start", "dept_id", deptID)
const maxDepth = 50
visited := make(map[int64]bool, maxDepth)
var parts []string
current := deptID
for i := 0; i < maxDepth; i++ {
if current < 1 || visited[current] {
break
}
visited[current] = true
info, err := client.GetDeptInfo(ctx, current)
if err != nil {
return "", fmt.Errorf("get dept info %d: %w", current, err)
}
if strings.TrimSpace(info.Name) != "" {
parts = append([]string{strings.TrimSpace(info.Name)}, parts...)
}
// 钉钉根部门 dept_id=1,ParentID 通常为 0;遇到 0 / self 终止避免循环。
if info.ParentID < 1 || info.ParentID == current {
break
}
current = info.ParentID
}
// 去除根组织名(parts[0] 始终是企业全称),仅保留部门层级。
// 例:["公司","A","B"] → "A/B";["公司"] → ""(公司直属)。
if len(parts) > 0 {
parts = parts[1:]
}
return strings.Join(parts, "/"), nil
}