From 25a0d8b9825e3160ccb1e484c229702143bd0448 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/4] 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 | 266 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 232 insertions(+), 49 deletions(-) diff --git a/drivers/139/driver.go b/drivers/139/driver.go index 6386a2f25..a6202d90b 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 3ff123ba3..4fe8e4365 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -170,29 +170,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 } @@ -740,10 +734,92 @@ func (d *Yun139) getDiskQuotaDetail(ctx context.Context) (*DiskQuotaDetail, erro return &resp, nil } +func getMd5(dataStr string) string { + hash := md5.Sum([]byte(dataStr)) + return fmt.Sprintf("%x", hash) +} + +// sanitizeLoginCookies filters and orders cookies based on a predefined allowlist. +// This is necessary because the login endpoint requires a specific cookie order and +// rejects unknown cookies. The function ensures that only necessary cookies are sent, +// preventing potential login failures due to cookie changes by the service. +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) @@ -752,6 +828,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", @@ -770,7 +848,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{} @@ -788,37 +866,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) + } + if res == nil { + return "", fmt.Errorf("step1 login request failed: response is nil (error: %v)", err) } - // 恢复客户端的默认重定向策略,以免影响后续请求 - base.RestyClient.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10)) + 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) @@ -846,16 +929,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] + } } - cookieStr := strings.Join(cookieStrings, "; ") - log.Debugf("DEBUG: 提取到的 Cookies: %s", cookieStr) - d.MailCookies = cookieStr + // 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) return sid, nil } @@ -1208,6 +1303,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 196a96d1d84331ead0816e1cc809fe6c1c59ea44 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/4] refactor(drivers/139): harden login credential flow Co-Authored-By: OpenAI Codex --- drivers/139/meta.go | 8 +- drivers/139/util.go | 405 +++++++++++++++++++++------------------ drivers/139/util_test.go | 116 +++++++++++ 3 files changed, 337 insertions(+), 192 deletions(-) create mode 100644 drivers/139/util_test.go diff --git a/drivers/139/meta.go b/drivers/139/meta.go index 91d54fd30..0d3b9d5e9 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 4fe8e4365..26bfa7f9c 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -25,6 +25,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" @@ -37,6 +38,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" @@ -110,8 +146,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) } @@ -739,65 +775,95 @@ func getMd5(dataStr string) string { return fmt.Sprintf("%x", hash) } -// sanitizeLoginCookies filters and orders cookies based on a predefined allowlist. -// This is necessary because the login endpoint requires a specific cookie order and -// rejects unknown cookies. The function ensures that only necessary cookies are sent, -// preventing potential login failures due to cookie changes by the service. -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) { @@ -822,9 +888,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 @@ -862,8 +925,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. @@ -873,15 +935,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 @@ -897,14 +960,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.") } } @@ -916,11 +979,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.") } } } @@ -929,28 +992,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 } @@ -962,29 +1005,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). @@ -995,14 +1030,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 } @@ -1159,7 +1193,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 @@ -1191,7 +1225,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)) @@ -1212,7 +1246,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 { @@ -1266,7 +1300,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) @@ -1281,14 +1315,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() @@ -1304,98 +1338,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) { @@ -1407,13 +1436,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 000000000..18e6426d7 --- /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 f28e1e7a5cad2c31c1a15c6b79e4449d1aeac1ed 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/4] fix(drivers/139): correct mail cookie help domain --- drivers/139/meta.go | 2 +- drivers/139/util.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/drivers/139/meta.go b/drivers/139/meta.go index 0d3b9d5e9..d93b2aa87 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"` diff --git a/drivers/139/util.go b/drivers/139/util.go index 26bfa7f9c..137c962aa 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -5,6 +5,7 @@ import ( "context" "crypto/aes" "crypto/cipher" + "crypto/md5" crypto_rand "crypto/rand" "crypto/sha1" "encoding/base64" From 75b4f35cad3a8954d396dd65d48f8b12a87a3cee Mon Sep 17 00:00:00 2001 From: ucnacdx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:33:54 +0800 Subject: [PATCH 4/4] test(drivers/139): cover live login authorization paths --- drivers/139/util.go | 18 +++++ drivers/139/util_test.go | 146 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/drivers/139/util.go b/drivers/139/util.go index 137c962aa..88f32487a 100644 --- a/drivers/139/util.go +++ b/drivers/139/util.go @@ -1035,6 +1035,21 @@ func (d *Yun139) step2_get_single_token(sid string) (string, error) { dycpwd := jsoniter.Get(res.Body(), "var", "artifact").ToString() if dycpwd == "" { + code := jsoniter.Get(res.Body(), "code").ToString() + summary := jsoniter.Get(res.Body(), "summary").ToString() + if code == "" { + if match := regexp.MustCompile(`['"]code['"]\s*:\s*['"]([^'"]+)['"]`).FindSubmatch(res.Body()); len(match) == 2 { + code = string(match[1]) + } + } + if summary == "" { + if match := regexp.MustCompile(`['"]summary['"]\s*:\s*['"]([^'"]+)['"]`).FindSubmatch(res.Body()); len(match) == 2 { + summary = string(match[1]) + } + } + if code != "" || summary != "" { + return "", fmt.Errorf("failed to extract dycpwd from artifact exchange response: code=%s summary=%s", code, summary) + } return "", errors.New("failed to extract dycpwd from artifact exchange response") } log.Debugf("DEBUG: dycpwd extracted from artifact exchange response.") @@ -1376,6 +1391,9 @@ func (d *Yun139) credentialState() (credentialState, error) { 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 } diff --git a/drivers/139/util_test.go b/drivers/139/util_test.go index 18e6426d7..f78f12d8a 100644 --- a/drivers/139/util_test.go +++ b/drivers/139/util_test.go @@ -2,7 +2,13 @@ package _139 import ( "net/http" + "os" + "strings" "testing" + "time" + + "github.com/OpenListTeam/OpenList/v4/drivers/base" + "github.com/go-resty/resty/v2" ) func TestSanitizeLoginCookiesReplacesJSessionIDAndOrdersAllowlist(t *testing.T) { @@ -81,6 +87,11 @@ func TestCredentialState(t *testing.T) { 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 { @@ -102,6 +113,141 @@ func TestCredentialState(t *testing.T) { } } +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) {