@koishi-ce/plugin-auth 1.0.0 → 1.0.2

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/src/index.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
+ // Copyright (c) 2026-present Koishi-CE contributors.
4
+
1
5
  /**
2
6
  * auth 插件(服务端):控制台登录鉴权。
3
7
  *
@@ -15,7 +19,10 @@ import {
15
19
  timingSafeEqual,
16
20
  } from "node:crypto";
17
21
  import { resolve } from "node:path";
18
- import type { Client, DataService } from "@koishi-ce/console";
22
+ import type {
23
+ Client,
24
+ DataService,
25
+ } from "@koishi-ce/console";
19
26
  import {
20
27
  type Binding,
21
28
  type Context,
@@ -62,10 +69,22 @@ declare module "@koishi-ce/console" {
62
69
  platform: string,
63
70
  pid: string,
64
71
  ): Promise<UserLogin>;
65
- "login/password"(this: Client, name: string, password: string): void;
66
- "login/token"(this: Client, id: number, token: string): void;
72
+ "login/password"(
73
+ this: Client,
74
+ name: string,
75
+ password: string,
76
+ ): void;
77
+ "login/token"(
78
+ this: Client,
79
+ id: number,
80
+ token: string,
81
+ ): void;
67
82
  "user/delete-token"(this: Client, inc: number): void;
68
- "user/unbind"(this: Client, platform: string, pid: string): void;
83
+ "user/unbind"(
84
+ this: Client,
85
+ platform: string,
86
+ pid: string,
87
+ ): void;
69
88
  "user/update"(this: Client, data: UserUpdate): void;
70
89
  "user/logout"(this: Client): void;
71
90
  }
@@ -116,18 +135,24 @@ const letters =
116
135
  export function randomId(length = 40) {
117
136
  return Array(length)
118
137
  .fill(0)
119
- .map(() => letters[Math.floor(Math.random() * letters.length)])
138
+ .map(
139
+ () =>
140
+ letters[Math.floor(Math.random() * letters.length)],
141
+ )
120
142
  .join("");
121
143
  }
122
144
 
123
145
  /** login/platform 事件的返回值:待登录用户信息 + 一次性验证码及其过期时间。 */
124
- export interface UserLogin extends Pick<User, "id" | "name"> {
146
+ export interface UserLogin
147
+ extends Pick<User, "id" | "name"> {
125
148
  token: string;
126
149
  expiredAt: number;
127
150
  }
128
151
 
129
152
  /** user/update 事件允许修改的用户字段。 */
130
- export type UserUpdate = Partial<Pick<User, "name" | "password" | "config">>;
153
+ export type UserUpdate = Partial<
154
+ Pick<User, "name" | "password" | "config">
155
+ >;
131
156
 
132
157
  /** PBKDF2-HMAC-SHA256 迭代次数(OWASP 2023 建议 600k;登录低频,开销可接受) */
133
158
  const PBKDF2_ROUNDS = 600_000;
@@ -135,7 +160,13 @@ const PBKDF2_ROUNDS = 600_000;
135
160
  /** 新格式密码哈希:`pbkdf2$<rounds>$<salt-hex>$<dk-hex>`(加盐 + 慢哈希)。 */
136
161
  function toHash(password: string) {
137
162
  const salt = randomBytes(16);
138
- const dk = pbkdf2Sync(password, salt, PBKDF2_ROUNDS, 32, "sha256");
163
+ const dk = pbkdf2Sync(
164
+ password,
165
+ salt,
166
+ PBKDF2_ROUNDS,
167
+ 32,
168
+ "sha256",
169
+ );
139
170
  return `pbkdf2$${PBKDF2_ROUNDS}$${salt.toString("hex")}$${dk.toString("hex")}`;
140
171
  }
141
172
 
@@ -147,7 +178,10 @@ function toHash(password: string) {
147
178
  * - 64 位十六进制旧格式:历史上无盐 SHA-256,仅用于校验(命中后由调用方
148
179
  * 透明升级为 PBKDF2),同样以恒定时间比较。
149
180
  */
150
- function verifyPassword(password: string, stored: string): boolean {
181
+ function verifyPassword(
182
+ password: string,
183
+ stored: string,
184
+ ): boolean {
151
185
  if (stored.startsWith("pbkdf2$")) {
152
186
  const [, rounds, saltHex, dkHex] = stored.split("$");
153
187
  if (!rounds || !saltHex || !dkHex) return false;
@@ -167,8 +201,13 @@ function verifyPassword(password: string, stored: string): boolean {
167
201
  // 命中后由调用方透明升级为 PBKDF2;新建密码一律走 toHash(pbkdf2$ 格式),
168
202
  // 故此处非弱哈希存储,属误报(Default setup 不支持注释抑制,须在平台 dismiss)。
169
203
  if (!/^[0-9a-f]{64}$/i.test(stored)) return false;
170
- const actual = createHash("sha256").update(password).digest();
171
- return timingSafeEqual(actual, Buffer.from(stored, "hex"));
204
+ const actual = createHash("sha256")
205
+ .update(password)
206
+ .digest();
207
+ return timingSafeEqual(
208
+ actual,
209
+ Buffer.from(stored, "hex"),
210
+ );
172
211
  }
173
212
 
174
213
  /**
@@ -185,37 +224,41 @@ class AuthService extends Service {
185
224
  // 的导出值迁为静态成员(合并到 class 的 namespace 本就编译为静态属性,运行时等价)
186
225
  static filter = false;
187
226
 
188
- static Admin: Schema<AuthService.Admin> = Schema.intersect([
189
- Schema.object({
190
- enabled: Schema.boolean().default(true),
191
- }),
192
- Schema.union([
227
+ static Admin: Schema<AuthService.Admin> =
228
+ Schema.intersect([
193
229
  Schema.object({
194
- enabled: Schema.const(true),
195
- username: Schema.string().default("admin"),
196
- password: Schema.string().role("secret").required(),
230
+ enabled: Schema.boolean().default(true),
197
231
  }),
198
- Schema.object({}),
199
- ]),
200
- ]);
201
-
202
- static Config: Schema<AuthService.Config> = Schema.intersect([
203
- Schema.object({
204
- admin: AuthService.Admin,
205
- }),
206
- Schema.object({
207
- authTokenExpire: Schema.natural()
208
- .role("ms")
209
- .default(Time.week)
210
- .min(Time.hour),
211
- loginTokenExpire: Schema.natural()
212
- .role("ms")
213
- .default(Time.minute * 10)
214
- .min(Time.minute),
215
- }),
216
- ]).i18n({
217
- "zh-CN": zhCN,
218
- });
232
+ Schema.union([
233
+ Schema.object({
234
+ enabled: Schema.const(true),
235
+ username: Schema.string().default("admin"),
236
+ password: Schema.string()
237
+ .role("secret")
238
+ .required(),
239
+ }),
240
+ Schema.object({}),
241
+ ]),
242
+ ]);
243
+
244
+ static Config: Schema<AuthService.Config> =
245
+ Schema.intersect([
246
+ Schema.object({
247
+ admin: AuthService.Admin,
248
+ }),
249
+ Schema.object({
250
+ authTokenExpire: Schema.natural()
251
+ .role("ms")
252
+ .default(Time.week)
253
+ .min(Time.hour),
254
+ loginTokenExpire: Schema.natural()
255
+ .role("ms")
256
+ .default(Time.minute * 10)
257
+ .min(Time.minute),
258
+ }),
259
+ ]).i18n({
260
+ "zh-CN": zhCN,
261
+ });
219
262
 
220
263
  // Service 基类已声明 config(T = any),此处覆盖为插件配置类型
221
264
  override config: AuthService.Config;
@@ -263,7 +306,8 @@ class AuthService extends Service {
263
306
 
264
307
  /** 启动时按配置确保管理员账户(id = 0)存在。 */
265
308
  override async start() {
266
- const { enabled, username, password } = this.config.admin;
309
+ const { enabled, username, password } =
310
+ this.config.admin;
267
311
  if (!enabled) return;
268
312
  this.ctx.logger.info("creating admin account");
269
313
  // enabled 分支的 Schema 已保证 username/password 存在(默认 admin / required),
@@ -287,24 +331,42 @@ class AuthService extends Service {
287
331
  * @param passive 为 true 时只写 client.auth,不推送数据也不刷新
288
332
  * (已登录状态下修改资料后仅同步本地时使用)
289
333
  */
290
- async setAuth(client: Client, auth = client.auth, passive = false) {
334
+ async setAuth(
335
+ client: Client,
336
+ auth = client.auth,
337
+ passive = false,
338
+ ) {
291
339
  client.auth = auth;
292
340
  if (passive) return;
293
341
  if (auth) {
294
- const bindings = await this.ctx.database.get("binding", { aid: auth.id });
342
+ const bindings = await this.ctx.database.get(
343
+ "binding",
344
+ { aid: auth.id },
345
+ );
295
346
  // 下发前剥离服务端字段;minato 模型字段必填,需按可删除的形状断言
296
- bindings.forEach((binding) => delete (binding as Partial<Binding>).aid);
297
- const tokens = await this.ctx.database.get("token", { id: auth.id });
347
+ bindings.forEach(
348
+ (binding) =>
349
+ delete (binding as Partial<Binding>).aid,
350
+ );
351
+ const tokens = await this.ctx.database.get("token", {
352
+ id: auth.id,
353
+ });
298
354
  tokens.reverse().forEach((login) => {
299
355
  delete (login as Partial<LoginToken>).id;
300
356
  delete (login as Partial<LoginToken>).token;
301
357
  });
302
358
  client.send({
303
359
  type: "data",
304
- body: { key: "user", value: { ...auth, bindings, tokens } },
360
+ body: {
361
+ key: "user",
362
+ value: { ...auth, bindings, tokens },
363
+ },
305
364
  });
306
365
  } else {
307
- client.send({ type: "data", body: { key: "user", value: null } });
366
+ client.send({
367
+ type: "data",
368
+ body: { key: "user", value: null },
369
+ });
308
370
  }
309
371
  client.ctx.emit("console/connection", client);
310
372
  client.refresh();
@@ -318,7 +380,10 @@ class AuthService extends Service {
318
380
  async createToken(
319
381
  client: Client,
320
382
  type: LoginType,
321
- user: Pick<User, "id" | "name" | "authority" | "config">,
383
+ user: Pick<
384
+ User,
385
+ "id" | "name" | "authority" | "config"
386
+ >,
322
387
  ) {
323
388
  // WebSocket 升级连接必带 HTTP 请求对象(见 console 服务端构造 Client 处)
324
389
  const { headers, socket } = client.request;
@@ -326,8 +391,10 @@ class AuthService extends Service {
326
391
  const lastUsedAt = new Date();
327
392
  const userAgent = headers["user-agent"]?.toString();
328
393
  const address =
329
- headers["x-forwarded-for"]?.toString() || socket.remoteAddress;
330
- const expiredAt = Date.now() + this.config.authTokenExpire;
394
+ headers["x-forwarded-for"]?.toString() ||
395
+ socket.remoteAddress;
396
+ const expiredAt =
397
+ Date.now() + this.config.authTokenExpire;
331
398
  const token = randomId();
332
399
  // 请求头字段可能缺失,undefined 由 minato 归一化为 NULL 落库,不做默认值替换
333
400
  await this.ctx.database.create("token", {
@@ -340,7 +407,11 @@ class AuthService extends Service {
340
407
  userAgent: userAgent as string,
341
408
  address: address as string,
342
409
  });
343
- await this.setAuth(client, { ...user, expiredAt, token });
410
+ await this.setAuth(client, {
411
+ ...user,
412
+ expiredAt,
413
+ token,
414
+ });
344
415
  }
345
416
 
346
417
  /** 注册全部登录 / 用户管理事件与权限拦截逻辑(构造时调用)。 */
@@ -349,46 +420,67 @@ class AuthService extends Service {
349
420
  const { ctx, config } = this;
350
421
  // 平台验证码登录的进行中状态:键为 `${platform}:${userId}`,
351
422
  // 值为 [验证码, 过期时间, 发起登录的客户端]
352
- const states: Record<string, [string, number, Client]> = {};
423
+ const states: Record<string, [string, number, Client]> =
424
+ {};
353
425
 
354
426
  // 用户密码登录:校验通过后签发新令牌;
355
427
  // 命中旧的无盐 SHA-256 存储时透明升级为 PBKDF2
356
- ctx.console.addListener("login/password", async function (name, password) {
357
- const [user] = await ctx.database.get("user", { name }, [
358
- "password",
359
- "name",
360
- "id",
361
- "authority",
362
- "config",
363
- ]);
364
- if (!user?.password || !verifyPassword(password, user.password))
365
- throw new Error("用户名或密码错误。");
366
- if (!user.password.startsWith("pbkdf2$")) {
367
- await ctx.database.set("user", user.id, {
368
- password: toHash(password),
369
- });
370
- }
371
- await self.createToken(this, "password", omit(user, ["password"]));
372
- });
428
+ ctx.console.addListener(
429
+ "login/password",
430
+ async function (name, password) {
431
+ const [user] = await ctx.database.get(
432
+ "user",
433
+ { name },
434
+ ["password", "name", "id", "authority", "config"],
435
+ );
436
+ if (
437
+ !user?.password ||
438
+ !verifyPassword(password, user.password)
439
+ )
440
+ throw new Error("用户名或密码错误。");
441
+ if (!user.password.startsWith("pbkdf2$")) {
442
+ await ctx.database.set("user", user.id, {
443
+ password: toHash(password),
444
+ });
445
+ }
446
+ await self.createToken(
447
+ this,
448
+ "password",
449
+ omit(user, ["password"]),
450
+ );
451
+ },
452
+ );
373
453
 
374
454
  // 已存令牌续期登录:本地记录的令牌未过期即恢复登录态,
375
455
  // 同时刷新该令牌的最后访问时间
376
- ctx.console.addListener("login/token", async function (aid, token) {
377
- const [data] = await ctx.database.get("token", { id: aid, token }, [
378
- "expiredAt",
379
- ]);
380
- if (!data || data.expiredAt <= Date.now())
381
- throw new Error("令牌已失效。");
382
- const [user] = await ctx.database.get("user", { id: aid }, [
383
- "id",
384
- "name",
385
- "authority",
386
- "config",
387
- ]);
388
- if (!user) throw new Error("用户不存在。");
389
- await ctx.database.set("token", { token }, { lastUsedAt: new Date() });
390
- await self.setAuth(this, { ...user, ...data, token });
391
- });
456
+ ctx.console.addListener(
457
+ "login/token",
458
+ async function (aid, token) {
459
+ const [data] = await ctx.database.get(
460
+ "token",
461
+ { id: aid, token },
462
+ ["expiredAt"],
463
+ );
464
+ if (!data || data.expiredAt <= Date.now())
465
+ throw new Error("令牌已失效。");
466
+ const [user] = await ctx.database.get(
467
+ "user",
468
+ { id: aid },
469
+ ["id", "name", "authority", "config"],
470
+ );
471
+ if (!user) throw new Error("用户不存在。");
472
+ await ctx.database.set(
473
+ "token",
474
+ { token },
475
+ { lastUsedAt: new Date() },
476
+ );
477
+ await self.setAuth(this, {
478
+ ...user,
479
+ ...data,
480
+ token,
481
+ });
482
+ },
483
+ );
392
484
 
393
485
  // 平台账户登录(第一步):校验平台账号存在后生成一次性验证码,
394
486
  // 用户把验证码发给任意机器人即可完成登录/绑定(见下方中间件)。
@@ -396,16 +488,19 @@ class AuthService extends Service {
396
488
  ctx.console.addListener(
397
489
  "login/platform",
398
490
  async function (platform, userId) {
399
- const user = await ctx.database.getUser(platform, userId, [
400
- "id",
401
- "name",
402
- ]);
491
+ const user = await ctx.database.getUser(
492
+ platform,
493
+ userId,
494
+ ["id", "name"],
495
+ );
403
496
  if (!user) throw new Error("找不到此账户。");
404
- if (this.auth?.id === user.id) throw new Error("你已经绑定了此账户。");
497
+ if (this.auth?.id === user.id)
498
+ throw new Error("你已经绑定了此账户。");
405
499
 
406
500
  const key = `${platform}:${userId}`;
407
501
  const token = Math.random().toString().slice(2, 8);
408
- const expiredAt = Date.now() + config.loginTokenExpire;
502
+ const expiredAt =
503
+ Date.now() + config.loginTokenExpire;
409
504
  states[key] = [token, expiredAt, this];
410
505
 
411
506
  // 客户端断开或验证码超时即作废本次登录状态
@@ -420,7 +515,12 @@ class AuthService extends Service {
420
515
  }, config.loginTokenExpire);
421
516
  this.socket.addEventListener("close", listener);
422
517
 
423
- return { id: user.id, name: user.name, token, expiredAt };
518
+ return {
519
+ id: user.id,
520
+ name: user.name,
521
+ token,
522
+ expiredAt,
523
+ };
424
524
  },
425
525
  );
426
526
 
@@ -455,65 +555,99 @@ class AuthService extends Service {
455
555
 
456
556
  // 拦截带 authority 要求的 console 事件:未登录、令牌过期或
457
557
  // 权限不足时拒绝(返回 true 表示拦截)
458
- ctx.on("console/intercept", async (client, listener) => {
459
- if (!listener.authority) return false;
460
- if (!client.auth) return true;
461
- if (client.auth.expiredAt <= Date.now()) return true;
462
- if (client.auth.authority < listener.authority) return true;
463
- return false;
464
- });
558
+ ctx.on(
559
+ "console/intercept",
560
+ async (client, listener) => {
561
+ if (!listener.authority) return false;
562
+ if (!client.auth) return true;
563
+ if (client.auth.expiredAt <= Date.now())
564
+ return true;
565
+ if (client.auth.authority < listener.authority)
566
+ return true;
567
+ return false;
568
+ },
569
+ );
465
570
 
466
571
  // 删除指定登录会话(登出其它设备)
467
- ctx.console.addListener("user/delete-token", async function (inc) {
468
- if (!this.auth) throw new Error("请先登录。");
469
- const [data] = await ctx.database.get("token", { id: this.auth.id, inc });
470
- if (!data) throw new Error("令牌不存在。");
471
- await ctx.database.remove("token", { inc });
472
- await self.setAuth(this);
473
- });
572
+ ctx.console.addListener(
573
+ "user/delete-token",
574
+ async function (inc) {
575
+ if (!this.auth) throw new Error("请先登录。");
576
+ const [data] = await ctx.database.get("token", {
577
+ id: this.auth.id,
578
+ inc,
579
+ });
580
+ if (!data) throw new Error("令牌不存在。");
581
+ await ctx.database.remove("token", { inc });
582
+ await self.setAuth(this);
583
+ },
584
+ );
474
585
 
475
586
  // 退出登录:删除当前令牌并清除登录态
476
- ctx.console.addListener("user/logout", async function () {
477
- if (this.auth) {
478
- await ctx.database.remove("token", { token: this.auth.token });
479
- }
480
- await self.setAuth(this, undefined);
481
- });
587
+ ctx.console.addListener(
588
+ "user/logout",
589
+ async function () {
590
+ if (this.auth) {
591
+ await ctx.database.remove("token", {
592
+ token: this.auth.token,
593
+ });
594
+ }
595
+ await self.setAuth(this, undefined);
596
+ },
597
+ );
482
598
 
483
599
  // 修改用户资料(用户名 / 密码 / 配置),密码先哈希再落库
484
- ctx.console.addListener("user/update", async function (data) {
485
- if (!this.auth) throw new Error("请先登录。");
486
- if (data.password) data.password = toHash(data.password);
487
- await ctx.database.set("user", { id: this.auth.id }, data);
488
- Object.assign(this.auth, data);
489
- await self.setAuth(this, undefined, true);
490
- });
600
+ ctx.console.addListener(
601
+ "user/update",
602
+ async function (data) {
603
+ if (!this.auth) throw new Error("请先登录。");
604
+ if (data.password)
605
+ data.password = toHash(data.password);
606
+ await ctx.database.set(
607
+ "user",
608
+ { id: this.auth.id },
609
+ data,
610
+ );
611
+ Object.assign(this.auth, data);
612
+ await self.setAuth(this, undefined, true);
613
+ },
614
+ );
491
615
 
492
616
  // 解绑平台账号:绑到别的用户时改指回其主账号;是自身主账号且
493
617
  // 仅剩一个自绑定时拒绝解绑(避免用户失去登录途径),否则删除记录
494
- ctx.console.addListener("user/unbind", async function (platform, pid) {
495
- if (!this.auth) throw new Error("请先登录。");
496
- const bindings = await ctx.database.get("binding", { aid: this.auth.id });
497
- // 客户端仅对已列出的绑定发起解绑,查找必命中,未命中视为异常状态
498
- const binding = bindings.find(
499
- (item) => item.platform === platform && item.pid === pid,
500
- );
501
- if (!binding) throw new Error("绑定不存在。");
502
- if (binding.aid !== binding.bid) {
503
- await ctx.database.set(
504
- "binding",
505
- { platform, pid },
506
- { aid: binding.bid },
618
+ ctx.console.addListener(
619
+ "user/unbind",
620
+ async function (platform, pid) {
621
+ if (!this.auth) throw new Error("请先登录。");
622
+ const bindings = await ctx.database.get("binding", {
623
+ aid: this.auth.id,
624
+ });
625
+ // 客户端仅对已列出的绑定发起解绑,查找必命中,未命中视为异常状态
626
+ const binding = bindings.find(
627
+ (item) =>
628
+ item.platform === platform && item.pid === pid,
507
629
  );
508
- } else if (
509
- bindings.filter((item) => item.aid === item.bid).length === 1
510
- ) {
511
- throw new Error("无法解除绑定。");
512
- } else {
513
- await ctx.database.remove("binding", { platform, pid });
514
- }
515
- await self.setAuth(this);
516
- });
630
+ if (!binding) throw new Error("绑定不存在。");
631
+ if (binding.aid !== binding.bid) {
632
+ await ctx.database.set(
633
+ "binding",
634
+ { platform, pid },
635
+ { aid: binding.bid },
636
+ );
637
+ } else if (
638
+ bindings.filter((item) => item.aid === item.bid)
639
+ .length === 1
640
+ ) {
641
+ throw new Error("无法解除绑定。");
642
+ } else {
643
+ await ctx.database.remove("binding", {
644
+ platform,
645
+ pid,
646
+ });
647
+ }
648
+ await self.setAuth(this);
649
+ },
650
+ );
517
651
  }
518
652
  }
519
653