293 lines
8.9 KiB
Go
293 lines
8.9 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 auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
|
)
|
|
|
|
// oauthHTTPClient is a dedicated HTTP client for OAuth operations with
|
|
// explicit timeout and TLS configuration, replacing http.DefaultClient.
|
|
var oauthHTTPClient = &http.Client{
|
|
Timeout: 15 * time.Second,
|
|
}
|
|
|
|
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
|
|
type OAuthProvider struct {
|
|
configDir string
|
|
clientID string
|
|
logger *slog.Logger
|
|
Output io.Writer
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewOAuthProvider creates a new OAuth provider.
|
|
func NewOAuthProvider(configDir string, logger *slog.Logger) *OAuthProvider {
|
|
return &OAuthProvider{
|
|
configDir: configDir,
|
|
clientID: ClientID(),
|
|
logger: logger,
|
|
Output: os.Stderr,
|
|
httpClient: oauthHTTPClient,
|
|
}
|
|
}
|
|
|
|
func (p *OAuthProvider) output() io.Writer {
|
|
if p != nil && p.Output != nil {
|
|
return p.Output
|
|
}
|
|
return io.Discard
|
|
}
|
|
|
|
// Login performs authentication with smart degradation:
|
|
// 1. If force=false, try silent token refresh first (refresh_token)
|
|
// 2. If all silent methods fail (or force=true), fall back to browser OAuth flow
|
|
func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, error) {
|
|
// Smart degradation: try silent refresh before opening browser.
|
|
if !force {
|
|
data, err := LoadTokenData(p.configDir)
|
|
if err == nil {
|
|
// Case 1: access_token still valid — no action needed.
|
|
if data.IsAccessTokenValid() {
|
|
if p.logger != nil {
|
|
p.logger.Debug("access_token still valid, skipping login")
|
|
}
|
|
return data, nil
|
|
}
|
|
// Case 2: refresh using refresh_token (with lock to prevent concurrent refresh).
|
|
if data.IsRefreshTokenValid() {
|
|
if p.logger != nil {
|
|
p.logger.Debug("access_token expired, trying refresh_token")
|
|
}
|
|
refreshed, rErr := p.lockedRefresh(ctx)
|
|
if rErr == nil {
|
|
return refreshed, nil
|
|
}
|
|
if p.logger != nil {
|
|
p.logger.Warn(i18n.T("refresh_token 刷新失败,将尝试扫码登录"), "error", rErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fall through: full browser OAuth flow.
|
|
// Find a free port for the callback server.
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("starting callback listener: %w", err)
|
|
}
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, CallbackPath)
|
|
|
|
codeCh := make(chan string, 1)
|
|
errCh := make(chan error, 1)
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(CallbackPath, func(w http.ResponseWriter, r *http.Request) {
|
|
code := r.URL.Query().Get("authCode")
|
|
if code == "" {
|
|
code = r.URL.Query().Get("code")
|
|
}
|
|
if code == "" {
|
|
select {
|
|
case errCh <- errors.New(i18n.T("回调中未收到授权码")):
|
|
default:
|
|
}
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = fmt.Fprint(w, i18n.T("授权失败:未收到授权码"))
|
|
return
|
|
}
|
|
select {
|
|
case codeCh <- code:
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_, _ = fmt.Fprint(w, successHTML)
|
|
default:
|
|
// Select already exited (timeout/cancel); discard late callback.
|
|
w.WriteHeader(http.StatusGone)
|
|
}
|
|
})
|
|
|
|
server := &http.Server{Handler: mux}
|
|
go func() {
|
|
if serveErr := server.Serve(listener); !errors.Is(serveErr, http.ErrServerClosed) {
|
|
select {
|
|
case errCh <- fmt.Errorf("callback server error: %w", serveErr):
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
defer func() {
|
|
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer shutCancel()
|
|
_ = server.Shutdown(shutCtx)
|
|
}()
|
|
|
|
authURL := buildAuthURL(p.clientID, redirectURI)
|
|
if p.logger != nil {
|
|
p.logger.Debug("authorization URL", "url", authURL)
|
|
}
|
|
if err := openBrowser(authURL); err != nil && p.logger != nil {
|
|
p.logger.Warn(i18n.T("无法自动打开浏览器"), "error", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T("🔐 登录钉钉"))
|
|
_, _ = fmt.Fprintln(p.output(), "")
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T("请在浏览器中完成扫码授权。"))
|
|
_, _ = fmt.Fprintf(p.output(), i18n.T("如果浏览器未自动打开,请手动访问:\n %s\n\n"), authURL)
|
|
_, _ = fmt.Fprintln(p.output(), i18n.T("⏳ 等待授权中..."))
|
|
|
|
timeout := time.NewTimer(5 * time.Minute)
|
|
defer timeout.Stop()
|
|
|
|
var authCode string
|
|
select {
|
|
case authCode = <-codeCh:
|
|
case err := <-errCh:
|
|
return nil, err
|
|
case <-timeout.C:
|
|
return nil, errors.New(i18n.T("授权超时(5分钟),请重试"))
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
tokenData, err := p.exchangeCode(ctx, authCode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
|
}
|
|
if err := SaveTokenData(p.configDir, tokenData); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
|
}
|
|
return tokenData, nil
|
|
}
|
|
|
|
// GetAccessToken returns a valid access token, auto-refreshing if needed.
|
|
// Uses a file lock with double-check pattern to prevent concurrent refresh
|
|
// from multiple CLI processes.
|
|
func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
|
|
data, err := LoadTokenData(p.configDir)
|
|
if err != nil {
|
|
return "", errors.New(i18n.T("未登录,请运行 dws auth login"))
|
|
}
|
|
|
|
// Fast path: access_token still valid — no lock needed.
|
|
if data.IsAccessTokenValid() {
|
|
return data.AccessToken, nil
|
|
}
|
|
|
|
// Slow path: token expired — try locked refresh.
|
|
if data.IsRefreshTokenValid() {
|
|
refreshed, rErr := p.lockedRefresh(ctx)
|
|
if rErr == nil {
|
|
return refreshed.AccessToken, nil
|
|
}
|
|
if p.logger != nil {
|
|
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
|
|
}
|
|
}
|
|
|
|
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
|
|
}
|
|
|
|
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
|
|
// It uses a double-check pattern with both process-level and file-level locking:
|
|
//
|
|
// Layer 1 (Process Lock - sync.Map):
|
|
//
|
|
// Prevents multiple goroutines within the same process from refreshing simultaneously.
|
|
// If another goroutine is already refreshing, we wait for it and then re-check.
|
|
//
|
|
// Layer 2 (File Lock - flock/LockFileEx):
|
|
//
|
|
// Prevents multiple CLI processes from refreshing simultaneously.
|
|
// If another process is refreshing, we wait for the file lock and then re-check.
|
|
//
|
|
// Double-Check Pattern:
|
|
//
|
|
// After acquiring the lock, we re-load from disk because another goroutine/process
|
|
// may have already completed the refresh while we were waiting. This prevents the
|
|
// classic race where two callers both see an expired token and both call the
|
|
// refresh API, invalidating each other's refresh_token.
|
|
func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
|
// Acquire dual-layer lock (process-level + file-level)
|
|
lock, err := AcquireDualLock(ctx, p.configDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acquiring dual lock: %w", err)
|
|
}
|
|
defer lock.Release()
|
|
|
|
// Double-check: re-load from disk — another goroutine/process may have refreshed
|
|
// while we were waiting for the lock.
|
|
data, err := LoadTokenData(p.configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data.IsAccessTokenValid() {
|
|
if p.logger != nil {
|
|
if lock.Waited {
|
|
p.logger.Debug("token already refreshed by another goroutine/process")
|
|
} else {
|
|
p.logger.Debug("token still valid after acquiring lock")
|
|
}
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// Still expired — we need to actually refresh.
|
|
if !data.IsRefreshTokenValid() {
|
|
return nil, fmt.Errorf("refresh_token 已过期")
|
|
}
|
|
|
|
if p.logger != nil {
|
|
p.logger.Debug("refreshing token (dual-locked)")
|
|
}
|
|
return p.refreshWithRefreshToken(ctx, data)
|
|
}
|
|
|
|
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
|
// external host, exchanges it for tokens, and persists them.
|
|
func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) {
|
|
tokenData, err := p.exchangeCode(ctx, authCode)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("换取 token 失败"), err)
|
|
}
|
|
if uid != "" {
|
|
tokenData.UserID = uid
|
|
}
|
|
if err := SaveTokenData(p.configDir, tokenData); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", i18n.T("保存 token 失败"), err)
|
|
}
|
|
return tokenData, nil
|
|
}
|
|
|
|
// Logout clears all stored credentials.
|
|
func (p *OAuthProvider) Logout() error {
|
|
return DeleteTokenData(p.configDir)
|
|
}
|
|
|
|
// Status returns the current auth status.
|
|
func (p *OAuthProvider) Status() (*TokenData, error) {
|
|
return LoadTokenData(p.configDir)
|
|
}
|