gcli2api / internal /panel /login.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
12.4 kB
// login.go 面板内嵌的 WorkBuddy CN OAuth 设备授权流程(cmd/login 的进程内移植)。
//
// POST /panel/api/login/start → 拿 state+authUrl,state 存进程内(不再落 /tmp,
// 原方案在 Windows 上不可用),返回授权 URL;
// GET /panel/api/login/poll → 面板前端每 3s 轮询本接口;未完成返回 done=false,
// 完成后取 uid/nickname、凭证落盘 auths/workbuddy-<uid>.json、热加载进池
// (pool.Add + Revive),并顺带签到 + 余额刷新 —— 免重启加载新账号。
//
// 无 PKCE(workbuddy 设备流由服务端签发 state),请求头与上游端点与 cmd/login 保持一致。
package panel
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
)
const (
upstreamBaseCN = "https://copilot.tencent.com"
upstreamBaseGlobal = "https://www.workbuddy.ai"
clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
originRefererCN = "https://www.codebuddy.cn"
originRefererGlobal = "https://www.workbuddy.ai"
)
// loginEndpoints 按 realm 返回设备授权三端点(auth/state、token、account)+ Origin。
// realm=="global" → 国际版(workbuddy.ai 同域);cn/非法/缺省 → CN(零回归)。
func loginEndpoints(realm string) (state, token, account, origin string) {
if realm == "global" {
base := upstreamBaseGlobal
return base + "/v2/plugin/auth/state?platform=CLI",
base + "/v2/plugin/auth/token?state=",
base + "/v2/plugin/login/account?state=",
originRefererGlobal
}
base := upstreamBaseCN
return base + "/v2/plugin/auth/state?platform=CLI",
base + "/v2/plugin/auth/token?state=",
base + "/v2/plugin/login/account?state=",
originRefererCN
}
// loginHTTP 设备授权专用 client:短超时、无 cookie(每请求携带 state,无会话态)。
var loginHTTP = &http.Client{Timeout: 30 * time.Second}
func commonHeaders(req *http.Request, origin string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Origin", origin)
req.Header.Set("Referer", origin+"/")
req.Header.Set("User-Agent", clientUA)
}
// validUID 校验上游返回的 uid 是否可安全用于拼文件名。
// 只放行字母、数字、下划线、连字符(腾讯侧 uid 实测为 UUID 形态),
// 长度上限 64 兜底异常超长串;拒绝 . / \ 等路径字符与空串。
func validUID(uid string) bool {
if uid == "" || len(uid) > 64 {
return false
}
for _, c := range uid {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
default:
return false
}
}
return true
}
// apiEnvelope 与 upstream 同形:{code,msg,data},code!=0 视为业务错误。
type apiEnvelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
// doJSON 发一次 JSON 请求并解信封。origin 为 Origin/Referer 基础域(随 realm 切)。
func doJSON(method, fullURL, bearer string, body io.Reader, origin string) (json.RawMessage, int, error) {
req, err := http.NewRequest(method, fullURL, body)
if err != nil {
return nil, 0, err
}
commonHeaders(req, origin)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := loginHTTP.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 300 {
return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
}
var env apiEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
}
if env.Code != 0 {
return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
}
return env.Data, resp.StatusCode, nil
}
// loginStart 发起设备授权:POST auth/state 拿授权 URL。
// body 可带 {"realm":"global"}(缺省 cn);state 会话记 realm,poll 同 realm 落盘。
func (p *Panel) loginStart(w http.ResponseWriter, r *http.Request) {
realm := "cn"
if r.Body != nil {
var reqBody struct {
Realm string `json:"realm"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&reqBody); err == nil {
if reqBody.Realm == "global" {
realm = "global"
}
}
}
epState, _, _, origin := loginEndpoints(realm)
data, status, err := doJSON(http.MethodPost, epState, "", bytes.NewReader([]byte("{}")), origin)
if err != nil {
writeErr(w, http.StatusBadGateway, fmt.Sprintf("auth state (upstream %d): %v", status, err))
return
}
var st struct {
State string `json:"state"`
AuthURL string `json:"authUrl"`
}
if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
writeErr(w, http.StatusBadGateway, "auth state: missing state or authUrl")
return
}
p.loginMu.Lock()
// 顺手回收过期会话,防"开弹窗走开"的 state 滞留。
for s, sess := range p.logins {
if time.Since(sess.created) > loginTTL {
delete(p.logins, s)
}
}
p.logins[st.State] = loginSession{created: time.Now(), realm: realm}
p.loginMu.Unlock()
log.Printf("panel: 发起 OAuth 添加账号 realm=%s(state=%s...)", realm, st.State[:min(8, len(st.State))])
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "url": st.AuthURL, "state": st.State, "realm": realm})
}
// loginPoll 轮询登录态。未完成 → {done:false};完成 → 建凭证、落盘、热加载、签到。
func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
if state == "" {
writeErr(w, http.StatusBadRequest, "missing state")
return
}
p.loginMu.Lock()
sess, known := p.logins[state]
p.loginMu.Unlock()
if !known {
writeErr(w, http.StatusNotFound, "unknown or expired state(请重新发起添加账号)")
return
}
_, epToken, epAcct, origin := loginEndpoints(sess.realm)
// auth/token 是权威登录状态端点:pending 时业务 code 非 0("login ing")。
tokRaw, _, err := doJSON(http.MethodGet, epToken+state, "", nil, origin)
if err != nil {
// pending / 未完成:面板前端继续轮询。
writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": err.Error()})
return
}
var tok struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int64 `json:"expiresIn"`
Domain string `json:"domain"`
}
if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
writeJSON(w, http.StatusOK, map[string]any{"done": false, "message": "waiting for login"})
return
}
// 完成:取 uid/nickname(失败不阻塞,仅缺展示名)。
var acct struct {
UID string `json:"uid"`
EnterpriseID string `json:"enterpriseId"`
Nickname string `json:"nickname"`
}
if acctRaw, _, err := doJSON(http.MethodGet, epAcct+state, tok.AccessToken, nil, origin); err == nil {
_ = json.Unmarshal(acctRaw, &acct)
}
if acct.UID == "" {
writeErr(w, http.StatusBadGateway, "login done but no uid(token 已发但账号信息获取失败,请重试)")
return
}
// UID 来自上游响应,未经校验就用于拼文件名会被路径穿越利用
// (filepath.Join("./auths", "workbuddy-../../evil.json") → auths/evil.json)。
// UID 是腾讯侧账号标识,实测为 UUID(十六进制与连字符),故只放行 [A-Za-z0-9_-]。
if !validUID(acct.UID) {
writeErr(w, http.StatusBadGateway, "上游返回的 uid 含非法字符,拒绝落盘(防路径穿越)")
return
}
// 凭证落盘(嵌套形,与 auths/ 目录既有格式一致)→ 热加载进池。
if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil {
writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error())
return
}
a := &auth.Auth{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(),
Domain: tok.Domain,
UID: acct.UID,
EnterpriseID: acct.EnterpriseID,
Nickname: acct.Nickname,
FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", acct.UID)),
}
// global 登录:落盘 auth.realm=global(Realm() 按此判域;不写则依赖 domain 后缀回落)。
if sess.realm == "global" {
if _, err := auth.BackfillRealmFor(a, "global"); err != nil {
writeErr(w, http.StatusInternalServerError, "set realm: "+err.Error())
return
}
} else {
// CN 也显式补 realm 键(幂等),让 auth 文件形态统一(与 LoadDir 存量迁移对齐)。
_, _ = a.BackfillRealm()
}
if err := a.SaveAtomic(); err != nil {
writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error())
return
}
p.cfg.Pool.Add(a)
p.cfg.Pool.Revive(acct.UID) // 全新登录 = 人工恢复口径:清掉旧号遗留的禁用/冷却/熔断
// 顺带签到 + 余额刷新(幂等;失败不影响登录结果,只体现在返回字段里)。
// realm 分支:CN 走 DailyCheckin;global 无 CN 签到体系,改为注册激活 + trial 领取
// (D4 门控同 scheduler:CN 任务端点对 global 不发起任何调用)。
checkinMsg := ""
remain := int64(-1)
total := int64(0)
if sess.realm == "global" {
// 注册激活(幂等):region required 时自动补地区(白名单首个,HK)后重新激活。
// 失败不阻断登录结果(auth 已落盘),只在返回字段里体现。
if activated, err := p.cfg.Upstream.GlobalCompleteRegistration(a); err != nil {
checkinMsg = "注册激活失败: " + err.Error()
log.Printf("panel: global 注册激活 uid=%s: %v", acct.UID, err)
} else if activated {
log.Printf("panel: global 注册激活 uid=%s 完成", acct.UID)
}
// trial 加油包(幂等 14051 = 已领过,非错误)。
if claimed, err := p.cfg.Upstream.ClaimTrial(a); err != nil {
checkinMsg = joinMsg(checkinMsg, "trial 领取失败: "+err.Error())
log.Printf("panel: global trial uid=%s: %v", acct.UID, err)
} else if claimed {
log.Printf("panel: global trial uid=%s 已领", acct.UID)
}
} else {
if err := p.cfg.Upstream.DailyCheckin(a); err != nil {
checkinMsg = err.Error()
}
}
if rm, tt, err := p.cfg.Upstream.UserResource(a); err == nil {
remain, total = rm, tt
p.cfg.Pool.ReenableIfCredits(acct.UID, rm, tt)
}
p.loginMu.Lock()
delete(p.logins, state)
p.loginMu.Unlock()
log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm)
writeJSON(w, http.StatusOK, map[string]any{
"done": true,
"uid": acct.UID,
"nickname": acct.Nickname,
"realm": sess.realm,
"credits": remain,
"credits_total": total,
"checkin_message": checkinMsg,
})
}
// joinMsg 拼接 login 完成后的提示消息(多段用「;」连接,空段跳过)。
func joinMsg(parts ...string) string {
out := ""
for _, s := range parts {
if s == "" {
continue
}
if out != "" {
out += ";"
}
out += s
}
return out
}
// loginRegions 返回 global 注册可选地区(panel 前端选地区弹窗用;CN 不调用)。
// 未持账号时返回白名单静态兜底(前端只读展示,不依赖上游)。
func (p *Panel) loginRegions(w http.ResponseWriter, r *http.Request) {
// 静态白名单(对齐国际版 web 展示集):面板前端只读展示,无需账号态。
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"regions": []map[string]string{
{"code": "HK", "name": "Hong Kong"},
{"code": "MO", "name": "Macao"},
{"code": "SG", "name": "Singapore"},
{"code": "TH", "name": "Thailand"},
{"code": "PH", "name": "Philippines"},
{"code": "MY", "name": "Malaysia"},
{"code": "ID", "name": "Indonesia"},
},
})
}