From 991718a874f6d86841a794d0f814eea3093c6a87 Mon Sep 17 00:00:00 2001 From: ucnacdx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sun, 1 Feb 2026 22:45:57 +0800 Subject: [PATCH 1/3] feat(drivers/139): optimize login flow with cookie reuse and robust fallback - Cookie Reuse Strategy: Introduced a fast-path login mechanism. If valid MailCookies (containing Os_SSo_Sid) are present, the driver attempts to skip the full password login (Step 1) and directly exchange the SID for a token (Step 2 -> Step 3). This significantly reduces risk control triggers and improves initialization speed. - Authorization Priority: Added a check to skip the entire login process if a valid Authorization string is already present in the configuration. - Robust Fallback: Implemented a fallback mechanism. If the fast-path (cookie reuse) fails (e.g., expired cookie), the driver automatically falls back to the full password login flow (Step 1 -> Step 2 -> Step 3) to ensure service availability. - Credential Validation: Refined validation logic. Now accepts configuration with only Authorization, or only MailCookies (for fast path), while strictly enforcing that if Username or Password is provided, all three credentials (including MailCookies) must be present to support the fallback password login. - Security: Ensured that when falling back to password login, only necessary cookies are sent (via sanitizeLoginCookies) to avoid polluting the request. - Code Cleanup: Removed unused imports and improved code formatting. --- drivers/139/driver.go | 15 +-- drivers/139/util.go | 258 +++++++++++++++++++++++++++++++++++------- 2 files changed, 224 insertions(+), 49 deletions(-) diff --git a/drivers/139/driver.go b/drivers/139/driver.go index 4e3ea3e920..a099b221c8 100644 --- a/drivers/139/driver.go +++ b/drivers/139/driver.go @@ -42,18 +42,11 @@ func (d *Yun139) GetAddition() driver.Additional { func (d *Yun139) Init(ctx context.Context) error { if d.ref == nil { - if len(d.Authorization) == 0 { - 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") - } + if err := d.validateAndInitCredentials(); err != nil { + return err } + + // Always refresh token for renewal (uses original fallback behavior) err := d.refreshToken() if err != nil { return err diff --git a/drivers/139/util.go b/drivers/139/util.go index c026de52a2..959da6b415 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -171,29 +171,23 @@ func (d *Yun139) request(url string, method string, callback base.ReqCallback, r } log.Debugf("[139] response body: %s", res.String()) if !e.Success { - // Always try to unmarshal to the specific response type first if 'resp' is provided. - if resp != nil { - err = utils.Json.Unmarshal(res.Body(), resp) - if err != nil { - log.Debugf("[139] failed to unmarshal response to specific type: %v", err) - return nil, err // Return unmarshal error - } - if createBatchOprTaskResp, ok := resp.(*CreateBatchOprTaskResp); ok { - log.Debugf("[139] CreateBatchOprTaskResp.Result.ResultCode: %s", createBatchOprTaskResp.Result.ResultCode) - if createBatchOprTaskResp.Result.ResultCode == "0" { - goto SUCCESS_PROCESS - } + if resp == nil { + return nil, errors.New(e.Message) + } + // Attempt to unmarshal to see if it contains the special success code. + if err := utils.Json.Unmarshal(res.Body(), resp); err == nil { + if taskResp, ok := resp.(*CreateBatchOprTaskResp); ok && taskResp.Result.ResultCode == "0" { + return res.Body(), nil } } - return nil, errors.New(e.Message) // Fallback to original error if not handled + return nil, errors.New(e.Message) } + if resp != nil { - err = utils.Json.Unmarshal(res.Body(), resp) - if err != nil { + if err := utils.Json.Unmarshal(res.Body(), resp); err != nil { return nil, err } } -SUCCESS_PROCESS: return res.Body(), nil } @@ -763,10 +757,84 @@ func getMd5(dataStr string) string { return fmt.Sprintf("%x", hash) } +// sanitizeLoginCookies enforces a strict allowlist and order for cookies to prevent login failures. +func sanitizeLoginCookies(existingCookies string, newJSessionID string) string { + orderedCookieNames := []string{ + "behaviorid", + "Os_SSo_Sid", + "_139_index_isLoginType", + "_139_login_version", + "Login_UserNumber", + "cookiepartid8011", + "_139_login_agreement", + "UserData", + "rmUin8011", + "cookiepartid", + "UUIDToken", + "SkinPath28011", + "cbauto", + "areaCode8011", + "cookieLen", + "DEVICE_INFO_DIGEST", + "JSESSIONID", + "loginProcessFlag", + "provCode8011", + "S_DEVICE_TOKEN", + "taskIdCloud", + "UserNowState", + "UserNowState8011", + "ut8011", + } + + // Store existing cookies in a map for easy lookup + existingCookiesMap := make(map[string]string) + cookies := strings.Split(existingCookies, ";") + for _, cookie := range cookies { + cookie = strings.TrimSpace(cookie) + parts := strings.SplitN(cookie, "=", 2) + if len(parts) == 2 { + existingCookiesMap[parts[0]] = parts[1] + } + } + + var finalCookieParts []string + // Iterate through the ordered names and build the final cookie string + for _, name := range orderedCookieNames { + if name == "JSESSIONID" { + if newJSessionID != "" { + finalCookieParts = append(finalCookieParts, name+"="+newJSessionID) + } + continue + } + + if value, ok := existingCookiesMap[name]; ok { + finalCookieParts = append(finalCookieParts, name+"="+value) + } + } + + return strings.Join(finalCookieParts, "; ") +} + func (d *Yun139) step1_password_login() (string, error) { log.Debugf("--- 执行步骤 1: 登录 API ---") loginURL := "https://mail.10086.cn/Login/Login.ashx" + log.Debugf("--- 执行步骤 1.1: 获取 JSESSIONID ---") + getResp, err := base.RestyClient.R().Get(loginURL) + if err != nil { + return "", fmt.Errorf("step1 get jsessionid failed: %w", err) + } + var jsessionid string + for _, cookie := range getResp.Cookies() { + if cookie.Name == "JSESSIONID" { + jsessionid = cookie.Value + break + } + } + if jsessionid == "" { + log.Warnf("139yun: failed to get JSESSIONID from GET request.") + } + // 密码 SHA1 哈希 hashedPassword := sha1Hash(fmt.Sprintf("fetion.com.cn:%s", d.Password)) log.Debugf("DEBUG: 原始密码: %s", d.Password) @@ -775,6 +843,8 @@ func (d *Yun139) step1_password_login() (string, error) { cguid := strconv.FormatInt(time.Now().UnixMilli(), 10) // 随机生成 cguid + sanitizedCookie := sanitizeLoginCookies(d.MailCookies, jsessionid) + loginHeaders := map[string]string{ "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6,en-GB;q=0.5", @@ -793,7 +863,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": sanitizedCookie, } loginData := url.Values{} @@ -811,37 +881,42 @@ func (d *Yun139) step1_password_login() (string, error) { log.Debugf("DEBUG: 登录请求 Body: %s", loginData.Encode()) // 设置客户端不跟随重定向 - client := base.RestyClient.SetRedirectPolicy(resty.NoRedirectPolicy()) + // Create a new client to avoid race conditions on the global client's redirect policy. + client := resty.New().SetRedirectPolicy(resty.NoRedirectPolicy()) res, err := client.R(). SetHeaders(loginHeaders). SetFormDataFromValues(loginData). Post(loginURL) - if err != nil { - // 如果是重定向错误,则不作为失败处理,因为我们禁止了自动重定向 - if res != nil && res.StatusCode() >= 300 && res.StatusCode() < 400 { - log.Debugf("DEBUG: 登录响应 Status Code: %d (Redirect)", res.StatusCode()) - } else { - return "", fmt.Errorf("step1 login request failed: %w", err) - } - } else { - log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode()) + // When NoRedirectPolicy is used, resty returns an error on redirect, but the response should still be available. + if err != nil && !strings.Contains(err.Error(), "auto redirect is disabled") { + return "", fmt.Errorf("step1 login request failed: %w", err) } - // 恢复客户端的默认重定向策略,以免影响后续请求 - base.RestyClient.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10)) + if res == nil { + return "", fmt.Errorf("step1 login request failed: response is nil (error: %v)", err) + } + log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode()) log.Debugf("DEBUG: 登录响应 Headers: %+v", res.Header()) var sid, extractedCguid string - // 从 Location 头部提取 sid 和 cguid + // 从 Location 头部提取 sid 和 cguid, 并处理风控 locationHeader := res.Header().Get("Location") if locationHeader != "" { + if ecMatch := regexp.MustCompile(`ec=([^&]+)`).FindStringSubmatch(locationHeader); len(ecMatch) > 1 { + return "", fmt.Errorf("risk control triggered: %s", ecMatch[0]) + } + 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) + } else if strings.Contains(locationHeader, "default.html") { + return "", errors.New("authentication failed: sid is missing in default.html redirect") } + if len(cguidMatch) > 1 { extractedCguid = cguidMatch[1] log.Debugf("DEBUG: 从 Location 提取到 cguid: %s", extractedCguid) @@ -869,16 +944,28 @@ 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 + // Update cookies from response, merging new ones with existing ones. + existingCookiesMap := make(map[string]string) + // 1. Populate map with existing cookies from the driver. + cookies := strings.Split(d.MailCookies, ";") for _, cookie := range cookies { - cookieStrings = append(cookieStrings, cookie.Name+"="+cookie.Value) + cookie = strings.TrimSpace(cookie) + parts := strings.SplitN(cookie, "=", 2) + if len(parts) == 2 { + existingCookiesMap[parts[0]] = parts[1] + } + } + // 2. Update map with new cookies from the Set-Cookie headers in the response. + for _, cookie := range res.Cookies() { + existingCookiesMap[cookie.Name] = cookie.Value } - cookieStr := strings.Join(cookieStrings, "; ") - log.Debugf("DEBUG: 提取到的 Cookies: %s", cookieStr) - d.MailCookies = cookieStr + // 3. Rebuild the cookie string. The order doesn't matter here, as sanitizeLoginCookies will reorder it later if needed. + var finalCookieParts []string + for name, value := range existingCookiesMap { + finalCookieParts = append(finalCookieParts, name+"="+value) + } + d.MailCookies = strings.Join(finalCookieParts, "; ") + log.Debugf("DEBUG: 更新后的 Cookies: %s", d.MailCookies) return sid, nil } @@ -1231,6 +1318,101 @@ func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) { return newAuthorization, nil } +func (d *Yun139) validateAndInitCredentials() error { + // More robust validation for MailCookies + trimmedCookies := strings.TrimSpace(d.MailCookies) + if trimmedCookies != "" { + d.MailCookies = trimmedCookies // Update with trimmed value + if !strings.Contains(d.MailCookies, "=") || len(strings.Split(d.MailCookies, "=")[0]) == 0 { + return fmt.Errorf("MailCookies format is invalid, please check your configuration") + } + } + + // Priority 1: If Authorization exists, skip login process completely. + // We assume it's valid for now; validity will be checked by refreshToken() later in Init(). + if d.Authorization != "" { + log.Debugf("139yun: Authorization exists, skipping initialization login.") + return nil + } + + // Validate all-or-nothing check for username and password + // "Cookies can exist alone, but if username or password is provided, all three must be provided" + hasUserOrPass := d.Username != "" || d.Password != "" + hasAll := d.MailCookies != "" && d.Username != "" && d.Password != "" + + if hasUserOrPass && !hasAll { + return fmt.Errorf("if username or password is provided, all three (mail_cookies, username, password) must be provided") + } + + // If no Authorization, attempt to generate it. + // We can try if we have ALL credentials OR if we just have MailCookies (try fast path only) + if hasAll || d.MailCookies != "" { + log.Infof("139yun: Authorization missing, attempting login...") + + success := false + var sid string + + // Priority 2: Try fast login using existing cookies (Step 2 -> Step 3) + // Extract SID from current MailCookies + cookies := strings.Split(d.MailCookies, ";") + for _, cookie := range cookies { + cookie = strings.TrimSpace(cookie) + // Check for Os_SSo_Sid + if strings.HasPrefix(cookie, "Os_SSo_Sid=") { + sid = strings.TrimPrefix(cookie, "Os_SSo_Sid=") + break + } + } + + // Try Step 2 directly with existing SID and Cookies (using full cookies as implicit context) + if sid != "" { + 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.Infof("139yun: Step 2 success. Proceeding to Step 3.") + // If Step 2 succeeds, proceed to Step 3 + auth, err := d.step3_third_party_login(token) + if err == nil { + d.Authorization = auth + op.MustSaveDriverStorage(d) + success = true + log.Infof("139yun: fast login success (Step 2 -> Step 3).") + } else { + log.Warnf("139yun: fast login Step 3 failed: %v", err) + } + } else { + log.Warnf("139yun: fast login Step 2 failed: %v", err) + } + } else { + if d.MailCookies != "" { + log.Warnf("139yun: Os_SSo_Sid not found in existing cookies. Skipping fast login.") + } + } + + // Priority 3: Fallback to full password login (Step 1 -> Step 2 -> Step 3) + // Only possible if we have ALL credentials (hasAll == true) + if !success { + if hasAll { + log.Infof("139yun: fast login failed or not possible, performing full password login (Step 1).") + // loginWithPassword() calls step1_password_login(), which internally strictly uses + // sanitizeLoginCookies() to ensure only necessary cookies are sent for password login. + _, err := d.loginWithPassword() + if err != nil { + return fmt.Errorf("login with password failed: %w", err) + } + } else { + // If we don't have password, we can't fallback. report error. + return fmt.Errorf("fast login with cookies failed, and cannot fallback to password login (missing username/password)") + } + } + } else { + // No Authorization and missing credentials (and even no cookies) + return fmt.Errorf("authorization is empty and credentials are not provided") + } + + return nil +} + func (d *Yun139) loginWithPassword() (string, error) { if d.Username == "" || d.Password == "" || d.MailCookies == "" { return "", errors.New("username, password or mail_cookies is empty") From bec2306b349a123c2ff12cb23d17c1961b2bfadf Mon Sep 17 00:00:00 2001 From: ucnacdx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:35:19 +0800 Subject: [PATCH 2/3] refactor(drivers/139): harden login credential flow Co-Authored-By: OpenAI Codex --- drivers/139/meta.go | 8 +- drivers/139/util.go | 402 +++++++++++++++++++++------------------ drivers/139/util_test.go | 116 +++++++++++ 3 files changed, 337 insertions(+), 189 deletions(-) create mode 100644 drivers/139/util_test.go diff --git a/drivers/139/meta.go b/drivers/139/meta.go index 91d54fd300..0d3b9d5e90 100644 --- a/drivers/139/meta.go +++ b/drivers/139/meta.go @@ -7,10 +7,10 @@ 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."` + MailCookies string `json:"mail_cookies" type:"text" help:"Cookies from mail.139.com. Can be used alone for fast login, or with username and password for full login fallback."` driver.RootID Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"` CloudID string `json:"cloud_id"` diff --git a/drivers/139/util.go b/drivers/139/util.go index 959da6b415..19e4ccb882 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -26,6 +26,7 @@ import ( "github.com/OpenListTeam/OpenList/v4/internal/driver" "github.com/OpenListTeam/OpenList/v4/internal/model" "github.com/OpenListTeam/OpenList/v4/internal/op" + 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/go-resty/resty/v2" @@ -38,6 +39,41 @@ const ( KEY_HEX_2 = "7150714477323633586746674c337538" // 第二层 AES 解密密钥 ) +var mailLoginCookieOrder = []string{ + "behaviorid", + "Os_SSo_Sid", + "_139_index_isLoginType", + "_139_login_version", + "Login_UserNumber", + "cookiepartid8011", + "_139_login_agreement", + "UserData", + "rmUin8011", + "cookiepartid", + "UUIDToken", + "SkinPath28011", + "cbauto", + "areaCode8011", + "cookieLen", + "DEVICE_INFO_DIGEST", + "JSESSIONID", + "loginProcessFlag", + "provCode8011", + "S_DEVICE_TOKEN", + "taskIdCloud", + "UserNowState", + "UserNowState8011", + "ut8011", +} + +type credentialState int + +const ( + credentialStateAuthorization credentialState = iota + credentialStateFullLogin + credentialStateCookiesOnly +) + // do others that not defined in Driver interface func (d *Yun139) isFamily() bool { return d.Type == "family" @@ -111,8 +147,8 @@ func (d *Yun139) refreshToken() error { 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) + _, loginErr := d.loginWithPassword() + log.Debugf("139yun: password login generated a new authorization.") if loginErr != nil { return fmt.Errorf("failed to login with password after refresh failed: %w", loginErr) } @@ -757,62 +793,95 @@ func getMd5(dataStr string) string { return fmt.Sprintf("%x", hash) } -// sanitizeLoginCookies enforces a strict allowlist and order for cookies to prevent login failures. -func sanitizeLoginCookies(existingCookies string, newJSessionID string) string { - orderedCookieNames := []string{ - "behaviorid", - "Os_SSo_Sid", - "_139_index_isLoginType", - "_139_login_version", - "Login_UserNumber", - "cookiepartid8011", - "_139_login_agreement", - "UserData", - "rmUin8011", - "cookiepartid", - "UUIDToken", - "SkinPath28011", - "cbauto", - "areaCode8011", - "cookieLen", - "DEVICE_INFO_DIGEST", - "JSESSIONID", - "loginProcessFlag", - "provCode8011", - "S_DEVICE_TOKEN", - "taskIdCloud", - "UserNowState", - "UserNowState8011", - "ut8011", - } - - // Store existing cookies in a map for easy lookup - existingCookiesMap := make(map[string]string) - cookies := strings.Split(existingCookies, ";") - for _, cookie := range cookies { - cookie = strings.TrimSpace(cookie) - parts := strings.SplitN(cookie, "=", 2) - if len(parts) == 2 { - existingCookiesMap[parts[0]] = parts[1] +func parseCookieMap(raw string) map[string]string { + cookies := make(map[string]string) + for _, c := range cookiepkg.Parse(raw) { + if c.Name != "" { + cookies[c.Name] = c.Value + } + } + return cookies +} + +func formatCookiesByOrder(cookies map[string]string, orderedNames []string, includeExtraNames bool) string { + if len(cookies) == 0 { + return "" + } + + seen := make(map[string]struct{}, len(orderedNames)) + parts := make([]string, 0, len(cookies)) + for _, name := range orderedNames { + seen[name] = struct{}{} + if value, ok := cookies[name]; ok { + parts = append(parts, name+"="+value) } } - var finalCookieParts []string - // Iterate through the ordered names and build the final cookie string - for _, name := range orderedCookieNames { - if name == "JSESSIONID" { - if newJSessionID != "" { - finalCookieParts = append(finalCookieParts, name+"="+newJSessionID) + if includeExtraNames { + extraNames := make([]string, 0, len(cookies)) + for name := range cookies { + if _, ok := seen[name]; !ok { + extraNames = append(extraNames, name) } - continue } + sort.Strings(extraNames) + for _, name := range extraNames { + parts = append(parts, name+"="+cookies[name]) + } + } + + return strings.Join(parts, "; ") +} + +// sanitizeLoginCookies filters and orders the mail login cookies. A stale +// JSESSIONID is intentionally dropped when a fresh one cannot be fetched, +// because sending an expired JSESSIONID can trigger mail.10086.cn risk control. +func sanitizeLoginCookies(existingCookies string, newJSessionID string) string { + cookies := parseCookieMap(existingCookies) + delete(cookies, "JSESSIONID") + if newJSessionID != "" { + cookies["JSESSIONID"] = newJSessionID + } + return formatCookiesByOrder(cookies, mailLoginCookieOrder, false) +} + +func mergeMailCookies(existingCookies string, responseCookies []*http.Cookie) string { + cookies := parseCookieMap(existingCookies) + for _, c := range responseCookies { + if c.Name != "" { + cookies[c.Name] = c.Value + } + } + return formatCookiesByOrder(cookies, mailLoginCookieOrder, true) +} - if value, ok := existingCookiesMap[name]; ok { - finalCookieParts = append(finalCookieParts, name+"="+value) +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 +} - return strings.Join(finalCookieParts, "; ") +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 (d *Yun139) step1_password_login() (string, error) { @@ -837,9 +906,6 @@ func (d *Yun139) step1_password_login() (string, error) { // 密码 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 @@ -877,8 +943,7 @@ 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: 登录请求已准备,cookie_count=%d", len(cookiepkg.Parse(sanitizedCookie))) // 设置客户端不跟随重定向 // Create a new client to avoid race conditions on the global client's redirect policy. @@ -888,15 +953,16 @@ func (d *Yun139) step1_password_login() (string, error) { SetFormDataFromValues(loginData). Post(loginURL) - // When NoRedirectPolicy is used, resty returns an error on redirect, but the response should still be available. - if err != nil && !strings.Contains(err.Error(), "auto redirect is disabled") { - return "", fmt.Errorf("step1 login request failed: %w", err) - } if res == nil { return "", fmt.Errorf("step1 login request failed: response is nil (error: %v)", err) } + // With NoRedirectPolicy, redirects can be surfaced as errors while the + // response is still available. Accept only HTTP redirects explicitly. + if err != nil && !isRedirectStatus(res.StatusCode()) { + return "", fmt.Errorf("step1 login request failed: status %d: %w", res.StatusCode(), err) + } log.Debugf("DEBUG: 登录响应 Status Code: %d", res.StatusCode()) - log.Debugf("DEBUG: 登录响应 Headers: %+v", res.Header()) + log.Debugf("DEBUG: 登录响应 Location present: %t", res.Header().Get("Location") != "") var sid, extractedCguid string @@ -912,14 +978,14 @@ func (d *Yun139) step1_password_login() (string, error) { if len(sidMatch) > 1 { sid = sidMatch[1] - log.Debugf("DEBUG: 从 Location 提取到 sid: %s", sid) + log.Debugf("DEBUG: 从 Location 提取到 sid.") } else if strings.Contains(locationHeader, "default.html") { return "", errors.New("authentication failed: sid is missing in default.html redirect") } if len(cguidMatch) > 1 { extractedCguid = cguidMatch[1] - log.Debugf("DEBUG: 从 Location 提取到 cguid: %s", extractedCguid) + log.Debugf("DEBUG: 从 Location 提取到 cguid.") } } @@ -931,11 +997,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.") } } } @@ -944,28 +1010,8 @@ func (d *Yun139) step1_password_login() (string, error) { return "", errors.New("failed to extract sid or cguid from login response") } - // Update cookies from response, merging new ones with existing ones. - existingCookiesMap := make(map[string]string) - // 1. Populate map with existing cookies from the driver. - cookies := strings.Split(d.MailCookies, ";") - for _, cookie := range cookies { - cookie = strings.TrimSpace(cookie) - parts := strings.SplitN(cookie, "=", 2) - if len(parts) == 2 { - existingCookiesMap[parts[0]] = parts[1] - } - } - // 2. Update map with new cookies from the Set-Cookie headers in the response. - for _, cookie := range res.Cookies() { - existingCookiesMap[cookie.Name] = cookie.Value - } - // 3. Rebuild the cookie string. The order doesn't matter here, as sanitizeLoginCookies will reorder it later if needed. - var finalCookieParts []string - for name, value := range existingCookiesMap { - finalCookieParts = append(finalCookieParts, name+"="+value) - } - d.MailCookies = strings.Join(finalCookieParts, "; ") - log.Debugf("DEBUG: 更新后的 Cookies: %s", d.MailCookies) + d.MailCookies = mergeMailCookies(d.MailCookies, res.Cookies()) + log.Debugf("DEBUG: 更新后的 Cookies 数量: %d", len(cookiepkg.Parse(d.MailCookies))) return sid, nil } @@ -977,29 +1023,21 @@ func (d *Yun139) step2_get_single_token(sid string) (string, error) { exchangeArtifactURL := fmt.Sprintf("https://smsrebuild1.mail.10086.cn/setting/s?func=%s&sid=%s&cguid=%s", url.QueryEscape("umc:getArtifact"), sid, cguid) // 从 MailCookies 中提取 RMKEY - var rmkey string - cookies := strings.Split(d.MailCookies, ";") - for _, cookie := range cookies { - cookie = strings.TrimSpace(cookie) - if strings.HasPrefix(cookie, "RMKEY=") { - rmkey = cookie - break - } - } + _, rmkey := extractFastLoginCookies(d.MailCookies) if rmkey == "" { return "", errors.New("RMKEY not found in MailCookies") } + rmkeyHeader := "RMKEY=" + rmkey exchangePassidHeaders := map[string]string{ "Host": "smsrebuild1.mail.10086.cn", - "Cookie": rmkey, + "Cookie": rmkeyHeader, "Content-Type": "text/xml; charset=utf-8", "Accept-Encoding": "gzip", "User-Agent": "okhttp/4.12.0", } - log.Debugf("DEBUG: 换passid 请求 URL: %s", exchangeArtifactURL) - log.Debugf("DEBUG: 换passid 请求 Headers: %+v", exchangePassidHeaders) + log.Debugf("DEBUG: 换passid 请求已准备") res, err := base.RestyClient.R(). SetHeaders(exchangePassidHeaders). @@ -1010,14 +1048,13 @@ func (d *Yun139) step2_get_single_token(sid string) (string, error) { } log.Debugf("DEBUG: 换passid 响应 Status Code: %d", res.StatusCode()) - log.Debugf("DEBUG: 换passid 响应 Headers: %+v", res.Header()) - log.Debugf("DEBUG: 换passid 响应 Body: %s...", res.String()[:min(len(res.String()), 500)]) + log.Debugf("DEBUG: 换passid 响应 Body length: %d", len(res.Body())) dycpwd := jsoniter.Get(res.Body(), "var", "artifact").ToString() if dycpwd == "" { return "", errors.New("failed to extract dycpwd from artifact exchange response") } - log.Debugf("DEBUG: 提取到 dycpwd: %s", dycpwd) + log.Debugf("DEBUG: dycpwd extracted from artifact exchange response.") return dycpwd, nil } @@ -1174,7 +1211,7 @@ func (d *Yun139) yun139EncryptedRequest(url string, body interface{}, headers ma if err != nil { return nil, fmt.Errorf("yun139EncryptedRequest: failed to marshal and sort body: %w", err) } - log.Debugf("yun139EncryptedRequest: Request Body (plaintext): %s", sortedJson) + log.Debugf("yun139EncryptedRequest: plaintext request body prepared, length=%d", len(sortedJson)) // 3. Encrypt the body using AES/CBC iv := make([]byte, 16) // 16 bytes for AES-128 @@ -1206,7 +1243,7 @@ func (d *Yun139) yun139EncryptedRequest(url string, body interface{}, headers ma var decryptedBytes []byte if len(respBody) > 0 && respBody[0] == '{' { - log.Warnf("yun139EncryptedRequest: received a plain JSON response, not an encrypted string. Body: %s", string(respBody)) + log.Warnf("yun139EncryptedRequest: received a plain JSON response, not an encrypted string, length=%d", len(respBody)) decryptedBytes = respBody } else { decodedResp, err := base64.StdEncoding.DecodeString(string(respBody)) @@ -1227,7 +1264,7 @@ func (d *Yun139) yun139EncryptedRequest(url string, body interface{}, headers ma } } - log.Debugf("yun139EncryptedRequest: Response Body (decrypted): %s", string(decryptedBytes)) + log.Debugf("yun139EncryptedRequest: decrypted response body received, length=%d", len(decryptedBytes)) // 6. Unmarshal to the final response struct if resp != nil { @@ -1281,7 +1318,7 @@ func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) { if hexInner == "" { return "", errors.New("missing data field in first layer decryption result") } - log.Debugf("DEBUG: 第一层解密提取到 hex_inner: %s...", hexInner[:min(len(hexInner), 50)]) + log.Debugf("DEBUG: 第一层解密提取到 hex_inner, length=%d", len(hexInner)) // 第二层解密 key2, err := hex.DecodeString(KEY_HEX_2) @@ -1296,14 +1333,14 @@ func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) { if err != nil { return "", fmt.Errorf("step3 response layer2 aes ecb decrypt failed: %w", err) } - log.Debugf("DEBUG: 最终解密结果: %s", string(finalJsonStrBytes)) + log.Debugf("DEBUG: third party login response decrypted.") // 提取 authToken authToken := jsoniter.Get(finalJsonStrBytes, "authToken").ToString() if authToken == "" { return "", errors.New("failed to extract authToken from final decryption result") } - log.Debugf("DEBUG: 提取到 authToken: %s", authToken) + log.Debugf("DEBUG: authToken extracted from third party login response.") // 提取 account 和 userDomainId account := jsoniter.Get(finalJsonStrBytes, "account").ToString() @@ -1319,98 +1356,93 @@ func (d *Yun139) step3_third_party_login(dycpwd string) (string, error) { } func (d *Yun139) validateAndInitCredentials() error { - // More robust validation for MailCookies - trimmedCookies := strings.TrimSpace(d.MailCookies) - if trimmedCookies != "" { - d.MailCookies = trimmedCookies // Update with trimmed value - if !strings.Contains(d.MailCookies, "=") || len(strings.Split(d.MailCookies, "=")[0]) == 0 { - return fmt.Errorf("MailCookies format is invalid, please check your configuration") - } + state, err := d.credentialState() + if err != nil { + return err } - // Priority 1: If Authorization exists, skip login process completely. - // We assume it's valid for now; validity will be checked by refreshToken() later in Init(). - if d.Authorization != "" { + 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) } +} - // Validate all-or-nothing check for username and password - // "Cookies can exist alone, but if username or password is provided, all three must be provided" - hasUserOrPass := d.Username != "" || d.Password != "" - hasAll := d.MailCookies != "" && d.Username != "" && d.Password != "" +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 hasUserOrPass && !hasAll { - return fmt.Errorf("if username or password is provided, all three (mail_cookies, username, password) must be provided") + if d.Authorization != "" { + return credentialStateAuthorization, nil } - // If no Authorization, attempt to generate it. - // We can try if we have ALL credentials OR if we just have MailCookies (try fast path only) - if hasAll || d.MailCookies != "" { - log.Infof("139yun: Authorization missing, attempting login...") + if d.MailCookies != "" && !hasCookiePair(d.MailCookies) { + return 0, fmt.Errorf("MailCookies format is invalid, please check your configuration") + } - success := false - var sid string - - // Priority 2: Try fast login using existing cookies (Step 2 -> Step 3) - // Extract SID from current MailCookies - cookies := strings.Split(d.MailCookies, ";") - for _, cookie := range cookies { - cookie = strings.TrimSpace(cookie) - // Check for Os_SSo_Sid - if strings.HasPrefix(cookie, "Os_SSo_Sid=") { - sid = strings.TrimPrefix(cookie, "Os_SSo_Sid=") - break - } - } + hasUsername := d.Username != "" + hasPassword := strings.TrimSpace(d.Password) != "" + hasCookies := d.MailCookies != "" - // Try Step 2 directly with existing SID and Cookies (using full cookies as implicit context) - if sid != "" { - 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.Infof("139yun: Step 2 success. Proceeding to Step 3.") - // If Step 2 succeeds, proceed to Step 3 - auth, err := d.step3_third_party_login(token) - if err == nil { - d.Authorization = auth - op.MustSaveDriverStorage(d) - success = true - log.Infof("139yun: fast login success (Step 2 -> Step 3).") - } else { - log.Warnf("139yun: fast login Step 3 failed: %v", err) - } - } else { - log.Warnf("139yun: fast login Step 2 failed: %v", err) - } - } else { - if d.MailCookies != "" { - log.Warnf("139yun: Os_SSo_Sid not found in existing cookies. Skipping fast login.") - } + 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 + } - // Priority 3: Fallback to full password login (Step 1 -> Step 2 -> Step 3) - // Only possible if we have ALL credentials (hasAll == true) - if !success { - if hasAll { - log.Infof("139yun: fast login failed or not possible, performing full password login (Step 1).") - // loginWithPassword() calls step1_password_login(), which internally strictly uses - // sanitizeLoginCookies() to ensure only necessary cookies are sent for password login. - _, err := d.loginWithPassword() - if err != nil { - return fmt.Errorf("login with password failed: %w", err) - } - } else { - // If we don't have password, we can't fallback. report error. - return fmt.Errorf("fast login with cookies failed, and cannot fallback to password login (missing username/password)") - } - } - } else { - // No Authorization and missing credentials (and even no cookies) - return fmt.Errorf("authorization is empty and credentials are not provided") + if hasCookies { + return credentialStateCookiesOnly, nil } - return nil + return 0, fmt.Errorf("authorization is empty and credentials are not provided") +} + +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) loginWithPassword() (string, error) { @@ -1422,13 +1454,13 @@ func (d *Yun139) loginWithPassword() (string, error) { if err != nil { return "", err } - log.Infof("Step 1 success, passId: %s", passId) + log.Infof("Step 1 success.") token, err := d.step2_get_single_token(passId) if err != nil { return "", err } - log.Infof("Step 2 success, token: %s", token) + log.Infof("Step 2 success.") newAuth, err := d.step3_third_party_login(token) if err != nil { diff --git a/drivers/139/util_test.go b/drivers/139/util_test.go new file mode 100644 index 0000000000..18e6426d73 --- /dev/null +++ b/drivers/139/util_test.go @@ -0,0 +1,116 @@ +package _139 + +import ( + "net/http" + "testing" +) + +func TestSanitizeLoginCookiesReplacesJSessionIDAndOrdersAllowlist(t *testing.T) { + got := sanitizeLoginCookies("unknown=x; RMKEY=rm; JSESSIONID=old; Os_SSo_Sid=sid; behaviorid=b", "fresh") + want := "behaviorid=b; Os_SSo_Sid=sid; JSESSIONID=fresh" + if got != want { + t.Fatalf("sanitizeLoginCookies() = %q, want %q", got, want) + } +} + +func TestSanitizeLoginCookiesDropsStaleJSessionIDWhenFreshOneMissing(t *testing.T) { + got := sanitizeLoginCookies("JSESSIONID=old; Os_SSo_Sid=sid", "") + want := "Os_SSo_Sid=sid" + if got != want { + t.Fatalf("sanitizeLoginCookies() = %q, want %q", got, want) + } +} + +func TestMergeMailCookiesIsDeterministicAndKeepsExtrasSorted(t *testing.T) { + got := mergeMailCookies("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 := "behaviorid=b; Os_SSo_Sid=sid; RMKEY=rm; a=av; z=zv" + if got != want { + t.Fatalf("mergeMailCookies() = %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, + }, + } + + 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 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) + } + } +} From 5a94c9b4e0ab8838c9cdb7e25c0bf61bd973ecb1 Mon Sep 17 00:00:00 2001 From: ucnacdx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:21:12 +0800 Subject: [PATCH 3/3] fix(drivers/139): correct mail cookie help domain --- drivers/139/meta.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/139/meta.go b/drivers/139/meta.go index 0d3b9d5e90..d93b2aa87c 100644 --- a/drivers/139/meta.go +++ b/drivers/139/meta.go @@ -10,7 +10,7 @@ type Addition struct { 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."` - MailCookies string `json:"mail_cookies" type:"text" help:"Cookies from mail.139.com. Can be used alone for fast login, or with username and password for full login fallback."` + MailCookies string `json:"mail_cookies" type:"text" help:"Cookies from mail.10086.cn. Can be used alone for fast login, or with username and password for full login fallback."` driver.RootID Type string `json:"type" type:"select" options:"personal_new,family,group,personal" default:"personal_new"` CloudID string `json:"cloud_id"`