@oj-module/cli 0.2.43 → 0.2.46
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 +421 -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 +176 -0
- package/templates/src/auth/api/_shared/wecom/crypt.ts +317 -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 +246 -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 +701 -0
- package/templates/tests/wecom-crypt.spec.ts +155 -0
- package/templates/tests/wecom-endpoints.spec.ts +468 -0
- package/templates/tests/wecom-landing.spec.ts +54 -0
- package/vendor/host-versions.json +3 -3
|
@@ -0,0 +1,246 @@
|
|
|
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, readCallbackConfig, 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
|
+
//
|
|
197
|
+
// 只依赖 Tier A 凭据(readCallbackConfig):token/aeskey/baseUrl/encKey 全部部署方本地生成,
|
|
198
|
+
// 不需要 suite_id——因此「配齐四项正确即可响应验证」,**不必等代开发模版创建、拿到 suite_id 之后**。
|
|
199
|
+
// 这正是打破鸡生蛋死锁(plan §18)的落点:先有可验证的回调,才能创建模版、拿到 suite 凭据。
|
|
200
|
+
//
|
|
201
|
+
// suite_id 在「模版创建成功」后才下发;URL 验证可能发生在拿到 suite_id 之前(创建表单内联校验),
|
|
202
|
+
// 此时 receiveid 无从比对 → 走 tolerant(签名已是最外层鉴权闸门,receiveid 仅为防御性兜底)。
|
|
203
|
+
async get() {
|
|
204
|
+
try {
|
|
205
|
+
const cfg = readCallbackConfig();
|
|
206
|
+
const encrypted = query("echostr");
|
|
207
|
+
const expected = wecomSignature(cfg.token, query("timestamp"), query("nonce"), encrypted);
|
|
208
|
+
if (expected !== query("msg_signature"))
|
|
209
|
+
throw badRequest("回调签名校验失败");
|
|
210
|
+
await guardReplay(query("timestamp"), query("nonce"), runtimeReplayGate());
|
|
211
|
+
const suiteId = (vars.get("wecom_suite_id") ?? "").trim();
|
|
212
|
+
try {
|
|
213
|
+
plainText(unwrapWecomMessage(encrypted, cfg.encodingAesKey, suiteId, !suiteId));
|
|
214
|
+
}
|
|
215
|
+
catch (e) {
|
|
216
|
+
// 解密失败只透出「失败」:错钥/未下发套件/被篡改三者不可区分,也不该让外部据此判断哪一环配错
|
|
217
|
+
log.warn("wecom callback decrypt failed", "reason", (e as Error).message);
|
|
218
|
+
throw badRequest("回调消息解密失败");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
failWith(e);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
// 指令回调:验签 → 解密 → 分发(suite_ticket / create_auth / change_auth / cancel_auth /
|
|
227
|
+
// reset_permanent_code)。回 `success` 才算收下,否则企微 5 秒后重推三次。
|
|
228
|
+
async post() {
|
|
229
|
+
try {
|
|
230
|
+
const xml = typeof http.body === "string" ? http.body : "";
|
|
231
|
+
const encrypted = xmlField(xml, "Encrypt");
|
|
232
|
+
if (!encrypted)
|
|
233
|
+
throw badRequest("回调报文缺少 Encrypt");
|
|
234
|
+
const { cfg, plain } = await decryptVerified(encrypted);
|
|
235
|
+
const client = createWecomClient({ http: runtimeHttp(), cache: runtimeCache() });
|
|
236
|
+
await handleSuiteEvent(plain, { cfg, client, store: runtimeStoreDeps(cfg) });
|
|
237
|
+
plainText("success");
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
// 失败必须让企微看到非 success:它重推三次,正好覆盖「suite_ticket 未到/上游抖」这类瞬态
|
|
241
|
+
failWith(e);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
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
|
};
|
|
@@ -33,7 +33,7 @@ export function createAuthClient(ctx: ModuleContext) {
|
|
|
33
33
|
}
|
|
34
34
|
ctx.register.apiPrefix(API_PREFIX);
|
|
35
35
|
bindRequest(ctx.utils.request);
|
|
36
|
-
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost };
|
|
36
|
+
return { authLoginPost, authLogoutPost, authRefreshPost, authPasswordPut, authLdapLoginPost, authWecomAdminAppsGet, authWecomAdminStatusPost, authWecomAdminInstallGet, authWecomLoginGet, authWecomCallbackGet, authWecomTicketPost, authWecomEventGet, authWecomEventPost };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/**
|
|
@@ -140,3 +140,150 @@ export async function authLdapLoginPost(): Promise<unknown> {
|
|
|
140
140
|
throw await toApiError(e);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
export type AuthWecomAdminAppsGetData = z.infer<(typeof schemas)["authWecomAdminAppsGet"]["data"]>;
|
|
145
|
+
|
|
146
|
+
export async function authWecomAdminAppsGet(): Promise<AuthWecomAdminAppsGetData> {
|
|
147
|
+
const client = ensureReq();
|
|
148
|
+
try {
|
|
149
|
+
const env = await client.get(`auth/wecom/admin/apps`).json<OjEnvelope<AuthWecomAdminAppsGetData>>();
|
|
150
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
151
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
152
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
153
|
+
const data = env.data as AuthWecomAdminAppsGetData;
|
|
154
|
+
if (import.meta.env.DEV) {
|
|
155
|
+
const { schemas } = await import("./api.schemas");
|
|
156
|
+
const r = schemas["authWecomAdminAppsGet"].data.safeParse(data);
|
|
157
|
+
if (!r.success)
|
|
158
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminAppsGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
159
|
+
}
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw await toApiError(e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type AuthWecomAdminStatusPostBody = z.input<(typeof schemas)["authWecomAdminStatusPost"]["body"]>;
|
|
168
|
+
export type AuthWecomAdminStatusPostData = z.infer<(typeof schemas)["authWecomAdminStatusPost"]["data"]>;
|
|
169
|
+
|
|
170
|
+
export async function authWecomAdminStatusPost(body: AuthWecomAdminStatusPostBody): Promise<AuthWecomAdminStatusPostData> {
|
|
171
|
+
const client = ensureReq();
|
|
172
|
+
try {
|
|
173
|
+
const env = await client.post(`auth/wecom/admin/status`, { json: body }).json<OjEnvelope<AuthWecomAdminStatusPostData>>();
|
|
174
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
175
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
176
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
177
|
+
const data = env.data as AuthWecomAdminStatusPostData;
|
|
178
|
+
if (import.meta.env.DEV) {
|
|
179
|
+
const { schemas } = await import("./api.schemas");
|
|
180
|
+
const r = schemas["authWecomAdminStatusPost"].data.safeParse(data);
|
|
181
|
+
if (!r.success)
|
|
182
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminStatusPost 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
183
|
+
}
|
|
184
|
+
return data;
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
throw await toApiError(e);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type AuthWecomAdminInstallGetData = z.infer<(typeof schemas)["authWecomAdminInstallGet"]["data"]>;
|
|
192
|
+
|
|
193
|
+
export async function authWecomAdminInstallGet(): Promise<AuthWecomAdminInstallGetData> {
|
|
194
|
+
const client = ensureReq();
|
|
195
|
+
try {
|
|
196
|
+
const env = await client.get(`auth/wecom/admin/install`).json<OjEnvelope<AuthWecomAdminInstallGetData>>();
|
|
197
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
198
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
199
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
200
|
+
const data = env.data as AuthWecomAdminInstallGetData;
|
|
201
|
+
if (import.meta.env.DEV) {
|
|
202
|
+
const { schemas } = await import("./api.schemas");
|
|
203
|
+
const r = schemas["authWecomAdminInstallGet"].data.safeParse(data);
|
|
204
|
+
if (!r.success)
|
|
205
|
+
throw new ContractApiError(-1, `[契约违例] authWecomAdminInstallGet 响应与契约不符:${r.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
|
|
206
|
+
}
|
|
207
|
+
return data;
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
throw await toApiError(e);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function authWecomLoginGet(): Promise<unknown> {
|
|
215
|
+
const client = ensureReq();
|
|
216
|
+
try {
|
|
217
|
+
const env = await client.get(`auth/wecom/login`).json<OjEnvelope<unknown>>();
|
|
218
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
219
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
220
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
221
|
+
const data = env.data as unknown;
|
|
222
|
+
return data;
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
throw await toApiError(e);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function authWecomCallbackGet(): Promise<unknown> {
|
|
230
|
+
const client = ensureReq();
|
|
231
|
+
try {
|
|
232
|
+
const env = await client.get(`auth/wecom/callback`).json<OjEnvelope<unknown>>();
|
|
233
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
234
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
235
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
236
|
+
const data = env.data as unknown;
|
|
237
|
+
return data;
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
throw await toApiError(e);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type AuthWecomTicketPostBody = z.input<(typeof schemas)["authWecomTicketPost"]["body"]>;
|
|
245
|
+
|
|
246
|
+
export async function authWecomTicketPost(body: AuthWecomTicketPostBody): Promise<unknown> {
|
|
247
|
+
const client = ensureReq();
|
|
248
|
+
try {
|
|
249
|
+
const env = await client.post(`auth/wecom/ticket`, { json: body }).json<OjEnvelope<unknown>>();
|
|
250
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
251
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
252
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
253
|
+
const data = env.data as unknown;
|
|
254
|
+
return data;
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
throw await toApiError(e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function authWecomEventGet(): Promise<unknown> {
|
|
262
|
+
const client = ensureReq();
|
|
263
|
+
try {
|
|
264
|
+
const env = await client.get(`auth/wecom/event`).json<OjEnvelope<unknown>>();
|
|
265
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
266
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
267
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
268
|
+
const data = env.data as unknown;
|
|
269
|
+
return data;
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
throw await toApiError(e);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function authWecomEventPost(): Promise<unknown> {
|
|
277
|
+
const client = ensureReq();
|
|
278
|
+
try {
|
|
279
|
+
const env = await client.post(`auth/wecom/event`).json<OjEnvelope<unknown>>();
|
|
280
|
+
// 2xx + code!==0 也是业务错误(§6.2 通道 a):oj 不会这么发,但契约机制的价值恰是防漂移
|
|
281
|
+
if (typeof env.code === "number" && env.code !== 0)
|
|
282
|
+
throw new ContractApiError(env.code, env.msg ?? "业务错误(信封 code 非 0)");
|
|
283
|
+
const data = env.data as unknown;
|
|
284
|
+
return data;
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
throw await toApiError(e);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -4,11 +4,13 @@ tables:
|
|
|
4
4
|
pk: id
|
|
5
5
|
columns:
|
|
6
6
|
id: {type: integer, autoincrement: true}
|
|
7
|
+
tenant_id: {type: text, null: false}
|
|
7
8
|
category: {type: text, null: false}
|
|
8
9
|
value: {type: integer, null: false}
|
|
9
10
|
home_line:
|
|
10
11
|
pk: id
|
|
11
12
|
columns:
|
|
12
13
|
id: {type: integer, autoincrement: true}
|
|
14
|
+
tenant_id: {type: text, null: false}
|
|
13
15
|
day: {type: integer, null: false}
|
|
14
16
|
value: {type: integer, null: false}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AuthProvider, AuthType } from "@oj-module/runtime";
|
|
2
|
+
|
|
3
|
+
import { fetchLogin, fetchLogout, fetchUserInfo, request, useAuthStore } from "@oj-module/runtime";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 登录模块的认证 provider(P5 接缝)。
|
|
7
|
+
*
|
|
8
|
+
* 为什么必须注册:`useAuthStore.login()` 只认两条路——provider 或内置 `auth/login`。
|
|
9
|
+
* 企微登录拿到的是**票据**而不是口令,走不了内置路;而 store 的写入(token 持久化、
|
|
10
|
+
* 之后 guard 拉用户信息)必须只有一个入口,所以由本 provider 接管并把其余方式
|
|
11
|
+
* **原样委托**回内置实现(`fetchLogin/fetchLogout/fetchUserInfo`,行为零变化)。
|
|
12
|
+
*
|
|
13
|
+
* 契约(见 runtime `store/auth-provider.ts`):provider 对返回值自负责,失败请直接
|
|
14
|
+
* reject——runtime 原样透传,错误提示归登录页。
|
|
15
|
+
*/
|
|
16
|
+
export const authProvider: AuthProvider = {
|
|
17
|
+
async login(payload): Promise<AuthType> {
|
|
18
|
+
if (payload.method !== "wecom")
|
|
19
|
+
return fetchLogin(payload);
|
|
20
|
+
|
|
21
|
+
const ticket = payload.ticket?.trim() ?? "";
|
|
22
|
+
if (!ticket)
|
|
23
|
+
throw new Error("缺少企业微信登录票据,请从企业微信重新进入");
|
|
24
|
+
|
|
25
|
+
// 服务端只认 `{ ticket }`(schema 校验 minLength 32):它由 302 落地点带过来,
|
|
26
|
+
// 只在内存里存在一瞬间,不落 localStorage。
|
|
27
|
+
//
|
|
28
|
+
// throwHttpErrors:false —— ky 默认把非 2xx 抛成 HTTPError,message 是
|
|
29
|
+
// "Request failed with status code 401",登录页只能原样贴给用户看。
|
|
30
|
+
// 这里自己读信封,把后端 `fail(401, "登录票据无效或已使用")` 的 msg 透上去。
|
|
31
|
+
const res = await request.post("auth/wecom/ticket", { json: { ticket }, throwHttpErrors: false });
|
|
32
|
+
const env = await res
|
|
33
|
+
.json<{ code: number, msg?: string, data?: { access_token?: string, refresh_token?: string } }>()
|
|
34
|
+
.catch(() => undefined);
|
|
35
|
+
if (!res.ok)
|
|
36
|
+
throw new Error(env?.msg || `企业微信登录失败(HTTP ${res.status})`);
|
|
37
|
+
return {
|
|
38
|
+
token: env?.data?.access_token ?? "",
|
|
39
|
+
refreshToken: env?.data?.refresh_token ?? "",
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async logout(): Promise<void> {
|
|
44
|
+
await fetchLogout({ refreshToken: useAuthStore.getState().refreshToken });
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
getUserInfo: () => fetchUserInfo(),
|
|
48
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { defineModule } from "@oj-module/runtime";
|
|
1
|
+
import { defineModule, useAuthStore, useTenantStore } from "@oj-module/runtime";
|
|
2
2
|
import { lazy } from "react";
|
|
3
|
+
import { authProvider } from "./auth-provider";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 登录模块:提供 `/login` 路由与内容区。
|
|
@@ -56,4 +57,23 @@ export default defineModule({
|
|
|
56
57
|
"zh-CN": () => import("./locales/zh-CN.json"),
|
|
57
58
|
"en-US": () => import("./locales/en-US.json"),
|
|
58
59
|
},
|
|
60
|
+
lifecycle: {
|
|
61
|
+
/**
|
|
62
|
+
* 租户上下文(L2):请求头由**运行时**注入(`runtime/store/tenant.ts`),模块不再注册
|
|
63
|
+
* headerProvider——宿主 shell 的 hydrateUser / solution-routes 都在任何模块 onInit 之前
|
|
64
|
+
* 就发请求,模块注册必然晚一步(2026-10-30 冷启动 e2e 实测两个 400 missing tenant header)。
|
|
65
|
+
* 模块这里只保留「登出清租户」这一业务语义。
|
|
66
|
+
*/
|
|
67
|
+
async onInit(ctx) {
|
|
68
|
+
// 认证 provider:仅接管「企微票据换会话」,其余方式原样委托内置链(见 auth-provider.ts)。
|
|
69
|
+
// 注册时机必须在任何登录动作之前——onInit 先于路由渲染。
|
|
70
|
+
ctx.register.authProvider(authProvider);
|
|
71
|
+
// 登出必须清租户:否则下一个用户会带着上一个用户的租户头(跨租户请求 fail-closed 403,
|
|
72
|
+
// 但单租户部署会静默落到别人的 default 视图)。token 由非空转空即视为登出。
|
|
73
|
+
useAuthStore.subscribe((state, prev) => {
|
|
74
|
+
if (prev.token && !state.token)
|
|
75
|
+
useTenantStore.getState().clear();
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
},
|
|
59
79
|
});
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
"password": "Password",
|
|
7
7
|
"submit": "Sign in",
|
|
8
8
|
"success": "Signed in",
|
|
9
|
+
"tenant": {
|
|
10
|
+
"label": "Enterprise code",
|
|
11
|
+
"hint": "Only needed on multi-tenant deployments (e.g. WeCom corp_id); leave empty otherwise",
|
|
12
|
+
"toggle": "Sign in with enterprise code",
|
|
13
|
+
"hide": "Hide enterprise code"
|
|
14
|
+
},
|
|
9
15
|
"changePwd": {
|
|
10
16
|
"title": "Change Password",
|
|
11
17
|
"hint": "After first login or an admin reset, you must change your password before using the system.",
|
|
@@ -21,6 +27,10 @@
|
|
|
21
27
|
"local": "Password",
|
|
22
28
|
"ldap": "LDAP",
|
|
23
29
|
"sso": "SSO",
|
|
24
|
-
"sso.action": "Sign in with SSO"
|
|
30
|
+
"sso.action": "Sign in with SSO",
|
|
31
|
+
"wecom": "WeCom",
|
|
32
|
+
"wecom.action": "Sign in with WeCom",
|
|
33
|
+
"wecom.hint": "Enter the enterprise code (corp_id, visible in the WeCom console), or use the dedicated sign-in link from your admin",
|
|
34
|
+
"wecom.exchanging": "Completing WeCom sign-in…"
|
|
25
35
|
}
|
|
26
36
|
}
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
"password": "密码",
|
|
7
7
|
"submit": "登 录",
|
|
8
8
|
"success": "登录成功",
|
|
9
|
+
"tenant": {
|
|
10
|
+
"label": "企业代码",
|
|
11
|
+
"hint": "多租户部署才需要填(如企微 corp_id);单租户部署留空即可",
|
|
12
|
+
"toggle": "使用企业代码登录",
|
|
13
|
+
"hide": "收起企业代码"
|
|
14
|
+
},
|
|
9
15
|
"changePwd": {
|
|
10
16
|
"title": "修改密码",
|
|
11
17
|
"hint": "首次登录或管理员重置口令后,需先修改密码才能使用系统。",
|
|
@@ -21,6 +27,10 @@
|
|
|
21
27
|
"local": "账号密码",
|
|
22
28
|
"ldap": "LDAP",
|
|
23
29
|
"sso": "SSO",
|
|
24
|
-
"sso.action": "使用 SSO 登录"
|
|
30
|
+
"sso.action": "使用 SSO 登录",
|
|
31
|
+
"wecom": "企业微信",
|
|
32
|
+
"wecom.action": "使用企业微信登录",
|
|
33
|
+
"wecom.hint": "填写企业代码(企微后台可见的 corp_id),或直接使用管理员收到的专属登录链接",
|
|
34
|
+
"wecom.exchanging": "正在完成企业微信登录…"
|
|
25
35
|
}
|
|
26
36
|
}
|