Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,6 @@ pippit-tool-cli query-result \

## 鉴权

原生 CLI 命令通过 `pippit-tool-cli login` 打开小云雀网页授权,并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。
原生 CLI 命令通过 `pippit-tool-cli login` 显示小云雀网页授权链接与设备码。浏览器可以在另一台设备上打开链接,登录个人空间并核对设备码后确认,原终端会自动继续并把本机设备专属凭证保存到系统安全凭证库;Access Key 不会显示在终端。可用 `pippit-tool-cli status` 查看状态、`pippit-tool-cli logout` 清除本机登录。需要兼容旧服务且浏览器与 CLI 在同一台机器时,可显式使用 `login --legacy-loopback`。

CI 或 Agent 可继续显式设置 `XYQ_ACCESS_KEY`,它会覆盖本机网页登录凭证;配置错误时不会静默回退到个人登录。会话提交和查询共享上述凭据。
4 changes: 3 additions & 1 deletion cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type logoutResult struct {
// NewLoginCommand creates the top-level `pippit-tool-cli login` command.
func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
var forceRefresh bool
var legacyLoopback bool
command := &cobra.Command{
Use: "login",
Short: "通过浏览器登录小云雀 CLI",
Expand All @@ -40,7 +41,7 @@ func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com
return err
}
credential, err := manager.Login(command.Context(), internal_auth.LoginOptions{
Progress: stderr, ForceRefresh: forceRefresh,
Progress: stderr, ForceRefresh: forceRefresh, LegacyLoopback: legacyLoopback,
})
if err != nil {
return err
Expand All @@ -60,6 +61,7 @@ func NewLoginCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Com
command.SetOut(stdout)
command.SetErr(stderr)
command.Flags().BoolVar(&forceRefresh, "force", false, "强制轮换当前设备的 CLI Access Key(仅在旧密钥被拒绝时使用)")
command.Flags().BoolVar(&legacyLoopback, "legacy-loopback", false, "使用旧版同机浏览器回调登录(不支持跨设备)")
return command
}

Expand Down
14 changes: 14 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,20 @@ func TestLoginCommandCanRequestSafeCredentialRotation(t *testing.T) {
}
}

func TestLoginCommandRequiresExplicitLegacyLoopback(t *testing.T) {
manager := &fakeAuthManager{credential: &internal_auth.Credential{
UID: "123", CredentialScope: "account-device-scope", ExpiredAt: time.Now().Add(time.Hour).Unix(),
}}
command := NewLoginCommand(io.Discard, io.Discard, &common.Runner{Config: &config.Config{}, Auth: manager})
command.SetArgs([]string{"--legacy-loopback"})
if err := command.Execute(); err != nil {
t.Fatal(err)
}
if len(manager.loginOptions) != 1 || !manager.loginOptions[0].LegacyLoopback {
t.Fatal("explicit legacy login option was lost")
}
}

func (manager *fakeAuthManager) Status(context.Context) (*internal_auth.Status, error) {
return manager.status, nil
}
Expand Down
13 changes: 10 additions & 3 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ func TestManagerLoginStoresPageIssuedCredentialWithoutServerExchange(t *testing.
var loginURLAtOpen string
var loginURLShownBeforeOpen bool
credential, err := manager.Login(context.Background(), LoginOptions{
Progress: &progress,
LegacyLoopback: true,
Progress: &progress,
OpenURL: func(rawURL string) error {
loginURLAtOpen = rawURL
loginURLShownBeforeOpen = strings.Contains(progress.String(), rawURL)
Expand Down Expand Up @@ -257,7 +258,8 @@ func TestManagerWaitsForCallbackWhenBrowserOpenFails(t *testing.T) {
)
var progress bytes.Buffer
credential, err := manager.Login(context.Background(), LoginOptions{
Progress: &progress,
LegacyLoopback: true,
Progress: &progress,
OpenURL: func(rawURL string) error {
loginURL, parseErr := url.Parse(rawURL)
if parseErr != nil {
Expand Down Expand Up @@ -304,6 +306,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) {
store := &memoryCredentialStore{credential: cloneCredential(old)}
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
_, err := manager.Login(context.Background(), LoginOptions{
LegacyLoopback: true,
ForceRefresh: true,
ExpectedCredentialScope: old.CredentialScope,
OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), "account-b", "new-id", "new-ak", true),
Expand All @@ -320,6 +323,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) {
store := &memoryCredentialStore{credential: cloneCredential(old)}
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
_, err := manager.Login(context.Background(), LoginOptions{
LegacyLoopback: true,
ForceRefresh: true,
ExpectedCredentialScope: old.CredentialScope,
OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, old.TokenID, old.AccessKey, true),
Expand All @@ -336,6 +340,7 @@ func TestManagerReauthenticationPinsAccountAndRequiresRotation(t *testing.T) {
store := &memoryCredentialStore{credential: cloneCredential(old)}
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
got, err := manager.Login(context.Background(), LoginOptions{
LegacyLoopback: true,
ForceRefresh: true,
ExpectedCredentialScope: old.CredentialScope,
OpenURL: callbackOpener(t, fixedNow.Add(time.Hour), old.UID, "rotated-id", "rotated-ak", true),
Expand Down Expand Up @@ -423,7 +428,8 @@ func TestManagerRejectsExpiredPageCredential(t *testing.T) {
store := &memoryCredentialStore{}
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
_, err := manager.Login(context.Background(), LoginOptions{
OpenURL: callbackOpener(t, fixedNow.Add(-time.Hour), "user", "id", "ak", false),
LegacyLoopback: true,
OpenURL: callbackOpener(t, fixedNow.Add(-time.Hour), "user", "id", "ak", false),
})
if !errors.Is(err, ErrCredentialExpired) {
t.Fatalf("expired callback error = %v", err)
Expand All @@ -442,6 +448,7 @@ func TestManagerDerivesExpectedAccountFromDurableScope(t *testing.T) {
manager := NewManager(config.Load(), WithCredentialStore(store), withClockForTest(func() time.Time { return fixedNow }))
expectedScope := credentialScope("durable-user", identity.DeviceID)
_, err := manager.Login(context.Background(), LoginOptions{
LegacyLoopback: true,
ForceRefresh: true,
ExpectedCredentialScope: expectedScope,
OpenURL: func(rawURL string) error {
Expand Down
16 changes: 15 additions & 1 deletion internal/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
Expand All @@ -20,6 +21,9 @@ type Manager struct {
authBaseURL *url.URL
random io.Reader
now func() time.Time
authClient *http.Client
wait func(context.Context, time.Duration) error
jitter func() time.Duration
credentialMu sync.Mutex
cachedCredential *Credential
credentialCacheLoaded bool
Expand All @@ -46,6 +50,9 @@ func NewManager(cfg *config.Config, options ...ManagerOption) *Manager {
authBaseURL: authBaseURL,
random: rand.Reader,
now: time.Now,
authClient: &http.Client{Timeout: 15 * time.Second},
wait: waitForAuthorization,
jitter: authorizationJitter,
}
for _, option := range options {
if option != nil {
Expand Down Expand Up @@ -75,6 +82,13 @@ func (m *Manager) ResolveAccessKey(ctx context.Context) (string, error) {
}

func (m *Manager) Login(ctx context.Context, options LoginOptions) (*Credential, error) {
if options.LegacyLoopback {
return m.loginLoopback(ctx, options)
}
return m.loginSession(ctx, options)
}

func (m *Manager) loginLoopback(ctx context.Context, options LoginOptions) (*Credential, error) {
if err := m.validate(); err != nil {
return nil, err
}
Expand Down Expand Up @@ -308,7 +322,7 @@ func (m *Manager) validate() error {
if m.authBaseURL.Scheme != "https" && !(m.authBaseURL.Scheme == "http" && isLoopbackHost(m.authBaseURL.Hostname())) {
return errors.New("小云雀授权地址必须使用 HTTPS")
}
if m.authBaseURL.Host == "" || m.authBaseURL.RawQuery != "" || m.authBaseURL.Fragment != "" {
if m.authBaseURL.Host == "" || m.authBaseURL.User != nil || m.authBaseURL.Opaque != "" || m.authBaseURL.RawQuery != "" || m.authBaseURL.Fragment != "" {
return errors.New("小云雀授权地址无效")
}
return nil
Expand Down
220 changes: 220 additions & 0 deletions internal/auth/session_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package auth

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)

const (
sessionAPIPath = "/api/web/v1/cli/auth/session/"
authorizePagePath = "/cli/pippit-tool-authorize"
minimumPollInterval = 5 * time.Second
claimWindow = 5 * time.Minute
maxSessionResponseBytes = 64 << 10
)

type authSession struct {
ID string `json:"session_id"`
UserCode string `json:"user_code"`
Status string `json:"status"`
Reason string `json:"reason"`
ExpiresAt int64 `json:"expires_at"`
ClaimExpiresAt int64 `json:"claim_expires_at"`
Interval int64 `json:"interval"`
ExpectedAccount string `json:"expected_account"`
Force bool `json:"force"`
TeamID string `json:"team_id"`
}

type sessionCredential struct {
AccessKey string `json:"ak"`
TokenID string `json:"token_id"`
UID string `json:"uid"`
ExpiredAt int64 `json:"expired_at"`
TeamID string `json:"team_id"`
}

type sessionResponse struct {
Session authSession `json:"session"`
ClaimSecret string `json:"claim_secret"`
VerificationURI string `json:"verification_uri"`
Credential *sessionCredential `json:"credential"`
}

type createSessionRequest struct {
DeviceID string `json:"device_id"`
ExpectedAccount string `json:"expected_account,omitempty"`
TokenID string `json:"token_id,omitempty"`
Force bool `json:"force,omitempty"`
}

type sessionRequest struct {
SessionID string `json:"session_id"`
TokenID string `json:"token_id,omitempty"`
Result string `json:"result,omitempty"`
}

type sessionRequestError struct {
retryable bool
slowDown bool
timeout bool
retryAfter time.Duration
}

func (e *sessionRequestError) Error() string {
if e.timeout {
return "授权服务请求超时"
}
if e.slowDown {
return "授权请求过于频繁,请稍后重试"
}
if e.retryable {
return "授权服务暂不可用,请稍后重试"
}
return "授权请求失败,请重新登录"
}

// The client uses the canonical authentication origin, independently of the
// business API base URL. No redirect may carry a claim secret to another URL.
func (m *Manager) callSession(ctx context.Context, action, secret string, input any) (*sessionResponse, error) {
payload, err := json.Marshal(input)
if err != nil {
return nil, errors.New("授权请求格式无效")
}
endpoint := *m.authBaseURL
endpoint.Path, endpoint.RawPath = sessionAPIPath+action, ""
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
if err != nil {
return nil, errors.New("授权请求地址无效")
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
if secret != "" {
request.Header.Set("X-Cli-Session-Secret", secret)
}
client := *m.authClient
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
client.Jar = nil
response, err := client.Do(request)
if err != nil {
return nil, sessionTransportError(ctx, err)
}
defer response.Body.Close()
if action == "create" && (response.StatusCode == http.StatusNotFound || response.StatusCode == http.StatusMethodNotAllowed) {
return nil, ErrSessionUnsupported
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
timedOut := response.StatusCode == http.StatusRequestTimeout || response.StatusCode == http.StatusGatewayTimeout
return nil, &sessionRequestError{
retryable: timedOut || response.StatusCode == http.StatusTooManyRequests || response.StatusCode >= 500,
slowDown: response.StatusCode == http.StatusTooManyRequests,
timeout: timedOut,
retryAfter: retryAfterDelay(response.Header.Get("Retry-After"), m.now()),
}
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxSessionResponseBytes+1))
if err != nil {
return nil, sessionTransportError(ctx, err)
}
if len(body) > maxSessionResponseBytes {
return nil, errors.New("授权服务返回内容过大")
}
var envelope struct {
Ret string `json:"ret"`
Data *sessionResponse `json:"data"`
}
if json.Unmarshal(body, &envelope) != nil {
return nil, errors.New("授权服务响应格式无效")
}
// Values come from capcut_business_common_code.thrift. Never classify
// errors by untrusted errmsg text or copy it into terminal output.
if envelope.Ret != "0" {
return nil, &sessionRequestError{
retryable: envelope.Ret == "4" || envelope.Ret == "6" || envelope.Ret == "10" || envelope.Ret == "20002",
slowDown: envelope.Ret == "10", timeout: envelope.Ret == "6",
retryAfter: retryAfterDelay(response.Header.Get("Retry-After"), m.now()),
}
}
if envelope.Data == nil {
return nil, errors.New("授权服务响应缺少会话")
}
return envelope.Data, nil
}

func sessionTransportError(ctx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
var timeout interface{ Timeout() bool }
return &sessionRequestError{retryable: true, timeout: errors.As(err, &timeout) && timeout.Timeout()}
}

func retryAfterDelay(value string, now time.Time) time.Duration {
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds > 0 {
return time.Duration(min(seconds, 3600)) * time.Second
}
if deadline, err := http.ParseTime(value); err == nil && deadline.After(now) {
return min(deadline.Sub(now), time.Hour)
}
return 0
}

func validSessionSecret(value string) bool {
decoded, err := base64.RawURLEncoding.DecodeString(value)
return err == nil && len(decoded) == 32 && len(value) == 43 &&
constantTimeEqual(value, base64.RawURLEncoding.EncodeToString(decoded))
}

func personalSpace(teamID string) bool { return teamID == "" || teamID == "0" }

func validateSession(session authSession, request createSessionRequest, sessionID string) error {
if !validSessionSecret(session.ID) || (sessionID != "" && !constantTimeEqual(sessionID, session.ID)) ||
!personalSpace(session.TeamID) || session.Force != request.Force ||
!constantTimeEqual(session.ExpectedAccount, request.ExpectedAccount) ||
session.ExpiresAt <= 0 || session.Interval < 0 || session.Interval > 3600 {
return errors.New("授权会话绑定无效")
}
if len(session.UserCode) != 9 || session.UserCode[4] != '-' {
return errors.New("授权设备码无效")
}
for i, c := range session.UserCode {
if i != 4 && !strings.ContainsRune("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", c) {
return errors.New("授权设备码无效")
}
}
switch session.Status {
case "pending", "denied", "expired":
case "authorized", "completed":
if session.ClaimExpiresAt <= 0 || session.ClaimExpiresAt > session.ExpiresAt+int64(claimWindow/time.Second) {
return errors.New("授权领取期限无效")
}
default:
return errors.New("授权会话状态无效")
}
return nil
}

func verificationURL(base *url.URL, rawURI, sessionID string) (string, error) {
reference, err := url.Parse(rawURI)
if err != nil {
return "", errors.New("网页授权地址无效")
}
target := base.ResolveReference(reference)
query, queryErr := url.ParseQuery(target.RawQuery)
if queryErr != nil || target.User != nil || target.Opaque != "" || target.Fragment != "" ||
target.Scheme != base.Scheme || !strings.EqualFold(target.Host, base.Host) ||
target.Path != authorizePagePath || target.RawPath != "" || len(query) != 1 ||
len(query["session_id"]) != 1 || !constantTimeEqual(query.Get("session_id"), sessionID) {
return "", errors.New("网页授权地址不可信")
}
return target.String(), nil
}
Loading
Loading