Compare commits

...
Author SHA1 Message Date
Cursor Agentandqaiu 1ab9df7826 fix: guide Authorization when 123 password login hits captcha
123 pan username/password sign-in that returns captcha or risk-control
now fails with a user-facing hint to configure Authorization (Bearer/JWT),
instead of the raw upstream message. Wrong-password and network errors stay
distinct. No captcha solving, QR login, or retries.

Co-authored-by: qaiu <[email protected]>
2026-09-27 22:08:16 +00:00
qaiu ab8b687837 Merge pull request #231 from qaiu/cursor/release-047-9e8b
release: v0.4.7
2026-09-25 16:35:29 +08:00
7 changed files with 454 additions and 14 deletions
+2
View File
@@ -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`),无需重启服务,仅本次请求生效。
@@ -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 的说明
@@ -57,6 +57,8 @@
}
```
> 123云盘(`ye`)账号密码登录触发验证码/风控时,请改用 `authType=authorization` 或 `accesstoken`(`token` 填浏览器中的 Bearer token / JWT),或在 `app-dev.yml` 配置 `auths.ye.authorization`。解析不会尝试验证码。
### 自定义认证
```json
{
@@ -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) + "...";
}
}
@@ -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;
+50 -6
View File
@@ -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("⚠️ 未提供登录信息,某些文件可能无法下载")
@@ -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 = "<html>请完成滑块验证</html>";
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));
}
}