sub2api/backend/internal/handler/auth_dingtalk_client.go
DaydreamCoding b19da9c7fe feat(dingtalk): 钉钉 OAuth 登录接入与 internal_only 用户属性同步
⚠️ 应用类型约束:当前实现仅支持「钉钉登录-企业内部应用」(DingTalk 开放平台
internal_app 类型)。第三方个人应用、第三方企业应用类型暂不支持——OAuth 流程
相同但 corp 校验、跨企业行为不同。backend 通过 DingTalkAppKind 校验对非
internal_app 类型 fail-closed(硬约束)。

钉钉 OAuth 登录主链
- 4 步 OAuth 链:ExchangeCodeForUserToken / GetUnionIdByUserToken /
  GetUserIdByUnionId / GetStaffInfoByUserId;app token 缓存
- pending session 机制持久化 OAuth 中间态;cookie-only token 持久化
- 三种分流:bind_login_required / email_completion / choose_account_action
- corp_restriction_policy 支持 none + internal_only;stale "whitelist" 在
  加载层与写入层均静默 coerce 为 none + slog.Warn
- bypass_registration 开关:企业内部模式豁免全局 REGISTRATION_DISABLED
- isReservedEmail / signup_source / canUnbindProvider / OAuth pending flow
  等横切点支持 dingtalk provider
- migration 136:4 表 CHECK 约束加入 'dingtalk' provider 值

internal_only 模式同步企业邮箱/姓名/部门到用户属性
- SyncCorpEmail / SyncDisplayName / SyncDept 三个独立开关 + 对应
  SyncXxxAttrKey 目标属性 key(默认 dingtalk_email / dingtalk_name /
  dingtalk_department);非 internal_only policy 在写入层与加载层均
  coerce 为 false,admin handler 与 setting_service 双层兜底
- 同步语义:首次注册写 users.username(昵称优先 → 企业姓名 fallback),
  之后每次登录刷新 3 个属性;空值也写入以覆盖旧值
- 邮箱三级 fallback:org_email > email > extension["企业邮箱"]
  (钉钉自定义字段 JSON)
- 部门路径递归向上拼接,跳过 dept_id=1 选首个真实子部门,剥离根组织名
- GetUnionIdByUserToken 同时返回 OIDC /contact/users/me 的 nick 字段;
  新增 GetDeptInfo 调用 OAPI /topapi/v2/department/get
- AuthHandler 注入 UserAttributeService;OAuth pending flow 在
  createPendingOAuthAccount / bindPendingOAuthLogin 分别派发到
  AfterRegistration(syncUsername=true)/ AfterLogin
- migration 137 seed dingtalk_email/name/department 三个用户属性定义

附带修复(同集成路径暴露的两个 OAuth 注册回归)
- LoginOrRegisterOAuthWithTokenPair 新建用户分支用 inferLegacySignupSource
  覆写 caller 显式传入的 signupSource,导致 dingtalk/linuxdo/oidc/wechat
  渠道授权按 email 渠道读取;改为只在 caller 未显式传入时回退邮箱推断
- mergeProviderDefaultGrantSettings 把 parse fallback 默认值
  (Concurrency=5 / Balance=0) 当作"未配置"哨兵,admin 显式设 5 时被误判
  退回全局默认(复现:全局默认 1 + 渠道默认并发 5 + grant_on_signup → 新
  用户实际 concurrency=1);去掉哨兵,admin 任何 >=0 值都覆盖 globalDefaults

前端
- DingTalk Login / Callback / EmailCompletion / ChoiceAccount / Error
  视图;router + auth API client
- admin SettingsView:corp policy radio(none / internal_only)+ bypass
  注册开关 + i18n;internal_only 下展示三同步开关 + 目标 attr key 下拉
  (拉取 user attribute definitions),展示 fieldEmail /
  qyapi_get_department_list 钉钉权限申请提示
- Profile:S1 主动绑定 / S5 解绑钉钉按钮 + 合成邮箱防自锁

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 15:27:47 +08:00

399 lines
12 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// dingTalkClientConfig 是 DingTalkClient 需要的最小配置子集
type dingTalkClientConfig struct {
ClientID string
ClientSecret string
TokenURL string
UserInfoURL string
}
type DingTalkClient struct {
cfg dingTalkClientConfig
appToken string
appTokenExp time.Time // 钉钉 7200s留 200s 余量 → 7000s
mu sync.Mutex
httpClient *http.Client
// TODO(multi-instance): Redis 集中缓存 appToken
}
type DingTalkUserTokenResp struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpireIn int64 `json:"expireIn"`
CorpID string `json:"corpId"`
}
func (c *DingTalkClient) ExchangeCodeForUserToken(ctx context.Context, code string) (*DingTalkUserTokenResp, error) {
body := map[string]string{
"clientId": c.cfg.ClientID,
"clientSecret": c.cfg.ClientSecret,
"code": code,
"grantType": "authorization_code",
}
payload, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.TokenURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var out DingTalkUserTokenResp
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
if strings.TrimSpace(out.AccessToken) == "" {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
return &out, nil
}
type DingTalkAPIError struct {
Code string
Message string
HTTP int
}
func (e *DingTalkAPIError) Error() string {
return fmt.Sprintf("dingtalk api error code=%s msg=%s http=%d", e.Code, e.Message, e.HTTP)
}
func parseDingTalkErr(raw []byte, status int) error {
var v struct {
Code string `json:"code"`
Message string `json:"message"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
_ = json.Unmarshal(raw, &v)
code := v.Code
if code == "" && v.ErrCode != 0 {
code = fmt.Sprintf("%d", v.ErrCode)
}
msg := v.Message
if msg == "" {
msg = v.ErrMsg
}
return &DingTalkAPIError{Code: code, Message: msg, HTTP: status}
}
// GetUnionIdByUserToken 调用 /v1.0/contact/users/me 返回 unionId 与用户自设昵称 nick。
// nick 来自钉钉新版 OIDC 接口(用户在 App 个人资料填的昵称),与旧版 user/get.nickname 不同源。
func (c *DingTalkClient) GetUnionIdByUserToken(ctx context.Context, userToken string) (unionID string, nick string, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.cfg.UserInfoURL, nil)
if err != nil {
return "", "", err
}
req.Header.Set("x-acs-dingtalk-access-token", userToken)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
UnionID string `json:"unionId"`
Nick string `json:"nick"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", "", err
}
if strings.TrimSpace(v.UnionID) == "" {
return "", "", parseDingTalkErr(raw, resp.StatusCode)
}
return v.UnionID, v.Nick, nil
}
type DingTalkStaffInfo struct {
UserID string
Name string // 企业内真实姓名(钉钉企业管理后台配置)
Nickname string // 钉钉个人昵称(用户自己设置)
Email string
DeptIDs []int64
// CorpID 不来自 staff 接口,来自 userToken不在此 struct
}
// dingTalkOAPIBase 推导钉钉旧版 OAPI base URLhost: api.dingtalk.com → oapi.dingtalk.com
// getbyunionid 与 topapi/v2/user/get 仅在旧版 OAPI 提供,不在 v1.0 OpenAPI。
func (c *DingTalkClient) dingTalkOAPIBase() string {
u, err := url.Parse(c.cfg.UserInfoURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "https://oapi.dingtalk.com"
}
host := u.Host
if strings.HasPrefix(host, "api.") {
host = "oapi." + strings.TrimPrefix(host, "api.")
}
return u.Scheme + "://" + host
}
func (c *DingTalkClient) GetAppToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.appToken != "" && time.Now().Before(c.appTokenExp) {
return c.appToken, nil
}
body := map[string]string{"appKey": c.cfg.ClientID, "appSecret": c.cfg.ClientSecret}
payload, _ := json.Marshal(body)
// 钉钉新版 v1.0 企业内部应用 access_token: POST /v1.0/oauth2/accessToken
// 此 token 也可作为旧版 OAPI 的 access_token 使用(钉钉文档已说明)
appTokenURL := strings.Replace(c.cfg.TokenURL, "/oauth2/userAccessToken", "/oauth2/accessToken", 1)
if !strings.Contains(appTokenURL, "accessToken") && !strings.Contains(appTokenURL, "gettoken") {
appTokenURL = c.cfg.TokenURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, appTokenURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", err
}
if v.AccessToken == "" {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
c.appToken = v.AccessToken
ttl := v.ExpireIn
if ttl > 200 {
ttl -= 200
}
c.appTokenExp = time.Now().Add(time.Duration(ttl) * time.Second)
return c.appToken, nil
}
func (c *DingTalkClient) GetUserIdByUnionId(ctx context.Context, unionID string) (string, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return "", err
}
body := map[string]string{"unionid": unionID}
payload, _ := json.Marshal(body)
// 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=XXX
// access_token 通过 query string 传递(不是 header
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/user/getbyunionid?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
UserID string `json:"userid"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return "", err
}
if v.ErrCode != 0 {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
if strings.TrimSpace(v.Result.UserID) == "" {
return "", parseDingTalkErr(raw, resp.StatusCode)
}
return v.Result.UserID, nil
}
// DingTalkDeptInfo 部门信息topapi/v2/department/get 返回子集)
type DingTalkDeptInfo struct {
DeptID int64
Name string
ParentID int64
}
// GetDeptInfo 查询单个部门信息(用于递归拼部门路径)。
// 调用钉钉旧版 OAPI: POST /topapi/v2/department/get?access_token=XXX
func (c *DingTalkClient) GetDeptInfo(ctx context.Context, deptID int64) (*DingTalkDeptInfo, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return nil, err
}
body := map[string]any{"dept_id": deptID, "language": "zh_CN"}
payload, _ := json.Marshal(body)
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/v2/department/get?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // test stub fallback
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
DeptID int64 `json:"dept_id"`
Name string `json:"name"`
ParentID int64 `json:"parent_id"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v.ErrCode != 0 {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
return &DingTalkDeptInfo{
DeptID: v.Result.DeptID,
Name: v.Result.Name,
ParentID: v.Result.ParentID,
}, nil
}
func (c *DingTalkClient) GetStaffInfoByUserId(ctx context.Context, userID string) (*DingTalkStaffInfo, error) {
appToken, err := c.GetAppToken(ctx)
if err != nil {
return nil, err
}
body := map[string]string{"userid": userID}
payload, _ := json.Marshal(body)
// 钉钉旧版 OAPI: POST https://oapi.dingtalk.com/topapi/v2/user/get?access_token=XXX
var targetURL string
if strings.Contains(c.cfg.UserInfoURL, "/contact/users/me") {
targetURL = c.dingTalkOAPIBase() + "/topapi/v2/user/get?access_token=" + url.QueryEscape(appToken)
} else {
targetURL = c.cfg.UserInfoURL // fallback for test stub
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
var v struct {
Result struct {
UserID string `json:"userid"`
Name string `json:"name"`
Nickname string `json:"nickname"`
Email string `json:"email"`
OrgEmail string `json:"org_email"`
Extension string `json:"extension"`
DeptID []int64 `json:"dept_id_list"`
} `json:"result"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
if v.ErrCode != 0 {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
if strings.TrimSpace(v.Result.UserID) == "" {
return nil, parseDingTalkErr(raw, resp.StatusCode)
}
// 邮箱三级 fallbackorg_email > email > extension["企业邮箱"]钉钉自定义扩展字段JSON string
email := strings.TrimSpace(v.Result.OrgEmail)
emailSource := "org_email"
if email == "" {
email = strings.TrimSpace(v.Result.Email)
emailSource = "email"
}
extensionParsed := false
if email == "" && strings.TrimSpace(v.Result.Extension) != "" {
var ext map[string]string
if err := json.Unmarshal([]byte(v.Result.Extension), &ext); err == nil {
extensionParsed = true
if v, ok := ext["企业邮箱"]; ok {
email = strings.TrimSpace(v)
emailSource = "extension.企业邮箱"
}
}
}
if email == "" {
emailSource = "none"
}
slog.Info("dingtalk staff fetched",
"userid", v.Result.UserID,
"name_present", v.Result.Name != "",
"nickname_present", v.Result.Nickname != "",
"name_eq_nickname", v.Result.Name != "" && v.Result.Name == v.Result.Nickname,
"email_present", v.Result.Email != "",
"org_email_present", v.Result.OrgEmail != "",
"extension_present", v.Result.Extension != "",
"extension_parsed", extensionParsed,
"email_source", emailSource,
"dept_count", len(v.Result.DeptID),
)
return &DingTalkStaffInfo{
UserID: v.Result.UserID,
Name: v.Result.Name,
Nickname: v.Result.Nickname,
Email: email,
DeptIDs: v.Result.DeptID,
}, nil
}