diff --git a/README.md b/README.md index 037c96d..30dac95 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,8 @@ auths: ``` > ⚠️ 注意:YAML 中 key 后面不写值(如 `authorization:` 空着)等同于没配置,不会生效,必须填入真实的账号密码或 token 内容。 +> +> 若 123 云盘账号密码登录触发验证码或风控,解析会直接失败并提示改用 Authorization,不会尝试验证码。请改填上面的 `authorization` / `token`(浏览器 localStorage 的 `authorToken`,或请求头 `authorization` / Cookie `sso-token` 里的 JWT)。 如果只是临时调用一次,不想改动服务端配置,也可以用上面提到的 `auth` 参数临时传递(`authType` 可选 `password`/`accesstoken`/`authorization`),无需重启服务,仅本次请求生效。 diff --git a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md index 39ebdaa..78ab2ed 100644 --- a/parser/doc/auth-param/AUTH_PARAM_GUIDE.md +++ b/parser/doc/auth-param/AUTH_PARAM_GUIDE.md @@ -109,6 +109,8 @@ URL解码 → Base64解码 → AES解密 → JSON对象 } ``` +> 123云盘(`ye`)账号密码登录如果返回验证码或风控,解析会直接失败,并提示改用 Authorization,不会尝试验证码。请在浏览器登录后复制 `authorToken` / 请求头 `authorization` / Cookie `sso-token`,用 `authType=authorization` 或 `accesstoken` 传入,或写入 `app-dev.yml` 的 `auths.ye.authorization`。 + #### 4. 自定义认证 ```json { @@ -339,3 +341,4 @@ server: ## 更新日志 - **2026-02-05**: 初始版本,支持 accesstoken、cookie、password、custom 认证类型 +- **2026-09-27**: 补充 123云盘账号密码登录触发验证码/风控时改用 Authorization 的说明 diff --git a/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md b/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md index ec14754..f84110a 100644 --- a/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md +++ b/parser/doc/auth-param/AUTH_PARAM_GUIDE_SIMPLE.md @@ -57,6 +57,8 @@ } ``` +> 123云盘(`ye`)账号密码登录触发验证码/风控时,请改用 `authType=authorization` 或 `accesstoken`(`token` 填浏览器中的 Bearer token / JWT),或在 `app-dev.yml` 配置 `auths.ye.authorization`。解析不会尝试验证码。 + ### 自定义认证 ```json { diff --git a/parser/src/main/java/cn/qaiu/parser/PasswordLoginRisk.java b/parser/src/main/java/cn/qaiu/parser/PasswordLoginRisk.java new file mode 100644 index 0000000..f757972 --- /dev/null +++ b/parser/src/main/java/cn/qaiu/parser/PasswordLoginRisk.java @@ -0,0 +1,270 @@ +package cn.qaiu.parser; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Locale; + +/** + * 账号密码登录撞上验证码/风控时的用户提示。 + * 只识别失败形态并引导改用 Authorization,不实现验证码求解、扫码或重试。 + */ +public final class PasswordLoginRisk { + + /** + * 123 云盘(ye)账号密码登录被验证码/风控拦住时返回给调用方的固定说明。 + * 上游 code/message 会另附在这句话后面,便于和“密码错误”区分。 + */ + public static final String AUTHORIZATION_HINT = + "账号密码登录触发了验证码/风控,已停止用户名密码登录(不会尝试验证码)。" + + "请改为配置 Authorization(浏览器登录后的 Bearer token / JWT," + + "例如 localStorage 的 authorToken、请求头 authorization 或 Cookie 的 sso-token)。" + + "静态配置写在 app-dev.yml 的 auths.ye.authorization(与 auths.ye.token 等价);" + + "临时请求使用 authType=authorization 或 authType=accesstoken,token 填该令牌(不要重复带 Bearer 前缀)。" + + "说明见 parser/doc/auth-param/。"; + + /** + * 上游文案命中这些片段时视为验证码/风控,而不是单纯的密码错误。 + * 依据公开客户端(AList/OpenList)实际展示过的 123 登录失败文案,以及登录页上的滑块/人机验证提示。 + */ + private static final String[] RISK_KEYWORDS = { + "验证码", + "风控", + "安全风险", + "安全验证", + "安全校验", + "滑块", + "滑动验证", + "拼图验证", + "短信验证", + "微信登录", + "微信进行登录", + "境外登录", + "需要验证", + "请验证", + "请你验证", + "进行验证", + "完成验证", + "二次验证", + "人机验证", + "操作频繁", + "请求过于频繁", + "请求频繁", + "captcha", + "recaptcha", + "risk control" + }; + + private PasswordLoginRisk() { + } + + public static boolean isLoginSuccess(JsonObject json) { + if (json == null) { + return false; + } + Integer code = readCode(json); + return code != null && code == 200 && StringUtils.isNotBlank(readToken(json)); + } + + public static boolean indicatesCaptchaOrRisk(JsonObject json) { + if (json == null) { + return false; + } + if (hasTruthyRiskField(json) || hasTruthyRiskField(dataObject(json))) { + return true; + } + return textIndicatesCaptchaOrRisk(collectMessageText(json)); + } + + public static boolean textIndicatesCaptchaOrRisk(String text) { + if (StringUtils.isBlank(text)) { + return false; + } + String lower = text.toLowerCase(Locale.ROOT); + for (String keyword : RISK_KEYWORDS) { + if (lower.contains(keyword.toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + /** + * 非成功登录响应的用户可见说明。验证码/风控走 Authorization 引导,其余保持“登录失败: 上游文案”。 + */ + public static String describeLoginFailure(JsonObject json) { + if (json == null) { + return "登录响应格式异常"; + } + if (indicatesCaptchaOrRisk(json)) { + return AUTHORIZATION_HINT + " 上游返回:" + upstreamSummary(json); + } + Integer code = readCode(json); + if (code != null && code == 200) { + return "未获取到token"; + } + String message = firstMessage(json); + if (StringUtils.isBlank(message)) { + return "登录失败"; + } + return "登录失败: " + message; + } + + public static String nonJsonLoginFailure(String body) { + if (textIndicatesCaptchaOrRisk(body)) { + return AUTHORIZATION_HINT + " 上游返回:" + truncate(body, 300); + } + return "登录响应格式异常: " + body; + } + + private static String collectMessageText(JsonObject json) { + StringBuilder text = new StringBuilder(); + appendMessage(text, json); + JsonObject data = dataObject(json); + if (data != null) { + appendMessage(text, data); + } + return text.toString(); + } + + private static void appendMessage(StringBuilder text, JsonObject json) { + append(text, json.getString("message")); + append(text, json.getString("msg")); + } + + private static void append(StringBuilder text, String value) { + if (StringUtils.isNotBlank(value)) { + if (text.length() > 0) { + text.append(' '); + } + text.append(value); + } + } + + private static boolean hasTruthyRiskField(JsonObject obj) { + if (obj == null) { + return false; + } + for (String key : obj.fieldNames()) { + if (!isRiskFieldName(key)) { + continue; + } + if (isTruthyRiskValue(obj.getValue(key))) { + return true; + } + } + return false; + } + + private static boolean isRiskFieldName(String key) { + String lower = key.toLowerCase(Locale.ROOT); + return lower.contains("captcha") + || lower.contains("vcode") + || lower.contains("risk") + || "needverify".equals(lower) + || "need_verify".equals(lower) + || "slideverify".equals(lower) + || "verifycode".equals(lower) + || "verify_code".equals(lower); + } + + private static boolean isTruthyRiskValue(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue() != 0d; + } + if (value instanceof CharSequence) { + String text = value.toString().trim(); + if (text.isEmpty()) { + return false; + } + String lower = text.toLowerCase(Locale.ROOT); + return !"false".equals(lower) && !"0".equals(lower) && !"null".equals(lower) && !"none".equals(lower); + } + if (value instanceof JsonObject) { + return !((JsonObject) value).isEmpty(); + } + if (value instanceof JsonArray) { + return !((JsonArray) value).isEmpty(); + } + return true; + } + + private static String upstreamSummary(JsonObject json) { + StringBuilder summary = new StringBuilder(); + Integer code = readCode(json); + if (code != null) { + summary.append("code=").append(code); + } + String message = firstMessage(json); + if (StringUtils.isNotBlank(message)) { + if (summary.length() > 0) { + summary.append(", "); + } + summary.append("message=").append(message); + } + if (summary.length() == 0) { + summary.append(json.encode()); + } + return truncate(summary.toString(), 300); + } + + private static String firstMessage(JsonObject json) { + String message = json.getString("message"); + if (StringUtils.isBlank(message)) { + message = json.getString("msg"); + } + if (StringUtils.isBlank(message)) { + JsonObject data = dataObject(json); + if (data != null) { + message = data.getString("message"); + if (StringUtils.isBlank(message)) { + message = data.getString("msg"); + } + } + } + return message; + } + + static String readToken(JsonObject json) { + JsonObject data = dataObject(json); + if (data == null) { + return null; + } + Object token = data.getValue("token"); + return token == null ? null : token.toString(); + } + + private static JsonObject dataObject(JsonObject json) { + Object data = json.getValue("data"); + return data instanceof JsonObject ? (JsonObject) data : null; + } + + static Integer readCode(JsonObject json) { + Object code = json.getValue("code"); + if (code instanceof Number) { + return ((Number) code).intValue(); + } + if (code instanceof String && StringUtils.isNumeric(((String) code).trim())) { + return Integer.parseInt(((String) code).trim()); + } + return null; + } + + private static String truncate(String value, int max) { + if (value == null) { + return ""; + } + String trimmed = value.trim(); + if (trimmed.length() <= max) { + return trimmed; + } + return trimmed.substring(0, max) + "..."; + } +} diff --git a/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java b/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java index 1d3e894..965acf3 100644 --- a/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java +++ b/parser/src/main/java/cn/qaiu/parser/impl/Ye2Tool.java @@ -3,6 +3,7 @@ package cn.qaiu.parser.impl; import cn.qaiu.entity.FileInfo; import cn.qaiu.entity.ShareLinkInfo; import cn.qaiu.parser.PanBase; +import cn.qaiu.parser.PasswordLoginRisk; import cn.qaiu.parser.TokenCache; import cn.qaiu.util.CommonUtils; import cn.qaiu.util.FileSizeConverter; @@ -272,24 +273,27 @@ public class Ye2Tool extends PanBase { .putHeader("platform", "web") .sendJsonObject(loginBody) .onSuccess(res -> { - JsonObject json = res.bodyAsJsonObject(); + JsonObject json; + try { + json = res.bodyAsJsonObject(); + } catch (Exception e) { + promise.fail(PasswordLoginRisk.nonJsonLoginFailure(res.bodyAsString())); + return; + } if (json == null) { - promise.fail("登录响应格式异常: " + res.bodyAsString()); + promise.fail(PasswordLoginRisk.nonJsonLoginFailure(res.bodyAsString())); return; } if (!json.containsKey("code")) { promise.fail("登录响应格式异常: " + res.bodyAsString()); return; } - if (json.getInteger("code") != 200) { - promise.fail("登录失败: " + json.getString("message")); + // 验证码/风控只提示改用 Authorization,不求解、不重试。 + if (!PasswordLoginRisk.isLoginSuccess(json)) { + promise.fail(PasswordLoginRisk.describeLoginFailure(json)); return; } JsonObject data = json.getJsonObject("data"); - if (data == null || !data.containsKey("token")) { - promise.fail("未获取到token"); - return; - } String ssoToken = data.getString("token"); String expireStr = data.getString("expire"); long expireMs; diff --git a/parser/src/main/resources/py/123.py b/parser/src/main/resources/py/123.py index 8102a88..2018b70 100644 --- a/parser/src/main/resources/py/123.py +++ b/parser/src/main/resources/py/123.py @@ -55,8 +55,50 @@ def encode123(url, way, version, timestamp): # 返回加密后的URL参数 return f"?{y}={time_long}-{a}-{final_crc}" +# 与 parser PasswordLoginRisk 保持一致:验证码/风控只提示改用 Authorization,不求解、不重试。 +_RISK_KEYWORDS = ( + "验证码", "风控", "安全风险", "安全验证", "安全校验", "滑块", "滑动验证", + "拼图验证", "短信验证", "微信登录", "微信进行登录", "境外登录", "需要验证", "请验证", + "请你验证", "进行验证", "完成验证", "二次验证", "人机验证", "操作频繁", "请求过于频繁", + "请求频繁", "captcha", "recaptcha", "risk control", +) +_AUTH_HINT = ( + "账号密码登录触发了验证码/风控,已停止用户名密码登录(不会尝试验证码)。" + "请改为配置 Authorization(浏览器登录后的 Bearer token / JWT," + "例如 localStorage 的 authorToken、请求头 authorization 或 Cookie 的 sso-token)。" + "静态配置写在 app-dev.yml 的 auths.ye.authorization(与 auths.ye.token 等价);" + "临时请求使用 authType=authorization 或 authType=accesstoken,token 填该令牌(不要重复带 Bearer 前缀)。" + "说明见 parser/doc/auth-param/。" +) + + +def format_login_failure(result): + """把 123 登录失败响应转成用户可见说明。""" + if not isinstance(result, dict): + return "登录失败" + message = result.get("message") or result.get("msg") or "" + data = result.get("data") if isinstance(result.get("data"), dict) else {} + data_message = data.get("message") or data.get("msg") or "" + blob = f"{message} {data_message}".lower() + risk_field = any( + key.lower().find(flag) >= 0 and data.get(key) not in (None, False, 0, "", "false", "0") + for key in data + for flag in ("captcha", "vcode", "risk", "needverify", "need_verify", "slideverify", "verifycode", "verify_code") + ) + if risk_field or any(keyword.lower() in blob for keyword in _RISK_KEYWORDS): + code = result.get("code") + upstream = message or data_message + extra = f"code={code}" if code is not None else "" + if upstream: + extra = f"{extra}, message={upstream}" if extra else f"message={upstream}" + return f"{_AUTH_HINT} 上游返回:{extra}" if extra else _AUTH_HINT + if result.get("code") == 200: + return "未获取到token" + return f"登录失败: {message}" if message else "登录失败" + + def login_123pan(username, password): - """登录123盘获取token""" + """登录123盘获取token。成功返回 token,失败返回 None(错误已打印)。""" print(f"🔐 正在登录账号: {username}") login_data = { @@ -73,16 +115,18 @@ def login_123pan(username, password): ) result = response.json() - if result.get('code') == 200: + if result.get('code') == 200 and result.get('data', {}).get('token'): token = result.get('data', {}).get('token', '') print(f"✅ 登录成功!") return token else: - error_msg = result.get('message', '未知错误') - print(f"❌ 登录失败: {error_msg}") + error_msg = format_login_failure(result) + login_123pan.last_error = error_msg + print(f"❌ {error_msg}") return None except Exception as e: - print(f"❌ 登录请求失败: {e}") + login_123pan.last_error = f"登录请求失败: {e}" + print(f"❌ {login_123pan.last_error}") return None def get_share_info(share_key, password=''): @@ -189,7 +233,7 @@ def start(link, password='', username='', user_password=''): if not token: return { "code": 201, - "message": "登录失败" + "message": getattr(login_123pan, "last_error", None) or "登录失败" } else: print("⚠️ 未提供登录信息,某些文件可能无法下载") diff --git a/parser/src/test/java/cn/qaiu/parser/PasswordLoginRiskTest.java b/parser/src/test/java/cn/qaiu/parser/PasswordLoginRiskTest.java new file mode 100644 index 0000000..5a66485 --- /dev/null +++ b/parser/src/test/java/cn/qaiu/parser/PasswordLoginRiskTest.java @@ -0,0 +1,115 @@ +package cn.qaiu.parser; + +import io.vertx.core.json.JsonObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PasswordLoginRiskTest { + + @Test + public void securityRiskMessageGuidesAuthorization() { + JsonObject body = new JsonObject() + .put("code", 401) + .put("message", "当前账号存在安全风险,请使用短信验证码或者微信进行登录。"); + + assertTrue(PasswordLoginRisk.indicatesCaptchaOrRisk(body)); + String message = PasswordLoginRisk.describeLoginFailure(body); + assertTrue(message.startsWith(PasswordLoginRisk.AUTHORIZATION_HINT)); + assertTrue(message.contains("验证码/风控")); + assertTrue(message.contains("auths.ye.authorization")); + assertTrue(message.contains("authType=authorization")); + assertTrue(message.contains("authType=accesstoken")); + assertTrue(message.contains("code=401")); + assertTrue(message.contains("当前账号存在安全风险")); + assertFalse(message.startsWith("登录失败:")); + } + + @Test + public void overseasRiskAndCaptchaPhrasesMatch() { + assertRisk("当前账号存在境外登录风险,请使用短信验证码或者微信进行登录。"); + assertRisk("请完成安全验证"); + assertRisk("请进行滑块验证"); + assertRisk("操作频繁,请稍后再试"); + assertRisk("captcha required"); + } + + @Test + public void wrongPasswordStaysDistinct() { + JsonObject body = new JsonObject() + .put("code", 500) + .put("message", "用户名或密码错误"); + + assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(body)); + assertEquals("登录失败: 用户名或密码错误", PasswordLoginRisk.describeLoginFailure(body)); + + JsonObject account = new JsonObject().put("code", 1).put("msg", "账号或密码错误"); + assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(account)); + assertEquals("登录失败: 账号或密码错误", PasswordLoginRisk.describeLoginFailure(account)); + } + + @Test + public void riskFieldWithoutKeywordStillHints() { + JsonObject body = new JsonObject() + .put("code", 200) + .put("message", "ok") + .put("data", new JsonObject().put("needCaptcha", true)); + + assertFalse(PasswordLoginRisk.isLoginSuccess(body)); + assertTrue(PasswordLoginRisk.indicatesCaptchaOrRisk(body)); + assertTrue(PasswordLoginRisk.describeLoginFailure(body).contains("Authorization")); + } + + @Test + public void falseCaptchaFlagDoesNotHint() { + JsonObject body = new JsonObject() + .put("code", 500) + .put("message", "用户名或密码错误") + .put("data", new JsonObject().put("needCaptcha", false)); + + assertFalse(PasswordLoginRisk.indicatesCaptchaOrRisk(body)); + assertEquals("登录失败: 用户名或密码错误", PasswordLoginRisk.describeLoginFailure(body)); + } + + @Test + public void successTokenIsNotTreatedAsFailure() { + JsonObject body = new JsonObject() + .put("code", 200) + .put("message", "ok") + .put("data", new JsonObject().put("token", "jwt-token")); + + assertTrue(PasswordLoginRisk.isLoginSuccess(body)); + } + + @Test + public void blankTokenWithoutRiskKeepsMissingTokenMessage() { + JsonObject body = new JsonObject() + .put("code", 200) + .put("message", "ok") + .put("data", new JsonObject().put("token", "")); + + assertFalse(PasswordLoginRisk.isLoginSuccess(body)); + assertEquals("未获取到token", PasswordLoginRisk.describeLoginFailure(body)); + } + + @Test + public void nonJsonCaptchaBodyUsesHint() { + String body = "请完成滑块验证"; + String message = PasswordLoginRisk.nonJsonLoginFailure(body); + assertTrue(message.startsWith(PasswordLoginRisk.AUTHORIZATION_HINT)); + } + + @Test + public void nonJsonWrongPasswordStaysFormatError() { + assertEquals("登录响应格式异常: 用户名或密码错误", + PasswordLoginRisk.nonJsonLoginFailure("用户名或密码错误")); + } + + private static void assertRisk(String message) { + JsonObject body = new JsonObject().put("code", 5202).put("message", message); + assertTrue(message, PasswordLoginRisk.indicatesCaptchaOrRisk(body)); + assertTrue(PasswordLoginRisk.describeLoginFailure(body).startsWith(PasswordLoginRisk.AUTHORIZATION_HINT)); + } +}