From 3715badf34ac5c6f903867103cb11b76a72cce7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=97=E5=87=87?= Date: Thu, 25 Jun 2026 20:14:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BC=81=E4=B8=9A=E5=87=AD?= =?UTF-8?q?=E8=AF=81hook&=E5=AF=B9=E5=BA=94=E5=87=AD=E8=AF=81=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=A3=80=E9=AA=8C=E6=9C=AA=E9=80=9A=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E6=8A=A5=E9=94=99=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/runner.go | 3 + internal/auth/classify_denial_reason_test.go | 7 ++ internal/auth/device_flow.go | 8 ++ internal/auth/edition_headers.go | 42 +++++++ internal/auth/oauth_helpers.go | 115 +++++++++++++++++++ internal/auth/oauth_provider.go | 18 ++- pkg/edition/edition.go | 3 + 7 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 internal/auth/edition_headers.go diff --git a/internal/app/runner.go b/internal/app/runner.go index 65c0b5cb..df7d269d 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -699,6 +699,9 @@ func resolveIdentityHeaders() map[string]string { if fn := edition.Get().MergeHeaders; fn != nil { headers = fn(headers) } + if fn := edition.Get().EnterpriseCredentialHeaders; fn != nil { + headers = fn(headers) + } return headers } diff --git a/internal/auth/classify_denial_reason_test.go b/internal/auth/classify_denial_reason_test.go index 3fde4344..9212de6a 100644 --- a/internal/auth/classify_denial_reason_test.go +++ b/internal/auth/classify_denial_reason_test.go @@ -29,6 +29,13 @@ func TestClassifyDenialReason(t *testing.T) { }, want: "channel_required", }, + { + name: "error ENTERPRISE_NOT_AUTHORIZED", + status: &CLIAuthStatus{ + ErrorCode: "ENTERPRISE_NOT_AUTHORIZED", + }, + want: "enterprise_not_authorized", + }, { name: "error NO_AUTH", status: &CLIAuthStatus{ diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index 031d8b7a..e2dddc74 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -267,6 +267,14 @@ func (p *DeviceFlowProvider) loginOnce(ctx context.Context, attempt int) (*Token _, _ = fmt.Fprintln(p.output(), i18n.T(" 请升级到最新版本的 CLI 后重试。")) _, _ = fmt.Fprintln(p.output(), "") return nil, errors.New(i18n.T("当前组织已开启渠道管控,请升级到最新版本的 CLI 后重试")) + case "enterprise_not_authorized": + msg := i18n.T("本次请求未通过企业安全认证") + if authStatus != nil && strings.TrimSpace(authStatus.ErrorMsg) != "" { + msg = strings.TrimSpace(authStatus.ErrorMsg) + } + _, _ = fmt.Fprintln(p.output(), dfRed("⚠️ "+msg)) + _, _ = fmt.Fprintln(p.output(), "") + return nil, errors.New(msg) case "no_auth": _, _ = fmt.Fprintln(p.output(), dfRed(i18n.T("⚠️ 认证已失效"))) _, _ = fmt.Fprintln(p.output(), i18n.T(" 请执行 dws auth 重新登录。")) diff --git a/internal/auth/edition_headers.go b/internal/auth/edition_headers.go new file mode 100644 index 00000000..94a35c5f --- /dev/null +++ b/internal/auth/edition_headers.go @@ -0,0 +1,42 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "net/http" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +// applyEditionEnterpriseCredentialHeaders injects overlay-provided enterprise +// credential headers (e.g. x-dws-enterprise-credential) into MCP control-plane +// and OAuth proxy requests. +func applyEditionEnterpriseCredentialHeaders(req *http.Request) { + if req == nil { + return + } + fn := edition.Get().EnterpriseCredentialHeaders + if fn == nil { + return + } + merged := fn(nil) + for k, v := range merged { + k = strings.TrimSpace(k) + v = strings.TrimSpace(v) + if k != "" && v != "" { + req.Header.Set(k, v) + } + } +} diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 372b353a..1242b0e8 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -18,11 +18,13 @@ import ( "context" "encoding/json" "fmt" + "html" "io" "net/http" "net/url" "os" "slices" + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" @@ -203,6 +205,7 @@ func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("Content-Type", "application/json") + applyEditionEnterpriseCredentialHeaders(req) client := p.httpClient if client == nil { @@ -1118,6 +1121,112 @@ const channelDeniedHTML = ` ` +const enterpriseDeniedHTML = ` + + + + + 钉钉 CLI + + + +
+ lock icon +

企业安全认证未通过

+

__ENTERPRISE_DENIED_MSG__

+
+ +` + +// defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg. +const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证" + +// renderEnterpriseDeniedHTML injects the server-provided denial message (falling +// back to the default text) into the enterprise-denied page. The message is +// HTML-escaped before insertion. +func renderEnterpriseDeniedHTML(serverMsg string) string { + msg := strings.TrimSpace(serverMsg) + if msg == "" { + msg = defaultEnterpriseDeniedMsg + } + return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。") +} + // CLIAuthStatus represents the response from /cli/cliAuthEnabled API. type CLIAuthStatus struct { Success bool `json:"success"` @@ -1154,6 +1263,9 @@ func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string { if status.ErrorCode == "CHANNEL_REQUIRED" { return "channel_required" } + if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" { + return "enterprise_not_authorized" + } if status.ErrorCode == "NO_AUTH" { return "no_auth" } @@ -1243,6 +1355,7 @@ func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken s if ch := os.Getenv("DWS_CHANNEL"); ch != "" { req.Header.Set("x-dws-channel", ch) } + applyEditionEnterpriseCredentialHeaders(req) client := p.httpClient if client == nil { @@ -1294,6 +1407,7 @@ func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminRespo return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("x-user-access-token", accessToken) + applyEditionEnterpriseCredentialHeaders(req) resp, err := oauthHTTPClient.Do(req) if err != nil { @@ -1341,6 +1455,7 @@ func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) ( return nil, fmt.Errorf("creating request: %w", err) } req.Header.Set("x-user-access-token", accessToken) + applyEditionEnterpriseCredentialHeaders(req) resp, err := oauthHTTPClient.Do(req) if err != nil { diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 26707552..85aeeb0b 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -23,6 +23,7 @@ import ( "net" "net/http" "os" + "strings" "sync" "time" @@ -141,6 +142,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro err error cliAuthDisabled bool denialReason string + errorMsg string // server-provided errorMsg from /cli/cliAuthEnabled } resultCh := make(chan callbackResult, 1) errCh := make(chan error, 1) @@ -261,6 +263,13 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } cliAuthEnabled := denialReason == "" + // Server-provided errorMsg (nil-safe), surfaced both on the page and to + // the terminal so portal can update copy without releasing the CLI. + serverMsg := "" + if authStatus != nil { + serverMsg = authStatus.ErrorMsg + } + // Update CLI auth disabled state callbackTokenMu.Lock() callbackAuthDisabled = !cliAuthEnabled @@ -275,6 +284,8 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro _, _ = fmt.Fprint(w, accessDeniedHTML) case denialReason == "channel_not_allowed" || denialReason == "channel_required": _, _ = fmt.Fprint(w, channelDeniedHTML) + case denialReason == "enterprise_not_authorized": + _, _ = fmt.Fprint(w, renderEnterpriseDeniedHTML(serverMsg)) default: _, _ = fmt.Fprint(w, notEnabledHTML) } @@ -284,7 +295,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro } // Notify main goroutine with full result select { - case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason}: + case resultCh <- callbackResult{token: tokenData, cliAuthDisabled: !cliAuthEnabled, denialReason: denialReason, errorMsg: serverMsg}: default: } }) @@ -433,6 +444,11 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro return nil, errors.New(i18n.T("您不在该组织的 CLI 授权人员范围内,请联系组织管理员将您加入授权名单")) case "channel_not_allowed", "channel_required": return nil, errors.New(i18n.T("当前渠道未获得该组织授权,或组织已开启渠道管控,请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI")) + case "enterprise_not_authorized": + if msg := strings.TrimSpace(result.errorMsg); msg != "" { + return nil, errors.New(msg) + } + return nil, errors.New(i18n.T("本次请求未通过企业安全认证")) } _, _ = fmt.Fprintln(p.output(), "") diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index 0db286d3..6b7cbb88 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -79,6 +79,9 @@ type Hooks struct { // --- HTTP headers --- MergeHeaders func(base map[string]string) map[string]string + // --- EnterpriseCredential HTTP headers --- + EnterpriseCredentialHeaders func(base map[string]string) map[string]string + // --- auth --- AuthClientID string // OAuth client ID for device-flow authorisation AuthClientFromMCP bool // true → fetch client ID from MCP at runtime