@oj-module/cli 0.2.42 → 0.2.45
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 +7 -5
- package/devkit/SKILL.md +22 -2
- package/devkit/manual.md +5 -0
- package/devkit/wecom-daikaifa.md +392 -0
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +102 -39
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/manifest-routes-derive.test.ts +22 -0
- package/src/__tests__/site-tenant-header.test.ts +54 -0
- package/src/__tests__/templates-tenant-scope.test.ts +139 -0
- package/src/build.ts +9 -0
- package/src/init.ts +4 -1
- package/src/install.ts +2 -2
- package/src/market/__tests__/verify.test.ts +32 -1
- package/src/market/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/verify.ts +7 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +111 -101
- package/templates/openapi.json +319 -0
- package/templates/src/_platform/api/_lifecycle/steps/download.ts +6 -2
- package/templates/src/_platform/api/_lifecycle/steps/smoke.ts +17 -9
- package/templates/src/_platform/api/_shared/respond.ts +11 -6
- package/templates/src/_platform/api/_shared/service/domain/settings.ts +5 -3
- package/templates/src/_platform/api/_shared/service/domain/users.ts +9 -3
- package/templates/src/_platform/api/_shared/service/infra/access.ts +59 -4
- package/templates/src/_platform/api/_shared/service/infra/query.ts +5 -1
- package/templates/src/_platform/api/contacts/api.ts +4 -1
- package/templates/src/_platform/api/contacts/detail/api.ts +3 -0
- package/templates/src/_platform/api/migrations/0015__tenant.mysql.sql +12 -0
- package/templates/src/_platform/api/migrations/0015__tenant.pg.sql +11 -0
- package/templates/src/_platform/api/migrations/0015__tenant.sql +44 -0
- package/templates/src/_platform/api/schema.yaml +28 -0
- package/templates/src/_platform/api/seed.sql +3 -0
- package/templates/src/auth/api/_shared/wecom/admin.ts +112 -0
- package/templates/src/auth/api/_shared/wecom/client.ts +243 -0
- package/templates/src/auth/api/_shared/wecom/config.ts +133 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +312 -0
- package/templates/src/auth/api/_shared/wecom/events.ts +127 -0
- package/templates/src/auth/api/_shared/wecom/login.ts +137 -0
- package/templates/src/auth/api/_shared/wecom/replay.ts +66 -0
- package/templates/src/auth/api/_shared/wecom/store.ts +230 -0
- package/templates/src/auth/api/_shared/wecom/xml.ts +36 -0
- package/templates/src/auth/api/manifest.yaml +6 -0
- package/templates/src/auth/api/migrations/0001__wecom.mysql.sql +35 -0
- package/templates/src/auth/api/migrations/0001__wecom.pg.sql +33 -0
- package/templates/src/auth/api/migrations/0001__wecom.sql +47 -0
- package/templates/src/auth/api/schema.yaml +48 -0
- package/templates/src/auth/api/wecom/admin/api.ts +121 -0
- package/templates/src/auth/api/wecom/api.ts +226 -0
- package/templates/src/auth/web/__client/api.schemas.ts +36 -0
- package/templates/src/auth/web/__client/api.ts +148 -1
- package/templates/src/demo/api/migrations/0002__tenant.mysql.sql +2 -0
- package/templates/src/demo/api/migrations/0002__tenant.sql +2 -0
- package/templates/src/demo/api/schema.yaml +1 -0
- package/templates/src/home/api/migrations/0002__tenant.mysql.sql +3 -0
- package/templates/src/home/api/migrations/0002__tenant.sql +3 -0
- package/templates/src/home/api/schema.yaml +2 -0
- package/templates/src/login/web/auth-provider.ts +48 -0
- package/templates/src/login/web/entry.ts +21 -1
- package/templates/src/login/web/locales/en-US.json +11 -1
- package/templates/src/login/web/locales/zh-CN.json +11 -1
- package/templates/src/login/web/pages/login.tsx +154 -25
- package/templates/src/login/web/wecom.ts +51 -0
- package/templates/src/notification/api/_shared/notify.ts +9 -5
- package/templates/src/notification/api/api.ts +25 -0
- package/templates/src/notification/api/del/api.ts +5 -0
- package/templates/src/notification/api/manifest.yaml +3 -0
- package/templates/src/notification/api/migrations/0005__tenant.mysql.sql +6 -0
- package/templates/src/notification/api/migrations/0005__tenant.sql +6 -0
- package/templates/src/notification/api/schema.yaml +4 -0
- package/templates/src/personal-center/api/profile/api.ts +13 -0
- package/templates/src/personal-center/api/upload/api.ts +8 -1
- package/templates/src/system/web/entry.ts +8 -0
- package/templates/src/system/web/locales/en-US.json +33 -2
- package/templates/src/system/web/locales/zh-CN.json +33 -2
- package/templates/src/system/web/pages/login-methods/index.tsx +2 -1
- package/templates/src/system/web/pages/users/index.tsx +1 -1
- package/templates/src/system/web/pages/wecom/index.tsx +205 -0
- package/templates/tests/fixture.ts +91 -11
- package/templates/tests/guard.spec.ts +9 -3
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +8 -6
- package/templates/tests/lifecycle/installer.spec.ts +76 -0
- package/templates/tests/lifecycle/trust.spec.ts +1 -1
- package/templates/tests/query-utils.spec.ts +12 -9
- package/templates/tests/rbac-guard.spec.ts +18 -1
- package/templates/tests/tenant-access-cache.spec.ts +99 -0
- package/templates/tests/tenant-forgery.spec.ts +152 -0
- package/templates/tests/tenant-isolation.spec.ts +133 -0
- package/templates/tests/username-namespace.spec.ts +118 -0
- package/templates/tests/wecom-admin.spec.ts +386 -0
- package/templates/tests/wecom-connector.spec.ts +671 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +457 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// 企微代开发「指令回调」的验签与解密原语(Phase 1)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么手写:oj isolate 的 crypto 面(0.1.110 实测,devkit global.d.ts)只有
|
|
4
|
+
// sha256Hex / md5Hex / aesGcm* / verifyEd25519 / verifyEd25519;企微指令回调钉死
|
|
5
|
+
// `msg_signature = sha1(sort(token,timestamp,nonce,Encrypt))` + `AES-256-CBC(PKCS#7)`,
|
|
6
|
+
// 两样都没有。处置与 _platform/api/_lifecycle/sha384.ts 一致:自实现 + 上游需求 R8
|
|
7
|
+
// (`crypto.sha1Hex` / `crypto.aesCbcDecrypt` 原语)。落地 R8 后本文件可直接删。
|
|
8
|
+
//
|
|
9
|
+
// 边界:只做**解密**(回包走明文 `echostr` / `success`),不做加密;输入按字节处理,
|
|
10
|
+
// 长度/填充非法一律抛错(不猜、不截断)——宁可事件处理失败留痕,不可解出半截明文。
|
|
11
|
+
// S 盒不手抄 512 字节常量表:按 FIPS 197 定义式(GF 乘法逆元 + 仿射变换)在加载期生成,
|
|
12
|
+
// 逆 S 盒由置换求逆(加载期一次性,<5ms)。
|
|
13
|
+
|
|
14
|
+
const BLOCK = 16;
|
|
15
|
+
|
|
16
|
+
/** GF(2^8) 乘法,域多项式 x^8+x^4+x^3+x+1(0x11B),AES 唯一乘法 */
|
|
17
|
+
function gmul(a: number, b: number): number {
|
|
18
|
+
let p = 0;
|
|
19
|
+
let x = a & 0xFF;
|
|
20
|
+
let y = b & 0xFF;
|
|
21
|
+
for (let i = 0; i < 8; i++) {
|
|
22
|
+
if (y & 1)
|
|
23
|
+
p ^= x;
|
|
24
|
+
const hi = x & 0x80;
|
|
25
|
+
x = (x << 1) & 0xFF;
|
|
26
|
+
if (hi)
|
|
27
|
+
x ^= 0x1B;
|
|
28
|
+
y >>= 1;
|
|
29
|
+
}
|
|
30
|
+
return p;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 乘法逆元(0 的逆元定义为 0):255 次试乘,加载期一次性 */
|
|
34
|
+
function ginv(a: number): number {
|
|
35
|
+
if (a === 0)
|
|
36
|
+
return 0;
|
|
37
|
+
for (let x = 1; x < 256; x++) {
|
|
38
|
+
if (gmul(a, x) === 1)
|
|
39
|
+
return x;
|
|
40
|
+
}
|
|
41
|
+
throw new Error("aes: no inverse"); // 不可达(GF(2^8) 除 0 外皆可逆)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rotl8 = (v: number, n: number): number => ((v << n) | (v >>> (8 - n))) & 0xFF;
|
|
45
|
+
|
|
46
|
+
// S 盒 = 逆元 ⊕ 循环移位 1/2/3/4 ⊕ 0x63(FIPS 197 §5.1.1);逆 S 盒 = 该置换的逆
|
|
47
|
+
const SBOX = new Uint8Array(256);
|
|
48
|
+
const INV_SBOX = new Uint8Array(256);
|
|
49
|
+
for (let a = 0; a < 256; a++) {
|
|
50
|
+
const inv = ginv(a);
|
|
51
|
+
const s = inv ^ rotl8(inv, 1) ^ rotl8(inv, 2) ^ rotl8(inv, 3) ^ rotl8(inv, 4) ^ 0x63;
|
|
52
|
+
SBOX[a] = s;
|
|
53
|
+
INV_SBOX[s] = a;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const NK = 8; // AES-256:密钥 8 个 32bit 字
|
|
57
|
+
const NR = 14; // 轮数
|
|
58
|
+
const RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40]; // 只需前 NR/NK = 7 项
|
|
59
|
+
|
|
60
|
+
/** 密钥扩展(FIPS 197 §5.2):返回 4*(NR+1) 个 32bit 字,字内 big-endian(b0 在高位) */
|
|
61
|
+
function expandKey(key: Uint8Array): Uint32Array {
|
|
62
|
+
if (key.length !== 32)
|
|
63
|
+
throw new Error(`aes: key must be 32 bytes, got ${key.length}`);
|
|
64
|
+
const w = new Uint32Array(4 * (NR + 1));
|
|
65
|
+
for (let i = 0; i < NK; i++)
|
|
66
|
+
w[i] = ((key[4 * i]! << 24) | (key[4 * i + 1]! << 16) | (key[4 * i + 2]! << 8) | key[4 * i + 3]!) >>> 0;
|
|
67
|
+
for (let i = NK; i < w.length; i++) {
|
|
68
|
+
let t = w[i - 1]!;
|
|
69
|
+
if (i % NK === 0) {
|
|
70
|
+
// RotWord → SubWord → Rcon
|
|
71
|
+
t = ((t << 8) | (t >>> 24)) >>> 0;
|
|
72
|
+
t = ((SBOX[(t >>> 24) & 0xFF]! << 24) | (SBOX[(t >>> 16) & 0xFF]! << 16) | (SBOX[(t >>> 8) & 0xFF]! << 8) | SBOX[t & 0xFF]!) >>> 0;
|
|
73
|
+
t = (t ^ (RCON[i / NK - 1]! << 24)) >>> 0;
|
|
74
|
+
}
|
|
75
|
+
else if (i % NK === 4) {
|
|
76
|
+
// AES-256 特有:半轮 SubWord
|
|
77
|
+
t = ((SBOX[(t >>> 24) & 0xFF]! << 24) | (SBOX[(t >>> 16) & 0xFF]! << 16) | (SBOX[(t >>> 8) & 0xFF]! << 8) | SBOX[t & 0xFF]!) >>> 0;
|
|
78
|
+
}
|
|
79
|
+
w[i] = (w[i - NK]! ^ t) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
return w;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 状态按列优先:state[r + 4c] */
|
|
85
|
+
function addRoundKey(state: Uint8Array, w: Uint32Array, round: number): void {
|
|
86
|
+
for (let c = 0; c < 4; c++) {
|
|
87
|
+
const word = w[round * 4 + c]!;
|
|
88
|
+
state[4 * c]! ^= (word >>> 24) & 0xFF;
|
|
89
|
+
state[4 * c + 1]! ^= (word >>> 16) & 0xFF;
|
|
90
|
+
state[4 * c + 2]! ^= (word >>> 8) & 0xFF;
|
|
91
|
+
state[4 * c + 3]! ^= word & 0xFF;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** InvShiftRows:第 r 行循环右移 r 位 */
|
|
96
|
+
function invShiftRows(s: Uint8Array): void {
|
|
97
|
+
const t = s.slice();
|
|
98
|
+
for (let r = 1; r < 4; r++) {
|
|
99
|
+
for (let c = 0; c < 4; c++)
|
|
100
|
+
s[r + 4 * c] = t[r + 4 * ((c - r + 4) % 4)]!;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function invSubBytes(s: Uint8Array): void {
|
|
105
|
+
for (let i = 0; i < BLOCK; i++)
|
|
106
|
+
s[i] = INV_SBOX[s[i]!]!;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** InvMixColumns:列乘 [0e 0b 0d 09] 循环矩阵 */
|
|
110
|
+
function invMixColumns(s: Uint8Array): void {
|
|
111
|
+
for (let c = 0; c < 4; c++) {
|
|
112
|
+
const [a0, a1, a2, a3] = [s[4 * c]!, s[4 * c + 1]!, s[4 * c + 2]!, s[4 * c + 3]!];
|
|
113
|
+
s[4 * c] = gmul(a0, 0x0E) ^ gmul(a1, 0x0B) ^ gmul(a2, 0x0D) ^ gmul(a3, 0x09);
|
|
114
|
+
s[4 * c + 1] = gmul(a0, 0x09) ^ gmul(a1, 0x0E) ^ gmul(a2, 0x0B) ^ gmul(a3, 0x0D);
|
|
115
|
+
s[4 * c + 2] = gmul(a0, 0x0D) ^ gmul(a1, 0x09) ^ gmul(a2, 0x0E) ^ gmul(a3, 0x0B);
|
|
116
|
+
s[4 * c + 3] = gmul(a0, 0x0B) ^ gmul(a1, 0x0D) ^ gmul(a2, 0x09) ^ gmul(a3, 0x0E);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 单块解密(等价逆密码) */
|
|
121
|
+
function decryptBlock(input: Uint8Array, w: Uint32Array, out: Uint8Array): void {
|
|
122
|
+
const s = input.slice();
|
|
123
|
+
addRoundKey(s, w, NR);
|
|
124
|
+
for (let round = NR - 1; round >= 1; round--) {
|
|
125
|
+
invShiftRows(s);
|
|
126
|
+
invSubBytes(s);
|
|
127
|
+
addRoundKey(s, w, round);
|
|
128
|
+
invMixColumns(s);
|
|
129
|
+
}
|
|
130
|
+
invShiftRows(s);
|
|
131
|
+
invSubBytes(s);
|
|
132
|
+
addRoundKey(s, w, 0);
|
|
133
|
+
out.set(s);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** AES-256-CBC 解密(不做填充剥离) */
|
|
137
|
+
export function aes256CbcDecrypt(cipher: Uint8Array, key: Uint8Array, iv: Uint8Array): Uint8Array {
|
|
138
|
+
if (iv.length !== BLOCK)
|
|
139
|
+
throw new Error(`aes: iv must be 16 bytes, got ${iv.length}`);
|
|
140
|
+
if (cipher.length === 0 || cipher.length % BLOCK !== 0)
|
|
141
|
+
throw new Error(`aes: ciphertext length must be a positive multiple of 16, got ${cipher.length}`);
|
|
142
|
+
const w = expandKey(key);
|
|
143
|
+
const out = new Uint8Array(cipher.length);
|
|
144
|
+
const block = new Uint8Array(BLOCK);
|
|
145
|
+
let prev = iv.slice();
|
|
146
|
+
for (let off = 0; off < cipher.length; off += BLOCK) {
|
|
147
|
+
decryptBlock(cipher.subarray(off, off + BLOCK), w, block);
|
|
148
|
+
for (let i = 0; i < BLOCK; i++) {
|
|
149
|
+
out[off + i] = block[i]! ^ prev[i]!;
|
|
150
|
+
}
|
|
151
|
+
prev = cipher.subarray(off, off + BLOCK).slice();
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** PKCS#7 去填充(1..16 字节,逐字节校验;非法抛错) */
|
|
157
|
+
export function pkcs7Unpad(data: Uint8Array): Uint8Array {
|
|
158
|
+
if (data.length === 0 || data.length % BLOCK !== 0)
|
|
159
|
+
throw new Error("pkcs7: bad length");
|
|
160
|
+
const pad = data[data.length - 1]!;
|
|
161
|
+
if (pad < 1 || pad > BLOCK || pad > data.length)
|
|
162
|
+
throw new Error("pkcs7: bad padding");
|
|
163
|
+
for (let i = data.length - pad; i < data.length; i++) {
|
|
164
|
+
if (data[i] !== pad)
|
|
165
|
+
throw new Error("pkcs7: bad padding");
|
|
166
|
+
}
|
|
167
|
+
return data.subarray(0, data.length - pad);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** SHA-1(FIPS 180-4),返回 20 字节 */
|
|
171
|
+
export function sha1(data: Uint8Array): Uint8Array {
|
|
172
|
+
const len = data.length;
|
|
173
|
+
const withPad = new Uint8Array((((len + 8) >> 6) + 1) * 64);
|
|
174
|
+
withPad.set(data);
|
|
175
|
+
withPad[len] = 0x80;
|
|
176
|
+
const bits = len * 8;
|
|
177
|
+
// 长度域 64bit big-endian:低 32 位足够(>512MB 的输入本就不该进 isolate)
|
|
178
|
+
const dv = new DataView(withPad.buffer);
|
|
179
|
+
dv.setUint32(withPad.length - 8, Math.floor(bits / 0x100000000));
|
|
180
|
+
dv.setUint32(withPad.length - 4, bits >>> 0);
|
|
181
|
+
|
|
182
|
+
let h0 = 0x67452301;
|
|
183
|
+
let h1 = 0xEFCDAB89;
|
|
184
|
+
let h2 = 0x98BADCFE;
|
|
185
|
+
let h3 = 0x10325476;
|
|
186
|
+
let h4 = 0xC3D2E1F0;
|
|
187
|
+
const w = new Uint32Array(80);
|
|
188
|
+
|
|
189
|
+
for (let off = 0; off < withPad.length; off += 64) {
|
|
190
|
+
for (let i = 0; i < 16; i++)
|
|
191
|
+
w[i] = dv.getUint32(off + i * 4);
|
|
192
|
+
for (let i = 16; i < 80; i++) {
|
|
193
|
+
const v = w[i - 3]! ^ w[i - 8]! ^ w[i - 14]! ^ w[i - 16]!;
|
|
194
|
+
w[i] = ((v << 1) | (v >>> 31)) >>> 0;
|
|
195
|
+
}
|
|
196
|
+
let a = h0;
|
|
197
|
+
let b = h1;
|
|
198
|
+
let c = h2;
|
|
199
|
+
let d = h3;
|
|
200
|
+
let e = h4;
|
|
201
|
+
for (let i = 0; i < 80; i++) {
|
|
202
|
+
let f: number;
|
|
203
|
+
let k: number;
|
|
204
|
+
if (i < 20) {
|
|
205
|
+
f = (b & c) | (~b & d);
|
|
206
|
+
k = 0x5A827999;
|
|
207
|
+
}
|
|
208
|
+
else if (i < 40) {
|
|
209
|
+
f = b ^ c ^ d;
|
|
210
|
+
k = 0x6ED9EBA1;
|
|
211
|
+
}
|
|
212
|
+
else if (i < 60) {
|
|
213
|
+
f = (b & c) | (b & d) | (c & d);
|
|
214
|
+
k = 0x8F1BBCDC;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
f = b ^ c ^ d;
|
|
218
|
+
k = 0xCA62C1D6;
|
|
219
|
+
}
|
|
220
|
+
const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[i]!) >>> 0;
|
|
221
|
+
e = d;
|
|
222
|
+
d = c;
|
|
223
|
+
c = ((b << 30) | (b >>> 2)) >>> 0;
|
|
224
|
+
b = a;
|
|
225
|
+
a = temp;
|
|
226
|
+
}
|
|
227
|
+
h0 = (h0 + a) >>> 0;
|
|
228
|
+
h1 = (h1 + b) >>> 0;
|
|
229
|
+
h2 = (h2 + c) >>> 0;
|
|
230
|
+
h3 = (h3 + d) >>> 0;
|
|
231
|
+
h4 = (h4 + e) >>> 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const out = new Uint8Array(20);
|
|
235
|
+
const odv = new DataView(out.buffer);
|
|
236
|
+
odv.setUint32(0, h0);
|
|
237
|
+
odv.setUint32(4, h1);
|
|
238
|
+
odv.setUint32(8, h2);
|
|
239
|
+
odv.setUint32(12, h3);
|
|
240
|
+
odv.setUint32(16, h4);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const encoder = new TextEncoder();
|
|
245
|
+
const decoder = new TextDecoder();
|
|
246
|
+
|
|
247
|
+
export function utf8Bytes(s: string): Uint8Array {
|
|
248
|
+
return encoder.encode(s);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function bytesToUtf8(b: Uint8Array): string {
|
|
252
|
+
return decoder.decode(b);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function bytesToHex(b: Uint8Array): string {
|
|
256
|
+
let out = "";
|
|
257
|
+
for (const x of b)
|
|
258
|
+
out += x.toString(16).padStart(2, "0");
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 标准 base64 解码(oj v0.1.30 起有全局 atob;非法输入由 atob 抛错) */
|
|
263
|
+
export function b64ToBytes(s: string): Uint8Array {
|
|
264
|
+
const bin = atob(s.replace(/\s+/g, ""));
|
|
265
|
+
const out = new Uint8Array(bin.length);
|
|
266
|
+
for (let i = 0; i < bin.length; i++)
|
|
267
|
+
out[i] = bin.charCodeAt(i);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function bytesToB64(b: Uint8Array): string {
|
|
272
|
+
let bin = "";
|
|
273
|
+
for (const x of b)
|
|
274
|
+
bin += String.fromCharCode(x);
|
|
275
|
+
return btoa(bin);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function sha1Hex(data: string | Uint8Array): string {
|
|
279
|
+
return bytesToHex(sha1(typeof data === "string" ? utf8Bytes(data) : data));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 企微签名(明文 `signature` 与密文 `msg_signature` 同算法):
|
|
284
|
+
* 参数按字典序排序后拼接、sha1 取十六进制。
|
|
285
|
+
*/
|
|
286
|
+
export function wecomSignature(...parts: string[]): string {
|
|
287
|
+
return sha1Hex(parts.slice().sort().join(""));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 解出企微消息明文(`<xml>…</xml>` 或裸 `echostr`)。
|
|
292
|
+
*
|
|
293
|
+
* 明文结构(企微 §消息加解密):`random(16B) | msg_len(4B, big-endian) | msg | receiveid`。
|
|
294
|
+
* `receiveid` 对第三方平台/代开发模版是 **suite_id**;不一致即拒(错钥/错套件混用必须 fail-closed,
|
|
295
|
+
* 否则不同套件的回调会互相污染)。43 字符 EncodingAESKey 末尾补 "=" 才是 44 字符 base64(32 字节)。
|
|
296
|
+
*/
|
|
297
|
+
export function unwrapWecomMessage(encryptedB64: string, encodingAesKey: string, expectedReceiver: string): string {
|
|
298
|
+
const key = b64ToBytes(`${encodingAesKey}=`);
|
|
299
|
+
if (key.length !== 32)
|
|
300
|
+
throw new Error(`wecom: EncodingAESKey must decode to 32 bytes, got ${key.length}`);
|
|
301
|
+
const plain = pkcs7Unpad(aes256CbcDecrypt(b64ToBytes(encryptedB64), key, key.subarray(0, BLOCK)));
|
|
302
|
+
if (plain.length < 20)
|
|
303
|
+
throw new Error("wecom: plaintext too short");
|
|
304
|
+
const msgLen = new DataView(plain.buffer, plain.byteOffset, plain.byteLength).getUint32(16);
|
|
305
|
+
if (20 + msgLen > plain.length)
|
|
306
|
+
throw new Error("wecom: msg length out of range");
|
|
307
|
+
const msg = bytesToUtf8(plain.subarray(20, 20 + msgLen));
|
|
308
|
+
const receiver = bytesToUtf8(plain.subarray(20 + msgLen));
|
|
309
|
+
if (receiver !== expectedReceiver)
|
|
310
|
+
throw new Error(`wecom: receiver mismatch (got ${receiver === "" ? "<empty>" : "other"}, expected suite)`);
|
|
311
|
+
return msg;
|
|
312
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { WecomClient } from "./client";
|
|
2
|
+
import type { WecomConfig } from "./config";
|
|
3
|
+
import type { WecomStoreDeps } from "./store";
|
|
4
|
+
// 企微「指令回调」事件处理:suite_ticket / create_auth / change_auth / cancel_auth /
|
|
5
|
+
// reset_permanent_code(代开发重置 secret 走后者)。
|
|
6
|
+
//
|
|
7
|
+
// 为什么这几件事必须由回调驱动(而不是管理端手工录):
|
|
8
|
+
// · suite_ticket 每 10 分钟推一次、30 分钟过期,拿不到就换不出 suite_access_token,
|
|
9
|
+
// 整套「企业授权 → 应用 secret → 企业 token」链路全断;
|
|
10
|
+
// · permanent_code(= 代开发应用的 secret)只在 create_auth / reset_permanent_code 时
|
|
11
|
+
// 由 AuthCode 一次性换取,企微后台**不显示**它;
|
|
12
|
+
// · 企业解绑/取消授权只在 cancel_auth 出现。
|
|
13
|
+
// 因此这里不是「可选的后台功能」,是代开发模式的下限能力。
|
|
14
|
+
//
|
|
15
|
+
// 响应纪律:处理完返回(路由回纯文本 success)。企微 5 秒内拿不到 success 会重推三次;
|
|
16
|
+
// 重推对我们是幂等的(profile upsert / ticket 覆盖),但 AuthCode 一次有效,重推时已换取过 → 502,
|
|
17
|
+
// 属预期(日志留痕即可,不影响已落库的 profile)。
|
|
18
|
+
import { invalidateAllUsers } from "#/_platform/_shared/service/infra/access";
|
|
19
|
+
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
20
|
+
import { corpScope, getSuiteTicket, putSuiteTicket, revokeTenantSessions, saveProfile, setProfileStatus, tenantOfCorp } from "./store";
|
|
21
|
+
import { xmlField } from "./xml";
|
|
22
|
+
|
|
23
|
+
/** suite_ticket 官方有效期 30 分钟(10 分钟推一次);本地按 30 分钟作废,宁可拒一次也不拿过期票打企微 */
|
|
24
|
+
const SUITE_TICKET_TTL_SECS = 30 * 60;
|
|
25
|
+
|
|
26
|
+
export interface SuiteEventDeps {
|
|
27
|
+
cfg: WecomConfig
|
|
28
|
+
client: WecomClient
|
|
29
|
+
store: WecomStoreDeps
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 事件里带的 State(授权链接自定义参数):只认 `env=xxx` 或裸环境名,其余一律按正式环境 */
|
|
33
|
+
export function envOfState(state: string | null): string {
|
|
34
|
+
if (!state)
|
|
35
|
+
return "prod";
|
|
36
|
+
const trimmed = state.trim();
|
|
37
|
+
const kv = /^env=([\w-]+)$/i.exec(trimmed);
|
|
38
|
+
if (kv)
|
|
39
|
+
return kv[1]!.toLowerCase();
|
|
40
|
+
return /^(?:prod|test|staging|dev)$/i.test(trimmed) ? trimmed.toLowerCase() : "prod";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireField(xml: string, name: string, infoType: string): string {
|
|
44
|
+
const value = xmlField(xml, name);
|
|
45
|
+
if (!value)
|
|
46
|
+
throw Object.assign(new Error(`企微事件 ${infoType} 缺少 ${name}`), { status: 400 });
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 该 corp 的所有应用都标记为停用,并吊销其租户下的全部会话 */
|
|
51
|
+
async function disableCorp(corpId: string, deps: SuiteEventDeps): Promise<void> {
|
|
52
|
+
const rows = await db.table("wecom_app_profile").select(["agent_id"]).where(corpScope(corpId)).all() as Array<{ agent_id: string }>;
|
|
53
|
+
for (const r of rows)
|
|
54
|
+
await deps.client.invalidateCorpToken(corpId, String(r.agent_id));
|
|
55
|
+
await setProfileStatus(corpId, "disabled", deps.store);
|
|
56
|
+
// 会话必须与授权同频:解绑后旧 refresh 串不得继续换新 access token。
|
|
57
|
+
// tenant 条件写死在 SQL 里(不用 truncate:它的清理范围取决于本请求是否已绑过租户),
|
|
58
|
+
// 取消授权回调是匿名端点,不该依赖那种隐式前提。
|
|
59
|
+
await revokeTenantSessions(tenantOfCorp(corpId));
|
|
60
|
+
invalidateAllUsers();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** AuthCode → permanent_code → 按 agent 逐个 upsert profile(幂等;同一企业多应用各自一行) */
|
|
64
|
+
async function applyAuthCode(authCode: string, state: string | null, deps: SuiteEventDeps): Promise<{ corpId: string, agentIds: string[] }> {
|
|
65
|
+
const { cfg, client, store } = deps;
|
|
66
|
+
const suiteTicket = await getSuiteTicket(cfg.suiteId, store);
|
|
67
|
+
const suiteToken = await client.suiteAccessToken(cfg.suiteId, cfg.suiteSecret, suiteTicket.ticket);
|
|
68
|
+
const granted = await client.permanentCodeByAuthCode(suiteToken, authCode);
|
|
69
|
+
const env = envOfState(state ?? granted.state);
|
|
70
|
+
for (const agentId of granted.agentIds) {
|
|
71
|
+
await saveProfile({
|
|
72
|
+
suiteId: cfg.suiteId,
|
|
73
|
+
corpId: granted.corpId,
|
|
74
|
+
authCorpId: granted.corpId,
|
|
75
|
+
agentId,
|
|
76
|
+
env,
|
|
77
|
+
corpName: granted.corpName,
|
|
78
|
+
permanentCode: granted.permanentCode,
|
|
79
|
+
}, store);
|
|
80
|
+
// secret 可能已轮换:旧 token 必须立刻作废,否则下一个登录会带着失效 token 打 userinfo
|
|
81
|
+
await client.invalidateCorpToken(granted.corpId, agentId);
|
|
82
|
+
}
|
|
83
|
+
await recordOp({ action: "wecom.auth.apply", module: "auth", target: granted.corpId, detail: { agentIds: granted.agentIds, env } });
|
|
84
|
+
return { corpId: granted.corpId, agentIds: granted.agentIds };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 处理一条已验签并解密的回调明文;返回值仅用于日志(未知 InfoType 也回 success,不触发重推) */
|
|
88
|
+
export async function handleSuiteEvent(plaintext: string, deps: SuiteEventDeps): Promise<string> {
|
|
89
|
+
const infoType = xmlField(plaintext, "InfoType") ?? "";
|
|
90
|
+
const suiteId = xmlField(plaintext, "SuiteId") ?? "";
|
|
91
|
+
if (suiteId && suiteId !== deps.cfg.suiteId)
|
|
92
|
+
throw Object.assign(new Error("企微事件 SuiteId 与本部署配置不一致"), { status: 400 });
|
|
93
|
+
|
|
94
|
+
switch (infoType) {
|
|
95
|
+
case "suite_ticket": {
|
|
96
|
+
const ticket = requireField(plaintext, "SuiteTicket", infoType);
|
|
97
|
+
await putSuiteTicket(deps.cfg.suiteId, ticket, deps.store.now() + SUITE_TICKET_TTL_SECS, deps.store);
|
|
98
|
+
log.info("wecom suite_ticket updated", "suiteId", deps.cfg.suiteId);
|
|
99
|
+
return infoType;
|
|
100
|
+
}
|
|
101
|
+
case "create_auth":
|
|
102
|
+
case "reset_permanent_code": {
|
|
103
|
+
const authCode = requireField(plaintext, "AuthCode", infoType);
|
|
104
|
+
const applied = await applyAuthCode(authCode, xmlField(plaintext, "State"), deps);
|
|
105
|
+
log.info("wecom auth applied", "event", infoType, "corpId", applied.corpId, "agents", applied.agentIds.join(","));
|
|
106
|
+
return infoType;
|
|
107
|
+
}
|
|
108
|
+
case "change_auth": {
|
|
109
|
+
// 可见范围/权限/启停变化:MVP 只做「数据可见性不加码」——不改状态、不动 secret,
|
|
110
|
+
// 靠下次登录重新走 userinfo(企微会拒掉已移出可见范围的成员)。停用/解绑走 cancel_auth。
|
|
111
|
+
const authCorpId = requireField(plaintext, "AuthCorpId", infoType);
|
|
112
|
+
log.info("wecom auth changed", "corpId", authCorpId);
|
|
113
|
+
return infoType;
|
|
114
|
+
}
|
|
115
|
+
case "cancel_auth": {
|
|
116
|
+
const authCorpId = requireField(plaintext, "AuthCorpId", infoType);
|
|
117
|
+
await disableCorp(authCorpId, deps);
|
|
118
|
+
await recordOp({ action: "wecom.auth.cancel", module: "auth", target: authCorpId, level: 4 });
|
|
119
|
+
log.warn("wecom auth cancelled", "corpId", authCorpId);
|
|
120
|
+
return infoType;
|
|
121
|
+
}
|
|
122
|
+
default:
|
|
123
|
+
// 通讯录变更等事件本模块不消费:记账即可,回 success 让企微别再推
|
|
124
|
+
log.info("wecom event ignored", "infoType", infoType);
|
|
125
|
+
return infoType;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// 企微登录领域服务:授权链接构造、state 一次性票据、code→userid、JIT 建档。
|
|
2
|
+
//
|
|
3
|
+
// 两条入口(都落在同一个回调上):
|
|
4
|
+
// · 企微客户端内打开(UA 含 wxwork)→ 网页授权 snsapi_base,静默拿 code;
|
|
5
|
+
// · 普通浏览器 → qrConnect 扫码(appid=corpid + agentid)。
|
|
6
|
+
// 二者换 userid 走同一个 `auth/getuserinfo`(代开发=自建应用口径,不是第三方应用口径)。
|
|
7
|
+
//
|
|
8
|
+
// JIT(无独立注册):外部身份沿用 `wecom:<userid>` 用户名前缀(D6),口令哈希占位 `!wecom`
|
|
9
|
+
// (任何口令都验不过,低信任来源不接管本地号,同 ldap 的 `!ldap`);外层租户由 corp_id 决定。
|
|
10
|
+
import type { WecomCache } from "./client";
|
|
11
|
+
|
|
12
|
+
export const AUTHORIZE_IN_APP = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
|
13
|
+
export const AUTHORIZE_QRCODE = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect";
|
|
14
|
+
|
|
15
|
+
/** 登录票据有效期:302 落地后 SPA 立即兑换,180s 足够;越短越安全 */
|
|
16
|
+
export const LOGIN_TICKET_TTL_SECS = 180;
|
|
17
|
+
|
|
18
|
+
/** state 一次性票据(kv):既不进 URL 也不可伪造——随机 nonce 即凭证,服务端查得到才认 */
|
|
19
|
+
export const LOGIN_STATE_TTL_SECS = 600;
|
|
20
|
+
|
|
21
|
+
export interface PendingLogin {
|
|
22
|
+
corpId: string
|
|
23
|
+
agentId: string
|
|
24
|
+
env: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isWecomUa(ua: string): boolean {
|
|
28
|
+
return /wxwork/i.test(ua);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 授权链接。`redirect_uri` 必须整串 encode(含自己的 query),否则企微会把我们的参数当自己的解析。
|
|
33
|
+
* 扫码用 qrConnect(`appid` + `agentid`);企微内用 oauth2 授权(scope 固定 snsapi_base:
|
|
34
|
+
* 只换 userid,不弹授权页,够 JIT 与身份识别;要姓名头像再加 snsapi_privateinfo,属 Phase 3)。
|
|
35
|
+
*/
|
|
36
|
+
export function buildAuthorizeUrl(opts: {
|
|
37
|
+
corpId: string
|
|
38
|
+
agentId: string
|
|
39
|
+
redirectUri: string
|
|
40
|
+
state: string
|
|
41
|
+
inWecom: boolean
|
|
42
|
+
}): string {
|
|
43
|
+
const redirect = encodeURIComponent(opts.redirectUri);
|
|
44
|
+
const appid = encodeURIComponent(opts.corpId);
|
|
45
|
+
const agentid = encodeURIComponent(opts.agentId);
|
|
46
|
+
if (opts.inWecom) {
|
|
47
|
+
return `${AUTHORIZE_IN_APP}?appid=${appid}&redirect_uri=${redirect}&response_type=code&scope=snsapi_base&state=${encodeURIComponent(opts.state)}&agentid=${agentid}#wechat_redirect`;
|
|
48
|
+
}
|
|
49
|
+
return `${AUTHORIZE_QRCODE}?appid=${appid}&agentid=${agentid}&redirect_uri=${redirect}&state=${encodeURIComponent(opts.state)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function putLoginState(nonce: string, pending: PendingLogin, cache: WecomCache): Promise<void> {
|
|
53
|
+
await cache.set(`wecom:state:${nonce}`, JSON.stringify(pending), LOGIN_STATE_TTL_SECS);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 取并立即删除(一次性):拿不到 = 过期/伪造/已用过 */
|
|
57
|
+
export async function takeLoginState(nonce: string, cache: WecomCache): Promise<PendingLogin> {
|
|
58
|
+
const raw = await cache.get(`wecom:state:${nonce}`);
|
|
59
|
+
if (!raw)
|
|
60
|
+
throw Object.assign(new Error("登录状态已失效,请从企业微信重新进入"), { status: 400 });
|
|
61
|
+
await cache.del(`wecom:state:${nonce}`);
|
|
62
|
+
const parsed = JSON.parse(raw) as Partial<PendingLogin>;
|
|
63
|
+
if (!parsed.corpId || !parsed.agentId)
|
|
64
|
+
throw Object.assign(new Error("登录状态已失效,请从企业微信重新进入"), { status: 400 });
|
|
65
|
+
return { corpId: parsed.corpId, agentId: parsed.agentId, env: parsed.env ?? "prod" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 外部号用户名命名空间(D6):与 ldap:/sso: 同约定,禁止本地建号占用 */
|
|
69
|
+
export function externalUsername(userid: string): string {
|
|
70
|
+
return `wecom:${userid}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 占位口令哈希:bcrypt.verify 对任何口令返回 false(与 `!ldap` 同义) */
|
|
74
|
+
export const WECOM_PASSWORD_PLACEHOLDER = "!wecom";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* JIT find-or-create(租户由调用方先 ensureTenant 绑好,insert 的 tenant_id 由 sql_guard 自动填)。
|
|
78
|
+
* 返回 uid 与禁用位:禁号仍要拦(企微侧未移除,本地已停用时拒绝签发)。
|
|
79
|
+
*
|
|
80
|
+
* `resolveName` 只在**首次建档**时调用一次(老用户不为此多打一次上游):拿到名字就写进
|
|
81
|
+
* `display_name`,拿不到(无通讯录权限/上游抖)用工号兜底——用户列表里宁可显示工号,也不要空白行。
|
|
82
|
+
*/
|
|
83
|
+
export async function ensureWecomUser(
|
|
84
|
+
corpId: string,
|
|
85
|
+
userid: string,
|
|
86
|
+
resolveName?: () => Promise<string | null>,
|
|
87
|
+
): Promise<{ uid: number, forbidden: boolean }> {
|
|
88
|
+
const username = externalUsername(userid);
|
|
89
|
+
let rows = await db.table("users").select(["id", "is_forbidden"]).where({ field: "username", op: "eq", value: username }).all() as Array<{ id: number, is_forbidden: number }>;
|
|
90
|
+
if (!rows.length) {
|
|
91
|
+
const name = resolveName ? await resolveName().catch(() => null) : null;
|
|
92
|
+
try {
|
|
93
|
+
await db.table("users").insert({
|
|
94
|
+
username,
|
|
95
|
+
display_name: name?.trim() || userid,
|
|
96
|
+
password_hash: WECOM_PASSWORD_PLACEHOLDER,
|
|
97
|
+
avatar_base64: "",
|
|
98
|
+
source: "wecom",
|
|
99
|
+
corp_id: corpId,
|
|
100
|
+
must_change: 0,
|
|
101
|
+
is_forbidden: 0,
|
|
102
|
+
}).run();
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
// 唯一约束兜底(预查与插入间的竞态,同 createUser / ldap 模式):撞 (tenant_id, username) → 回查复用
|
|
106
|
+
if (!/unique|constraint/i.test(e instanceof Error ? e.message : String(e)))
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
rows = await db.table("users").select(["id", "is_forbidden"]).where({ field: "username", op: "eq", value: username }).all() as Array<{ id: number, is_forbidden: number }>;
|
|
110
|
+
}
|
|
111
|
+
if (!rows.length)
|
|
112
|
+
throw Object.assign(new Error("账号建档失败"), { status: 500 });
|
|
113
|
+
return { uid: Number(rows[0]!.id), forbidden: Number(rows[0]!.is_forbidden) === 1 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 换会话前的最后一道闸:建号可能已过几分钟(票据 TTL 180s),期间管理员可能停用了这个号。
|
|
118
|
+
* 「企微侧还在、本地已停用」必须以本地为准(停用是安全动作,不能被 IdP 覆盖)。
|
|
119
|
+
*/
|
|
120
|
+
export async function assertUserActive(uid: number): Promise<void> {
|
|
121
|
+
const rows = await db.table("users").select(["is_forbidden"]).where({ field: "id", op: "eq", value: uid }).all() as Array<{ is_forbidden: number }>;
|
|
122
|
+
if (!rows[0])
|
|
123
|
+
throw Object.assign(new Error("账号不存在"), { status: 401 });
|
|
124
|
+
if (Number(rows[0].is_forbidden) === 1)
|
|
125
|
+
throw Object.assign(new Error("账号已被停用,请联系管理员"), { status: 403 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 302 落地地址:票据只能走 query(浏览器历史/Referer 可见,故只是**一次性票**,不是 token)。
|
|
130
|
+
* `tenant` 一并带上:前端 tenantStore 启动时读它 → 后续每个请求的 `X-TENANT-ID` 才对得上,
|
|
131
|
+
* 否则换票请求会落到默认租户、被 `ensureTenant` 判成 403。
|
|
132
|
+
* 路径用 SPA 默认的 browser 路由 `/login`;hash 路由部署由前端 Phase 2 的落地页自行改写。
|
|
133
|
+
*/
|
|
134
|
+
export function loginLandingUrl(opts: { origin: string, ticket: string, tenant: string }): string {
|
|
135
|
+
const q = new URLSearchParams({ wecom_ticket: opts.ticket, tenant: opts.tenant });
|
|
136
|
+
return `${opts.origin}/login?${q.toString()}`;
|
|
137
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 企微指令回调的**重放防护**(2026-10-30 补缺口)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么必须有第二道:企微的验签只证明「这个报文是拿着 Token 的一方签的」,
|
|
4
|
+
// 而 (timestamp, nonce, Encrypt, msg_signature) 这个组合在 Token 不变的前提下
|
|
5
|
+
// **永久有效**——服务端不校验 timestamp 的新旧,抓到过一次合法回调的人可以在任意
|
|
6
|
+
// 时刻原样重放。最坏的一条是 cancel_auth:重放即触发 disableCorp(整家企业停用 +
|
|
7
|
+
// 吊销该企业全部会话),而企微只在「拿不到 success」时才重推,所以它连报警窗口都没有。
|
|
8
|
+
// 官方 SDK 的解密样例里同样有这一步(此处原本漏掉)。
|
|
9
|
+
//
|
|
10
|
+
// 两道闸:
|
|
11
|
+
// 1. timestamp 新鲜度:与本机时间偏差超过 ±5 分钟即拒(企微→本站是直连 HTTPS,
|
|
12
|
+
// 正常网络与重试延迟在秒级;窗口取 5 分钟只为容忍双方时钟不同步)。
|
|
13
|
+
// 2. nonce 幂等:kv.incr 原子计数,首次才写入 TTL。计数而不是读改写,是为了
|
|
14
|
+
// 「两个并发同 nonce」也只有一个拿到 1(oj 的 kv 在配了 Redis 时是多实例共享的)。
|
|
15
|
+
//
|
|
16
|
+
// 两条都回调 400:它不是「本站要鉴权」的问题,401 会把排查带偏。
|
|
17
|
+
const REPLAY_WINDOW_SECS = 5 * 60;
|
|
18
|
+
/** 计数键存活时间:只要盖住重放窗口即可,避免 kv 无界增长 */
|
|
19
|
+
const NONCE_TTL_SECS = 2 * REPLAY_WINDOW_SECS;
|
|
20
|
+
|
|
21
|
+
export interface ReplayGate {
|
|
22
|
+
/** 原子自增并返回新值(kv.incr 同语义) */
|
|
23
|
+
incr: (key: string) => Promise<number>
|
|
24
|
+
expire: (key: string, ttlSecs: number) => Promise<unknown>
|
|
25
|
+
del: (key: string) => Promise<unknown>
|
|
26
|
+
now: () => number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fail(status: number, message: string): never {
|
|
30
|
+
throw Object.assign(new Error(message), { status });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 运行时形态:计数走 kv(Redis 时跨实例),时钟取本机秒级时间戳 */
|
|
34
|
+
export function runtimeReplayGate(): ReplayGate {
|
|
35
|
+
return {
|
|
36
|
+
incr: key => kv.incr(key),
|
|
37
|
+
expire: (key, ttlSecs) => kv.expire(key, ttlSecs),
|
|
38
|
+
del: key => kv.del(key),
|
|
39
|
+
now: () => Math.floor(Date.now() / 1000),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 校验一次回调的时效与唯一性。通过 => void;不通过 => 抛 400。
|
|
45
|
+
* 调用时机:**签名校验之后、解密之前**(先验签避免给未鉴权的请求写 kv,再拦重放省一次解密)。
|
|
46
|
+
*/
|
|
47
|
+
export async function guardReplay(timestampRaw: string, nonce: string, gate: ReplayGate): Promise<void> {
|
|
48
|
+
const ts = Number(timestampRaw);
|
|
49
|
+
if (!Number.isInteger(ts) || ts <= 0)
|
|
50
|
+
fail(400, "回调 timestamp 非法");
|
|
51
|
+
if (Math.abs(gate.now() - ts) > REPLAY_WINDOW_SECS)
|
|
52
|
+
fail(400, "回调 timestamp 超出允许窗口:请检查服务器时间,或该请求为历史回调的重放");
|
|
53
|
+
if (!nonce)
|
|
54
|
+
fail(400, "回调 nonce 缺失");
|
|
55
|
+
|
|
56
|
+
const key = `wecom:nonce:${nonce}`;
|
|
57
|
+
const seen = Number(await gate.incr(key));
|
|
58
|
+
if (seen !== 1)
|
|
59
|
+
fail(400, "回调 nonce 重复:同一条指令回调已被处理过(重放请求已拒绝)");
|
|
60
|
+
// 首次访问才有资格设 TTL:设不上就删键——留一个没有过期的计数键既让它永久增长,
|
|
61
|
+
// 也会把未来的同一 nonce(虽概率极低)误判成重放。
|
|
62
|
+
if (!(await gate.expire(key, NONCE_TTL_SECS))) {
|
|
63
|
+
log.warn("wecom replay nonce ttl not set", "key", key);
|
|
64
|
+
await gate.del(key);
|
|
65
|
+
}
|
|
66
|
+
}
|