From 376cada3d4917f9ac9136eb2ca313c8384557dcf Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:29:09 +0800 Subject: [PATCH] fix(drivers/139): optimize login flow with cookie reuse and robust fallback (#2067) * fix(drivers/139): optimize login flow with robust tests * fix(drivers/139): preserve configured HTTP client settings --- drivers/139/driver.go | 15 +- drivers/139/meta.go | 9 +- drivers/139/util.go | 516 ++++++++++++++++++++++++++++++++++++--- drivers/139/util_test.go | 408 +++++++++++++++++++++++++++++++ 4 files changed, 894 insertions(+), 54 deletions(-) create mode 100644 drivers/139/util_test.go diff --git a/drivers/139/driver.go b/drivers/139/driver.go index 41bfe3e8d..a647fed94 100644 --- a/drivers/139/driver.go +++ b/drivers/139/driver.go @@ -45,16 +45,11 @@ func (d *Yun139) GetAddition() driver.Additional { func (d *Yun139) Init(ctx context.Context) error { if d.ref == nil { - if len(d.Authorization) == 0 && !d.isShare() { - if d.Username != "" && d.Password != "" { - log.Infof("139yun: authorization is empty, trying to login with password.") - newAuth, err := d.loginWithPassword() - log.Debugf("newAuth: Ok: %s", newAuth) - if err != nil { - return fmt.Errorf("login with password failed: %w", err) - } - } else { - return fmt.Errorf("authorization is empty and username/password is not provided") + // Share links may be browsed without account credentials. All other + // storage types need an authorization or a supported login fallback. + if !d.isShare() { + if err := d.validateAndInitCredentials(); err != nil { + return err } } if d.Authorization != "" { diff --git a/drivers/139/meta.go b/drivers/139/meta.go index 10033f334..86b587784 100644 --- a/drivers/139/meta.go +++ b/drivers/139/meta.go @@ -7,10 +7,11 @@ import ( type Addition struct { //Account string `json:"account" required:"true"` - Authorization string `json:"authorization" type:"text" required:"true"` - Username string `json:"username" required:"true"` - Password string `json:"password" required:"true" secret:"true"` - MailCookies string `json:"mail_cookies" required:"true" type:"text" help:"Cookies from mail.139.com used for login authentication."` + Authorization string `json:"authorization" type:"text" help:"Authorization can be used alone. If empty, use mail_cookies alone for fast login, or mail_cookies + username + password for full login fallback."` + Username string `json:"username" help:"Required only when using password login fallback with mail_cookies."` + Password string `json:"password" secret:"true" help:"Required only when using password login fallback with mail_cookies."` + SmsCode string `json:"sms_code" secret:"true" help:"Fill this only after OpenList reports that a 139 Mail SMS verification code was sent, then save the storage again."` + MailCookies string `json:"mail_cookies" type:"text" help:"Cookies from mail.10086.cn. Used for fast login only when Authorization is empty; otherwise retained as device context for password login fallback."` driver.RootID Type string `json:"type" type:"select" options:"personal_new,family,group,personal,share" default:"personal_new"` LinkID string `json:"link_id" type:"text" help:"Multiple shares are separated by commas or new lines. Use link_id#password for password-protected shares."` diff --git a/drivers/139/util.go b/drivers/139/util.go index d8ce131cf..b5fa9130a 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -6,9 +6,12 @@ import ( "crypto/aes" "crypto/cipher" crypto_rand "crypto/rand" + "crypto/rsa" "crypto/sha1" + "crypto/x509" "encoding/base64" "encoding/hex" + "encoding/xml" "errors" "fmt" "io" @@ -27,6 +30,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" streamPkg "github.com/OpenListTeam/OpenList/v4/internal/stream" + cookiepkg "github.com/OpenListTeam/OpenList/v4/pkg/cookie" "github.com/OpenListTeam/OpenList/v4/pkg/utils" "github.com/OpenListTeam/OpenList/v4/pkg/utils/random" "github.com/avast/retry-go" @@ -36,8 +40,43 @@ import ( ) const ( - KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" // 第一层 AES 解密密钥 - KEY_HEX_2 = "7150714477323633586746674c337538" // 第二层 AES 解密密钥 + KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d" // 第一层 AES 解密密钥 + KEY_HEX_2 = "7150714477323633586746674c337538" // 第二层 AES 解密密钥 + mailPublicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnsOHTFtwW5rq/8gGhPlM5Z3RPdeN/d+FYIHb5JmcfBOCozXuT8c+0anvxtkjzghixwNlnmBuhN8OYfS789YuH/qReQbHC7OdlisLildNWPHRNUYcPa0W3lXSG3+81CXK7FDXPvXo5ubw2GqVbIsccMarI1dyfXdi4ITiCXvmM9wYBdUs9yXtoorhlpyYUI2GV8HNsQjWK9P5QZHT3ox5Qy+mjRmvv6RUFJLPOMkOS/pGZ0DwC1ypFZBxstW0/ftVupdOmGWvW7J2/e3dq3A/UvIkC4YUY/diL1wighJx1G9MiRROISjNMvNyUDSTqPJy516+l3sgHEbc067QIJx2NQIDAQAB" +) + +var ( + mailRootURL = "https://mail.10086.cn/" + mailPasswordURL = "https://mail.10086.cn/Login/Login.ashx" + mailSMSURL = "https://mail.10086.cn/s" +) + +var mailLoginCookieExclusions = map[string]struct{}{ + "hecaiyun_stay_time": {}, + "isShowAgreeIconNew": {}, + "hecaiyundata2021jssdkcross": {}, + "random": {}, + "a_l2": {}, + "hecaiyun_stay_url": {}, + "a_l": {}, + "_139mail_login_type": {}, + "_139mail_login_shortAddr": {}, + "sajssdk_2015_cross_new_user": {}, + "fromhtml5": {}, + "html5SkinPath8011": {}, + "Os_SSo_Sid": {}, + "sid": {}, + "Login_UserNumber": {}, + "RMKEY": {}, + "rtexpired": {}, +} + +type credentialState int + +const ( + credentialStateAuthorization credentialState = iota + credentialStateFullLogin + credentialStateCookiesOnly ) // do others that not defined in Driver interface @@ -86,29 +125,28 @@ func (d *Yun139) refreshToken() error { } decode, err := base64.StdEncoding.DecodeString(d.Authorization) if err != nil { - return fmt.Errorf("authorization decode failed: %s", err) + return d.loginAfterAuthorizationFailure(fmt.Errorf("authorization decode failed: %w", err)) } decodeStr := string(decode) splits := strings.Split(decodeStr, ":") if len(splits) < 3 { - return fmt.Errorf("authorization is invalid, splits < 3") + return d.loginAfterAuthorizationFailure(errors.New("authorization is invalid, splits < 3")) } d.Account = splits[1] strs := strings.Split(splits[2], "|") if len(strs) < 4 { - return fmt.Errorf("authorization is invalid, strs < 4") + return d.loginAfterAuthorizationFailure(errors.New("authorization is invalid, strs < 4")) } expiration, err := strconv.ParseInt(strs[3], 10, 64) if err != nil { - return fmt.Errorf("authorization is invalid") + return d.loginAfterAuthorizationFailure(errors.New("authorization expiration is invalid")) } expiration -= time.Now().UnixMilli() if expiration > 1000*60*60*24*15 { - // Authorization有效期大于15天无需刷新 return nil } if expiration < 0 { - return fmt.Errorf("authorization has expired") + return d.loginAfterAuthorizationFailure(errors.New("authorization has expired")) } url := "https://aas.caiyun.feixin.10086.cn:443/tellin/authTokenRefresh.do" @@ -120,13 +158,7 @@ func (d *Yun139) refreshToken() error { SetResult(&resp). Post(url) if err != nil || resp.Return != "0" { - log.Warnf("139yun: failed to refresh token with old token: %v, desc: %s. trying to login with password.", err, resp.Desc) - newAuth, loginErr := d.loginWithPassword() - log.Debugf("newAuth: Ok: %s", newAuth) - if loginErr != nil { - return fmt.Errorf("failed to login with password after refresh failed: %w", loginErr) - } - return nil + return d.loginAfterAuthorizationFailure(fmt.Errorf("token refresh failed: %v, desc: %s", err, resp.Desc)) } d.Authorization = base64.StdEncoding.EncodeToString([]byte(splits[0] + ":" + splits[1] + ":" + resp.Token)) @@ -134,6 +166,18 @@ func (d *Yun139) refreshToken() error { return nil } +// loginAfterAuthorizationFailure deliberately skips cookie fast login. Mail +// cookies are only reused as device context for the password login request. +func (d *Yun139) loginAfterAuthorizationFailure(cause error) error { + log.Warnf("139yun: %v; trying password login.", cause) + newAuth, err := d.loginWithPassword() + log.Debugf("139yun: password fallback generated authorization: %t", newAuth != "") + if err != nil { + return fmt.Errorf("%v; password login failed: %w", cause, err) + } + return nil +} + func (d *Yun139) request(url string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) { req := base.RestyClient.R() randStr := random.String(16) @@ -1116,15 +1160,253 @@ func (d *Yun139) getDiskQuotaDetail(ctx context.Context) (*DiskQuotaDetail, erro return &resp, nil } +type mailLoginResponse struct { + Code string `json:"code"` + Summary string `json:"summary"` + Var struct { + LoginSuccessURL string `json:"loginSuccessUrl"` + } `json:"var"` +} + +func parseMailLoginResponse(body []byte) mailLoginResponse { + var response mailLoginResponse + if utils.Json.Unmarshal(body, &response) == nil && (response.Code != "" || response.Summary != "" || response.Var.LoginSuccessURL != "") { + return response + } + if match := regexp.MustCompile(`['"]?code['"]?\s*:\s*['"]([^'"]+)`).FindSubmatch(body); len(match) == 2 { + response.Code = string(match[1]) + } + if match := regexp.MustCompile(`['"]?summary['"]?\s*:\s*['"]([^'"]+)`).FindSubmatch(body); len(match) == 2 { + response.Summary = string(match[1]) + } + if match := regexp.MustCompile(`['"]?loginSuccessUrl['"]?\s*:\s*['"]([^'"]+)`).FindSubmatch(body); len(match) == 2 { + response.Var.LoginSuccessURL = string(match[1]) + } + return response +} + +func mergeMailCookieHeader(existing string, responseCookies []*http.Cookie) string { + cookies := cookiepkg.Parse(existing) + for _, responseCookie := range responseCookies { + if responseCookie != nil && responseCookie.Name != "" { + cookies = cookiepkg.SetCookie(cookies, responseCookie.Name, responseCookie.Value) + } + } + return cookiepkg.ToString(cookies) +} + +func sanitizeMailLoginCookies(existing, newJSessionID string) string { + cookies := cookiepkg.Parse(existing) + filtered := cookies[:0] + for _, cookie := range cookies { + if _, excluded := mailLoginCookieExclusions[cookie.Name]; excluded { + continue + } + if cookie.Name == "JSESSIONID" { + if newJSessionID == "" { + continue + } + cookie.Value = newJSessionID + newJSessionID = "" + } + filtered = append(filtered, cookie) + } + if newJSessionID != "" { + filtered = append(filtered, &http.Cookie{Name: "JSESSIONID", Value: newJSessionID}) + } + return cookiepkg.ToString(filtered) +} + +func mailRiskCode(location string) string { + parsed, err := url.Parse(location) + if err != nil { + return "" + } + return parsed.Query().Get("ec") +} + +func smsSceneForRisk(riskCode string) (int, bool) { + switch riskCode { + case "PML401010062": + return 2, true + case "MW0016": + return 4, true + case "S025", "S035": + return 1, true + default: + return 0, false + } +} + +func encryptMailLoginName(account string) (string, error) { + der, err := base64.StdEncoding.DecodeString(mailPublicKey) + if err != nil { + return "", fmt.Errorf("decode mail public key: %w", err) + } + parsed, err := x509.ParsePKIXPublicKey(der) + if err != nil { + return "", fmt.Errorf("parse mail public key: %w", err) + } + publicKey, ok := parsed.(*rsa.PublicKey) + if !ok { + return "", errors.New("mail public key is not RSA") + } + encrypted, err := rsa.EncryptPKCS1v15(crypto_rand.Reader, publicKey, []byte(account)) + if err != nil { + return "", fmt.Errorf("encrypt mail login name: %w", err) + } + return base64.StdEncoding.EncodeToString(encrypted), nil +} + +func mailXMLHeaders(cookie string) map[string]string { + return map[string]string{ + "Cookie": cookie, + "Content-Type": "application/xml; charset=utf-8", + "Accept-Encoding": "gzip", + "User-Agent": "okhttp/4.12.0", + } +} + +func new139RestyClient() *resty.Client { + if base.RestyClient != nil { + return base.RestyClient.Clone() + } + return resty.New() +} + +func (d *Yun139) sendSMSVerificationCode(riskCode string) error { + scene, ok := smsSceneForRisk(riskCode) + if !ok { + return fmt.Errorf("139 Mail risk control does not support SMS verification: %s", riskCode) + } + loginName, err := encryptMailLoginName(d.Username) + if err != nil { + return err + } + body := strings.Join([]string{ + "", + mailXMLField("loginName", loginName), + mailXMLField("fv", "4"), + mailXMLField("clientId", "1003"), + mailXMLField("eMode", "1"), + mailXMLField("loginFailureUrl", ""), + mailXMLField("loginSuccessUrl", ""), + mailXMLField("verifyCode", ""), + mailXMLField("version", "1.0"), + mailXMLField("scene", strconv.Itoa(scene)), + "", + }, "") + res, err := new139RestyClient().R(). + SetHeaders(mailXMLHeaders(d.MailCookies)). + SetBody(body). + Post(mailSMSURL + "?func=" + url.QueryEscape("login:sendSmsCodeByScene") + "&cguid=" + strconv.FormatInt(time.Now().UnixMilli(), 10)) + if err != nil { + return fmt.Errorf("send 139 Mail SMS verification code: %w", err) + } + d.MailCookies = mergeMailCookieHeader(d.MailCookies, res.Cookies()) + response := parseMailLoginResponse(res.Body()) + if response.Code == "S_OK" { + return nil + } + switch response.Code { + case "PML401010021", "PML401010022": + return fmt.Errorf("139 Mail requires picture verification before SMS can be sent: %s", response.Code) + case "PML404010001": + return errors.New("139 Mail SMS verification code was requested too frequently") + case "PML401010002": + return errors.New("139 Mail rejected the SMS verification parameters") + default: + return fmt.Errorf("send 139 Mail SMS verification code failed: code=%s summary=%s", response.Code, response.Summary) + } +} + +func (d *Yun139) verifySMSCode(riskCode string) (string, error) { + if _, ok := smsSceneForRisk(riskCode); !ok { + return "", fmt.Errorf("139 Mail risk control does not support SMS verification: %s", riskCode) + } + loginName, err := encryptMailLoginName(d.Username) + if err != nil { + return "", err + } + pwdType := "" + if riskCode == "MW0016" { + pwdType = mailXMLField("pwdType", "1") + } + body := strings.Join([]string{ + "", + mailXMLField("clientId", "1003"), + mailXMLField("version", "4"), + mailXMLField("loginType", "0"), + mailXMLField("authType", "2"), + mailXMLField("loginName", loginName), + mailXMLField("eMode", "1"), + mailXMLField("loginPassword", sha1Hash("fetion.com.cn:"+d.SmsCode)), + mailXMLField("createAutoLoginSecretKey", "1"), + mailXMLField("verifyCode", ""), + mailXMLField("verifyAgentId", ""), + mailXMLField("reqFrom", "3"), + mailXMLField("needWCookie", "1"), + pwdType, + "", + }, "") + res, err := new139RestyClient().R(). + SetHeaders(mailXMLHeaders(d.MailCookies)). + SetBody(body). + Post(mailSMSURL + "?func=" + url.QueryEscape("/login/inlogin.action") + "&cguid=" + strconv.FormatInt(time.Now().UnixMilli(), 10)) + if err != nil { + return "", fmt.Errorf("verify 139 Mail SMS code: %w", err) + } + d.MailCookies = mergeMailCookieHeader(d.MailCookies, res.Cookies()) + response := parseMailLoginResponse(res.Body()) + if response.Code != "S_OK" { + return "", fmt.Errorf("verify 139 Mail SMS code failed: code=%s summary=%s", response.Code, response.Summary) + } + sid := "" + if response.Var.LoginSuccessURL != "" { + if successURL, parseErr := url.Parse(response.Var.LoginSuccessURL); parseErr == nil { + sid = successURL.Query().Get("sid") + } + } + if sid == "" { + for _, cookie := range cookiepkg.Parse(d.MailCookies) { + if cookie.Name == "Os_SSo_Sid" || cookie.Name == "sid" { + sid = cookie.Value + break + } + } + } + if sid == "" { + return "", errors.New("139 Mail SMS verification succeeded but did not return sid") + } + d.SmsCode = "" + return sid, nil +} + func (d *Yun139) step1_password_login() (string, error) { log.Debugf("--- 执行步骤 1: 登录 API ---") - loginURL := "https://mail.10086.cn/Login/Login.ashx" + loginURL := mailPasswordURL + + preLogin, err := new139RestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()).R().Get(mailRootURL) + if preLogin == nil { + return "", fmt.Errorf("step1 pre-login request failed: %v", err) + } + if err != nil && (preLogin.StatusCode() < 300 || preLogin.StatusCode() >= 400) { + return "", fmt.Errorf("step1 pre-login request failed: %w", err) + } + jsessionid := "" + for _, cookie := range preLogin.Cookies() { + if cookie.Name == "JSESSIONID" { + jsessionid = cookie.Value + break + } + } + if jsessionid == "" { + return "", errors.New("step1 pre-login response did not set JSESSIONID") + } + loginCookies := sanitizeMailLoginCookies(d.MailCookies, jsessionid) // 密码 SHA1 哈希 hashedPassword := sha1Hash(fmt.Sprintf("fetion.com.cn:%s", d.Password)) - log.Debugf("DEBUG: 原始密码: %s", d.Password) - log.Debugf("DEBUG: SHA1 输入: fetion.com.cn:%s", d.Password) - log.Debugf("DEBUG: 生成的 Password 哈希: %s", hashedPassword) cguid := strconv.FormatInt(time.Now().UnixMilli(), 10) // 随机生成 cguid @@ -1146,7 +1428,7 @@ func (d *Yun139) step1_password_login() (string, error) { "sec-fetch-user": "?1", "upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0", - "Cookie": d.MailCookies, + "Cookie": loginCookies, } loginData := url.Values{} @@ -1160,11 +1442,10 @@ func (d *Yun139) step1_password_login() (string, error) { loginData.Set("authType", "2") log.Debugf("DEBUG: 登录请求 URL: %s", loginURL) - log.Debugf("DEBUG: 登录请求 Headers: %+v", loginHeaders) - log.Debugf("DEBUG: 登录请求 Body: %s", loginData.Encode()) + log.Debugf("DEBUG: 登录请求已准备") // 设置客户端不跟随重定向 - client := base.RestyClient.SetRedirectPolicy(resty.NoRedirectPolicy()) + client := new139RestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()) res, err := client.R(). SetHeaders(loginHeaders). SetFormDataFromValues(loginData). @@ -1180,24 +1461,36 @@ func (d *Yun139) step1_password_login() (string, error) { } else { log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode()) } - // 恢复客户端的默认重定向策略,以免影响后续请求 - base.RestyClient.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10)) - log.Debugf("DEBUG: 登录响应 Headers: %+v", res.Header()) + log.Debugf("DEBUG: 登录响应 Location present: %t", res.Header().Get("Location") != "") var sid, extractedCguid string // 从 Location 头部提取 sid 和 cguid locationHeader := res.Header().Get("Location") if locationHeader != "" { + d.MailCookies = mergeMailCookieHeader(loginCookies, res.Cookies()) + if riskCode := mailRiskCode(locationHeader); riskCode != "" { + if _, ok := smsSceneForRisk(riskCode); !ok { + return "", fmt.Errorf("139 Mail risk control triggered: %s", riskCode) + } + if strings.TrimSpace(d.SmsCode) == "" { + if sendErr := d.sendSMSVerificationCode(riskCode); sendErr != nil { + return "", sendErr + } + op.MustSaveDriverStorage(d) + return "", errors.New("139 Mail SMS verification code sent; fill sms_code and save the storage again") + } + return d.verifySMSCode(riskCode) + } sidMatch := regexp.MustCompile(`sid=([^&]+)`).FindStringSubmatch(locationHeader) cguidMatch := regexp.MustCompile(`cguid=([^&]+)`).FindStringSubmatch(locationHeader) if len(sidMatch) > 1 { sid = sidMatch[1] - log.Debugf("DEBUG: 从 Location 提取到 sid: %s", sid) + log.Debugf("DEBUG: 从 Location 提取到 sid.") } if len(cguidMatch) > 1 { extractedCguid = cguidMatch[1] - log.Debugf("DEBUG: 从 Location 提取到 cguid: %s", extractedCguid) + log.Debugf("DEBUG: 从 Location 提取到 cguid.") } } @@ -1209,11 +1502,11 @@ func (d *Yun139) step1_password_login() (string, error) { cookieCguidMatch := regexp.MustCompile(`cguid=([^;]+)`).FindStringSubmatch(cookieStr) if len(ssoSidMatch) > 1 && sid == "" { sid = ssoSidMatch[1] - log.Debugf("DEBUG: 从 Set-Cookie 提取到 sid: %s", sid) + log.Debugf("DEBUG: 从 Set-Cookie 提取到 sid.") } if len(cookieCguidMatch) > 1 && extractedCguid == "" { extractedCguid = cookieCguidMatch[1] - log.Debugf("DEBUG: 从 Set-Cookie 提取到 cguid: %s", extractedCguid) + log.Debugf("DEBUG: 从 Set-Cookie 提取到 cguid.") } } } @@ -1222,16 +1515,7 @@ func (d *Yun139) step1_password_login() (string, error) { return "", errors.New("failed to extract sid or cguid from login response") } - // 提取并记录 cookies - loginUrlObj, _ := url.Parse(loginURL) - cookies := base.RestyClient.GetClient().Jar.Cookies(loginUrlObj) - var cookieStrings []string - for _, cookie := range cookies { - cookieStrings = append(cookieStrings, cookie.Name+"="+cookie.Value) - } - cookieStr := strings.Join(cookieStrings, "; ") - log.Debugf("DEBUG: 提取到的 Cookies: %s", cookieStr) - d.MailCookies = cookieStr + d.MailCookies = mergeMailCookieHeader(loginCookies, res.Cookies()) return sid, nil } @@ -1287,6 +1571,16 @@ func (d *Yun139) step2_get_single_token(sid string) (string, error) { return dycpwd, nil } +func mailXMLField(name, value string) string { + return `` + escapeXML(value) + `` +} + +func escapeXML(value string) string { + var escaped bytes.Buffer + _ = xml.EscapeText(&escaped, []byte(value)) + return escaped.String() +} + // --- 辅助函数:加密/解密 --- // sha1Hash 计算 SHA1 哈希值,返回十六进制字符串。 @@ -1582,6 +1876,148 @@ func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) { return newAuthorization, nil } +func extractFastLoginCookies(mailCookies string) (sid string, rmkey string) { + for _, c := range cookiepkg.Parse(mailCookies) { + switch c.Name { + case "Os_SSo_Sid": + sid = c.Value + case "RMKEY": + rmkey = c.Value + } + if sid != "" && rmkey != "" { + return sid, rmkey + } + } + return sid, rmkey +} + +func isRedirectStatus(statusCode int) bool { + return statusCode >= 300 && statusCode <= 399 +} + +func hasCookiePair(raw string) bool { + for _, part := range strings.Split(raw, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(part), "=") + if ok && strings.TrimSpace(name) != "" && value != "" { + return true + } + } + return false +} + +func fetchMailJSessionID(endpoint string) (string, error) { + client := new139RestyClient().SetRedirectPolicy(resty.NoRedirectPolicy()) + res, err := client.R().Get(endpoint) + if res == nil { + return "", fmt.Errorf("pre-login request returned no response: %v", err) + } + if err != nil && !isRedirectStatus(res.StatusCode()) { + return "", fmt.Errorf("pre-login request failed with status %d: %w", res.StatusCode(), err) + } + if res.StatusCode() >= http.StatusBadRequest { + return "", fmt.Errorf("pre-login request failed with status %d", res.StatusCode()) + } + for _, cookie := range res.Cookies() { + if cookie.Name == "JSESSIONID" && cookie.Value != "" { + return cookie.Value, nil + } + } + return "", errors.New("pre-login response did not set JSESSIONID") +} + +func (d *Yun139) tryFastLoginWithCookies() bool { + sid, rmkey := extractFastLoginCookies(d.MailCookies) + if sid == "" || rmkey == "" { + log.Warnf("139yun: fast login skipped, required cookies missing: Os_SSo_Sid=%t RMKEY=%t", sid != "", rmkey != "") + return false + } + + log.Infof("139yun: attempting fast login using existing SID/Cookies (Step 2).") + token, err := d.step2_get_single_token(sid) + if err != nil || token == "" { + log.Warnf("139yun: fast login Step 2 failed: %v", err) + return false + } + + log.Infof("139yun: Step 2 success. Proceeding to Step 3.") + auth, err := d.step3_third_party_login(token) + if err != nil { + log.Warnf("139yun: fast login Step 3 failed: %v", err) + return false + } + + d.Authorization = auth + op.MustSaveDriverStorage(d) + log.Infof("139yun: fast login success (Step 2 -> Step 3).") + return true +} + +func (d *Yun139) validateAndInitCredentials() error { + state, err := d.credentialState() + if err != nil { + return err + } + + switch state { + case credentialStateAuthorization: + // Authorization is refreshed by Init immediately after this helper returns. + log.Debugf("139yun: Authorization exists, skipping initialization login.") + return nil + case credentialStateFullLogin, credentialStateCookiesOnly: + log.Infof("139yun: Authorization missing, attempting login...") + if d.tryFastLoginWithCookies() { + return nil + } + + if state == credentialStateCookiesOnly { + return fmt.Errorf("fast login with cookies failed, and cannot fallback to password login (missing username/password)") + } + + log.Infof("139yun: fast login failed or not possible, performing full password login (Step 1).") + _, err := d.loginWithPassword() + if err != nil { + return fmt.Errorf("login with password failed: %w", err) + } + return nil + default: + return fmt.Errorf("unsupported credential state: %d", state) + } +} + +func (d *Yun139) credentialState() (credentialState, error) { + d.Authorization = strings.TrimSpace(d.Authorization) + d.Username = strings.TrimSpace(d.Username) + d.MailCookies = strings.TrimSpace(d.MailCookies) + + if d.Authorization != "" { + if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") { + return 0, fmt.Errorf("authorization should not include Basic prefix") + } + return credentialStateAuthorization, nil + } + + if d.MailCookies != "" && !hasCookiePair(d.MailCookies) { + return 0, fmt.Errorf("MailCookies format is invalid, please check your configuration") + } + + hasUsername := d.Username != "" + hasPassword := strings.TrimSpace(d.Password) != "" + hasCookies := d.MailCookies != "" + + if hasUsername || hasPassword { + if !hasUsername || !hasPassword || !hasCookies { + return 0, fmt.Errorf("if username or password is provided, all three (mail_cookies, username, password) must be provided") + } + return credentialStateFullLogin, nil + } + + if hasCookies { + return credentialStateCookiesOnly, nil + } + + return 0, fmt.Errorf("authorization is empty and credentials are not provided") +} + func (d *Yun139) loginWithPassword() (string, error) { if d.Username == "" || d.Password == "" || d.MailCookies == "" { return "", errors.New("username, password or mail_cookies is empty") diff --git a/drivers/139/util_test.go b/drivers/139/util_test.go new file mode 100644 index 000000000..1080cd31f --- /dev/null +++ b/drivers/139/util_test.go @@ -0,0 +1,408 @@ +package _139 + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/go-resty/resty/v2" +) + +func TestSanitizeLoginCookiesDropsStaleJSessionIDWhenFreshOneMissing(t *testing.T) { + got := sanitizeMailLoginCookies("JSESSIONID=old; behaviorid=b", "") + want := "behaviorid=b" + if got != want { + t.Fatalf("sanitizeMailLoginCookies() = %q, want %q", got, want) + } +} + +func TestMergeMailCookiesPreservesExistingOrderAndAppendsNewNames(t *testing.T) { + got := mergeMailCookieHeader("z=zv; behaviorid=b; Os_SSo_Sid=old", []*http.Cookie{ + {Name: "RMKEY", Value: "rm"}, + {Name: "Os_SSo_Sid", Value: "sid"}, + {Name: "a", Value: "av"}, + }) + want := "z=zv;behaviorid=b;Os_SSo_Sid=sid;RMKEY=rm;a=av" + if got != want { + t.Fatalf("mergeMailCookieHeader() = %q, want %q", got, want) + } +} + +func TestExtractFastLoginCookies(t *testing.T) { + sid, rmkey := extractFastLoginCookies("RMKEY=rm; Os_SSo_Sid=sid") + if sid != "sid" || rmkey != "rm" { + t.Fatalf("extractFastLoginCookies() = %q, %q; want sid, rm", sid, rmkey) + } +} + +func TestCredentialState(t *testing.T) { + tests := []struct { + name string + d Yun139 + want credentialState + err bool + }{ + { + name: "authorization", + d: Yun139{Addition: Addition{Authorization: " auth "}}, + want: credentialStateAuthorization, + }, + { + name: "full login", + d: Yun139{Addition: Addition{ + MailCookies: "RMKEY=rm; Os_SSo_Sid=sid", + Username: "user", + Password: "password", + }}, + want: credentialStateFullLogin, + }, + { + name: "cookies only", + d: Yun139{Addition: Addition{MailCookies: "RMKEY=rm; Os_SSo_Sid=sid"}}, + want: credentialStateCookiesOnly, + }, + { + name: "partial password login", + d: Yun139{Addition: Addition{Username: "user"}}, + err: true, + }, + { + name: "missing credentials", + d: Yun139{}, + err: true, + }, + { + name: "invalid cookie", + d: Yun139{Addition: Addition{MailCookies: "invalid-cookie"}}, + err: true, + }, + { + name: "authorization with basic prefix", + d: Yun139{Addition: Addition{Authorization: "Basic abc"}}, + err: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.d.credentialState() + if tt.err { + if err == nil { + t.Fatal("credentialState() expected error") + } + return + } + if err != nil { + t.Fatalf("credentialState() unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("credentialState() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIntegrationLoginObtainsAuthorization(t *testing.T) { + if os.Getenv("OPENLIST_139_INTEGRATION") != "1" { + t.Skip("set OPENLIST_139_INTEGRATION=1 to run live 139Yun login checks") + } + base.RestyClient = resty.New(). + SetHeader("user-agent", base.UserAgent). + SetRetryCount(3). + SetRetryResetReaders(true). + SetTimeout(30 * time.Second) + + username := os.Getenv("OPENLIST_139_USERNAME") + password := os.Getenv("OPENLIST_139_PASSWORD") + mailCookies := os.Getenv("OPENLIST_139_MAIL_COOKIES") + authorization := strings.TrimSpace(os.Getenv("OPENLIST_139_AUTHORIZATION")) + + if authorization != "" { + t.Run("authorization", func(t *testing.T) { + d := Yun139{Addition: Addition{Authorization: authorization}} + state, err := d.credentialState() + if err != nil { + t.Fatalf("credentialState() unexpected error: %v", err) + } + if state != credentialStateAuthorization { + t.Fatalf("credentialState() = %v, want authorization", state) + } + if d.Authorization == "" || strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") { + t.Fatal("authorization should be present without Basic prefix") + } + }) + } + + if mailCookies == "" { + t.Fatal("OPENLIST_139_MAIL_COOKIES is required") + } + + runFastLogin := func(t *testing.T, mailCookies string) { + t.Helper() + d := Yun139{Addition: Addition{MailCookies: mailCookies}} + state, err := d.credentialState() + if err != nil { + t.Fatalf("credentialState() unexpected error: %v", err) + } + if state != credentialStateCookiesOnly { + t.Fatalf("credentialState() = %v, want cookies only", state) + } + sid, rmkey := extractFastLoginCookies(d.MailCookies) + if sid == "" || rmkey == "" { + t.Fatal("mail cookies are missing Os_SSo_Sid or RMKEY") + } + token, err := d.step2_get_single_token(sid) + if err != nil { + t.Fatalf("step2_get_single_token() error: %v", err) + } + auth, err := d.step3_third_party_login(token) + if err != nil { + t.Fatalf("step3_third_party_login() error: %v", err) + } + d.Authorization = auth + if d.Authorization == "" { + t.Fatal("authorization is empty after fast login") + } + } + + if username == "" || password == "" { + t.Fatal("OPENLIST_139_USERNAME and OPENLIST_139_PASSWORD are required for password fallback") + } + + var refreshedMailCookies string + var generatedAuthorization string + t.Run("password login fallback", func(t *testing.T) { + d := Yun139{Addition: Addition{ + MailCookies: mailCookies, + Username: username, + Password: password, + }} + state, err := d.credentialState() + if err != nil { + t.Fatalf("credentialState() unexpected error: %v", err) + } + if state != credentialStateFullLogin { + t.Fatalf("credentialState() = %v, want full login", state) + } + passId, err := d.step1_password_login() + if err != nil { + t.Fatalf("step1_password_login() error: %v", err) + } + token, err := d.step2_get_single_token(passId) + if err != nil { + t.Fatalf("step2_get_single_token() error: %v", err) + } + auth, err := d.step3_third_party_login(token) + if err != nil { + t.Fatalf("step3_third_party_login() error: %v", err) + } + d.Authorization = auth + if auth == "" || d.Authorization == "" { + t.Fatal("authorization is empty after password login") + } + generatedAuthorization = auth + refreshedMailCookies = d.MailCookies + }) + + t.Run("authorization generated by password login", func(t *testing.T) { + if generatedAuthorization == "" { + t.Fatal("password login did not generate authorization") + } + d := Yun139{Addition: Addition{Authorization: generatedAuthorization}} + state, err := d.credentialState() + if err != nil { + t.Fatalf("credentialState() unexpected error: %v", err) + } + if state != credentialStateAuthorization { + t.Fatalf("credentialState() = %v, want authorization", state) + } + if strings.HasPrefix(strings.ToLower(d.Authorization), "basic ") { + t.Fatal("authorization should not include Basic prefix") + } + }) + + t.Run("mail cookies fast login from input", func(t *testing.T) { + sid, rmkey := extractFastLoginCookies(mailCookies) + if sid == "" || rmkey == "" { + t.Skip("input mail cookies are missing Os_SSo_Sid or RMKEY") + } + runFastLogin(t, mailCookies) + }) + + t.Run("mail cookies fast login after password login", func(t *testing.T) { + if refreshedMailCookies == "" { + t.Fatal("password login did not refresh mail cookies") + } + runFastLogin(t, refreshedMailCookies) + }) +} + +func TestIsRedirectStatus(t *testing.T) { + for _, status := range []int{300, 301, 302, 307, 399} { + if !isRedirectStatus(status) { + t.Fatalf("isRedirectStatus(%d) = false, want true", status) + } + } + for _, status := range []int{200, 299, 400, 500} { + if isRedirectStatus(status) { + t.Fatalf("isRedirectStatus(%d) = true, want false", status) + } + } +} + +func TestFetchMailJSessionIDAcceptsRedirectResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: "JSESSIONID", Value: "fresh"}) + http.Redirect(w, r, "/next", http.StatusFound) + })) + defer server.Close() + + got, err := fetchMailJSessionID(server.URL) + if err != nil { + t.Fatalf("fetchMailJSessionID() error: %v", err) + } + if got != "fresh" { + t.Fatalf("fetchMailJSessionID() = %q, want fresh", got) + } +} + +func TestInvalidAuthorizationDoesNotUseCookieFastLogin(t *testing.T) { + d := Yun139{Addition: Addition{ + Authorization: "not-base64", + MailCookies: "Os_SSo_Sid=sid; RMKEY=rmkey", + }} + + err := d.refreshToken() + if err == nil || !strings.Contains(err.Error(), "password login failed") { + t.Fatalf("refreshToken() error = %v, want password login fallback error", err) + } + if d.Authorization != "not-base64" { + t.Fatalf("Authorization = %q, want original invalid value retained", d.Authorization) + } +} + +func TestSMSSceneForRisk(t *testing.T) { + tests := map[string]int{ + "S025": 1, + "S035": 1, + "PML401010062": 2, + "MW0016": 4, + } + for riskCode, want := range tests { + got, ok := smsSceneForRisk(riskCode) + if !ok || got != want { + t.Fatalf("smsSceneForRisk(%q) = %d, %t; want %d, true", riskCode, got, ok, want) + } + } + if _, ok := smsSceneForRisk("PICTURE_ONLY"); ok { + t.Fatal("smsSceneForRisk() accepted unsupported risk code") + } +} + +func TestSanitizeMailLoginCookiesKeepsDeviceContext(t *testing.T) { + got := sanitizeMailLoginCookies( + "behaviorid=device; Os_SSo_Sid=old-sid; RMKEY=old-rmkey; JSESSIONID=old-session; S_DEVICE_TOKEN=fingerprint", + "new-session", + ) + want := "behaviorid=device;JSESSIONID=new-session;S_DEVICE_TOKEN=fingerprint" + if got != want { + t.Fatalf("sanitizeMailLoginCookies() = %q, want %q", got, want) + } +} + +func TestSendSMSVerificationCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + if r.URL.Query().Get("func") != "login:sendSmsCodeByScene" { + t.Errorf("func = %q", r.URL.Query().Get("func")) + } + if !strings.Contains(string(body), `1`) { + t.Errorf("request body = %s", body) + } + if !strings.Contains(r.Header.Get("Cookie"), "device=fingerprint") { + t.Errorf("Cookie = %q", r.Header.Get("Cookie")) + } + http.SetCookie(w, &http.Cookie{Name: "challenge", Value: "sms"}) + _, _ = io.WriteString(w, `{"code":"S_OK"}`) + })) + defer server.Close() + + oldURL := mailSMSURL + mailSMSURL = server.URL + defer func() { mailSMSURL = oldURL }() + + d := Yun139{Addition: Addition{Username: "18800000000", MailCookies: "device=fingerprint"}} + if err := d.sendSMSVerificationCode("S025"); err != nil { + t.Fatalf("sendSMSVerificationCode() error = %v", err) + } + if !strings.Contains(d.MailCookies, "challenge=sms") { + t.Fatalf("MailCookies = %q", d.MailCookies) + } +} + +func TestSendSMSVerificationCodeStopsAtPictureChallenge(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, `{"code":"PML401010021"}`) + })) + defer server.Close() + + oldURL := mailSMSURL + mailSMSURL = server.URL + defer func() { mailSMSURL = oldURL }() + + d := Yun139{Addition: Addition{Username: "18800000000"}} + err := d.sendSMSVerificationCode("S025") + if err == nil || !strings.Contains(err.Error(), "requires picture verification") { + t.Fatalf("sendSMSVerificationCode() error = %v", err) + } +} + +func TestVerifySMSCode(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read request body: %v", err) + } + if r.URL.Query().Get("func") != "/login/inlogin.action" { + t.Errorf("func = %q", r.URL.Query().Get("func")) + } + wantHash := sha1Hash("fetion.com.cn:123456") + if !strings.Contains(string(body), ``+wantHash+``) { + t.Errorf("request body does not contain SMS code hash: %s", body) + } + http.SetCookie(w, &http.Cookie{Name: "RMKEY", Value: "new-rmkey"}) + _, _ = io.WriteString(w, `{"code":"S_OK","var":{"loginSuccessUrl":"https://mail.10086.cn/?sid=sms-sid"}}`) + })) + defer server.Close() + + oldURL := mailSMSURL + mailSMSURL = server.URL + defer func() { mailSMSURL = oldURL }() + + d := Yun139{Addition: Addition{ + Username: "18800000000", + SmsCode: "123456", + MailCookies: "challenge=sms", + }} + sid, err := d.verifySMSCode("S025") + if err != nil { + t.Fatalf("verifySMSCode() error = %v", err) + } + if sid != "sms-sid" { + t.Fatalf("sid = %q", sid) + } + if d.SmsCode != "" { + t.Fatalf("SmsCode = %q, want cleared", d.SmsCode) + } + if !strings.Contains(d.MailCookies, "RMKEY=new-rmkey") { + t.Fatalf("MailCookies = %q", d.MailCookies) + } +}