Files
LitePan/drivers/189Cloud/qrlogin.go
T
2026-07-29 18:40:45 +08:00

307 lines
9.8 KiB
Go

package cloud189
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"time"
qrcode "github.com/skip2/go-qrcode"
"litepan/internal/domain"
"litepan/internal/driver"
"litepan/internal/httpx"
)
type qrSession struct {
Created int64 `json:"ts"`
LT string `json:"lt"`
ReqID string `json:"rid"`
ParamID string `json:"pid"`
CaptchaToken string `json:"ct"`
QRURL string `json:"qr"`
EncryUUID string `json:"eu"`
Cookies string `json:"ck"`
}
func (d *Driver) StartQRLogin(ctx context.Context) (*driver.QRStartResult, error) {
client := qrHTTPClient()
baseParams, err := initQRBaseParams(ctx, client)
if err != nil {
return nil, err
}
form := url.Values{"appId": {appID}}
var uuidResp struct {
UUID string `json:"uuid"`
EncryUUID string `json:"encryuuid"`
}
if err := qrPostForm(ctx, client, authURL+"/api/logbox/oauth2/getUUID.do", form, &uuidResp, nil); err != nil {
return nil, err
}
if uuidResp.UUID == "" || uuidResp.EncryUUID == "" {
return nil, domain.Errorf(domain.CodeDriverError, "获取二维码失败:响应缺少 uuid/encryuuid")
}
png, err := qrcode.Encode(uuidResp.UUID, qrcode.Medium, 256)
if err != nil {
return nil, domain.Wrap(domain.CodeInternal, err)
}
sess := qrSession{
Created: time.Now().Unix(),
LT: baseParams["lt"],
ReqID: baseParams["req_id"],
ParamID: baseParams["param_id"],
CaptchaToken: baseParams["captcha_token"],
QRURL: uuidResp.UUID,
EncryUUID: uuidResp.EncryUUID,
Cookies: exportJarCookies(client),
}
return &driver.QRStartResult{
Token: encodeQRSession(sess),
QRImageBase64: "data:image/png;base64," + base64.StdEncoding.EncodeToString(png),
QRURL: uuidResp.UUID,
ExpiresIn: qrCodeTimeoutSec,
Title: "扫码获取Token",
Hint: "请使用小翼管家/天翼云盘/支付宝 APP扫码,成功后授权信息将填入表单",
}, nil
}
func (d *Driver) PollQRLogin(ctx context.Context, token string) (*driver.QRPollResult, error) {
sess, err := decodeQRSession(token)
if err != nil {
return nil, domain.Errorf(domain.CodeValidation, "扫码会话无效,请重新获取二维码")
}
if time.Now().Unix()-sess.Created > qrCodeTimeoutSec {
return &driver.QRPollResult{Status: driver.QRExpired, Message: "二维码已过期,请重新获取"}, nil
}
client := qrHTTPClient()
importJarCookies(client, sess.Cookies)
now := time.Now()
form := url.Values{
"appId": {appID},
"clientType": {clientType},
"returnUrl": {returnURL},
"paramId": {sess.ParamID},
"uuid": {sess.QRURL},
"encryuuid": {sess.EncryUUID},
"date": {now.Format("2006-01-0215:04:05.") + fmt.Sprintf("%03d", now.Nanosecond()/1e6)},
"timeStamp": {fmt.Sprintf("%d", now.UnixMilli())},
"cb_SaveName": {"0"},
"isOauth2": {"true"},
"state": {""},
}
headers := map[string]string{
"Referer": authURL,
"Reqid": sess.ReqID,
"lt": sess.LT,
"Accept": "application/json;charset=UTF-8",
}
var resp map[string]any
if err := qrPostForm(ctx, client, authURL+"/api/logbox/oauth2/qrcodeLoginState.do", form, &resp, headers); err != nil {
return &driver.QRPollResult{Status: driver.QRWaiting, Message: "请扫码并在手机上确认登录"}, nil
}
status, ok := qrStatusCode(resp)
if !ok {
return &driver.QRPollResult{Status: driver.QRWaiting, Message: "请扫码并在手机上确认登录"}, nil
}
switch status {
case 0:
redirectURL := qrRedirectURL(resp)
if redirectURL == "" {
return &driver.QRPollResult{Status: driver.QRFailed, Message: "扫码成功但未返回授权地址:" + qrResponseSummary(resp)}, nil
}
return finalizeQRLogin(ctx, client, redirectURL)
case -11001:
return &driver.QRPollResult{Status: driver.QRExpired, Message: "二维码已过期,请重新获取"}, nil
case -106, -11002:
return &driver.QRPollResult{Status: driver.QRWaiting, Message: "请扫码并在手机上确认登录"}, nil
default:
msg := firstString(anyString(resp["msg"]), anyString(resp["message"]), fmt.Sprintf("扫码登录失败,状态码 %d", status))
return &driver.QRPollResult{Status: driver.QRFailed, Message: msg}, nil
}
}
func qrStatusCode(resp map[string]any) (int, bool) {
for _, key := range []string{"status", "result", "code"} {
if _, exists := resp[key]; !exists {
continue
}
return anyInt(resp[key]), true
}
return 0, false
}
func qrRedirectURL(resp map[string]any) string {
for _, key := range []string{"redirectUrl", "redirectURL", "redirect_url", "RedirectUrl", "RedirectURL", "url", "loginUrl"} {
if value := anyString(resp[key]); value != "" {
return value
}
}
for _, key := range []string{"data", "result"} {
if nested, ok := resp[key].(map[string]any); ok {
if value := qrRedirectURL(nested); value != "" {
return value
}
}
}
return ""
}
func qrResponseSummary(resp map[string]any) string {
raw, err := json.Marshal(resp)
if err != nil || len(raw) == 0 {
return "响应为空"
}
return httpx.Truncate(raw, 220)
}
func initQRBaseParams(ctx context.Context, client *http.Client) (map[string]string, error) {
params := url.Values{
"appId": {appID},
"clientType": {clientType},
"returnURL": {returnURL},
"timeStamp": {fmt.Sprintf("%d", time.Now().UnixMilli())},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, webURL+"/api/portal/unifyLoginForPC.action?"+params.Encode(), nil)
if err != nil {
return nil, domain.Wrap(domain.CodeInternal, err)
}
set189Headers(req)
resp, data, err := httpx.Execute(client, req, 2<<20)
if err != nil {
return nil, domain.Wrap(domain.CodeDriverError, err)
}
if resp.StatusCode != http.StatusOK {
return nil, domain.Errorf(domain.CodeDriverError, "初始化登录参数失败 HTTP %d", resp.StatusCode)
}
return extractQRBaseParams(string(data))
}
func extractQRBaseParams(html string) (map[string]string, error) {
patterns := map[string]string{
"captcha_token": `'captchaToken'\s+value='(.+?)'`,
"lt": `lt\s*=\s*"(.+?)"`,
"param_id": `paramId\s*=\s*"(.+?)"`,
"req_id": `reqId\s*=\s*"(.+?)"`,
}
out := map[string]string{}
for key, pattern := range patterns {
match := regexp.MustCompile(pattern).FindStringSubmatch(html)
if len(match) < 2 {
return nil, domain.Errorf(domain.CodeDriverError, "解析天翼登录参数失败:缺少 %s", key)
}
out[key] = match[1]
}
return out, nil
}
func finalizeQRLogin(ctx context.Context, client *http.Client, redirectURL string) (*driver.QRPollResult, error) {
query := clientSuffix()
query.Set("redirectURL", redirectURL)
var out sessionResp
req, err := httpx.NewJSONRequest(ctx, http.MethodPost, apiURL+"/getSessionForPC.action", query, nil)
if err != nil {
return nil, domain.Wrap(domain.CodeInternal, err)
}
set189Headers(req)
resp, data, err := httpx.Execute(client, req, httpx.DefaultReadLimit)
if err != nil {
return &driver.QRPollResult{Status: driver.QRFailed, Message: "换取会话失败"}, nil
}
if resp.StatusCode != http.StatusOK {
return &driver.QRPollResult{Status: driver.QRFailed, Message: fmt.Sprintf("换取会话失败,HTTP %d", resp.StatusCode)}, nil
}
if err := decodeJSON(data, &out); err != nil {
return &driver.QRPollResult{Status: driver.QRFailed, Message: err.Error()}, nil
}
if !successResCode(out.ResCode) {
return &driver.QRPollResult{Status: driver.QRFailed, Message: firstString(out.ResMessage, "换取会话失败")}, nil
}
if out.RefreshToken == "" {
return &driver.QRPollResult{Status: driver.QRFailed, Message: "登录完成但未收到 refreshToken"}, nil
}
return &driver.QRPollResult{
Status: driver.QRSuccess,
Credentials: domain.AuthCredentials{
AccessToken: out.AccessToken,
RefreshToken: out.RefreshToken,
TokenExpires: time.Now().Add(7 * 24 * time.Hour),
},
}, nil
}
func qrHTTPClient() *http.Client {
jar, _ := cookiejar.New(nil)
return &http.Client{Timeout: 30 * time.Second, Jar: jar}
}
func qrPostForm(ctx context.Context, client *http.Client, rawURL string, form url.Values, out any, headers map[string]string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, strings.NewReader(form.Encode()))
if err != nil {
return domain.Wrap(domain.CodeInternal, err)
}
set189Headers(req)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, data, err := httpx.Execute(client, req, httpx.DefaultReadLimit)
if err != nil {
return domain.Wrap(domain.CodeDriverError, err)
}
if resp.StatusCode != http.StatusOK {
return domain.Errorf(domain.CodeDriverError, "天翼扫码接口 HTTP %d: %s", resp.StatusCode, httpx.Truncate(data, 300))
}
return decodeJSON(data, out)
}
func encodeQRSession(sess qrSession) string {
b, _ := json.Marshal(sess)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeQRSession(token string) (qrSession, error) {
var sess qrSession
b, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return sess, err
}
return sess, json.Unmarshal(b, &sess)
}
func exportJarCookies(client *http.Client) string {
if client == nil || client.Jar == nil {
return ""
}
var parts []string
for _, host := range []string{webURL, authURL, apiURL} {
u, _ := url.Parse(host)
for _, c := range client.Jar.Cookies(u) {
parts = append(parts, c.Name+"="+c.Value)
}
}
return strings.Join(parts, "; ")
}
func importJarCookies(client *http.Client, raw string) {
if client == nil || client.Jar == nil || raw == "" {
return
}
for _, host := range []string{webURL, authURL, apiURL} {
u, _ := url.Parse(host)
var cookies []*http.Cookie
for _, part := range strings.Split(raw, ";") {
name, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok && name != "" {
cookies = append(cookies, &http.Cookie{Name: name, Value: value})
}
}
client.Jar.SetCookies(u, cookies)
}
}