@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,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
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// 企微三张表 + 租户上下文的持久化面(全部走构造器,交给 sql_guard 管租户)。
|
|
2
|
+
//
|
|
3
|
+
// 表定性(与 schema.yaml / config.shared_allow 三处一致):
|
|
4
|
+
// wecom_app_profile / wecom_suite_ticket / wecom_login_ticket 均 `tenant: false`(D9)——
|
|
5
|
+
// 它们本身就是「corp → 租户」的映射权威与套件级凭证,查它们的时候必然还没有租户上下文。
|
|
6
|
+
// 共享 = 不受守卫约束,故只放映射/凭证/一次性票据,不放任何租户业务数据。
|
|
7
|
+
//
|
|
8
|
+
// permanent_code(= 代开发应用的 secret,全 corp 数据权限)只以 AES-GCM 密文落库(§7.5),
|
|
9
|
+
// 加解密经 deps 注入:Node 侧测试用假实现,运行时走 crypto.aesGcmEncrypt/Decrypt。
|
|
10
|
+
import type { WecomConfig } from "./config";
|
|
11
|
+
|
|
12
|
+
export interface WecomStoreDeps {
|
|
13
|
+
encrypt: (plain: string) => string
|
|
14
|
+
decrypt: (blob: string) => string
|
|
15
|
+
now: () => number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AppProfile {
|
|
19
|
+
suiteId: string
|
|
20
|
+
/** 真实企业 ID(get_permanent_code 的 auth_corp_info.corpid):登录 URL 里的「企业代码」即它 */
|
|
21
|
+
corpId: string
|
|
22
|
+
/** 企微事件里的 AuthCorpId(第三方应用体系下可能是 open_corpid,故与 corp_id 分列存) */
|
|
23
|
+
authCorpId: string
|
|
24
|
+
agentId: string
|
|
25
|
+
/** 测试/正式等部署标签(D5:只作标签,不参与主键) */
|
|
26
|
+
env: string
|
|
27
|
+
corpName: string | null
|
|
28
|
+
status: "active" | "disabled"
|
|
29
|
+
permanentCode: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 套件票据:企微每 10 分钟推一次,有效期 30 分钟;过期不得发起任何套件级 API 调用 */
|
|
33
|
+
export interface SuiteTicket {
|
|
34
|
+
ticket: string
|
|
35
|
+
expiresAt: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LoginTicketRow {
|
|
39
|
+
uid: number
|
|
40
|
+
/** 票据所属租户(列名 `owner_tenant`:共享表不声明 tenant_id,见 migration 头注释) */
|
|
41
|
+
ownerTenant: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fail(status: number, message: string): never {
|
|
45
|
+
throw Object.assign(new Error(message), { status });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 把请求绑到指定租户(L2 的第四层):浏览器 302 回调带不了自定义头 → 请求是匿名态,
|
|
50
|
+
* 用 db.asTenant(服务端推导值) 补身份。已带头则只做一致性复核(不符即伪造,403)。
|
|
51
|
+
* 注意 asTenant 是**请求级且只能设一次**(oj 手册),故这里不重复调用。
|
|
52
|
+
*/
|
|
53
|
+
export function ensureTenant(tenantId: string): void {
|
|
54
|
+
const current = http.tenantId;
|
|
55
|
+
if (current == null || current === "") {
|
|
56
|
+
db.asTenant(tenantId);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (current !== tenantId)
|
|
60
|
+
fail(403, "tenant header does not match the authenticated tenant");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 企微回调推导出来的租户键:直接就是企业 ID(D10:企业代码=corp_id,故 URL/头/隔离列三者同值) */
|
|
64
|
+
export function tenantOfCorp(corpId: string): string {
|
|
65
|
+
return corpId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 企业匹配谓词(corp_id 与 auth_corp_id 双列等价命中:代开发拿到的是 corp_id,
|
|
70
|
+
* 第三方/open_corpid 落到 auth_corp_id)。
|
|
71
|
+
* 抽取理由:清单 / 启停 / 停企业三处原本各写一份 `or: [...]`,任一处漏一列的表现都是
|
|
72
|
+
* 「某类企业查不到」,而它只在真实第三方身份下才出现。
|
|
73
|
+
*/
|
|
74
|
+
export function corpScope(corpId: string) {
|
|
75
|
+
return {
|
|
76
|
+
or: [
|
|
77
|
+
{ field: "corp_id", op: "eq", value: corpId },
|
|
78
|
+
{ field: "auth_corp_id", op: "eq", value: corpId },
|
|
79
|
+
],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 企业 + 应用(profile 主键的两段:一次授权多家应用时按 agent 逐个命中) */
|
|
84
|
+
export function corpAgentScope(corpId: string, agentId: string) {
|
|
85
|
+
return {
|
|
86
|
+
and: [
|
|
87
|
+
corpScope(corpId),
|
|
88
|
+
{ field: "agent_id", op: "eq", value: agentId },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 吊销某租户的全部会话(refresh token)。
|
|
95
|
+
*
|
|
96
|
+
* 用带 `tenant_id` 的裸 SQL 而不是 `truncate()`:后者靠「守卫自动注入当前租户」生效,
|
|
97
|
+
* 而 `db.asTenant` 一次请求只能设一次、且要求当时还没租户——本次请求是否处于匿名态
|
|
98
|
+
* 决定了 truncate 的清理范围,边界交给了上游实现。条件写死在 SQL 里,范围自带。
|
|
99
|
+
*/
|
|
100
|
+
export async function revokeTenantSessions(tenantId: string): Promise<number> {
|
|
101
|
+
return Number(await db.exec("DELETE FROM sys_refresh_token WHERE tenant_id = ?", [tenantId]));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isUniqueViolation(e: unknown): boolean {
|
|
105
|
+
return /unique|constraint/i.test(e instanceof Error ? e.message : String(e));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function saveProfile(p: Omit<AppProfile, "status"> & { status?: AppProfile["status"] }, deps: WecomStoreDeps): Promise<void> {
|
|
109
|
+
const now = deps.now();
|
|
110
|
+
const scope = {
|
|
111
|
+
and: [
|
|
112
|
+
{ field: "suite_id", op: "eq", value: p.suiteId },
|
|
113
|
+
corpAgentScope(p.corpId, p.agentId),
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
const rows = await db.table("wecom_app_profile").select(["suite_id"]).where(scope).all();
|
|
117
|
+
const cols = {
|
|
118
|
+
auth_corp_id: p.authCorpId,
|
|
119
|
+
env: p.env,
|
|
120
|
+
corp_name: p.corpName,
|
|
121
|
+
status: p.status ?? "active",
|
|
122
|
+
permanent_code_enc: deps.encrypt(p.permanentCode),
|
|
123
|
+
update_time: now,
|
|
124
|
+
};
|
|
125
|
+
if (rows.length) {
|
|
126
|
+
await db.table("wecom_app_profile").update(cols).where(scope).run();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await db.table("wecom_app_profile").insert({ suite_id: p.suiteId, corp_id: p.corpId, agent_id: p.agentId, installed_at: now, ...cols }).run();
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
// 并发插入者先落(企微 5 秒未拿到 success 就重推,两条 create_auth 撞车是常态):
|
|
134
|
+
// 撞 (suite_id, corp_id, agent_id) 即转为更新,不让它以 500 收场——
|
|
135
|
+
// 否则企微再推第三次,这次授权就有可能在还剩一次重推时被判死。
|
|
136
|
+
if (!isUniqueViolation(e))
|
|
137
|
+
throw e;
|
|
138
|
+
await db.table("wecom_app_profile").update(cols).where(scope).run();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 按企业代码找应用。`agentId` 为空时要求该企业只有一个启用中的应用。
|
|
144
|
+
* 「企业不存在」与「已停用」返回**同一条文案**(§5.3 枚举风险:不让外部探测哪些企业装过)。
|
|
145
|
+
*/
|
|
146
|
+
export async function findProfile(corpId: string, agentId: string | null, deps: WecomStoreDeps): Promise<AppProfile> {
|
|
147
|
+
const rows = await db.table("wecom_app_profile").select(["*"]).where(corpScope(corpId)).all() as Array<Record<string, unknown>>;
|
|
148
|
+
const active = rows.filter(r => String(r.status) === "active");
|
|
149
|
+
const picked = agentId ? active.filter(r => String(r.agent_id) === agentId) : active;
|
|
150
|
+
if (picked.length === 0)
|
|
151
|
+
fail(400, "企业代码无效或应用未开通");
|
|
152
|
+
if (picked.length > 1)
|
|
153
|
+
fail(400, "该企业开通了多个应用,登录链接必须带 agent 参数");
|
|
154
|
+
const r = picked[0]!;
|
|
155
|
+
return {
|
|
156
|
+
suiteId: String(r.suite_id),
|
|
157
|
+
corpId: String(r.corp_id),
|
|
158
|
+
authCorpId: r.auth_corp_id == null ? String(r.corp_id) : String(r.auth_corp_id),
|
|
159
|
+
agentId: String(r.agent_id),
|
|
160
|
+
env: r.env == null ? "prod" : String(r.env),
|
|
161
|
+
corpName: r.corp_name == null ? null : String(r.corp_name),
|
|
162
|
+
status: String(r.status) === "disabled" ? "disabled" : "active",
|
|
163
|
+
permanentCode: deps.decrypt(String(r.permanent_code_enc)),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 停用/解绑:只改状态,不删行(取消授权也要留痕,§7.5 审计) */
|
|
168
|
+
export async function setProfileStatus(corpId: string, status: AppProfile["status"], deps: WecomStoreDeps): Promise<number> {
|
|
169
|
+
return Number(await db.table("wecom_app_profile").update({ status, update_time: deps.now() }).where(corpScope(corpId)).run());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function putSuiteTicket(suiteId: string, ticket: string, expiresAt: number, deps: WecomStoreDeps): Promise<void> {
|
|
173
|
+
const rows = await db.table("wecom_suite_ticket").select(["suite_id"]).where({ field: "suite_id", op: "eq", value: suiteId }).all();
|
|
174
|
+
const cols = { suite_ticket: ticket, expires_at: expiresAt, update_time: deps.now() };
|
|
175
|
+
if (rows.length)
|
|
176
|
+
await db.table("wecom_suite_ticket").update(cols).where({ field: "suite_id", op: "eq", value: suiteId }).run();
|
|
177
|
+
else
|
|
178
|
+
await db.table("wecom_suite_ticket").insert({ suite_id: suiteId, ...cols }).run();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 套件票据(过期作废:过期后调 get_suite_token 会被企微拒,故本地先拒,省一次往返与一次失败计数) */
|
|
182
|
+
export async function getSuiteTicket(suiteId: string, deps: WecomStoreDeps): Promise<SuiteTicket> {
|
|
183
|
+
const rows = await db.table("wecom_suite_ticket").select(["suite_ticket", "expires_at"]).where({ field: "suite_id", op: "eq", value: suiteId }).all() as Array<Record<string, unknown>>;
|
|
184
|
+
const row = rows[0];
|
|
185
|
+
if (!row)
|
|
186
|
+
fail(503, "企微套件票据(suite_ticket)尚未收到:回调 URL 未打通或企微尚未推送");
|
|
187
|
+
const expiresAt = Number(row.expires_at);
|
|
188
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= deps.now())
|
|
189
|
+
fail(503, "企微套件票据(suite_ticket)已过期:企微每 10 分钟推送一次,请检查回调 URL");
|
|
190
|
+
return { ticket: String(row.suite_ticket), expiresAt };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 一次性登录票据:只存 SHA-256,明文只出现在 302 的 URL 与 SPA 内存里 */
|
|
194
|
+
export async function issueLoginTicket(ticketHash: string, uid: number, ownerTenant: string, ttlSecs: number, deps: WecomStoreDeps): Promise<void> {
|
|
195
|
+
const now = deps.now();
|
|
196
|
+
// 顺手清过期票(共享表、无租户语义;避免长期累积;失败不影响签发)
|
|
197
|
+
await db.table("wecom_login_ticket").where({ field: "expires_at", op: "lt", value: now }).delete().run();
|
|
198
|
+
await db.table("wecom_login_ticket").insert({ ticket_hash: ticketHash, uid, owner_tenant: ownerTenant, expires_at: now + ttlSecs, used: 0, created_at: now }).run();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 兑换登录票据(原子单次消费):先 `update used=1 where used=0` 抢所有权——
|
|
203
|
+
* 抢不到说明已被用/伪造;抢到再校验过期。并发双花只可能有一个 changes=1。
|
|
204
|
+
*/
|
|
205
|
+
export async function claimLoginTicket(ticketHash: string, deps: WecomStoreDeps): Promise<LoginTicketRow> {
|
|
206
|
+
const changed = Number(await db.table("wecom_login_ticket").update({ used: 1 }).where({
|
|
207
|
+
and: [
|
|
208
|
+
{ field: "ticket_hash", op: "eq", value: ticketHash },
|
|
209
|
+
{ field: "used", op: "eq", value: 0 },
|
|
210
|
+
],
|
|
211
|
+
}).run());
|
|
212
|
+
if (changed !== 1)
|
|
213
|
+
fail(401, "登录票据无效或已使用");
|
|
214
|
+
const rows = await db.table("wecom_login_ticket").select(["uid", "owner_tenant", "expires_at"]).where({ field: "ticket_hash", op: "eq", value: ticketHash }).all() as Array<Record<string, unknown>>;
|
|
215
|
+
const row = rows[0];
|
|
216
|
+
if (!row)
|
|
217
|
+
fail(401, "登录票据无效或已使用");
|
|
218
|
+
if (Number(row.expires_at) <= deps.now())
|
|
219
|
+
fail(401, "登录票据已过期,请重新登录");
|
|
220
|
+
return { uid: Number(row.uid), ownerTenant: String(row.owner_tenant) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 运行时依赖:密钥取自部署常量,加密原语取自 oj 运行时全局 */
|
|
224
|
+
export function runtimeStoreDeps(cfg: WecomConfig): WecomStoreDeps {
|
|
225
|
+
return {
|
|
226
|
+
encrypt: (plain: string) => crypto.aesGcmEncrypt(plain, cfg.encKey),
|
|
227
|
+
decrypt: (blob: string) => crypto.aesGcmDecrypt(blob, cfg.encKey),
|
|
228
|
+
now: () => Math.floor(Date.now() / 1000),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// 企微消息 XML 取字段(只读、白名单式)。
|
|
2
|
+
// 为什么不用通用 XML 解析:明文已由 AES-CBC + msg_signature 双重认证(见 crypt.ts),
|
|
3
|
+
// 这里只从**可信明文**里取几个扁平标签,且标签名由调用方常量给出——不解析结构、不处理命名空间,
|
|
4
|
+
// 用正则换掉一个解析器依赖与它的实体展开攻击面。标签名非纯字母直接抛错(防拼接正则)。
|
|
5
|
+
// 未命中返回 null(「没这个字段」与「字段为空」由调用方区分)。
|
|
6
|
+
|
|
7
|
+
const XML_ENTITIES: Record<string, string> = {
|
|
8
|
+
amp: "&",
|
|
9
|
+
lt: "<",
|
|
10
|
+
gt: ">",
|
|
11
|
+
quot: "\"",
|
|
12
|
+
apos: "'",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function decodeEntities(s: string): string {
|
|
16
|
+
return s.replace(/&(amp|lt|gt|quot|apos|#\d+|#x[\da-f]+);/gi, (whole, body: string) => {
|
|
17
|
+
if (body[0] === "#") {
|
|
18
|
+
const code = body[1] === "x" || body[1] === "X"
|
|
19
|
+
? Number.parseInt(body.slice(2), 16)
|
|
20
|
+
: Number.parseInt(body.slice(1), 10);
|
|
21
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10FFFF ? String.fromCodePoint(code) : whole;
|
|
22
|
+
}
|
|
23
|
+
return XML_ENTITIES[body.toLowerCase()] ?? whole;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 取 `<Name>值</Name>`(支持 CDATA 与实体);未命中 null */
|
|
28
|
+
export function xmlField(xml: string, name: string): string | null {
|
|
29
|
+
if (!/^[A-Z_]\w*$/i.test(name))
|
|
30
|
+
throw new Error(`xml: illegal field name ${name}`);
|
|
31
|
+
const re = new RegExp(`<${name}>(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))</${name}>`);
|
|
32
|
+
const m = re.exec(xml);
|
|
33
|
+
if (!m)
|
|
34
|
+
return null;
|
|
35
|
+
return decodeEntities((m[1] ?? m[2] ?? "").trim());
|
|
36
|
+
}
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
name: auth
|
|
2
2
|
desc: JWT 鉴权端点(业务路由):login/refresh/logout;Bearer 守卫由 oj-auth 提供
|
|
3
3
|
version: 0.1.0
|
|
4
|
+
# 企微代开发 connector 的三张表(schema.yaml 定性 + migrations/0001__wecom* 建表):
|
|
5
|
+
# 共享表,只放 corp→租户映射 / 套件凭证 / 一次性登录票据,不放租户业务数据。
|
|
6
|
+
tables:
|
|
7
|
+
- wecom_app_profile
|
|
8
|
+
- wecom_suite_ticket
|
|
9
|
+
- wecom_login_ticket
|
|
4
10
|
deps: # 两处都用:login/refresh 读 _platform.users(ownership_guard: deny,§3/S003);
|
|
5
11
|
# 跨模块别名 `#/_platform/…` 的依赖门禁(oj build S008)
|
|
6
12
|
_platform: ^0.1.0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- 企微代开发三表 mysql 方言覆盖(方言覆盖必胜,通用兜底;oj-bin docs/migration.md §3.1)。
|
|
2
|
+
-- 差异:TEXT 主键须给长度(按业务长度收窄为 VARCHAR);INTEGER → BIGINT(秒级 epoch);
|
|
3
|
+
-- 复合主键里 VARCHAR 合计长度须可进 InnoDB 索引;索引写在 CREATE TABLE 内(MySQL 无 CREATE INDEX IF NOT EXISTS)。
|
|
4
|
+
CREATE TABLE IF NOT EXISTS wecom_app_profile (
|
|
5
|
+
suite_id VARCHAR(64) NOT NULL,
|
|
6
|
+
corp_id VARCHAR(64) NOT NULL,
|
|
7
|
+
agent_id VARCHAR(32) NOT NULL,
|
|
8
|
+
auth_corp_id VARCHAR(64) NOT NULL,
|
|
9
|
+
env VARCHAR(32) NOT NULL DEFAULT 'prod',
|
|
10
|
+
corp_name VARCHAR(255),
|
|
11
|
+
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
|
12
|
+
permanent_code_enc TEXT NOT NULL,
|
|
13
|
+
installed_at BIGINT NOT NULL,
|
|
14
|
+
update_time BIGINT NOT NULL,
|
|
15
|
+
PRIMARY KEY (suite_id, corp_id, agent_id)
|
|
16
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
17
|
+
|
|
18
|
+
CREATE TABLE IF NOT EXISTS wecom_suite_ticket (
|
|
19
|
+
suite_id VARCHAR(64) NOT NULL,
|
|
20
|
+
suite_ticket TEXT NOT NULL,
|
|
21
|
+
expires_at BIGINT NOT NULL,
|
|
22
|
+
update_time BIGINT NOT NULL,
|
|
23
|
+
PRIMARY KEY (suite_id)
|
|
24
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS wecom_login_ticket (
|
|
27
|
+
ticket_hash CHAR(64) NOT NULL,
|
|
28
|
+
uid BIGINT NOT NULL,
|
|
29
|
+
owner_tenant VARCHAR(128) NOT NULL,
|
|
30
|
+
expires_at BIGINT NOT NULL,
|
|
31
|
+
used INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
created_at BIGINT NOT NULL,
|
|
33
|
+
PRIMARY KEY (ticket_hash),
|
|
34
|
+
KEY idx_wecom_login_ticket_expires (expires_at)
|
|
35
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|