307 lines
9.8 KiB
Go
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)
|
|
}
|
|
}
|