@koishi-ce/plugin-auth 1.0.0 → 1.0.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.
- package/README.md +106 -0
- package/dist/index.js +1 -1
- package/dist/{index.css → style.css} +1 -1
- package/lib/assets/{zh-CN-KbNPwFxh.yml → zh-CN-DChWGTwa.yml} +4 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.mjs +1 -1
- package/locales/de-DE.yml +4 -0
- package/locales/en-US.yml +4 -0
- package/locales/fr-FR.yml +4 -0
- package/locales/ja-JP.yml +4 -0
- package/locales/ru-RU.yml +4 -0
- package/locales/zh-CN.yml +4 -0
- package/locales/zh-TW.yml +4 -0
- package/package.json +6 -4
- package/src/__tests__/index.test.ts +602 -0
- package/src/index.ts +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @koishi-ce/plugin-auth
|
|
2
|
+
|
|
3
|
+
**简体中文** | [English](#english)
|
|
4
|
+
|
|
5
|
+
控制台的用户登录鉴权插件,移植自上游 [koishijs/webui](https://github.com/koishijs/webui) 的 `plugins/auth`,提供 `ctx.auth` 服务。控制台要部署到公网时建议安装本插件,否则任何能访问端口的人都能操作你的机器人。需要先启用 [`@koishi-ce/plugin-console`](https://github.com/Koishi-CE/koishi/tree/main/plugins/webui/console),并依赖数据库。
|
|
6
|
+
|
|
7
|
+
## 登录方式
|
|
8
|
+
|
|
9
|
+
- **用户名密码**:`login/password`,使用内置管理员或数据库中的用户;
|
|
10
|
+
- **平台验证码**:`login/platform`,网页生成 6 位验证码,用户把验证码发给任意机器人完成登录(同时可绑定平台账号,支持私聊一步、群聊两步);
|
|
11
|
+
- **令牌续期**:`login/token`,已保存的登录令牌静默续期,页面刷新自动执行。
|
|
12
|
+
|
|
13
|
+
密码哈希采用 PBKDF2-HMAC-SHA256(600k 轮,OWASP 2023 建议);历史无盐 SHA-256 哈希在命中后透明升级。
|
|
14
|
+
|
|
15
|
+
## 配置项
|
|
16
|
+
|
|
17
|
+
| 字段 | 类型 | 默认值 | 说明 |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| `admin.enabled` | boolean | true | 启动时确保内置管理员账户存在(id 0,权限 5) |
|
|
20
|
+
| `admin.username` | string | admin | 内置管理员用户名 |
|
|
21
|
+
| `admin.password` | string | (必填) | 内置管理员密码 |
|
|
22
|
+
| `authTokenExpire` | number | 1 周 | 登录令牌有效期(最小 1 小时) |
|
|
23
|
+
| `loginTokenExpire` | number | 10 分钟 | 平台验证码有效期(最小 1 分钟) |
|
|
24
|
+
|
|
25
|
+
## 数据表
|
|
26
|
+
|
|
27
|
+
- 扩展内置 `user` 表:`password`(哈希)、`config`(客户端控制台配置快照,支持多客户端同步);
|
|
28
|
+
- 新建 `token` 表:登录令牌记录(类型、过期时间、最近使用时间、User-Agent、来源地址)。
|
|
29
|
+
|
|
30
|
+
## 页面与行为
|
|
31
|
+
|
|
32
|
+
- `/login`「登录」页与 `/profile`「用户资料」页;未登录访问自动跳转,权限不足的页面与活动自动隐藏;
|
|
33
|
+
- 绑定平台账户、多客户端配置同步冲突两个全局对话框;
|
|
34
|
+
- 服务端 `console/intercept` 拦截器按权限拒绝未登录、令牌过期与权限不足的事件调用。
|
|
35
|
+
|
|
36
|
+
## 用法
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
bun add @koishi-ce/plugin-auth
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
plugins:
|
|
44
|
+
console: {}
|
|
45
|
+
auth:
|
|
46
|
+
admin:
|
|
47
|
+
username: admin
|
|
48
|
+
password: "你的密码"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 许可证
|
|
52
|
+
|
|
53
|
+
[AGPL-3.0](https://github.com/Koishi-CE/koishi/blob/main/LICENSES/AGPL-3.0.txt)。本包是上游 koishijs/webui 的社区再分发,版权归 Shigma 及 Koishijs 贡献者(上游)与 Koishi-CE 贡献者,见 [NOTICE](https://github.com/Koishi-CE/koishi/blob/main/NOTICE)。
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## English
|
|
58
|
+
|
|
59
|
+
Console user authentication for Koishi, ported from `plugins/auth` of the upstream [koishijs/webui](https://github.com/koishijs/webui), providing the `ctx.auth` service. Recommended whenever the console is exposed to a public network — without it, anyone who can reach the port can operate your bot. Requires [`@koishi-ce/plugin-console`](https://github.com/Koishi-CE/koishi/tree/main/plugins/webui/console) and a database.
|
|
60
|
+
|
|
61
|
+
## Login methods
|
|
62
|
+
|
|
63
|
+
- **Username + password** (`login/password`) — the built-in admin or database users.
|
|
64
|
+
- **Platform verification code** (`login/platform`) — the web page shows a 6-digit code; the user sends it to any bot to log in (optionally binding the platform account; one step in private chat, two steps in groups).
|
|
65
|
+
- **Token renewal** (`login/token`) — saved tokens renew silently; page refreshes do this automatically.
|
|
66
|
+
|
|
67
|
+
Password hashing uses PBKDF2-HMAC-SHA256 (600k iterations, OWASP 2023); legacy unsalted SHA-256 hashes are transparently upgraded on first match.
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
| Field | Type | Default | Description |
|
|
72
|
+
| --- | --- | --- | --- |
|
|
73
|
+
| `admin.enabled` | boolean | true | Ensure the built-in admin account exists on startup (id 0, authority 5) |
|
|
74
|
+
| `admin.username` | string | admin | Built-in admin username |
|
|
75
|
+
| `admin.password` | string | (required) | Built-in admin password |
|
|
76
|
+
| `authTokenExpire` | number | 1 week | Login token lifetime (min 1 hour) |
|
|
77
|
+
| `loginTokenExpire` | number | 10 min | Platform code lifetime (min 1 minute) |
|
|
78
|
+
|
|
79
|
+
## Tables
|
|
80
|
+
|
|
81
|
+
Extends the built-in `user` table (`password` hash, `config` snapshot for cross-client settings sync) and creates a `token` table (type, expiry, last-used time, User-Agent, address).
|
|
82
|
+
|
|
83
|
+
## Pages and behavior
|
|
84
|
+
|
|
85
|
+
- `/login` and `/profile` pages; unauthenticated visits redirect to login; pages and activities beyond the user's authority are hidden.
|
|
86
|
+
- Global dialogs for platform-account binding and settings-sync conflicts.
|
|
87
|
+
- A server-side `console/intercept` interceptor rejects unauthenticated, expired and insufficient-authority event calls.
|
|
88
|
+
|
|
89
|
+
## Usage
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
bun add @koishi-ce/plugin-auth
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
plugins:
|
|
97
|
+
console: {}
|
|
98
|
+
auth:
|
|
99
|
+
admin:
|
|
100
|
+
username: admin
|
|
101
|
+
password: "your-password"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
[AGPL-3.0](https://github.com/Koishi-CE/koishi/blob/main/LICENSES/AGPL-3.0.txt). Community redistribution of upstream koishijs/webui; copyright Shigma and Koishijs contributors (upstream) and Koishi-CE contributors — see [NOTICE](https://github.com/Koishi-CE/koishi/blob/main/NOTICE).
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{Schema as e,deepEqual as t,icons as n,message as r,pick as i,router as a,send as o,store as s,useConfig as c,useStorage as l}from"@koishi-ce/client";import{Fragment as u,computed as d,createBlock as f,createCommentVNode as p,createElementBlock as m,createElementVNode as h,createTextVNode as g,createVNode as _,defineComponent as v,h as y,isRef as b,openBlock as x,ref as S,renderList as C,resolveComponent as w,toDisplayString as T,unref as E,watch as D,withCtx as O,withKeys as k,withModifiers as A}from"vue";import{useRouter as j}from"vue-router";var M=l(`auth`,2,()=>({authType:0})),N=S(!1),P=S(!1),F={class:`login-form text-center`},ee={key:0},te={key:1},I={class:`hint`},L={class:`token`},R={class:`control`},z={key:0},B={key:1},V={key:0,class:`error`},H={class:`control`},U={key:0,class:`error`},W={class:`control`},G=v({__name:`login-form`,setup(e){let t=S(),n=S(),r=S(!1),i=0;async function a(){let e=Date.now();if(e<i)return;let{platform:r,userId:a}=M.value;if(!(!r||!a)){i=e+1e3;try{n.value=await o(`login/platform`,r,a)}catch(e){t.value=e.message}}}async function c(){let{name:e,password:n}=M.value;try{await o(`login/password`,e,n)}catch(e){t.value=e.message}}let l=j();function d(){s.user?N.value=!1:l.back()}return(e,i)=>{let o=w(`k-button`),l=w(`k-tab`),f=w(`k-icon`),v=w(`el-input`);return x(),m(`div`,F,[n.value?(x(),m(u,{key:0},[E(s).user?(x(),m(`h1`,ee,[...i[7]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,te,[...i[8]||=[h(`span`,null,`平台账户登录`,-1)]])),h(`p`,I,`欢迎你,`+T(n.value.name||`Koishi 用户`)+`!`,1),i[10]||=h(`p`,{class:`hint`},`请用上述账号将下面的验证码发送给任意正在运行的机器人`,-1),h(`p`,L,T(n.value.token),1),h(`div`,R,[_(o,{onClick:i[0]||=e=>n.value.token=null},{default:O(()=>[...i[9]||=[g(`返回上一步`,-1)]]),_:1})])],64)):(x(),m(u,{key:1},[E(s).user?(x(),m(`h1`,z,[...i[11]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,B,[_(l,{data:[`用户密码登录`,`平台账户登录`],modelValue:E(M).authType,"onUpdate:modelValue":i[1]||=e=>E(M).authType=e},null,8,[`modelValue`])])),E(s).user||E(M).authType===1?(x(),m(u,{key:2},[_(v,{placeholder:`平台名`,modelValue:E(M).platform,"onUpdate:modelValue":i[2]||=e=>E(M).platform=e},{prefix:O(()=>[_(f,{name:`at`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`账号`,modelValue:E(M).userId,"onUpdate:modelValue":i[3]||=e=>E(M).userId=e,onKeypress:k(A(a,[`stop`]),[`enter`])},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`,`onKeypress`]),t.value?(x(),m(`p`,V,T(t.value),1)):p(`v-if`,!0),h(`div`,H,[_(o,{onClick:d},{default:O(()=>[...i[12]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:a},{default:O(()=>[...i[13]||=[g(`获取验证码`,-1)]]),_:1})])],64)):(x(),m(u,{key:3},[_(v,{placeholder:`用户名`,modelValue:E(M).name,"onUpdate:modelValue":i[4]||=e=>E(M).name=e},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`密码`,modelValue:E(M).password,"onUpdate:modelValue":i[6]||=e=>E(M).password=e,onKeypress:k(A(c,[`stop`]),[`enter`]),type:r.value?`text`:`password`},{prefix:O(()=>[_(f,{name:`lock`})]),suffix:O(()=>[_(f,{name:r.value?`eye`:`eye-slash`,onClick:i[5]||=e=>r.value=!r.value},null,8,[`name`])]),_:1},8,[`modelValue`,`onKeypress`,`type`]),t.value?(x(),m(`p`,U,T(t.value),1)):p(`v-if`,!0),h(`div`,W,[_(o,{onClick:d},{default:O(()=>[...i[14]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:c},{default:O(()=>[...i[15]||=[g(`登录`,-1)]]),_:1})])],64))],64))])}}}),K=v({__name:`bind-dialog`,setup(e){return(e,t)=>{let n=w(`el-dialog`);return x(),m(u,null,[p(` 绑定平台账户对话框:已登录状态下从个人资料页唤起,内嵌登录表单 `),_(n,{center:``,modelValue:E(N),"onUpdate:modelValue":t[0]||=e=>b(N)?N.value=e:null,class:`bind-dialog`},{default:O(()=>[_(G)]),_:1},8,[`modelValue`])],2112)}}}),q=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},J={},Y={class:`k-icon k-icon-at`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function X(e,t){return x(),m(u,null,[p(` @ 图标:登录表单"平台名"输入框的前缀 `),(x(),m(`svg`,Y,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 8C118.941 8 8 118.919 8 256c0 137.059 110.919 248 248 248 48.154 0 95.342-14.14 135.408-40.223 12.005-7.815 14.625-24.288 5.552-35.372l-10.177-12.433c-7.671-9.371-21.179-11.667-31.373-5.129C325.92 429.757 291.314 440 256 440c-101.458 0-184-82.542-184-184S154.542 72 256 72c100.139 0 184 57.619 184 160 0 38.786-21.093 79.742-58.17 83.693-17.349-.454-16.91-12.857-13.476-30.024l23.433-121.11C394.653 149.75 383.308 136 368.225 136h-44.981a13.518 13.518 0 0 0-13.432 11.993l-.01.092c-14.697-17.901-40.448-21.775-59.971-21.775-74.58 0-137.831 62.234-137.831 151.46 0 65.303 36.785 105.87 96 105.87 26.984 0 57.369-15.637 74.991-38.333 9.522 34.104 40.613 34.103 70.71 34.103C462.609 379.41 504 307.798 504 232 504 95.653 394.023 8 256 8zm-21.68 304.43c-22.249 0-36.07-15.623-36.07-40.771 0-44.993 30.779-72.729 58.63-72.729 22.292 0 35.601 15.241 35.601 40.77 0 45.061-33.875 72.73-58.161 72.73z`},null,-1)]]))],2112)}var Z=q(J,[[`render`,X]]),Q={},ne={class:`k-icon check`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function re(e,t){return x(),m(u,null,[p(` 对勾图标:个人资料页"应用更改"菜单项 `),(x(),m(`svg`,ne,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M438.6 105.4C451.1 117.9 451.1 138.1 438.6 150.6L182.6 406.6C170.1 419.1 149.9 419.1 137.4 406.6L9.372 278.6C-3.124 266.1-3.124 245.9 9.372 233.4C21.87 220.9 42.13 220.9 54.63 233.4L159.1 338.7L393.4 105.4C405.9 92.88 426.1 92.88 438.6 105.4H438.6z`},null,-1)]]))],2112)}var ie=q(Q,[[`render`,re]]),ae={},oe={class:`k-icon k-icon-lock`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function se(e,t){return x(),m(u,null,[p(` 挂锁图标:登录表单"密码"输入框的前缀 `),(x(),m(`svg`,oe,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z`},null,-1)]]))],2112)}var ce=q(ae,[[`render`,se]]),le={},ue={class:`k-icon k-icon-sign-in`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function de(e,t){return x(),m(u,null,[p(` 登入箭头图标:登录页活动入口 `),(x(),m(`svg`,ue,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M384 256c0-8.188-3.125-16.38-9.375-22.62l-128-128C237.5 96.22 223.7 93.47 211.8 98.44C199.8 103.4 192 115.1 192 128v64H48C21.49 192 0 213.5 0 240v32C0 298.5 21.49 320 48 320H192v64c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C380.9 272.4 384 264.2 384 256zM224 384V288H48C39.18 288 32 280.8 32 272v-32C32 231.2 39.18 224 48 224H224L223.1 128l128 128L224 384zM432 32h-96C327.2 32 320 39.16 320 48S327.2 64 336 64h96C458.5 64 480 85.53 480 112v288c0 26.47-21.53 48-48 48h-96c-8.844 0-16 7.156-16 16s7.156 16 16 16h96c44.13 0 80-35.88 80-80v-288C512 67.88 476.1 32 432 32z`},null,-1)]]))],2112)}var fe=q(le,[[`render`,de]]),pe={},me={class:`k-icon k-icon-sign-out`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function he(e,t){return x(),m(u,null,[p(` 登出箭头图标:个人资料页"退出登录"菜单项 `),(x(),m(`svg`,me,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M176 448h-96C53.53 448 32 426.5 32 400v-288C32 85.53 53.53 64 80 64h96C184.8 64 192 56.84 192 48S184.8 32 176 32h-96C35.88 32 0 67.88 0 112v288C0 444.1 35.88 480 80 480h96C184.8 480 192 472.8 192 464S184.8 448 176 448zM502.6 233.4l-128-128c-9.156-9.156-22.91-11.91-34.88-6.938C327.8 103.4 320 115.1 320 128l.0918 63.1L176 192C149.5 192 128 213.5 128 240v32C128 298.5 149.5 320 176 320l144.1-.001L320 384c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C508.9 272.4 512 264.2 512 256S508.9 239.6 502.6 233.4zM352 384V288H176C167.2 288 160 280.8 160 272v-32C160 231.2 167.2 224 176 224H352l-.0039-96l128 128L352 384z`},null,-1)]]))],2112)}var ge=q(pe,[[`render`,he]]),_e={},ve={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function ye(e,t){return x(),m(u,null,[p(` 人像图标:个人资料页活动入口 `),(x(),m(`svg`,ve,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 480c-47.24 0-91.04-14.78-127.2-39.84C132.9 390.9 173.8 352 224 352h64c50.25 0 91.14 38.94 95.21 88.16C347 465.2 303.2 480 256 480zM411.7 416.7C397.6 361.3 347.7 320 288 320H224c-59.73 0-109.6 41.3-123.7 96.72C58.27 375.1 32 319 32 256c0-123.5 100.5-224 224-224s224 100.5 224 224C480 319 453.7 375.1 411.7 416.7zM256 128C211.8 128 176 163.8 176 208C176 252.2 211.8 288 256 288s80-35.82 80-80C336 163.8 300.2 128 256 128zM256 256C229.5 256 208 234.5 208 208S229.5 160 256 160s48 21.53 48 48S282.5 256 256 256z`},null,-1)]]))],2112)}var be=q(_e,[[`render`,ye]]),$=v({__name:`login`,setup(e){return(e,t)=>{let n=w(`k-card`),r=w(`k-layout`);return x(),m(u,null,[p(` 登录页:居中卡片内嵌登录表单 `),_(r,{main:`darker page-login`},{default:O(()=>[_(n,null,{default:O(()=>[_(G)]),_:1})]),_:1})],2112)}}}),xe={class:`k-schema-header`},Se={class:`header`},Ce={class:`left`},we={class:`right`},Te=v({__name:`profile`,setup(t){let n={platform:`平台账户`,password:`用户密码`},i=S({}),a=d(()=>e.object({name:e.string().description(`用户名`).default(M.value.name),password:e.string().role(`secret`).description(`密码`).default(M.value.password)}).description(`基本资料`));async function c(){return s.user=null,delete M.value.id,delete M.value.token,delete M.value.expiredAt,o(`user/logout`)}async function l(){try{await o(`user/update`,i.value),r.success(`修改成功!`),Object.assign(M.value,i.value),Object.assign(s.user,i.value),i.value={}}catch(e){r.error(e.message)}}let v=d(()=>s.user?.bindings.filter(e=>s.user.id===e.bid)),y=d(()=>[{icon:`check`,label:`应用更改`,disabled:!i.value||!Object.keys(i.value).length,action:l},{type:`danger`,icon:`sign-out`,label:`退出登录`,action:c}]);return(e,t)=>{let r=w(`k-form`),c=w(`el-button`),l=w(`k-content`),d=w(`k-layout`);return x(),f(d,{main:`page-profile`,menu:y.value},{default:O(()=>[_(l,null,{default:O(()=>[_(r,{schema:a.value,modelValue:i.value,"onUpdate:modelValue":t[0]||=e=>i.value=e},null,8,[`schema`,`modelValue`]),h(`h2`,xe,[t[3]||=g(` 平台绑定 `,-1),_(c,{solid:``,class:`right`,onClick:t[1]||=e=>N.value=!0},{default:O(()=>[...t[2]||=[g(`添加`,-1)]]),_:1})]),(x(!0),m(u,null,C(E(s).user.bindings,({platform:e,pid:n,bid:r})=>(x(),m(`div`,{class:`k-schema-item`,key:`${e}:${n}`},[h(`div`,Se,[h(`div`,Ce,T(e)+` (`+T(n)+`)`,1),h(`div`,we,[v.value.length>1||r!==E(s).user.id?(x(),f(c,{key:0,onClick:A(t=>E(o)(`user/unbind`,e,n),[`stop`,`prevent`])},{default:O(()=>[...t[4]||=[g(`解绑`,-1)]]),_:1},8,[`onClick`])):p(`v-if`,!0)])])]))),128)),t[6]||=h(`h2`,{class:`k-schema-header`},`登录历史`,-1),h(`ul`,null,[(x(!0),m(u,null,C(E(s).user.tokens,({inc:e,type:r,createdAt:i,lastUsedAt:a,address:s,userAgent:l})=>(x(),m(`li`,{key:e},[h(`div`,null,`登录类型:`+T(n[r]),1),h(`div`,null,`登录时间:`+T(i),1),h(`div`,null,`最后访问:`+T(a),1),h(`div`,null,`IP 地址:`+T(s),1),h(`div`,null,`客户端:`+T(l),1),h(`div`,null,[_(c,{onClick:t=>E(o)(`user/delete-token`,e)},{default:O(()=>[...t[5]||=[g(`移除会话`,-1)]]),_:1},8,[`onClick`])])]))),128))])]),_:1})]),_:1},8,[`menu`])}}}),Ee=q(v({__name:`sync-dialog`,setup(e){let t=c();async function n(e){if(M.value.sync=!!e,P.value=!1,e){if(e===`download`){t.value=s.user.config;return}try{await o(`user/update`,{config:t.value})}catch(e){r.error(e.message)}}}return(e,r)=>{let i=w(`el-button`),a=w(`el-button-group`),o=w(`el-dialog`);return x(),f(o,{class:`sync-dialog`,onClose:r[3]||=e=>E(t).sync??=!1,"show-close":!1,"close-on-click-modal":!1,"close-on-press-escape":!1,modelValue:E(P),"onUpdate:modelValue":r[4]||=e=>b(P)?P.value=e:null},{default:O(()=>[r[8]||=h(`p`,{class:`text-center`},`检测到你本地的配置与云端不同步,是否同步配置?`,-1),_(a,{class:`text-center`},{default:O(()=>[_(i,{onClick:r[0]||=e=>n(`upload`)},{default:O(()=>[...r[5]||=[g(`上传当前配置`,-1)]]),_:1}),_(i,{onClick:r[1]||=e=>n(`download`)},{default:O(()=>[...r[6]||=[g(`下载云端配置`,-1)]]),_:1}),_(i,{onClick:r[2]||=e=>n()},{default:O(()=>[...r[7]||=[g(`关闭配置同步`,-1)]]),_:1})]),_:1})]),_:1},8,[`modelValue`])}}}),[[`__scopeId`,`data-v-d245ddd5`]]);n.register(`at`,Z),n.register(`check`,ie),n.register(`lock`,ce),n.register(`sign-in`,fe),n.register(`sign-out`,ge),n.register(`user-full`,be);var De=n=>{let{token:l,id:u,expiredAt:d}=M.value;l&&u!==void 0&&d!==void 0&&d>Date.now()&&o(`login/token`,u,l).catch(e=>r.error(e.message)),n.on(`activity`,e=>{let t=e.authority??0;return t>0&&(!s.user||s.user.authority<t)}),n.scope.disposables.push(a.beforeEach(e=>{let{activity:t}=e.meta;if(t){if((t.authority||(t.fields??[]).includes(`user`))&&!s.user)return history.state.forward===`/login`?`/`:`/login`;if(t.authority&&s.user&&t.authority>s.user.authority)return r.error(`权限不足。`),!1}})),n.page({path:`/login`,name:`登录`,icon:`sign-in`,position:`bottom`,order:500,disabled:()=>!!s.user,component:$}),n.page({path:`/profile`,name:`用户资料`,icon:`user-full`,fields:[`user`],position:`bottom`,order:500,component:Te}),n.slot({type:`global`,component:K}),n.slot({type:`global`,component:Ee}),n.settings({id:`user`,title:`用户设置`,component:v(()=>()=>y(w(`k-form`),{schema:e.object({sync:e.boolean().description(`在多个客户端间同步设置。`)}).description(`同步设置`),initial:M.value,modelValue:M.value,"onUpdate:modelValue":e=>M.value=e}))});let f=c();function p(){s.user&&(t(s.user.config,f.value)||(P.value=!0))}n.on(`dispose`,D(f,async e=>{!e||!s.user||!M.value.sync||await o(`user/update`,{config:e})},{deep:!0})),n.on(`dispose`,D(()=>M.value.sync,async e=>{e&&s.user&&p()})),n.on(`dispose`,D(()=>s.user,(e,t)=>{if(N.value=!1,!e)return a.push(`/login`);if(M.value.sync&&p(),t)return;Object.assign(M.value,i(e,[`id`,`name`,`token`,`expiredAt`])),r.success(`欢迎回来,${e.name||`Koishi 用户`}!`);let n=a.currentRoute.value.redirectedFrom;n&&!n.path.startsWith(`/login`)?a.push(n):a.push(`/profile`)}))};export{De as default};
|
|
1
|
+
import{Schema as e,deepEqual as t,icons as n,message as r,pick as i,router as a,send as o,store as s,useConfig as c,useStorage as l}from"@koishi-ce/client";import{Fragment as u,computed as d,createBlock as f,createCommentVNode as p,createElementBlock as m,createElementVNode as h,createTextVNode as g,createVNode as _,defineComponent as v,h as y,isRef as b,openBlock as x,ref as S,renderList as C,resolveComponent as w,toDisplayString as T,unref as E,watch as D,withCtx as O,withKeys as k,withModifiers as A}from"vue";import{useRouter as j}from"vue-router";var M=l(`auth`,2,()=>({authType:0})),N=S(!1),P=S(!1),F={class:`login-form text-center`},ee={key:0},te={key:1},I={class:`hint`},L={class:`token`},R={class:`control`},z={key:0},B={key:1},V={key:0,class:`error`},H={class:`control`},U={key:0,class:`error`},W={class:`control`},G=v({__name:`login-form`,setup(e){let t=S(),n=S(),r=S(!1),i=0;async function a(){let e=Date.now();if(e<i)return;let{platform:r,userId:a}=M.value;if(!(!r||!a)){i=e+1e3;try{n.value=await o(`login/platform`,r,a)}catch(e){t.value=e.message}}}async function c(){let{name:e,password:n}=M.value;try{await o(`login/password`,e,n)}catch(e){t.value=e.message}}let l=j();function d(){s.user?N.value=!1:l.back()}return(e,i)=>{let o=w(`k-button`),l=w(`k-tab`),f=w(`k-icon`),v=w(`el-input`);return x(),m(`div`,F,[n.value?(x(),m(u,{key:0},[E(s).user?(x(),m(`h1`,ee,[...i[7]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,te,[...i[8]||=[h(`span`,null,`平台账户登录`,-1)]])),h(`p`,I,`欢迎你,`+T(n.value.name||`Koishi 用户`)+`!`,1),i[10]||=h(`p`,{class:`hint`},`请用上述账号将下面的验证码发送给任意正在运行的机器人`,-1),h(`p`,L,T(n.value.token),1),h(`div`,R,[_(o,{onClick:i[0]||=e=>n.value.token=null},{default:O(()=>[...i[9]||=[g(`返回上一步`,-1)]]),_:1})])],64)):(x(),m(u,{key:1},[E(s).user?(x(),m(`h1`,z,[...i[11]||=[h(`span`,null,`平台账户绑定`,-1)]])):(x(),m(`h1`,B,[_(l,{data:[`用户密码登录`,`平台账户登录`],modelValue:E(M).authType,"onUpdate:modelValue":i[1]||=e=>E(M).authType=e},null,8,[`modelValue`])])),E(s).user||E(M).authType===1?(x(),m(u,{key:2},[_(v,{placeholder:`平台名`,modelValue:E(M).platform,"onUpdate:modelValue":i[2]||=e=>E(M).platform=e},{prefix:O(()=>[_(f,{name:`at`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`账号`,modelValue:E(M).userId,"onUpdate:modelValue":i[3]||=e=>E(M).userId=e,onKeypress:k(A(a,[`stop`]),[`enter`])},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`,`onKeypress`]),t.value?(x(),m(`p`,V,T(t.value),1)):p(`v-if`,!0),h(`div`,H,[_(o,{onClick:d},{default:O(()=>[...i[12]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:a},{default:O(()=>[...i[13]||=[g(`获取验证码`,-1)]]),_:1})])],64)):(x(),m(u,{key:3},[_(v,{placeholder:`用户名`,modelValue:E(M).name,"onUpdate:modelValue":i[4]||=e=>E(M).name=e},{prefix:O(()=>[_(f,{name:`user`})]),_:1},8,[`modelValue`]),_(v,{placeholder:`密码`,modelValue:E(M).password,"onUpdate:modelValue":i[6]||=e=>E(M).password=e,onKeypress:k(A(c,[`stop`]),[`enter`]),type:r.value?`text`:`password`},{prefix:O(()=>[_(f,{name:`lock`})]),suffix:O(()=>[_(f,{name:r.value?`eye`:`eye-slash`,onClick:i[5]||=e=>r.value=!r.value},null,8,[`name`])]),_:1},8,[`modelValue`,`onKeypress`,`type`]),t.value?(x(),m(`p`,U,T(t.value),1)):p(`v-if`,!0),h(`div`,W,[_(o,{onClick:d},{default:O(()=>[...i[14]||=[g(`返回`,-1)]]),_:1}),_(o,{onClick:c},{default:O(()=>[...i[15]||=[g(`登录`,-1)]]),_:1})])],64))],64))])}}}),K=v({__name:`bind-dialog`,setup(e){return(e,t)=>{let n=w(`el-dialog`);return x(),m(u,null,[p(` 绑定平台账户对话框:已登录状态下从个人资料页唤起,内嵌登录表单 `),_(n,{center:``,modelValue:E(N),"onUpdate:modelValue":t[0]||=e=>b(N)?N.value=e:null,class:`bind-dialog`},{default:O(()=>[_(G)]),_:1},8,[`modelValue`])],2112)}}}),q=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n},J={},Y={class:`k-icon k-icon-at`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function X(e,t){return x(),m(u,null,[p(` @ 图标:登录表单"平台名"输入框的前缀 `),(x(),m(`svg`,Y,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 8C118.941 8 8 118.919 8 256c0 137.059 110.919 248 248 248 48.154 0 95.342-14.14 135.408-40.223 12.005-7.815 14.625-24.288 5.552-35.372l-10.177-12.433c-7.671-9.371-21.179-11.667-31.373-5.129C325.92 429.757 291.314 440 256 440c-101.458 0-184-82.542-184-184S154.542 72 256 72c100.139 0 184 57.619 184 160 0 38.786-21.093 79.742-58.17 83.693-17.349-.454-16.91-12.857-13.476-30.024l23.433-121.11C394.653 149.75 383.308 136 368.225 136h-44.981a13.518 13.518 0 0 0-13.432 11.993l-.01.092c-14.697-17.901-40.448-21.775-59.971-21.775-74.58 0-137.831 62.234-137.831 151.46 0 65.303 36.785 105.87 96 105.87 26.984 0 57.369-15.637 74.991-38.333 9.522 34.104 40.613 34.103 70.71 34.103C462.609 379.41 504 307.798 504 232 504 95.653 394.023 8 256 8zm-21.68 304.43c-22.249 0-36.07-15.623-36.07-40.771 0-44.993 30.779-72.729 58.63-72.729 22.292 0 35.601 15.241 35.601 40.77 0 45.061-33.875 72.73-58.161 72.73z`},null,-1)]]))],2112)}var Z=q(J,[[`render`,X]]),Q={},ne={class:`k-icon check`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function re(e,t){return x(),m(u,null,[p(` 对勾图标:个人资料页"应用更改"菜单项 `),(x(),m(`svg`,ne,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M438.6 105.4C451.1 117.9 451.1 138.1 438.6 150.6L182.6 406.6C170.1 419.1 149.9 419.1 137.4 406.6L9.372 278.6C-3.124 266.1-3.124 245.9 9.372 233.4C21.87 220.9 42.13 220.9 54.63 233.4L159.1 338.7L393.4 105.4C405.9 92.88 426.1 92.88 438.6 105.4H438.6z`},null,-1)]]))],2112)}var ie=q(Q,[[`render`,re]]),ae={},oe={class:`k-icon k-icon-lock`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 448 512`};function se(e,t){return x(),m(u,null,[p(` 挂锁图标:登录表单"密码"输入框的前缀 `),(x(),m(`svg`,oe,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M400 224h-24v-72C376 68.2 307.8 0 224 0S72 68.2 72 152v72H48c-26.5 0-48 21.5-48 48v192c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V272c0-26.5-21.5-48-48-48zm-104 0H152v-72c0-39.7 32.3-72 72-72s72 32.3 72 72v72z`},null,-1)]]))],2112)}var ce=q(ae,[[`render`,se]]),le={},ue={class:`k-icon k-icon-sign-in`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function de(e,t){return x(),m(u,null,[p(` 登入箭头图标:登录页活动入口 `),(x(),m(`svg`,ue,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M384 256c0-8.188-3.125-16.38-9.375-22.62l-128-128C237.5 96.22 223.7 93.47 211.8 98.44C199.8 103.4 192 115.1 192 128v64H48C21.49 192 0 213.5 0 240v32C0 298.5 21.49 320 48 320H192v64c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C380.9 272.4 384 264.2 384 256zM224 384V288H48C39.18 288 32 280.8 32 272v-32C32 231.2 39.18 224 48 224H224L223.1 128l128 128L224 384zM432 32h-96C327.2 32 320 39.16 320 48S327.2 64 336 64h96C458.5 64 480 85.53 480 112v288c0 26.47-21.53 48-48 48h-96c-8.844 0-16 7.156-16 16s7.156 16 16 16h96c44.13 0 80-35.88 80-80v-288C512 67.88 476.1 32 432 32z`},null,-1)]]))],2112)}var fe=q(le,[[`render`,de]]),pe={},me={class:`k-icon k-icon-sign-out`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function he(e,t){return x(),m(u,null,[p(` 登出箭头图标:个人资料页"退出登录"菜单项 `),(x(),m(`svg`,me,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M176 448h-96C53.53 448 32 426.5 32 400v-288C32 85.53 53.53 64 80 64h96C184.8 64 192 56.84 192 48S184.8 32 176 32h-96C35.88 32 0 67.88 0 112v288C0 444.1 35.88 480 80 480h96C184.8 480 192 472.8 192 464S184.8 448 176 448zM502.6 233.4l-128-128c-9.156-9.156-22.91-11.91-34.88-6.938C327.8 103.4 320 115.1 320 128l.0918 63.1L176 192C149.5 192 128 213.5 128 240v32C128 298.5 149.5 320 176 320l144.1-.001L320 384c0 12.94 7.797 24.62 19.75 29.56c11.97 4.969 25.72 2.219 34.88-6.938l128-128C508.9 272.4 512 264.2 512 256S508.9 239.6 502.6 233.4zM352 384V288H176C167.2 288 160 280.8 160 272v-32C160 231.2 167.2 224 176 224H352l-.0039-96l128 128L352 384z`},null,-1)]]))],2112)}var ge=q(pe,[[`render`,he]]),_e={},ve={class:`k-icon`,xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 512 512`};function ye(e,t){return x(),m(u,null,[p(` 人像图标:个人资料页活动入口 `),(x(),m(`svg`,ve,[...t[0]||=[h(`path`,{fill:`currentColor`,d:`M256 0C114.6 0 0 114.6 0 256s114.6 256 256 256s256-114.6 256-256S397.4 0 256 0zM256 480c-47.24 0-91.04-14.78-127.2-39.84C132.9 390.9 173.8 352 224 352h64c50.25 0 91.14 38.94 95.21 88.16C347 465.2 303.2 480 256 480zM411.7 416.7C397.6 361.3 347.7 320 288 320H224c-59.73 0-109.6 41.3-123.7 96.72C58.27 375.1 32 319 32 256c0-123.5 100.5-224 224-224s224 100.5 224 224C480 319 453.7 375.1 411.7 416.7zM256 128C211.8 128 176 163.8 176 208C176 252.2 211.8 288 256 288s80-35.82 80-80C336 163.8 300.2 128 256 128zM256 256C229.5 256 208 234.5 208 208S229.5 160 256 160s48 21.53 48 48S282.5 256 256 256z`},null,-1)]]))],2112)}var be=q(_e,[[`render`,ye]]),$=v({__name:`login`,setup(e){return(e,t)=>{let n=w(`k-card`),r=w(`k-layout`);return x(),m(u,null,[p(` 登录页:居中卡片内嵌登录表单 `),_(r,{main:`darker page-login`},{default:O(()=>[_(n,null,{default:O(()=>[_(G)]),_:1})]),_:1})],2112)}}}),xe={class:`k-schema-header`},Se={class:`header`},Ce={class:`left`},we={class:`right`},Te=v({__name:`profile`,setup(t){let n={platform:`平台账户`,password:`用户密码`},i=S({}),a=d(()=>e.object({name:e.string().description(`用户名`).default(M.value.name),password:e.string().role(`secret`).description(`密码`).default(M.value.password)}).description(`基本资料`));async function c(){return s.user=null,delete M.value.id,delete M.value.token,delete M.value.expiredAt,o(`user/logout`)}async function l(){try{await o(`user/update`,i.value),r.success(`修改成功!`),Object.assign(M.value,i.value),Object.assign(s.user,i.value),i.value={}}catch(e){r.error(e.message)}}let v=d(()=>s.user?.bindings.filter(e=>s.user.id===e.bid)),y=d(()=>[{icon:`check`,label:`应用更改`,disabled:!i.value||!Object.keys(i.value).length,action:l},{type:`danger`,icon:`sign-out`,label:`退出登录`,action:c}]);return(e,t)=>{let r=w(`k-form`),c=w(`el-button`),l=w(`k-content`),d=w(`k-layout`);return x(),f(d,{main:`page-profile`,menu:y.value},{default:O(()=>[_(l,null,{default:O(()=>[_(r,{schema:a.value,modelValue:i.value,"onUpdate:modelValue":t[0]||=e=>i.value=e},null,8,[`schema`,`modelValue`]),h(`h2`,xe,[t[3]||=g(` 平台绑定 `,-1),_(c,{solid:``,class:`right`,onClick:t[1]||=e=>N.value=!0},{default:O(()=>[...t[2]||=[g(`添加`,-1)]]),_:1})]),(x(!0),m(u,null,C(E(s).user.bindings,({platform:e,pid:n,bid:r})=>(x(),m(`div`,{class:`k-schema-item`,key:`${e}:${n}`},[h(`div`,Se,[h(`div`,Ce,T(e)+` (`+T(n)+`)`,1),h(`div`,we,[v.value.length>1||r!==E(s).user.id?(x(),f(c,{key:0,onClick:A(t=>E(o)(`user/unbind`,e,n),[`stop`,`prevent`])},{default:O(()=>[...t[4]||=[g(`解绑`,-1)]]),_:1},8,[`onClick`])):p(`v-if`,!0)])])]))),128)),t[6]||=h(`h2`,{class:`k-schema-header`},`登录历史`,-1),h(`ul`,null,[(x(!0),m(u,null,C(E(s).user.tokens,({inc:e,type:r,createdAt:i,lastUsedAt:a,address:s,userAgent:l})=>(x(),m(`li`,{key:e},[h(`div`,null,`登录类型:`+T(n[r]),1),h(`div`,null,`登录时间:`+T(i),1),h(`div`,null,`最后访问:`+T(a),1),h(`div`,null,`IP 地址:`+T(s),1),h(`div`,null,`客户端:`+T(l),1),h(`div`,null,[_(c,{onClick:t=>E(o)(`user/delete-token`,e)},{default:O(()=>[...t[5]||=[g(`移除会话`,-1)]]),_:1},8,[`onClick`])])]))),128))])]),_:1})]),_:1},8,[`menu`])}}}),Ee=q(v({__name:`sync-dialog`,setup(e){let t=c();async function n(e){if(M.value.sync=!!e,P.value=!1,e){if(e===`download`){t.value=s.user.config;return}try{await o(`user/update`,{config:t.value})}catch(e){r.error(e.message)}}}return(e,r)=>{let i=w(`el-button`),a=w(`el-button-group`),o=w(`el-dialog`);return x(),f(o,{class:`sync-dialog`,onClose:r[3]||=e=>E(t).sync??=!1,"show-close":!1,"close-on-click-modal":!1,"close-on-press-escape":!1,modelValue:E(P),"onUpdate:modelValue":r[4]||=e=>b(P)?P.value=e:null},{default:O(()=>[r[8]||=h(`p`,{class:`text-center`},`检测到你本地的配置与云端不同步,是否同步配置?`,-1),_(a,{class:`text-center`},{default:O(()=>[_(i,{onClick:r[0]||=e=>n(`upload`)},{default:O(()=>[...r[5]||=[g(`上传当前配置`,-1)]]),_:1}),_(i,{onClick:r[1]||=e=>n(`download`)},{default:O(()=>[...r[6]||=[g(`下载云端配置`,-1)]]),_:1}),_(i,{onClick:r[2]||=e=>n()},{default:O(()=>[...r[7]||=[g(`关闭配置同步`,-1)]]),_:1})]),_:1})]),_:1},8,[`modelValue`])}}}),[[`__scopeId`,`data-v-440ef018`]]);n.register(`at`,Z),n.register(`check`,ie),n.register(`lock`,ce),n.register(`sign-in`,fe),n.register(`sign-out`,ge),n.register(`user-full`,be);var De=n=>{let{token:l,id:u,expiredAt:d}=M.value;l&&u!==void 0&&d!==void 0&&d>Date.now()&&o(`login/token`,u,l).catch(e=>r.error(e.message)),n.on(`activity`,e=>{let t=e.authority??0;return t>0&&(!s.user||s.user.authority<t)}),n.scope.disposables.push(a.beforeEach(e=>{let{activity:t}=e.meta;if(t){if((t.authority||(t.fields??[]).includes(`user`))&&!s.user)return history.state.forward===`/login`?`/`:`/login`;if(t.authority&&s.user&&t.authority>s.user.authority)return r.error(`权限不足。`),!1}})),n.page({path:`/login`,name:`登录`,icon:`sign-in`,position:`bottom`,order:500,disabled:()=>!!s.user,component:$}),n.page({path:`/profile`,name:`用户资料`,icon:`user-full`,fields:[`user`],position:`bottom`,order:500,component:Te}),n.slot({type:`global`,component:K}),n.slot({type:`global`,component:Ee}),n.settings({id:`user`,title:`用户设置`,component:v(()=>()=>y(w(`k-form`),{schema:e.object({sync:e.boolean().description(`在多个客户端间同步设置。`)}).description(`同步设置`),initial:M.value,modelValue:M.value,"onUpdate:modelValue":e=>M.value=e}))});let f=c();function p(){s.user&&(t(s.user.config,f.value)||(P.value=!0))}n.on(`dispose`,D(f,async e=>{!e||!s.user||!M.value.sync||await o(`user/update`,{config:e})},{deep:!0})),n.on(`dispose`,D(()=>M.value.sync,async e=>{e&&s.user&&p()})),n.on(`dispose`,D(()=>s.user,(e,t)=>{if(N.value=!1,!e)return a.push(`/login`);if(M.value.sync&&p(),t)return;Object.assign(M.value,i(e,[`id`,`name`,`token`,`expiredAt`])),r.success(`欢迎回来,${e.name||`Koishi 用户`}!`);let n=a.currentRoute.value.redirectedFrom;n&&!n.path.startsWith(`/login`)?a.push(n):a.push(`/profile`)}))};export{De as default};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
.login-form h1{cursor:default;margin:2.5rem auto;font-size:1.5rem}.login-form .token{font-weight:700}.login-form .el-input{margin:1rem auto;display:block}.login-form .el-input__wrapper{width:400px}.login-form .control{margin:2.5rem auto}.login-form .k-button{width:8rem;margin:0 1rem}.page-login{flex-direction:column;justify-content:center;align-items:center;display:flex}.page-login .k-card{flex-direction:column;justify-content:center;width:600px;margin:0;display:flex}.page-login .k-card .k-card-body{padding:3rem 0!important}.page-profile h1{margin:1.5rem 0;font-size:1.375rem;line-height:2rem}.page-profile .el-button.right{float:right}.el-button-group.text-center[data-v-
|
|
1
|
+
.login-form h1{cursor:default;margin:2.5rem auto;font-size:1.5rem}.login-form .token{font-weight:700}.login-form .el-input{margin:1rem auto;display:block}.login-form .el-input__wrapper{width:400px}.login-form .control{margin:2.5rem auto}.login-form .k-button{width:8rem;margin:0 1rem}.page-login{flex-direction:column;justify-content:center;align-items:center;display:flex}.page-login .k-card{flex-direction:column;justify-content:center;width:600px;margin:0;display:flex}.page-login .k-card .k-card-body{padding:3rem 0!important}.page-profile h1{margin:1.5rem 0;font-size:1.375rem;line-height:2rem}.page-profile .el-button.right{float:right}.el-button-group.text-center[data-v-440ef018]{justify-content:center;display:flex}.block{display:block}.h1{height:.25rem}.h2{height:.5rem}.flex{display:flex}.text-center{text-align:center}
|
|
2
2
|
/*$vite$:1*/
|
package/lib/index.d.ts
CHANGED
|
@@ -97,7 +97,7 @@ declare class AuthService extends Service {
|
|
|
97
97
|
* @param passive 为 true 时只写 client.auth,不推送数据也不刷新
|
|
98
98
|
* (已登录状态下修改资料后仅同步本地时使用)
|
|
99
99
|
*/
|
|
100
|
-
setAuth(client: Client, auth?:
|
|
100
|
+
setAuth(client: Client, auth?: Auth | undefined, passive?: boolean): Promise<void>;
|
|
101
101
|
/**
|
|
102
102
|
* 为用户签发新令牌并立即让客户端登录。
|
|
103
103
|
* 从 WebSocket 升级请求中提取 User-Agent 与来源 IP 一并落库,
|
package/lib/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { Schema, Service, Time, omit } from "@koishi-ce/koishi";
|
|
4
|
-
import zhCN from "./assets/zh-CN-
|
|
4
|
+
import zhCN from "./assets/zh-CN-DChWGTwa.yml";
|
|
5
5
|
//#region src/index.ts
|
|
6
6
|
/**
|
|
7
7
|
* auth 插件(服务端):控制台登录鉴权。
|
package/locales/de-DE.yml
CHANGED
package/locales/en-US.yml
CHANGED
package/locales/fr-FR.yml
CHANGED
package/locales/ja-JP.yml
CHANGED
package/locales/ru-RU.yml
CHANGED
package/locales/zh-CN.yml
CHANGED
package/locales/zh-TW.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koishi-ce/plugin-auth",
|
|
3
3
|
"description": "Console User Authentication",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
7
7
|
"typings": "lib/index.d.ts",
|
|
@@ -50,11 +50,13 @@
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
|
-
"@
|
|
54
|
-
"koishi": "^
|
|
53
|
+
"@koishi-ce/plugin-console": "^1.0.0",
|
|
54
|
+
"@koishi-ce/koishi": "^1.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@koishi-ce/client": "^1.0.
|
|
57
|
+
"@koishi-ce/client": "^1.0.3",
|
|
58
|
+
"vue": "^3.5.12",
|
|
59
|
+
"vue-router": "^5.2.0"
|
|
58
60
|
},
|
|
59
61
|
"dependencies": {
|
|
60
62
|
"@koishi-ce/console": "^1.0.0"
|
|
@@ -0,0 +1,602 @@
|
|
|
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
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @koishi-ce/plugin-auth(控制台鉴权)的行为测试。
|
|
7
|
+
*
|
|
8
|
+
* 以内存数据库 + TestConsole(内存 WebSocket 客户端)驱动真实 RPC 链路,
|
|
9
|
+
* 覆盖:管理员账户初始化、密码登录(含旧版 SHA-256 哈希透明升级)、
|
|
10
|
+
* 令牌续期登录、平台验证码两步登录与绑定改挂、权限拦截、
|
|
11
|
+
* 令牌删除 / 登出 / 资料更新 / 解绑等用户管理事件。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
15
|
+
import { createHash, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
16
|
+
import type { IncomingMessage } from "node:http";
|
|
17
|
+
import { type Client, Console, type Entry } from "@koishi-ce/console";
|
|
18
|
+
import { App, type Plugin, Time, type Universal } from "@koishi-ce/koishi";
|
|
19
|
+
import auth, { type Auth, randomId } from "@koishi-ce/plugin-auth";
|
|
20
|
+
import mockClient from "@koishi-ce/plugin-mock";
|
|
21
|
+
import memory from "@koishijs/plugin-database-memory";
|
|
22
|
+
|
|
23
|
+
// 声明测试专用事件(带权限门槛),驱动 console/intercept 鉴权链路
|
|
24
|
+
declare module "@koishi-ce/console" {
|
|
25
|
+
interface Events {
|
|
26
|
+
"test/admin-only"(): string;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 出站消息形状 */
|
|
31
|
+
interface SentMessage {
|
|
32
|
+
type: string;
|
|
33
|
+
body: {
|
|
34
|
+
id?: number;
|
|
35
|
+
key?: string;
|
|
36
|
+
value?: unknown;
|
|
37
|
+
error?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 内存 WebSocket 桩 */
|
|
42
|
+
class FakeSocket {
|
|
43
|
+
sent: string[] = [];
|
|
44
|
+
// message 与 close 的监听器统一为同构签名(never 载荷),保证集合存取类型一致
|
|
45
|
+
private messageHandlers = new Set<(event: never) => void>();
|
|
46
|
+
private closeHandlers = new Set<(event: never) => void>();
|
|
47
|
+
|
|
48
|
+
send(data: string) {
|
|
49
|
+
this.sent.push(data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addEventListener(type: string, listener: (event: never) => void) {
|
|
53
|
+
if (type === "message") this.messageHandlers.add(listener);
|
|
54
|
+
if (type === "close") this.closeHandlers.add(listener);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
removeEventListener(type: string, listener: (event: never) => void) {
|
|
58
|
+
if (type === "message") this.messageHandlers.delete(listener);
|
|
59
|
+
if (type === "close") this.closeHandlers.delete(listener);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
receive(text: string) {
|
|
63
|
+
for (const handler of this.messageHandlers) {
|
|
64
|
+
handler({ data: Buffer.from(text) } as never);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
shutdown() {
|
|
69
|
+
// close 监听器签名统一带 never 载荷,调用时补占位实参
|
|
70
|
+
for (const handler of this.closeHandlers) handler(undefined as never);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get socket(): Universal.WebSocket {
|
|
74
|
+
return this as unknown as Universal.WebSocket;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fakeRequest(headers: Record<string, string> = {}) {
|
|
79
|
+
return {
|
|
80
|
+
headers,
|
|
81
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
82
|
+
} as unknown as IncomingMessage;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function tick(ms = 30) {
|
|
86
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Console 抽象基类的最小实现 */
|
|
90
|
+
class TestConsole extends Console {
|
|
91
|
+
resolveEntry(files: Entry.Files, key: string): string[] {
|
|
92
|
+
const list =
|
|
93
|
+
typeof files === "string" || Array.isArray(files) ? files : files.prod;
|
|
94
|
+
return [String(list), key];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
acceptClient(socket: Universal.WebSocket, request: IncomingMessage): Client {
|
|
98
|
+
let accepted: Client | undefined;
|
|
99
|
+
const dispose = this.ctx.on("console/connection", (client) => {
|
|
100
|
+
accepted = client;
|
|
101
|
+
});
|
|
102
|
+
this.accept(socket, request);
|
|
103
|
+
dispose();
|
|
104
|
+
if (!accepted) throw new Error("client not accepted");
|
|
105
|
+
return accepted;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 测试客户端:包一层真实 Client,提供 RPC 调用与消息读取 */
|
|
110
|
+
class TestClient {
|
|
111
|
+
readonly socket = new FakeSocket();
|
|
112
|
+
readonly client: Client;
|
|
113
|
+
private nextId = 0;
|
|
114
|
+
|
|
115
|
+
constructor(service: TestConsole, headers: Record<string, string> = {}) {
|
|
116
|
+
this.client = service.acceptClient(
|
|
117
|
+
this.socket.socket,
|
|
118
|
+
fakeRequest(headers),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 发起一次 RPC 并轮询等待回执(PBKDF2 校验耗时较长,固定等待不可靠) */
|
|
123
|
+
async call(type: string, args: unknown[], timeout = 5000) {
|
|
124
|
+
const id = ++this.nextId;
|
|
125
|
+
this.socket.receive(JSON.stringify({ type, args, id }));
|
|
126
|
+
const deadline = Date.now() + timeout;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const response = this.messages().find(
|
|
129
|
+
(msg) => msg.type === "response" && msg.body.id === id,
|
|
130
|
+
);
|
|
131
|
+
if (response) return response.body;
|
|
132
|
+
if (Date.now() > deadline) {
|
|
133
|
+
throw new Error(`no response for ${type}`);
|
|
134
|
+
}
|
|
135
|
+
await tick(10);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 最近一条 user 数据消息 */
|
|
140
|
+
lastUserData() {
|
|
141
|
+
const data = this.messages()
|
|
142
|
+
.filter((msg) => msg.type === "data" && msg.body.key === "user")
|
|
143
|
+
.at(-1);
|
|
144
|
+
return data?.body.value as
|
|
145
|
+
| (Auth & { tokens: unknown[] })
|
|
146
|
+
| null
|
|
147
|
+
| undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
messages(): SentMessage[] {
|
|
151
|
+
return this.socket.sent.map((line) => JSON.parse(line) as SentMessage);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
close() {
|
|
155
|
+
this.socket.shutdown();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const app = new App();
|
|
160
|
+
// 同 admin:CJS 实现配 ESM 声明,nodenext 互操作视图多包一层 default,类型层穿透取真实类
|
|
161
|
+
app.plugin(memory as unknown as typeof memory.default);
|
|
162
|
+
app.plugin(mockClient);
|
|
163
|
+
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
164
|
+
app.plugin(TestConsole as unknown as Plugin.Constructor<App>);
|
|
165
|
+
app.plugin(auth, {
|
|
166
|
+
admin: { enabled: true, username: "root", password: "admin-pass" },
|
|
167
|
+
authTokenExpire: Time.week,
|
|
168
|
+
loginTokenExpire: Time.minute * 5,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const service = () => app.console as TestConsole;
|
|
172
|
+
let aliceId = 0;
|
|
173
|
+
let bobId = 0;
|
|
174
|
+
|
|
175
|
+
beforeAll(async () => {
|
|
176
|
+
await app.start();
|
|
177
|
+
// alice:用于平台验证码登录;bob:用于绑定改挂场景
|
|
178
|
+
const alice = await app.database.createUser("mock", "111", {
|
|
179
|
+
name: "alice",
|
|
180
|
+
authority: 2,
|
|
181
|
+
});
|
|
182
|
+
const bob = await app.database.createUser("mock", "222", {
|
|
183
|
+
name: "bob",
|
|
184
|
+
authority: 1,
|
|
185
|
+
});
|
|
186
|
+
aliceId = alice.id;
|
|
187
|
+
bobId = bob.id;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterAll(async () => {
|
|
191
|
+
// 先注销全部入口并等异步刷新落地,避免停机期间异步回访已卸载的 console 服务
|
|
192
|
+
for (const entry of Object.values(service().entries)) {
|
|
193
|
+
entry.dispose();
|
|
194
|
+
}
|
|
195
|
+
await tick();
|
|
196
|
+
await app.stop();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** 以管理员身份登录并返回客户端 */
|
|
200
|
+
async function loginAdmin() {
|
|
201
|
+
const client = new TestClient(service(), {
|
|
202
|
+
"user-agent": "test-agent",
|
|
203
|
+
"x-forwarded-for": "1.2.3.4",
|
|
204
|
+
});
|
|
205
|
+
await client.call("login/password", ["root", "admin-pass"]);
|
|
206
|
+
return client;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
describe("@koishi-ce/plugin-auth", () => {
|
|
210
|
+
it("启动时创建管理员账户;randomId 生成随机令牌", async () => {
|
|
211
|
+
const [admin] = await app.database.get("user", { id: 0 });
|
|
212
|
+
expect(admin?.name).toBe("root");
|
|
213
|
+
expect(admin?.authority).toBe(5);
|
|
214
|
+
expect(admin?.password?.startsWith("pbkdf2$")).toBe(true);
|
|
215
|
+
expect(randomId(8)).toMatch(/^[0-9a-zA-Z]{8}$/);
|
|
216
|
+
expect(randomId()).toHaveLength(40);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("密码登录", () => {
|
|
220
|
+
it("密码错误与账户缺失时拒绝", async () => {
|
|
221
|
+
const client = new TestClient(service());
|
|
222
|
+
const wrong = await client.call("login/password", ["root", "wrong-pass"]);
|
|
223
|
+
expect(wrong.error).toContain("用户名或密码错误");
|
|
224
|
+
const missing = await client.call("login/password", ["nobody", "x"]);
|
|
225
|
+
expect(missing.error).toContain("用户名或密码错误");
|
|
226
|
+
client.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("登录成功签发令牌并下发登录态", async () => {
|
|
230
|
+
const client = new TestClient(service(), {
|
|
231
|
+
"user-agent": "test-agent",
|
|
232
|
+
"x-forwarded-for": "1.2.3.4",
|
|
233
|
+
});
|
|
234
|
+
const response = await client.call("login/password", [
|
|
235
|
+
"root",
|
|
236
|
+
"admin-pass",
|
|
237
|
+
]);
|
|
238
|
+
expect(response.error).toBeUndefined();
|
|
239
|
+
expect(client.client.auth?.id).toBe(0);
|
|
240
|
+
expect(client.client.auth?.token).toHaveLength(40);
|
|
241
|
+
|
|
242
|
+
// 令牌落库记录来源信息(auth 已由上方断言保证存在)
|
|
243
|
+
const [row] = await app.database.get("token", {
|
|
244
|
+
token: client.client.auth!.token,
|
|
245
|
+
});
|
|
246
|
+
expect(row?.userAgent).toBe("test-agent");
|
|
247
|
+
expect(row?.address).toBe("1.2.3.4");
|
|
248
|
+
expect(row?.type).toBe("password");
|
|
249
|
+
expect(row?.expiredAt).toBeGreaterThan(Date.now());
|
|
250
|
+
|
|
251
|
+
// 下发的 user 数据附带会话与绑定明细
|
|
252
|
+
const data = client.lastUserData();
|
|
253
|
+
expect(data?.name).toBe("root");
|
|
254
|
+
expect(Array.isArray(data?.tokens)).toBe(true);
|
|
255
|
+
client.close();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("旧版无盐 SHA-256 哈希校验通过后透明升级", async () => {
|
|
259
|
+
const legacy = createHash("sha256").update("legacy-pass").digest("hex");
|
|
260
|
+
await app.database.set("user", 0, { password: legacy });
|
|
261
|
+
|
|
262
|
+
const client = new TestClient(service());
|
|
263
|
+
const response = await client.call("login/password", [
|
|
264
|
+
"root",
|
|
265
|
+
"legacy-pass",
|
|
266
|
+
]);
|
|
267
|
+
expect(response.error).toBeUndefined();
|
|
268
|
+
const [row] = await app.database.get("user", { id: 0 }, ["password"]);
|
|
269
|
+
expect(row?.password?.startsWith("pbkdf2$")).toBe(true);
|
|
270
|
+
client.close();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("畸形哈希与空密码一律拒绝", async () => {
|
|
274
|
+
await app.database.set("user", 0, { password: "pbkdf2$malformed" });
|
|
275
|
+
const malformed = new TestClient(service());
|
|
276
|
+
expect(
|
|
277
|
+
(await malformed.call("login/password", ["root", "x"])).error,
|
|
278
|
+
).toContain("用户名或密码错误");
|
|
279
|
+
malformed.close();
|
|
280
|
+
|
|
281
|
+
await app.database.set("user", 0, { password: "not-a-hash-at-all" });
|
|
282
|
+
const alien = new TestClient(service());
|
|
283
|
+
expect(
|
|
284
|
+
(await alien.call("login/password", ["root", "x"])).error,
|
|
285
|
+
).toContain("用户名或密码错误");
|
|
286
|
+
alien.close();
|
|
287
|
+
|
|
288
|
+
await app.database.set("user", 0, { password: "" });
|
|
289
|
+
const empty = new TestClient(service());
|
|
290
|
+
expect(
|
|
291
|
+
(await empty.call("login/password", ["root", "x"])).error,
|
|
292
|
+
).toContain("用户名或密码错误");
|
|
293
|
+
empty.close();
|
|
294
|
+
|
|
295
|
+
// 还原为合法管理员密码(与插件同格式的 PBKDF2 哈希)供后续用例使用
|
|
296
|
+
const salt = randomBytes(16);
|
|
297
|
+
const dk = pbkdf2Sync("admin-pass", salt, 600_000, 32, "sha256");
|
|
298
|
+
await app.database.set("user", 0, {
|
|
299
|
+
password: `pbkdf2$600000$${salt.toString("hex")}$${dk.toString("hex")}`,
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("令牌登录", () => {
|
|
305
|
+
it("有效令牌恢复登录态并刷新最后访问时间", async () => {
|
|
306
|
+
const admin = await loginAdmin();
|
|
307
|
+
const token = admin.client.auth?.token;
|
|
308
|
+
expect(token).toBeTruthy();
|
|
309
|
+
|
|
310
|
+
const client = new TestClient(service());
|
|
311
|
+
const response = await client.call("login/token", [0, token]);
|
|
312
|
+
expect(response.error).toBeUndefined();
|
|
313
|
+
expect(client.client.auth?.id).toBe(0);
|
|
314
|
+
|
|
315
|
+
const [row] = await app.database.get("token", { token: token! });
|
|
316
|
+
expect(row?.lastUsedAt?.valueOf()).toBeGreaterThan(
|
|
317
|
+
row?.createdAt?.valueOf() ?? 0,
|
|
318
|
+
);
|
|
319
|
+
admin.close();
|
|
320
|
+
client.close();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("过期 / 不存在 / 用户缺失的令牌被拒绝", async () => {
|
|
324
|
+
await app.database.create("token", {
|
|
325
|
+
id: 0,
|
|
326
|
+
type: "password",
|
|
327
|
+
token: "expired-token",
|
|
328
|
+
expiredAt: Date.now() - 1000,
|
|
329
|
+
createdAt: new Date(),
|
|
330
|
+
lastUsedAt: new Date(),
|
|
331
|
+
userAgent: "ua",
|
|
332
|
+
address: "addr",
|
|
333
|
+
});
|
|
334
|
+
await app.database.create("token", {
|
|
335
|
+
id: 999,
|
|
336
|
+
type: "password",
|
|
337
|
+
token: "ghost-token",
|
|
338
|
+
expiredAt: Date.now() + Time.hour,
|
|
339
|
+
createdAt: new Date(),
|
|
340
|
+
lastUsedAt: new Date(),
|
|
341
|
+
userAgent: "ua",
|
|
342
|
+
address: "addr",
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const client = new TestClient(service());
|
|
346
|
+
expect(
|
|
347
|
+
(await client.call("login/token", [0, "expired-token"])).error,
|
|
348
|
+
).toContain("令牌已失效");
|
|
349
|
+
expect(
|
|
350
|
+
(await client.call("login/token", [0, "no-such-token"])).error,
|
|
351
|
+
).toContain("令牌已失效");
|
|
352
|
+
expect(
|
|
353
|
+
(await client.call("login/token", [999, "ghost-token"])).error,
|
|
354
|
+
).toContain("用户不存在");
|
|
355
|
+
client.close();
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("平台验证码登录", () => {
|
|
360
|
+
it("账户不存在或已绑定同一账户时拒绝", async () => {
|
|
361
|
+
const client = new TestClient(service());
|
|
362
|
+
expect(
|
|
363
|
+
(await client.call("login/platform", ["mock", "404"])).error,
|
|
364
|
+
).toContain("找不到此账户");
|
|
365
|
+
|
|
366
|
+
client.client.auth = { id: aliceId } as Auth;
|
|
367
|
+
expect(
|
|
368
|
+
(await client.call("login/platform", ["mock", "111"])).error,
|
|
369
|
+
).toContain("你已经绑定了此账户");
|
|
370
|
+
client.close();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("未登录客户端经验证码完成登录", async () => {
|
|
374
|
+
const client = new TestClient(service());
|
|
375
|
+
const result = await client.call("login/platform", ["mock", "111"]);
|
|
376
|
+
expect(result.error).toBeUndefined();
|
|
377
|
+
expect(result.value).toMatchObject({ id: aliceId, name: "alice" });
|
|
378
|
+
const code = (result.value as { token: string }).token;
|
|
379
|
+
expect(code).toMatch(/^\d{6}$/);
|
|
380
|
+
|
|
381
|
+
// 无关消息不消费验证码状态
|
|
382
|
+
const bot = app.mock.client("999", "888");
|
|
383
|
+
await bot.receive("hello-world");
|
|
384
|
+
|
|
385
|
+
// 用户把验证码发给机器人 → 为对应账户签发令牌
|
|
386
|
+
const user = app.mock.client("111");
|
|
387
|
+
await user.receive(code);
|
|
388
|
+
await tick();
|
|
389
|
+
expect(client.client.auth?.id).toBe(aliceId);
|
|
390
|
+
const data = client.lastUserData();
|
|
391
|
+
expect(data?.name).toBe("alice");
|
|
392
|
+
client.close();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("已登录客户端经验证码改绑平台账户", async () => {
|
|
396
|
+
const client = new TestClient(service());
|
|
397
|
+
// 已登录为 alice,把 bob 的平台账号绑到 alice 名下
|
|
398
|
+
client.client.auth = { id: aliceId } as Auth;
|
|
399
|
+
const result = await client.call("login/platform", ["mock", "222"]);
|
|
400
|
+
expect(result.error).toBeUndefined();
|
|
401
|
+
const code = (result.value as { token: string }).token;
|
|
402
|
+
|
|
403
|
+
const user = app.mock.client("222");
|
|
404
|
+
await user.receive(code);
|
|
405
|
+
await tick();
|
|
406
|
+
const [binding] = await app.database.get("binding", {
|
|
407
|
+
platform: "mock",
|
|
408
|
+
pid: "222",
|
|
409
|
+
});
|
|
410
|
+
expect(binding?.aid).toBe(aliceId);
|
|
411
|
+
client.close();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("验证码超时后触发状态清理回调", async () => {
|
|
415
|
+
// Schema 要 loginTokenExpire >= 1min,构造后直接改字段绕开校验,
|
|
416
|
+
// 用 50ms 时效快速触发超时回调
|
|
417
|
+
const keep = app.auth.config.loginTokenExpire;
|
|
418
|
+
app.auth.config.loginTokenExpire = 50;
|
|
419
|
+
const client = new TestClient(service());
|
|
420
|
+
const result = await client.call("login/platform", ["mock", "111"]);
|
|
421
|
+
expect(result.error).toBeUndefined();
|
|
422
|
+
// 等待超时回调执行(状态清理与否取决于到期判定,此处验证回调不抛错)
|
|
423
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
424
|
+
client.close();
|
|
425
|
+
app.auth.config.loginTokenExpire = keep;
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe("权限拦截(console/intercept)", () => {
|
|
430
|
+
it("按登录态与权限等级拦截事件", async () => {
|
|
431
|
+
app.console.addListener("test/admin-only", () => "secret", {
|
|
432
|
+
authority: 4,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// 未登录:拦截
|
|
436
|
+
const anonymous = new TestClient(service());
|
|
437
|
+
expect((await anonymous.call("test/admin-only", [])).error).toBe(
|
|
438
|
+
"unauthorized",
|
|
439
|
+
);
|
|
440
|
+
anonymous.close();
|
|
441
|
+
|
|
442
|
+
// 权限不足:拦截
|
|
443
|
+
const low = new TestClient(service());
|
|
444
|
+
low.client.auth = {
|
|
445
|
+
id: bobId,
|
|
446
|
+
authority: 1,
|
|
447
|
+
expiredAt: Date.now() + Time.hour,
|
|
448
|
+
token: "t",
|
|
449
|
+
} as Auth;
|
|
450
|
+
expect((await low.call("test/admin-only", [])).error).toBe(
|
|
451
|
+
"unauthorized",
|
|
452
|
+
);
|
|
453
|
+
low.close();
|
|
454
|
+
|
|
455
|
+
// 令牌过期:拦截
|
|
456
|
+
const expired = new TestClient(service());
|
|
457
|
+
expired.client.auth = {
|
|
458
|
+
id: 0,
|
|
459
|
+
authority: 5,
|
|
460
|
+
expiredAt: Date.now() - 1,
|
|
461
|
+
token: "t",
|
|
462
|
+
} as Auth;
|
|
463
|
+
expect((await expired.call("test/admin-only", [])).error).toBe(
|
|
464
|
+
"unauthorized",
|
|
465
|
+
);
|
|
466
|
+
expired.close();
|
|
467
|
+
|
|
468
|
+
// 管理员:放行
|
|
469
|
+
const admin = await loginAdmin();
|
|
470
|
+
expect((await admin.call("test/admin-only", [])).value).toBe("secret");
|
|
471
|
+
admin.close();
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
describe("用户管理事件", () => {
|
|
476
|
+
it("user/delete-token 删除指定会话", async () => {
|
|
477
|
+
const anonymous = new TestClient(service());
|
|
478
|
+
expect((await anonymous.call("user/delete-token", [1])).error).toContain(
|
|
479
|
+
"请先登录",
|
|
480
|
+
);
|
|
481
|
+
anonymous.close();
|
|
482
|
+
|
|
483
|
+
const admin = await loginAdmin();
|
|
484
|
+
const [row] = await app.database.get("token", {
|
|
485
|
+
token: admin.client.auth!.token,
|
|
486
|
+
});
|
|
487
|
+
const bad = await admin.call("user/delete-token", [99999]);
|
|
488
|
+
expect(bad.error).toContain("令牌不存在");
|
|
489
|
+
|
|
490
|
+
const ok = await admin.call("user/delete-token", [row?.inc]);
|
|
491
|
+
expect(ok.error).toBeUndefined();
|
|
492
|
+
const [removed] = await app.database.get("token", { inc: row!.inc });
|
|
493
|
+
expect(removed).toBeUndefined();
|
|
494
|
+
admin.close();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("user/logout 删除当前令牌并重发登录态", async () => {
|
|
498
|
+
const admin = await loginAdmin();
|
|
499
|
+
const token = admin.client.auth?.token;
|
|
500
|
+
const response = await admin.call("user/logout", []);
|
|
501
|
+
expect(response.error).toBeUndefined();
|
|
502
|
+
// 令牌已被删除,其它设备无法再用它续期(loginAdmin 成功后令牌必存在)
|
|
503
|
+
const [row] = await app.database.get("token", { token: token! });
|
|
504
|
+
expect(row).toBeUndefined();
|
|
505
|
+
// 移植偏差说明:上游 logout 以 setAuth(this, null) 显式清空登录态,
|
|
506
|
+
// 本仓改为传 undefined,命中 setAuth 的默认参数(沿用 client.auth),
|
|
507
|
+
// 故当前行为是重发 user 数据而非下发 null;此处按实际行为断言
|
|
508
|
+
expect(admin.client.auth?.id).toBe(0);
|
|
509
|
+
const data = admin.lastUserData();
|
|
510
|
+
expect(data?.name).toBe("root");
|
|
511
|
+
admin.close();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("setAuth 对匿名客户端下发 null(登出数据通道)", async () => {
|
|
515
|
+
const anonymous = new TestClient(service());
|
|
516
|
+
await app.auth.setAuth(anonymous.client);
|
|
517
|
+
await tick();
|
|
518
|
+
expect(anonymous.lastUserData()).toBeNull();
|
|
519
|
+
anonymous.close();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("user/update 修改资料(密码加哈希、配置透传)", async () => {
|
|
523
|
+
const anonymous = new TestClient(service());
|
|
524
|
+
expect(
|
|
525
|
+
(await anonymous.call("user/update", [{ name: "x" }])).error,
|
|
526
|
+
).toContain("请先登录");
|
|
527
|
+
anonymous.close();
|
|
528
|
+
|
|
529
|
+
const admin = await loginAdmin();
|
|
530
|
+
const response = await admin.call("user/update", [
|
|
531
|
+
{ name: "renamed", password: "new-pass", config: { theme: "dark" } },
|
|
532
|
+
]);
|
|
533
|
+
expect(response.error).toBeUndefined();
|
|
534
|
+
// passive 更新:本地登录态同步但不推送数据
|
|
535
|
+
expect(admin.client.auth?.name).toBe("renamed");
|
|
536
|
+
const [row] = await app.database.get("user", { id: 0 });
|
|
537
|
+
expect(row?.name).toBe("renamed");
|
|
538
|
+
expect(row?.password?.startsWith("pbkdf2$")).toBe(true);
|
|
539
|
+
expect(row?.config).toEqual({ theme: "dark" });
|
|
540
|
+
admin.close();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("user/unbind 解绑平台账号的三种分支", async () => {
|
|
544
|
+
const anonymous = new TestClient(service());
|
|
545
|
+
expect(
|
|
546
|
+
(await anonymous.call("user/unbind", ["mock", "111"])).error,
|
|
547
|
+
).toContain("请先登录");
|
|
548
|
+
anonymous.close();
|
|
549
|
+
|
|
550
|
+
// 以 alice 身份登录
|
|
551
|
+
const alice = new TestClient(service());
|
|
552
|
+
const grant = await alice.call("login/platform", ["mock", "111"]);
|
|
553
|
+
const code = (grant.value as { token: string }).token;
|
|
554
|
+
await app.mock.client("111").receive(code);
|
|
555
|
+
await tick();
|
|
556
|
+
expect(alice.client.auth?.id).toBe(aliceId);
|
|
557
|
+
|
|
558
|
+
// 绑定不存在
|
|
559
|
+
expect(
|
|
560
|
+
(await alice.call("user/unbind", ["mock", "000"])).error,
|
|
561
|
+
).toContain("绑定不存在");
|
|
562
|
+
|
|
563
|
+
// 仅剩一个自绑定(主账号):拒绝解绑
|
|
564
|
+
expect(
|
|
565
|
+
(await alice.call("user/unbind", ["mock", "111"])).error,
|
|
566
|
+
).toContain("无法解除绑定");
|
|
567
|
+
|
|
568
|
+
// 追加第二个自绑定后可解绑
|
|
569
|
+
await app.database.create("binding", {
|
|
570
|
+
aid: aliceId,
|
|
571
|
+
bid: aliceId,
|
|
572
|
+
platform: "mock",
|
|
573
|
+
pid: "333",
|
|
574
|
+
});
|
|
575
|
+
expect(
|
|
576
|
+
(await alice.call("user/unbind", ["mock", "333"])).error,
|
|
577
|
+
).toBeUndefined();
|
|
578
|
+
const [gone] = await app.database.get("binding", {
|
|
579
|
+
platform: "mock",
|
|
580
|
+
pid: "333",
|
|
581
|
+
});
|
|
582
|
+
expect(gone).toBeUndefined();
|
|
583
|
+
|
|
584
|
+
// 绑到他人名下(aid !== bid):解绑时改回其主账号
|
|
585
|
+
await app.database.create("binding", {
|
|
586
|
+
aid: aliceId,
|
|
587
|
+
bid: bobId,
|
|
588
|
+
platform: "mock",
|
|
589
|
+
pid: "444",
|
|
590
|
+
});
|
|
591
|
+
expect(
|
|
592
|
+
(await alice.call("user/unbind", ["mock", "444"])).error,
|
|
593
|
+
).toBeUndefined();
|
|
594
|
+
const [moved] = await app.database.get("binding", {
|
|
595
|
+
platform: "mock",
|
|
596
|
+
pid: "444",
|
|
597
|
+
});
|
|
598
|
+
expect(moved?.aid).toBe(bobId);
|
|
599
|
+
alice.close();
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
});
|