@oj-module/cli 0.2.43 → 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/bootstrap.ts +15 -0
- package/src/market/setup.ts +3 -3
- package/src/market/walkthrough.ts +35 -29
- package/src/site.ts +17 -1
- package/templates/config.yaml +72 -2
- 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,33 @@
|
|
|
1
|
+
-- 企微代开发三表 pg 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
|
|
2
|
+
-- 差异:INTEGER → BIGINT(秒级 epoch);TEXT 在 pg 合法,主键/密文列保留 TEXT。
|
|
3
|
+
CREATE TABLE IF NOT EXISTS wecom_app_profile (
|
|
4
|
+
suite_id TEXT NOT NULL,
|
|
5
|
+
corp_id TEXT NOT NULL,
|
|
6
|
+
agent_id TEXT NOT NULL,
|
|
7
|
+
auth_corp_id TEXT NOT NULL,
|
|
8
|
+
env TEXT NOT NULL DEFAULT 'prod',
|
|
9
|
+
corp_name TEXT,
|
|
10
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
11
|
+
permanent_code_enc TEXT NOT NULL,
|
|
12
|
+
installed_at BIGINT NOT NULL,
|
|
13
|
+
update_time BIGINT NOT NULL,
|
|
14
|
+
PRIMARY KEY (suite_id, corp_id, agent_id)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS wecom_suite_ticket (
|
|
18
|
+
suite_id TEXT NOT NULL PRIMARY KEY,
|
|
19
|
+
suite_ticket TEXT NOT NULL,
|
|
20
|
+
expires_at BIGINT NOT NULL,
|
|
21
|
+
update_time BIGINT NOT NULL
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS wecom_login_ticket (
|
|
25
|
+
ticket_hash TEXT NOT NULL PRIMARY KEY,
|
|
26
|
+
uid BIGINT NOT NULL,
|
|
27
|
+
owner_tenant TEXT NOT NULL,
|
|
28
|
+
expires_at BIGINT NOT NULL,
|
|
29
|
+
used INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
created_at BIGINT NOT NULL
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_wecom_login_ticket_expires ON wecom_login_ticket (expires_at);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
-- 企微代开发 connector 的三张表(阶段 P1)。结构只进 migrations(S006),列集与 schema.yaml 一致。
|
|
2
|
+
--
|
|
3
|
+
-- 定性(三处必须一致:本文件头 / schema.yaml `tenant: false` / config.yaml `tenant.shared_allow`):
|
|
4
|
+
-- 三张表全部**共享表**——它们在「还没有租户上下文」的时刻被读写:
|
|
5
|
+
-- · wecom_app_profile 本身就是 corp → 租户的映射权威(查它的时候必然不知道租户);
|
|
6
|
+
-- · wecom_suite_ticket 是套件级凭证,按 suite_id 全局唯一;
|
|
7
|
+
-- · wecom_login_ticket 跨「匿名 302 回跳」与「已认证换取」两段,票据本身携带租户。
|
|
8
|
+
-- 共享 = 不受 sql_guard 约束,故只放映射/凭证/一次性票据,不放任何租户业务数据。
|
|
9
|
+
--
|
|
10
|
+
-- 凭证保密(§7.5):permanent_code 即代开发应用的 secret(具备全 corp 数据权限),
|
|
11
|
+
-- 只以 AES-GCM 密文落库(crypto.aesGcmEncrypt,密钥取部署常量 wecom_enc_key),列名带 _enc 后缀。
|
|
12
|
+
CREATE TABLE IF NOT EXISTS wecom_app_profile (
|
|
13
|
+
suite_id TEXT NOT NULL,
|
|
14
|
+
corp_id TEXT NOT NULL,
|
|
15
|
+
agent_id TEXT NOT NULL,
|
|
16
|
+
auth_corp_id TEXT NOT NULL,
|
|
17
|
+
env TEXT NOT NULL DEFAULT 'prod',
|
|
18
|
+
corp_name TEXT,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
20
|
+
permanent_code_enc TEXT NOT NULL,
|
|
21
|
+
installed_at INTEGER NOT NULL,
|
|
22
|
+
update_time INTEGER NOT NULL,
|
|
23
|
+
PRIMARY KEY (suite_id, corp_id, agent_id)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS wecom_suite_ticket (
|
|
27
|
+
suite_id TEXT NOT NULL PRIMARY KEY,
|
|
28
|
+
suite_ticket TEXT NOT NULL,
|
|
29
|
+
expires_at INTEGER NOT NULL,
|
|
30
|
+
update_time INTEGER NOT NULL
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- 一次性登录票据:302 的 URL 里只能放票,不能放 access/refresh token(会被浏览器历史、
|
|
34
|
+
-- Referer、反代日志留存)。只存 SHA-256,明文仅存在于 302 Location 与 SPA 内存。
|
|
35
|
+
-- 列名用 owner_tenant 而不是 tenant_id:本表是共享表,出现 tenant_id 列会被
|
|
36
|
+
-- 「tenant:false 却声明隔离列」的定性红线判为自相矛盾,而它的语义是「票据绑定的租户」。
|
|
37
|
+
CREATE TABLE IF NOT EXISTS wecom_login_ticket (
|
|
38
|
+
ticket_hash TEXT NOT NULL PRIMARY KEY,
|
|
39
|
+
uid INTEGER NOT NULL,
|
|
40
|
+
owner_tenant TEXT NOT NULL,
|
|
41
|
+
expires_at INTEGER NOT NULL,
|
|
42
|
+
used INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
created_at INTEGER NOT NULL
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
-- 过期票清理按 expires_at 扫(签发时顺手删);无索引会全表扫
|
|
47
|
+
CREATE INDEX IF NOT EXISTS idx_wecom_login_ticket_expires ON wecom_login_ticket (expires_at);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# 声明式表结构(§4.2 / D1-C):auth 模块自有表——企微代开发 connector 的三张表。
|
|
2
|
+
# 结构真源是 migrations/0001__wecom*.sql(S006);本文件负责「列集/主键/租户定性」的运行时元数据,
|
|
3
|
+
# 与迁移逐列对齐(列集不一致时 oj 的 schema 校验会 fail-fast)。
|
|
4
|
+
#
|
|
5
|
+
# 定性(三处一致:这里 / config.yaml `tenant.shared_allow` / 迁移文件头注释):
|
|
6
|
+
# 三张表都是**共享表**(`tenant: false`)——它们在「还没有租户上下文」的时刻被读写,
|
|
7
|
+
# 而且本身就是 corp → 租户的映射权威。只放映射/凭证/一次性票据,不放租户业务数据。
|
|
8
|
+
tables:
|
|
9
|
+
wecom_app_profile:
|
|
10
|
+
# 共享表:corp → 租户的映射权威,查它时必然还不知道租户(D9)
|
|
11
|
+
tenant: false
|
|
12
|
+
pk: [suite_id, corp_id, agent_id]
|
|
13
|
+
columns:
|
|
14
|
+
suite_id: {type: text, null: false}
|
|
15
|
+
corp_id: {type: text, null: false}
|
|
16
|
+
agent_id: {type: text, null: false}
|
|
17
|
+
# 企微事件里的 AuthCorpId:第三方应用体系下可能是 open_corpid,故与 corp_id 分列存
|
|
18
|
+
auth_corp_id: {type: text, null: false}
|
|
19
|
+
# 测试/正式等部署标签(D5:只作标签,不参与主键)
|
|
20
|
+
env: {type: text, null: false}
|
|
21
|
+
corp_name: {type: text}
|
|
22
|
+
# active / disabled:取消授权只改状态不删行(留痕)
|
|
23
|
+
status: {type: text, null: false}
|
|
24
|
+
# permanent_code(= 代开发应用的 secret)的 AES-GCM 密文,禁止明文落库
|
|
25
|
+
permanent_code_enc: {type: text, null: false}
|
|
26
|
+
installed_at: {type: integer, null: false}
|
|
27
|
+
update_time: {type: integer, null: false}
|
|
28
|
+
wecom_suite_ticket:
|
|
29
|
+
# 共享表:套件级凭证,按 suite_id 全局唯一(每 10 分钟被企微覆盖一次)
|
|
30
|
+
tenant: false
|
|
31
|
+
pk: suite_id
|
|
32
|
+
columns:
|
|
33
|
+
suite_id: {type: text, null: false}
|
|
34
|
+
suite_ticket: {type: text, null: false}
|
|
35
|
+
expires_at: {type: integer, null: false}
|
|
36
|
+
update_time: {type: integer, null: false}
|
|
37
|
+
wecom_login_ticket:
|
|
38
|
+
# 共享表:一次性登录票据,跨「匿名 302 回跳」与「已认证换取」两段
|
|
39
|
+
tenant: false
|
|
40
|
+
pk: ticket_hash
|
|
41
|
+
columns:
|
|
42
|
+
ticket_hash: {type: text, null: false}
|
|
43
|
+
uid: {type: integer, null: false}
|
|
44
|
+
# 票据绑定的租户。不叫 tenant_id:共享表出现隔离列会被定性红线判为自相矛盾
|
|
45
|
+
owner_tenant: {type: text, null: false}
|
|
46
|
+
expires_at: {type: integer, null: false}
|
|
47
|
+
used: {type: integer, null: false}
|
|
48
|
+
created_at: {type: integer, null: false}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// 代开发后台(服务商运营面):GET /auth/wecom/admin/apps · POST /auth/wecom/admin/status · GET /auth/wecom/admin/install
|
|
2
|
+
//
|
|
3
|
+
// 权限:仅超级管理员(`assertSuperAdmin`)。这三件事都是**跨租户**的站点级操作
|
|
4
|
+
// (看所有已授权企业、停用某企业、生成安装链接),不是某个租户的管理员能做的;
|
|
5
|
+
// 用 `assertSystemAdmin` 会把 admin 也放进来,而 admin 属于某个租户,等于让他看别家企业的 corp_id。
|
|
6
|
+
//
|
|
7
|
+
// 三个端点都不挂在 `tenant.anonymous_paths` 里:它们是正常登录后的管理面,必须带租户头,
|
|
8
|
+
// 也就不会走 `db.asTenant`(见 _shared/wecom/admin.ts 头注释第 2 点)。
|
|
9
|
+
import { respond } from "#/_platform/_shared/respond";
|
|
10
|
+
import { assertSuperAdmin } from "#/_platform/_shared/service/infra/access";
|
|
11
|
+
import { recordOp } from "#/_platform/_shared/service/infra/audit";
|
|
12
|
+
import { createInstall, listApps, setAppStatus } from "../../_shared/wecom/admin";
|
|
13
|
+
import { createWecomClient, runtimeCache, runtimeHttp } from "../../_shared/wecom/client";
|
|
14
|
+
import { readWecomConfig } from "../../_shared/wecom/config";
|
|
15
|
+
import { runtimeStoreDeps } from "../../_shared/wecom/store";
|
|
16
|
+
|
|
17
|
+
/** 清单:已授权企业 × 应用(不含 permanent_code,secret 不出库) */
|
|
18
|
+
async function get() {
|
|
19
|
+
await respond(async () => {
|
|
20
|
+
await assertSuperAdmin();
|
|
21
|
+
return listApps();
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
get.response = {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: {
|
|
27
|
+
type: "object",
|
|
28
|
+
required: ["suite_id", "corp_id", "agent_id", "auth_corp_id", "env", "status", "installed_at", "update_time"],
|
|
29
|
+
properties: {
|
|
30
|
+
suite_id: { type: "string" },
|
|
31
|
+
corp_id: { type: "string" },
|
|
32
|
+
agent_id: { type: "string" },
|
|
33
|
+
auth_corp_id: { type: "string" },
|
|
34
|
+
env: { type: "string" },
|
|
35
|
+
corp_name: { type: ["string", "null"] },
|
|
36
|
+
status: { type: "string", enum: ["active", "disabled"] },
|
|
37
|
+
installed_at: { type: "number" },
|
|
38
|
+
update_time: { type: "number" },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const status = {
|
|
44
|
+
// 启停某个企业的一个应用。停用是安全动作:企业整体停用时同步吊销其会话(见 admin.ts)。
|
|
45
|
+
async post() {
|
|
46
|
+
await respond(async () => {
|
|
47
|
+
await assertSuperAdmin();
|
|
48
|
+
const body = (http.body ?? {}) as { corpId?: unknown, agentId?: unknown, status?: unknown };
|
|
49
|
+
const corpId = String(body.corpId ?? "").trim();
|
|
50
|
+
const agentId = String(body.agentId ?? "").trim();
|
|
51
|
+
const next = String(body.status ?? "").trim();
|
|
52
|
+
if (!corpId || !agentId)
|
|
53
|
+
throw Object.assign(new Error("缺少企业代码或应用 id"), { status: 400 });
|
|
54
|
+
if (next !== "active" && next !== "disabled")
|
|
55
|
+
throw Object.assign(new Error("status 只能是 active 或 disabled"), { status: 400 });
|
|
56
|
+
|
|
57
|
+
const result = await setAppStatus(corpId, agentId, next, {
|
|
58
|
+
client: createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
|
|
59
|
+
// 只有 update_time 用到 now,加解密用不上:这里给空实现,避免为一个时间戳去读部署常量
|
|
60
|
+
store: { encrypt: () => "", decrypt: () => "", now: () => Math.floor(Date.now() / 1000) },
|
|
61
|
+
});
|
|
62
|
+
await recordOp({
|
|
63
|
+
action: next === "active" ? "wecom.admin.enable" : "wecom.admin.disable",
|
|
64
|
+
module: "auth",
|
|
65
|
+
target: corpId,
|
|
66
|
+
detail: { agentId, revokedSessions: result.revokedSessions },
|
|
67
|
+
// 停用是断人访问的动作,按安全事件留痕(与 cancel_auth 同级)
|
|
68
|
+
level: next === "active" ? 2 : 4,
|
|
69
|
+
});
|
|
70
|
+
return result;
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
status.post.schema = {
|
|
75
|
+
body: {
|
|
76
|
+
type: "object",
|
|
77
|
+
required: ["corpId", "agentId", "status"],
|
|
78
|
+
properties: {
|
|
79
|
+
corpId: { type: "string", minLength: 1 },
|
|
80
|
+
agentId: { type: "string", minLength: 1 },
|
|
81
|
+
status: { type: "string", enum: ["active", "disabled"] },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
status.post.response = {
|
|
86
|
+
type: "object",
|
|
87
|
+
required: ["updated", "revokedSessions"],
|
|
88
|
+
properties: {
|
|
89
|
+
updated: { type: "number" },
|
|
90
|
+
revokedSessions: { type: "number" },
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const install = {
|
|
95
|
+
// 开通入口:生成 install URL(预授权码 10 分钟有效,故不缓存,每次点都重新换)。
|
|
96
|
+
// 顺带回「指令回调 URL」——服务商后台要把它逐字填进企微「代开发」配置页。
|
|
97
|
+
async get() {
|
|
98
|
+
await respond(async () => {
|
|
99
|
+
await assertSuperAdmin();
|
|
100
|
+
const cfg = readWecomConfig();
|
|
101
|
+
const state = String((http.query as Record<string, unknown>)?.state ?? "").trim();
|
|
102
|
+
return createInstall(
|
|
103
|
+
cfg,
|
|
104
|
+
createWecomClient({ http: runtimeHttp(), cache: runtimeCache() }),
|
|
105
|
+
runtimeStoreDeps(cfg),
|
|
106
|
+
state,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
install.get.response = {
|
|
112
|
+
type: "object",
|
|
113
|
+
required: ["installUrl", "callbackUrl", "expiresIn"],
|
|
114
|
+
properties: {
|
|
115
|
+
installUrl: { type: "string" },
|
|
116
|
+
callbackUrl: { type: "string" },
|
|
117
|
+
expiresIn: { type: "number" },
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export default { apps: { get }, status, install };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { WecomConfig } from "../_shared/wecom/config";
|
|
2
|
+
import type { WecomStoreDeps } from "../_shared/wecom/store";
|
|
3
|
+
// GET /api/auth/wecom/login?corp=&agent=&env= —— 发起企微授权(企微内静默 / 浏览器扫码)
|
|
4
|
+
// GET /api/auth/wecom/callback?code=&state= —— 企微回跳:code→userid→JIT→一次性票据→302 SPA
|
|
5
|
+
// POST /api/auth/wecom/ticket {ticket} —— 一次性票据换 TokenBundle(token 不进 URL)
|
|
6
|
+
// GET /api/auth/wecom/event —— 企微回调 URL 验证(echostr 解出后原样回)
|
|
7
|
+
// POST /api/auth/wecom/event —— 企微指令回调(suite_ticket / 授权 / 解绑)
|
|
8
|
+
//
|
|
9
|
+
// 三处纪律:
|
|
10
|
+
// 1. 两个 302 端点**不能**用 respond(它末尾无脑 json.ok,会把 302 覆盖成 200 信封)——
|
|
11
|
+
// 异常统一交 failWith(respond.ts 同一个信封收口)。
|
|
12
|
+
// 2. 企微事件端点必须回**纯文本** `success` / 解出的 `echostr`(信封会被企微判成失败并重推三次),
|
|
13
|
+
// 故走 json.stream 的 text/plain 通道(json.raw 是 JSON 通道,会把 echostr 加引号)。
|
|
14
|
+
// 3. 这四个端点都在 `auth.anonymous_paths`(无 Bearer)与 `tenant.anonymous_paths`(浏览器带不了
|
|
15
|
+
// `X-TENANT-ID`)里:租户只能由服务端从 profile / 票据推导(db.asTenant,见 store.ts::ensureTenant)。
|
|
16
|
+
import { failWith, respond } from "#/_platform/_shared/respond";
|
|
17
|
+
import { clientIp, guardLoginThrottle, noteLoginFailed, noteLoginSucceeded } from "../_shared/login-throttle";
|
|
18
|
+
import { createSession } from "../_shared/session";
|
|
19
|
+
import { createWecomClient, runtimeCache, runtimeHttp } from "../_shared/wecom/client";
|
|
20
|
+
import { loginRedirectOf, readWecomConfig, siteRootOf } from "../_shared/wecom/config";
|
|
21
|
+
import { unwrapWecomMessage, wecomSignature } from "../_shared/wecom/crypt";
|
|
22
|
+
import { handleSuiteEvent } from "../_shared/wecom/events";
|
|
23
|
+
import {
|
|
24
|
+
assertUserActive,
|
|
25
|
+
buildAuthorizeUrl,
|
|
26
|
+
ensureWecomUser,
|
|
27
|
+
LOGIN_TICKET_TTL_SECS,
|
|
28
|
+
loginLandingUrl,
|
|
29
|
+
putLoginState,
|
|
30
|
+
takeLoginState,
|
|
31
|
+
} from "../_shared/wecom/login";
|
|
32
|
+
import { guardReplay, runtimeReplayGate } from "../_shared/wecom/replay";
|
|
33
|
+
import { claimLoginTicket, ensureTenant, findProfile, issueLoginTicket, runtimeStoreDeps, tenantOfCorp } from "../_shared/wecom/store";
|
|
34
|
+
import { xmlField } from "../_shared/wecom/xml";
|
|
35
|
+
|
|
36
|
+
// 模块级禁止 readWecomConfig()/createSession()/runtimeHttp():oj build 的 introspect 在空沙箱里
|
|
37
|
+
// 评估模块(无 vars/jwt/fetch),一律在 handler 内按需构造(§9.4)。
|
|
38
|
+
|
|
39
|
+
/** 请求头的读取按大小写不敏感处理(http.headers 的键大小写随客户端) */
|
|
40
|
+
function header(name: string): string {
|
|
41
|
+
const all = (http.headers ?? {}) as Record<string, string>;
|
|
42
|
+
const key = Object.keys(all).find(k => k.toLowerCase() === name);
|
|
43
|
+
return key ? String(all[key] ?? "") : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function query(name: string): string {
|
|
47
|
+
return String(((http.query ?? {}) as Record<string, unknown>)[name] ?? "").trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 纯文本 200(企微事件回执专用;信封会被判失败并触发重推) */
|
|
51
|
+
function plainText(body: string): void {
|
|
52
|
+
const stream = json.stream({ contentType: "text/plain; charset=utf-8" });
|
|
53
|
+
stream.write(body);
|
|
54
|
+
stream.end();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function badRequest(message: string): Error {
|
|
58
|
+
return Object.assign(new Error(message), { status: 400 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 校验企微回调签名并解出明文。签名不合法 → 400(不是 401:调用方是企微服务器,
|
|
63
|
+
* 401 会被当成「本站需要鉴权」误导排查);信封里的原文不回显,避免给探测者提供比对材料。
|
|
64
|
+
*
|
|
65
|
+
* 三道顺序不可换:先验签(不给未鉴权请求写 kv)→ 再拦重放(省一次解密,也让
|
|
66
|
+
* cancel_auth 这种危险性最高的报文止步在落库之前)→ 最后解密。
|
|
67
|
+
*/
|
|
68
|
+
async function decryptVerified(encrypted: string): Promise<{ cfg: WecomConfig, plain: string }> {
|
|
69
|
+
const cfg = readWecomConfig();
|
|
70
|
+
const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
|
|
71
|
+
if (expected !== query("msg_signature"))
|
|
72
|
+
throw badRequest("回调签名校验失败");
|
|
73
|
+
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
74
|
+
try {
|
|
75
|
+
return { cfg, plain: unwrapWecomMessage(encrypted, cfg.encodingAesKey, cfg.suiteId) };
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
// 解密失败只透出「失败」:错钥/错套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
79
|
+
log.warn("wecom callback decrypt failed", "reason", (e as Error).message);
|
|
80
|
+
throw badRequest("回调消息解密失败");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const login = {
|
|
85
|
+
// 企微内打开(UA 含 wxwork)→ oauth2 静默授权;普通浏览器 → qrConnect 扫码。
|
|
86
|
+
// corp 必填(D10:企业代码 = corp_id,服务商后台可生成带码的专属登录链接)。
|
|
87
|
+
async get() {
|
|
88
|
+
try {
|
|
89
|
+
const cfg = readWecomConfig();
|
|
90
|
+
const corp = query("corp");
|
|
91
|
+
const agent = query("agent");
|
|
92
|
+
if (!corp)
|
|
93
|
+
throw badRequest("缺少企业代码(corp):请使用企业管理员收到的登录链接,或在登录页填写企业代码");
|
|
94
|
+
|
|
95
|
+
// 企业代码解析是**未登录可调的枚举面**(§5.3):复用登录节流,维度 (IP, 企业代码)
|
|
96
|
+
const ip = await clientIp();
|
|
97
|
+
await guardLoginThrottle(ip, corp);
|
|
98
|
+
|
|
99
|
+
const deps = runtimeStoreDeps(cfg);
|
|
100
|
+
const profile = await findProfile(corp, agent || null, deps).catch(async (e: Error & { status?: number }) => {
|
|
101
|
+
// 只有「企业代码无效/多应用未指定」计失败;配置类错误(缺 vars)不该污染节流计数
|
|
102
|
+
if (e.status === 400)
|
|
103
|
+
await noteLoginFailed(ip, corp);
|
|
104
|
+
throw e;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const state = crypto.randomHex(16);
|
|
108
|
+
await putLoginState(state, { corpId: profile.corpId, agentId: profile.agentId, env: profile.env }, runtimeCache());
|
|
109
|
+
const url = buildAuthorizeUrl({
|
|
110
|
+
corpId: profile.corpId,
|
|
111
|
+
agentId: profile.agentId,
|
|
112
|
+
redirectUri: loginRedirectOf(cfg),
|
|
113
|
+
state,
|
|
114
|
+
inWecom: /wxwork/i.test(header("user-agent")),
|
|
115
|
+
});
|
|
116
|
+
json.redirect.found(url);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
failWith(e); // 302 不在错误通道里:异常同样走统一信封,不能让浏览器看到裸 500
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const callback = {
|
|
125
|
+
// 企微回跳:state 一次性(kv 取即删)→ profile → 绑租户 → code 换 userid → JIT 建档 → 发票 → 302。
|
|
126
|
+
async get() {
|
|
127
|
+
try {
|
|
128
|
+
const cfg = readWecomConfig();
|
|
129
|
+
const code = query("code");
|
|
130
|
+
const state = query("state");
|
|
131
|
+
// 用户在企业微信里点了「拒绝」时企微不回 code,只回 state
|
|
132
|
+
if (!code)
|
|
133
|
+
throw badRequest("授权未完成:企业微信未返回 code(可能已在授权页取消)");
|
|
134
|
+
if (!state)
|
|
135
|
+
throw badRequest("登录状态缺失:请从企业微信重新进入");
|
|
136
|
+
|
|
137
|
+
const deps = runtimeStoreDeps(cfg);
|
|
138
|
+
const pending = await takeLoginState(state, runtimeCache());
|
|
139
|
+
const profile = await findProfile(pending.corpId, pending.agentId, deps);
|
|
140
|
+
// 顺序刚性:先绑租户再碰 users(JIT 建号是租户表写入,守卫 deny 下缺上下文即拒)
|
|
141
|
+
ensureTenant(tenantOfCorp(profile.corpId));
|
|
142
|
+
|
|
143
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
144
|
+
const userid = await client.userIdByCode(profile.corpId, profile.agentId, profile.permanentCode, code);
|
|
145
|
+
// 名字是惰性取的:只有首次建档才多打一次 user/get(老用户零开销),拿不到就用工号兜底
|
|
146
|
+
const { uid, forbidden } = await ensureWecomUser(profile.corpId, userid, () =>
|
|
147
|
+
client.userNameById(profile.corpId, profile.agentId, profile.permanentCode, userid));
|
|
148
|
+
if (forbidden)
|
|
149
|
+
throw Object.assign(new Error("账号已被停用,请联系管理员"), { status: 403 });
|
|
150
|
+
|
|
151
|
+
// 票据而不是 token 进 URL:token 会留在浏览器历史/Referer/反代日志里
|
|
152
|
+
const ticket = crypto.randomHex(32);
|
|
153
|
+
const tenant = tenantOfCorp(profile.corpId);
|
|
154
|
+
await issueLoginTicket(crypto.sha256Hex(ticket), uid, tenant, LOGIN_TICKET_TTL_SECS, deps);
|
|
155
|
+
await noteLoginSucceeded(await clientIp(), profile.corpId);
|
|
156
|
+
log.info("wecom login ok", "tenant", tenant, "agentId", profile.agentId);
|
|
157
|
+
json.redirect.found(loginLandingUrl({ origin: siteRootOf(cfg), ticket, tenant }));
|
|
158
|
+
}
|
|
159
|
+
catch (e) {
|
|
160
|
+
failWith(e);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const ticket = {
|
|
166
|
+
// 一次性票据换会话。票据即凭证:先抢所有权(原子 used 0→1)再校验过期,重放与并发双花都只会成功一次。
|
|
167
|
+
async post() {
|
|
168
|
+
await respond(async () => {
|
|
169
|
+
const raw = String((http.body as { ticket?: string } | null)?.ticket ?? "");
|
|
170
|
+
if (!raw)
|
|
171
|
+
throw badRequest("缺少 ticket");
|
|
172
|
+
// 换票只碰 DB 与会话:**不读部署凭据**——加解密在这条路径上用不到,而套件凭据没配好
|
|
173
|
+
// 时把「已签发的票」判成登录失败,等于把配置事故伪装成票据失效(admin 的 status 同处理)。
|
|
174
|
+
const storeDeps: WecomStoreDeps = { encrypt: () => "", decrypt: () => "", now: () => Math.floor(Date.now() / 1000) };
|
|
175
|
+
const claimed = await claimLoginTicket(crypto.sha256Hex(raw), storeDeps);
|
|
176
|
+
// 票据里的租户是权威:前端落地页必须先 bootstrapTenant(`?tenant=` 由 callback 的 302 带上),
|
|
177
|
+
// 否则这里拿到的是默认租户头,ensureTenant 会以 403 明确拒绝(而不是悄悄落到 default)
|
|
178
|
+
ensureTenant(claimed.ownerTenant);
|
|
179
|
+
await assertUserActive(claimed.uid);
|
|
180
|
+
return createSession().issueExternal(claimed.uid);
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
ticket.post.schema = {
|
|
185
|
+
body: {
|
|
186
|
+
type: "object",
|
|
187
|
+
required: ["ticket"],
|
|
188
|
+
properties: {
|
|
189
|
+
ticket: { type: "string", minLength: 32, maxLength: 128 },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const event = {
|
|
195
|
+
// 企微后台「保存回调 URL」时会 GET 一次:解出 echostr 后**原样明文回**(不套信封、不加引号)
|
|
196
|
+
async get() {
|
|
197
|
+
try {
|
|
198
|
+
const { plain } = await decryptVerified(query("echostr"));
|
|
199
|
+
plainText(plain);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
failWith(e);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// 指令回调:验签 → 解密 → 分发(suite_ticket / create_auth / change_auth / cancel_auth /
|
|
207
|
+
// reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
208
|
+
async post() {
|
|
209
|
+
try {
|
|
210
|
+
const xml = typeof http.body === "string" ? http.body : "";
|
|
211
|
+
const encrypted = xmlField(xml, "Encrypt");
|
|
212
|
+
if (!encrypted)
|
|
213
|
+
throw badRequest("回调报文缺少 Encrypt");
|
|
214
|
+
const { cfg, plain } = await decryptVerified(encrypted);
|
|
215
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
216
|
+
await handleSuiteEvent(plain, { cfg, client, store: runtimeStoreDeps(cfg) });
|
|
217
|
+
plainText("success");
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
// 失败必须让企微看到非 success:它重推三次,正好覆盖「suite_ticket 未到/上游抖」这类瞬态
|
|
221
|
+
failWith(e);
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export default { login, callback, ticket, event };
|
|
@@ -8,6 +8,42 @@ export const schemas = {
|
|
|
8
8
|
body: z.object({
|
|
9
9
|
"old_password": z.string().min(1),
|
|
10
10
|
"new_password": z.string().min(6)
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
"authWecomAdminAppsGet": {
|
|
14
|
+
data: z.array(z.object({
|
|
15
|
+
"suite_id": z.string(),
|
|
16
|
+
"corp_id": z.string(),
|
|
17
|
+
"agent_id": z.string(),
|
|
18
|
+
"auth_corp_id": z.string(),
|
|
19
|
+
"env": z.string(),
|
|
20
|
+
"corp_name": z.string().nullable().optional(),
|
|
21
|
+
"status": z.enum(["active", "disabled"]),
|
|
22
|
+
"installed_at": z.number(),
|
|
23
|
+
"update_time": z.number()
|
|
24
|
+
})),
|
|
25
|
+
},
|
|
26
|
+
"authWecomAdminStatusPost": {
|
|
27
|
+
body: z.object({
|
|
28
|
+
"corpId": z.string().min(1),
|
|
29
|
+
"agentId": z.string().min(1),
|
|
30
|
+
"status": z.enum(["active", "disabled"])
|
|
31
|
+
}),
|
|
32
|
+
data: z.object({
|
|
33
|
+
"updated": z.number(),
|
|
34
|
+
"revokedSessions": z.number()
|
|
35
|
+
}),
|
|
36
|
+
},
|
|
37
|
+
"authWecomAdminInstallGet": {
|
|
38
|
+
data: z.object({
|
|
39
|
+
"installUrl": z.string(),
|
|
40
|
+
"callbackUrl": z.string(),
|
|
41
|
+
"expiresIn": z.number()
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
"authWecomTicketPost": {
|
|
45
|
+
body: z.object({
|
|
46
|
+
"ticket": z.string().min(32).max(128)
|
|
11
47
|
}),
|
|
12
48
|
},
|
|
13
49
|
};
|