Download internal/panel/login.go from a3216/gcli2api: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/panel/login.go
- Command line
-
hf download hf://spaces/a3216/gcli2api/internal/panel/login.go
-
curl -L -o login.go https://huggingface.co/spaces/a3216/gcli2api/resolve/main/internal/panel/login.go
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"}, | |
| }, | |
| }) | |
| } | |