@oj-module/cli 0.1.21 → 0.2.1

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.
Files changed (107) hide show
  1. package/devkit/README.md +1 -1
  2. package/devkit/SKILL.md +17 -3
  3. package/devkit/manual.md +77 -0
  4. package/package.json +2 -2
  5. package/shell-dist/assets/{index-BUyoDvUl.js → index-CLdhl3Qd.js} +61 -12
  6. package/shell-dist/assets/runtime.js +226 -89
  7. package/shell-dist/index.html +3 -3
  8. package/shell-dist/versions.json +3 -3
  9. package/src/build.ts +25 -0
  10. package/src/init.ts +3 -0
  11. package/templates/README.md +15 -0
  12. package/templates/api/config.yaml +14 -0
  13. package/templates/api/seed.mysql.sql +5 -0
  14. package/templates/api/seed.pg.sql +5 -0
  15. package/templates/api/src/_platform/_shared/respond.ts +18 -0
  16. package/templates/api/src/_platform/_shared/service/access.ts +99 -0
  17. package/templates/api/src/_platform/_shared/service/audit.ts +90 -0
  18. package/templates/api/src/_platform/_shared/service/contacts.ts +36 -0
  19. package/templates/api/src/_platform/_shared/service/guard.ts +30 -0
  20. package/templates/api/src/_platform/_shared/service/oplog.ts +47 -0
  21. package/templates/api/src/_platform/_shared/service/query.ts +53 -0
  22. package/templates/api/src/_platform/_shared/service/rate.ts +79 -0
  23. package/templates/api/src/_platform/_shared/service/roles.ts +137 -0
  24. package/templates/api/src/_platform/_shared/service/settings.ts +67 -0
  25. package/templates/api/src/_platform/_shared/service/solution-perms.ts +94 -0
  26. package/templates/api/src/_platform/_shared/service/users.ts +163 -0
  27. package/templates/api/src/_platform/contacts/api.ts +22 -0
  28. package/templates/api/src/_platform/contacts/detail/api.ts +18 -0
  29. package/templates/api/src/_platform/manifest.yaml +9 -1
  30. package/templates/api/src/_platform/migrations/0001__create_users.mysql.sql +9 -0
  31. package/templates/api/src/_platform/migrations/0001__create_users.pg.sql +9 -0
  32. package/templates/api/src/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +3 -0
  33. package/templates/api/src/_platform/migrations/0003__users_source_display_name.mysql.sql +5 -0
  34. package/templates/api/src/_platform/migrations/0003__users_source_display_name.sql +5 -0
  35. package/templates/api/src/_platform/migrations/0004__sys_rbac.mysql.sql +70 -0
  36. package/templates/api/src/_platform/migrations/0004__sys_rbac.pg.sql +67 -0
  37. package/templates/api/src/_platform/migrations/0004__sys_rbac.sql +67 -0
  38. package/templates/api/src/_platform/migrations/0005__users_must_change.mysql.sql +3 -0
  39. package/templates/api/src/_platform/migrations/0005__users_must_change.sql +2 -0
  40. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.mysql.sql +3 -0
  41. package/templates/api/src/_platform/migrations/0006__users_is_forbidden.sql +3 -0
  42. package/templates/api/src/_platform/migrations/0007__users_email.sql +3 -0
  43. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.mysql.sql +10 -0
  44. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.pg.sql +10 -0
  45. package/templates/api/src/_platform/migrations/0008__sys_refresh_token.sql +11 -0
  46. package/templates/api/src/_platform/migrations/0009__sys_throttle.mysql.sql +12 -0
  47. package/templates/api/src/_platform/migrations/0009__sys_throttle.pg.sql +12 -0
  48. package/templates/api/src/_platform/migrations/0009__sys_throttle.sql +13 -0
  49. package/templates/api/src/_platform/schema.yaml +76 -1
  50. package/templates/api/src/_platform/seed.mysql.sql +8 -0
  51. package/templates/api/src/_platform/seed.pg.sql +8 -0
  52. package/templates/api/src/_platform/seed.sql +19 -2
  53. package/templates/api/src/auth/_shared/ldap-login.ts +86 -0
  54. package/templates/api/src/auth/_shared/ldap.ts +14 -0
  55. package/templates/api/src/auth/_shared/login-throttle.ts +29 -0
  56. package/templates/api/src/auth/_shared/session.ts +163 -18
  57. package/templates/api/src/auth/api.ts +54 -44
  58. package/templates/api/src/auth/ldap-login/api.ts +29 -0
  59. package/templates/api/src/system/login-methods/api.ts +19 -0
  60. package/templates/api/src/system/manifest.yaml +5 -0
  61. package/templates/api/src/system/oplog/api.ts +53 -0
  62. package/templates/api/src/system/permissions/api.ts +26 -0
  63. package/templates/api/src/system/roles/api.ts +75 -0
  64. package/templates/api/src/system/roles/bindings/api.ts +29 -0
  65. package/templates/api/src/system/settings/api.ts +60 -0
  66. package/templates/api/src/system/users/api.ts +58 -0
  67. package/templates/api/src/system/users/forbidden/api.ts +26 -0
  68. package/templates/api/src/system/users/password/api.ts +26 -0
  69. package/templates/api/src/system/users/roles/api.ts +27 -0
  70. package/templates/api/src/web/user-info/api.ts +9 -18
  71. package/templates/api/tests/auth-ldap.spec.ts +173 -0
  72. package/templates/api/tests/auth-session.spec.ts +194 -0
  73. package/templates/api/tests/auth-throttle.spec.ts +74 -0
  74. package/templates/api/tests/fixture.ts +50 -0
  75. package/templates/api/tests/guard.spec.ts +62 -0
  76. package/templates/api/tests/query-utils.spec.ts +68 -0
  77. package/templates/api/tests/rbac-audit.spec.ts +105 -0
  78. package/templates/api/tests/rbac-contacts.spec.ts +85 -0
  79. package/templates/api/tests/rbac-guard.spec.ts +192 -0
  80. package/templates/api/tests/rbac-roles.spec.ts +159 -0
  81. package/templates/api/tests/rbac-seed.spec.ts +54 -0
  82. package/templates/api/tests/rbac-settings.spec.ts +79 -0
  83. package/templates/api/tests/rbac-solution-perms.spec.ts +168 -0
  84. package/templates/api/tests/rbac-tables.spec.ts +63 -0
  85. package/templates/api/tests/rbac-users.spec.ts +164 -0
  86. package/templates/api/vitest.config.ts +12 -0
  87. package/templates/openapi.json +1060 -31
  88. package/templates/pnpm-workspace.yaml +9 -0
  89. package/templates/web/src/auth/__client/api.schemas.ts +6 -1
  90. package/templates/web/src/auth/__client/api.ts +33 -1
  91. package/templates/web/src/login/entry.ts +17 -0
  92. package/templates/web/src/login/locales/en-US.json +12 -2
  93. package/templates/web/src/login/locales/zh-CN.json +12 -2
  94. package/templates/web/src/login/pages/change-password.tsx +74 -0
  95. package/templates/web/src/system/entry.ts +90 -0
  96. package/templates/web/src/system/locales/en-US.json +98 -0
  97. package/templates/web/src/system/locales/zh-CN.json +98 -0
  98. package/templates/web/src/system/pages/errors.ts +51 -0
  99. package/templates/web/src/system/pages/forbidden.tsx +35 -0
  100. package/templates/web/src/system/pages/login-methods/index.tsx +29 -0
  101. package/templates/web/src/system/pages/oplog/index.tsx +91 -0
  102. package/templates/web/src/system/pages/roles/index.tsx +91 -0
  103. package/templates/web/src/system/pages/settings/index.tsx +106 -0
  104. package/templates/web/src/system/pages/users/index.tsx +168 -0
  105. package/templates/web/src/system/pages/users/roles.tsx +68 -0
  106. package/templates/web.config.ts +8 -0
  107. package/vendor/host-versions.json +3 -3
@@ -0,0 +1,58 @@
1
+ import { respond } from "../../_platform/_shared/respond";
2
+ // 管理域端点组 /api/system/users(AC1-7~10):handler 只编排——assertSystemAdmin 守卫 → service → json。
3
+ // 保护红线在 service 层硬 enforcement:禁止操作自己、admin 不碰成员资格、最后 superadmin 兜底。
4
+ // 子端点:roles/password/forbidden 各为独立目录(wiki per-endpoint 先例)。
5
+ import { assertSystemAdmin } from "../../_platform/_shared/service/access";
6
+ import { createUser, listUsers } from "../../_platform/_shared/service/users";
7
+
8
+ const bcryptDeps = { bcryptHash: (pw: string) => bcrypt.hash(pw) };
9
+
10
+ // GET /api/system/users —— 用户列表(C1 页面数据源;无口令哈希,service 已排除)。
11
+ async function get() {
12
+ await respond(async () => {
13
+ await assertSystemAdmin();
14
+ return listUsers();
15
+ });
16
+ }
17
+ get.response = {
18
+ type: "array",
19
+ items: {
20
+ type: "object",
21
+ required: ["id", "username", "display_name", "email", "is_forbidden", "must_change", "roles"],
22
+ properties: {
23
+ id: { type: "number" },
24
+ username: { type: "string" },
25
+ display_name: { type: ["string", "null"] },
26
+ email: { type: ["string", "null"] },
27
+ // service 已做 Number(x)===1 布尔归一,此处是真 boolean
28
+ is_forbidden: { type: "boolean" },
29
+ must_change: { type: "boolean" },
30
+ roles: { type: "array", items: { type: "string" } },
31
+ },
32
+ },
33
+ };
34
+
35
+ // POST /api/system/users —— 建号(B6 加固;默认 must_change=1 首登改密)。
36
+ async function post() {
37
+ await assertSystemAdmin();
38
+ json.ok({ id: await createUser(http.body || {}, bcryptDeps) });
39
+ }
40
+ post.schema = {
41
+ body: {
42
+ type: "object",
43
+ required: ["username", "password"],
44
+ properties: {
45
+ username: { type: "string", minLength: 1 },
46
+ password: { type: "string", minLength: 6 },
47
+ displayName: { type: "string" },
48
+ email: { type: "string" },
49
+ },
50
+ },
51
+ };
52
+ post.response = {
53
+ type: "object",
54
+ required: ["id"],
55
+ properties: { id: { type: "number" } },
56
+ };
57
+
58
+ export default { get, post };
@@ -0,0 +1,26 @@
1
+ import { respond } from "../../../_platform/_shared/respond";
2
+ // PUT /api/system/users/forbidden?uid= —— 禁用/解禁(禁用即删 refresh,撤销即强退)。
3
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
4
+ import { setForbidden } from "../../../_platform/_shared/service/users";
5
+
6
+ async function put() {
7
+ await respond(async () => {
8
+ await assertSystemAdmin();
9
+ const q = http.query as Record<string, unknown>;
10
+ return setForbidden(Number(http.user?.id), Number(q.uid), Boolean(http.body?.forbidden));
11
+ });
12
+ }
13
+ put.schema = {
14
+ query: {
15
+ type: "object",
16
+ required: ["uid"],
17
+ properties: { uid: { type: "number" } },
18
+ },
19
+ body: {
20
+ type: "object",
21
+ required: ["forbidden"],
22
+ properties: { forbidden: { type: "boolean" } },
23
+ },
24
+ };
25
+
26
+ export default { put };
@@ -0,0 +1,26 @@
1
+ import { respond } from "../../../_platform/_shared/respond";
2
+ // PUT /api/system/users/password?uid= —— 管理员重置口令(must_change=1 + 级联吊销 refresh)。
3
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
4
+ import { changePasswordByAdmin } from "../../../_platform/_shared/service/users";
5
+
6
+ async function put() {
7
+ await respond(async () => {
8
+ await assertSystemAdmin();
9
+ const q = http.query as Record<string, unknown>;
10
+ return changePasswordByAdmin(Number(http.user?.id), Number(q.uid), String(http.body?.password ?? ""), { bcryptHash: (pw: string) => bcrypt.hash(pw) });
11
+ });
12
+ }
13
+ put.schema = {
14
+ query: {
15
+ type: "object",
16
+ required: ["uid"],
17
+ properties: { uid: { type: "number" } },
18
+ },
19
+ body: {
20
+ type: "object",
21
+ required: ["password"],
22
+ properties: { password: { type: "string", minLength: 6 } },
23
+ },
24
+ };
25
+
26
+ export default { put };
@@ -0,0 +1,27 @@
1
+ import { respond } from "../../../_platform/_shared/respond";
2
+ // PUT /api/system/users/roles?uid= —— 整集替换用户角色(AC1-7;sys_user_role 权威源 + 写时失效)。
3
+ import { assertSystemAdmin } from "../../../_platform/_shared/service/access";
4
+ import { updateUserRoles } from "../../../_platform/_shared/service/users";
5
+
6
+ async function put() {
7
+ await respond(async () => {
8
+ await assertSystemAdmin();
9
+ const q = http.query as Record<string, unknown>;
10
+ const roles = Array.isArray(http.body?.roles) ? http.body.roles.map(String) : [];
11
+ return updateUserRoles(Number(http.user?.id), Number(q.uid), roles);
12
+ });
13
+ }
14
+ put.schema = {
15
+ query: {
16
+ type: "object",
17
+ required: ["uid"],
18
+ properties: { uid: { type: "number" } },
19
+ },
20
+ body: {
21
+ type: "object",
22
+ required: ["roles"],
23
+ properties: { roles: { type: "array", items: { type: "string" } } },
24
+ },
25
+ };
26
+
27
+ export default { put };
@@ -1,8 +1,7 @@
1
1
  // GET /api/web/user-info —— 前端登录链用户信息端点(Bearer 守卫保护)。
2
- // 按 http.user.id 查 users 表,snake_case → camelCase 映射;
3
- // 响应一律走 json.ok/json.fail 信封(oj 红线);前端 runtime 适配层(D10)
4
- // 负责把 {code:0,data} 转成前端 {code:200,result}。
5
- // avatar 读 avatar_base64(personal-center 头像上传回写,_platform 0002 迁移)。
2
+ // B4:roles 出自 sys_user_role 权威源(users.roles JSON 列已被 0003 移除,D10)——
3
+ // 体验层角色走本资料接口,不进 JWT claims;avatar 读 avatar_base64,email 读 0007 迁移列。
4
+ // 响应一律走 json.ok/json.fail 信封(oj 红线)。
6
5
  export default {
7
6
  async get() {
8
7
  const uid = http.user?.id;
@@ -12,7 +11,7 @@ export default {
12
11
  }
13
12
  try {
14
13
  const rows = await db.query(
15
- "SELECT id, username, roles, avatar_base64 FROM users WHERE id = ?",
14
+ "SELECT id, username, display_name, avatar_base64, email, must_change FROM users WHERE id = ?",
16
15
  [Number(uid)],
17
16
  );
18
17
  const row = rows[0];
@@ -20,23 +19,15 @@ export default {
20
19
  json.fail(404, "user not found");
21
20
  return;
22
21
  }
23
- let roles: string[] = [];
24
- try {
25
- const parsed = JSON.parse(String(row.roles ?? "[]"));
26
- if (Array.isArray(parsed))
27
- roles = parsed as string[];
28
- }
29
- catch {
30
- roles = [];
31
- }
22
+ const roleRows = await db.query("SELECT role_name FROM sys_user_role WHERE user_id = ?", [Number(uid)]) as Array<{ role_name: string }>;
32
23
  json.ok({
33
24
  id: String(row.id),
34
25
  avatar: String(row.avatar_base64 ?? ""),
35
26
  username: String(row.username),
36
- email: "",
37
- phoneNumber: "",
38
- description: "",
39
- roles,
27
+ displayName: row.display_name == null ? "" : String(row.display_name),
28
+ email: String(row.email ?? ""),
29
+ roles: roleRows.map(r => r.role_name),
30
+ mustChange: Number(row.must_change) === 1,
40
31
  permissions: [],
41
32
  });
42
33
  }
@@ -0,0 +1,173 @@
1
+ // BDD:LDAP 登录(D1/AC1-12/PRD §4.5)——esc RFC 4515 红线 + ldapLogin 服务九步流程。
2
+ // axis("ldap") 是 oj 运行时全局(search/bind 无参数绑定面),gateway 注入可测;
3
+ // session 注入假实现,断言 JIT/禁用/签发编排,不触真 token。
4
+ /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
5
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
6
+
7
+ import { esc, firstAttr } from "../src/auth/_shared/ldap";
8
+ import { createLdapLogin } from "../src/auth/_shared/ldap-login";
9
+ import { installDb } from "./fixture";
10
+
11
+ let dbo: ReturnType<typeof installDb>;
12
+
13
+ beforeAll(() => {
14
+ dbo = installDb();
15
+ });
16
+
17
+ afterEach(() => {
18
+ dbo.exec("DELETE FROM users WHERE username LIKE 'ldap:%'");
19
+ dbo.exec("DELETE FROM sys_setting WHERE key LIKE 'ldap%'");
20
+ });
21
+
22
+ function seedLdapSettings(overrides: Record<string, string> = {}) {
23
+ const base = { "ldap.enabled": "1", "ldap_base_dn": "ou=people,dc=example,dc=com", "ldap_uid_attr": "uid", "ldap_email_attr": "mail" };
24
+ for (const [k, v] of Object.entries({ ...base, ...overrides }))
25
+ dbo.prepare("INSERT OR REPLACE INTO sys_setting (key, module, \"group\", type, value, default_value, secret, update_time) VALUES (?, 'ldap', 'ldap', 'string', ?, '', 0, 0)").run(k, v);
26
+ }
27
+
28
+ interface FakeEntry { dn: string, attrs: Record<string, string[]> }
29
+
30
+ function fakeGateway(behavior: {
31
+ search?: (filter: string) => FakeEntry[] | Promise<FakeEntry[]>
32
+ bind?: (dn: string, password: string) => boolean | Promise<boolean>
33
+ } = {}) {
34
+ return {
35
+ searches: [] as string[],
36
+ binds: [] as Array<{ dn: string, password: string }>,
37
+ async search(_base: string, opts: { filter: string }) {
38
+ this.searches.push(opts.filter);
39
+ const r = behavior.search?.(opts.filter);
40
+ if (r instanceof Promise)
41
+ return await r;
42
+ return r ?? [{ dn: "uid=x,ou=people,dc=example,dc=com", attrs: { uid: ["alice"], mail: ["alice@example.com"] } }];
43
+ },
44
+ async bind(dn: string, password: string) {
45
+ this.binds.push({ dn, password });
46
+ const r = behavior.bind?.(dn, password);
47
+ return r instanceof Promise ? await r : (r ?? true);
48
+ },
49
+ };
50
+ }
51
+
52
+ function fakeSession() {
53
+ return {
54
+ issued: [] as number[],
55
+ async issueExternal(uid: number) {
56
+ this.issued.push(uid);
57
+ return { access_token: "a", refresh_token: "r", expires_in: 900, restricted: false, user: { id: uid, username: `u${uid}`, display_name: null, avatar_base64: null, must_change: false, roles: [] } };
58
+ },
59
+ };
60
+ }
61
+
62
+ describe("esc:RFC 4515 转义红线(filter 无参数绑定,注入即目录查询篡改)", () => {
63
+ it.each([
64
+ ["*", "\\2a"],
65
+ ["(", "\\28"],
66
+ [")", "\\29"],
67
+ ["\\", "\\5c"],
68
+ ["\0", "\\00"],
69
+ ["alice*", "alice\\2a"],
70
+ ["a)(uid=x", "a\\29\\28uid=x"],
71
+ [" Plain ", " Plain "], // 空格不转义(RFC 4515 无此要求)
72
+ ["中文明文", "中文明文"],
73
+ ])("%j → %j", (input, expected) => {
74
+ expect(esc(input)).toBe(expected);
75
+ });
76
+ });
77
+
78
+ describe("firstAttr:多值取首 / 缺失取空串", () => {
79
+ it("首值与缺省", () => {
80
+ expect(firstAttr({ mail: ["a@x.com", "b@x.com"] }, "mail")).toBe("a@x.com");
81
+ expect(firstAttr({}, "mail")).toBe("");
82
+ expect(firstAttr(undefined, "mail")).toBe("");
83
+ });
84
+ });
85
+
86
+ describe("AC1-12:LDAP 登录九步流程(D1)", () => {
87
+ it("① ldap.enabled=0 → 400(未启用)", async () => {
88
+ seedLdapSettings({ "ldap.enabled": "0" });
89
+ const g = fakeGateway();
90
+ const login = createLdapLogin(fakeSession(), g);
91
+ await expect(login("alice", "pw")).rejects.toMatchObject({ status: 400 });
92
+ expect(g.searches.length).toBe(0); // 闸住,不触上游
93
+ });
94
+
95
+ it("③ base_dn 空 → 400", async () => {
96
+ seedLdapSettings({ ldap_base_dn: "" });
97
+ const g = fakeGateway();
98
+ const login = createLdapLogin(fakeSession(), g);
99
+ await expect(login("alice", "pw")).rejects.toMatchObject({ status: 400 });
100
+ expect(g.searches.length).toBe(0);
101
+ });
102
+
103
+ it("② username/password 空 → 400", async () => {
104
+ seedLdapSettings();
105
+ const login = createLdapLogin(fakeSession(), fakeGateway());
106
+ await expect(login("", "pw")).rejects.toMatchObject({ status: 400 });
107
+ await expect(login("alice", "")).rejects.toMatchObject({ status: 400 });
108
+ });
109
+
110
+ it("⑤ search 抛 → 502;0 条/多条 → 401(不猜)", async () => {
111
+ seedLdapSettings();
112
+ const s = fakeSession();
113
+ const searchThrow = fakeGateway({ search: () => Promise.reject(new Error("conn reset")) });
114
+ await expect(createLdapLogin(s, searchThrow)("alice", "pw")).rejects.toMatchObject({ status: 502 });
115
+ await expect(createLdapLogin(s, fakeGateway({ search: () => [] }))("alice", "pw")).rejects.toMatchObject({ status: 401 });
116
+ await expect(createLdapLogin(s, fakeGateway({ search: () => [{ dn: "a", attrs: {} }, { dn: "b", attrs: {} }] }))("alice", "pw")).rejects.toMatchObject({ status: 401 });
117
+ expect(s.issued.length).toBe(0);
118
+ });
119
+
120
+ it("⑤ filter 必含转义后的用户名(esc 红线在真实调用路径上)", async () => {
121
+ seedLdapSettings();
122
+ const g = fakeGateway();
123
+ await createLdapLogin(fakeSession(), g)("ali(*)", "pw").catch(() => {});
124
+ expect(g.searches[0]).toBe("(uid=ali\\28\\2a\\29)");
125
+ });
126
+
127
+ it("⑥ bind false → 401;bind 抛 → 502", async () => {
128
+ seedLdapSettings();
129
+ const s = fakeSession();
130
+ await expect(createLdapLogin(s, fakeGateway({ bind: () => false }))("alice", "pw")).rejects.toMatchObject({ status: 401 });
131
+ const bindThrow = fakeGateway({ bind: () => Promise.reject(new Error("tls")) });
132
+ await expect(createLdapLogin(s, bindThrow)("alice", "pw")).rejects.toMatchObject({ status: 502 });
133
+ expect(s.issued.length).toBe(0);
134
+ });
135
+
136
+ it("⑦ JIT 建号:ldap:<uid> / !ldap / source=ldap / must_change=0 / email 映射 / 零角色", async () => {
137
+ seedLdapSettings();
138
+ const s = fakeSession();
139
+ await createLdapLogin(s, fakeGateway())("alice", "pw");
140
+ const row = dbo.prepare("SELECT username, password_hash, source, must_change, email, is_forbidden FROM users WHERE username = 'ldap:alice'").get() as Record<string, unknown>;
141
+ expect(row.password_hash).toBe("!ldap");
142
+ expect(row.source).toBe("ldap");
143
+ expect(row.must_change).toBe(0); // 外来号无本地旧口令,悬挂期会 brick(设计 §9.4)
144
+ expect(row.email).toBe("alice@example.com");
145
+ expect(row.is_forbidden).toBe(0);
146
+ expect(dbo.prepare("SELECT COUNT(*) n FROM sys_user_role WHERE user_id = (SELECT id FROM users WHERE username = 'ldap:alice')").get().n).toBe(0);
147
+ expect(s.issued.length).toBe(1);
148
+ });
149
+
150
+ it("⑦ 撞 UNIQUE 竞态 → 回查复用,不重复建号", async () => {
151
+ seedLdapSettings();
152
+ dbo.prepare("INSERT INTO users (username, password_hash, source, must_change) VALUES ('ldap:alice', '!ldap', 'ldap', 0)").run();
153
+ const s = fakeSession();
154
+ await createLdapLogin(s, fakeGateway())("alice", "pw");
155
+ expect(dbo.prepare("SELECT COUNT(*) n FROM users WHERE username = 'ldap:alice'").get().n).toBe(1);
156
+ expect(s.issued.length).toBe(1);
157
+ });
158
+
159
+ it("⑧ is_forbidden=1 → 403 不签发(禁号闭环三处之二;bind 成功也不放行)", async () => {
160
+ seedLdapSettings();
161
+ dbo.prepare("INSERT INTO users (username, password_hash, source, must_change, is_forbidden) VALUES ('ldap:alice', '!ldap', 'ldap', 0, 1)").run();
162
+ const s = fakeSession();
163
+ await expect(createLdapLogin(s, fakeGateway())("alice", "pw")).rejects.toMatchObject({ status: 403 });
164
+ expect(s.issued.length).toBe(0);
165
+ });
166
+
167
+ it("④ 映射缺省:uid_attr/mail attr 缺省 uid/mail", async () => {
168
+ seedLdapSettings({ ldap_uid_attr: "", ldap_email_attr: "" });
169
+ const g = fakeGateway();
170
+ await createLdapLogin(fakeSession(), g)("alice", "pw");
171
+ expect(g.searches[0]).toBe("(uid=alice)");
172
+ });
173
+ });
@@ -0,0 +1,194 @@
1
+ // BDD:登录链——B1 登录顺序(D14 统一 401)、B2 首登改密(AC1-27)、B3 refresh 轮换/reuse 检测(AC1-13)、B4 JWT 仅 sub。
2
+ // deps 全注入(Node 侧 crypto 只读不可 stub,设计 §9.1);fake bcrypt 以 `hashed:<pw>` 为口令哈希约定。
3
+ /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
4
+ import type { SessionDeps } from "../src/auth/_shared/session";
5
+
6
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
7
+ import { assertPermission } from "../src/_platform/_shared/service/access";
8
+ import { createSession } from "../src/auth/_shared/session";
9
+ import { installDb } from "./fixture";
10
+
11
+ let dbo: ReturnType<typeof installDb>;
12
+ const g = globalThis as any;
13
+
14
+ let clock: number;
15
+ let signedSubs: string[];
16
+ let randomSeq: string[];
17
+
18
+ function deps(): SessionDeps {
19
+ return {
20
+ randomHex: () => randomSeq.shift() ?? "rnd-fallback",
21
+ sha256Hex: (s: string) => `h(${s})`,
22
+ signAccess: (sub: string) => {
23
+ signedSubs.push(sub);
24
+ return `jwt(${sub})`;
25
+ },
26
+ accessTtlSecs: 900,
27
+ refreshTtlSecs: 30 * 24 * 3600,
28
+ bcryptHash: async (pw: string) => `hashed:${pw}`,
29
+ bcryptVerify: async (pw: string, h: string) => h === `hashed:${pw}`,
30
+ now: () => clock,
31
+ };
32
+ }
33
+
34
+ /** 建用户;password_hash 走 fake bcrypt 约定。 */
35
+ function mkUser(username: string, opts: { pw?: string, forbidden?: boolean, mustChange?: boolean } = {}): number {
36
+ dbo.prepare("INSERT INTO users (username, password_hash, is_forbidden, must_change) VALUES (?, ?, ?, ?)")
37
+ .run(username, `hashed:${opts.pw ?? "pw"}`, opts.forbidden ? 1 : 0, opts.mustChange ? 1 : 0);
38
+ return (dbo.prepare("SELECT id FROM users WHERE username = ?").get(username) as { id: number }).id;
39
+ }
40
+
41
+ function refreshRow(token: string) {
42
+ return dbo.prepare("SELECT * FROM sys_refresh_token WHERE token_hash = ?").get(`h(${token})`) as Record<string, unknown> | undefined;
43
+ }
44
+
45
+ beforeAll(() => {
46
+ dbo = installDb();
47
+ clock = 0;
48
+ signedSubs = [];
49
+ randomSeq = [];
50
+ });
51
+
52
+ afterEach(() => {
53
+ g.http = {};
54
+ dbo.exec("DELETE FROM sys_refresh_token");
55
+ dbo.exec("DELETE FROM sys_user_role");
56
+ dbo.exec("DELETE FROM users WHERE username LIKE 's%'");
57
+ });
58
+
59
+ describe("B1/D14:本地登录顺序与统一 401", () => {
60
+ it("口令正确未禁用 → 签发 + profile roles 来自 sys_user_role JOIN", async () => {
61
+ const uid = mkUser("s_alice", { pw: "secret1" });
62
+ dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'editor')").run(uid);
63
+ const s = createSession(deps());
64
+ const r = await s.localLogin("s_alice", "secret1");
65
+ expect(r.access_token).toBe(`jwt(${uid})`);
66
+ expect(r.user.roles).toEqual(["editor"]);
67
+ expect(r.restricted).toBe(false);
68
+ });
69
+
70
+ it("错误口令 → 401 invalid credentials", async () => {
71
+ mkUser("s_bob", { pw: "secret1" });
72
+ await expect(createSession(deps()).localLogin("s_bob", "wrong"))
73
+ .rejects
74
+ .toMatchObject({ status: 401, message: "invalid credentials" });
75
+ });
76
+
77
+ it("号不存在 → 401 同文案(不泄露存在性)", async () => {
78
+ await expect(createSession(deps()).localLogin("s_ghost", "x"))
79
+ .rejects
80
+ .toMatchObject({ status: 401, message: "invalid credentials" });
81
+ });
82
+
83
+ it("口令对但 is_forbidden=1 → 401 同文案(禁用状态不泄露,D14)", async () => {
84
+ mkUser("s_carol", { pw: "secret1", forbidden: true });
85
+ await expect(createSession(deps()).localLogin("s_carol", "secret1"))
86
+ .rejects
87
+ .toMatchObject({ status: 401, message: "invalid credentials" });
88
+ });
89
+
90
+ it("B4:signAccess 仅收到 sub(roles 不进 claims)", async () => {
91
+ signedSubs = [];
92
+ const uid = mkUser("s_dave", { pw: "secret1" });
93
+ dbo.prepare("INSERT INTO sys_user_role (user_id, role_name) VALUES (?, 'admin')").run(uid);
94
+ await createSession(deps()).localLogin("s_dave", "secret1");
95
+ expect(signedSubs).toEqual([String(uid)]);
96
+ });
97
+ });
98
+
99
+ describe("B2/AC1-27:must_change 首登改密闭环", () => {
100
+ it("must_change=1 → 登录响应 restricted=true,判权出口 403 must_change", async () => {
101
+ const uid = mkUser("s_eve", { pw: "oldpass", mustChange: true });
102
+ const s = createSession(deps());
103
+ const r = await s.localLogin("s_eve", "oldpass");
104
+ expect(r.restricted).toBe(true);
105
+ expect(r.user.must_change).toBe(true);
106
+ g.http = { user: { id: uid } };
107
+ await expect(assertPermission("wiki.page.edit")).rejects.toMatchObject({ status: 403, code: "must_change" });
108
+ });
109
+
110
+ it("旧口令错 → 401;新口令 <6 → 400", async () => {
111
+ const uid = mkUser("s_frank", { pw: "oldpass", mustChange: true });
112
+ const s = createSession(deps());
113
+ await expect(s.changePassword(uid, "nope", "newpass1")).rejects.toMatchObject({ status: 401 });
114
+ await expect(s.changePassword(uid, "oldpass", "123")).rejects.toMatchObject({ status: 400 });
115
+ });
116
+
117
+ it("改密成功:must_change 清 0、旧 refresh 全删、换发 unrestricted 正式 token", async () => {
118
+ const uid = mkUser("s_gina", { pw: "oldpass", mustChange: true });
119
+ const s = createSession(deps());
120
+ randomSeq = ["rt-old"];
121
+ await s.localLogin("s_gina", "oldpass");
122
+ expect(refreshRow("rt-old")).toBeDefined();
123
+ randomSeq = ["rt-new"];
124
+ const r = await s.changePassword(uid, "oldpass", "newpass1");
125
+ expect(refreshRow("rt-old")).toBeUndefined();
126
+ expect(refreshRow("rt-new")).toBeDefined();
127
+ expect(r.restricted).toBe(false);
128
+ const after = dbo.prepare("SELECT must_change FROM users WHERE id = ?").get(uid) as { must_change: number };
129
+ expect(Number(after.must_change)).toBe(0);
130
+ });
131
+ });
132
+
133
+ describe("B3/AC1-13:refresh 轮换、reuse 检测、TTL", () => {
134
+ it("轮换作旧:旧串 revoked=1,新串可用且 access/refresh 均更新", async () => {
135
+ mkUser("s_hank", { pw: "secret1" });
136
+ const s = createSession(deps());
137
+ randomSeq = ["rt-1"];
138
+ const t1 = await s.localLogin("s_hank", "secret1");
139
+ randomSeq = ["rt-2"];
140
+ clock = 1000;
141
+ const t2 = await s.refresh(t1.refresh_token);
142
+ expect(t2.refresh_token).toBe("rt-2");
143
+ expect(t2.access_token).toBe(t1.access_token); // 同 sub,签名恒定(fake)
144
+ expect(Number(refreshRow("rt-1")?.revoked)).toBe(1);
145
+ expect(Number(refreshRow("rt-2")?.revoked)).toBe(0);
146
+ });
147
+
148
+ it("reuse 检测:已吊销串二次出现 → 全族吊销 + 审计 auth.refresh.reuse + 401", async () => {
149
+ mkUser("s_ivy", { pw: "secret1" });
150
+ const s = createSession(deps());
151
+ randomSeq = ["rt-a"];
152
+ const t1 = await s.localLogin("s_ivy", "secret1");
153
+ randomSeq = ["rt-b"];
154
+ await s.refresh(t1.refresh_token); // rt-a 作旧,rt-b 活跃
155
+ randomSeq = ["rt-c"];
156
+ await expect(s.refresh(t1.refresh_token)).rejects.toMatchObject({ status: 401 }); // 重放 rt-a
157
+ // 全族吊销:rt-b 也被删
158
+ expect(refreshRow("rt-b")).toBeUndefined();
159
+ const audit = dbo.prepare("SELECT * FROM sys_oplog WHERE action = 'auth.refresh.reuse'").all();
160
+ expect(audit.length).toBe(1);
161
+ });
162
+
163
+ it("过期串 → 401(now 推进过 expires_at)", async () => {
164
+ mkUser("s_jack", { pw: "secret1" });
165
+ clock = 0;
166
+ const s = createSession(deps());
167
+ randomSeq = ["rt-x"];
168
+ const t1 = await s.localLogin("s_jack", "secret1");
169
+ clock = 31 * 24 * 3600;
170
+ await expect(s.refresh(t1.refresh_token)).rejects.toMatchObject({ status: 401 });
171
+ });
172
+
173
+ it("滑动窗口封顶:新 exp = min(旧 exp, now + 30d)", async () => {
174
+ mkUser("s_kate", { pw: "secret1" });
175
+ clock = 0;
176
+ const s = createSession(deps());
177
+ randomSeq = ["rt-p"];
178
+ const t1 = await s.localLogin("s_kate", "secret1");
179
+ clock = 10 * 24 * 3600; // 第 10 天刷新
180
+ randomSeq = ["rt-q"];
181
+ await s.refresh(t1.refresh_token);
182
+ const cap = 30 * 24 * 3600; // 旧 exp(绝对 30d)
183
+ expect(Number(refreshRow("rt-q")?.expires_at)).toBe(Math.min(cap, clock + 30 * 24 * 3600));
184
+ });
185
+
186
+ it("吊销联动:revokeAll 后旧串失效(禁用/改密级联的底层)", async () => {
187
+ const uid = mkUser("s_leo", { pw: "secret1" });
188
+ const s = createSession(deps());
189
+ randomSeq = ["rt-m"];
190
+ const t1 = await s.localLogin("s_leo", "secret1");
191
+ await s.revokeAll(uid);
192
+ await expect(s.refresh(t1.refresh_token)).rejects.toMatchObject({ status: 401 });
193
+ });
194
+ });
@@ -0,0 +1,74 @@
1
+ // BDD:登录限速——AC1-26 同 (IP, username) 连败阈值 → 429 + 指数 backoff;
2
+ // 阻塞期内尝试继续累加并指数延长(防阻断期无脑重试);成功清零。
3
+ // 反常识回归锚点:oj 每请求重建模块实例,进程内 Map 方案真实运行时恒失效——
4
+ // 本用例跑真实迁移建出的 sys_login_throttle 表,断言落库语义。
5
+ /* eslint-disable test/prefer-lowercase-title -- AC 编号是验收锚点,保留大写 */
6
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
7
+
8
+ import { loginBlocked, loginFailed, loginSucceeded, resetLoginThrottle } from "../src/_platform/_shared/service/rate";
9
+ import { installDb } from "./fixture";
10
+
11
+ let dbo: ReturnType<typeof installDb>;
12
+ let NOW = 0;
13
+
14
+ const OPTS = { threshold: 6, baseBackoffMs: 15_000, now: () => NOW };
15
+
16
+ beforeAll(() => {
17
+ dbo = installDb();
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await resetLoginThrottle();
22
+ NOW = 0;
23
+ });
24
+
25
+ describe("AC1-26:连败阈值与指数 backoff(DB 状态)", () => {
26
+ it("前 5 次失败不阻塞,第 6 次失败后第 7 次尝试 429(该次尝试即延长一档)", async () => {
27
+ for (let i = 0; i < 5; i++)
28
+ expect(await loginFailed("ip|bob", OPTS)).toBe(i + 1);
29
+ expect(await loginFailed("ip|bob", OPTS)).toBe(6); // 第 6 次失败 → 触发 base 阻断
30
+ NOW += 1;
31
+ // 第 7 次尝试:429;尝试本身累加失败并把 backoff 延长到 base×2
32
+ await expect(loginBlocked("ip|bob", OPTS)).rejects.toMatchObject({ status: 429, retryAfterMs: 30_000 });
33
+ });
34
+
35
+ it("指数延长:阻塞期内继续尝试 backoff ×2(30s → 60s)", async () => {
36
+ for (let i = 0; i < 6; i++)
37
+ await loginFailed("ip|amy", OPTS);
38
+ NOW += 1;
39
+ await expect(loginBlocked("ip|amy", OPTS)).rejects.toMatchObject({ retryAfterMs: 30_000 }); // 第 7 次尝试
40
+ NOW += 1000; // 仍在 30s 窗口内
41
+ await expect(loginBlocked("ip|amy", OPTS)).rejects.toMatchObject({ retryAfterMs: 60_000 });
42
+ });
43
+
44
+ it("backoff 窗口过后放行且计数保留(再败一次即重回阻断)", async () => {
45
+ for (let i = 0; i < 6; i++)
46
+ await loginFailed("ip|dan", OPTS);
47
+ NOW += 16_000; // 15s backoff 过期
48
+ await loginBlocked("ip|dan", OPTS); // 不抛(窗口已过,count=6 保留)
49
+ expect(await loginFailed("ip|dan", OPTS)).toBe(7); // 再来一次失败 → 立即重回阻断
50
+ await expect(loginBlocked("ip|dan", OPTS)).rejects.toMatchObject({ status: 429 });
51
+ });
52
+
53
+ it("成功清零:loginSucceeded 后同 key 不再阻塞", async () => {
54
+ for (let i = 0; i < 6; i++)
55
+ await loginFailed("ip|eve", OPTS);
56
+ await loginSucceeded("ip|eve");
57
+ await loginBlocked("ip|eve", OPTS); // 不抛
58
+ });
59
+
60
+ it("不同 (IP, username) 互不影响", async () => {
61
+ for (let i = 0; i < 6; i++)
62
+ await loginFailed("ip1|x", OPTS);
63
+ await loginBlocked("ip2|x", OPTS); // 不同 IP 不抛
64
+ await loginBlocked("ip1|y", OPTS); // 不同 username 不抛
65
+ });
66
+
67
+ it("状态落库:连败计数与阻断窗口在 sys_login_throttle 可查(跨请求语义的存证)", async () => {
68
+ for (let i = 0; i < 6; i++)
69
+ await loginFailed("ip|db", OPTS);
70
+ const row = dbo.prepare("SELECT fail_count, blocked_until FROM sys_login_throttle WHERE pair_key = 'ip|db'").get() as { fail_count: number, blocked_until: number };
71
+ expect(row.fail_count).toBe(6);
72
+ expect(row.blocked_until).toBe(15_000); // base × 2^0
73
+ });
74
+ });