Skip to content

Commit 546e26c

Browse files
committed
feat: support Cap captcha for Sub2API login
1 parent 011870c commit 546e26c

7 files changed

Lines changed: 277 additions & 6 deletions

File tree

app/(dashboard)/stations/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const TYPE_HINTS: Record<string, string> = {
7373
newapi: "New API 后台「个人设置」的系统访问令牌 + 用户 ID;地址填站点根地址。",
7474
"newapi-key": "任意可用的 sk- 密钥;通过 OpenAI 兼容计费接口查询额度。",
7575
sub2api: "Sub2API 登录后的访问令牌(JWT);过期需手动更换,推荐用账号密码模式。",
76-
"sub2api-password": "填 Sub2API 的登录邮箱和密码,面板会自动登录并在令牌过期时自动续期。开启 2FA 的账号不支持。",
76+
"sub2api-password": "填 Sub2API 的登录邮箱和密码,面板会自动登录并在令牌过期时自动续期;站点使用 Cap 验证码时也会自动完成验证。",
7777
fixed: "包月 / 包年等定期投入的上游:不访问任何接口,只按天摊销计入利润成本。",
7878
};
7979

lib/cap.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import capWasm from "@cap.js/wasm";
2+
3+
const MAX_CHALLENGES = 256;
4+
const MAX_POW_INPUT_LENGTH = 256;
5+
const MAX_ESTIMATED_HASHES = 50_000_000;
6+
7+
function seededHex(seed, length) {
8+
let state = 2166136261;
9+
for (let i = 0; i < seed.length; i++) {
10+
state ^= seed.charCodeAt(i);
11+
state += (state << 1) + (state << 4) + (state << 7) + (state << 8) + (state << 24);
12+
}
13+
state >>>= 0;
14+
15+
let out = "";
16+
while (out.length < length) {
17+
state ^= state << 13;
18+
state ^= state >>> 17;
19+
state ^= state << 5;
20+
out += (state >>> 0).toString(16).padStart(8, "0");
21+
}
22+
return out.slice(0, length);
23+
}
24+
25+
function validatePowInput(salt, target) {
26+
if (typeof salt !== "string" || typeof target !== "string" ||
27+
!salt || !/^[0-9a-f]+$/i.test(target) ||
28+
salt.length > MAX_POW_INPUT_LENGTH || target.length > MAX_POW_INPUT_LENGTH) {
29+
throw new Error("Cap challenge 格式无效");
30+
}
31+
}
32+
33+
function solvePow(salt, target) {
34+
validatePowInput(salt, target);
35+
return Number(capWasm.solve_pow(salt, target));
36+
}
37+
38+
function oldFormatChallenges(body) {
39+
if (Array.isArray(body.challenge)) return body.challenge;
40+
const spec = body.challenge;
41+
const count = Number(spec?.c);
42+
const saltLength = Number(spec?.s);
43+
const difficulty = Number(spec?.d);
44+
if (!Number.isInteger(count) || count < 1 || count > MAX_CHALLENGES ||
45+
!Number.isInteger(saltLength) || saltLength < 1 || saltLength > MAX_POW_INPUT_LENGTH ||
46+
!Number.isInteger(difficulty) || difficulty < 1 || difficulty > 16) {
47+
throw new Error("Cap challenge 参数无效");
48+
}
49+
50+
return Array.from({ length: count }, (_, index) => {
51+
const n = index + 1;
52+
return [
53+
seededHex(`${body.token}${n}`, saltLength),
54+
seededHex(`${body.token}${n}d`, difficulty),
55+
];
56+
});
57+
}
58+
59+
function solveChallenge(body) {
60+
if (!body?.token) throw new Error("Cap challenge 缺少 token");
61+
62+
if (body.format === 2 && Array.isArray(body.challenges)) {
63+
if (body.challenges.length < 1 || body.challenges.length > MAX_CHALLENGES) {
64+
throw new Error("Cap challenge 数量无效");
65+
}
66+
const pow = body.challenges.map((challenge) => {
67+
if (challenge?.protocol !== "sha256-pow") {
68+
throw new Error(`Cap challenge 需要 sha256-pow,收到:${challenge?.protocol || "unknown"}`);
69+
}
70+
validatePowInput(challenge.payload?.salt, challenge.payload?.target);
71+
return challenge.payload;
72+
});
73+
const estimatedHashes = pow.reduce((sum, item) => sum + 16 ** item.target.length, 0);
74+
if (!Number.isFinite(estimatedHashes) || estimatedHashes > MAX_ESTIMATED_HASHES) {
75+
throw new Error("Cap challenge 工作量超出单次登录限制");
76+
}
77+
return pow.map((item) => ({ nonce: solvePow(item.salt, item.target) }));
78+
}
79+
80+
const challenges = oldFormatChallenges(body);
81+
if (challenges.length < 1 || challenges.length > MAX_CHALLENGES) {
82+
throw new Error("Cap challenge 数量无效");
83+
}
84+
const estimatedHashes = challenges.reduce((sum, [, target]) => sum + 16 ** target.length, 0);
85+
if (!Number.isFinite(estimatedHashes) || estimatedHashes > MAX_ESTIMATED_HASHES) {
86+
throw new Error("Cap challenge 工作量超出单次登录限制");
87+
}
88+
return challenges.map(([salt, target]) => solvePow(salt, target));
89+
}
90+
91+
function capEndpoint(stationBase, apiEndpoint, siteKey) {
92+
const origin = new URL(stationBase).origin;
93+
const rawEndpoint = String(apiEndpoint || "").trim();
94+
const key = String(siteKey || "").trim().replace(/^\/+|\/+$/g, "");
95+
if (!rawEndpoint || !key) throw new Error("Cap 验证码配置不完整");
96+
const path = new URL(rawEndpoint, `${origin}/`).pathname.replace(/\/+$/, "");
97+
if (!path) throw new Error("Cap 验证码配置不完整");
98+
return `${origin}${path}/${encodeURIComponent(key)}/`;
99+
}
100+
101+
// Sub2API 把验证码配置放在公开设置里。只有启用 Cap 时才生成令牌,
102+
// 未开启验证码或使用其他 provider 的站点继续走原有登录流程。
103+
export async function capTokenForLogin(stationBase, request) {
104+
let settingsResponse;
105+
try {
106+
settingsResponse = await request(`${stationBase}/api/v1/settings/public`);
107+
} catch {
108+
return null;
109+
}
110+
if (settingsResponse.status >= 300) return null;
111+
112+
const settings = settingsResponse.body?.data ?? settingsResponse.body ?? {};
113+
if (settings.captcha_provider !== "cap" || settings.turnstile_enabled === false) return null;
114+
115+
const endpoint = capEndpoint(stationBase, settings.cap_api_endpoint, settings.cap_site_key);
116+
const challengeResponse = await request(`${endpoint}challenge`, { method: "POST", timeoutMs: 15000 });
117+
if (challengeResponse.status >= 300) {
118+
throw new Error(`Cap challenge 获取失败:HTTP ${challengeResponse.status}`);
119+
}
120+
const challenge = challengeResponse.body || {};
121+
if (challenge.error) throw new Error(`Cap challenge 获取失败:${challenge.error}`);
122+
123+
const solutions = solveChallenge(challenge);
124+
const redeemResponse = await request(`${endpoint}redeem`, {
125+
method: "POST",
126+
json: { token: challenge.token, solutions },
127+
timeoutMs: 15000,
128+
});
129+
const redeemed = redeemResponse.body || {};
130+
if (redeemResponse.status >= 300 || !redeemed.success || !redeemed.token) {
131+
throw new Error(`Cap challenge 验证失败:${redeemed.error || `HTTP ${redeemResponse.status}`}`);
132+
}
133+
return redeemed.token;
134+
}

lib/providers.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
// 用密码重新登录(POST /api/v1/auth/login),全自动恢复。
1010
//
1111
// Sub2API 接口契约(源自 Wei-Shaw/sub2api 后端源码):
12-
// POST /api/v1/auth/login body {email, password} → {code:0, data:{access_token, refresh_token, expires_in, user}}
12+
// POST /api/v1/auth/login body {email, password, turnstile_token?} → {code:0, data:{access_token, refresh_token, expires_in, user}}
1313
// POST /api/v1/auth/refresh body {refresh_token} → {code:0, data:{access_token, refresh_token, expires_in}}
1414
// GET /api/v1/auth/me Bearer JWT → {code:0, data:{username, email, balance, total_recharged, ...}}
1515
// 过期:HTTP 401,body {code:"TOKEN_EXPIRED"};开启 2FA 的账号 login 返回 data.requires_2fa
1616

17+
import { capTokenForLogin } from "./cap.js";
18+
1719
// new-api / one-api 的额度单位换算:默认 500000 额度 = 1 美元
1820
const QUOTA_PER_UNIT = 500000;
1921
// 主动刷新缓冲:令牌剩余寿命低于该值就先刷新(与官方客户端一致)
@@ -146,9 +148,14 @@ async function sub2apiLogin(base, station, tokens) {
146148
const password = String(station.password || "");
147149
if (!email || !password) throw new Error("缺少邮箱或密码");
148150

151+
const captchaToken = await capTokenForLogin(base, request);
149152
const r = await request(`${base}/api/v1/auth/login`, {
150153
method: "POST",
151-
json: { email, password },
154+
json: {
155+
email,
156+
password,
157+
...(captchaToken ? { turnstile_token: captchaToken } : {}),
158+
},
152159
});
153160
if (r.status === 401) throw new Error("登录失败:邮箱或密码错误");
154161
if (r.status === 429) throw new Error("登录失败:请求过于频繁(站点限流),稍后自动重试");

lib/providers.test.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import assert from "node:assert/strict";
2+
import { createHash } from "node:crypto";
3+
import { createServer } from "node:http";
4+
import test from "node:test";
5+
import { queryStation } from "./providers.js";
6+
7+
function seededHex(seed, length) {
8+
let state = 2166136261;
9+
for (let i = 0; i < seed.length; i++) {
10+
state ^= seed.charCodeAt(i);
11+
state += (state << 1) + (state << 4) + (state << 7) + (state << 8) + (state << 24);
12+
}
13+
state >>>= 0;
14+
let out = "";
15+
while (out.length < length) {
16+
state ^= state << 13;
17+
state ^= state >>> 17;
18+
state ^= state << 5;
19+
out += (state >>> 0).toString(16).padStart(8, "0");
20+
}
21+
return out.slice(0, length);
22+
}
23+
24+
async function readJson(request) {
25+
const chunks = [];
26+
for await (const chunk of request) chunks.push(chunk);
27+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
28+
}
29+
30+
function sendJson(response, body, status = 200) {
31+
response.writeHead(status, { "Content-Type": "application/json" });
32+
response.end(JSON.stringify(body));
33+
}
34+
35+
test("Sub2API password login solves a Cap challenge and sends turnstile_token", async (t) => {
36+
const challengeToken = "local-challenge";
37+
const challengeSpec = { c: 2, s: 8, d: 1 };
38+
let origin;
39+
let loginBody;
40+
41+
const server = createServer(async (request, response) => {
42+
if (request.url === "/api/v1/settings/public") {
43+
return sendJson(response, {
44+
code: 0,
45+
data: {
46+
turnstile_enabled: true,
47+
captcha_provider: "cap",
48+
cap_api_endpoint: `${origin}/cap`,
49+
cap_site_key: "local-site",
50+
},
51+
});
52+
}
53+
if (request.url === "/cap/local-site/challenge" && request.method === "POST") {
54+
return sendJson(response, { challenge: challengeSpec, token: challengeToken });
55+
}
56+
if (request.url === "/cap/local-site/redeem" && request.method === "POST") {
57+
const body = await readJson(request);
58+
assert.equal(body.token, challengeToken);
59+
assert.equal(body.solutions.length, challengeSpec.c);
60+
for (let i = 0; i < body.solutions.length; i++) {
61+
const n = i + 1;
62+
const salt = seededHex(`${challengeToken}${n}`, challengeSpec.s);
63+
const target = seededHex(`${challengeToken}${n}d`, challengeSpec.d);
64+
const hash = createHash("sha256").update(`${salt}${body.solutions[i]}`).digest("hex");
65+
assert.ok(hash.startsWith(target));
66+
}
67+
return sendJson(response, { success: true, token: "local-cap-token", expires: Date.now() + 60000 });
68+
}
69+
if (request.url === "/api/v1/auth/login" && request.method === "POST") {
70+
loginBody = await readJson(request);
71+
return sendJson(response, {
72+
code: 0,
73+
data: {
74+
access_token: "access-token",
75+
refresh_token: "refresh-token",
76+
expires_in: 3600,
77+
user: { email: "user@example.com" },
78+
},
79+
});
80+
}
81+
if (request.url === "/api/v1/auth/me") {
82+
assert.equal(request.headers.authorization, "Bearer access-token");
83+
return sendJson(response, {
84+
code: 0,
85+
data: { email: "user@example.com", balance: 12.5, total_recharged: 20 },
86+
});
87+
}
88+
if (request.url === "/api/v1/usage/dashboard/stats") {
89+
return sendJson(response, {
90+
code: 0,
91+
data: { today_actual_cost: 1.25, today_requests: 3, today_tokens: 4000 },
92+
});
93+
}
94+
sendJson(response, { error: "not found" }, 404);
95+
});
96+
97+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
98+
t.after(() => new Promise((resolve) => server.close(resolve)));
99+
const address = server.address();
100+
origin = `http://127.0.0.1:${address.port}`;
101+
102+
const station = {
103+
type: "sub2api-password",
104+
baseUrl: origin,
105+
email: "user@example.com",
106+
password: "secret123",
107+
};
108+
const { result, tokensChanged } = await queryStation(station);
109+
110+
assert.equal(result.ok, true);
111+
assert.equal(result.remaining, 12.5);
112+
assert.equal(result.used, 7.5);
113+
assert.equal(result.todayUsed, 1.25);
114+
assert.equal(tokensChanged, true);
115+
assert.deepEqual(loginBody, {
116+
email: "user@example.com",
117+
password: "secret123",
118+
turnstile_token: "local-cap-token",
119+
});
120+
assert.equal(station.s2Tokens.refreshToken, "refresh-token");
121+
});

next.config.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
const nextConfig = {
33
// Docker 部署用 standalone 产物(node server.js 单进程,含后台刷新循环)
44
output: "standalone",
5-
// mysql2 是原生 CJS 服务端依赖,不打包进 serverless bundle
6-
serverExternalPackages: ["mysql2"],
5+
// 原生/CJS 服务端依赖不打包进 serverless bundle;Cap 的 WASM 文件需保留原始目录结构
6+
serverExternalPackages: ["mysql2", "@cap.js/wasm"],
77
// 运行时数据(站点凭证/会话密钥)绝不进构建产物:
88
// 文件追踪会因代码引用 ./data 路径把整个目录拷进 standalone,必须显式排除
99
outputFileTracingExcludes: { "*": ["./data/**", "data/**"] },

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
"dev": "next dev",
1010
"build": "next build",
1111
"start": "next start",
12-
"test": "node --test lib/alerts.test.js db/store.test.js server/own-helpers.test.js",
12+
"test": "node --test lib/alerts.test.js lib/providers.test.js db/store.test.js server/own-helpers.test.js",
1313
"db:migrate": "node db/migrate.js"
1414
},
1515
"dependencies": {
1616
"@ant-design/nextjs-registry": "^1.3.0",
1717
"@ant-design/plots": "^2.6.8",
1818
"@ant-design/pro-components": "3.1.14-2",
19+
"@cap.js/wasm": "0.0.7",
1920
"antd": "^6.5.1",
2021
"dayjs": "^1.11.13",
2122
"mysql2": "^3.15.3",

0 commit comments

Comments
 (0)