@oj-module/cli 0.2.48 → 0.2.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/devkit/README.md +9 -7
- package/devkit/SKILL.md +53 -14
- package/devkit/manual.md +203 -3
- package/devkit/wecom-daikaifa.md +100 -1
- package/devkit/wecom-internal-app.md +643 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +2 -2
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/build-backend.test.ts +80 -0
- package/src/build.ts +50 -0
- package/src/init.ts +5 -4
- package/templates/config.yaml +28 -6
- package/templates/openapi.json +174 -2
- package/templates/src/auth/api/_shared/wecom/admin.ts +87 -7
- package/templates/src/auth/api/_shared/wecom/client.ts +42 -6
- package/templates/src/auth/api/_shared/wecom/config.ts +256 -46
- package/templates/src/auth/api/_shared/wecom/contacts.ts +7 -1
- package/templates/src/auth/api/_shared/wecom/events.ts +25 -6
- package/templates/src/auth/api/_shared/wecom/login.ts +44 -4
- package/templates/src/auth/api/_shared/wecom/replay.ts +44 -3
- package/templates/src/auth/api/_shared/wecom/store.ts +61 -12
- package/templates/src/auth/api/_shared/wecom/sync.ts +25 -1
- package/templates/src/auth/api/migrations/0003__credential_kind.mysql.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.pg.sql +3 -0
- package/templates/src/auth/api/migrations/0003__credential_kind.sql +9 -0
- package/templates/src/auth/api/schema.yaml +2 -0
- package/templates/src/auth/api/wecom/admin/api.ts +112 -11
- package/templates/src/auth/api/wecom/api.ts +151 -59
- package/templates/src/auth/api/wecom/sync/api.ts +21 -5
- package/templates/src/auth/web/__client/api.schemas.ts +22 -1
- package/templates/src/auth/web/__client/api.ts +47 -1
- package/templates/src/login/web/locales/en-US.json +2 -0
- package/templates/src/login/web/locales/zh-CN.json +2 -0
- package/templates/src/login/web/pages/login.tsx +39 -3
- package/templates/src/system/web/locales/en-US.json +24 -1
- package/templates/src/system/web/locales/zh-CN.json +24 -1
- package/templates/src/system/web/pages/departments/sync-bar.tsx +32 -7
- package/templates/src/system/web/pages/wecom/index.tsx +164 -46
- package/templates/tests/wecom-admin.spec.ts +7 -7
- package/templates/tests/wecom-connector.spec.ts +38 -8
- package/templates/tests/wecom-corp.spec.ts +523 -0
- package/templates/tests/wecom-endpoints.spec.ts +73 -7
- package/templates/tests/wecom-org-sync.spec.ts +7 -1
- package/vendor/host-versions.json +3 -3
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CallbackCreds, WecomKind } from "../_shared/wecom/config";
|
|
2
|
+
import type { SelfApp } from "../_shared/wecom/login";
|
|
2
3
|
import type { CallbackFields } from "../_shared/wecom/payload";
|
|
4
|
+
import type { ReplayClaim } from "../_shared/wecom/replay";
|
|
3
5
|
import type { WecomStoreDeps } from "../_shared/wecom/store";
|
|
4
6
|
// GET /api/auth/wecom/login?corp=&agent=&env= —— 发起企微授权(企微内静默 / 浏览器扫码)
|
|
5
7
|
// GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
|
|
6
8
|
// POST /api/auth/wecom/ticket {ticket} —— 一次性票据换 TokenBundle(token 不进 URL)
|
|
7
9
|
// GET /api/auth/wecom/event —— 企微回调 URL 验证(echostr 解出后原样回)
|
|
8
|
-
// POST /api/auth/wecom/event ——
|
|
10
|
+
// POST /api/auth/wecom/event —— 企微回调(代开发指令事件 / 自建应用通讯录变更)
|
|
9
11
|
//
|
|
10
12
|
// 三处纪律:
|
|
11
13
|
// 1. 两个 302 端点**不能**用 respond(它末尾无脑 json.ok,会把 302 覆盖成 200 信封)——
|
|
@@ -18,7 +20,17 @@ import { failWith, respond } from "#/_platform/_shared/respond";
|
|
|
18
20
|
import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "../_shared/login-throttle";
|
|
19
21
|
import { createSession } from "../_shared/session";
|
|
20
22
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
|
|
21
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
|
|
25
|
+
callbackCreds,
|
|
26
|
+
loginRedirectOf,
|
|
27
|
+
readBase,
|
|
28
|
+
readCorpConfig,
|
|
29
|
+
readSuiteConfig,
|
|
30
|
+
siteRootOf,
|
|
31
|
+
wecomKinds,
|
|
32
|
+
} from "../_shared/wecom/config";
|
|
33
|
+
import { handleContactChange } from "../_shared/wecom/contacts";
|
|
22
34
|
import { sha1Hex, unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
23
35
|
import { handleSuiteEvent } from "../_shared/wecom/events";
|
|
24
36
|
import {
|
|
@@ -28,14 +40,16 @@ import {
|
|
|
28
40
|
LOGIN_TICKET_TTL_SECS,
|
|
29
41
|
loginLandingUrl,
|
|
30
42
|
putLoginState,
|
|
43
|
+
resolveWecomProfile,
|
|
44
|
+
|
|
31
45
|
selfUpdate,
|
|
32
46
|
takeLoginState,
|
|
33
47
|
} from "../_shared/wecom/login";
|
|
34
48
|
import { callbackFields, field } from "../_shared/wecom/payload";
|
|
35
|
-
import { guardReplay, runtimeReplayGate } from "../_shared/wecom/replay";
|
|
49
|
+
import { guardReplay, markApplied, ReplayAlreadyApplied, runtimeReplayGate } from "../_shared/wecom/replay";
|
|
36
50
|
import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
|
|
37
51
|
|
|
38
|
-
// 模块级禁止
|
|
52
|
+
// 模块级禁止 readBase()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
|
|
39
53
|
// 评估模块(无 vars/jwt/fetch),一律在 handler 内按需构造(§9.4)。
|
|
40
54
|
|
|
41
55
|
/** 请求头的读取按大小写不敏感处理(http.headers 的键大小写随客户端) */
|
|
@@ -51,7 +65,7 @@ function query(name: string): string {
|
|
|
51
65
|
|
|
52
66
|
/** 纯文本 200(企微事件回执专用;信封会被判失败并触发重推) */
|
|
53
67
|
function plainText(body: string): void {
|
|
54
|
-
const stream = json.stream({ contentType: "text/plain; charset=utf-8" });
|
|
68
|
+
const stream = json.stream({ contentType: "text/plain; charset=utf-8", status: 200 });
|
|
55
69
|
stream.write(body);
|
|
56
70
|
stream.end();
|
|
57
71
|
}
|
|
@@ -60,6 +74,19 @@ function badRequest(message: string): Error {
|
|
|
60
74
|
return Object.assign(new Error(message), { status: 400 });
|
|
61
75
|
}
|
|
62
76
|
|
|
77
|
+
/**
|
|
78
|
+
* 本部署自己那家企业内部应用(配了 `wecom_corp_*` 才有)。
|
|
79
|
+
*
|
|
80
|
+
* 作用是给登录入口一个**确定的默认目标**,而不是去猜档案里哪一行是「本站自己」:共存部署里
|
|
81
|
+
* 档案表还有别家企业通过代开发装进来的行,猜的代价是本站 `/login` 反过来回 400。
|
|
82
|
+
*/
|
|
83
|
+
function corpSelf(): SelfApp | undefined {
|
|
84
|
+
if (!wecomKinds().includes("corp"))
|
|
85
|
+
return undefined;
|
|
86
|
+
const c = readCorpConfig();
|
|
87
|
+
return { corpId: c.corpId, agentId: c.agentId };
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
/**
|
|
64
91
|
* 解不开时把「哪一侧配错」记进日志——只回一句 `pkcs7: bad padding` 等于没给线索。
|
|
65
92
|
*
|
|
@@ -68,23 +95,26 @@ function badRequest(message: string): Error {
|
|
|
68
95
|
* 一个字符),或这条推送其实来自另一个套件/模板(那把钥匙解不开这份密文)。
|
|
69
96
|
* `aesKeyFp` 是本进程**实际加载**那把钥匙的指纹:改完 config 对不上就说明没生效(没重启/改错文件)。
|
|
70
97
|
* · `receiver mismatch` → **钥匙对、身份不对**。`receiveid` 已按候选试过(见 unwrapAccepting:
|
|
71
|
-
*
|
|
72
|
-
*
|
|
98
|
+
* 凭据自己的 receiver + 信封 `ToUserName`),两边都对不上只剩两种:信封里没有 `ToUserName`,
|
|
99
|
+
* 或这条推送既不属本部署、receiveid 也不是它自己声称的身份(伪造/转发)。
|
|
73
100
|
*
|
|
74
|
-
*
|
|
101
|
+
* `kind` 必须记:共存部署里有两套钥匙,日志不写「这次用的是哪套」,排障就退化成猜。
|
|
102
|
+
* 注意 `envelopeToUser` **不等于** `configuredReceiver` 是正常的:数据回调的 `ToUserName` 是 corpid。
|
|
75
103
|
*/
|
|
76
|
-
function noteDecryptFailure(e: unknown, envelope: CallbackFields | null,
|
|
104
|
+
function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, creds: CallbackCreds): void {
|
|
77
105
|
log.warn(
|
|
78
106
|
"wecom callback decrypt failed",
|
|
79
107
|
"reason",
|
|
80
108
|
(e as Error).message,
|
|
109
|
+
"kind",
|
|
110
|
+
creds.kind,
|
|
81
111
|
"envelopeToUser",
|
|
82
112
|
// URL 验证(GET)只有 query 里的 echostr,没有报文信封——那一侧记空串
|
|
83
113
|
envelope ? field(envelope, "ToUserName") : "",
|
|
84
|
-
"
|
|
85
|
-
|
|
114
|
+
"configuredReceiver",
|
|
115
|
+
creds.receiver,
|
|
86
116
|
"aesKeyFp",
|
|
87
|
-
sha1Hex(
|
|
117
|
+
sha1Hex(creds.encodingAesKey).slice(0, 8),
|
|
88
118
|
);
|
|
89
119
|
}
|
|
90
120
|
|
|
@@ -92,12 +122,12 @@ function noteDecryptFailure(e: unknown, envelope: CallbackFields | null, cfg: {
|
|
|
92
122
|
* 按「可能合法的 receiveid」依次尝试解密。
|
|
93
123
|
*
|
|
94
124
|
* receiveid 的含义随场景变(90968 附注):**第三方事件的回调是 suiteid**(suite_ticket / create_auth
|
|
95
|
-
* / cancel_auth
|
|
125
|
+
* / cancel_auth),**企业应用(含代开发应用的数据回调、自建应用的通讯录事件)是 corpid**。
|
|
96
126
|
* 我们一个端点吃两种,只认 suiteid 会把所有数据回调判成「解密失败」——而真正的错是「比错了值」。
|
|
97
127
|
*
|
|
98
128
|
* 只对 `receiver mismatch` 换下一个候选:`bad padding` 说明密钥就不对,换谁都解不开,直接抛。
|
|
99
129
|
*/
|
|
100
|
-
function unwrapAccepting(encrypted: string,
|
|
130
|
+
function unwrapAccepting(encrypted: string, creds: CallbackCreds, candidates: string[]): string {
|
|
101
131
|
let lastErr: unknown;
|
|
102
132
|
const tried = new Set<string>();
|
|
103
133
|
for (const receiver of candidates) {
|
|
@@ -105,7 +135,7 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
|
|
|
105
135
|
continue;
|
|
106
136
|
tried.add(receiver);
|
|
107
137
|
try {
|
|
108
|
-
return unwrapWecomMessage(encrypted,
|
|
138
|
+
return unwrapWecomMessage(encrypted, creds.encodingAesKey, receiver);
|
|
109
139
|
}
|
|
110
140
|
catch (e) {
|
|
111
141
|
if (!/receiver mismatch/.test((e as Error).message))
|
|
@@ -116,6 +146,14 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
|
|
|
116
146
|
throw lastErr ?? new Error("wecom: 没有可比对的 receiveid(suite_id 未配置?)");
|
|
117
147
|
}
|
|
118
148
|
|
|
149
|
+
/** 用哪一把钥匙能验过签名:**逐个试**(SHA1 一次,成本可忽略),命中即用它的钥匙解密 */
|
|
150
|
+
function matchCreds(encrypted: string): CallbackCreds | null {
|
|
151
|
+
const sig = query("msg_signature");
|
|
152
|
+
const ts = query("timestamp");
|
|
153
|
+
const nonce = query("nonce");
|
|
154
|
+
return callbackCreds().find(c => wecomSignature(c.token, ts, nonce, encrypted) === sig) ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
/**
|
|
120
158
|
* 校验企微回调签名并解出明文。签名不合法 → 400(不是 401:调用方是企微服务器,
|
|
121
159
|
* 401 会被当成「本站需要鉴权」误导排查);信封里的原文不回显,避免给探测者提供比对材料。
|
|
@@ -123,53 +161,75 @@ function unwrapAccepting(encrypted: string, cfg: WecomConfig, candidates: string
|
|
|
123
161
|
* 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
|
|
124
162
|
* cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
|
|
125
163
|
*/
|
|
126
|
-
async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
if (expected !== query("msg_signature"))
|
|
164
|
+
async function decryptVerified(encrypted: string, envelope: CallbackFields): Promise<{ kind: WecomKind, plain: string, claim: ReplayClaim }> {
|
|
165
|
+
const creds = matchCreds(encrypted);
|
|
166
|
+
if (!creds)
|
|
130
167
|
throw badRequest("回调签名校验失败");
|
|
131
|
-
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
168
|
+
const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
132
169
|
try {
|
|
133
170
|
// 信封的 ToUserName 就是这条报文的身份:套件事件里是 suiteid,应用数据回调里是 corpid,
|
|
134
|
-
// 与
|
|
135
|
-
return {
|
|
171
|
+
// 与 receiver 必然一致(两者在同一份密文/明文里,签名已经覆盖)。
|
|
172
|
+
return { kind: creds.kind, plain: unwrapAccepting(encrypted, creds, [creds.receiver, field(envelope, "ToUserName")]), claim };
|
|
136
173
|
}
|
|
137
174
|
catch (e) {
|
|
175
|
+
// 解不开的这一条不能占住 nonce:企微重推是它唯一的恢复路径,占住就把重推挡成 400
|
|
176
|
+
await claim.release();
|
|
138
177
|
// 解密失败只对外透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
139
|
-
noteDecryptFailure(e, envelope,
|
|
178
|
+
noteDecryptFailure(e, envelope, creds);
|
|
140
179
|
throw badRequest("回调消息解密失败");
|
|
141
180
|
}
|
|
142
181
|
}
|
|
143
182
|
|
|
183
|
+
/** 放回一次 nonce 占用;放回失败只告警——它不该盖住真正的失败原因 */
|
|
184
|
+
async function releaseClaim(claim: ReplayClaim | undefined): Promise<void> {
|
|
185
|
+
if (!claim)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
await claim.release();
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
log.warn("wecom replay nonce release failed", "reason", (e as Error).message);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
144
195
|
const login = {
|
|
145
196
|
// 企微内打开(UA 含 wxwork)→ oauth2 静默授权;普通浏览器 → qrConnect 扫码。
|
|
146
197
|
// corp 必填(D10:企业代码 = corp_id,服务商后台可生成带码的专属登录链接)。
|
|
147
198
|
async get() {
|
|
148
199
|
try {
|
|
149
|
-
|
|
200
|
+
// 登录只用「与来源无关」的两项(落库密钥 + 基址):自建模式没有 suite 凭据,
|
|
201
|
+
// 在这里读整套 suite 配置会把一条正常的登录打成 400。
|
|
202
|
+
const base = readBase();
|
|
203
|
+
const deps = runtimeStoreDeps(base);
|
|
204
|
+
|
|
150
205
|
const corp = query("corp");
|
|
151
206
|
const agent = query("agent");
|
|
152
|
-
if (!corp)
|
|
153
|
-
throw badRequest("缺少企业代码(corp):请使用企业管理员收到的登录链接,或在登录页填写企业代码");
|
|
154
207
|
|
|
155
|
-
// 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)
|
|
156
208
|
const ip = await clientIp();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
209
|
+
// 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)。
|
|
210
|
+
// corp 缺省时(代开发模板常只服务一家企业)自动从已安装档案解析,省去手动填企业代码,
|
|
211
|
+
// 也让「应用主页」可直接填 https://<域名>/login(无需 ${corpid} 占位符,企微后台会因非法 URL 字符拒收)。
|
|
212
|
+
let profile: { corpId: string, agentId: string, env: string };
|
|
213
|
+
if (corp) {
|
|
214
|
+
await guardLoginThrottle(ip, corp);
|
|
215
|
+
profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
|
|
216
|
+
// 只有「企业代码无效/多应用未指定」计失败;配置类错误(缺 vars)不该污染节流计数
|
|
217
|
+
if (e.status === 400)
|
|
218
|
+
await noteLoginFailed(ip, corp);
|
|
219
|
+
throw e;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
profile = await resolveWecomProfile(agent || null, corpSelf());
|
|
224
|
+
await guardLoginThrottle(ip, profile.corpId);
|
|
225
|
+
}
|
|
166
226
|
|
|
167
227
|
const state = crypto.randomHex(16);
|
|
168
228
|
await putLoginState(state, { corpId: profile.corpId, agentId: profile.agentId, env: profile.env }, runtimeCache());
|
|
169
229
|
const url = buildAuthorizeUrl({
|
|
170
230
|
corpId: profile.corpId,
|
|
171
231
|
agentId: profile.agentId,
|
|
172
|
-
redirectUri: loginRedirectOf(
|
|
232
|
+
redirectUri: loginRedirectOf(base),
|
|
173
233
|
state,
|
|
174
234
|
inWecom: /wxwork/i.test(header("user-agent")),
|
|
175
235
|
});
|
|
@@ -185,7 +245,7 @@ const callback = {
|
|
|
185
245
|
// 企微回跳:state 一次性(kv 取即删)→ profile → 绑租户 → code 换 userid → JIT 建档 → 发票 → 302。
|
|
186
246
|
async get() {
|
|
187
247
|
try {
|
|
188
|
-
const
|
|
248
|
+
const base = readBase();
|
|
189
249
|
const code = query("code");
|
|
190
250
|
const state = query("state");
|
|
191
251
|
// 用户在企业微信里点了「拒绝」时企微不回 code,只回 state
|
|
@@ -194,7 +254,7 @@ const callback = {
|
|
|
194
254
|
if (!state)
|
|
195
255
|
throw badRequest("登录状态缺失:请从企业微信重新进入");
|
|
196
256
|
|
|
197
|
-
const deps = runtimeStoreDeps(
|
|
257
|
+
const deps = runtimeStoreDeps(base);
|
|
198
258
|
const cache = runtimeCache();
|
|
199
259
|
const pending = await takeLoginState(state, cache);
|
|
200
260
|
const profile = await findProfile(pending.corpId, pending.agentId, deps);
|
|
@@ -224,7 +284,7 @@ const callback = {
|
|
|
224
284
|
await issueLoginTicket(crypto.sha256Hex(ticket), uid, tenant, LOGIN_TICKET_TTL_SECS, deps);
|
|
225
285
|
await noteLoginSucceeded(await clientIp(), profile.corpId);
|
|
226
286
|
log.info("wecom login ok", "tenant", tenant, "agentId", profile.agentId);
|
|
227
|
-
json.redirect.found(loginLandingUrl({ origin: siteRootOf(
|
|
287
|
+
json.redirect.found(loginLandingUrl({ origin: siteRootOf(base), ticket, tenant }));
|
|
228
288
|
}
|
|
229
289
|
catch (e) {
|
|
230
290
|
failWith(e);
|
|
@@ -264,32 +324,35 @@ ticket.post.schema = {
|
|
|
264
324
|
const event = {
|
|
265
325
|
// 企微后台「保存回调 URL」时会 GET 一次:解出 echostr 后**原样明文回**(不套信封、不加引号)。
|
|
266
326
|
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
327
|
+
// 只依赖回调凭据(`callbackCreds()`):token/aeskey 由部署方与企微后台成对填写,不需要 suite_id——
|
|
328
|
+
// 因此「配齐即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。这正是打破鸡生蛋死锁
|
|
329
|
+
// (plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
|
|
330
|
+
//
|
|
331
|
+
// 共存部署有**两套**回调凭据(代开发的指令回调 / 自建应用的通讯录同步回调),企微用哪一套签名
|
|
332
|
+
// 取决于管理员在哪个后台点「保存」→ 逐个试签,命中哪套就用哪套钥匙解密。两套都没配才报错。
|
|
270
333
|
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
334
|
+
// URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验),加上「应用回调 URL 验证时 receiveid
|
|
335
|
+
// 是 corpid」这一层,GET 根本无从挑一个值来比 → 一律 tolerant,见下面 try 里的说明
|
|
336
|
+
// (签名是唯一闸门,本端点不落状态)。
|
|
274
337
|
async get() {
|
|
275
338
|
try {
|
|
276
|
-
const cfg = readCallbackConfig();
|
|
277
339
|
const encrypted = query("echostr");
|
|
278
|
-
const
|
|
279
|
-
if (
|
|
340
|
+
const creds = matchCreds(encrypted);
|
|
341
|
+
if (!creds)
|
|
280
342
|
throw badRequest("回调签名校验失败");
|
|
281
|
-
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
282
|
-
const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
|
|
343
|
+
const claim = await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
283
344
|
try {
|
|
284
345
|
// receiveid 一律不比对:GET 只有 echostr、没有报文信封,无从知道该比哪一个——
|
|
285
|
-
// 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL
|
|
346
|
+
// 「模板/指令回调 URL」验证时 receiveid 是 suiteid,「应用回调 URL / 通讯录同步」验证时是 corpid。
|
|
286
347
|
// 放宽是安全的:签名已过(token 只有本部署与企微知道),回包只是把解出的 echostr 原样返回,
|
|
287
348
|
// 不写任何状态;真正的 receiveid 比对在 POST 那条路径上(见 unwrapAccepting)。
|
|
288
|
-
plainText(unwrapWecomMessage(encrypted,
|
|
349
|
+
plainText(unwrapWecomMessage(encrypted, creds.encodingAesKey, creds.receiver, true));
|
|
289
350
|
}
|
|
290
351
|
catch (e) {
|
|
352
|
+
// 验证没通过就放回 nonce:企微后台点「保存」会连着验几次,占住会把后几次都挡成 400
|
|
353
|
+
await claim.release();
|
|
291
354
|
// 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
292
|
-
noteDecryptFailure(e, null,
|
|
355
|
+
noteDecryptFailure(e, null, creds);
|
|
293
356
|
throw badRequest("回调消息解密失败");
|
|
294
357
|
}
|
|
295
358
|
}
|
|
@@ -298,23 +361,52 @@ const event = {
|
|
|
298
361
|
}
|
|
299
362
|
},
|
|
300
363
|
|
|
301
|
-
// 回调(指令 +
|
|
302
|
-
//
|
|
364
|
+
// 回调(指令 + 应用数据 + 通讯录同步):验签 → 解密 → 按**命中凭据的来源**分发。
|
|
365
|
+
// 回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
366
|
+
//
|
|
367
|
+
// 来源分派是共存的关键:suite 凭据只可能收到套件事件(suite_ticket / create_auth / change_auth /
|
|
368
|
+
// cancel_auth);corp 凭据只可能收到通讯录变更(企业管理员在「通讯录同步 → 接收事件服务器」填的本 URL),
|
|
369
|
+
// **没有**套件事件这回事,所以直接进 change_contact 分支,不进 handleSuiteEvent 的 switch。
|
|
303
370
|
//
|
|
304
371
|
// 包体形态按官方文档是 XML(见 payload.ts 的开头),但归一化那一层两种都认,
|
|
305
372
|
// 所以本函数只认字段名,不认格式。
|
|
306
373
|
async post() {
|
|
374
|
+
let claim: ReplayClaim | undefined;
|
|
307
375
|
try {
|
|
308
376
|
const envelope = callbackFields(http.body);
|
|
309
377
|
const encrypted = field(envelope, "Encrypt");
|
|
310
378
|
if (!encrypted)
|
|
311
379
|
throw badRequest("回调报文缺少 Encrypt(不是企微回调,或中间件改写了请求体)");
|
|
312
|
-
const
|
|
380
|
+
const verified = await decryptVerified(encrypted, envelope);
|
|
381
|
+
claim = verified.claim;
|
|
382
|
+
|
|
313
383
|
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
314
|
-
|
|
384
|
+
if (verified.kind === "corp") {
|
|
385
|
+
// 自建应用没有套件流程:报文只可能是 change_contact(其余形态由 contacts.ts 记账后放过)
|
|
386
|
+
await handleContactChange(callbackFields(verified.plain), {
|
|
387
|
+
client,
|
|
388
|
+
store: runtimeStoreDeps(readBase()),
|
|
389
|
+
credentialKind: "corp",
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
const cfg = readSuiteConfig();
|
|
394
|
+
await handleSuiteEvent(verified.plain, { cfg, client, store: runtimeStoreDeps(cfg) });
|
|
395
|
+
}
|
|
396
|
+
// 处理成功即标记 nonce 已落库:企微重推时命中即幂等回 success,解开「首推偏慢被企微判超时、
|
|
397
|
+
// 重推又被重放守卫挡 400」的死局——管理员端不再卡在「应用安装失败」。
|
|
398
|
+
await markApplied(query("nonce"), runtimeReplayGate()).catch(() => {});
|
|
315
399
|
plainText("success");
|
|
316
400
|
}
|
|
317
401
|
catch (e) {
|
|
402
|
+
// 重推且首推已成功落库:幂等回 success,管理员端不再卡在「应用安装失败」。
|
|
403
|
+
if (e instanceof ReplayAlreadyApplied) {
|
|
404
|
+
plainText("success");
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
// 处理失败必须放回 nonce:企微只在「拿不到 success」时重推,而那正是需要重推的时刻;
|
|
408
|
+
// 占住不放,重推会被自己的重放守卫判成 400,瞬态故障的恢复路径当场断掉。
|
|
409
|
+
await releaseClaim(claim);
|
|
318
410
|
// 企微侧只会显示「应用安装失败,联系服务商」,原因一概不透露——这里是唯一能查到
|
|
319
411
|
// 「哪一环拒了这条事件」的地方(缺这一行,这场失败在运维视角是零信息)。
|
|
320
412
|
const err = e as Error & { status?: number };
|
|
@@ -18,7 +18,7 @@ import { assertPermission, assertTenantSelf } from "#/_platform/_shared/service/
|
|
|
18
18
|
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
19
19
|
import { rateLimit } from "#/_platform/_shared/service/infra/rate";
|
|
20
20
|
import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
|
|
21
|
-
import {
|
|
21
|
+
import { corpIdentity, readBase } from "../../_shared/wecom/config";
|
|
22
22
|
import { findProfile, probeProfile, runtimeStoreDeps } from "../../_shared/wecom/store";
|
|
23
23
|
import { attachProfile, cancelSync, emptyView, startSync, syncStatus } from "../../_shared/wecom/sync";
|
|
24
24
|
|
|
@@ -44,12 +44,15 @@ const STATS = {
|
|
|
44
44
|
|
|
45
45
|
const VIEW = {
|
|
46
46
|
type: "object",
|
|
47
|
-
required: ["connected", "authMode", "profileStatus", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
47
|
+
required: ["connected", "authMode", "profileStatus", "agentId", "credentialKind", "status", "phase", "stats", "progress", "errCode", "errDetail", "startedAt", "finishedAt", "updateTime"],
|
|
48
48
|
properties: {
|
|
49
49
|
// 授权事实(页面据此决定「整条隐藏 / 成员授权卡片 / 冻结黄条 / 正常同步栏」)
|
|
50
50
|
connected: { type: "boolean" },
|
|
51
51
|
authMode: { type: "number" },
|
|
52
52
|
profileStatus: { type: ["string", "null"] },
|
|
53
|
+
// 分辨锚点:多应用/共存企业下前端必须原样回传(C6)
|
|
54
|
+
agentId: { type: ["string", "null"] },
|
|
55
|
+
credentialKind: { type: ["string", "null"], enum: ["suite", "corp", null] },
|
|
53
56
|
status: { type: "string", enum: ["idle", "running", "done", "failed", "cancelled"] },
|
|
54
57
|
phase: { type: "string", enum: ["departments", "members", "reconcile"] },
|
|
55
58
|
stats: STATS,
|
|
@@ -80,6 +83,18 @@ function agentOf(raw: unknown): string | null {
|
|
|
80
83
|
return s.length > 0 ? s : null;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 未带 agent 时的兜底:**只有当本租户就是本部署自建的那家企业**才用配置里的 agent。
|
|
88
|
+
*
|
|
89
|
+
* 共存下的双应用租户(同一 corp 同时有代开发行与自建行)不带 agent 时探针只能 400,
|
|
90
|
+
* 而「点进自己组织架构页」不该要求管理员手填应用 id。别家企业没有这个兜底——
|
|
91
|
+
* 它们的 agent 只能来自登录链接/状态条回传,不由本站配置决定(那正是代开发的意义)。
|
|
92
|
+
*/
|
|
93
|
+
function selfAgentOf(tenantId: string): string | null {
|
|
94
|
+
const self = corpIdentity();
|
|
95
|
+
return self && self.corpId === tenantId ? self.agentId : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
/**
|
|
84
99
|
* 解析当前租户的授权档案(企业代码 = 租户;多应用时必须带 agent)。
|
|
85
100
|
*
|
|
@@ -88,14 +103,15 @@ function agentOf(raw: unknown): string | null {
|
|
|
88
103
|
* 是自己填错了),findProfile 负责解密出 permanent_code。
|
|
89
104
|
*/
|
|
90
105
|
async function profileOf(tenantId: string, agent: unknown) {
|
|
91
|
-
const probe = await probeProfile(tenantId, agentOf(agent));
|
|
106
|
+
const probe = await probeProfile(tenantId, agentOf(agent) ?? selfAgentOf(tenantId));
|
|
92
107
|
if (!probe) {
|
|
93
108
|
throw Object.assign(new Error("本站尚未开通企业微信:没有可同步的授权记录"), { status: 400 });
|
|
94
109
|
}
|
|
95
110
|
if (probe.status !== "active") {
|
|
96
111
|
throw Object.assign(new Error("该企业已取消授权或应用已停用:组织架构同步已冻结,请联系服务商重新开通"), { status: 400 });
|
|
97
112
|
}
|
|
98
|
-
|
|
113
|
+
// 同步只用与来源无关的两项(落库密钥 + 基址):读整套 suite 配置会把自建部署打成 400
|
|
114
|
+
const cfg = readBase();
|
|
99
115
|
const profile = await findProfile(tenantId, probe.agentId, runtimeStoreDeps(cfg));
|
|
100
116
|
return { profile, probe, cfg };
|
|
101
117
|
}
|
|
@@ -149,7 +165,7 @@ const status = {
|
|
|
149
165
|
const tenantId = currentTenant();
|
|
150
166
|
if (!tenantId)
|
|
151
167
|
throw Object.assign(new Error("缺少租户上下文"), { status: 400 });
|
|
152
|
-
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent));
|
|
168
|
+
const probe = await probeProfile(tenantId, agentOf((http.query as Record<string, unknown>).agent) ?? selfAgentOf(tenantId));
|
|
153
169
|
if (!probe)
|
|
154
170
|
return attachProfile(emptyView("idle"), null);
|
|
155
171
|
return attachProfile(await syncStatus(tenantId, probe.agentId), probe);
|
|
@@ -19,6 +19,7 @@ export const schemas = {
|
|
|
19
19
|
"env": z.string(),
|
|
20
20
|
"corp_name": z.string().nullable().optional(),
|
|
21
21
|
"status": z.enum(["active", "disabled"]),
|
|
22
|
+
"credential_kind": z.enum(["suite", "corp"]),
|
|
22
23
|
"auth_mode": z.number(),
|
|
23
24
|
"installed_at": z.number(),
|
|
24
25
|
"update_time": z.number()
|
|
@@ -40,6 +41,24 @@ export const schemas = {
|
|
|
40
41
|
"installUrl": z.string(),
|
|
41
42
|
"callbackUrl": z.string(),
|
|
42
43
|
"expiresIn": z.number()
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
"authWecomAdminOverviewGet": {
|
|
47
|
+
data: z.object({
|
|
48
|
+
"kinds": z.array(z.enum(["suite", "corp"])),
|
|
49
|
+
"configError": z.string().nullable(),
|
|
50
|
+
"callbackUrl": z.string().nullable(),
|
|
51
|
+
"corpId": z.string().nullable(),
|
|
52
|
+
"agentId": z.string().nullable(),
|
|
53
|
+
"corpCallbackConfigured": z.boolean()
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
"authWecomAdminConnectPost": {
|
|
57
|
+
data: z.object({
|
|
58
|
+
"corpId": z.string(),
|
|
59
|
+
"agentId": z.string(),
|
|
60
|
+
"departments": z.number(),
|
|
61
|
+
"members": z.number()
|
|
43
62
|
}),
|
|
44
63
|
},
|
|
45
64
|
"authWecomTicketPost": {
|
|
@@ -108,7 +127,9 @@ export const schemas = {
|
|
|
108
127
|
}).nullable(),
|
|
109
128
|
"startedAt": z.number(),
|
|
110
129
|
"finishedAt": z.number(),
|
|
111
|
-
"updateTime": z.number()
|
|
130
|
+
"updateTime": z.number(),
|
|
131
|
+
"agentId": z.string().nullable(),
|
|
132
|
+
"credentialKind": z.union([z.literal("suite"), z.literal("corp"), z.literal(null)]).nullable()
|
|
112
133
|
}),
|
|
113
134
|
},
|
|
114
135
|
"authWecomSyncCancelPost": {
|
|
@@ -33,7 +33,7 @@ export function createAuthClient(ctx: ModuleContext) {
|
|
|
33
33
|
}
|
|
34
34
|
ctx.register.apiPrefix(API_PREFIX);
|
|
35
35
|
bindRequest(ctx.utils.request);
|
|
36
|
-
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
36
|
+
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomAdminOverviewGet, authWecomAdminConnectPost, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost, authWecomSyncStartPost, authWecomSyncStatusGet, authWecomSyncCancelPost };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -211,6 +211,52 @@ export async function authWecomAdminInstallGet(): Promise<AuthWecomAdminInstallG
|
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
export type AuthWecomAdminOverviewGetData = z.infer<(typeof schemas)["authWecomAdminOverviewGet"]["data"]>;
|
|
215
|
+
|
|
216
|
+
export async function authWecomAdminOverviewGet(): Promise<AuthWecomAdminOverviewGetData> {
|
|
217
|
+
const client = ensureReq();
|
|
218
|
+
try {
|
|
219
|
+
const env = await client.get(`auth/wecom/admin/overview`).json<OjEnvelope<AuthWecomAdminOverviewGetData>>();
|
|
220
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
221
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
222
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
223
|
+
const data = env.data as AuthWecomAdminOverviewGetData;
|
|
224
|
+
if (import.meta.env.DEV) {
|
|
225
|
+
const { schemas } = await import("./api.schemas");
|
|
226
|
+
const r = schemas["authWecomAdminOverviewGet"].data.safeParse(data);
|
|
227
|
+
if (!r.success)
|
|
228
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminOverviewGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
229
|
+
}
|
|
230
|
+
return data;
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
throw await toApiError(e);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export type AuthWecomAdminConnectPostData = z.infer<(typeof schemas)["authWecomAdminConnectPost"]["data"]>;
|
|
238
|
+
|
|
239
|
+
export async function authWecomAdminConnectPost(): Promise<AuthWecomAdminConnectPostData> {
|
|
240
|
+
const client = ensureReq();
|
|
241
|
+
try {
|
|
242
|
+
const env = await client.post(`auth/wecom/admin/connect`).json<OjEnvelope<AuthWecomAdminConnectPostData>>();
|
|
243
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
244
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
245
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
246
|
+
const data = env.data as AuthWecomAdminConnectPostData;
|
|
247
|
+
if (import.meta.env.DEV) {
|
|
248
|
+
const { schemas } = await import("./api.schemas");
|
|
249
|
+
const r = schemas["authWecomAdminConnectPost"].data.safeParse(data);
|
|
250
|
+
if (!r.success)
|
|
251
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminConnectPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
252
|
+
}
|
|
253
|
+
return data;
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
throw await toApiError(e);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
214
260
|
export async function authWecomLoginGet(): Promise<unknown> {
|
|
215
261
|
const client = ensureReq();
|
|
216
262
|
try {
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"wecom": "WeCom",
|
|
32
32
|
"wecom.action": "Sign in with WeCom",
|
|
33
33
|
"wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
|
|
34
|
+
"wecom.agent": "App ID (agentid)",
|
|
35
|
+
"wecom.agentHint": "Leave empty if the corp has a single app; otherwise use the agentid provided by your admin",
|
|
34
36
|
"wecom.exchanging": "Completing WeCom sign-in…"
|
|
35
37
|
}
|
|
36
38
|
}
|