821 lines
27 KiB
Go
821 lines
27 KiB
Go
// 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 app
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
|
||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||
"github.com/charmbracelet/huh"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
type authLoginConfig struct {
|
||
Token string
|
||
Force bool
|
||
Device bool
|
||
Recommend bool
|
||
}
|
||
|
||
func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "auth",
|
||
Short: "认证管理",
|
||
Long: "管理钉钉 CLI 的认证凭证。支持 OAuth 扫码登录和 Device Flow。",
|
||
Args: cobra.NoArgs,
|
||
TraverseChildren: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
return cmd.Help()
|
||
},
|
||
}
|
||
|
||
if !edition.Get().HideAuthLogin {
|
||
cmd.AddCommand(newAuthLoginCommand(patCaller))
|
||
}
|
||
cmd.AddCommand(
|
||
newAuthLogoutCommand(),
|
||
newAuthStatusCommand(),
|
||
newAuthExportCommand(),
|
||
newAuthImportCommand(),
|
||
newAuthExchangeCommand(),
|
||
newAuthResetCommand(),
|
||
)
|
||
return cmd
|
||
}
|
||
|
||
func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "login",
|
||
Short: "登录钉钉(自动刷新 token,必要时扫码)",
|
||
Long: `登录钉钉并获取认证凭证。
|
||
|
||
支持的登录方式:
|
||
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
|
||
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
|
||
- 直接提供 Token (--token): 跳过授权,使用已有 token
|
||
|
||
不支持的登录方式:
|
||
- 邮箱/密码登录
|
||
- 手机号/验证码登录
|
||
- 应用凭证 (AppKey/AppSecret) 直接登录
|
||
|
||
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
|
||
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
|
||
|
||
示例:
|
||
dws auth login # 本机扫码登录 (loopback 流)
|
||
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
|
||
dws auth login --force # 强制重新登录 (忽略缓存 token)
|
||
dws auth login --token xxx # 使用指定 token
|
||
dws auth login --recommend # 登录成功后打开推荐权限批量授权页`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
cfg, err := resolveAuthLoginConfig(cmd)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
configDir := defaultConfigDir()
|
||
var tokenData *authpkg.TokenData
|
||
|
||
switch {
|
||
case strings.TrimSpace(cfg.Token) != "":
|
||
tokenData = &authpkg.TokenData{
|
||
AccessToken: cfg.Token,
|
||
ExpiresAt: time.Now().Add(config.ManualTokenExpiry),
|
||
}
|
||
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to persist auth token: %v", err))
|
||
}
|
||
case cfg.Device:
|
||
loginCtx, cancel := context.WithTimeout(cmd.Context(), config.DeviceFlowTimeout)
|
||
defer cancel()
|
||
|
||
provider := authpkg.NewDeviceFlowProvider(configDir, nil)
|
||
provider.Output = cmd.ErrOrStderr()
|
||
tokenData, err = provider.Login(loginCtx)
|
||
if err != nil {
|
||
return apperrors.NewAuth(fmt.Sprintf("device authorization failed: %v", err))
|
||
}
|
||
default:
|
||
loginCtx, cancel := context.WithTimeout(cmd.Context(), config.OAuthFlowTimeout)
|
||
defer cancel()
|
||
|
||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||
provider.Output = cmd.ErrOrStderr()
|
||
configureOAuthProviderCompatibility(provider, configDir)
|
||
tokenData, err = provider.Login(loginCtx, cfg.Force)
|
||
if err != nil {
|
||
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
|
||
}
|
||
}
|
||
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
|
||
w := cmd.OutOrStdout()
|
||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||
recommendHumanMode := authLoginShouldUseRecommendHumanMode(cmd, format, cfg.Recommend)
|
||
runRecommend := func() error {
|
||
if !cfg.Recommend {
|
||
return nil
|
||
}
|
||
opts := pat.LoginRecommendOptions{}
|
||
if recommendHumanMode {
|
||
opts.ProductSelector = func(products []pat.LoginRecommendProduct) ([]string, error) {
|
||
return selectLoginRecommendProducts(products)
|
||
}
|
||
}
|
||
retryFormat := format
|
||
if recommendHumanMode {
|
||
retryFormat = "table"
|
||
}
|
||
run := func(ctx context.Context) error {
|
||
return pat.RunLoginRecommendAuthorizationWithOptions(ctx, patCaller, cmd.ErrOrStderr(), opts)
|
||
}
|
||
err := run(cmd.Context())
|
||
if patErr := apperrors.AsPatAuthCheckError(err); patErr != nil {
|
||
return runDirectPATAuthCheck(
|
||
cmd.Context(),
|
||
&GlobalFlags{Format: retryFormat},
|
||
patErr,
|
||
run,
|
||
cmd.ErrOrStderr(),
|
||
)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Check if JSON output is requested
|
||
if strings.EqualFold(strings.TrimSpace(format), "json") && !recommendHumanMode {
|
||
if err := runRecommend(); err != nil {
|
||
return err
|
||
}
|
||
return writeAuthLoginJSON(w, tokenData, cfg.Force)
|
||
}
|
||
|
||
// Default table output
|
||
fmt.Fprintln(w)
|
||
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
|
||
fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n")
|
||
} else {
|
||
fmt.Fprintf(w, "[OK] 登录成功!\n")
|
||
}
|
||
if tokenData != nil {
|
||
if tokenData.CorpName != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
|
||
}
|
||
if tokenData.CorpID != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||
}
|
||
if tokenData.UserName != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "用户:", tokenData.UserName)
|
||
}
|
||
if expiry := authLoginDisplayExpiry(tokenData); expiry != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", expiry)
|
||
}
|
||
}
|
||
fmt.Fprintf(w, "Token 将自动刷新,无需重复登录\n")
|
||
return runRecommend()
|
||
},
|
||
}
|
||
cmd.Flags().String("token", "", "Access token")
|
||
cmd.Flags().Bool("device", false, "Use device authorization flow")
|
||
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
|
||
cmd.Flags().Bool("recommend", false, "登录成功后批量授权服务端推荐的安全权限")
|
||
// Hidden compatibility flags
|
||
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
|
||
cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes")
|
||
cmd.Flags().String("authorize-url", "", "Override DingTalk authorization URL")
|
||
cmd.Flags().String("token-url", "", "Override DingTalk token exchange URL")
|
||
cmd.Flags().String("refresh-url", "", "Override DingTalk refresh token URL")
|
||
cmd.Flags().Int("login-timeout", 0, "Login timeout seconds")
|
||
cmd.Flags().Bool("no-browser", false, "Suppress browser launch")
|
||
_ = cmd.Flags().MarkHidden("redirect-url")
|
||
_ = cmd.Flags().MarkHidden("scopes")
|
||
_ = cmd.Flags().MarkHidden("authorize-url")
|
||
_ = cmd.Flags().MarkHidden("token-url")
|
||
_ = cmd.Flags().MarkHidden("refresh-url")
|
||
_ = cmd.Flags().MarkHidden("login-timeout")
|
||
_ = cmd.Flags().MarkHidden("no-browser")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthLogoutCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "logout",
|
||
Short: "清除认证信息",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
configDir := defaultConfigDir()
|
||
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||
defer cancel()
|
||
_ = authpkg.RevokeTokenRemote(revokeCtx)
|
||
|
||
// Load token data to get associated clientId before deletion
|
||
var storedClientID string
|
||
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
|
||
storedClientID = tokenData.ClientID
|
||
}
|
||
|
||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
|
||
}
|
||
// Clean up associated client secret and app token from keychain
|
||
if storedClientID != "" {
|
||
_ = authpkg.DeleteClientSecret(storedClientID)
|
||
_ = authpkg.DeleteAppTokenData(storedClientID)
|
||
}
|
||
// Also try cleaning app token using appKey from app config
|
||
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
|
||
_ = authpkg.DeleteAppTokenData(appKey)
|
||
}
|
||
// Clean up app credentials (app.json + keychain secret)
|
||
_ = authpkg.DeleteAppConfig(configDir)
|
||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||
_ = os.Remove(filepath.Join(configDir, "token.json"))
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
|
||
if !edition.Get().IsEmbedded {
|
||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func newAuthStatusCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "status",
|
||
Short: "查看认证状态",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
configDir := defaultConfigDir()
|
||
|
||
authenticated := false
|
||
refreshed := false
|
||
var tokenData *authpkg.TokenData
|
||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||
configureOAuthProviderCompatibility(provider, configDir)
|
||
if data, err := provider.Status(); err == nil {
|
||
tokenData = data
|
||
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
|
||
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
|
||
_, refreshErr := provider.GetAccessToken(refreshCtx)
|
||
cancel()
|
||
if refreshErr == nil {
|
||
if updatedData, statusErr := provider.Status(); statusErr == nil {
|
||
tokenData = updatedData
|
||
refreshed = true
|
||
}
|
||
} else if edition.Get().AutoPurgeToken {
|
||
_ = authpkg.DeleteTokenData(configDir)
|
||
}
|
||
}
|
||
if authStatusAuthenticated(tokenData) {
|
||
authenticated = true
|
||
}
|
||
}
|
||
|
||
// Check if JSON output is requested
|
||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
|
||
}
|
||
|
||
// Default table output
|
||
w := cmd.OutOrStdout()
|
||
if authenticated {
|
||
if refreshed {
|
||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||
fmt.Fprintln(w, "Token 已自动刷新")
|
||
} else {
|
||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
|
||
}
|
||
if tokenData != nil {
|
||
if tokenData.IsRefreshTokenValid() {
|
||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
|
||
} else {
|
||
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "缺失或已过期 ⚠️")
|
||
}
|
||
}
|
||
if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt)
|
||
}
|
||
} else {
|
||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||
if !edition.Get().IsEmbedded {
|
||
fmt.Fprintln(w, "运行 dws auth login 进行登录")
|
||
}
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func newAuthExportCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "export",
|
||
Short: "导出可迁移认证包",
|
||
Long: `导出包含 refresh token 与解密材料的认证包,便于在另一台 Linux 沙箱中导入。
|
||
|
||
包内包含 ~/.local/share/dws-cli 加密 keychain 与 ~/.dws 必要配置,不含 token 明文。
|
||
|
||
示例:
|
||
dws auth export -o dws-auth.tar.gz
|
||
dws auth export --base64 > dws-auth.b64`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
output, err := cmd.Flags().GetString("output")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --output")
|
||
}
|
||
asBase64, err := cmd.Flags().GetBool("base64")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --base64")
|
||
}
|
||
output = strings.TrimSpace(output)
|
||
if !asBase64 && output == "" {
|
||
return apperrors.NewValidation("--output is required unless --base64 is used")
|
||
}
|
||
if !authpkg.PortableExportSupported() {
|
||
return apperrors.NewValidation(fmt.Sprintf(
|
||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||
keychain.DisableKeychainEnv,
|
||
))
|
||
}
|
||
if !authpkg.PortableAuthSourceReady() {
|
||
return apperrors.NewValidation("尚未登录,请先运行 dws auth login")
|
||
}
|
||
|
||
var bundle bytes.Buffer
|
||
if err := authpkg.ExportPortableAuthBundle(defaultConfigDir(), &bundle); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to export auth bundle: %v", err))
|
||
}
|
||
|
||
if asBase64 {
|
||
payload := []byte(base64.StdEncoding.EncodeToString(bundle.Bytes()) + "\n")
|
||
if output == "" {
|
||
_, err := cmd.OutOrStdout().Write(payload)
|
||
return err
|
||
}
|
||
if err := helpers.AtomicWrite(output, payload, config.FilePerm); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output)
|
||
return nil
|
||
}
|
||
|
||
if err := helpers.AtomicWrite(output, bundle.Bytes(), config.FilePerm); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to write auth bundle: %v", err))
|
||
}
|
||
fmt.Fprintf(cmd.OutOrStdout(), "[OK] 已导出认证包: %s\n", output)
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "认证包含敏感凭据,用完请删除: rm -P %s\n", output)
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().StringP("output", "o", "", "认证包输出路径")
|
||
cmd.Flags().Bool("base64", false, "将认证包编码为 base64,便于复制粘贴")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthImportCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "import",
|
||
Short: "导入可迁移认证包",
|
||
Long: `从 dws auth export 生成的 tar.gz 或 base64 文件恢复认证。
|
||
|
||
导入后请运行 dws auth status 确认 refresh token 仍有效。
|
||
|
||
示例:
|
||
dws auth import -i dws-auth.tar.gz
|
||
dws auth import -i dws-auth.b64 --base64`,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
input, err := cmd.Flags().GetString("input")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --input")
|
||
}
|
||
input = strings.TrimSpace(input)
|
||
if input == "" {
|
||
return apperrors.NewValidation("--input is required")
|
||
}
|
||
asBase64, err := cmd.Flags().GetBool("base64")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --base64")
|
||
}
|
||
force, err := cmd.Flags().GetBool("force")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --force")
|
||
}
|
||
|
||
configDir := defaultConfigDir()
|
||
if !force && authpkg.PortableAuthTargetPopulated(configDir) {
|
||
return apperrors.NewValidation("检测到已有登录态,请使用 --force 确认覆盖")
|
||
}
|
||
|
||
payload, err := os.ReadFile(input)
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to read auth bundle: %v", err))
|
||
}
|
||
if asBase64 {
|
||
payload, err = base64.StdEncoding.DecodeString(strings.TrimSpace(string(payload)))
|
||
if err != nil {
|
||
return apperrors.NewValidation(fmt.Sprintf("invalid base64 auth bundle: %v", err))
|
||
}
|
||
}
|
||
report, err := authpkg.ImportPortableAuthBundle(configDir, bytes.NewReader(payload))
|
||
if err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to import auth bundle: %v", err))
|
||
}
|
||
if report.OSMismatch {
|
||
fmt.Fprintf(cmd.ErrOrStderr(), "警告: 认证包来自 %s,当前系统为 %s,请确认解密材料兼容\n", report.BundleOS, runtime.GOOS)
|
||
}
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
fmt.Fprintln(cmd.OutOrStdout(), "[OK] 已导入认证包")
|
||
fmt.Fprintln(cmd.OutOrStdout(), "请运行 dws auth status 验证登录状态")
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().StringP("input", "i", "", "认证包输入路径")
|
||
cmd.Flags().Bool("base64", false, "输入为 base64 编码的认证包")
|
||
cmd.Flags().Bool("force", false, "覆盖已有登录态")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthExchangeCommand() *cobra.Command {
|
||
cmd := &cobra.Command{
|
||
Use: "exchange",
|
||
Short: "Exchange an authorization code for credentials",
|
||
Hidden: true,
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
code, err := cmd.Flags().GetString("code")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --code")
|
||
}
|
||
code = strings.TrimSpace(code)
|
||
if code == "" {
|
||
return apperrors.NewValidation("--code is required")
|
||
}
|
||
uid, err := cmd.Flags().GetString("uid")
|
||
if err != nil {
|
||
return apperrors.NewInternal("failed to read --uid")
|
||
}
|
||
|
||
configDir := defaultConfigDir()
|
||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||
configureOAuthProviderCompatibility(provider, configDir)
|
||
exchangeCtx, cancel := context.WithTimeout(cmd.Context(), time.Minute)
|
||
defer cancel()
|
||
tokenData, err := provider.ExchangeAuthCode(exchangeCtx, code, strings.TrimSpace(uid))
|
||
if err != nil {
|
||
return apperrors.NewAuth(fmt.Sprintf("failed to exchange authorization code: %v", err))
|
||
}
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintln(w, "[OK] 授权码兑换成功!")
|
||
if strings.TrimSpace(uid) != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "用户:", strings.TrimSpace(uid))
|
||
}
|
||
if strings.TrimSpace(tokenData.CorpID) != "" {
|
||
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
|
||
}
|
||
if !tokenData.ExpiresAt.IsZero() {
|
||
fmt.Fprintf(w, "%-16s%s\n", "有效期:", authLoginFormatExpiry(tokenData.ExpiresAt))
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
cmd.Flags().String("code", "", "Authorization code")
|
||
cmd.Flags().String("uid", "", "Optional user identifier for compatibility")
|
||
cmd.Flags().String("client-id", "", "Compatibility flag")
|
||
cmd.Flags().String("authorize-url", "", "Compatibility flag")
|
||
cmd.Flags().String("token-url", "", "Compatibility flag")
|
||
cmd.Flags().String("refresh-url", "", "Compatibility flag")
|
||
cmd.Flags().String("redirect-url", "", "Compatibility flag")
|
||
cmd.Flags().String("scopes", "", "Compatibility flag")
|
||
return cmd
|
||
}
|
||
|
||
func newAuthResetCommand() *cobra.Command {
|
||
return &cobra.Command{
|
||
Use: "reset",
|
||
Short: "重置认证信息(清除本地 Token,触发重新授权)",
|
||
DisableAutoGenTag: true,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
configDir := defaultConfigDir()
|
||
if err := authpkg.DeleteTokenData(configDir); err != nil {
|
||
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
|
||
}
|
||
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
|
||
_ = os.Remove(filepath.Join(configDir, "token"))
|
||
ResetRuntimeTokenCache()
|
||
clearCompatCache()
|
||
w := cmd.OutOrStdout()
|
||
fmt.Fprintln(w, "[OK] 认证信息已重置")
|
||
if !edition.Get().IsEmbedded {
|
||
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
}
|
||
|
||
func timeOrEmpty(t time.Time) string {
|
||
if t.IsZero() {
|
||
return ""
|
||
}
|
||
return t.UTC().Format(time.RFC3339)
|
||
}
|
||
|
||
func authLoginFormatExpiry(t time.Time) string {
|
||
remaining := time.Until(t)
|
||
if remaining <= 0 {
|
||
return "已过期"
|
||
}
|
||
if remaining > 24*time.Hour {
|
||
return fmt.Sprintf("%.0f 天后", remaining.Hours()/24)
|
||
}
|
||
return fmt.Sprintf("%.0f 小时后", remaining.Hours())
|
||
}
|
||
|
||
// authLoginDisplayExpiry 返回用于显示的有效期(优先显示 refresh token 有效期)
|
||
func authLoginDisplayExpiry(data *authpkg.TokenData) string {
|
||
if data == nil {
|
||
return ""
|
||
}
|
||
// 优先使用 refresh token 有效期(更长,对用户更有意义)
|
||
if data.IsRefreshTokenValid() {
|
||
return authLoginFormatExpiry(data.RefreshExpAt)
|
||
}
|
||
// 回退到 access token 有效期
|
||
if !data.ExpiresAt.IsZero() {
|
||
return authLoginFormatExpiry(data.ExpiresAt)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func selectLoginRecommendProducts(products []pat.LoginRecommendProduct) ([]string, error) {
|
||
if len(products) == 0 {
|
||
return nil, nil
|
||
}
|
||
selected := make([]string, 0, len(products))
|
||
options := make([]huh.Option[string], 0, len(products))
|
||
for _, product := range products {
|
||
code := strings.TrimSpace(product.ProductCode)
|
||
if code == "" {
|
||
continue
|
||
}
|
||
selected = append(selected, code)
|
||
options = append(options, huh.NewOption(loginRecommendProductLabel(product), code).Selected(true))
|
||
}
|
||
if len(options) == 0 {
|
||
return nil, nil
|
||
}
|
||
height := len(options)
|
||
if height > 15 {
|
||
height = 15
|
||
}
|
||
form := huh.NewForm(
|
||
huh.NewGroup(
|
||
huh.NewMultiSelect[string]().
|
||
Title("选择要授权的业务域").
|
||
Description("空格选择 回车确认").
|
||
Options(options...).
|
||
Height(height).
|
||
Value(&selected).
|
||
Validate(func(values []string) error {
|
||
if len(values) == 0 {
|
||
return fmt.Errorf("至少选择一个授权业务域")
|
||
}
|
||
return nil
|
||
}),
|
||
),
|
||
)
|
||
if err := form.Run(); err != nil {
|
||
return nil, fmt.Errorf("授权业务域选择中止: %w", err)
|
||
}
|
||
return selected, nil
|
||
}
|
||
|
||
func authLoginShouldShowRecommendSelector(cmd *cobra.Command, format string) bool {
|
||
return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, isInteractiveTerminal())
|
||
}
|
||
|
||
func authLoginShouldShowRecommendSelectorForTerminal(cmd *cobra.Command, format string, interactive bool) bool {
|
||
return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, true, interactive)
|
||
}
|
||
|
||
func authLoginShouldUseRecommendHumanMode(cmd *cobra.Command, format string, recommend bool) bool {
|
||
return authLoginShouldUseRecommendHumanModeForTerminal(cmd, format, recommend, isInteractiveTerminal())
|
||
}
|
||
|
||
func authLoginShouldUseRecommendHumanModeForTerminal(cmd *cobra.Command, format string, recommend bool, interactive bool) bool {
|
||
if !recommend || !interactive {
|
||
return false
|
||
}
|
||
if !strings.EqualFold(strings.TrimSpace(format), "json") {
|
||
return true
|
||
}
|
||
flags := cmd.Root().PersistentFlags()
|
||
return !flags.Changed("format")
|
||
}
|
||
|
||
func loginRecommendProductLabel(product pat.LoginRecommendProduct) string {
|
||
name := strings.TrimSpace(product.ProductName)
|
||
if name == "" || name == product.ProductCode {
|
||
name = product.ProductCode
|
||
}
|
||
summary := strings.TrimSpace(product.Summary)
|
||
if summary != "" {
|
||
summary = " - " + clipRunes(summary, 42)
|
||
}
|
||
return fmt.Sprintf("%-10s %s%s", product.ProductCode, name, summary)
|
||
}
|
||
|
||
func clipRunes(value string, limit int) string {
|
||
if limit <= 0 {
|
||
return ""
|
||
}
|
||
runes := []rune(value)
|
||
if len(runes) <= limit {
|
||
return value
|
||
}
|
||
return string(runes[:limit]) + "..."
|
||
}
|
||
|
||
func clearCompatCache() {
|
||
store := cacheStoreFromEnv()
|
||
if store != nil {
|
||
_ = os.RemoveAll(store.Root)
|
||
}
|
||
}
|
||
|
||
func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||
token, err := cmd.Flags().GetString("token")
|
||
if err != nil {
|
||
return authLoginConfig{}, apperrors.NewInternal("failed to read --token")
|
||
}
|
||
device, err := cmd.Flags().GetBool("device")
|
||
if err != nil {
|
||
return authLoginConfig{}, apperrors.NewInternal("failed to read --device")
|
||
}
|
||
force, err := cmd.Flags().GetBool("force")
|
||
if err != nil {
|
||
return authLoginConfig{}, apperrors.NewInternal("failed to read --force")
|
||
}
|
||
recommend, err := cmd.Flags().GetBool("recommend")
|
||
if err != nil {
|
||
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
|
||
}
|
||
return authLoginConfig{
|
||
Token: strings.TrimSpace(token),
|
||
Force: force,
|
||
Device: device,
|
||
Recommend: recommend,
|
||
}, nil
|
||
}
|
||
|
||
func authStatusAuthenticated(data *authpkg.TokenData) bool {
|
||
if data == nil {
|
||
return false
|
||
}
|
||
return data.IsAccessTokenValid() || data.IsRefreshTokenValid()
|
||
}
|
||
|
||
func authStatusUpdatedAt(data *authpkg.TokenData) string {
|
||
if data == nil {
|
||
return ""
|
||
}
|
||
if data.IsAccessTokenValid() {
|
||
return timeOrEmpty(data.ExpiresAt)
|
||
}
|
||
if data.IsRefreshTokenValid() {
|
||
return timeOrEmpty(data.RefreshExpAt)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// authStatusResponse is the JSON response for auth status command.
|
||
type authStatusResponse struct {
|
||
Success bool `json:"success"`
|
||
Authenticated bool `json:"authenticated"`
|
||
Message string `json:"message,omitempty"`
|
||
Refreshed bool `json:"refreshed,omitempty"`
|
||
TokenValid bool `json:"token_valid,omitempty"`
|
||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||
ExpiresAt string `json:"expires_at,omitempty"`
|
||
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
|
||
CorpID string `json:"corp_id,omitempty"`
|
||
CorpName string `json:"corp_name,omitempty"`
|
||
UserID string `json:"user_id,omitempty"`
|
||
UserName string `json:"user_name,omitempty"`
|
||
}
|
||
|
||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
|
||
resp := authStatusResponse{
|
||
Success: true,
|
||
Authenticated: authenticated,
|
||
}
|
||
|
||
if !authenticated {
|
||
resp.Message = "未登录"
|
||
} else if data != nil {
|
||
resp.Refreshed = refreshed
|
||
resp.TokenValid = data.IsAccessTokenValid()
|
||
resp.RefreshTokenValid = data.IsRefreshTokenValid()
|
||
if !data.ExpiresAt.IsZero() {
|
||
resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano)
|
||
}
|
||
if !data.RefreshExpAt.IsZero() {
|
||
resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano)
|
||
}
|
||
resp.CorpID = data.CorpID
|
||
resp.CorpName = data.CorpName
|
||
resp.UserID = data.UserID
|
||
resp.UserName = data.UserName
|
||
}
|
||
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(resp)
|
||
}
|
||
|
||
// authLoginResponse is the JSON response for auth login command.
|
||
type authLoginResponse struct {
|
||
Success bool `json:"success"`
|
||
Message string `json:"message"`
|
||
TokenValid bool `json:"token_valid,omitempty"`
|
||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||
ExpiresAt string `json:"expires_at,omitempty"`
|
||
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
|
||
CorpID string `json:"corp_id,omitempty"`
|
||
CorpName string `json:"corp_name,omitempty"`
|
||
UserID string `json:"user_id,omitempty"`
|
||
UserName string `json:"user_name,omitempty"`
|
||
}
|
||
|
||
func writeAuthLoginJSON(w io.Writer, data *authpkg.TokenData, forced bool) error {
|
||
resp := authLoginResponse{
|
||
Success: true,
|
||
Message: "登录成功",
|
||
}
|
||
|
||
if data != nil {
|
||
if data.IsAccessTokenValid() && !forced {
|
||
resp.Message = "Token 有效,无需重新登录"
|
||
}
|
||
resp.TokenValid = data.IsAccessTokenValid()
|
||
resp.RefreshTokenValid = data.IsRefreshTokenValid()
|
||
if !data.ExpiresAt.IsZero() {
|
||
resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano)
|
||
}
|
||
if !data.RefreshExpAt.IsZero() {
|
||
resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano)
|
||
}
|
||
resp.CorpID = data.CorpID
|
||
resp.CorpName = data.CorpName
|
||
resp.UserID = data.UserID
|
||
resp.UserName = data.UserName
|
||
}
|
||
|
||
enc := json.NewEncoder(w)
|
||
enc.SetIndent("", " ")
|
||
return enc.Encode(resp)
|
||
}
|