Files
dingtalk-workspace-cli/internal/app/auth_command.go
T

505 lines
16 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 (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
type authLoginConfig struct {
Token string
Force bool
Device bool
}
func buildAuthCommand() *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()
},
}
cmd.AddCommand(newAuthLoginCommand())
cmd.AddCommand(
newAuthLogoutCommand(),
newAuthStatusCommand(),
newAuthExchangeCommand(),
newAuthResetCommand(),
)
return cmd
}
func newAuthLoginCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "登录钉钉(自动刷新 token,必要时扫码)",
Long: `登录钉钉并获取认证凭证。
支持的登录方式:
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
- 直接提供 Token: 通过 --token 参数传入已有 token
不支持的登录方式:
- 邮箱/密码登录
- 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录
示例:
dws auth login # 扫码登录
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --token xxx # 使用指定 token`,
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))
}
}
clearCompatCache()
w := cmd.OutOrStdout()
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
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 nil
},
}
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)")
// 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)
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
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
}
}
}
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 updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" {
fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt)
}
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
fmt.Fprintln(w, "运行 dws auth login 进行登录")
}
return nil
},
}
}
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))
}
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"))
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 认证信息已重置")
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 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")
}
return authLoginConfig{
Token: strings.TrimSpace(token),
Force: force,
Device: device,
}, 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)
}