@koishi-ce/plugin-auth 1.0.0

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/dist/index.css ADDED
@@ -0,0 +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-d245ddd5]{justify-content:center;display:flex}.block{display:block}.h1{height:.25rem}.h2{height:.5rem}.flex{display:flex}.text-center{text-align:center}
2
+ /*$vite$:1*/
package/dist/index.js ADDED
@@ -0,0 +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};
@@ -0,0 +1,8 @@
1
+ - $description: 管理员设置
2
+ admin:
3
+ enabled: 启用管理员账号。
4
+ username: 管理员用户名。
5
+ password: 管理员密码。
6
+ - $description: 高级设置
7
+ authTokenExpire: 用户令牌有效期。
8
+ loginTokenExpire: 登录令牌有效期。
package/lib/index.d.ts ADDED
@@ -0,0 +1,123 @@
1
+ import { Binding, Context, Schema, Service, User } from "@koishi-ce/koishi";
2
+ import { Client, DataService } from "@koishi-ce/console";
3
+ //#region src/index.d.ts
4
+ declare module "@koishi-ce/koishi" {
5
+ interface Context {
6
+ auth: AuthService;
7
+ }
8
+ interface User {
9
+ password: string;
10
+ /** 客户端控制台配置的 JSON 快照(内容仅由浏览器端解释,服务端透传;建表初始为 null) */
11
+ config: object | null;
12
+ }
13
+ interface Tables {
14
+ token: LoginToken;
15
+ }
16
+ }
17
+ declare module "@koishi-ce/console" {
18
+ interface Client {
19
+ auth?: Auth | undefined;
20
+ }
21
+ namespace Console {
22
+ interface Services {
23
+ user: DataService<AuthData>;
24
+ }
25
+ }
26
+ interface Events {
27
+ "login/platform"(this: Client, platform: string, pid: string): Promise<UserLogin>;
28
+ "login/password"(this: Client, name: string, password: string): void;
29
+ "login/token"(this: Client, id: number, token: string): void;
30
+ "user/delete-token"(this: Client, inc: number): void;
31
+ "user/unbind"(this: Client, platform: string, pid: string): void;
32
+ "user/update"(this: Client, data: UserUpdate): void;
33
+ "user/logout"(this: Client): void;
34
+ }
35
+ }
36
+ /** token 表记录:一次登录会话的令牌及其来源信息。 */
37
+ interface LoginToken {
38
+ /** 自增主键(删除指定会话用) */
39
+ inc: number;
40
+ /** 所属用户 id */
41
+ id: number;
42
+ /** 登录方式 */
43
+ type: LoginType;
44
+ /** 随机令牌(唯一索引) */
45
+ token: string;
46
+ /** 过期时间戳(毫秒) */
47
+ expiredAt: number;
48
+ createdAt: Date;
49
+ lastUsedAt: Date;
50
+ /** 登录时的 User-Agent */
51
+ userAgent: string;
52
+ /** 登录时的来源 IP */
53
+ address: string;
54
+ }
55
+ /** 下发到客户端的登录态(user 数据服务的单条形态,不含 tokens/bindings 明细)。 */
56
+ type Auth = Pick<LoginToken, "token" | "expiredAt"> & Pick<User, "id" | "name" | "authority" | "config">;
57
+ /** user 数据服务下发给客户端的完整鉴权数据:登录态 + 会话列表 + 绑定列表。 */
58
+ interface AuthData extends Auth {
59
+ tokens: Omit<LoginToken, "token" | "id">[];
60
+ bindings: Omit<Binding, "aid">[];
61
+ }
62
+ /** 登录方式:平台验证码 / 用户密码 / 已存令牌续期。 */
63
+ type LoginType = "platform" | "password" | "token";
64
+ /**
65
+ * 生成指定长度的随机令牌字符串。
66
+ * @param length 令牌长度,默认 40
67
+ * @returns 从字符表随机取字符拼接成的字符串
68
+ */
69
+ declare function randomId(length?: number): string;
70
+ /** login/platform 事件的返回值:待登录用户信息 + 一次性验证码及其过期时间。 */
71
+ interface UserLogin extends Pick<User, "id" | "name"> {
72
+ token: string;
73
+ expiredAt: number;
74
+ }
75
+ /** user/update 事件允许修改的用户字段。 */
76
+ type UserUpdate = Partial<Pick<User, "name" | "password" | "config">>;
77
+ /**
78
+ * 鉴权服务:维护控制台客户端的登录态。
79
+ *
80
+ * 构造时扩展 user 表(password / config 字段)与 token 表,注册浏览器端
81
+ * 的登录与用户管理事件;登录成功后把 Auth 写入 client.auth 并以 user
82
+ * 数据服务全量下发。依赖 console 与 database 服务。
83
+ */
84
+ declare class AuthService extends Service {
85
+ static inject: string[];
86
+ static filter: boolean;
87
+ static Admin: Schema<AuthService.Admin>;
88
+ static Config: Schema<AuthService.Config>;
89
+ config: AuthService.Config;
90
+ constructor(ctx: Context, config: AuthService.Config);
91
+ /** 启动时按配置确保管理员账户(id = 0)存在。 */
92
+ start(): Promise<void>;
93
+ /**
94
+ * 设置客户端登录态并全量下发 user 数据。
95
+ * @param client 目标 WebSocket 客户端
96
+ * @param auth 新登录态;缺省沿用 client.auth,传 undefined 表示登出
97
+ * @param passive 为 true 时只写 client.auth,不推送数据也不刷新
98
+ * (已登录状态下修改资料后仅同步本地时使用)
99
+ */
100
+ setAuth(client: Client, auth?: any, passive?: boolean): Promise<void>;
101
+ /**
102
+ * 为用户签发新令牌并立即让客户端登录。
103
+ * 从 WebSocket 升级请求中提取 User-Agent 与来源 IP 一并落库,
104
+ * 过期时长由 authTokenExpire 配置决定。
105
+ */
106
+ createToken(client: Client, type: LoginType, user: Pick<User, "id" | "name" | "authority" | "config">): Promise<void>;
107
+ /** 注册全部登录 / 用户管理事件与权限拦截逻辑(构造时调用)。 */
108
+ initLogin(): void;
109
+ }
110
+ declare namespace AuthService {
111
+ interface Admin {
112
+ enabled?: boolean;
113
+ username?: string;
114
+ password?: string;
115
+ }
116
+ interface Config {
117
+ admin: Admin;
118
+ authTokenExpire: number;
119
+ loginTokenExpire: number;
120
+ }
121
+ }
122
+ //#endregion
123
+ export { Auth, LoginToken, UserLogin, UserUpdate, AuthService as default, randomId };
package/lib/index.mjs ADDED
@@ -0,0 +1,320 @@
1
+ import { createHash, pbkdf2Sync, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { Schema, Service, Time, omit } from "@koishi-ce/koishi";
4
+ import zhCN from "./assets/zh-CN-KbNPwFxh.yml";
5
+ //#region src/index.ts
6
+ /**
7
+ * auth 插件(服务端):控制台登录鉴权。
8
+ *
9
+ * 向应用注入 ctx.auth 服务(AuthService):扩展 user 表的 password 字段
10
+ * 并建立 token 会话表,支持用户密码 / 平台验证码 / 已存令牌三种登录方式,
11
+ * 维护每个 WebSocket 客户端的登录态(client.auth),并按登录用户的权限
12
+ * 拦截 console 事件。浏览器端对应实现在 ../client/(登录页、个人资料页、
13
+ * 配置同步对话框)。
14
+ */
15
+ const letters = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
16
+ /**
17
+ * 生成指定长度的随机令牌字符串。
18
+ * @param length 令牌长度,默认 40
19
+ * @returns 从字符表随机取字符拼接成的字符串
20
+ */
21
+ function randomId(length = 40) {
22
+ return Array(length).fill(0).map(() => letters[Math.floor(Math.random() * 62)]).join("");
23
+ }
24
+ /** PBKDF2-HMAC-SHA256 迭代次数(OWASP 2023 建议 600k;登录低频,开销可接受) */
25
+ const PBKDF2_ROUNDS = 6e5;
26
+ /** 新格式密码哈希:`pbkdf2$<rounds>$<salt-hex>$<dk-hex>`(加盐 + 慢哈希)。 */
27
+ function toHash(password) {
28
+ const salt = randomBytes(16);
29
+ const dk = pbkdf2Sync(password, salt, PBKDF2_ROUNDS, 32, "sha256");
30
+ return `pbkdf2$${PBKDF2_ROUNDS}$${salt.toString("hex")}$${dk.toString("hex")}`;
31
+ }
32
+ /**
33
+ * 校验明文密码与库中存储是否匹配。
34
+ *
35
+ * 兼容两种存储格式:
36
+ * - `pbkdf2$...` 新格式:按存储的盐与迭代次数重派生,恒定时间比较;
37
+ * - 64 位十六进制旧格式:历史上无盐 SHA-256,仅用于校验(命中后由调用方
38
+ * 透明升级为 PBKDF2),同样以恒定时间比较。
39
+ */
40
+ function verifyPassword(password, stored) {
41
+ if (stored.startsWith("pbkdf2$")) {
42
+ const [, rounds, saltHex, dkHex] = stored.split("$");
43
+ if (!rounds || !saltHex || !dkHex) return false;
44
+ const expected = Buffer.from(dkHex, "hex");
45
+ const actual = pbkdf2Sync(password, Buffer.from(saltHex, "hex"), Number(rounds), expected.length, "sha256");
46
+ return timingSafeEqual(actual, expected);
47
+ }
48
+ if (!/^[0-9a-f]{64}$/i.test(stored)) return false;
49
+ const actual = createHash("sha256").update(password).digest();
50
+ return timingSafeEqual(actual, Buffer.from(stored, "hex"));
51
+ }
52
+ /**
53
+ * 鉴权服务:维护控制台客户端的登录态。
54
+ *
55
+ * 构造时扩展 user 表(password / config 字段)与 token 表,注册浏览器端
56
+ * 的登录与用户管理事件;登录成功后把 Auth 写入 client.auth 并以 user
57
+ * 数据服务全量下发。依赖 console 与 database 服务。
58
+ */
59
+ var AuthService = class AuthService extends Service {
60
+ static inject = ["console", "database"];
61
+ static filter = false;
62
+ static Admin = Schema.intersect([Schema.object({ enabled: Schema.boolean().default(true) }), Schema.union([Schema.object({
63
+ enabled: Schema.const(true),
64
+ username: Schema.string().default("admin"),
65
+ password: Schema.string().role("secret").required()
66
+ }), Schema.object({})])]);
67
+ static Config = Schema.intersect([Schema.object({ admin: AuthService.Admin }), Schema.object({
68
+ authTokenExpire: Schema.natural().role("ms").default(Time.week).min(Time.hour),
69
+ loginTokenExpire: Schema.natural().role("ms").default(Time.minute * 10).min(Time.minute)
70
+ })]).i18n({ "zh-CN": zhCN });
71
+ config;
72
+ constructor(ctx, config) {
73
+ super(ctx, "auth");
74
+ this.config = config;
75
+ ctx.model.extend("user", {
76
+ password: "string(255)",
77
+ config: {
78
+ type: "json",
79
+ length: 65535,
80
+ initial: null
81
+ }
82
+ });
83
+ ctx.model.extend("token", {
84
+ inc: "unsigned",
85
+ id: "unsigned",
86
+ type: "string(255)",
87
+ token: "string(255)",
88
+ expiredAt: "unsigned(8)",
89
+ createdAt: "timestamp",
90
+ lastUsedAt: "timestamp",
91
+ userAgent: "string(255)",
92
+ address: "string(255)"
93
+ }, {
94
+ primary: "inc",
95
+ autoInc: true,
96
+ unique: ["token"]
97
+ });
98
+ ctx.console.addEntry({
99
+ dev: resolve(__dirname, "../client/index.ts"),
100
+ prod: resolve(__dirname, "../dist")
101
+ });
102
+ this.initLogin();
103
+ }
104
+ /** 启动时按配置确保管理员账户(id = 0)存在。 */
105
+ async start() {
106
+ const { enabled, username, password } = this.config.admin;
107
+ if (!enabled) return;
108
+ this.ctx.logger.info("creating admin account");
109
+ if (!username || !password) return;
110
+ await this.ctx.database.upsert("user", [{
111
+ id: 0,
112
+ name: username,
113
+ authority: 5,
114
+ password: toHash(password),
115
+ createdAt: /* @__PURE__ */ new Date()
116
+ }]);
117
+ }
118
+ /**
119
+ * 设置客户端登录态并全量下发 user 数据。
120
+ * @param client 目标 WebSocket 客户端
121
+ * @param auth 新登录态;缺省沿用 client.auth,传 undefined 表示登出
122
+ * @param passive 为 true 时只写 client.auth,不推送数据也不刷新
123
+ * (已登录状态下修改资料后仅同步本地时使用)
124
+ */
125
+ async setAuth(client, auth = client.auth, passive = false) {
126
+ client.auth = auth;
127
+ if (passive) return;
128
+ if (auth) {
129
+ const bindings = await this.ctx.database.get("binding", { aid: auth.id });
130
+ bindings.forEach((binding) => delete binding.aid);
131
+ const tokens = await this.ctx.database.get("token", { id: auth.id });
132
+ tokens.reverse().forEach((login) => {
133
+ delete login.id;
134
+ delete login.token;
135
+ });
136
+ client.send({
137
+ type: "data",
138
+ body: {
139
+ key: "user",
140
+ value: {
141
+ ...auth,
142
+ bindings,
143
+ tokens
144
+ }
145
+ }
146
+ });
147
+ } else client.send({
148
+ type: "data",
149
+ body: {
150
+ key: "user",
151
+ value: null
152
+ }
153
+ });
154
+ client.ctx.emit("console/connection", client);
155
+ client.refresh();
156
+ }
157
+ /**
158
+ * 为用户签发新令牌并立即让客户端登录。
159
+ * 从 WebSocket 升级请求中提取 User-Agent 与来源 IP 一并落库,
160
+ * 过期时长由 authTokenExpire 配置决定。
161
+ */
162
+ async createToken(client, type, user) {
163
+ const { headers, socket } = client.request;
164
+ const createdAt = /* @__PURE__ */ new Date();
165
+ const lastUsedAt = /* @__PURE__ */ new Date();
166
+ const userAgent = headers["user-agent"]?.toString();
167
+ const address = headers["x-forwarded-for"]?.toString() || socket.remoteAddress;
168
+ const expiredAt = Date.now() + this.config.authTokenExpire;
169
+ const token = randomId();
170
+ await this.ctx.database.create("token", {
171
+ id: user.id,
172
+ type,
173
+ expiredAt,
174
+ token,
175
+ createdAt,
176
+ lastUsedAt,
177
+ userAgent,
178
+ address
179
+ });
180
+ await this.setAuth(client, {
181
+ ...user,
182
+ expiredAt,
183
+ token
184
+ });
185
+ }
186
+ /** 注册全部登录 / 用户管理事件与权限拦截逻辑(构造时调用)。 */
187
+ initLogin() {
188
+ const self = this;
189
+ const { ctx, config } = this;
190
+ const states = {};
191
+ ctx.console.addListener("login/password", async function(name, password) {
192
+ const [user] = await ctx.database.get("user", { name }, [
193
+ "password",
194
+ "name",
195
+ "id",
196
+ "authority",
197
+ "config"
198
+ ]);
199
+ if (!user?.password || !verifyPassword(password, user.password)) throw new Error("用户名或密码错误。");
200
+ if (!user.password.startsWith("pbkdf2$")) await ctx.database.set("user", user.id, { password: toHash(password) });
201
+ await self.createToken(this, "password", omit(user, ["password"]));
202
+ });
203
+ ctx.console.addListener("login/token", async function(aid, token) {
204
+ const [data] = await ctx.database.get("token", {
205
+ id: aid,
206
+ token
207
+ }, ["expiredAt"]);
208
+ if (!data || data.expiredAt <= Date.now()) throw new Error("令牌已失效。");
209
+ const [user] = await ctx.database.get("user", { id: aid }, [
210
+ "id",
211
+ "name",
212
+ "authority",
213
+ "config"
214
+ ]);
215
+ if (!user) throw new Error("用户不存在。");
216
+ await ctx.database.set("token", { token }, { lastUsedAt: /* @__PURE__ */ new Date() });
217
+ await self.setAuth(this, {
218
+ ...user,
219
+ ...data,
220
+ token
221
+ });
222
+ });
223
+ ctx.console.addListener("login/platform", async function(platform, userId) {
224
+ const user = await ctx.database.getUser(platform, userId, ["id", "name"]);
225
+ if (!user) throw new Error("找不到此账户。");
226
+ if (this.auth?.id === user.id) throw new Error("你已经绑定了此账户。");
227
+ const key = `${platform}:${userId}`;
228
+ const token = Math.random().toString().slice(2, 8);
229
+ const expiredAt = Date.now() + config.loginTokenExpire;
230
+ states[key] = [
231
+ token,
232
+ expiredAt,
233
+ this
234
+ ];
235
+ const listener = () => {
236
+ delete states[key];
237
+ dispose();
238
+ this.socket.removeEventListener("close", dispose);
239
+ };
240
+ const dispose = ctx.setTimeout(() => {
241
+ const state = states[key];
242
+ if (state && state[1] >= Date.now()) listener();
243
+ }, config.loginTokenExpire);
244
+ this.socket.addEventListener("close", listener);
245
+ return {
246
+ id: user.id,
247
+ name: user.name,
248
+ token,
249
+ expiredAt
250
+ };
251
+ });
252
+ ctx.middleware(async (session, next) => {
253
+ const state = states[session.uid];
254
+ if (!state || state[0] !== session.stripped.content) return next();
255
+ const { platform, userId: pid } = session;
256
+ if (!pid) return next();
257
+ if (state[2].auth) {
258
+ await ctx.database.set("binding", {
259
+ platform,
260
+ pid
261
+ }, { aid: state[2].auth.id });
262
+ return self.setAuth(state[2], state[2].auth);
263
+ } else {
264
+ const user = await session.observeUser([
265
+ "id",
266
+ "name",
267
+ "authority",
268
+ "config"
269
+ ]);
270
+ return self.createToken(state[2], "platform", user);
271
+ }
272
+ }, true);
273
+ ctx.on("console/intercept", async (client, listener) => {
274
+ if (!listener.authority) return false;
275
+ if (!client.auth) return true;
276
+ if (client.auth.expiredAt <= Date.now()) return true;
277
+ if (client.auth.authority < listener.authority) return true;
278
+ return false;
279
+ });
280
+ ctx.console.addListener("user/delete-token", async function(inc) {
281
+ if (!this.auth) throw new Error("请先登录。");
282
+ const [data] = await ctx.database.get("token", {
283
+ id: this.auth.id,
284
+ inc
285
+ });
286
+ if (!data) throw new Error("令牌不存在。");
287
+ await ctx.database.remove("token", { inc });
288
+ await self.setAuth(this);
289
+ });
290
+ ctx.console.addListener("user/logout", async function() {
291
+ if (this.auth) await ctx.database.remove("token", { token: this.auth.token });
292
+ await self.setAuth(this, void 0);
293
+ });
294
+ ctx.console.addListener("user/update", async function(data) {
295
+ if (!this.auth) throw new Error("请先登录。");
296
+ if (data.password) data.password = toHash(data.password);
297
+ await ctx.database.set("user", { id: this.auth.id }, data);
298
+ Object.assign(this.auth, data);
299
+ await self.setAuth(this, void 0, true);
300
+ });
301
+ ctx.console.addListener("user/unbind", async function(platform, pid) {
302
+ if (!this.auth) throw new Error("请先登录。");
303
+ const bindings = await ctx.database.get("binding", { aid: this.auth.id });
304
+ const binding = bindings.find((item) => item.platform === platform && item.pid === pid);
305
+ if (!binding) throw new Error("绑定不存在。");
306
+ if (binding.aid !== binding.bid) await ctx.database.set("binding", {
307
+ platform,
308
+ pid
309
+ }, { aid: binding.bid });
310
+ else if (bindings.filter((item) => item.aid === item.bid).length === 1) throw new Error("无法解除绑定。");
311
+ else await ctx.database.remove("binding", {
312
+ platform,
313
+ pid
314
+ });
315
+ await self.setAuth(this);
316
+ });
317
+ }
318
+ };
319
+ //#endregion
320
+ export { AuthService as default, randomId };
@@ -0,0 +1,10 @@
1
+ -
2
+ $description: 管理员设置
3
+ admin:
4
+ enabled: 启用管理员账号。
5
+ username: 管理员用户名。
6
+ password: 管理员密码。
7
+ -
8
+ $description: 高级设置
9
+ authTokenExpire: 用户令牌有效期。
10
+ loginTokenExpire: 登录令牌有效期。
@@ -0,0 +1,10 @@
1
+ -
2
+ $description: 管理员设置
3
+ admin:
4
+ enabled: 启用管理员账号。
5
+ username: 管理员用户名。
6
+ password: 管理员密码。
7
+ -
8
+ $description: 高级设置
9
+ authTokenExpire: 用户令牌有效期。
10
+ loginTokenExpire: 登录令牌有效期。
@@ -0,0 +1,10 @@
1
+ -
2
+ $description: 管理员设置
3
+ admin:
4
+ enabled: 启用管理员账号。
5
+ username: 管理员用户名。
6
+ password: 管理员密码。
7
+ -
8
+ $description: 高级设置
9
+ authTokenExpire: 用户令牌有效期。
10
+ loginTokenExpire: 登录令牌有效期。
@@ -0,0 +1,10 @@
1
+ -
2
+ $description: 管理员设置
3
+ admin:
4
+ enabled: 启用管理员账号。
5
+ username: 管理员用户名。
6
+ password: 管理员密码。
7
+ -
8
+ $description: 高级设置
9
+ authTokenExpire: 用户令牌有效期。
10
+ loginTokenExpire: 登录令牌有效期。
@@ -0,0 +1,10 @@
1
+ -
2
+ $description: 管理员设置
3
+ admin:
4
+ enabled: 启用管理员账号。
5
+ username: 管理员用户名。
6
+ password: 管理员密码。
7
+ -
8
+ $description: 高级设置
9
+ authTokenExpire: 用户令牌有效期。
10
+ loginTokenExpire: 登录令牌有效期。
@@ -0,0 +1,8 @@
1
+ - $description: 管理员设置
2
+ admin:
3
+ enabled: 启用管理员账号。
4
+ username: 管理员用户名。
5
+ password: 管理员密码。
6
+ - $description: 高级设置
7
+ authTokenExpire: 用户令牌有效期。
8
+ loginTokenExpire: 登录令牌有效期。
@@ -0,0 +1,10 @@
1
+ -
2
+ $description: 管理員設定
3
+ admin:
4
+ enabled: 啟用管理員賬號。
5
+ username: 管理員用戶名。
6
+ password: 管理員密碼。
7
+ -
8
+ $description: 高級設定
9
+ authTokenExpire: 用戶令牌有效期。
10
+ loginTokenExpire: 登錄令牌有效期。
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@koishi-ce/plugin-auth",
3
+ "description": "Console User Authentication",
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "main": "lib/index.mjs",
7
+ "typings": "lib/index.d.ts",
8
+ "files": [
9
+ "lib",
10
+ "dist",
11
+ "src",
12
+ "locales"
13
+ ],
14
+ "contributors": [
15
+ "Shigma <shigma10826@gmail.com>",
16
+ "Oppenheymu <oppenheymu@gmail.com>"
17
+ ],
18
+ "license": "AGPL-3.0",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Koishi-CE/koishi.git",
22
+ "directory": "plugins/webui/auth"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/Koishi-CE/koishi/issues"
26
+ },
27
+ "homepage": "https://koishi.chat/plugins/console/auth.html",
28
+ "keywords": [
29
+ "bot",
30
+ "chatbot",
31
+ "koishi",
32
+ "plugin",
33
+ "authentication",
34
+ "login",
35
+ "console"
36
+ ],
37
+ "koishi": {
38
+ "public": [
39
+ "dist"
40
+ ],
41
+ "description": {
42
+ "en": "Console user authentication (recommended for console deployed to public network)",
43
+ "zh": "控制台用户登录 (如果你的控制台要部署到公网,建议安装此插件以确保安全性)"
44
+ },
45
+ "service": {
46
+ "required": [
47
+ "database",
48
+ "console"
49
+ ]
50
+ }
51
+ },
52
+ "peerDependencies": {
53
+ "@koishijs/plugin-console": "^5.30.11",
54
+ "koishi": "^4.18.11"
55
+ },
56
+ "devDependencies": {
57
+ "@koishi-ce/client": "^1.0.0"
58
+ },
59
+ "dependencies": {
60
+ "@koishi-ce/console": "^1.0.0"
61
+ },
62
+ "exports": {
63
+ ".": {
64
+ "source": "./src/index.ts",
65
+ "types": "./lib/index.d.ts",
66
+ "import": "./lib/index.mjs",
67
+ "default": "./lib/index.mjs"
68
+ }
69
+ }
70
+ }
package/src/index.ts ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * auth 插件(服务端):控制台登录鉴权。
3
+ *
4
+ * 向应用注入 ctx.auth 服务(AuthService):扩展 user 表的 password 字段
5
+ * 并建立 token 会话表,支持用户密码 / 平台验证码 / 已存令牌三种登录方式,
6
+ * 维护每个 WebSocket 客户端的登录态(client.auth),并按登录用户的权限
7
+ * 拦截 console 事件。浏览器端对应实现在 ../client/(登录页、个人资料页、
8
+ * 配置同步对话框)。
9
+ */
10
+
11
+ import {
12
+ createHash,
13
+ pbkdf2Sync,
14
+ randomBytes,
15
+ timingSafeEqual,
16
+ } from "node:crypto";
17
+ import { resolve } from "node:path";
18
+ import type { Client, DataService } from "@koishi-ce/console";
19
+ import {
20
+ type Binding,
21
+ type Context,
22
+ omit,
23
+ Schema,
24
+ Service,
25
+ Time,
26
+ type User,
27
+ } from "@koishi-ce/koishi";
28
+ import zhCN from "../locales/zh-CN.yml";
29
+
30
+ declare module "@koishi-ce/koishi" {
31
+ interface Context {
32
+ auth: AuthService;
33
+ }
34
+
35
+ interface User {
36
+ password: string;
37
+ /** 客户端控制台配置的 JSON 快照(内容仅由浏览器端解释,服务端透传;建表初始为 null) */
38
+ config: object | null;
39
+ }
40
+
41
+ interface Tables {
42
+ token: LoginToken;
43
+ }
44
+ }
45
+
46
+ declare module "@koishi-ce/console" {
47
+ interface Client {
48
+ // setAuth 会以 undefined 清除登录态,exactOptionalPropertyTypes 下
49
+ // 需显式允许 undefined
50
+ auth?: Auth | undefined;
51
+ }
52
+
53
+ namespace Console {
54
+ export interface Services {
55
+ user: DataService<AuthData>;
56
+ }
57
+ }
58
+
59
+ interface Events {
60
+ "login/platform"(
61
+ this: Client,
62
+ platform: string,
63
+ pid: string,
64
+ ): Promise<UserLogin>;
65
+ "login/password"(this: Client, name: string, password: string): void;
66
+ "login/token"(this: Client, id: number, token: string): void;
67
+ "user/delete-token"(this: Client, inc: number): void;
68
+ "user/unbind"(this: Client, platform: string, pid: string): void;
69
+ "user/update"(this: Client, data: UserUpdate): void;
70
+ "user/logout"(this: Client): void;
71
+ }
72
+ }
73
+
74
+ /** token 表记录:一次登录会话的令牌及其来源信息。 */
75
+ export interface LoginToken {
76
+ /** 自增主键(删除指定会话用) */
77
+ inc: number;
78
+ /** 所属用户 id */
79
+ id: number;
80
+ /** 登录方式 */
81
+ type: LoginType;
82
+ /** 随机令牌(唯一索引) */
83
+ token: string;
84
+ /** 过期时间戳(毫秒) */
85
+ expiredAt: number;
86
+ createdAt: Date;
87
+ lastUsedAt: Date;
88
+ /** 登录时的 User-Agent */
89
+ userAgent: string;
90
+ /** 登录时的来源 IP */
91
+ address: string;
92
+ }
93
+
94
+ /** 下发到客户端的登录态(user 数据服务的单条形态,不含 tokens/bindings 明细)。 */
95
+ export type Auth = Pick<LoginToken, "token" | "expiredAt"> &
96
+ Pick<User, "id" | "name" | "authority" | "config">;
97
+
98
+ /** user 数据服务下发给客户端的完整鉴权数据:登录态 + 会话列表 + 绑定列表。 */
99
+ interface AuthData extends Auth {
100
+ tokens: Omit<LoginToken, "token" | "id">[];
101
+ bindings: Omit<Binding, "aid">[];
102
+ }
103
+
104
+ /** 登录方式:平台验证码 / 用户密码 / 已存令牌续期。 */
105
+ type LoginType = "platform" | "password" | "token";
106
+
107
+ // 随机令牌的字符表(数字 + 大小写字母)
108
+ const letters =
109
+ "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
110
+
111
+ /**
112
+ * 生成指定长度的随机令牌字符串。
113
+ * @param length 令牌长度,默认 40
114
+ * @returns 从字符表随机取字符拼接成的字符串
115
+ */
116
+ export function randomId(length = 40) {
117
+ return Array(length)
118
+ .fill(0)
119
+ .map(() => letters[Math.floor(Math.random() * letters.length)])
120
+ .join("");
121
+ }
122
+
123
+ /** login/platform 事件的返回值:待登录用户信息 + 一次性验证码及其过期时间。 */
124
+ export interface UserLogin extends Pick<User, "id" | "name"> {
125
+ token: string;
126
+ expiredAt: number;
127
+ }
128
+
129
+ /** user/update 事件允许修改的用户字段。 */
130
+ export type UserUpdate = Partial<Pick<User, "name" | "password" | "config">>;
131
+
132
+ /** PBKDF2-HMAC-SHA256 迭代次数(OWASP 2023 建议 600k;登录低频,开销可接受) */
133
+ const PBKDF2_ROUNDS = 600_000;
134
+
135
+ /** 新格式密码哈希:`pbkdf2$<rounds>$<salt-hex>$<dk-hex>`(加盐 + 慢哈希)。 */
136
+ function toHash(password: string) {
137
+ const salt = randomBytes(16);
138
+ const dk = pbkdf2Sync(password, salt, PBKDF2_ROUNDS, 32, "sha256");
139
+ return `pbkdf2$${PBKDF2_ROUNDS}$${salt.toString("hex")}$${dk.toString("hex")}`;
140
+ }
141
+
142
+ /**
143
+ * 校验明文密码与库中存储是否匹配。
144
+ *
145
+ * 兼容两种存储格式:
146
+ * - `pbkdf2$...` 新格式:按存储的盐与迭代次数重派生,恒定时间比较;
147
+ * - 64 位十六进制旧格式:历史上无盐 SHA-256,仅用于校验(命中后由调用方
148
+ * 透明升级为 PBKDF2),同样以恒定时间比较。
149
+ */
150
+ function verifyPassword(password: string, stored: string): boolean {
151
+ if (stored.startsWith("pbkdf2$")) {
152
+ const [, rounds, saltHex, dkHex] = stored.split("$");
153
+ if (!rounds || !saltHex || !dkHex) return false;
154
+ const expected = Buffer.from(dkHex, "hex");
155
+ const actual = pbkdf2Sync(
156
+ password,
157
+ Buffer.from(saltHex, "hex"),
158
+ Number(rounds),
159
+ expected.length,
160
+ "sha256",
161
+ );
162
+ // 长度已按 expected.length 派生,恒定时间比较不会抛错
163
+ return timingSafeEqual(actual, expected);
164
+ }
165
+ // 旧格式:裸 SHA-256 十六进制。
166
+ // 无盐 SHA-256 只用于存量密码的**校验**(不可删,删了旧用户无法登录),
167
+ // 命中后由调用方透明升级为 PBKDF2;新建密码一律走 toHash(pbkdf2$ 格式),
168
+ // 故此处非弱哈希存储,属误报(Default setup 不支持注释抑制,须在平台 dismiss)。
169
+ 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"));
172
+ }
173
+
174
+ /**
175
+ * 鉴权服务:维护控制台客户端的登录态。
176
+ *
177
+ * 构造时扩展 user 表(password / config 字段)与 token 表,注册浏览器端
178
+ * 的登录与用户管理事件;登录成功后把 Auth 写入 client.auth 并以 user
179
+ * 数据服务全量下发。依赖 console 与 database 服务。
180
+ */
181
+ class AuthService extends Service {
182
+ static inject = ["console", "database"];
183
+
184
+ // erasableSyntaxOnly 禁止携带运行时值的 namespace:原 `namespace AuthService`
185
+ // 的导出值迁为静态成员(合并到 class 的 namespace 本就编译为静态属性,运行时等价)
186
+ static filter = false;
187
+
188
+ static Admin: Schema<AuthService.Admin> = Schema.intersect([
189
+ Schema.object({
190
+ enabled: Schema.boolean().default(true),
191
+ }),
192
+ Schema.union([
193
+ Schema.object({
194
+ enabled: Schema.const(true),
195
+ username: Schema.string().default("admin"),
196
+ password: Schema.string().role("secret").required(),
197
+ }),
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
+ });
219
+
220
+ // Service 基类已声明 config(T = any),此处覆盖为插件配置类型
221
+ override config: AuthService.Config;
222
+
223
+ constructor(ctx: Context, config: AuthService.Config) {
224
+ super(ctx, "auth");
225
+ this.config = config;
226
+
227
+ ctx.model.extend("user", {
228
+ password: "string(255)",
229
+ config: {
230
+ type: "json",
231
+ length: 65535,
232
+ initial: null,
233
+ },
234
+ });
235
+
236
+ ctx.model.extend(
237
+ "token",
238
+ {
239
+ inc: "unsigned",
240
+ id: "unsigned",
241
+ type: "string(255)",
242
+ token: "string(255)",
243
+ expiredAt: "unsigned(8)",
244
+ createdAt: "timestamp",
245
+ lastUsedAt: "timestamp",
246
+ userAgent: "string(255)",
247
+ address: "string(255)",
248
+ },
249
+ {
250
+ primary: "inc",
251
+ autoInc: true,
252
+ unique: ["token"],
253
+ },
254
+ );
255
+
256
+ ctx.console.addEntry({
257
+ dev: resolve(__dirname, "../client/index.ts"),
258
+ prod: resolve(__dirname, "../dist"),
259
+ });
260
+
261
+ this.initLogin();
262
+ }
263
+
264
+ /** 启动时按配置确保管理员账户(id = 0)存在。 */
265
+ override async start() {
266
+ const { enabled, username, password } = this.config.admin;
267
+ if (!enabled) return;
268
+ this.ctx.logger.info("creating admin account");
269
+ // enabled 分支的 Schema 已保证 username/password 存在(默认 admin / required),
270
+ // 此处守卫仅用于类型收窄
271
+ if (!username || !password) return;
272
+ await this.ctx.database.upsert("user", [
273
+ {
274
+ id: 0,
275
+ name: username,
276
+ authority: 5,
277
+ password: toHash(password),
278
+ createdAt: new Date(),
279
+ },
280
+ ]);
281
+ }
282
+
283
+ /**
284
+ * 设置客户端登录态并全量下发 user 数据。
285
+ * @param client 目标 WebSocket 客户端
286
+ * @param auth 新登录态;缺省沿用 client.auth,传 undefined 表示登出
287
+ * @param passive 为 true 时只写 client.auth,不推送数据也不刷新
288
+ * (已登录状态下修改资料后仅同步本地时使用)
289
+ */
290
+ async setAuth(client: Client, auth = client.auth, passive = false) {
291
+ client.auth = auth;
292
+ if (passive) return;
293
+ if (auth) {
294
+ const bindings = await this.ctx.database.get("binding", { aid: auth.id });
295
+ // 下发前剥离服务端字段;minato 模型字段必填,需按可删除的形状断言
296
+ bindings.forEach((binding) => delete (binding as Partial<Binding>).aid);
297
+ const tokens = await this.ctx.database.get("token", { id: auth.id });
298
+ tokens.reverse().forEach((login) => {
299
+ delete (login as Partial<LoginToken>).id;
300
+ delete (login as Partial<LoginToken>).token;
301
+ });
302
+ client.send({
303
+ type: "data",
304
+ body: { key: "user", value: { ...auth, bindings, tokens } },
305
+ });
306
+ } else {
307
+ client.send({ type: "data", body: { key: "user", value: null } });
308
+ }
309
+ client.ctx.emit("console/connection", client);
310
+ client.refresh();
311
+ }
312
+
313
+ /**
314
+ * 为用户签发新令牌并立即让客户端登录。
315
+ * 从 WebSocket 升级请求中提取 User-Agent 与来源 IP 一并落库,
316
+ * 过期时长由 authTokenExpire 配置决定。
317
+ */
318
+ async createToken(
319
+ client: Client,
320
+ type: LoginType,
321
+ user: Pick<User, "id" | "name" | "authority" | "config">,
322
+ ) {
323
+ // WebSocket 升级连接必带 HTTP 请求对象(见 console 服务端构造 Client 处)
324
+ const { headers, socket } = client.request;
325
+ const createdAt = new Date();
326
+ const lastUsedAt = new Date();
327
+ const userAgent = headers["user-agent"]?.toString();
328
+ const address =
329
+ headers["x-forwarded-for"]?.toString() || socket.remoteAddress;
330
+ const expiredAt = Date.now() + this.config.authTokenExpire;
331
+ const token = randomId();
332
+ // 请求头字段可能缺失,undefined 由 minato 归一化为 NULL 落库,不做默认值替换
333
+ await this.ctx.database.create("token", {
334
+ id: user.id,
335
+ type,
336
+ expiredAt,
337
+ token,
338
+ createdAt,
339
+ lastUsedAt,
340
+ userAgent: userAgent as string,
341
+ address: address as string,
342
+ });
343
+ await this.setAuth(client, { ...user, expiredAt, token });
344
+ }
345
+
346
+ /** 注册全部登录 / 用户管理事件与权限拦截逻辑(构造时调用)。 */
347
+ initLogin() {
348
+ const self = this;
349
+ const { ctx, config } = this;
350
+ // 平台验证码登录的进行中状态:键为 `${platform}:${userId}`,
351
+ // 值为 [验证码, 过期时间, 发起登录的客户端]
352
+ const states: Record<string, [string, number, Client]> = {};
353
+
354
+ // 用户密码登录:校验通过后签发新令牌;
355
+ // 命中旧的无盐 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
+ });
373
+
374
+ // 已存令牌续期登录:本地记录的令牌未过期即恢复登录态,
375
+ // 同时刷新该令牌的最后访问时间
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
+ });
392
+
393
+ // 平台账户登录(第一步):校验平台账号存在后生成一次性验证码,
394
+ // 用户把验证码发给任意机器人即可完成登录/绑定(见下方中间件)。
395
+ // 状态在验证码过期或客户端断开时清理
396
+ ctx.console.addListener(
397
+ "login/platform",
398
+ async function (platform, userId) {
399
+ const user = await ctx.database.getUser(platform, userId, [
400
+ "id",
401
+ "name",
402
+ ]);
403
+ if (!user) throw new Error("找不到此账户。");
404
+ if (this.auth?.id === user.id) throw new Error("你已经绑定了此账户。");
405
+
406
+ const key = `${platform}:${userId}`;
407
+ const token = Math.random().toString().slice(2, 8);
408
+ const expiredAt = Date.now() + config.loginTokenExpire;
409
+ states[key] = [token, expiredAt, this];
410
+
411
+ // 客户端断开或验证码超时即作废本次登录状态
412
+ const listener = () => {
413
+ delete states[key];
414
+ dispose();
415
+ this.socket.removeEventListener("close", dispose);
416
+ };
417
+ const dispose = ctx.setTimeout(() => {
418
+ const state = states[key];
419
+ if (state && state[1] >= Date.now()) listener();
420
+ }, config.loginTokenExpire);
421
+ this.socket.addEventListener("close", listener);
422
+
423
+ return { id: user.id, name: user.name, token, expiredAt };
424
+ },
425
+ );
426
+
427
+ // 平台账户登录(第二步):前置中间件捕获用户发给机器人的验证码——
428
+ // 客户端已登录则把该平台账号绑定到当前用户,否则为平台对应用户签发令牌
429
+ ctx.middleware(async (session, next) => {
430
+ const state = states[session.uid];
431
+ if (!state || state[0] !== session.stripped.content) {
432
+ return next();
433
+ }
434
+
435
+ const { platform, userId: pid } = session;
436
+ // states 的键由 `${platform}:${userId}` 构成,能命中即说明 userId 存在
437
+ if (!pid) return next();
438
+ if (state[2].auth) {
439
+ await ctx.database.set(
440
+ "binding",
441
+ { platform, pid },
442
+ { aid: state[2].auth.id },
443
+ );
444
+ return self.setAuth(state[2], state[2].auth);
445
+ } else {
446
+ const user = await session.observeUser([
447
+ "id",
448
+ "name",
449
+ "authority",
450
+ "config",
451
+ ]);
452
+ return self.createToken(state[2], "platform", user);
453
+ }
454
+ }, true);
455
+
456
+ // 拦截带 authority 要求的 console 事件:未登录、令牌过期或
457
+ // 权限不足时拒绝(返回 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
+ });
465
+
466
+ // 删除指定登录会话(登出其它设备)
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
+ });
474
+
475
+ // 退出登录:删除当前令牌并清除登录态
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
+ });
482
+
483
+ // 修改用户资料(用户名 / 密码 / 配置),密码先哈希再落库
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
+ });
491
+
492
+ // 解绑平台账号:绑到别的用户时改指回其主账号;是自身主账号且
493
+ // 仅剩一个自绑定时拒绝解绑(避免用户失去登录途径),否则删除记录
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 },
507
+ );
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
+ });
517
+ }
518
+ }
519
+
520
+ // 纯类型 namespace(仅含接口,erasableSyntaxOnly 允许),与上面的 class 合并声明
521
+ namespace AuthService {
522
+ export interface Admin {
523
+ enabled?: boolean;
524
+ username?: string;
525
+ password?: string;
526
+ }
527
+
528
+ // Schema 默认值保证三个字段在运行时始终存在,故声明为必填
529
+ export interface Config {
530
+ admin: Admin;
531
+ authTokenExpire: number;
532
+ loginTokenExpire: number;
533
+ }
534
+ }
535
+
536
+ export default AuthService;